commit ba15a574ab20eb2410190f7b5f68364aa57fcaf6 Author: ManikandanC Date: Fri Oct 23 18:38:17 2026 +0530 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 diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..9703fdd --- /dev/null +++ b/.env.example @@ -0,0 +1,302 @@ +# Salud eCW Charge Entry Bot — configuration template. +# Copy to `.env` and fill in. `.env` is gitignored; never commit real values. +# A '#' after a space starts a comment (`CLAIM_PROCESS_LIMIT=5 # demo` is 5); +# a '#' with no space before it is part of the value. Quote a value that needs +# ` #` in it. A yes/no or number setting the bot cannot read stops the run +# before login instead of falling back to a default. + +# ── eCW portal ──────────────────────────────────────────────────────────────── +ECW_URL=https://.ecwcloud.com/mobiledoc/jsp/webemr/index.jsp +ECW_USERNAME= +ECW_PASSWORD= +# Blank when the login has no authenticator app (the code then comes by email). +ECW_SECRET_KEY= + +# Optional deep link opened once login is confirmed (e.g. the provider +# schedule). eCW drops the URL fragment during the login redirect, so it has to +# be navigated again afterwards. +ECW_LANDING_URL= + +# ── Second factor ───────────────────────────────────────────────────────────── +# auto | totp | mail | manual +# auto → totp if ECW_SECRET_KEY is set, else mail if IMAP is configured, +# else manual +# mail → read the challenge out of the mailbox. eCW sends either a 6-digit +# code or a "Push Notification" approval link; the bot detects which +# and either types the code or opens the link and confirms it. +OTP_MODE=auto + +IMAP_HOST= +IMAP_PORT=993 +IMAP_USERNAME= +IMAP_PASSWORD= +IMAP_FOLDER=INBOX +IMAP_SSL=true + +OTP_WAIT_SECONDS=120 +OTP_POLL_SECONDS=5 + +# eCW's push mail is delayed rather than dropped, sometimes by ten minutes. +# These control how long each poll waits before pressing "Resend OTP", and how +# many times. 300 + 300 + OTP_WAIT_SECONDS = 15 min before a human is asked. +OTP_RESEND_ATTEMPTS=2 +OTP_RESEND_WAIT_SECONDS=300 +# A challenge older than this is warned about, not skipped — see README. +OTP_LINK_AGE_WARN_SECONDS=540 + +# ── Claim lookup ────────────────────────────────────────────────────────────── +# Claim Status, selected by option value. The label is verified afterwards +# because picking the wrong status still returns claims — just the wrong queue, +# and "Medcor Ready" (108ME) sits one line from "Medcor Testing" (113ME). +CLAIM_STATUS=108ME +CLAIM_STATUS_LABEL=Medcor Ready + +# Which date the claim search filters on. Note that "Service Dt(s)" is the only +# one that matches Claim Status "Encounters Without Claims" — those rows have no +# claim, so they have no claim/submitted/payment date. See README. +CLAIM_DATE_TYPE=Claim Dt(s) +CLAIM_FROM_DATE=06/01/2020 +# Blank → today, computed when the run reaches the form. +CLAIM_TO_DATE= + +# Show the operator the filters and wait for OK before running the search. +# Cancel aborts the run. A TIMEOUT carries on instead: aborting there threw away +# a completed login and a mailed 2FA approval link because nobody was at the +# desk, and the lookup only reads. Set 0 to skip the box entirely. +CONFIRM_BEFORE_LOOKUP=1 + +# How long to wait for the results grid after Lookup, and how often to check. +# Do not go back to wait_for_load_state("networkidle") here: eCW's Angular shell +# polls in the background so the network is never idle, and that call sat silent +# for its whole timeout — every run was killed at that line and the export step +# below was never reached. See README. +LOOKUP_TIMEOUT_MS=180000 +LOOKUP_POLL_SECONDS=3 + +# The Balance row arrives holding "> 0.0" with every control disabled, and eCW +# queries on the model rather than the inputs — so the filter is live and +# unclickable. 1 = clear it via Angular's model; 0 = leave it alone. See README. +FORCE_CLEAR_BALANCE=1 + +# Billing → View Claims Report → . EXCEL | CSV | TEXT. +CLAIM_REPORT_FORMAT=EXCEL + +# The report is saved into Input\ , not Output\ : it reads like output because +# the bot downloaded it, but it is the INPUT to the charge-entry phase, which +# walks its claim ids one at a time. Naming it Output would invite someone to +# clear it out between runs and strand the phase that consumes it. +# Saved as _., e.g. +# Claim Report_09-07-2026.xlsx +# Fixed and predictable, because the next phase has to find it without being +# told where it is. The extension is whatever eCW actually sent. +CLAIM_REPORT_NAME=Claim Report + +# Before a new report is saved, every earlier one moves to +# Input\Archive\ , so Input\ holds EXACTLY ONE file and it is always +# the current one. Six reports accumulated over three days of testing, +# and once more than one is there "which report is current?" stops +# being obvious - to a person, and to code that has to find it without +# being told the name. Moved, never deleted: patient data, and each is +# the evidence of what an earlier run worked from. +# 0 leaves them all in Input\ side by side. +ARCHIVE_OLD_INPUTS=1 + +# After the export, read the claim ids back out of the saved file and show +# them in a message box: count, the first and last few, and any duplicates +# or blank rows skipped. Worth its own step — it proves the file the next +# phase consumes is readable and holds what it should, while the browser is +# still open and a problem is cheap to look at. The full list goes to the +# log either way. Set 0 for unattended runs. +SHOW_CLAIM_IDS=1 + +# After categorizing the export by payer, show a box listing any PAYER value +# with no entry in src/ecw_bot/payer_groups.json (those rows get a blank +# Group Name, not a guess). 1 = show it, 0 = log only. +NOTIFY_UNMAPPED_PAYERS=1 + +# TEMPORARY testing override. Blank (default) = normal live search + export. +# Set to a file path (e.g. a hand-maintained "standard" sheet listing one +# known claim per implemented condition) and the bot skips the live bulk +# lookup and export, reading claim ids straight from that file instead — the +# filters are still set and every claim is still searched/opened/fixed the +# normal way, only the source of the id list changes. Lets a new condition +# (or a regression in an old one) be checked in minutes instead of a full run. +# A test run says so in the log ([MODE] / [TEST MODE]) and writes its summary +# to Output\Charge Summary_TEST_.xlsx, never the live summary's name. +# A path that does not exist stops the run before login (exit 2); a sheet +# that exists but cannot be read stops it with exit 5. The missing-path check +# also stops tools/check_claims_from_excel.py, so clear a stale path here. +TEST_CLAIM_IDS_FILE= + +# How many claim ids to search, one at a time, after the export. 0 = all. +# The default is 1 on purpose: the report holds ~540 ids and each one is a +# full server-side lookup, so "all" is a run measured in hours. Raise it +# deliberately once the per-claim work actually does something. +CLAIM_PROCESS_LIMIT=1 + +# A message box after each claim. Fine at a limit of 1 or 2; at 50 it is 50 +# dialogs, so raise CLAIM_PROCESS_LIMIT and set this to 0 together. The +# end-of-run summary box appears either way, and every claim is logged. +# At most one box per claim: a claim already shown in its REVIEW_BEFORE_CLOSE +# box gets no second one. Dental results are shown here too (0 hides them). +SHOW_EACH_CLAIM=1 + +# Information boxes (per-claim, review, summaries, report) go to the log as +# one "[BOX] title: first line" line instead of the screen. Default 0. The +# results page sets it to 1 for the runs it starts. Boxes a person must act +# on (CAPTCHA, 2FA manual step, security image, LIVE-WRITE confirm) are never +# quieted. +QUIET_BOXES=0 + +# A failure screenshot also saves the page's HTML (and its frames') beside +# it as _.html — the HTML to fix a changed selector from. PHI: +# it stays in Screenshots\. At most SAVE_DOM_LIMIT files per run. +SAVE_DOM_ON_FAILURE=1 +SAVE_DOM_LIMIT=30 + +# Pre-flight selector check: the claims search page before the first claim, +# and the claim form on the first claim of each screen type (UB / HCFA / +# Dental). A missing selector is named in the log ([PREFLIGHT]) and its page +# saved; PREFLIGHT_STOP=1 stops the run there (exit 7), 0 only warns. +PREFLIGHT=1 +PREFLIGHT_STOP=1 + +# For each claim, open the UB Claim form, read every field, close it. +# Closed with Cancel (#UBClaimBtn17) unless SAVE_CORRECTIONS=1 and the +# operator clicks OK on that claim's save box (shown only when a condition +# fired), in which case it closes with OK (#UBClaimBtn18, saveAllData(true)). +# See SAVE_CORRECTIONS for the guard. +OPEN_CLAIM_FORM=1 + +# Apply the write-path corrections in pages/claim_form.py (telehealth +# POS/modifier is the first one) once detected. Every correction still +# stops for an explicit per-claim confirmation box before it saves +# anything - this only gates whether the check runs at all. 0 restores +# read-only behaviour: read, extract, always close with Cancel. +# With SAVE_CORRECTIONS=0 below, a filled-in form is still closed with +# Cancel, so applying alone does not save - assuming Cancel discards the +# edits, which is unverified until review item P16's check on a test claim +# (adding a CPT row or ticking a bill-to box may already save on the eCW +# server before OK, and Cancel would not undo that). +APPLY_CORRECTIONS=1 + +# The actual write gate, one level down from APPLY_CORRECTIONS. A condition +# still fires and fills the DOM either way, visible on screen and in the +# per-claim summary box - but with this at 0, the confirmation box never +# appears and the claim always closes with Cancel, as if declined every +# time. Keep this at 0 while verifying a condition against live claims; +# set it to 1 only once the client is ready for it to actually write. +# 0 (and a missing or blank line) = VERIFY: the bot never clicks OK, so it +# never saves (whether Cancel discards every edit is still unverified - +# review item P16, see APPLY_CORRECTIONS above). +# 1 = LIVE-WRITE: WRITES TO LIVE eCW after each per-claim OK; the run asks +# for an OK at start. +SAVE_CORRECTIONS=0 + +# Pause on a message box before the claim form closes, so the corrections a +# condition just filled in can be checked on screen while they are still +# there. Only reached when SAVE_CORRECTIONS=0 - with it at 1 the save +# confirmation box is already that pause. Nothing is saved either way; the +# claim still closes with Cancel as soon as the box is answered, and the +# form behind it stays inspectable while it is up. Times out on +# MSGBOX_TIMEOUT_SECONDS. Set 0 for unattended runs. It is the corrected +# claim's only box: the SHOW_EACH_CLAIM box is skipped for that claim. +REVIEW_BEFORE_CLOSE=1 + +# Also click "Prog. Notes" and capture whatever screen it opens. +# Separate, because it is the one part whose destination has never +# been seen: the button carries ng-disabled="!FormData. +# EnableProgressNote", so on some claims it does nothing, and what it +# opens (a modal or a new tab) is discovered at run time. +OPEN_PROGRESS_NOTES=1 + +# How long (ms) to keep polling for the note document after clicking +# Prog. Notes before reporting that none opened. A note that loads +# normally is accepted in under a second; only a slow one uses this. +PROGRESS_NOTE_WAIT_MS=20000 + +# Dental claims only: open Info -> Sliding Fee Schedule and log whether the patient's sliding +# fee is active / expired on the claim's DOS. Read-only (Cancel, never Save). +CHECK_SLIDING_FEE=1 + +# Dental claims only: say whether Billing Notes mark it "HIGH RISK" +# (DentaQuest web portal claim). Read-only, in the SHOW_EACH_CLAIM box. +CHECK_HIGH_RISK=1 + +# End of every claims run: Output\Charge Summary_.xlsx in the team's +# 24-column format (report columns + what the run found per claim). +# Charge Summary_TEST_.xlsx when TEST_CLAIM_IDS_FILE is set. A same-day +# file is kept as __.xlsx; if it is open in Excel, the new +# sheet is saved under that kind of name instead (the log and a box say which). +WRITE_CHARGE_SUMMARY=1 +SUMMARY_ASSIGNED_TO=eCW Bot +DOWNLOAD_TIMEOUT_MS=300000 + +# Message box at the end of the export: row count, file name, size, folder. +# Informational only — the file is already saved before it appears, and it times +# out after MSGBOX_TIMEOUT_SECONDS. Set 0 for unattended runs. +NOTIFY_AFTER_EXPORT=1 + +# ── Proxy ───────────────────────────────────────────────────────────────────── +PROXY_IP= +PROXY_PORT= + +# ── Attempt guard ───────────────────────────────────────────────────────────── +# eCW restricts an account that is retried too hard, and afterwards it keeps +# showing the normal 2FA page while silently sending no approval mail. These +# keep the bot from being the cause. ECW_FORCE=1 in the environment skips them. +LOGIN_COOLDOWN_SECONDS=300 +LOGIN_MAX_PER_HOUR=4 + +# eCW's 2FA screen writes "Your log in request has timed out. Please try again" +# into #dispMessage and stops polling for the approval. That is a dead end: the +# emailed link is spent and waiting cannot help, so the bot closes the browser +# and starts the login over. Every retry goes through the guard above, because +# an unguarded retry loop against this portal is what earned the account its +# restriction once already. 0 turns the retry off and the run exits 6. +LOGIN_RETRY_ON_TIMEOUT=1 +# A plain cooldown with no more than this many seconds left is waited out rather +# than refused with exit 3 — refusing there just moves the retrying into the +# operator's hands, which is worse. The per-hour burst limit and a restriction +# lockout are never waited out: those mean stop, not "not yet". +# 0 restores the old exit-immediately behaviour. +COOLDOWN_MAX_WAIT_SECONDS=300 + +# Every operator message box times out after this, so an unattended run fails +# instead of holding a browser open indefinitely. +MSGBOX_TIMEOUT_SECONDS=180 + +# Free space on the system drive, checked before login. Under DISK_WARN_MB = +# a Warning on the Runs page; under DISK_STOP_MB = the run stops (exit 2) +# before login — the browser dies mid-login when C: fills up. +DISK_WARN_MB=2048 +DISK_STOP_MB=500 + +# A claim with no progress for this many minutes = a frozen page: the browser +# is closed and the run stops with exit 8 (recorded on the Runs page). +# Never under MSGBOX_TIMEOUT_SECONDS + 2 minutes. 0 = off. +WATCHDOG_MINUTES=10 + +# ── Browser ─────────────────────────────────────────────────────────────────── +HEADLESS=false +# Leave blank to use Playwright's bundled chromium. +CHROME_PATH= +# Set to run in Google Chrome on this debug port (profile CDP_PROFILE_DIR) +# instead of Playwright's Chromium — so the run can be watched with the +# Claude extension in that Chrome. 127.0.0.1 only. Blank = launch as before. +# No Chrome there at Start → the bot opens it, and closes it at the end +# (CDP_CLOSE_AFTER_RUN=0 leaves it open). start_ecw_chrome.bat opens one by +# hand (to add the extension); the bot never closes a Chrome it did not open. +# eCW's cookies and storage are cleared at the start and end of each run; +# the rest of the profile (the extension's sign-in) stays. Close a Chrome +# left open when you are done: while it is open, any program on this PC can +# drive it through the port. +BROWSER_CDP_URL= +CDP_PROFILE_DIR=D:\EcwChrome +CDP_CLOSE_AFTER_RUN=1 + +# ── Timeouts (ms) ───────────────────────────────────────────────────────────── +LOGIN_TIMEOUT_MS=120000 +NAV_TIMEOUT_MS=300000 +NAV_RETRIES=3 +CAPTCHA_ATTEMPTS=2 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..9a808e8 --- /dev/null +++ b/.gitignore @@ -0,0 +1,51 @@ +# Credentials — NEVER commit +.env + +# Run artifacts. Input/ and Output/ hold claims reports — patient data — so +# they are ignored for the same reason .env is, not merely because they are +# generated. +Logs/ +Screenshots/ +Input/ +# Output/ also holds Output/claims/*.json - one file per claim. +Output/ + +# Client files and live captures kept in the project root - patient data +# (PHI) and the client's own documents, ignored for the same reason Input/ +# and Output/ are. Dom.txt is a DOM capture of a live claim; the two workbooks +# (Charge Condition Document.xlsx, Charge Summary File Format.xlsx) are the +# client's. They sat untracked but NOT ignored, so a single `git add -A` would +# have put them in history (review item P12, 2026-10-01). They stay where they +# are - the operator has them open in Excel and Context.md refers to them in +# place - so the rules match them there instead of moving them. +# Root-anchored (the leading /) so only the root copies are matched; nothing +# tracked today is an .xlsx or a Dom*.txt. +/Dom*.txt +/*.xlsx +# Excel/Word lock files ("~$Charge Condition Document.xlsx") - created while a +# workbook is open, and they carry the user name. Never project content. +~$* + +# Python +__pycache__/ +*.pyc +*.pyo +.venv/ +venv/ + +# Playwright +playwright-report/ +test-results/ + +# OS / editor +Thumbs.db +.DS_Store +.vscode/ +.idea/ + +# Local Claude Code permission allowlist — operator/session-specific, not +# project config. +.claude/settings.local.json + +# Reference videos — large binaries that don't belong in git history. +*.mp4 diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..9c1f9f2 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,340 @@ +# 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_.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 +``: + +```css + .botrun { background: var(--panel); border: 1px solid var(--line); + border-radius: 8px; padding: 12px 16px; margin-bottom: 16px; } + .botrun .row { display: flex; gap: 12px; align-items: center; + flex-wrap: wrap; } + .botrun button.stop { background: var(--fail); border-color: var(--fail); } + .botrun button[disabled] { opacity: .5; cursor: default; } + .botrun progress { width: 220px; } + .botrun pre { background: var(--head); padding: 8px; border-radius: 6px; + max-height: 260px; overflow: auto; font-size: 12px; + white-space: pre-wrap; } + .chip { padding: 2px 8px; border-radius: 10px; font-size: 12px; + font-weight: 600; } +``` + +As the first child of `
` (before `{% if not runs %}`): + +```html +
+
+ Bot run + … + + +
+

VERIFY mode — nothing is saved to eCW. CAPTCHA and 2FA + boxes still come up on this PC when the login needs a person.

+ + +
+``` + +Before ``: + +```html + +``` + +- [ ] **Step 5: Run** + +Run: `python tests\test_web_app.py` → all passed (old cases + 6 new). + +- [ ] **Step 6: Stage** + +`git add web/app.py web/templates/index.html tests/test_web_app.py` + +--- + +### Task 7: Full suite, docs, live check, commit (with the operator) + +**Files:** +- Modify: `Context.md` (CRLF — patch through a Python script, not an editor that changes line endings): §2 test count and "How to run" (Start from the page), §3 code layout (`core/run_lock.py`, `web/bot_runner.py`, `web/run_status.py`), §5 web page bullet (Start/Stop/status, VERIFY only, token), §6 rulings (2026-10-14: page runs VERIFY only; info boxes off for page runs; one live run at a time), §8 last commits, §9 EEWA-17 note. +- Modify: `.env.example` — document `QUIET_BOXES` (default 0; set by the page for its runs). + +- [ ] **Step 1: Full suite** + +Run every `tests\test_*.py`; total must be all passed. Confirm `Logs\bot_run.json` was not created by the suite (`dir Logs\bot_run.json` → not found, unless a real run happened). + +- [ ] **Step 2: Docs** — update Context.md and `.env.example` as listed. + +- [ ] **Step 3: Live check — only when the operator says to run** (4/hour login guard; mailed 2FA) + 1. `.\run_web.bat`, open http://127.0.0.1:8000. + 2. Start run → phase moves Logging in → Waiting for the 2FA approval mail → Checking claims; progress and Pass/Fail/Review chips move; no per-claim boxes on the desktop; the log has `[BOX]` and `[RESULT]` lines and `[MODE]` says VERIFY. + 3. Start again while running → "already going". + 4. Let it finish → card says "Finished in …", results table shows the new run. + 5. A second short run: Stop mid-way → "Stopped by operator", Chrome gone. + +- [ ] **Step 4: Ask the operator to commit** — then + `git add src/ecw_bot/core/run_lock.py src/ecw_bot/flows/export_medcor_ready.py src/ecw_bot/settings.py src/ecw_bot/core/msgbox.py src/ecw_bot/auth/login.py web/run_status.py web/bot_runner.py web/app.py web/templates/index.html tests/test_run_lock.py tests/test_run_status.py tests/test_bot_runner.py tests/test_msgbox.py tests/test_web_app.py tests/test_mode_banner.py tests/test_test_claim_ids_file.py docs/superpowers/specs/2026-10-14-start-bot-from-web-page-design.md docs/superpowers/plans/2026-10-14-start-bot-from-web-page.md .env.example` + and commit `feat: start a VERIFY bot run from the results page`; Context.md in its own `docs:` commit. Never `run.py`, `tests/test_guard.py`, `tools/sql/Create_CV_Table.rpt`. + +--- + +## Rulings made while planning (spec refinements) + +- **Record kept, not deleted:** the spec said the lock file is removed when `main()` returns. The plan keeps `Logs\bot_run.json` as the latest run's record and adds `ended_at` / `exit_code` / `stopped_by_operator`, so the page can say how the run ended even after a web restart. "Live" = no `ended_at` and the pid is a running python process. Cost if wrong: one JSON file left in `Logs\`. +- **Quieting in `info()`, not only `info_box()`:** the export-report box (`pages/claim_report.py`) calls `msgbox.info()` directly; quieting at `info()` covers it. `confirm()` is never quieted. +- **The page needs the results database to render:** if SQL Server is down the page shows its error and no Start button (unchanged behaviour). Start from the page then waits for the database; the terminal still works. diff --git a/docs/superpowers/plans/2026-10-19-results-portal-redesign.md b/docs/superpowers/plans/2026-10-19-results-portal-redesign.md new file mode 100644 index 0000000..970db2e --- /dev/null +++ b/docs/superpowers/plans/2026-10-19-results-portal-redesign.md @@ -0,0 +1,67 @@ +# Results Portal Redesign Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Replace the one-page results table with a Dashboard → Claim status (date cards) → One day (short table + side panel) portal, plus a claim history search, in a navy & blue theme. + +**Architecture:** Server-rendered FastAPI + Jinja pages sharing `base.html`. Three new read-only queries in `web/repo.py` group the existing `CV_ChargeSummary` rows by the day a run started. The Bot run card and `/api/*` stay as they are; old `/?run_at=` links redirect to the day page. + +**Tech Stack:** FastAPI, Jinja2, pyodbc (SQL Server), vanilla JS, standalone test scripts (`python tests\test_web_app.py`). + +**Spec:** `docs/superpowers/specs/2026-10-19-results-portal-redesign-design.md` + +## Global Constraints + +- Bound to 127.0.0.1; Host allowlist, `no-store` headers, DB errors by kind — unchanged. +- Start / Stop need the page Origin and `X-Run-Token`; page runs are VERIFY only. +- All new pages read-only; every SQL statement parameterized; values never in SQL text. +- DB text is escaped: Jinja autoescape, panel data via `tojson` in an attribute, written with `textContent`. +- No claim ids or patient data in tracked docs; tests use made-up ids. +- No git commits unless the operator asks. + +--- + +### Task 1: Day-wise queries in the repo + +**Files:** Modify `web/repo.py`; Test `tests/test_web_app.py` + +**Interfaces — Produces:** +- `SqlResultsRepo.days(limit: int = 90) -> list[dict]` — keys `day` (date), `claims`, `runs`, `pass_count`, `fail_count`, `review_count`; newest first. +- `SqlResultsRepo.day_results(day: date, category=None, claim_no=None) -> list[dict]` — full rows (`_SELECT_COLUMNS`) with `run_at >= day AND run_at < day+1`, ordered `run_at, s_no, id`. +- `SqlResultsRepo.claim_history(claim_no: str) -> list[dict]` — `TOP 200` full rows `WHERE claim_no LIKE ?` ordered `run_at DESC, s_no, id`. +- `_coerce` also turns a text `day` into a `date`. + +- [ ] Tests: each query's SQL is a parameterized SELECT with the clauses above and the expected params (`[datetime(day), datetime(day)+1 day]`, `["%no%"]`); text `day` coerced. +- [ ] Implement; run `python tests\test_web_app.py`. + +### Task 2: Routes + +**Files:** Modify `web/app.py`; Test `tests/test_web_app.py` + +**Interfaces — Consumes:** Task 1. **Produces:** routes +- `GET /` → `dashboard.html` with `today` (days() row for `date.today()` or zero counts), `mode` ("VERIFY"/"LIVE-WRITE"), `run_token`. `GET /?run_at=` → 303 to `/day/` (keeps `category`, `claim_no`); bad `run_at` → 400. +- `GET /days` → `days.html` with `days`. +- `GET /day/{day}` (`YYYY-MM-DD`, else 400) with `category`, `claim_no`, `claim` → `day.html` with `day`, `counts` (from `days()` row or computed from the unfiltered rows), `rows` (each with `fields` = list of `[header, display text]` and `details_pretty`), filters. +- `GET /claim?claim_no=` → `claim.html` with `rows` (history) and `claim_no`; empty search → page with the box only. +- FakeRepo in tests gains `days`, `day_results`, `claim_history`. + +- [ ] Tests for each route (data shown, links, filters passed, 400s, redirect, DB down 503, hardening incl. new paths). +- [ ] Implement; run tests. + +### Task 3: Templates and theme + +**Files:** Create `web/templates/base.html`, `dashboard.html`, `days.html`, `day.html`, `claim.html`; delete `web/templates/index.html`. + +- `base.html`: `:root` navy palette, header (title, mode pill, breadcrumb block), `{% block content %}`, `{% block scripts %}`, footer PHI note. +- `dashboard.html`: Bot run card (moved from index.html, with the Live claims panel replacing the old Claim status panel; a saved claim opens `/day/?claim=`), today tiles, Find a claim form, big Claim status button. +- `days.html`: date cards grid with counts and colour bar; empty state. +- `day.html`: tiles as filter links, claim search, short table (#, Claim #, Time, Payer, Result, Comments), 10-a-page pager, side panel from `data-fields` (`tojson`) via `textContent`; `?claim=` opens it. +- `claim.html`: search box and history table linking to day pages. + +- [ ] Tests on rendered HTML (ids, links, no inline `style=`, theme vars, panel wiring, one-scroll lock, Live claims link target). +- [ ] Run `python tests\test_web_app.py` and `python tests\test_run_status.py`. + +### Task 4: Browser check + +- [ ] Serve the app on a fake repo with sample data (scratch script, made-up ids), open each page in the in-app browser, screenshot, check pager, panel, filters, empty states, narrow width. +- [ ] Full suite. diff --git a/docs/superpowers/specs/2026-09-15-payer-group-categorization-design.md b/docs/superpowers/specs/2026-09-15-payer-group-categorization-design.md new file mode 100644 index 0000000..66b1109 --- /dev/null +++ b/docs/superpowers/specs/2026-09-15-payer-group-categorization-design.md @@ -0,0 +1,182 @@ +# Payer → Group Name categorization — design + +Status: approved by user (Vasanth), 2026-09-15. Not yet implemented. + +## Background + +This is the next phase of the Salud eCW Charge Entry bot (see `README.md`), +which already automates login, 2FA, claim lookup, and exporting the +"Medcor Ready" claim queue to `Input\Claim Report_.xlsx`. + +The next manual step in the real process (shown in +`Salud-Charge Entry_Medcor Ready.mp4`, reviewed 2:45–10:00) is: an operator +renames a copy of that export to "Medcor ready", opens it in a dated network +folder (`...\Charge\Allocation\\\`, alongside sibling +workbooks `Patient`, `Pending with Error`, and `Program`), and manually +categorizes every row by filtering on the `PAYER` column and bulk-filling a +`Group Name` column with one of a small set of values (observed in the video: +`Commercial`, `Dentaquest`, `Medicaid`, `Medicare`, `Program`, plus at least +one more category for payers like `BH COLORADO ACCESS`). + +This spec covers automating **only** that categorization step — not the +downstream split into the three sibling workbooks, which stays manual for now +and may be scoped separately later. + +## Scope decisions (from user) + +- **Scope**: categorize only. Do not build the split into + Patient/Pending with Error/Program workbooks. +- **Mapping source**: derived from the actual current export + (`Input\Claim Report_09-11-2026.xlsx`, 729 rows, 39 distinct `PAYER` + strings), not invented from video frames. Only high-confidence entries are + seeded; everything else is left for a human to fill in (see "Seed mapping + table" below) — this is intentional, not a gap to silently paper over. +- **Unknown payer handling**: flag, never guess. A `PAYER` value with no + entry in the mapping table gets a blank `Group Name`, is logged, and is + called out in the run summary. No fuzzy/substring matching — exact string + match only, so a typo'd or renamed payer can never be silently bucketed + into the wrong group. +- **Integration point**: inside the existing bot, not a standalone script. + Runs automatically as part of `flows/export_medcor_ready.py`. +- **Output location**: a new file, `Output\Medcor Ready_.xlsx`. The + original `Input\Claim Report_.xlsx` is never modified — `claim_file.py` + and everything downstream of it depends on that file's exact column + layout (per `README.md`'s "Reading the claim ids back" section), so this + step must not touch it. + +## Architecture + +``` +flows/export_medcor_ready.py + ... existing steps (login → claim lookup → export → claim_file.claim_ids()) ... + → categorize_by_payer() NEW — reads Input\, writes Output\ + core/payer_groups.py NEW — the lookup table + classify() + payer_groups.json NEW — editable config, exact PAYER → Group Name + core/spreadsheet.py EXTENDED — write_xlsx(), stdlib only +``` + +`categorize_by_payer()` runs after the existing claim-id read-back (the point +where the report is already saved and validated — see +`README.md`'s "Reading the claim ids back"). It: + +1. Reads `Input\Claim Report_.xlsx` with the existing `spreadsheet.read_rows()`. +2. For each row, looks up `PAYER` in `payer_groups.classify()`. +3. Writes every original column plus a new `Group Name` column to + `Output\Medcor Ready_.xlsx`. +4. Logs and message-boxes a summary: rows categorized, rows left blank, and + the distinct list of unmapped `PAYER` strings — mirroring the existing + `NOTIFY_AFTER_EXPORT` / `SHOW_CLAIM_IDS` pattern in the same file. + +Never fatal: an unmapped payer does not stop the run, the same philosophy +`export_claims_report()` already uses for other soft failures. + +## Components + +### `src/ecw_bot/core/payer_groups.py` + +```python +def classify(payer: str) -> str | None: ... # exact match against the config, or None +def load_mapping() -> dict[str, str]: ... # reads payer_groups.json once +``` + +Exact-string lookup only. No normalization beyond what's needed to match +eCW's own values (the config keys are the literal `PAYER` strings as eCW +exports them). + +### `src/ecw_bot/payer_groups.json` + +A flat `{"": ""}` map, hand-editable by +anyone on the team without touching code — same reasoning as `.env` for +settings and `selectors\` for DOM selectors: this data changes for a +different reason (a new payer shows up) than the code around it does. + +### `core/spreadsheet.py` — new `write_xlsx()` + +The project deliberately has no `openpyxl` dependency (see README: "every +added package is an approval"). `write_xlsx()` follows the same approach +already used for reading — a minimal single-sheet xlsx as a zip of XML, cells +as inline strings (no `sharedStrings.xml`, matching what eCW's own exports +already do, per the existing reader's comments) — built with `zipfile` and +string templates only. + +## Data flow + +``` +Input\Claim Report_.xlsx + → read_rows() existing + → for each row: classify(PAYER) new + → write_xlsx(rows + Group Name) new + → Output\Medcor Ready_.xlsx +``` + +## Error handling + +- Unmapped payer: blank `Group Name`, logged, summarized — not fatal. +- `Input\` file missing/unreadable: this step is skipped with a clear log + line; it never blocks the rest of the run, since the export already + succeeded by the time this step runs. +- `write_xlsx()` failure (disk full, permission): logged and raised — same + severity as any other unexpected I/O failure in this flow, since silently + losing the categorized output would defeat the point of the step. + +## Testing + +`tests/test_payer_groups.py`, hand-rolled in the existing style (no pytest): + +- Exact-match lookups against a small fixture mapping. +- Unmapped payer → `None`, never a guess. +- Round-trip test for `write_xlsx()`: write a small fixture table, read it + back with the existing `read_rows()`, assert the data matches — the same + "never touched eCW" local-fixture philosophy as `test_claim_form.py`. + +## Seed mapping table + +Built from the real, current payer list (`Input\Claim Report_09-11-2026.xlsx`, +729 rows). Only high-confidence entries are seeded — this covers roughly 95% +of row volume in that file. + +**Mapped (seeded):** + +| Payer (exact eCW string) | Group | +|---|---| +| Medicaid | Medicaid | +| DentaQuest | Dentaquest | +| CHP+ DENTAQUEST | Dentaquest | +| AARP Medicare Advantage | Medicare | +| Aetna Medicare ADV | Medicare | +| OUTREACH PROGRAM | Program | +| Anthem Blue Cross Blue Shield | Commercial | +| Cigna HealthCare Of Colorado | Commercial | +| Cigna Healthcare of CO | Commercial | +| Cigna Salud Employee | Commercial | +| Cigna | Commercial | +| Cypress Benefit Admin / Cigna PPO | Commercial | +| United Healthcare | Commercial | +| United Health Care | Commercial | +| United healthcare | Commercial | +| Unitedhealth One | Commercial | +| Oxford Health Insurance UHC | Commercial | +| UMR | Commercial | +| Aetna | Commercial | +| Aetna Alaska Care | Commercial | +| AETNA HEALTH AND LIFE INSURANCE | Commercial | +| BIND BENEFITS INC | Commercial | +| Select Health | Commercial | + +**Left unmapped — needs a human decision before first real run (or will be +flagged on first run and filled in afterward):** + +BH COLORADO ACCESS (15 rows), Delta Dental of Colorado (3), Devoted (2), +Clinic Sliding Fee Scale (2), Patient (1), Gravie Admin Services (1), +Tricare (1), Rocky Mountain Health Plans (1), Denver Elevate Health Plan (1), +The Kempton Group Administrators Inc (1), Optum Care Network (1), GPA (1), +Northeast Health Partners (1), Delta Dental of Pennsylvania (1), +Delta Dental of Wisconsin (1), Delta Dental (1), Delta Dental of Arkansas (1). + +## Out of scope (explicitly) + +- Splitting rows into the Patient / Pending with Error / Program sibling + workbooks. +- Writing to the network `Charge\Allocation\...` folder — output stays local + to `Output\`. +- Any change to `Input\Claim Report_.xlsx` or anything that reads it. diff --git a/docs/superpowers/specs/2026-09-16-condition-framework-design.md b/docs/superpowers/specs/2026-09-16-condition-framework-design.md new file mode 100644 index 0000000..a483237 --- /dev/null +++ b/docs/superpowers/specs/2026-09-16-condition-framework-design.md @@ -0,0 +1,269 @@ +# Condition Framework — Design + +**Status:** Approved by user, 2026-09-16 +**Supersedes nothing** — this formalizes the pattern Condition 1 (telehealth) +already established informally in `flows/export_medcor_ready.py`. + +## Why + +The Charge Validation Process is, by the client's own description, a long +list of per-claim conditions to check and — when a condition's criteria are +met — a correction to apply (Condition 1: a telehealth visit needs POS=11 and +M1=GT). Conditions are being implemented one at a time, and there will be +many more of them. This is a healthcare billing bot: every write it makes +changes what gets billed to a real payer, so the process must be: + +- **Reusable** — adding Condition N must not require touching the code for + Conditions 1..N-1, and must follow one obvious, fixed shape every time. +- **Exception-safe** — a bug in one condition's logic must never corrupt + another condition's result on the same claim, crash the whole overnight + batch, or leave a claim half-changed with no record of what happened. +- **Provably correct before it ever touches a real claim** — every condition + must be unit-testable against a fixture, exactly like + `tests/test_telehealth_fix.py` already does for Condition 1. + +This spec explicitly rejects driving the live automation with an +LLM-agent-controlled browser (e.g. Playwright MCP) as the *engine* for this +reason: none of the three properties above hold for an engine whose +per-claim behavior is an LLM's live judgment call rather than fixed, +versioned code. Playwright MCP remains valuable as a *development and +debugging aid* (inspecting live eCW DOM, prototyping selectors) — that usage +is unaffected by this spec and requires no code change. + +## Non-goals + +- Not changing how claims are found, opened, or read (`claim_form.py`, + `export_medcor_ready.py`'s claims-phase — untouched). +- Not building a self-healing/adaptive-selector system. If eCW's DOM shifts + enough to break a selector, that is still a `selectors/claim_form.py` + update, done and tested like every past one (see CHANGELOG "Day 5"). +- Not retrying failed conditions automatically. A condition that throws is + logged and skipped for that claim, once, this run. + +## Architecture + +### New package: `src/ecw_bot/conditions/` + +``` +src/ecw_bot/conditions/ + __init__.py # CONDITIONS: list[module] — the registry + base.py # ConditionResult dataclass + run_all() + telehealth.py # Condition 1 — detect/apply/describe wrapper +``` + +### The Condition contract + +Every condition module in the registry exposes exactly these three +functions. This is the whole interface — nothing else is required, nothing +else is read by the runner. + +```python +def detect(claim_id: str, data: dict) -> bool: + """ + Pure. No `page`, no I/O, no side effects. `data` is the same dict + `claim_form.extract()` already produces (plus `progress_notes`, when + OPEN_PROGRESS_NOTES is on). Must be safe to call with a partially + populated dict — return False, never raise, on missing keys. + """ + +def apply(page, claim_id: str, data: dict) -> "ConditionResult": + """ + Only called when detect() returned True. Makes the eCW field edits for + this condition and returns what changed. MUST NOT click Save/OK and + MUST NOT show any confirmation dialog itself — the runner's caller owns + both, once, after every condition has had a chance to run. Raising is + allowed and expected to be handled by the caller (see Exception + handling below) — apply() does not need its own try/except for its own + field-write logic, only for optional recovery it wants to attempt + itself. + """ + +def describe(result: "ConditionResult") -> str: + """ + One human-readable block for this condition's contribution to the + combined confirmation dialog and the log line — mirrors the per-row + lines `_apply_telehealth_fix` already builds today. + """ +``` + +### `ConditionResult` (`conditions/base.py`) + +```python +@dataclass +class ConditionResult: + condition_name: str + changed: bool + changes: list # same shape as claim_form.set_cpt_pos_modifier()'s + # return value: [{"row", "code", "before", "after"}, ...] + + review: str = "" # added 2026-10-05 (unit D) — see below + + @classmethod + def none(cls, condition_name: str) -> "ConditionResult": + return cls(condition_name=condition_name, changed=False, changes=[]) + + @classmethod + def declined(cls, condition_name: str, reason: str) -> "ConditionResult": + return cls(condition_name=condition_name, changed=False, changes=[], + review=reason) +``` + +**Amendment 2026-10-05 (review items D2/P2, unit D).** `none()` means +"nothing to do — the claim already follows this rule". A condition that +matched the claim but could not act on it (the CPT/HCPCS tab would not +open, an admin code would not add with the grid untouched) now returns +`declined(name, reason)` instead: nothing changed, but the claim is not +clean, and `reason` reaches the Charge Summary as a "Review" row. A +changed result may also carry `review` (a failed add that still moved the +grid). Two optional module hooks join `detect/apply/describe`: + +- `needs_review(claim_id, data) -> str | None` — asked only when + `detect()` returned False, for a claim that matched the trigger but + cannot be acted on (vaccine_admin_codes on an unmapped payer, no payer + name, no usable age — never Medicare, which has its own rule). +- `summary_comment(result) -> str` — the Charge Summary comment for a + changed result, built from what actually happened, in place of the + summary's fixed `COMMENTS[name]`. + +### The runner (`conditions/base.py`) + +```python +def run_all(page, claim_id: str, data: dict) -> list[ConditionResult]: + """ + Runs every registered condition against one claim, in registry order. + Returns only the results where something actually changed — a claim + with nothing to fix returns an empty list, and the caller shows no + dialog and saves nothing. + """ + results = [] + for cond in CONDITIONS: + name = cond.__name__.rsplit(".", 1)[-1] + try: + if not cond.detect(claim_id, data): + continue + result = cond.apply(page, claim_id, data) + except Exception as exc: + print(f"[COND {name} {claim_id}] Failed: {exc}") + shot(page, f"condition_{name}_failed_{claim_id}") + continue # this condition's failure must not block the next one + if result.changed: + results.append(result) + return results +``` + +`shot()` is the existing screenshot helper already used by `open_claim()`'s +diagnostic wrapping (`claim_form.py`) — reused here, not reinvented. + +**Amendment 2026-10-05 (review items D2/P2, unit D).** The real signature +is now `run_all(page, claim_id, data, conditions=None, before_apply=None, +report=None)`. Its return value is unchanged — only the changed results — +but that list is not everything that happened, so a caller that passes a +dict as `report` gets the whole account: + +| key | contents | +|---|---| +| `fired` | names whose `detect()` returned True | +| `changed` | names with a changed result (= the return value) | +| `failed` | `{"name", "stage", "error"}` per raise; `stage` is `"detect"`, `"apply"` or `"needs_review"`, `error` the exception's first line | +| `review` | `{"name", "reason"}` from every result (changed or not) with a non-empty `review`, and from `needs_review()` of every condition whose `detect()` returned False | +| `comments` | `{name: summary_comment(result)}` for changed results whose module has the hook (a hook that raises is logged and left out — the summary falls back to `COMMENTS[name]`) | + +Exceptions are still isolated exactly as in the sketch above: logged, +screenshotted, and the next condition still runs. `detect()` and `apply()` +now each sit in their own try (the sketch's single try is how it began), so +the report can say which stage raised. + +### Caller contract (replaces `_apply_telehealth_fix`'s direct call) + +`export_medcor_ready.py`'s `_read_claim_form()` changes from calling +`_apply_telehealth_fix(page, claim_id, data)` directly to: + +```python +if cfg.APPLY_CORRECTIONS: + results = conditions.run_all(page, claim_id, data) + saved_write = _confirm_and_save(page, claim_id, results) +``` + +(As built since 2026-10-05: `run_all(..., report=report)` and +`_confirm_and_save(page, claim_id, results, report)`; the report is stored +on the claim's data as `data["_conditions"]` = `{"fired", "saved", +"failed", "review", "comments"}`, which the Charge Summary reads.) + +`_confirm_and_save` (new, small, lives in `export_medcor_ready.py` — it is +orchestration, not a condition): + +1. If `results` is empty → return `False` immediately, no dialog, no save. + (A claim where nothing fired is not different from today's "no + telehealth mention" case.) +2. Otherwise build one combined message: every fired condition's + `describe()` output, concatenated, in the same + `msgbox.ok_cancel(...)` shape Condition 1 uses today. +3. One Yes/No. Yes → `claim_form.save_claim(page)`. No → log "declined", + return `False` (the caller's existing `finally` still closes with + Cancel, unchanged). +4. Return whether the save actually happened, same as + `_apply_telehealth_fix` returns today — this is what `_read_claim_form` + uses to decide whether its own `finally` should call `close_claim()`. + +**Amendment 2026-10-05 (review item P2, unit D):** between 1 and 2 — +and after the SAVE_CORRECTIONS gate — if the report has any `failed` or +`review` entry, the box is never shown: the log says `[COND ] Not +offering Save: — closing with Cancel` and it returns `False`. + +**Why one dialog, not one per condition:** if two conditions both want +changes on the same claim, saving after only reading half the operator's +decision is exactly the kind of half-applied state this spec exists to +prevent. The operator sees the whole picture — every condition that fired, +every field that would change — before any single Save/Cancel decision. + +## Exception handling — the actual rules + +This is the section the user asked for explicitly, spelled out rather than +left implicit: + +| Failure | What happens | +|---|---| +| One condition's `detect()` raises | Caught by `run_all`'s try/except (its own try since 2026-10-05, so the report can say `stage: "detect"`). Logged, screenshotted, skipped, and recorded in the report's `failed` — the claim reads "Review", not clean. Other conditions still run. | +| One condition's `apply()` raises partway through a multi-row edit | Caught by `run_all`. Logged, screenshotted. That condition contributes nothing to `results`. **Corrected 2026-10-05 (review item P2):** this row used to say its partial edits "are never saved, because Save only happens ... for what's in `results`". That was wrong: Save (`saveAllData`) writes the whole form, partial edits included, and the box would have listed only the other conditions. So the raise is recorded in `run_all`'s report (`failed`, stage `"apply"`), and `_confirm_and_save` never offers Save for a claim whose report has a `failed` or `review` entry — the claim is closed with Cancel and reported "Review" in the Charge Summary. | +| `_confirm_and_save`'s dialog itself fails to show (msgbox error) | Not caught here — surfaces to `_read_claim_form`'s existing outer try/except, which logs "Could not read the form" and closes via `finally`. No new behavior needed; the existing outer boundary already covers this. | +| `save_claim()` returns `False` (save click didn't work) | Same as today: logged, `saved_write` stays `False`, `finally` calls `close_claim()` — the claim is left as read, not half-saved. | +| A condition throws on every claim in the batch (systemic bug in that one condition) | By design, this spec does not add batch-level circuit-breaking — each claim's screenshot + log line is enough signal for the operator to notice a pattern across a run's log file. Adding an auto-disable-after-N-failures behavior is explicitly deferred (YAGNI): today's batches run at `CLAIM_PROCESS_LIMIT` scale, small enough that log review is a real diagnostic step, not a chore. | + +## Testing + +- `tests/test_telehealth_fix.py` — unchanged. It tests + `claim_form.note_mentions_telehealth` and `claim_form.set_cpt_pos_modifier` + directly; those functions do not move. +- `tests/test_conditions_telehealth.py` (new) — tests + `conditions/telehealth.py`'s thin wrapper: `detect()` delegates to + `note_mentions_telehealth` correctly on a plain dict (no page needed); + `apply()` calls `set_cpt_pos_modifier` and returns a populated + `ConditionResult`; `describe()` produces the expected text. +- `tests/test_conditions_runner.py` (new) — tests `run_all()` and + `ConditionResult` in isolation with fake condition modules (no real + telehealth logic involved): + - a condition whose `detect()` returns `False` is never `apply()`-ed + - a condition whose `apply()` raises is skipped, and a *second*, + healthy condition registered after it in `CONDITIONS` still runs and + its result is still returned + - `results` is empty when every condition's `detect()` returns `False` + - a condition's raised exception does not propagate out of `run_all` + +## Migration checklist (informs the implementation plan) + +1. Create `conditions/base.py` (`ConditionResult`, `run_all`). +2. Create `conditions/telehealth.py` — move `note_mentions_telehealth`-based + detection logic into `detect()`, wrap `set_cpt_pos_modifier` in + `apply()`, write `describe()` from the existing message-building lines + in `_apply_telehealth_fix`. +3. Create `conditions/__init__.py` with `CONDITIONS = [telehealth]`. +4. Add `_confirm_and_save` to `export_medcor_ready.py`; delete + `_apply_telehealth_fix` (its logic is now split across + `conditions/telehealth.py` and `_confirm_and_save`). +5. Update `_read_claim_form` to call `conditions.run_all` + + `_confirm_and_save` instead of `_apply_telehealth_fix` directly. +6. New tests (above) all passing; full existing suite (88+ tests) still + passing unchanged. +7. Condition 2 (whatever it turns out to be) is the first real proof this + spec worked: it should require touching only a new + `conditions/.py` + its test file + one line in `CONDITIONS`. diff --git a/docs/superpowers/specs/2026-10-07-dental-claims-run-all-conditions-design.md b/docs/superpowers/specs/2026-10-07-dental-claims-run-all-conditions-design.md new file mode 100644 index 0000000..a75729c --- /dev/null +++ b/docs/superpowers/specs/2026-10-07-dental-claims-run-all-conditions-design.md @@ -0,0 +1,137 @@ +# Dental claims run every applicable condition — design + +Date: 2026-10-07. Status: approved in chat (operator, 2026-10-07); +implemented 2026-10-12 (operator: "this is our number one condition"). + +## 2026-10-12 update (as built) + +- The rule now also covers #12 (H codes: BH payer → modifier removed; + any other payer → line unchecked + fee 0) and #14 (Medicare: G8510 / + G8431 unchecked + fee 0, #6 not run). Both run on Dental claims like + every other condition; #12's BH path has no modifier column to read on + the Dental grid, so it never fires there. +- A fix the Dental screen cannot hold → **Review** (operator): a condition + module declares `DENTAL_UNSUPPORTED = ""` and `run_all` records + that reason instead of applying it on a `screen == "dental"` read — + telehealth, medicare_telehealth, vaccine_admin_codes. +- Dental claims are **never saved** in this change (operator), whatever + `SAVE_CORRECTIONS` says; the review-before-close box still shows. +- A Dental claim is read and run through the conditions even with + CHECK_HIGH_RISK and CHECK_SLIDING_FEE both off (it used to come back + blank). +- The Charge Summary applies the same claim-level checks (note read, CPT / + ICD rows read, conditions run) to a Dental read as to UB/HCFA. +- Row selectors gained a third (Dental) alternative, scoped to the Dental + window on screen (`:visible`); `extract()` returns `screen` + ("ub" / "hcfa" / "dental"). +- Not live-verified yet: the first live run should be SAVE-off on a few + Dental claims (one with DCONS or Dscprit). + +## Rule (operator, 2026-10-07) + +Every claim is checked against **every condition whose Payer column is +"All" / "All payers"**, **plus** every condition specific to that claim's +payer (Medicare, Medicaid, Dental payers, ...). This holds for every payer +and every claim screen — UB, HCFA and Dental alike. + +UB and HCFA claims already work this way: each claim runs the whole +registry (`conditions/__init__.py` `CONDITIONS`) and each condition's own +`detect()` decides from the claim's codes and payer. **The gap is Dental:** a +Dental claim today runs only #15 high risk and #8 sliding fee and never +reaches `run_all`. So, for example, `dummy_codes` (DCONS/Dscprit, which are +Dental codes) can never fire on any claim. + +## What a Dental claim will run + +| Doc # | Condition | Payer (document) | Module | +|---|---|---|---| +| 5 | J2930/J2001 uncheck + fee 0 | ALL | `j2930_j2001_update` | +| 6 | Depression DX pointer | All | `depression_dx_pointer` | +| 7 | Zero-fee uncheck / priced line check | ALL | `zero_fee_uncheck`, `priced_line_check` | +| 21 | Telehealth POS 2 + modifier | All | `telehealth` | +| 22 | Medicare telehealth G2025 | Medicare | `medicare_telehealth` | +| 18 | Vaccine admin codes | All except Medicare | `vaccine_admin_codes` | +| 8 | DCONS/Dscprit uncheck + fee 0 | All Dental Payer | `dummy_codes` | +| 8 | Sliding fee date check | All Dental Payer | `pages/sliding_fee` (unchanged) | +| 15 | High risk in Billing Notes | Dental Payers | `pages/dental_notes` (unchanged) | + +No condition gains a form-type check: each still decides from codes and +payer only. A condition whose codes are not on the claim does nothing. + +## Dental screen facts (from Dom.txt, claim screen `.modal[id^="dentalClaim"]`) + +- CPT/CDT grid `table[id^="claimCPTCodesTable"]`; real rows are + `tr[ng-repeat^="CPT in CPTCodes"]` (no `CPTROW_` id). +- Per row: checkbox `[ng-click*="updateBillToIns("]` (same as UB/HCFA), code + `[data-fieldname="web_cptcode"]`, pointers `web_icd1`..`web_icd4`, units + `web_units`, fee `web_billedFee` (blur runs `editCPTRow('BILLED_FEE')`), + DOS `DentalClaimCPTSDOS`. +- **No per-row POS and no modifier columns.** POS is claim-level + (`ng-model="ClaimData.pos"`). +- Prog. Notes button `button[ng-click="viewProgNote()"]` + (`dentalClaimProgressNoteBtn…`) — the existing `SEL_PROGRESS_NOTES` matches. +- Insurance grid: `#DentalClaimTbl2` (Bill To, Ins Id, Name, ...). +- Billing Notes: `#dentalClaimNotes` (already read by high risk). + +None of the row fields has been exercised live yet; Dom.txt is one capture. + +## Design + +1. **Selectors** (`selectors/claim_form.py`): add the Dental alternative to + each row selector the helpers already use — `SEL_CPT_ROWS`, + `SEL_CPT_CODE`, `SEL_CPT_ICD1..4`, `SEL_CPT_BILLED_FEE`, `SEL_CPT_UNITS`. + The Dental row selector is scoped under `.modal[id^="dentalClaim"]` so it + cannot match a grid on another screen. Within one row only one alternative + ever matches, the same contract the UB/HCFA pairs rely on. +2. **Reader** (`pages/claim_form.py` `_EXTRACT_JS`): read Dental rows into the + same `cpt` row shape (`code`, `icd1..4`, `units`, `billed_fee`, `billed`, + `sdos`; `pos`/`mod1..4` empty), insurance from `#DentalClaimTbl2`, the + Dental claim number, ICD list, and `notes` from `#dentalClaimNotes`. + One `extract()` for all three screens. +3. **Flow** (`flows/export_medcor_ready.py`): a Dental claim runs high risk + + sliding fee first (unchanged, read-only), then the same path as UB/HCFA: + `extract()`, Prog. Notes (when `OPEN_PROGRESS_NOTES`), `run_all`, per-claim + box, Charge Summary. The Dental results stay on the claim's data so the box + and the summary show both. +4. **A fix the Dental screen cannot make** — e.g. telehealth needs a row + modifier and the Dental grid has none, or POS is claim-level: the condition + returns `declined(...)` with the reason ("Dental screen has no modifier + column"), so the claim is **Review** in the Charge Summary. Never a silent + skip, never a guess. +5. **Charge Summary**: one row per claim combining condition comments with the + Dental findings (HIGH RISK, sliding fee). Category: Review beats Fail beats + Pass, as today. +6. **Saving**: Dental claims are **never saved** in this change, whatever + `SAVE_CORRECTIONS` says. Their corrections are shown and listed as + "Corrections pending (not saved)". Dental save is enabled only after a live + verification-mode run confirms the row writes. + +## Error handling + +- Dental form not loaded (`wait_claim_loaded`): conditions skipped, claim is + Review — same as HCFA today. +- A Dental row field missing (fee or checkbox not found): the existing helpers + already treat a missing field as unknown, not as zero/unchecked. +- High risk / sliding fee failures keep their current Review handling. + +## Testing + +- Grow `tests/fixtures/dental_claim.html` from Dom.txt's real Dental grid + (rows, checkbox, fee, pointers, insurance grid, Prog. Notes button) with no + patient data. +- `extract()` on the Dental fixture returns the expected `cpt` and `insurance`. +- Per condition, a Dental case: dummy codes (DCONS/Dscprit) uncheck + zero; + J2001 uncheck + zero; zero-fee uncheck; priced line check; depression + pointer retag on `web_icd*`; telehealth on Dental → declined/Review. +- Flow: a Dental claim gets high risk + sliding fee **and** condition results + in one summary row; never offered Save. +- Full suite stays green. +- Live: one SAVE-off run on Dental test claims (with a DCONS or Dscprit line) + before Dental save is considered. + +## Out of scope + +- New document conditions not yet built (#3, #4, #9-#14, #16, #19, #20, + #23-#55). +- Saving Dental claims. +- "Special programs" half of #8. diff --git a/docs/superpowers/specs/2026-10-08-results-db-and-web-page-design.md b/docs/superpowers/specs/2026-10-08-results-db-and-web-page-design.md new file mode 100644 index 0000000..5dc714f --- /dev/null +++ b/docs/superpowers/specs/2026-10-08-results-db-and-web-page-design.md @@ -0,0 +1,164 @@ +# Charge Summary in SQL Server + results web page — design + +Date: 2026-10-08. Status: design approved in chat (operator, 2026-10-08), +spec under review. + +## Goal + +Every run's Charge Summary rows are also stored in SQL Server, and a simple +local web page (FastAPI) shows them. This is the basic version; later it is +folded into the full portal. + +## Decisions (operator, 2026-10-08) + +- Server `MECARE331\MECARE331` (SQL Server 2022, on this machine), database + **`Charge_Validation`** (created by the operator). +- The table follows the team's **Charge Summary File Format** + (`\\mecare2\Production\Salud-eCW RPA\Charge Summary File Format.xlsx`) — + the same 24 columns as `core/charge_summary.HEADERS`, plus run columns and + a details JSON. PATIENT, Account Number and Guarantor Name are stored, as + in the Excel file. +- FastAPI for the backend. All needed packages are already installed + (fastapi 0.141, uvicorn, jinja2, httpx, pyodbc, ODBC Driver 18); nothing new + is added to `requirements.txt` for the bot itself. + +## Table + +`tools/sql/create_tables.sql`, run by the operator in SSMS (the bot never +creates or alters tables): + +```sql +USE Charge_Validation; +CREATE TABLE dbo.ChargeSummary ( + id INT IDENTITY(1,1) PRIMARY KEY, + run_at DATETIME2 NOT NULL, + run_mode NVARCHAR(20) NOT NULL, -- VERIFY / LIVE-WRITE / TEST + 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_ChargeSummary_run_at ON dbo.ChargeSummary (run_at); +CREATE INDEX IX_ChargeSummary_claim_no ON dbo.ChargeSummary (claim_no); +``` + +Column map (Excel → DB) lives in one place, `results_db.COLUMNS`, built from +`charge_summary.HEADERS` so the two cannot drift: S.No→s_no, CLAIMS#→claim_no, +SERVICE DATE→service_date, PVDR→pvdr, PATIENT→patient, PAYER→payer, Group +Name→group_name, STATUS→status, CHARGES→charges, PMTS/ADJS→pmts_adjs, +Pmts/Refunds→pmts_refunds, Adjustment→adjustment, WithHeld→withheld, +Balance→balance, Visit Type→visit_type, Account Number→account_number, +Provider Name→provider_name, Guarantor Name→guarantor_name, Recevied +date→received_date, "Assigned To "→assigned_to, Completed On→completed_on, +Comments→comments, Current Status→current_status, Category→category. + +Value conversion: money columns strip `$`, `,` and spaces → Decimal; dates +accept `mm/dd/yyyy` (and an Excel date/datetime) → date; blank or +unparseable → NULL (the raw text is not lost: the Excel file still has it). + +## Bot side — `src/ecw_bot/core/results_db.py` + +- Settings (`settings.py`, from `.env`): + - `RESULTS_DB` (bool, default **off**): write rows to the DB. + - `RESULTS_DB_CONN` (string): ODBC connection string. Default: + `DRIVER={ODBC Driver 18 for SQL Server};SERVER=MECARE331\MECARE331;DATABASE=Charge_Validation;Trusted_Connection=yes;TrustServerCertificate=yes;` + (Windows login, no password). A SQL login's password, if ever used, is put + in `.env` by the operator only. +- `to_record(row: dict, *, run_at, run_mode, details) -> dict`: one Charge + Summary row (header→value) to DB column→value, converted. +- `insert_rows(conn, records) -> int`: one parameterized `INSERT` per row + (`executemany`), one commit; returns rows written. Never builds SQL from + values. +- `save_summary(sheet, details, run_at, run_mode, connect=pyodbc.connect) -> + int | None`: `sheet` is the header+rows list `charge_summary.build_rows` + already returns; `details` maps claim no → details dict. Opens the + connection, inserts, closes. **Any exception is caught**: one + `[RESULTS-DB] Could not save ...` log line, returns None. The run, the Excel + summary and the claims are never affected. +- `details_json` per claim: `{"fired", "changed", "review", "failed", + "comments", "saved", "changes": {condition: [change, ...]}, + "dental": {"high_risk", "sliding_fee"}}` — taken from the claim's + `data["_conditions"]` (the flow adds `changes` there) and Dental results. + +## Flow changes + +- `charge_summary.write()` is split: `charge_summary.build(report_path, + outcomes, assigned_to) -> sheet` (read + `build_rows`), and `write()` saves + that sheet as today. The flow builds once and hands the same sheet to Excel + and to the DB, so both always hold the same rows. +- `_read_claim_form` adds `"changes": {r.condition_name: r.changes}` to + `data["_conditions"]`; the claim loop keeps `details[claim_id]` next to + `outcomes[claim_id]`. +- `_write_charge_summary` calls `results_db.save_summary(...)` after the Excel + write when `RESULTS_DB` is on, and logs `[RESULTS-DB] N row(s) saved`. +- `run_mode`: `TEST` when `TEST_CLAIM_IDS_FILE` is set, else `LIVE-WRITE` + when `SAVE_CORRECTIONS` is on, else `VERIFY` — the same words as the + `[MODE]` banner. + +## Web page — `web/` + +- `web/app.py` (FastAPI) and `web/templates/index.html` (Jinja2), plain + HTML/CSS, no external scripts or CDNs. +- Read-only: only `SELECT` statements, parameterized. +- `GET /` — page: + - run picker (distinct `run_at`, newest first; default latest run), + - Category filter (All / Pass / Fail / Review) and Claim# search, + - counts per Category for the selected run (Pass / Fail / Review tiles), + - the table in Charge Summary column order, Category coloured, + - each row expands to show `details_json` (conditions fired, before/after + changes, review reasons). +- `GET /api/runs` — runs with counts. `GET /api/results?run_at=&category=&claim_no=` + — rows as JSON (for the later portal). +- Start: `python -m uvicorn web.app:app --host 127.0.0.1 --port 8000`. + Bound to **127.0.0.1 only**: the page shows PHI and is not reachable from + the network. A `run_web.bat` helper does the same. +- The DB connection string is the same `RESULTS_DB_CONN`. If the DB cannot be + reached the page shows a plain error message, not a stack trace. + +## Error handling + +- DB unreachable / permission denied / table missing during a run → one log + line, run continues, Excel written. +- A value that does not convert → NULL for that column, row still saved. +- Web page: DB errors → HTTP 503 page "Results database not reachable". + +## Testing (no real DB) + +- `tests/test_results_db.py`: header→column map covers all 24 HEADERS; + money/date conversion (incl. `$1,234.50`, blank, junk); `to_record` output; + `insert_rows` sends one parameterized statement with values in column + order (fake connection records calls); `save_summary` swallows a connect + error and returns None; `details_json` shape. +- `tests/test_charge_summary.py`: `build()` + `write()` split keeps today's + output. +- `tests/test_web_app.py`: FastAPI `TestClient` with a fake repository — + page renders rows and Category counts, filters work, `/api/results` JSON + shape, DB error → 503. +- Full suite stays green. Live: operator runs `create_tables.sql`, sets + `RESULTS_DB=1`, one test run, then opens the page. + +## Out of scope (later portal) + +Login/roles, editing rows, charts beyond the Category tiles, scheduling, +AI features, hosting on the network. diff --git a/docs/superpowers/specs/2026-10-14-start-bot-from-web-page-design.md b/docs/superpowers/specs/2026-10-14-start-bot-from-web-page-design.md new file mode 100644 index 0000000..bf27425 --- /dev/null +++ b/docs/superpowers/specs/2026-10-14-start-bot-from-web-page-design.md @@ -0,0 +1,176 @@ +# Start the bot from the results web page — design + +Date: 2026-10-14. Approved section by section by the operator in chat +(approach A, sections 1–3). + +## Goal + +The operator starts a Charge Validation run from the local results page +(`run_web.bat` → http://127.0.0.1:8000) instead of the terminal. While it +runs, the page shows live status; it can stop it; when it ends, the page +shows that run's results. + +## Decisions (operator, 2026-10-14) + +| Question | Answer | +|---|---| +| Controls | Start + live status + Stop (no settings editing on the page) | +| Save mode | **VERIFY only.** A page-started run is always `SAVE_CORRECTIONS=0`, whatever `.env` says. LIVE-WRITE stays terminal-only. | +| Message boxes | **Off** for the run's information boxes (per-claim, review-before-close, "Run the Lookup?", summaries). Boxes that need a person to act in the browser — CAPTCHA, 2FA manual step, security image — **stay on**. | +| Approach | A: the web server starts `run.py` as a separate child process (not a thread, not Task Scheduler). | + +Out of scope (YAGNI): editing settings from the page, scheduling, more than +one run at a time, LIVE-WRITE from the page. + +## Components + +### New: `web/bot_runner.py` — process control only + +- `start() -> dict` — starts `python run.py` (the project's own `run.py`, + `sys.executable`, cwd = project root) as a child process with no console + window of its own (`CREATE_NO_WINDOW`); the Chrome window still shows. + The child's stdout/stderr go to `Logs\web_run_.out`, which catches a + crash before the bot opens its own log; the bot's `Logs\run_.log` + (named in the lock file) is the status source. + Refuses (raises `AlreadyRunning`) when the lock file names a live pid. +- `stop() -> dict` — `taskkill /PID /T /F`: the bot and its Chrome + close together. Clears the lock file. Status becomes "Stopped by + operator". +- `status() -> dict` — `running` (bool), `pid`, `started_at`, `log_path`, + `exit_code` (when this web process started it and it has ended), + `stopped_by_operator`. +- The command is fixed. Nothing from the request reaches the command line + or the environment. + +**Child environment** — set on the child process, so it wins over `.env` +(`settings.py` loads `.env` with `os.environ.setdefault`): + +``` +SAVE_CORRECTIONS=0 +ECW_KEEP_OPEN=0 # no "Press Enter to close" at the end +QUIET_BOXES=1 # run-flow information boxes go to the log +CONFIRM_BEFORE_LOOKUP=0 +``` + +Everything else follows `.env` (claim file, `CLAIM_PROCESS_LIMIT`, +`RESULTS_DB`, proxy, ...). + +### Lock file: `Logs\bot_run.json` + +`{"pid", "started_at", "log_path", "source": "web" | "terminal"}`. + +- Written by the **bot itself** at the start of `main()` (after + `runlog.start()`, so `log_path` is known) and removed when `main()` + returns — so a terminal run is seen by the page too. +- Before writing it, `main()` refuses to start (exit 2, before login) when + the file names a pid that is alive: two runs would share one eCW login + guard and one Charge Summary. +- A file naming a dead pid is stale: removed and ignored (a killed run + cannot remove its own file). +- `bot_runner.start()` checks it too, so the page answers 409 at once. + +### New: `web/run_status.py` — read the run log + +Pure functions over log text (no I/O except reading the file tail): + +- **Phase** from the last matching line: + `[LOGIN]` / `[CAPTCHA]` → "Logging in"; `[OTP]` / `[2FA]` → "Waiting for + the 2FA approval mail"; `[OK] Logged in` → "Logged in"; `[CLAIMS]` → + "Checking claims"; process ended → "Finished" or a failure text by exit + code. +- **Progress** from `[CLAIMS] --- claim n/N: ---` → `n`, `N`, current + claim id. +- **Counts** from the new `[RESULT]` lines (below) → Pass / Fail / Review. +- **Tail**: the last 30 lines. +- **Exit codes** (from `flows/export_medcor_ready.main`): 0 finished; 2 + config / mode gate; 3 login guard refused (its `[GUARD]` line shown); 5 + claims phase failed; 6 2FA timed out; other → "ended with code N". + +### Changed: `flows/export_medcor_ready.py` + +- After each claim's outcome is known in `_process_claims`, one + machine-readable line (terminal runs print it too): + + ``` + [RESULT] claim 3/7 9126000: Review + ``` + + The word is the Category the Charge Summary gets for that claim; a claim + that gets no Charge Summary row (not found in the grid) prints + `Not found`. +- `main()` writes / removes the lock file as above. + +### Changed: `core/msgbox.py` + +- New setting `QUIET_BOXES` (default off; `settings.py`). +- `info_box(title, msg, needs_person=False)`: with `QUIET_BOXES=1` and + `needs_person=False`, no box — one log line instead: + `[BOX] : <first line of msg>`. +- `ok_cancel` / `confirm` are unchanged (every remaining caller is a + person-needed step: CAPTCHA, 2FA manual, LIVE-WRITE confirm — the last + is never reached in VERIFY). The security-image `info_box` in + `auth/login.py` passes `needs_person=True`. + +### Changed: `web/app.py` + `web/templates/index.html` + +Endpoints: + +| Method | Path | Answer | +|---|---|---| +| POST | `/api/bot/start` | 200 `{status}`; 409 already running; 403 bad Origin/token | +| POST | `/api/bot/stop` | 200 `{status}`; 409 nothing running; 403 bad Origin/token | +| GET | `/api/bot/status` | `{running, phase, progress, counts, tail, started_at, exit_code, message}` | + +Page: a "Bot run" card above the results. +- Idle: **[Start run]** and "VERIFY mode — nothing is saved to eCW". +- Running (polls `/api/bot/status` every 2 s): phase, progress bar + "3 / 7", current claim, Pass/Fail/Review chips, collapsible "Live log" + (last 30 lines), **[Stop]** (with a confirm dialog). +- Ended: "Finished in 4m 12s — exit 0" (or the failure text); the results + table reloads on the newest run. + +## Security + +The page was read-only; Start/Stop are its first actions. The existing +TrustedHost check stops DNS rebinding but not a cross-site POST from +another tab, which could burn eCW login attempts. So every POST needs both: + +1. `Origin` (or, when absent, `Referer`) is `http://127.0.0.1:8000` or + `http://localhost:8000` — else 403. +2. Header `X-Run-Token` equals a token made at server start + (`secrets.token_urlsafe(32)`) and embedded in the page — else 403. + Compared with `secrets.compare_digest`. + +No password on the page; `.env` credentials are read only by the bot. +Responses keep `no-store`. The page stays bound to 127.0.0.1. The live log +tail can carry PHI, as the results table already does — local only. + +## Error handling + +- Already running (page or terminal) → 409 "A run is already going + (started HH:MM)". +- Guard refused (exit 3) → the `[GUARD]` line shown ("next attempt + allowed at ..."). +- Stop mid-claim: the claim form may stay open in eCW. VERIFY saves + nothing; the next run's `clear_screen` closes it. +- Web server restarted or page closed: the bot keeps running; on reload + the page finds it through the lock file. `exit_code` is unknown then + (the run is shown "Ended" when its pid is gone). +- Log file not there yet → phase "Starting". + +## Testing (standalone "N/M passed" scripts) + +- `tests/test_bot_runner.py` — a fake child script in place of `run.py` + (injectable command): start → running → stop → stopped; second start → + `AlreadyRunning`; stale lock (dead pid) cleared; the child sees the forced + env (`SAVE_CORRECTIONS=0`, `ECW_KEEP_OPEN=0`, `QUIET_BOXES=1`, + `CONFIRM_BEFORE_LOOKUP=0`) even when the parent env says otherwise. +- `tests/test_run_status.py` — sample log text → phase, progress, counts, + tail; each exit code's text. +- `tests/test_web_app.py` (added cases) — POST without/with wrong Origin or + token → 403; right Origin + token → 200 (runner stubbed); status JSON + shape; the token is in the page. +- `tests/test_msgbox.py` (added cases) — `QUIET_BOXES=1`: info box logged, + not shown; `needs_person=True` still shown. +- Flow test — `[RESULT]` line per claim; `main()` lock file written, + refused on a live one, removed at the end. diff --git a/docs/superpowers/specs/2026-10-19-results-portal-redesign-design.md b/docs/superpowers/specs/2026-10-19-results-portal-redesign-design.md new file mode 100644 index 0000000..1b42730 --- /dev/null +++ b/docs/superpowers/specs/2026-10-19-results-portal-redesign-design.md @@ -0,0 +1,96 @@ +# Results portal redesign — design + +Date: 2026-10-19. Operator requests (voice, Tanglish), choices made on the +visual companion mockups (`.superpowers/brainstorm/828-1792395410/`). + +## Problem + +The results page (`web/`, FastAPI + Jinja, 127.0.0.1:8000) opens on the +newest run only. A second run (20 claims) hides the first (10 claims) behind +a "Run" dropdown nobody noticed, so earlier runs look lost. The front page +is three quarters claims table (24 columns, sideways slider), which the +operator does not want on the home page. The look is "very basic". + +## What the operator chose + +| Question | Choice | +|---|---| +| Home page | **Dashboard**: Bot run card (Start / Stop / live progress), today's tiles, one big **Claim status** button | +| Claim status page | **Date cards**, newest first | +| A date with several runs | **All claims of the day together** (every run's rows in one list) | +| One day's page | **Short table + side panel** | +| Colours | **Navy & blue** (navy header, white cards on light grey, blue buttons) | +| Live run's claim list | In the **Bot run card** ("Live claims" side panel) | +| Search one claim over all dates | **Yes** — "Find a claim" on the Dashboard | +| Approach | **A** — server pages (FastAPI + Jinja), one page per screen | + +## Pages + +All pages share `base.html`: navy header with the title, a mode pill +(VERIFY / LIVE-WRITE from `SAVE_CORRECTIONS`) and a breadcrumb +(Dashboard › Claim status › 16 Oct 2026). Light grey background, white +cards, blue buttons; Pass green, Fail red, Review amber everywhere. + +1. **Dashboard `/`** + - Bot run card — the existing Start / Stop / progress / live log, plus a + **Live claims** link opening a side panel with every claim of the + running (or last) run: Waiting / Checking / result. A claim with a + saved result opens `/day/<date of its run>?claim=<no>`. + - Today tiles: claims checked today, Pass, Fail, Review (0 when no run + today). + - **Find a claim** box → `/claim?claim_no=…`. + - Big **Claim status →** button → `/days`. + - No claims table. +2. **Claim status `/days`** — one card per day (last 90 days with a run), + newest first: weekday + date, claims checked, number of runs, + Pass / Fail / Review and a thin colour bar. A card opens `/day/<date>`. +3. **One day `/day/YYYY-MM-DD`** + - Tiles All / Pass / Fail / Review; a tile is a filter link + (`?category=Fail`). Claim # search box (`?claim_no=`). + - Short table, 10 rows a page (client-side pager): #, Claim #, Time + (the run's time), Payer, Result chip, Comments (one line, cut with + an ellipsis). No sideways scroll. + - Row click → side panel with every Charge Summary field, the run's + time and mode, and the conditions/changes JSON. + - `?claim=<no>` opens that claim's panel on load (on its page). +4. **Claim history `/claim?claim_no=…`** — every saved check of claims + matching the number (newest first, up to 200): date, time, mode, + result, comments; each row opens `/day/<date>?claim=<no>`. + +`/?run_at=<time>` (old links) redirects (303) to `/day/<date of that run>`. +`/api/runs`, `/api/results`, `/api/bot/*` stay as they are. + +## Data + +No schema change. `web/repo.py` gains three parameterized SELECTs: + +- `days(limit=90)` — `GROUP BY CAST(run_at AS DATE)`: day, claims, + runs (`COUNT(DISTINCT run_at)`), pass / fail / review counts, newest + first. +- `day_results(day, category, claim_no)` — rows with + `run_at >= day AND run_at < day + 1`, ordered by run_at, S.No, id. +- `claim_history(claim_no)` — `TOP 200 … WHERE claim_no LIKE ?` ordered + by run_at DESC. + +Rules: a run belongs to the day it **started** (every row of a run shares +its `run_at`), so a run never splits over two cards. The same claim +checked twice in a day shows twice, each with its time; a day's count is +checks. "Not found" claims have no saved row (as today) and appear only in +the live panel. A legacy driver returning the day as text is coerced to a +date, like `run_at` already is. + +## Unchanged + +127.0.0.1 only; Host allowlist; `no-store` and the other headers; DB +errors reported by kind; Start / Stop need the page's Origin and token; +page runs are VERIFY only. All new pages are read-only. Page text from the +DB goes out escaped (Jinja autoescape; the panel's data as `tojson` in an +attribute, rendered with `textContent`). + +## Testing + +`tests/test_web_app.py` (fake repo, fake DB connection): each page renders +with its data and links; filters and the bad-input 400s; the redirect; +the three new SQL statements are parameterized and shaped as above; the +hardening, API and Start / Stop cases kept. A browser check of the four +pages on fixture data (Playwright, the in-app browser). diff --git a/docs/superpowers/specs/2026-10-22-run-tracking-and-portal-sign-in-design.md b/docs/superpowers/specs/2026-10-22-run-tracking-and-portal-sign-in-design.md new file mode 100644 index 0000000..971f00b --- /dev/null +++ b/docs/superpowers/specs/2026-10-22-run-tracking-and-portal-sign-in-design.md @@ -0,0 +1,161 @@ +# Run tracking and Portal Sign-in — design + +Date: 2026-10-22. Agreed with the operator in chat, question by question +(Q1–Q31, all "yes" to the recommended answers; Q3 changed to "who" = the +Portal User). Terms: `GLOSSARY.md`. Decision record: +`docs/adr/0001-run-events-in-files.md`. Jira: EEWA-31 (Stage 1), +EEWA-32 (Stage 2), EEWA-33 (Stage 3), each blocking the next. + +## Goal + +See every Run on the Portal: who started it, how long the eCW login took and +where the time went, every Run Error, Claim Error and Warning with its time, +and which claims a Run never reached. Only signed-in Portal Users see the +Portal; every Run records who started (and stopped) it. + +## Decisions + +| # | Decision | +|---|---| +| Q1 | Three kinds: **Run Error** (the Run stopped), **Claim Error** (one claim not checked), **Warning** (nothing stopped). **Fail** stays a Charge Summary category only. | +| Q2/Q28 | Per **eCW Login Attempt**: start, end, duration, 2FA mail wait, result; steps timed: page open, password typed, captcha, 2FA mail requested (the Log In click), mail received, approval clicked, home page. | +| Q3/Q8 | "Who" = the **Portal User** who signed in; **Started By** / **Stopped By**. | +| Q4 | Events go to `Logs\events_<run>.jsonl` (ADR 0001). | +| Q5/Q25 | Old run logs are not imported. Nothing is deleted. | +| Q6/Q18/Q19/Q22 | A **Runs** page on the Portal: list + detail, detail refreshes every 5 s while its Run is going. | +| Q9 | The Portal stays on 127.0.0.1. | +| Q10–Q17, Q20, Q21 | Sign-in (Stage 2, below). | +| Q14 | Terminal Runs are allowed; Started By = the Windows user. | +| Q15/Q23 | Stage 3: Run Error banner; `started_by` column in `CV_ChargeSummary`; the Excel sheet unchanged. | +| Q24 | Disk check at Run start: system drive under 2 GB = Warning, under 500 MB = Run Error, exit 2, before login. | +| Q26 | Claim Error = search raised / form not read / never finished loading; Charge Summary still says Review. A claim not found in eCW = Warning. | +| Q27 | **Not Reached** = queued claims a Run Error (or Stop) came before. | + +## Stage 1 — Run events and the Runs page (EEWA-31) + +### `core/run_events.py` (new) + +- `start(log_path)` — the events file is the log's name with `run_` → + `events_` and `.log` → `.jsonl` (`run_20261022_141530.log` → + `events_20261022_141530.jsonl`); the Run's id is `20261022_141530`. + Writes `run_start`. +- `emit(kind, **fields)` — one JSON line, `{"t": local ISO time to the + millisecond, "kind": kind, ...}`, flushed at once. **Never raises**: an + event that cannot be written is dropped, the Run carries on. +- Helpers: `warning(code, message, **f)`, `run_error(step, message, + exit_code, exc=None)` (with `traceback.format_exc()` when an exception is + given), `claim_error(claim, step, message)`, `login_step(step, **f)`, + `login_attempt_start()` / `login_attempt_end(result, message="")` (the + module numbers attempts). +- No OTP code, password or eCW username is ever put in an event. + +### Event kinds + +| kind | fields | +|---|---| +| `run_start` | `source` (portal / terminal), `started_by`, `mode` (VERIFY / LIVE-WRITE), `claim_limit`, `test_sheet` (file name only), `pid`, `log` | +| `disk` | `drive`, `free_mb` | +| `login_attempt_start` | `attempt` | +| `login_step` | `attempt`, `step`: `page_open`, `password`, `captcha_shown`, `captcha_solved`, `captcha_failed`, `twofa_requested`, `mail_received`, `approval_clicked`, `home` | +| `login_attempt_end` | `attempt`, `result`: `ok`, `twofa_timeout`, `captcha_failed`, `restricted`, `failed`; `message` | +| `queue` | `claims` (list) | +| `claim_start` | `n`, `total`, `claim` | +| `claim_result` | `n`, `claim`, `category` (Pass / Fail / Review / Not found) | +| `claim_error` | `claim`, `step` (`search` / `form` / `load`), `message` | +| `warning` | `code` (`not_found`, `fallback`, `dialog`, `cancel_retry`, `form_not_closed`, `disk_low`, `preflight`), `message`, optional `claim` | +| `screenshot` | `path` (a failure shot), optional `claim` | +| `run_error` | `step` (`config`, `disk`, `guard`, `login`, `restricted`, `twofa_timeout`, `preflight`, `claims`, `unexpected`), `exit_code`, `message`, `traceback` | +| `run_end` | `exit_code` | +| `stopped` | `stopped_by` (written by the Portal after Stop; Stage 2 fills the name) | + +`started_by`: env `ECW_STARTED_BY` (the Portal sets it, Stage 2), else the +Windows user. `source`: as `bot_run.json` has it (`ECW_RUN_SOURCE`). + +### Where the bot emits + +- `flows/export_medcor_ready.py`: `main()` starts the events after + `runlog.start()` and writes `run_end` in its `finally`; `_main` writes a + `run_error` at every non-zero return and in every `except`, with the + traceback; the disk check sits before login next to the CDP check; + `_login_with_retry` wraps each `login()` in attempt start / end; + `_process_claims` writes `queue`, `claim_start`, `claim_result`, + `claim_error` (search raised, `_error`, `_not_evaluated`) and the + not-found Warning. +- `auth/login.py` / `captcha.py` / `twofactor.py` / `mailbox.py`: the login + steps. +- `core/fallback.py` `report()`, `core/page_utils.py` `auto_dialog` (the + event only — its behaviour is unchanged, see Context.md §10) and the + failure `shot()`, `pages/claim_form.py` Cancel retries and form-not-closed, + `pages/preflight.py` FAIL: Warnings / screenshots. +- `web/bot_runner.py` `stop()`: appends `stopped` to the Run's events file. + +### Runs page + +- `web/runs.py` (new): reads `Logs\events_*.jsonl` into Run summaries and a + Run's detail. A Run with no `run_end` is **Running** when `bot_run.json` + names it and its pid is alive, **Stopped** when a `stopped` event is + there, else **Ended without a record** (killed). Result: Done (exit 0), + Run Error (exit and step), Stopped, Running. +- `GET /runs` — newest first: start, Started By, Run Source, mode, + duration, result, eCW Login Attempts + total login time, Pass / Fail / + Review, Claim Errors, Warnings. Filters: from / to date, result. +- `GET /runs/{run_id}` — the login attempts table (each step's time, 2FA + wait = mail received − 2FA requested), Run Errors (traceback folded), + Claim Errors, Warnings, Not Reached claims, the timeline, screenshot + links; refreshes every 5 s while Running. +- `GET /runs/{run_id}/shot/{n}` — the Run's n-th screenshot event, served + only from inside `Screenshots\` and only as `.png`: a saved page's HTML + served from the Portal's origin would run eCW's scripts there, so the + detail page names where the DOM file is instead of linking it. +- A "Runs" link on every page's header. + +### Disk check + +Settings `DISK_WARN_MB=2048`, `DISK_STOP_MB=500`, on the system drive. + +## Stage 2 — Sign-in (EEWA-32) + +- `CV_PortalUser` (username, display name, role, PBKDF2-SHA256 hash + salt + + iterations, must_change, disabled, created / changed) and + `CV_PortalSignIn` (time, username, event: sign_in / sign_out / + wrong_password / locked / timeout) in `Charge_Validation`. +- Roles Admin / Operator / Viewer. Start / Stop: Admin, Operator. Users + page and Sign-in history: Admin. +- `tools\add_portal_user.py` (password with `getpass`). Passwords ≥ 10 + characters; change own; Admin reset → must change at next Sign-in; + disable, never delete. +- 5 wrong passwords → 15 minutes locked. Session cookie (HttpOnly, + SameSite=Strict), 30 minutes idle. Every page and `/api` behind it; + 127.0.0.1 + Origin / token checks stay. +- The Portal passes the signed-in user to the child as `ECW_STARTED_BY`; + `stop()` writes `stopped_by`. `bot_run.json` keeps `started_by` too, so + the Dashboard's run card says "started by …". +- As built (`web/users.py`, `web/app.py`): sessions in the Portal's memory + (a restart signs everyone out); disabling a user, resetting their + password or changing their role ends / updates their open session at + once; an unknown username reads and costs the same as a wrong password; + the last active Admin cannot be demoted or disabled, nobody disables + themselves; every Admin change is a `CV_PortalSignIn` row with `by_user`; + every form POST carries the page token and is checked for Origin; + `next` after Sign-in never leaves the site. + +## Stage 3 — Alerts and `started_by` (EEWA-33) + +- Dashboard banner when the last Run ended in a Run Error. +- `CV_ChargeSummary.started_by` (nullable column, written from the Run's + Started By). +- As built: the banner also shows for a Run that ended without a record + (killed or crashed — no `run_end`), never for Done, Stopped or Running; + it names when, Started By, the step, exit code and message, how many + claims were Not Reached, and links the Run. `create_tables.sql` adds the + column to an existing table (`IF COL_LENGTH ... IS NULL ALTER TABLE`). + The bot checks the column before each insert (`results_db.has_column`) + and saves the rows without it while it is missing — a table not yet + updated never costs a Run its rows. The Portal selects it, shows it as + "Started By" in a claim's side panel, and a table without it reads + "Results table needs an update — run create_tables.sql" (SQLSTATE 42S22). + +## Out of scope + +Network access to the Portal, email / Teams alerts, importing old logs, +deleting old events, editing settings from the Portal. diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..47e4de7 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,8 @@ +# Salud eCW Charge Entry Bot — login phase. +# .env is parsed by src/ecw_bot/settings.py directly, so python-dotenv is not +# needed — these machines are locked down and every dependency is an approval. +# The tests run on plain asserts for the same reason: no pytest. +# Everything else (imaplib, email, ssl, ctypes) ships with the stdlib. + +playwright +pyotp diff --git a/run.py b/run.py new file mode 100644 index 0000000..cc7ac82 --- /dev/null +++ b/run.py @@ -0,0 +1,23 @@ +r""" +Salud eCW charge-entry bot — entry point. + + python run.py + +This is the only supported way to start the bot, and the two lines below are +why it exists. The old layout was a folder of flat modules run as +`python src\main.py`, which worked *only* because Python puts the script's own +directory on `sys.path` — so `import config` resolved. Any other way of +starting it (VS Code's "Run File" on a different file, Task Scheduler, pytest, +`python -m`) broke with ImportError. A package plus this shim fixes that: the +import path no longer depends on which file you happened to launch. +""" + +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parent / "src")) + +from ecw_bot.flows.export_medcor_ready import main # noqa: E402 + +if __name__ == "__main__": + sys.exit(main()) diff --git a/run_web.bat b/run_web.bat new file mode 100644 index 0000000..3f90e99 --- /dev/null +++ b/run_web.bat @@ -0,0 +1,12 @@ +@echo off +rem Results web page for the eCW Charge Validation bot (web/app.py). +rem Local only: http://127.0.0.1:8000 - the page shows patient data. +cd /d "%~dp0" +python -m uvicorn web.app:app --host 127.0.0.1 --port 8000 +if errorlevel 1 ( + echo. + echo The results page stopped with an error - see above. Common causes: + echo port 8000 already in use, or fastapi / uvicorn / jinja2 / pyodbc + echo not installed. + pause +) diff --git a/src/ecw_bot/__init__.py b/src/ecw_bot/__init__.py new file mode 100644 index 0000000..ffd3218 --- /dev/null +++ b/src/ecw_bot/__init__.py @@ -0,0 +1,21 @@ +r""" +Salud eCW charge-entry bot. + +Layers, outermost first. Nothing ever imports a layer above it, so the import +graph stays acyclic and any module can be read without chasing a cycle: + + flows\ the business process — "export the Medcor Ready queue" + pages\ one module per eCW screen; knows the DOM, not the process + auth\ login and the second factor + selectors\ every CSS selector, and nothing else + core\ browser, logging, message boxes, attempt guard — no eCW + knowledge at all, so the next client's bot reuses it as-is + settings.py .env values, and nothing else + +The split exists because these things change for different reasons: a selector +changes when eCW ships a new build, a setting when the process changes, a flow +when the work itself changes. Keeping them in one file — which is how this +started — meant editing a date range and a DOM selector in the same place. +""" + +__version__ = "0.2.0" diff --git a/src/ecw_bot/__main__.py b/src/ecw_bot/__main__.py new file mode 100644 index 0000000..00685c8 --- /dev/null +++ b/src/ecw_bot/__main__.py @@ -0,0 +1,16 @@ +r""" +`python -m ecw_bot` — same run as `run.py`, for Task Scheduler and CI. + +Needs `src\` on the import path, which `run.py` does for you. From the project +root: + + set PYTHONPATH=src + python -m ecw_bot +""" + +import sys + +from .flows.export_medcor_ready import main + +if __name__ == "__main__": + sys.exit(main()) diff --git a/src/ecw_bot/auth/__init__.py b/src/ecw_bot/auth/__init__.py new file mode 100644 index 0000000..d23dfc5 --- /dev/null +++ b/src/ecw_bot/auth/__init__.py @@ -0,0 +1 @@ +"""Login, captcha, and the second factor (mailbox-delivered approval link).""" diff --git a/src/ecw_bot/auth/captcha.py b/src/ecw_bot/auth/captcha.py new file mode 100644 index 0000000..3adb671 --- /dev/null +++ b/src/ecw_bot/auth/captcha.py @@ -0,0 +1,102 @@ +""" +Captcha escalation: two manual attempts, a fresh incognito browser, one last try. + +A human solves it, not the bot. The NAHMS bot tried OCR first and it was +unreliable enough that a wrong answer cost an attempt against a portal that +restricts accounts for retrying — so this asks instead. +""" + +from playwright.sync_api import Page, TimeoutError as PWTimeout + +from .. import settings as cfg +from ..core import msgbox, run_events +from ..core.page_utils import shot, visible +from ..selectors import login as sel +from .password_entry import type_password + + +def _clear_captcha(page: Page) -> None: + for one in sel.SEL_CAPTCHA_INPUT.split(","): + try: + el = page.locator(one.strip()).first + if el.is_visible(timeout=500): + el.fill("") + return + except Exception: + pass + + +def _past_captcha(page: Page) -> bool: + """True once the page has moved beyond the captcha/login step.""" + return any(visible(page, s) for s in + (sel.SEL_HOME, sel.SEL_OTP_INPUT, sel.SEL_PUSH_NOTICE, + sel.SEL_ALT_TFA)) + + +def handle_captcha(page: Page, client_name: str, password: str): + """ + Returns True (past the captcha), False (operator cancelled), or the string + "incognito" (attempts exhausted — caller should relaunch incognito). + """ + if not visible(page, sel.SEL_CAPTCHA_INPUT, timeout=2_000): + return True + + shot(page, "captcha") + run_events.login_step("captcha_shown") + for attempt in range(1, cfg.CAPTCHA_ATTEMPTS + 1): + print(f"[CAPTCHA] Attempt {attempt}/{cfg.CAPTCHA_ATTEMPTS} ({client_name})") + # The image often refreshes a beat after the page settles; the wait is + # what makes the code the operator sees the one the server expects. + page.wait_for_timeout(10_000) + _clear_captcha(page) + try: + type_password(page, password) + except Exception: + pass + + ok = msgbox.ok_cancel( + f"eCW Bot — CAPTCHA ({client_name})", + f"A CAPTCHA came up! (Attempt {attempt}/{cfg.CAPTCHA_ATTEMPTS})\n\n" + f"In the browser window:\n" + f"1. Type the CAPTCHA manually\n" + f"2. Click the Login button\n\n" + f"Click OK once logged in.\n" + f"Click Cancel to skip.", + ) + if not ok: + print(f"[CAPTCHA] Attempt {attempt} cancelled") + return False + + try: + page.wait_for_selector( + f"{sel.SEL_HOME}, {sel.SEL_OTP_INPUT}, " + f"{sel.SEL_CAPTCHA_INPUT}, {sel.SEL_PUSH_NOTICE}", + timeout=8_000, + ) + except PWTimeout: + pass + if _past_captcha(page): + print(f"[CAPTCHA] Attempt {attempt} passed") + run_events.login_step("captcha_solved", tries=attempt) + return True + + # Operator filled the box but left the button — press it for them. + try: + if visible(page, sel.SEL_CAPTCHA_INPUT): + page.locator(sel.SEL_LOGIN_BTN).click() + page.wait_for_selector( + f"{sel.SEL_HOME}, {sel.SEL_OTP_INPUT}, " + f"{sel.SEL_CAPTCHA_INPUT}, {sel.SEL_PUSH_NOTICE}", + timeout=10_000, + ) + except Exception: + pass + if _past_captcha(page): + print(f"[CAPTCHA] Attempt {attempt} passed after click") + run_events.login_step("captcha_solved", tries=attempt) + return True + + print(f"[CAPTCHA] Attempt {attempt} failed") + + run_events.login_step("captcha_failed", tries=cfg.CAPTCHA_ATTEMPTS) + return "incognito" diff --git a/src/ecw_bot/auth/dead_end.py b/src/ecw_bot/auth/dead_end.py new file mode 100644 index 0000000..18977a5 --- /dev/null +++ b/src/ecw_bot/auth/dead_end.py @@ -0,0 +1,81 @@ +""" +The 2FA screen's dead ends — states the page will never recover from. + +`initCheckTokenTimer()` polls `checkSecureToken` once a second and, on any of +these three answers, calls `clearInterval` and rewrites `#dispMessage`: + + isTokenExpired → "Your log in request has timed out. Please try again" + isVerified === "no" → "You confirmed that you weren't trying to Log in..." + leftOtpCount <= 0 → "...No attempt left." (three resends per login) + +Once that happens **the page has stopped polling**. It will not move on no +matter how long the bot waits, and the approval link in the mailbox is already +spent. The only way forward is a new login. + +Why this needs detecting explicitly: eCW writes all of it into `#dispMessage`, +the *same* element that carries the healthy "a notification has been sent to +your email" notice. `handle_2fa` treats that element being visible as "the push +notice is showing", so without reading its text a timed-out login looks exactly +like a healthy one still waiting for mail — and the bot sits out its full +ten-minute window, presses a Resend button eCW has already disabled, and then +asks a human. Twenty minutes to discover the page died in the first thirty +seconds. +""" + +from playwright.sync_api import Page + +from ..core.page_utils import shot +from ..selectors import login as sel + +# Message fragment → what it actually means, in the words the log should use. +_DEAD_ENDS = ( + (sel.TXT_LOGIN_TIMED_OUT, + "eCW timed out waiting for the approval link to be clicked"), + (sel.TXT_NOT_ME, + "somebody answered \"No, it wasn't me\" on the approval page"), + (sel.TXT_NO_ATTEMPTS, + "the three resends eCW allows per login are used up"), +) + + +class LoginTimedOut(RuntimeError): + """ + The 2FA handshake died and only a fresh login can fix it. + + Deliberately not a `Restricted`: that one means "stop, retrying makes it + worse". This one means the opposite — retrying is the *only* thing that + helps. The flow closes the browser and starts over, through the attempt + guard so a retry still cannot turn into a burst. + """ + + +def reason(page: Page) -> str: + """ + Which dead end the 2FA screen is showing, or "" when it looks healthy. + + Never raises and never waits long: it is called inside the mailbox polling + loop, so a slow answer here would add up across a ten-minute window. + """ + try: + text = page.locator(sel.SEL_OTP_MESSAGE).first.inner_text(timeout=2_000) + except Exception: + return "" # not on the 2FA screen, or it is gone + low = (text or "").lower() + for fragment, meaning in _DEAD_ENDS: + if fragment.lower() in low: + return meaning + return "" + + +def assert_alive(page: Page, where: str) -> None: + """Raise `LoginTimedOut` if the 2FA screen has hit a dead end.""" + why = reason(page) + if not why: + return + shot(page, "2fa_timed_out") + raise LoginTimedOut( + f"The 2FA screen is finished at {where}: {why}.\n" + f" eCW has stopped polling for the approval, so waiting cannot help " + f"and the emailed link is spent.\n" + f" The browser will be closed and the login started again." + ) diff --git a/src/ecw_bot/auth/login.py b/src/ecw_bot/auth/login.py new file mode 100644 index 0000000..1329ad0 --- /dev/null +++ b/src/ecw_bot/auth/login.py @@ -0,0 +1,332 @@ +""" +The login itself. + +Single-client rework of the NAHMS bot's auth module. The page flow is identical +(same portal, same eCW V12 markup) so the hard-won bits are kept verbatim: +navigation retries for the proxy's intermittent `net::ERR_ABORTED`, the dialog +auto-handler, the captcha escalation, and the security-image interstitial some +logins interpose between username and password. + +What changed from NAHMS: credentials come from `.env` instead of a shared +spreadsheet, one client instead of a loop, and the second factor is a mailed +approval link rather than an authenticator code — see `twofactor.py`. +""" + +from datetime import datetime, timezone + +from playwright.sync_api import Page, TimeoutError as PWTimeout + +from .. import settings as cfg +from ..core import msgbox +from ..core.browser import launch +from ..core.page_utils import shot, visible, goto_with_retry +from ..selectors import login as sel +from .captcha import handle_captcha +from .dead_end import LoginTimedOut, assert_alive +from .password_entry import type_password +from ..core import fallback, run_events +from ..selectors.fallbacks import CHAINS as FALLBACK_CHAINS +from .restricted import Restricted, assert_not_restricted +from .twofactor import handle_2fa + + +def _assert_login_page(page: Page) -> None: + """ + Confirm the real login form came back before typing any credentials. + + Unproxied, eCW answers this URL with HTTP 400 and a page that has no + username box. Left unchecked, the next line is a `page.fill()` that waits + out its timeout and fails with "locator not found" — which reads like a + changed selector rather than a proxy that is not carrying the request. So + the proxy the browser was actually given gets named here. + """ + try: + page.wait_for_selector(sel.SEL_USERNAME, timeout=30_000) + return + except PWTimeout: + pass + shot(page, "no_login_form") + proxy = cfg.proxy_settings() + body = "" + try: + body = (page.inner_text("body") or "").strip().replace("\n", " ")[:200] + except Exception: + pass + raise Restricted( + f"No login form at {cfg.ECW_URL}\n" + f" Browser proxy: {proxy['server'] if proxy else 'NONE — set PROXY_IP/PROXY_PORT'}\n" + f" eCW answers 400 with no form when the request is not proxied.\n" + f" Run tools\\check_net.py, then retry.\n" + f" Page said: {body or '(empty)'}" + ) + + +def _do_login(page: Page, context, client_name: str): + """One full pass at the UI login. True / False / "incognito".""" + proxy = cfg.proxy_settings() + print(f"[LOGIN] {cfg.ECW_URL}") + print(f"[LOGIN] via proxy {proxy['server'] if proxy else '(none)'}") + goto_with_retry(page, cfg.ECW_URL) + run_events.login_step("page_open") + assert_not_restricted(page, "the login page") + _assert_login_page(page) + + page.fill(sel.SEL_USERNAME, cfg.ECW_USERNAME) + page.click(sel.SEL_NEXT) + assert_not_restricted(page, "the username step") + + # Catch username-step errors ("User is Inactive") before waiting two + # minutes for a password box that will never appear. + page.wait_for_timeout(1_500) + try: + err = page.locator(sel.SEL_USERNAME_ERR).first + if err.is_visible(timeout=1_000): + text = err.inner_text().strip() + if text: + shot(page, "username_error") + raise RuntimeError(f"Username step rejected: {text}") + except RuntimeError: + raise + except Exception: + pass + + # Some logins interpose a security-image confirmation page. + try: + if page.locator("text=VERIFY YOUR SECURITY IMAGE").first.is_visible(timeout=3_000): + print("[LOGIN] Security image page") + msgbox.info_box( + f"eCW Bot — Security Image ({client_name})", + "The 'VERIFY YOUR SECURITY IMAGE' page came up.\n" + "Click Continue/OK in the browser.\n\n" + "Click OK once the password page appears.", + needs_person=True, + ) + except Exception: + pass + + page.wait_for_selector(sel.SEL_PASSWORD, timeout=cfg.LOGIN_TIMEOUT) + # A renamed password field or Log In button still works through its + # fallback (selectors/login.py); this says so in the log. + fallback.report(page, ["Password field", "Log In button"], + FALLBACK_CHAINS) + # Typed, not filled: a filled password left Log In doing nothing + # (2026-10-20, see password_entry.py). + type_password(page, cfg.ECW_PASSWORD) + run_events.login_step("password") + + result = handle_captcha(page, client_name, cfg.ECW_PASSWORD) + if result == "incognito": + return "incognito" + if not result: + return False + + # Stamped before the click so the mailbox poll ignores any older code. + clicked_at = datetime.now(timezone.utc) + if not visible(page, sel.SEL_HOME): + page.click(sel.SEL_LOGIN_BTN) + # This click is what makes eCW mail the approval link. + run_events.login_step("twofa_requested") + + try: + page.wait_for_selector( + f"{sel.SEL_HOME}, {sel.SEL_OTP_INPUT}, {sel.SEL_PUSH_NOTICE}, " + f"{sel.SEL_ALT_TFA}, {sel.SEL_CAPTCHA_INPUT}", + timeout=15_000, + ) + except PWTimeout: + pass + + assert_not_restricted(page, "the password step") + handle_2fa(page, context, client_name, clicked_at) + + # The 2FA screen can die between handle_2fa returning and the home screen + # appearing — it is polling once a second and gives up on its own clock. + # Without this the run waits out the full LOGIN_TIMEOUT for a home screen + # that is never coming, then reports "Login not confirmed", which reads + # like a wrong password. + assert_alive(page, "the step after 2FA") + + # Post-login interstitials (alerts, "what's new" panels). + for one in ("input[value='Cancel']", "button.clsMyButton"): + try: + page.locator(one).first.click(timeout=3_000) + except PWTimeout: + pass + except Exception: + pass + + if wait_for_home(page, cfg.LOGIN_TIMEOUT): + page.wait_for_timeout(2_000) + print("[LOGIN] Login confirmed") + run_events.login_step("home") + shot(page, "logged_in") + return True + print("[LOGIN] Login not confirmed") + shot(page, "login_unconfirmed") + return False + + +# Finds the password-expiry box by its text and tags its Cancel button so +# Playwright can click it for real (a JS el.click() would skip pointer events +# some eCW handlers listen for). Walks up from the text to the nearest +# ancestor that also holds a visible control labelled exactly "Cancel" — the +# box's own Cancel, never a Cancel somewhere else on the page. Returns +# {"days": "14" | null, "expired": bool, "cancel": bool}, or null when no such +# text is on screen. +_PASSWORD_PROMPT_JS = r""" +([expiresPattern, expiredPattern]) => { + const expires = new RegExp(expiresPattern, 'i'); + const expired = new RegExp(expiredPattern, 'i'); + const shown = el => { + if (!el || !el.getClientRects().length) return false; + const cs = getComputedStyle(el); + return cs.visibility !== 'hidden' && cs.display !== 'none'; + }; + const label = el => ((el.tagName === 'INPUT' ? el.value : el.textContent) + || '').replace(/\s+/g, ' ').trim().toLowerCase(); + const walker = document.createTreeWalker(document.body, NodeFilter.SHOW_TEXT); + let node; + while ((node = walker.nextNode())) { + const text = node.textContent.replace(/\s+/g, ' '); + const m = text.match(expires); + const gone = !m && expired.test(text); + if (!m && !gone) continue; + if (!shown(node.parentElement)) continue; + if (gone) return {days: null, expired: true, cancel: false}; + document.querySelectorAll('[data-ecwbot-pw-cancel]') + .forEach(e => e.removeAttribute('data-ecwbot-pw-cancel')); + for (let box = node.parentElement; box && box !== document.body; + box = box.parentElement) { + const cancel = [...box.querySelectorAll( + 'button, input[type="button"], input[type="submit"], a')] + .find(b => shown(b) && label(b) === 'cancel'); + if (cancel) { + cancel.setAttribute('data-ecwbot-pw-cancel', '1'); + return {days: m[1], expired: false, cancel: true}; + } + } + return {days: m[1], expired: false, cancel: false}; + } + return null; +} +""" + + +def dismiss_password_expiry_prompt(page: Page): + """ + Answer eCW's "This password expires in N days. Do you want to change the + password now?" box with **Cancel** (not now), if it is on screen. + + Seen live 2026-10-05 (Screenshots\\10-05-2026\\*_login_unconfirmed.png): + the box comes up after the 2FA approval and the home screen waits behind + it, so both runs that day timed out as "Login not confirmed". The generic + interstitial clicks above (`input[value='Cancel']`, `button.clsMyButton`) + do not match its button. + + Cancel only changes nothing: OK would open eCW's change-password screen, + and the bot must never set a password. The warning is logged so a person + changes it — once it has actually expired, eCW will not let Cancel through + and every run fails at login (raised as a RuntimeError here, with that + reason, instead of a two-minute "Login not confirmed"). + + Returns None when no such box is on screen, "cancelled" when it was + answered, "stuck" when it is there but Cancel could not be clicked. + """ + try: + found = page.evaluate(_PASSWORD_PROMPT_JS, + [sel.TXT_PASSWORD_EXPIRES, sel.TXT_PASSWORD_EXPIRED]) + except Exception: + return None + if not found: + return None + if found.get("expired"): + shot(page, "password_expired") + raise RuntimeError( + "eCW says the password has expired — change it in eCW by hand, " + "then update ECW_PASSWORD in .env. The bot never sets a password.") + days = found.get("days") or "?" + print(f"[LOGIN] eCW: the password expires in {days} day(s) — change it in " + f"eCW by hand soon, then update ECW_PASSWORD in .env") + if not found.get("cancel"): + print("[LOGIN] Password-expiry box has no Cancel button the bot can " + "find — answer it by hand") + shot(page, "password_expiry_no_cancel") + return "stuck" + try: + page.locator("[data-ecwbot-pw-cancel]").first.click(timeout=5_000) + except Exception as exc: + print(f"[LOGIN] Could not click Cancel on the password-expiry box: " + f"{str(exc).splitlines()[0]}") + return "stuck" + print("[LOGIN] Answered the password-expiry box with Cancel (not now)") + return "cancelled" + + +def wait_for_home(page: Page, timeout_ms: int) -> bool: + """ + Wait for the home screen, answering the password-expiry box with Cancel + whenever it shows up (see `dismiss_password_expiry_prompt`). Polls + rather than one `wait_for_selector`, because the box can appear at any + point after the 2FA approval and the home screen only renders once it is + gone. + """ + deadline = datetime.now().timestamp() + timeout_ms / 1000 + # A box that will not go away would otherwise be clicked (and logged) + # every half second until the deadline. + answers = 0 + while True: + if visible(page, sel.SEL_HOME): + return True + if answers < 3 and dismiss_password_expiry_prompt(page): # None = no box + answers += 1 + if datetime.now().timestamp() >= deadline: + return False + page.wait_for_timeout(500) + + +def login(pw, client_name: str = "Salud"): + """ + Log in and return (browser, context, page). The browser is left OPEN — + the caller owns closing it. + """ + browser, context, page = launch(pw) + try: + ok = _do_login(page, context, client_name) + + if ok == "incognito": + print("[LOGIN] Captcha attempts exhausted — retrying incognito") + browser.close() + browser, context, page = launch(pw, incognito=True) + ok = _do_login(page, context, client_name) + + if ok == "incognito" or not ok: + if not msgbox.ok_cancel( + f"eCW Bot — CAPTCHA final ({client_name})", + "CAPTCHA failed even in incognito!\n\n" + "Type the CAPTCHA in the browser and log in.\n\n" + "Click OK once logged in, or Cancel to give up.", + ): + raise RuntimeError("CAPTCHA not solved") + ok = True + + if not ok: + raise RuntimeError("Login failed") + except Exception: + try: + browser.close() + except Exception: + pass + raise + + # eCW drops the URL fragment during the login redirect, so the deep link to + # the provider schedule has to be re-navigated after login. + if cfg.ECW_LANDING_URL and cfg.ECW_LANDING_URL != cfg.ECW_URL: + print("[LOGIN] Opening landing page") + try: + goto_with_retry(page, cfg.ECW_LANDING_URL) + page.wait_for_timeout(3_000) + shot(page, "landing") + except Exception as exc: + print(f"[LOGIN] Landing page failed: {exc}") + + return browser, context, page diff --git a/src/ecw_bot/auth/mailbox.py b/src/ecw_bot/auth/mailbox.py new file mode 100644 index 0000000..fb6e653 --- /dev/null +++ b/src/ecw_bot/auth/mailbox.py @@ -0,0 +1,375 @@ +""" +Two-factor challenge retrieval for the eCW login. + +eCW's second factor is not one fixed thing, so this module reports WHAT the +challenge is and lets `auth.py` drive the browser accordingly: + + Challenge("totp", "123456") authenticator secret — instant, no mailbox + Challenge("code", "123456") 6-digit code read out of the mailbox + Challenge("link", "https://…/openLoginConfirmation?token=…") + push-approval magic link read out of the + mailbox — open it and click "Yes, it's me" + Challenge("manual", "") a human handles it in the browser + +The Salud account turns out to be the `link` case. Its mail ("Push Notification" +from no-reply@eclinicalmail.com) contains no digits at all — just +"click Yes, it's me to confirm" and a JWT link valid for 10 minutes. An +earlier build of this module assumed a 6-digit code and would have "found" +`000000`, scraped out of the stylesheet's `color: #000000`; both the HTML +stripping and the challenge model are fixed here. + +Roundcube's web UI is deliberately not scraped — its URL carries a `cpsess…` +session token that expires. IMAP is opened readonly, so nothing is marked read. + +That web UI now also demands a cPanel security code on sign-in, which looks like +a blocker but is not: cPanel's Two-Factor Authentication covers the cPanel and +Roundcube *web* interfaces only. IMAP, POP3 and SMTP are exempt — verified live +on this mailbox, whose CAPABILITY advertises AUTH=PLAIN and AUTH=LOGIN and no +2FA mechanism at all. Nothing here needs to change for it, and automating the +Roundcube login is the wrong fix if someone ever sees that screen. +""" + +import re +import ssl +import time +import email +import imaplib +from collections import namedtuple +from datetime import datetime, timezone, timedelta +from email.header import decode_header, make_header + +from ..core import msgbox as ui +from ..core import run_events +from .. import settings as cfg + + +Challenge = namedtuple("Challenge", "kind value") + +MANUAL = Challenge("manual", "") + + +# ============================================================================= +# HTML → text +# ============================================================================= + +# <style> and <script> bodies survive a naive tag strip, and an eCW mail carries +# ~16KB of CSS. That CSS is full of things that look like a 6-digit code +# (`#000000`, `#F1F1F1`), so these blocks have to go before any digit hunting. +_DROP_BLOCKS = re.compile(r"(?is)<(style|script|head)[^>]*>.*?</\1>") +_TAGS = re.compile(r"<[^>]+>") +_ENTITIES = { + " ": " ", "&": "&", "<": "<", ">": ">", + """: '"', "'": "'", "’": "'", "‘": "'", +} + + +def _html_to_text(html: str) -> str: + text = _DROP_BLOCKS.sub(" ", html) + text = _TAGS.sub(" ", text) + for ent, ch in _ENTITIES.items(): + text = text.replace(ent, ch) + return re.sub(r"\s+", " ", text).strip() + + +# ============================================================================= +# Code extraction (the `code` challenge) +# ============================================================================= + +# eCW words the mail differently across builds ("verification code", "one time +# passcode", "security code"), so anchor on a standalone 6-digit run that sits +# next to code-ish wording. The bare-6-digits fallback is deliberately NOT used +# any more: on a push-notification mail it only ever matched CSS noise. +_CODE_NEAR = re.compile( + r"(?:code|passcode|otp|pin)\D{0,40}?(\d{6})" + r"|(\d{6})\D{0,40}?(?:is your|is the|code)", + re.IGNORECASE, +) + + +def _extract_code(text: str) -> str: + if not text: + return "" + m = _CODE_NEAR.search(text) + return next((g for g in m.groups() if g), "") if m else "" + + +# ============================================================================= +# Link extraction (the `link` challenge) +# ============================================================================= + +# The approval endpoint eCW mails out. Matched on the path so a change of +# cloud host does not need a code change. +_APPROVE_HREF = re.compile( + r'href=["\']([^"\']*openLoginConfirmation[^"\']*)["\']', re.IGNORECASE) +_APPROVE_BARE = re.compile(r'(https?://\S*openLoginConfirmation\S*)', re.IGNORECASE) + + +def _extract_link(html: str) -> str: + m = _APPROVE_HREF.search(html) or _APPROVE_BARE.search(html) + if not m: + return "" + url = m.group(1).strip() + for ent, ch in (("&", "&"), ("&", "&")): + url = url.replace(ent, ch) + return url + + +# ============================================================================= +# Mailbox +# ============================================================================= + +def _connect(): + if cfg.IMAP_SSL: + return imaplib.IMAP4_SSL(cfg.IMAP_HOST, cfg.IMAP_PORT, + ssl_context=ssl.create_default_context()) + conn = imaplib.IMAP4(cfg.IMAP_HOST, cfg.IMAP_PORT) + conn.starttls(ssl_context=ssl.create_default_context()) + return conn + + +def _subject(msg) -> str: + try: + return str(make_header(decode_header(msg.get("Subject") or ""))) + except Exception: + return str(msg.get("Subject") or "") + + +def _parts(msg): + """Yield (content_type, decoded_text) for every text part, plain first.""" + out = [] + for part in (msg.walk() if msg.is_multipart() else [msg]): + if part.get_content_maintype() != "text": + continue + if "attachment" in str(part.get("Content-Disposition") or ""): + continue + try: + raw = part.get_payload(decode=True) or b"" + out.append((part.get_content_type(), + raw.decode(part.get_content_charset() or "utf-8", + errors="replace"))) + except Exception: + pass + out.sort(key=lambda p: p[0] != "text/plain") + return out + + +def _received_at(msg): + """When eCW *generated* the mail, from its Date header. + + Not when it arrived. The two differ by minutes on this mailbox — see + `_delivered_at` — but generation time is the right cutoff, because it is + also when the link's ten-minute JWT started counting down. + """ + try: + dt = email.utils.parsedate_to_datetime(msg.get("Date")) + except Exception: + return None + if dt is None: + return None + return dt.replace(tzinfo=timezone.utc) if dt.tzinfo is None else dt + + +def _delivered_at(raw_internaldate): + """When the mail actually landed in the mailbox, from IMAP's INTERNALDATE. + + Logged rather than used as a cutoff. It is the only way to see the lag + between eCW sending and the server delivering, and that lag is the reason + runs used to give up on a mail that was still in flight. + """ + try: + tup = imaplib.Internaldate2tuple(raw_internaldate) + return datetime.fromtimestamp(time.mktime(tup), timezone.utc) + except Exception: + return None + + +def _challenge_from(msg) -> Challenge: + """Read a message and decide whether it carries a link or a code.""" + subject = _subject(msg) + for ctype, body in _parts(msg): + # The link is looked for in the RAW part — stripping tags would take + # the href with it. + link = _extract_link(body) + if link: + return Challenge("link", link) + text = body if ctype == "text/plain" else _html_to_text(body) + code = _extract_code(f"{subject}\n{text}") + if code: + return Challenge("code", code) + return MANUAL + + +def _poll_mailbox(since: datetime, wait_seconds: int = None) -> Challenge: + """ + Poll until an eCW two-factor mail newer than `since` turns up. + + Only mail that arrived after the login button was clicked counts. A stale + link from an earlier run is already expired (the JWT is good for ten + minutes) and the portal would reject it — which looks exactly like a bad + password, so it is worth being strict here. + """ + deadline = time.time() + (wait_seconds or cfg.OTP_WAIT_SECONDS) + # IMAP SINCE is date-only, so it is just a prefilter; the Date header check + # below is the real cutoff. + search_since = (since - timedelta(days=1)).strftime("%d-%b-%Y") + attempt = 0 + # The "nothing yet" line is throttled to this. A poll cycle takes ~11s, so + # an unthrottled ten-minute window writes ~55 identical lines and buries the + # lines that matter. Errors are never throttled. + say_every = 60 + said_at = 0.0 + started = time.time() + print(f"[OTP] Waiting up to {int((deadline - started) / 60)} min for the " + f"mail — it has been seen taking ten") + + while time.time() < deadline: + attempt += 1 + conn = None + try: + conn = _connect() + conn.login(cfg.IMAP_USERNAME, cfg.IMAP_PASSWORD) + conn.select(cfg.IMAP_FOLDER, readonly=True) + typ, data = conn.search(None, "SINCE", search_since) + ids = data[0].split() if typ == "OK" and data and data[0] else [] + + # Most-recently-DELIVERED first, which is what descending sequence + # number means. Deliberately not sorted by Date: delivery here runs + # out of order, so the highest sequence number is the freshest thing + # the mailbox actually holds. + for num in reversed(ids[-40:]): + typ, raw = conn.fetch(num, "(INTERNALDATE RFC822)") + if typ != "OK" or not raw or not raw[0]: + continue + msg = email.message_from_bytes(raw[0][1]) + + received = _received_at(msg) + if received is None or received < since: + continue + + ch = _challenge_from(msg) + if ch.kind == "manual": + continue + + # Make the send-to-deliver lag visible. It is the whole reason + # this poll has to be patient, and without it in the log the + # next person guesses at "eCW didn't send". + lag = "" + delivered = _delivered_at(raw[0][0]) + if delivered: + lag = (f", delivered {delivered:%H:%M:%S} " + f"— {(delivered - received).total_seconds():.0f}s later") + + # Old but still used. When the mail takes ten minutes and the + # JWT lasts about ten, a link can arrive nearly expired — + # clicking it is the only chance of it working, and refusing is + # a guaranteed failure. The age is logged so that eCW's + # "verification link has expired" has a visible cause. + age = (datetime.now(timezone.utc) - received).total_seconds() + if age > cfg.OTP_LINK_AGE_WARN_SECONDS: + print(f"[OTP] WARNING: this challenge is {age / 60:.1f} min " + f"old — at eCW's ~10 min validity it may already be " + f"expired. Trying it anyway") + + # The kind only: never the code. + run_events.login_step("mail_received", mail_kind=ch.kind) + if ch.kind == "link": + print(f"[OTP] Approval link in {_subject(msg)!r} " + f"(sent {received:%H:%M:%S}{lag})") + else: + print(f"[OTP] Code {ch.value} in {_subject(msg)!r} " + f"(sent {received:%H:%M:%S}{lag})") + return ch + except Exception as exc: + print(f"[OTP] IMAP poll {attempt} failed: {exc}") + finally: + if conn is not None: + try: + conn.logout() + except Exception: + pass + + remaining = int(deadline - time.time()) + if remaining <= 0: + break + now = time.time() + if now - said_at >= say_every: + said_at = now + print(f"[OTP] Nothing yet — {int(now - started)}s waited, " + f"{remaining}s left") + time.sleep(min(cfg.OTP_POLL_SECONDS, max(remaining, 1))) + + return MANUAL + + +# ============================================================================= +# Manual +# ============================================================================= + +def _manual(client_name: str, what: str) -> Challenge: + """ + Hand the step to a human. Raises if they cancel or if nobody answers; + otherwise returns MANUAL, meaning "already dealt with in the browser". + """ + result = ui.confirm( + f"eCW Bot — 2FA ({client_name})", + f"{what}\n\n" + f"Complete the step in the browser window.\n\n" + f"Click OK once done.\n" + f"Click Cancel to skip.", + cfg.MSGBOX_TIMEOUT_SECONDS, + ) + if result == ui.TIMEOUT: + # Nobody at the desk. Failing here is the point: the previous build sat + # on a modal for forty minutes holding a browser open, logging nothing. + raise RuntimeError( + f"2FA needed a human and nobody answered within " + f"{cfg.MSGBOX_TIMEOUT_SECONDS}s") + if result != ui.OK: + raise RuntimeError("2FA cancelled by operator") + return MANUAL + + +# ============================================================================= +# Public entry point +# ============================================================================= + +def get_challenge(since: datetime = None, client_name: str = "eCW", + allow_manual: bool = True, + wait_seconds: int = None) -> Challenge: + """ + Return the two-factor challenge for this login attempt. + + `since` marks when the login button was clicked; anything older in the + mailbox is ignored. + + `allow_manual=False` returns MANUAL instead of prompting a human, so the + caller can do something about an empty mailbox first — `auth.py` uses it to + press eCW's "Resend OTP" and poll again. `wait_seconds` shortens that + intermediate poll; the caller's last attempt should use the full window. + """ + mode = cfg.resolved_otp_mode() + since = since or datetime.now(timezone.utc) + print(f"[OTP] mode={mode}") + + if mode == "totp": + import pyotp + return Challenge("totp", pyotp.TOTP(cfg.ECW_SECRET_KEY).now()) + + if mode == "mail": + ch = _poll_mailbox(since, wait_seconds) + if ch.kind != "manual": + return ch + if not allow_manual: + return MANUAL + print("[OTP] Mailbox produced nothing usable — falling back to manual") + return _manual(client_name, + "The 2FA mail did not arrive, or could not be read.") + + if not allow_manual: + return MANUAL + return _manual(client_name, "eCW is asking for 2FA.") + + +# Exposed so `auth.py` can hand the step to a human after its own retries, +# without re-entering the mailbox poll for another full window. +manual = _manual diff --git a/src/ecw_bot/auth/password_entry.py b/src/ecw_bot/auth/password_entry.py new file mode 100644 index 0000000..67dcf37 --- /dev/null +++ b/src/ecw_bot/auth/password_entry.py @@ -0,0 +1,24 @@ +""" +Putting the password into eCW's login page. + +Typed key by key, not set with `page.fill()`. Found 2026-10-20: after the +password change, a filled password left the Log In click doing nothing — no +request reached eCW, the button stayed grey and the run timed out as "Login +not confirmed". The same password typed key by key was sent at once and +eCW moved on to its 2FA page (network log of the diagnostic run). The login +page evidently reads the field from its key events, which `fill()` does not +send. The password is never logged. +""" + +from ..selectors import login as sel + +# Milliseconds between keys: close to a person typing, quick enough that a +# 16-character password takes about a second. +KEY_DELAY_MS = 60 + + +def type_password(page, password: str) -> None: + field = page.locator(sel.SEL_PASSWORD).first + field.click() + field.fill("") + field.press_sequentially(password, delay=KEY_DELAY_MS) diff --git a/src/ecw_bot/auth/restricted.py b/src/ecw_bot/auth/restricted.py new file mode 100644 index 0000000..f23ddc2 --- /dev/null +++ b/src/ecw_bot/auth/restricted.py @@ -0,0 +1,51 @@ +""" +eCW's refusal page, and the exception for it. + +Its own module because both `login.py` and `twofactor.py` check for it, and +putting it in either would have made them import each other. +""" + +from playwright.sync_api import Page + +from .. import settings as cfg +from ..core import guard +from ..core.page_utils import shot +from ..selectors import login as sel + + +class Restricted(RuntimeError): + """eCW is refusing the account outright — retrying makes it worse.""" + + +def assert_not_restricted(page: Page, where: str) -> None: + """ + Bail out the moment eCW shows "Access to the application has been restricted." + + This is an IP check, not a rate limit — the same URL answers 400 unproxied + and 200 through the proxy — so the first thing to suspect is that this + browser is not going through the proxy after all. The message says so, + because the obvious reading ("the account is locked, wait it out") sends you + looking in the wrong place. + + Detecting it at all matters because the portal carries on rendering the + normal 2FA screen and still claims it emailed an approval link while sending + nothing. Without this the run polls an empty mailbox for two minutes and + then blames the mailbox. + """ + try: + if not page.locator(f"text={sel.TXT_RESTRICTED}").first.is_visible(timeout=1_000): + return + except Exception: + return + shot(page, "restricted") + guard.record_restricted() + proxy = cfg.proxy_settings() + raise Restricted( + f"eCW refused the request at {where} " + f"(\"Access to the application has been restricted\").\n" + f" This is an IP check. Most likely the browser is not on the proxy.\n" + f" Configured proxy: {proxy['server'] if proxy else 'NONE — that is the bug'}\n" + f" Run tools\\check_net.py to confirm the proxy still reaches eCW.\n" + f" If the proxy is fine, the account itself is restricted: stop " + f"retrying and ask the eCW administrator." + ) diff --git a/src/ecw_bot/auth/twofactor.py b/src/ecw_bot/auth/twofactor.py new file mode 100644 index 0000000..4441415 --- /dev/null +++ b/src/ecw_bot/auth/twofactor.py @@ -0,0 +1,241 @@ +""" +The second factor. + +Two shapes are possible, and **the mail decides which, not the page**: a +6-digit code typed into `#OTPCode`, or a push approval where the login page +simply waits while someone clicks a link that was emailed. The Salud account is +the push case — `no-reply@eclinicalmail.com` sends a "Push Notification" whose +body contains no digits at all, only an `openLoginConfirmation?token=<JWT>` +link that has to be opened and confirmed with "Yes, it's me". + +The link is opened in a **throwaway tab**, never the login page: that page is +mid-handshake and polling for the approval, so navigating it away kills the +very session the link is meant to confirm. +""" + +from datetime import datetime, timezone + +from playwright.sync_api import Page, TimeoutError as PWTimeout + +from .. import settings as cfg +from ..core.page_utils import shot, visible, goto_with_retry, auto_dialog +from ..selectors import login as sel +from . import mailbox +from .dead_end import LoginTimedOut, assert_alive, reason as dead_end_reason +from .restricted import assert_not_restricted +from ..core import run_events + + +def _approve_in_new_tab(context, url: str) -> bool: + """ + Open the emailed approval link in a throwaway tab and click "Yes, it's me". + + The link is a JWT good for about ten minutes, so this has to happen + promptly — and the mail itself has been seen taking ten minutes to arrive, + which is why `mailbox.py` warns about a link's age rather than refusing it. + """ + tab = context.new_page() + tab.on("dialog", auto_dialog) + try: + goto_with_retry(tab, url) + tab.wait_for_timeout(1_500) + shot(tab, "approval_page") + assert_not_restricted(tab, "the approval page") + + try: + btn = tab.locator(sel.SEL_APPROVE_YES).first + btn.wait_for(state="visible", timeout=20_000) + btn.click() + print("[2FA] Clicked confirm on the approval page") + run_events.login_step("approval_clicked") + except Exception as exc: + print(f"[2FA] Confirm button not found ({exc})") + shot(tab, "approval_no_button") + return False + + tab.wait_for_timeout(3_000) + shot(tab, "approval_done") + return True + finally: + try: + tab.close() + except Exception: + pass + + +def _switch_to_app_auth(page: Page) -> None: + """ + Swap the emailed factor for the authenticator app, when there is a secret + to use. Guarded on the OTP box NOT being visible: #altTFAMsg also exists on + the OTP page itself, and clicking it there navigates away and breaks it. + """ + try: + alt = page.locator(sel.SEL_ALT_TFA).first + if not alt.is_visible(timeout=5_000): + return + print("[2FA] Switching to app-based auth (TOTP)") + alt.click() + page.wait_for_timeout(2_000) + send = page.locator(sel.SEL_ALT_TFA_SEND).first + if send.is_visible(timeout=5_000): + send.click() + page.wait_for_timeout(2_000) + except Exception: + pass + + +def _resend_push(page: Page) -> bool: + """ + Press "Resend OTP". False when the button isn't there, or is disabled. + + The enabled check is not defensive padding: eCW ships this button + `disabled="disabled"` and only `setReSendOtpMsg()` enables it. It stays + disabled on the timed-out page and permanently once `leftOtpCount` reaches + 0 — three resends per login. Clicking a disabled button just burns the + 30-second locator timeout and reports a failure that reads like a bad + selector. + """ + try: + btn = page.locator(sel.SEL_RESEND_OTP).first + if not btn.is_visible(timeout=5_000): + print("[2FA] No Resend button on this page") + return False + if not btn.is_enabled(timeout=2_000): + print("[2FA] Resend button is disabled — eCW allows three per " + "login and does not re-enable it once the page has died") + return False + btn.click() + page.wait_for_timeout(2_000) + return True + except Exception as exc: + print(f"[2FA] Resend click failed: {str(exc).splitlines()[0]}") + return False + + +def _challenge_with_resend(page: Page, client_name: str, + clicked_at: datetime): + """ + Get the challenge, pressing "Resend OTP" when the mailbox stays empty. + + Resend **late**, not early. The mail is delayed rather than missing — on + 2026-09-02 a message eCW stamped 11:19:10 was delivered *after* one stamped + 11:26:10 — so before ten minutes have passed "still in flight" is the + likeliest explanation, and a second message would only join the same slow + queue behind the first. An earlier version of this function resent early + and made things worse by shortening the first poll from 120s to 60s. + + Still unproven in a live run: on 2026-09-04 this branch was reached and + `SEL_RESEND_OTP` matched nothing, so the button on that screen has never + actually been pressed by the bot. + """ + since = clicked_at + last = cfg.OTP_RESEND_ATTEMPTS + + for attempt in range(last + 1): + # Checked BEFORE the poll, not only after. If eCW has already given up + # there is nothing to wait for, and this is what stops the bot spending + # its full ten-minute window on a page that stopped polling in the + # first thirty seconds. + assert_alive(page, "the 2FA screen before polling the mailbox") + + # The final attempt gets the full window; earlier ones stop sooner so + # there is still time to resend afterwards. + window = None if attempt == last else cfg.OTP_RESEND_WAIT_SECONDS + ch = mailbox.get_challenge(since=since, client_name=client_name, + allow_manual=False, wait_seconds=window) + if ch.kind != "manual": + return ch + + # The window closed with nothing in the mailbox. Before deciding that + # is a delivery delay, check whether the page died while we waited. + assert_alive(page, "the 2FA screen after an empty mailbox window") + + if attempt == last: + break + + # The page must still be the 2FA screen. If eCW moved on by itself + # (approval picked up elsewhere, session already trusted), resending + # would restart the challenge we just cleared. + if visible(page, sel.SEL_HOME): + print("[2FA] Page moved on by itself — no resend needed") + return ch + assert_not_restricted(page, "the 2FA screen before resending") + + # Waiting this long is a real risk to eCW's own login session: back at + # the username box means it dropped us, and a resend would go nowhere. + # Fail with the actual reason rather than after another silent window. + if visible(page, sel.SEL_USERNAME, timeout=2_000): + shot(page, "2fa_session_lost") + raise RuntimeError( + "eCW dropped back to the login form while waiting for the " + "approval mail — its login session expired before the mail " + "arrived. Lower OTP_RESEND_WAIT_SECONDS so the wait is shorter " + "than eCW's own timeout.") + + if not _resend_push(page): + print("[2FA] Nothing arrived and there is no Resend button to press") + break + # Anything mailed before this click is a stale JWT — the cutoff moves. + since = datetime.now(timezone.utc) + print(f"[2FA] Resend {attempt + 1}/{last} clicked — polling again") + + shot(page, "2fa_no_mail") + return mailbox.manual( + client_name, + f"No eCW 2FA mail arrived (tried {cfg.OTP_RESEND_ATTEMPTS} resend(s)).") + + +def handle_2fa(page: Page, context, client_name: str, + clicked_at: datetime) -> None: + """Deal with whatever second factor eCW shows. No-op when there isn't one.""" + if visible(page, sel.SEL_HOME): + return + + if cfg.ECW_SECRET_KEY and not visible(page, sel.SEL_OTP_INPUT): + _switch_to_app_auth(page) + + has_input = visible(page, sel.SEL_OTP_INPUT, timeout=5_000) + has_notice = visible(page, sel.SEL_PUSH_NOTICE, timeout=2_000) or \ + visible(page, sel.SEL_ALT_TFA, timeout=1_000) + if not (has_input or has_notice): + print("[2FA] No second factor prompted") + return + + shot(page, "2fa_prompt") + assert_not_restricted(page, "the 2FA screen") + ch = _challenge_with_resend(page, client_name, clicked_at) + + if ch.kind == "link": + if not _approve_in_new_tab(context, ch.value): + raise RuntimeError("Approval link opened but could not be confirmed") + # eCW's login page polls for the approval; it moves on by itself. But + # it may already have stopped polling while the mail was in flight — + # the link is then spent and confirming it changed nothing, so say that + # rather than letting the caller wait out LOGIN_TIMEOUT for a home + # screen that is never coming. + page.wait_for_timeout(2_000) + why = dead_end_reason(page) + if why: + shot(page, "2fa_timed_out") + raise LoginTimedOut( + f"The approval was confirmed, but the login page had already " + f"given up: {why}.\n" + f" The mail arrived too late to be useful. Starting over.") + print("[2FA] Waiting for the login page to pick up the approval") + return + + if ch.kind in ("code", "totp"): + if not has_input: + # A code arrived but the page never showed the box — wait a beat, + # it can lag the mail. + try: + page.wait_for_selector(sel.SEL_OTP_INPUT, timeout=15_000) + except PWTimeout: + raise RuntimeError(f"Got a {ch.kind} code but no #OTPCode box appeared") + page.fill(sel.SEL_OTP_INPUT, ch.value) + page.click(sel.SEL_OTP_SUBMIT) + print("[2FA] Code submitted") + return + + # manual — the operator already handled it in the browser. + print("[2FA] Handled manually") diff --git a/src/ecw_bot/claim_file.py b/src/ecw_bot/claim_file.py new file mode 100644 index 0000000..d197244 --- /dev/null +++ b/src/ecw_bot/claim_file.py @@ -0,0 +1,221 @@ +""" +The claim report on disk: find it, and pull the claim ids out of it. + +This is the seam between the two halves of the process. The export phase writes +`Input\\Claim Report_<MM-DD-YYYY>.xlsx`; the charge-entry phase walks the claim +ids in it one at a time. Neither should know how the other stores things, so the +file's shape is understood here and nowhere else. + +The header is **`CLAIMS#`**, read off the real file eCW produced on 2026-09-07 — +not `CLAIM #`, which is what the on-screen grid calls the same column. Guessing +from the screen would have produced a column that is never found. Its position +(column C, third) is deliberately *not* relied on: eCW's report has 18 columns +including a blank column A, and a report layout change would shift them. +""" + +from pathlib import Path + +from . import settings as cfg +from .core import spreadsheet + +# Header spellings that mean "the claim id", lowercased and stripped of spaces +# and punctuation. `CLAIMS#` is the one eCW actually writes; the rest are near +# misses seen on the screen or plausible on another build. +_HEADERS = ("claims", "claim", "claimno", "claimid", "claimnumber", "clm") + +# A claim id as eCW writes it: 7 digits today (9111000), allowed 5-10 so a +# practice with a different id range is not silently rejected. +_LOOKS_LIKE_ID = ("claim id", 5, 10) + + +def _norm(text: str) -> str: + return "".join(ch for ch in (text or "").lower() if ch.isalnum()) + + +def latest_report() -> Path: + """ + The one current report in `Input\\`. + + There is normally exactly one: the export archives every earlier report + into `Input\\Archive\\` before saving the new one, so "which file is + current?" has a structural answer rather than a naming convention to + remember. Picked by modification time all the same - see the comment on + why sorting by name is the wrong tool here. + + A report from an earlier day is still used if today's export has not run: + export and charge entry need not happen in the same session, and refusing + to work because the clock rolled past midnight would be an unhelpful kind + of correct. It is called out in the log so it is never a surprise. + """ + if not cfg.INPUTS.exists(): + raise FileNotFoundError( + f"{cfg.INPUTS} does not exist — the export step has not run yet") + + # Reports only. `Archive\` is a directory so `iterdir` + `is_file` skips it, + # and `rescued_*` is excluded by the name check: an orphan came from a run + # that died, so it may be truncated or from a different filter set, and it + # must never be mistaken for the current report. + found = [f for f in cfg.INPUTS.iterdir() + if f.is_file() and f.name.startswith(cfg.CLAIM_REPORT_NAME)] + if not found: + raise FileNotFoundError( + f"No '{cfg.CLAIM_REPORT_NAME}_*' file in {cfg.INPUTS}. The export " + f"step writes it; run the export first. (Earlier runs' reports are " + f"in {cfg.INPUTS_ARCHIVE.name}\\ — this looks only at the current " + f"one.)") + + # Newest by modification time, not by name. Sorting names is what would + # bite: `Claim Report_09-09-2026 (2).xlsx` sorts BEFORE + # `Claim Report_09-09-2026.xlsx`, because a space (0x20) is lower than a + # dot (0x2e) — so an alphabetical "first" can be the older file. + newest = max(found, key=lambda f: f.stat().st_mtime) + + # With archiving on there should only ever be one. If there are more, + # something left a file behind and the choice has become a guess — say so + # rather than silently picking one. + if len(found) > 1: + print(f"[FILE] WARNING: {len(found)} reports in {cfg.INPUTS.name}\\ " + f"when there should be one. Using the newest, {newest.name}. " + f"The others were not archived: " + f"{', '.join(f.name for f in found if f != newest)}") + if not newest.name.startswith(f"{cfg.CLAIM_REPORT_NAME}_{cfg.TODAY}"): + print(f"[FILE] Note: {newest.name} is not today's ({cfg.TODAY}) — " + f"the export step has not run today") + return newest + + +def _find_column(rows: list) -> tuple: + """ + (header row index, column index) for the claim id. + + Two passes, in this order. The header match is tried first because it is + unambiguous; the shape match is the fallback for a report with no header + row at all, and it is checked against several rows rather than one so a + stray number in a date or charge column cannot win. + """ + for r, row in enumerate(rows[:10]): + for c, cell in enumerate(row): + if _norm(cell) in _HEADERS: + return r, c + + _, lo, hi = _LOOKS_LIKE_ID + width = max((len(r) for r in rows[:20]), default=0) + for c in range(width): + sample = [(r[c] if c < len(r) else "") for r in rows[:20]] + digits = [s for s in sample if s.isdigit() and lo <= len(s) <= hi] + if len(digits) >= 5: + print(f"[FILE] No claim-id header found; column {c + 1} holds " + f"{len(digits)} id-shaped values, using that") + return -1, c + + raise ValueError( + f"No claim-id column in the report. Looked for a header in " + f"{list(_HEADERS)} and for a column of {lo}-{hi} digit numbers. " + f"First row reads: {rows[0] if rows else '(empty file)'}") + + +def claim_ids(path=None) -> dict: + """ + Read the claim ids out of the report. + + Returns everything the caller needs to *report* on what was read, not just + the list: a count with no sight of the duplicates or the skipped rows is + how a short list gets mistaken for a complete one. + + {"file": Path, "header": str, "column": int, "ids": [...], + "duplicates": [...], "blank_rows": int, "total_rows": int} + + `ids` is de-duplicated and keeps the report's own order — the queue is + worked top to bottom, and eCW can list the same claim twice when a claim + has more than one line item. + """ + path = Path(path) if path else latest_report() + rows = spreadsheet.read_rows(path) + if not rows: + raise ValueError(f"{path.name} has no rows at all") + + header_row, col = _find_column(rows) + header = rows[header_row][col] if header_row >= 0 else f"column {col + 1}" + + ids, seen, duplicates, blank = [], set(), [], 0 + for row in rows[header_row + 1:]: + value = (row[col] if col < len(row) else "").strip() + if not value: + blank += 1 + continue + if value in seen: + duplicates.append(value) + continue + seen.add(value) + ids.append(value) + + return {"file": path, "header": header, "column": col + 1, "ids": ids, + "duplicates": duplicates, "blank_rows": blank, + "total_rows": len(rows) - (header_row + 1)} + + +def summary(info: dict, show: int = 15) -> str: + """ + The claim ids as text for a message box. + + Returns text and shows nothing itself, so it can be tested without popping + a dialog — and so the flow decides whether a box appears at all. + + Only the first and last few ids. A real report holds 541 of them and a + MessageBox is neither scrollable nor selectable, so a full dump would be a + wall of digits nobody can read or copy. The ends are what a person actually + checks against the screen; the full list goes to the log, which *is* + searchable. + + Duplicates and blank rows are named rather than folded into the total. A + count on its own is how a short list gets accepted as a complete one. + """ + ids = info["ids"] + lines = [f"File : {info['file'].name}", + f"Column : {info['header']} (column {info['column']})", + f"Rows : {info['total_rows']:,}", + f"Claim ids : {len(ids):,}"] + + notes = [] + if info["duplicates"]: + notes.append(f"{len(info['duplicates'])} duplicate(s) skipped") + if info["blank_rows"]: + notes.append(f"{info['blank_rows']} blank row(s) skipped") + if notes: + lines.append(" (" + ", ".join(notes) + ")") + + if not ids: + lines.append("") + lines.append("No claim ids — likely means no filter matched " + "anything.") + return "\n".join(lines) + + lines.append("") + lines.append(f"First {min(show, len(ids))}:") + for i in range(0, min(show, len(ids)), 5): + lines.append(" " + " ".join(ids[i:i + 5])) + + if len(ids) > show: + rest = min(5, len(ids) - show) + lines.append("") + lines.append(f"Last {rest}:") + lines.append(" " + " ".join(ids[-rest:])) + + lines.append("") + lines.append(f"Total {len(ids):,} id(s). Full list is in the log.") + return "\n".join(lines) + + +def log_ids(info: dict, per_line: int = 10) -> None: + """ + Write every claim id to the log. + + The message box only shows the ends, so this is the copy that survives — + and it is what makes a run auditable after the fact: which claims were in + scope on a given day, in the order they would be worked. + """ + ids = info["ids"] + print(f"[FILE] {len(ids):,} claim id(s) from {info['file'].name} " + f"(column {info['header']!r}):") + for i in range(0, len(ids), per_line): + print("[FILE] " + " ".join(ids[i:i + per_line])) diff --git a/src/ecw_bot/conditions/__init__.py b/src/ecw_bot/conditions/__init__.py new file mode 100644 index 0000000..c72f588 --- /dev/null +++ b/src/ecw_bot/conditions/__init__.py @@ -0,0 +1,74 @@ +r""" +The Condition registry — every condition the Charge Validation bot applies. + +Add a new condition by writing `conditions/<name>.py` (detect/apply/describe, +see `base.py`'s module docstring for the contract) and adding it here — +between the two halves of the common rule below, not after them. Nothing +else needs to change: `run_all` and `export_medcor_ready.py` both iterate +this list rather than naming any condition directly. + +The operator's common rule for every claim is split in two, and the halves +bracket everything else: + + * `zero_fee_uncheck` stays FIRST: a checked line billed at zero gets + unchecked. Its `apply()` runs on the claim exactly as read, and every + specific condition's `apply()` after it works on a grid that already + follows that rule and has the last word on its own lines. + * `priced_line_check` stays LAST (ruled 2026-09-30): an unchecked line + with a billed fee gets checked. Every specific condition that excludes + a line (J2001/J2930, DCONS/Dscprit, Dental's DPIMP/DFIMP, Medicaid's + 15851/15852/73521, Medicare telehealth's other lines, depression's + duplicates) unchecks AND zeroes it, so by the time this runs those + lines read 0.00 and are left alone. Run any earlier, it would check them and their own + condition would uncheck them straight after — claim 9131000's toggle + between conditions. See that module's docstring. + +Every `detect()` still sees the claim as read (`run_all` detects everything +before applying anything), so a condition that asks "is this code billed?" +must answer for the end of the run. vaccine_admin_codes and +injection_admin_code count lines by `vaccine_admin_codes. +row_counts_as_billed()`: since 2026-09-30 not a checked zero-fee line — +zero_fee_uncheck will have unchecked it — so they agree with the first +half. vaccine_admin_codes does not yet account for the last half (an +unchecked priced admin line that priced_line_check will check): a known +gap, pinned by test_conditions_priced_line_check.py; injection_admin_code +asks `priced_line_check.will_check` for its 96372. dental_payer_check (#13) +and medicare_vaccine_a6 (#19) use `lines.ends_billed()`, which answers for +both halves (review, 2026-10-19). +""" + +from . import bh_h_code_modifier +from . import dental_dummy_codes +from . import dental_payer_check +from . import depression_dx_pointer +from . import dummy_codes +from . import injection_admin_code +from . import j2930_j2001_update +from . import medicaid_pregnancy_th +from . import medicaid_uncheck_cpts +from . import medicare_billing_screening +from . import medicare_pps_code +from . import medicare_telehealth +from . import medicare_vaccine_a6 +from . import medicare_wellness_history +from . import priced_line_check +from . import telehealth +from . import vaccine_admin_codes +from . import zero_fee_uncheck +from .base import run_all + +CONDITIONS = [zero_fee_uncheck, + telehealth, medicare_telehealth, depression_dx_pointer, + j2930_j2001_update, dummy_codes, dental_dummy_codes, + dental_payer_check, medicaid_uncheck_cpts, medicare_billing_screening, + medicare_vaccine_a6, medicare_wellness_history, + medicaid_pregnancy_th, + bh_h_code_modifier, vaccine_admin_codes, injection_admin_code, + medicare_pps_code, + priced_line_check] + +# condition_name -> module, so a caller holding a ConditionResult (which +# only carries the name, not the module) can still reach that condition's +# own describe(). Built from CONDITIONS rather than listed separately, so +# it can never drift out of sync with the registry above. +BY_NAME = {mod.NAME: mod for mod in CONDITIONS} diff --git a/src/ecw_bot/conditions/base.py b/src/ecw_bot/conditions/base.py new file mode 100644 index 0000000..bfc18c6 --- /dev/null +++ b/src/ecw_bot/conditions/base.py @@ -0,0 +1,272 @@ +r""" +The Condition contract, and the runner that applies every registered +condition to one claim. + +See docs\superpowers\specs\2026-09-16-condition-framework-design.md for the +full design. In short: every condition module exposes a `NAME` string, plus +`detect(claim_id, data) -> bool`, `apply(page, claim_id, data) -> +ConditionResult`, and `describe(result) -> str` — nothing else is required, +nothing else is read by `run_all`. `NAME` (rather than reading `__module__` +apart) is what a `ConditionResult` carries and what `conditions.BY_NAME` +keys on, so a caller holding only a result can still reach that condition's +own `describe()`. Conditions never click OK or show a confirmation dialog +themselves; the caller (`export_medcor_ready.py`'s `_confirm_and_save`) +owns both, once per claim, after every condition has had a chance to run. + +**But two steps save in eCW by themselves** (found 2026-10-15, audit log +2026-10-16): ticking a CPT line's Assign To Patient box (updateBillToIns) +and adding a CPT row (the picker's OK). Cancel undoes neither. So in VERIFY +`claim_form.writes_allowed()` is False and neither happens: the box is set +on screen only and no row is added (Review, "add by hand"). In LIVE-WRITE +both still happen during `apply()` — before the per-claim OK, and kept even +when that OK is declined or the claim is Review (out of scope until the +client approves writes; the save box says so). + +**Exception isolation is the point of this module.** A condition that +raises — in `detect()` or `apply()` — is logged, screenshotted, and +skipped; it never stops the next condition from running, and it never +crashes the claim or the batch. See the spec's "Exception handling" table +for the full set of rules this encodes. + +**Skipped is not the same as clean** (review items D2/P2, 2026-10-01; unit +D, 2026-10-05). Before this, `run_all` returned only the changed results, +so a condition that raised, or one that matched the claim but declined to +act (unmapped payer, CPT tab not confirmed, an admin code that would not +add), left no trace — the Charge Summary called those claims "Clean / +Pass", and a condition that raised half-way through `apply()` left its +edits on the form while the save box listed only the others. Three +optional, backwards-compatible additions say what really happened: + + * `ConditionResult.review` — non-empty means "a human must look at this + claim", with the reason. `ConditionResult.declined(name, reason)` is + the not-changed form of it; a changed result may carry one too. + * module hook `needs_review(claim_id, data) -> str | None` — for a + condition whose `detect()` returned False although the claim matched + its trigger, because it cannot be acted on (vaccine_admin_codes on an + unmapped payer). + * module hook `summary_comment(result) -> str` — the Charge Summary + comment for a changed result, built from what actually happened, in + place of the summary's fixed `COMMENTS[name]`. + * module attribute `DENTAL_UNSUPPORTED = "<reason>"` — for a condition + whose fix the Dental Claim screen cannot hold (no per-row POS, no + modifier columns, no Fee Schedule add flow verified). On a Dental read + (`data["screen"] == "dental"`) a fired condition with it is not + applied: the reason goes to `review`, so the claim is Review, never a + silent skip (operator, 2026-10-12). + +Conditions do not call each other. One that must know an earlier one's +verdict reads it from the claim's `data`, under a `_`-prefixed key that the +earlier one's `apply()` writes (`_wellness_history`: `medicare_wellness_history` +→ `medicare_pps_code`, spec EEWA-34). Every `detect()` runs before any +`apply()`, so such a key is there only for `apply()`, and the registry order +(tested) is what makes it there. + +`run_all(..., report={})` fills `report` with all of it (fired / changed / +failed / review / comments). Its return value is unchanged — the changed +results — so old callers see no difference. The flow stores the report on +the claim's data, where the summary reads it, and never offers Save for a +claim whose report has a failed or review entry: a raise mid-`apply()` can +leave edits on the form that no result describes. +""" + +from dataclasses import dataclass, field + +from ..core.page_utils import shot +from ..core.text import first_line + +# The review reason every condition's `apply()` declines with when its +# re-click of the CPT/HCPCS tab fails (all eight have that branch). One +# wording, so the Charge Summary reads the same whichever condition hit it. +CPT_TAB_NOT_CONFIRMED = "CPT/HCPCS tab could not be opened — not checked" + +# The review reason for the same kind of failure one step later: the claim's +# first read (`data["cpt"]`) had real CPT rows, but the live grid `apply()` +# re-read found none — the grid had not re-rendered after the tab re-click. +# That is not "no real CPT row, nothing to do" (`none()`): the fix was never +# applied, so the claim is a human's (final review, 2026-10-05). +CPT_GRID_NOT_READ = "CPT grid could not be read when applying — not checked" + + +def has_cpt_rows(data) -> bool: + """Whether a read (`extract()`'s dict) holds a CPT row with a code.""" + return any(str((r or {}).get("code") or "").strip() + for r in ((data or {}).get("cpt") or [])) + + +def _pos_norm(value) -> str: + return str(value or "").strip().lstrip("0") + + +def fix_text(change: dict, pos: str, modifier: str, sep: str = ", ") -> str: + """ + "POS 02, modifier GT on 99213" for one changed CPT row, naming ONLY the + field(s) that needed the fix — `set_cpt_pos_modifier` writes only the + field that differs, so "POS 02 / modifier GT" on a row whose POS was + already 2 read the correct field as part of the fix (final review, + 2026-10-05). Shared by the two telehealth conditions' summary comments; + `sep` keeps each one's own wording (", " / " / "). + """ + before = change.get("before") or {} + fixed = [] + if _pos_norm(before.get("pos")) != _pos_norm(pos): + fixed.append(f"POS {pos.zfill(2)}") + if str(before.get("mod1") or "").strip().upper() != modifier.upper(): + fixed.append(f"modifier {modifier}") + if not fixed: + # A changed row always differed in one of the two; if the read-back + # says otherwise, name both rather than nothing. + fixed = [f"POS {pos.zfill(2)}", f"modifier {modifier}"] + return f"{sep.join(fixed)} on {change.get('code')}" + + +@dataclass +class ConditionResult: + condition_name: str + changed: bool + changes: list = field(default_factory=list) + # Non-empty = a human must look at this claim, and why. Defaulted, so + # every existing `ConditionResult(...)` call and `== none(...)` + # comparison is unchanged. + review: str = "" + + @classmethod + def none(cls, condition_name: str) -> "ConditionResult": + """Nothing to do — the claim already follows this rule.""" + return cls(condition_name=condition_name, changed=False, changes=[]) + + @classmethod + def declined(cls, condition_name: str, reason: str) -> "ConditionResult": + """ + The claim matched, but this condition could not act on it (the + CPT/HCPCS tab would not open, an admin code would not add with the + grid untouched). Nothing changed — and unlike `none()`, that is not + "clean": `reason` reaches the Charge Summary as a Review row and + keeps the save box away from the claim. + """ + return cls(condition_name=condition_name, changed=False, changes=[], + review=reason) + + +def run_all(page, claim_id: str, data: dict, conditions=None, + before_apply=None, report=None) -> list: + """ + Runs every registered condition against one claim, in registry order. + + Two passes, not one: every condition's `detect()` runs first, against + the claim's still-untouched `data`, and only once that whole pass is + done does any condition's `apply()` run. This is what makes + `before_apply` meaningful — called once, with the list of condition + modules whose `detect()` returned True, after detection is complete + but before any of them has written to the DOM. Never called when + nothing fired. The flow passes a logger here (`export_medcor_ready.py`'s + `_log_before_apply`): a "[COND id] N condition(s) matched — about to + apply" line written on the claim's original values. It used to be a + message box of its own as well; since review item D9 (2026-10-05) the + only box is the after-the-fact one, `_review_before_close`, once + `apply()` has actually run. + + Returns only the results where something actually changed — a claim + with nothing to fix returns an empty list, and the caller shows no + dialog. That list is NOT everything that happened to the form: a + condition that raised half-way through `apply()` is not in it, yet the + edits it made before raising are still on the form (review item P2) — + which is why `report` exists. + + `report`, when a dict, is filled with the whole account (review items + D2/P2 — what the Charge Summary and the save gate read): + + * `fired` — names whose `detect()` returned True; + * `changed` — names whose result changed something (= the return); + * `failed` — `{"name", "stage", "error"}` for every raise; `stage` + is "detect", "apply" or "needs_review", `error` the + exception's first line; + * `review` — `{"name", "reason"}` from every result (changed or + not) with a non-empty `review`, and from the + `needs_review()` hook of every condition whose + `detect()` returned False; + * `comments` — `{name: summary_comment(result)}` for changed results + whose module has that hook. + + Exceptions are isolated exactly as before: logged, screenshotted, and + the next condition still runs. A `summary_comment()` that raises is + logged and left out of `comments` (the summary falls back to its fixed + comment): a wording bug is not a fact about the claim, so it must not + turn the claim into a Review row or keep its save box away. + + `conditions` defaults to the real registry (`conditions.CONDITIONS`); + tests pass a list of fake modules instead so this can be verified + without depending on whatever real conditions happen to be registered. + """ + if conditions is None: + from . import CONDITIONS as conditions + + # A throwaway dict when the caller passed none, so the bookkeeping + # below has one code path and old callers see nothing new. + rep = report if isinstance(report, dict) else {} + rep.update(fired=[], changed=[], failed=[], review=[], comments={}) + + def failed(cond, stage, exc): + print(f"[COND {cond.NAME} {claim_id}] Failed: {exc}") + shot(page, f"condition_{cond.NAME}_failed_{claim_id}") + rep["failed"].append({"name": cond.NAME, "stage": stage, + "error": first_line(exc)}) + + fired = [] + for cond in conditions: + try: + hit = cond.detect(claim_id, data) + except Exception as exc: + failed(cond, "detect", exc) + continue # this condition's failure must not block the next one + if hit: + fired.append(cond) + rep["fired"].append(cond.NAME) + continue + # Not fired — but "did not fire" can also mean "matched, and could + # not be acted on". Asked here, in the detect pass, so the hook + # sees the same untouched `data` detect() just decided on. + hook = getattr(cond, "needs_review", None) + if hook is None: + continue + try: + reason = hook(claim_id, data) + except Exception as exc: + failed(cond, "needs_review", exc) + continue + if reason: + rep["review"].append({"name": cond.NAME, "reason": reason}) + + if fired and before_apply is not None: + before_apply(fired) + + results = [] + dental = (data or {}).get("screen") == "dental" + for cond in fired: + unsupported = getattr(cond, "DENTAL_UNSUPPORTED", "") if dental else "" + if unsupported: + print(f"[COND {cond.NAME} {claim_id}] {unsupported}") + rep["review"].append({"name": cond.NAME, "reason": unsupported}) + continue + try: + result = cond.apply(page, claim_id, data) + except Exception as exc: + failed(cond, "apply", exc) + continue # this condition's failure must not block the next one + # Changed or not: a declined result (nothing changed) and a changed + # one that needs a hand check both carry their reason here. + if getattr(result, "review", ""): + rep["review"].append({"name": cond.NAME, "reason": result.review}) + if not result.changed: + continue + results.append(result) + rep["changed"].append(cond.NAME) + hook = getattr(cond, "summary_comment", None) + if hook is None: + continue + try: + rep["comments"][cond.NAME] = hook(result) + except Exception as exc: + print(f"[COND {cond.NAME} {claim_id}] Could not word the summary " + f"comment ({first_line(exc)}) — the summary uses the " + f"fixed one") + return results diff --git a/src/ecw_bot/conditions/bh_h_code_modifier.py b/src/ecw_bot/conditions/bh_h_code_modifier.py new file mode 100644 index 0000000..7361b9b --- /dev/null +++ b/src/ecw_bot/conditions/bh_h_code_modifier.py @@ -0,0 +1,200 @@ +r""" +Condition (document #12): an H code carries no modifier on a BH payer's +claim — telehealth excluded — and is not billed to any other payer. + +Per the client's Charge Condition Document, row 12 (CPT `Mental and +Psychotherapy`, payer `All Payers`): + "No Modifier should be added for H Code (exclude Telehealth)" + +Operator, 2026-10-07: + * Scope: payers whose name starts with "BH" (BH COLORADO ACCESS, BH ROCKY + MOUNTAIN HEALTH, BH ROCKY MTN HEALTH 2022, BH ROCK MTN UB, ...), matched + on the name itself so a new "BH ..." payer is covered without a table + edit. Not the whole RAE group, not every payer. + * Action: every modifier (M1-M4) on an H code line is removed. Other lines + are not touched. + * Telehealth visits are excluded: when the Progress Note says telehealth, + the telehealth rule's GT stays and this condition does not fire. + +H codes are the HCPCS behavioral-health codes, "H" plus four digits +(H0001-H2037) — e.g. H0023 and H0031 seen on BH COLORADO ACCESS claims. + +When the Progress Note was not read (no button, disabled, an error, or not +opened at all), whether the visit was telehealth is unknown, so nothing is +removed and the claim goes to Review. + +Operator, 2026-10-12: H codes are billed to BH payers only. On any other +payer (Medicare, Medicaid, Commercial ...) a billed H code line is +unchecked and its Billed Fee zeroed — telehealth or not, modifier or not, +so the Progress Note does not matter on that path. A line already unchecked +or at 0.00 is left alone. A claim whose payer was not read fires neither +path: BH or not is unknown. + +eCW may re-price a line when its modifier changes; the fee before and +after is recorded on that row's change and shown by `describe()` (review, +2026-10-12). Not a review reason — eCW did it, the bot only reports it. +""" + +import re + +from ..pages import claim_form +from .base import (CPT_GRID_NOT_READ, CPT_TAB_NOT_CONFIRMED, + ConditionResult, has_cpt_rows) + +NAME = "bh_h_code_modifier" + +_H_CODE = re.compile(r"^H\d{4}$", re.IGNORECASE) + +# The progress-note states in which the note was never read — the same +# set charge_summary._NOTE_NOT_READ lists. +_NOTE_NOT_READ_STATES = ("nothing", "absent", "disabled", "skipped") + +NOTE_NOT_READ = ("H code has a modifier, but the Progress Note was not read " + "— cannot tell if this is telehealth; modifier left as is") +NOT_REMOVED = "H code modifier could not be removed — remove it by hand" + +MODIFIER_COMMENT = "Modifier removed from H code (BH payer)" +NOT_BILLED = "H code not billed to a non-BH payer" + + +def _payer(data: dict) -> str: + rows = (data or {}).get("insurance") or [] + return (rows[0].get("Name") or "").strip() if rows else "" + + +def is_bh_payer(data: dict) -> bool: + return _payer(data).upper().startswith("BH") + + +def is_h_code(code) -> bool: + return bool(_H_CODE.match((code or "").strip())) + + +def _has_modifier(row) -> bool: + return any((row.get(k) or "").strip() + for k in ("mod1", "mod2", "mod3", "mod4")) + + +def _note_text(data: dict): + """ + The note's text, or None when it was never read — or read only in part + (`partial`: its sections never settled, so the telehealth line may be + the part that is missing; review, 2026-10-12). + """ + note = (data or {}).get("progress_notes") + if not isinstance(note, dict) or note.get("error") or note.get("partial"): + return None + if note.get("opened") in _NOTE_NOT_READ_STATES or "text" not in note: + return None + return note.get("text") or "" + + +def _is_billed(row) -> bool: + """Not unchecked, and a fee that was read and is not zero.""" + fee = row.get("billed_fee") + return (row.get("billed") is not False and fee is not None + and not claim_form.is_zero_fee(fee)) + + +def detect(claim_id: str, data: dict) -> bool: + if not _payer(data): + return False + if not is_bh_payer(data): + return any(is_h_code(r.get("code")) and _is_billed(r) + for r in (data or {}).get("cpt") or []) + if not any(is_h_code(r.get("code")) and _has_modifier(r) + for r in (data or {}).get("cpt") or []): + return False + text = _note_text(data) + return text is None or not claim_form.note_mentions_telehealth(text) + + +def apply(page, claim_id: str, data: dict) -> ConditionResult: + try: + claim_form.open_cpt_hcpcs_tab(page) + except Exception as exc: + print(f"[COND {NAME} {claim_id}] Could not confirm the CPT/HCPCS " + f"tab before fixing: {exc} — leaving the claim as read") + return ConditionResult.declined(NAME, CPT_TAB_NOT_CONFIRMED) + if not is_bh_payer(data): + return _uncheck_off_bh(page, claim_id, data) + if _note_text(data) is None: + print(f"[COND {NAME} {claim_id}] {NOTE_NOT_READ}") + return ConditionResult.declined(NAME, NOTE_NOT_READ) + + changes = claim_form.clear_cpt_row_modifiers(page, is_h_code) + if not changes: + if has_cpt_rows(data) and not has_cpt_rows(claim_form.extract(page)): + # The first read had CPT rows; the live grid showed none. The + # fix was never applied — not "nothing to do" (review, + # 2026-10-12). + print(f"[COND {NAME} {claim_id}] {CPT_GRID_NOT_READ} — leaving " + f"the claim as read") + return ConditionResult.declined(NAME, CPT_GRID_NOT_READ) + return ConditionResult.none(NAME) + review = "" if all(c["ok"] for c in changes) else NOT_REMOVED + return ConditionResult(condition_name=NAME, changed=True, changes=changes, + review=review) + + +def _uncheck_off_bh(page, claim_id: str, data: dict) -> ConditionResult: + """Non-BH payer: every H code line unchecked, Billed Fee 0.00.""" + changes = claim_form.zero_and_uncheck_cpt_rows_matching(page, is_h_code) + if changes: + return ConditionResult(condition_name=NAME, changed=True, + changes=changes) + if has_cpt_rows(data) and not has_cpt_rows(claim_form.extract(page)): + print(f"[COND {NAME} {claim_id}] {CPT_GRID_NOT_READ} — leaving " + f"the claim as read") + return ConditionResult.declined(NAME, CPT_GRID_NOT_READ) + return ConditionResult.none(NAME) + + +def _off_bh(result: ConditionResult) -> bool: + """The changes are the non-BH path's (unchecked / fee), not modifiers.""" + return any("before" not in c for c in result.changes) + + +def summary_comment(result: ConditionResult) -> str: + """ + Worded from what was done: a UB form with no Assign-To-Patient + checkbox only has its fee zeroed (operator, 2026-10-12, option A). + """ + if not _off_bh(result): + failed = [c for c in result.changes if not c.get("ok")] + if not failed: + return MODIFIER_COMMENT + if len(failed) == len(result.changes): + return "Modifier on H code NOT removed (BH payer) — remove by hand" + return f"{MODIFIER_COMMENT}; some NOT removed — check by hand" + done = [] + if any(c.get("unchecked") for c in result.changes): + done.append("unchecked") + if any("fee" in c for c in result.changes): + done.append("fee zeroed") + return f"{NOT_BILLED} ({' and '.join(done) or 'excluded'})" + + +def describe(result: ConditionResult) -> str: + if _off_bh(result): + lines = ["H code not billed to a non-BH payer:"] + for c in result.changes: + bits = [] + if c.get("unchecked"): + bits.append("checkbox unchecked") + if "fee" in c: + bits.append(f"fee {c['fee']['before']} -> " + f"{c['fee']['after']}") + lines.append(f" Row {c['row'] + 1} ({c['code']}): " + f"{', '.join(bits)}") + return "\n".join(lines) + lines = ["Modifier removed from H code (BH payer):"] + for c in result.changes: + removed = ", ".join(m for m in c["before"] if m.strip()) + status = "" if c.get("ok") else " — STILL SET, check by hand" + fee = c.get("fee") + repriced = (f"; eCW re-priced the fee {fee['before']} -> " + f"{fee['after']}" if fee else "") + lines.append(f" Row {c['row'] + 1} ({c['code']}): removed {removed}" + f"{repriced}{status}") + return "\n".join(lines) diff --git a/src/ecw_bot/conditions/dental_dummy_codes.py b/src/ecw_bot/conditions/dental_dummy_codes.py new file mode 100644 index 0000000..120cc8b --- /dev/null +++ b/src/ecw_bot/conditions/dental_dummy_codes.py @@ -0,0 +1,122 @@ +r""" +Condition (document #48): DPIMP is a dummy code on Dental payers — +"If a billed amount is populated for a dummy code, the amount must be +zeroed out and the CPT must be unchecked." Same treatment as +`dummy_codes.py` (#8, DCONS / Dscprit, every payer), but in its own file +and for Dental payers only (operator, 2026-10-13). + + * **Dental payer** — the first insurance row's payer in a Dental group of + the Claim Status Dashboard ("Dental - Commercial": Delta Dental, Aetna + Dental PPO ...; "Dental - CHP+/DentaQuest") → every DPIMP line is + unchecked and its Billed Fee zeroed. A line already unchecked at 0.00 + is left alone. + * **Any other payer** (Medicare, Medicaid, Commercial ...), an unmapped + payer or none read → nothing on the claim is changed; the claim goes + to Review with a note in the Charge Summary's Comments naming the + payer group, so a person decides (`needs_review`). + +The code is matched ignoring case and surrounding spaces, as #8 does. +DFIMP ("FINAL IMPRESSION") counts too: it is the code the live Dental grid +carries (claim test 2026-10-20) where the document says DPIMP (operator, +2026-10-20). Notes and the box name the code actually found. +""" + +from ..core import payer_groups +from ..pages import claim_form +from .base import CPT_TAB_NOT_CONFIRMED, ConditionResult + +NAME = "dental_dummy_codes" + +TARGET_CPT_CODES = {"DPIMP", "DFIMP"} + +# Rule groups (payer_groups.rule_group) that are Dental payers. +DENTAL_GROUPS = {"Dental - Commercial", "Dentaquest"} + + +def _payer(data: dict) -> str: + rows = (data or {}).get("insurance") or [] + return (rows[0].get("Name") or "").strip() if rows else "" + + +def _targets(data: dict) -> list: + """The #48 codes on the claim, upper-cased, in grid order, once each.""" + found = [] + for row in (data or {}).get("cpt") or []: + code = (row.get("code") or "").strip().upper() + if code in TARGET_CPT_CODES and code not in found: + found.append(code) + return found + + +def _has_target(data: dict) -> bool: + return bool(_targets(data)) + + +def _group(payer: str): + """(rule group, dashboard Group Name) — (None, None) when unmapped or + the mapping cannot be read.""" + try: + mapping = payer_groups.load_mapping() + except Exception: + return None, None + return (payer_groups.rule_group(payer, mapping), + payer_groups.classify(payer, mapping)) + + +def is_dental_payer(data: dict) -> bool: + payer = _payer(data) + return bool(payer) and _group(payer)[0] in DENTAL_GROUPS + + +def detect(claim_id: str, data: dict) -> bool: + return _has_target(data) and is_dental_payer(data) + + +def needs_review(claim_id: str, data: dict): + """DPIMP / DFIMP on a claim whose payer is not a Dental payer: left as + it is, and said in the Comments.""" + if not _has_target(data) or is_dental_payer(data): + return None + payer = _payer(data) + if not payer: + where = "a claim with no payer read" + else: + rule, shown = _group(payer) + where = (f"a {shown or rule} payer claim" if (shown or rule) + else "a payer not in the Claim Status Dashboard") + codes = "/".join(_targets(data)) + return (f"Dental dummy code {codes} on {where} — not changed (zeroed on " + f"Dental payers only); check by hand") + + +def apply(page, claim_id: str, data: dict) -> ConditionResult: + """ + Re-clicks CPT/HCPCS for the same reason `dummy_codes.py`'s `apply()` + does — eCW can leave the form on a different tab after the Prog. Notes + viewer cycle. A no-op on the HCFA and Dental screens. + """ + try: + claim_form.open_cpt_hcpcs_tab(page) + except Exception as exc: + print(f"[COND {NAME} {claim_id}] Could not confirm the CPT/HCPCS " + f"tab before fixing: {exc} — leaving the claim as read") + return ConditionResult.declined(NAME, CPT_TAB_NOT_CONFIRMED) + + changes = claim_form.zero_and_uncheck_cpt_rows(page, TARGET_CPT_CODES) + if not changes: + return ConditionResult.none(NAME) + + return ConditionResult(condition_name=NAME, changed=True, changes=changes) + + +def describe(result: ConditionResult) -> str: + codes = "/".join(dict.fromkeys(c["code"] for c in result.changes)) or "DPIMP/DFIMP" + lines = [f"Dental dummy code {codes} excluded (Dental payer):"] + for c in result.changes: + bits = [] + if c.get("unchecked"): + bits.append("checkbox unchecked") + if "fee" in c: + bits.append(f"fee {c['fee']['before']} -> {c['fee']['after']}") + lines.append(f" Row {c['row'] + 1} ({c['code']}): {', '.join(bits)}") + return "\n".join(lines) diff --git a/src/ecw_bot/conditions/dental_payer_check.py b/src/ecw_bot/conditions/dental_payer_check.py new file mode 100644 index 0000000..56b6d18 --- /dev/null +++ b/src/ecw_bot/conditions/dental_payer_check.py @@ -0,0 +1,172 @@ +r""" +Condition (document #13): Dental codes are billed only to DentaQuest, Delta +Dental or CHP+ Dental. + +Per the client's Charge Condition Document, row 13 (all Dental CPT codes): +"Dental Claims should be submitted only to the following payers: 1. Denta +Quest 2. Delta Dental 3. CHP+Dental. If the patient does not have any of +this insurance, we can bill the patient. Before billing him/her, we have to +check for the program that applies to the patient's condition and have to +take action based on that." + +Operator, 2026-10-16: + + * **A Dental code** is "D" + 4 digits — the CDT codes (D0120, D1110 ...). + The dummy codes DCONS, Dscprit, DPIMP and DFIMP start with D but are not + Dental codes. Only a line the claim ends up BILLING counts + (`lines.ends_billed`): not one at a zero fee, and an unchecked one only + when priced_line_check checks it at the end (review, 2026-10-19). + * **Allowed payers** are the document's three only: DentaQuest, CHP+ + DentaQuest, and every "Delta Dental ..." payer. By name, case and + spaces ignored: "dentaquest" anywhere in it, or starting "delta + dental" — every DentaQuest / Delta Dental payer on the dashboard's + list is spelt one of those ways, and a spelling the list does not have + ("DENTAQUEST", "Denta Quest") is no reason to send a claim to the + patient (review, 2026-10-19). Other Dental payers (Aetna Dental PPO, + Cigna Dental, MetLife ...) are NOT allowed. + * Any other payer, or none read → the claim is **Review**, with a comment + naming the codes and the payer: check the patient's program, then + Submit to Patient. A person does both. + +Operator, 2026-10-21 (EEWA-27): + + * **Secondary insurance counts**: an allowed payer on ANY insurance row + the form shows (primary, secondary ...) — nothing to do. + * No allowed payer on any row → the bot sets the **Claim Status to + Patient** (option value "PAT", `claim_form.set_claim_status`), with no + program check first. The comment names the codes and the payer(s). + * Only where the run may write (`claim_form.writes_allowed`: LIVE-WRITE, + and never on the Dental screen, whose claims are not saved): elsewhere + nothing is chosen and the claim is Review, "set the Claim Status to + Patient by hand". The status sticks only with the claim's OK. + * No payer read at all → Review, nothing changed (who is billed is + unknown). + +Works the same on the UB, HCFA and Dental screens. +""" + +import re + +from ..pages import claim_form +from . import lines +from .base import CPT_TAB_NOT_CONFIRMED, ConditionResult + +NAME = "dental_payer_check" + +# The Claim Status option for "Patient" (value on all three screens). +PATIENT = "PAT" + +_DENTAL_CODE = re.compile(r"^D\d{4}$") + +# Compared with every space taken out and case folded. +_ALLOWED_ANYWHERE = "dentaquest" # DentaQuest, CHP+ DENTAQUEST +_ALLOWED_PREFIX = "deltadental" # Delta Dental of Colorado ... + + +def is_dental_code(code) -> bool: + return bool(_DENTAL_CODE.match((code or "").strip().upper())) + + +def is_allowed_payer(payer) -> bool: + name = re.sub(r"\s+", "", payer or "").casefold() + return bool(name) and (_ALLOWED_ANYWHERE in name + or name.startswith(_ALLOWED_PREFIX)) + + +def billed_dental_codes(cpt) -> list: + return lines.billed_codes(cpt, is_dental_code) + + +def payers(data) -> list: + """Every insurance row's payer name the form showed, in order.""" + return [str((r or {}).get("Name") or "").strip() + for r in (data or {}).get("insurance") or [] + if str((r or {}).get("Name") or "").strip()] + + +def _wrong_payer(data): + """(codes, payers) when billed Dental codes go to payers none of which + is allowed; None otherwise, and when no payer was read.""" + codes = billed_dental_codes((data or {}).get("cpt")) + names = payers(data) + if not codes or not names or any(is_allowed_payer(n) for n in names): + return None + return codes, names + + +def _finding(codes, names) -> str: + return (f"Dental code(s) {', '.join(codes)} billed to " + f"{' / '.join(names)} — not DentaQuest / Delta Dental / CHP+ " + f"Dental") + + +def detect(claim_id: str, data: dict) -> bool: + return _wrong_payer(data) is not None + + +def needs_review(claim_id: str, data: dict): + """Only the case detect() cannot act on: Dental codes, no payer read.""" + codes = billed_dental_codes((data or {}).get("cpt")) + if not codes or payers(data): + return None + return (f"Dental code(s) {', '.join(codes)} billed with no payer read — " + f"check the payer; not DentaQuest / Delta Dental / CHP+ Dental " + f"→ set the Claim Status to Patient") + + +def apply(page, claim_id: str, data: dict) -> ConditionResult: + tag = f"[COND {NAME} {claim_id}]" + # The contract every condition keeps (review item D2): a form whose + # CPT/HCPCS tab cannot be confirmed is a decline, not "nothing to do". + try: + claim_form.open_cpt_hcpcs_tab(page) + except Exception: + return ConditionResult.declined(NAME, CPT_TAB_NOT_CONFIRMED) + found = _wrong_payer(data) + if found is None: + return ConditionResult.none(NAME) + codes, names = found + finding = _finding(codes, names) + change = {"codes": codes, "payers": names} + if not claim_form.writes_allowed(page): + review = (f"{finding}: Claim Status not changed by the bot (VERIFY, " + f"or a Dental claim) — set it to Patient by hand") + print(f"{tag} {review}") + return ConditionResult.declined(NAME, review) + done = claim_form.set_claim_status(page, PATIENT) + if done is None: + review = f"{finding}: no Claim Status box found — set it to Patient by hand" + print(f"{tag} {review}") + return ConditionResult.declined(NAME, review) + change.update(done) + if not done["ok"]: + review = (f"{finding}: Claim Status did not take Patient (reads " + f"{done['after'] or 'empty'}) — set it by hand") + print(f"{tag} {review}") + return ConditionResult(condition_name=NAME, + changed=done["after"] != done["before"], + changes=[change], review=review) + print(f"{tag} {finding}: Claim Status {done['before'] or '(empty)'} -> " + f"{done['after']} ({done['label']})") + return ConditionResult(condition_name=NAME, + changed=done["before"] != done["after"], + changes=[change]) + + +def summary_comment(result: ConditionResult) -> str: + c = result.changes[0] if result.changes else {} + finding = _finding(c.get("codes") or ["?"], c.get("payers") or ["?"]) + if result.review: + return f"{finding} - Claim Status NOT set to Patient, set it by hand" + return f"{finding} - Claim Status set to Patient" + + +def describe(result: ConditionResult) -> str: + out = ["Dental codes on a payer other than DentaQuest / Delta Dental / " + "CHP+ Dental (#13):"] + for c in result.changes: + out.append(f" Claim Status {c.get('before') or '(empty)'} -> " + f"{c.get('after')} ({c.get('label') or '?'})") + if result.review: + out.append(f" {result.review}") + return "\n".join(out) diff --git a/src/ecw_bot/conditions/depression_dx_pointer.py b/src/ecw_bot/conditions/depression_dx_pointer.py new file mode 100644 index 0000000..c120db2 --- /dev/null +++ b/src/ecw_bot/conditions/depression_dx_pointer.py @@ -0,0 +1,149 @@ +r""" +Condition 6 (document #6): the Depression DX ICD pointer (Z13.31) may be +tagged on a G8510 or G8431 CPT/HCPCS row, and nowhere else. + +Per the client's Charge Condition Document: + "Depression DX (Z13.31) only needs to TAG in the ICD pointer for these + CPT codes [G8510, G8431]. If any other codes are tagged, we can remove + them. + + Likewise, ICD Code Z13.31 tagged for any other CPT codes apart from + G8510, G8431, should be removed from those CPT codes" + +G8510 (negative depression screening) and G8431 (positive depression +screening) are two different CPT codes, but they share the SAME one ICD +pointer — Z13.31. The client confirmed live: only Z13.31 exists for this; +there is no separate "positive depression" ICD code, and an earlier attempt +to introduce one (Z13.32) was the client's own uncertain guess, not a real +code, and has been reverted. "Positive" vs "negative" describes what the +CPT code itself documents, not a different diagnosis pointer. + +Every payer except Medicare — the document lists "All", and document #14 +(`medicare_billing_screening.py`) replaces this process on Medicare claims. + +Real live example this fixes (claim 9103000): CPT row 99213 carried the +Z13.31 pointer in ICD2 alongside its own diagnosis in ICD1, while G8510's +own row already correctly carried it in ICD1 — 99213's copy needed +removing, not G8510's. + +Also confirmed live (claim 9103000): a G8510/G8431 row can be sitting +unchecked — excluded from the claim — from before its ICD pointer was +ever corrected. Once the pointer is fixed, that row needs re-including, +so this also checks (never unchecks) the "Assign To Patient" checkbox on +the KEPT row for each of the same two codes — only that one: any other +row for the same code is a duplicate (below), and checking an +already-excluded one only for the duplicate step to uncheck it again was +a reported change that changed nothing (claim 9131000). Nor a row billed +at zero (`claim_form.is_zero_fee()`): it is left excluded, per the +operator's common rule in `zero_fee_uncheck.py` (2026-09-30) that a line +billed at zero is never billed — that condition runs first and unchecks +a checked 0.00 G8510, and re-checking it here would toggle it. + +Which row is kept, per the operator's ruling of 2026-09-30: the first +G8510 (or G8431) whose Billed Fee is NOT zero — a row with no fee input +counts as not zero — or, only when every copy is zero-fee, the first by +position. Decided once, by `claim_form.kept_cpt_row_per_code()`, which +both the re-include step (`ensure_cpt_rows_checked()`) and the duplicate +step (`zero_and_uncheck_duplicate_cpt_rows()`) ask, so the two can never +pick different rows. Before the ruling "kept" was the first by position: +a first G8510 at 0.00 with a checked 65.00 copy after it ended with +NEITHER billed — zero_fee_uncheck unchecked the 0.00 one and the +duplicate step zeroed the 65.00 one. Now the 65.00 copy stays checked with +its fee, and the 0.00 copy is the duplicate. + +Not built here: the document's separate "Do not bill Screening codes... +(If screening is only performed, then we will move to provider +clarification)" note — that is a manual escalation path, not a mechanical +ICD-pointer edit. + +Extra step added per the client's follow-up: a G8510 or G8431 row can end +up duplicated on the same claim, both checked. Only one is ever needed — +so once the ICD pointer is retagged, every row for either code other than +the kept one (above) gets unchecked and its Billed Fee zeroed, the same +treatment `j2930_j2001_update.py` gives its own excluded codes. The kept +row is always left alone. A duplicate that is unchecked but still priced +is zeroed here without a click, and `priced_line_check.py` — registered +last — then finds it at 0.00 and leaves it excluded. +""" + +from ..pages import claim_form +from . import medicare_billing_screening +from .base import CPT_TAB_NOT_CONFIRMED, ConditionResult + +NAME = "depression_dx_pointer" + +DEPRESSION_DX_CODE = "Z13.31" +TARGET_CPT_CODES = {"G8510", "G8431"} + + +def _depression_pointer(data: dict) -> str | None: + for row in (data or {}).get("icd") or []: + if (row.get("Code") or "").strip() == DEPRESSION_DX_CODE: + pointer = (row.get("#") or "").strip() + return pointer or None + return None + + +def detect(claim_id: str, data: dict) -> bool: + # Not on a Medicare claim (document #14, operator 2026-10-07): there + # medicare_billing_screening excludes G8510/G8431 instead. + if medicare_billing_screening.is_medicare_claim(data): + return False + return _depression_pointer(data) is not None + + +def apply(page, claim_id: str, data: dict) -> ConditionResult: + """ + Re-clicks CPT/HCPCS for the same reason `telehealth.py`'s and + `medicare_telehealth.py`'s `apply()` do — eCW can leave the form on a + different tab after the Prog. Notes viewer cycle (seen live, claim + 9116000). A no-op on the professional/HCFA screen, which has no such + tab to lose. + """ + try: + claim_form.open_cpt_hcpcs_tab(page) + except Exception as exc: + print(f"[COND {NAME} {claim_id}] Could not confirm the CPT/HCPCS " + f"tab before fixing: {exc} — leaving the claim as read") + # Not "nothing to do": the claim matched and was never + # checked, so it goes to a human (review item D2). + return ConditionResult.declined(NAME, CPT_TAB_NOT_CONFIRMED) + + pointer = _depression_pointer(data) + if pointer is None: + return ConditionResult.none(NAME) + + changes = claim_form.retag_cpt_icd_pointer( + page, target_pointer=pointer, target_codes=TARGET_CPT_CODES) + changes += claim_form.ensure_cpt_rows_checked(page, TARGET_CPT_CODES) + changes += claim_form.zero_and_uncheck_duplicate_cpt_rows( + page, TARGET_CPT_CODES) + if not changes: + # Said out loud: without it the log's last word on an already-correct + # claim is "about to apply: depression_dx_pointer", which reads as a + # silent failure (claim 9132000). + print(f"[COND {NAME} {claim_id}] {DEPRESSION_DX_CODE} is already " + f"tagged only on G8510/G8431 — nothing to change") + return ConditionResult.none(NAME) + return ConditionResult(condition_name=NAME, changed=True, changes=changes) + + +def describe(result: ConditionResult) -> str: + lines = ["Depression DX (Z13.31) ICD pointer retagged:"] + for c in result.changes: + if "before" in c: + before = ", ".join(v for v in c["before"]["icd"] if v) or "none" + after = ", ".join(v for v in c["after"]["icd"] if v) or "none" + lines.append(f" Row {c['row'] + 1} ({c['code']}): " + f"ICD {before!r} -> {after!r}") + elif "unchecked" in c or "fee" in c: + bits = [] + if c.get("unchecked"): + bits.append("checkbox unchecked (duplicate row)") + if "fee" in c: + bits.append(f"fee {c['fee']['before']} -> {c['fee']['after']}") + lines.append(f" Row {c['row'] + 1} ({c['code']}): {', '.join(bits)}") + else: + lines.append(f" Row {c['row'] + 1} ({c['code']}): " + f"checkbox checked (was excluded from the claim)") + return "\n".join(lines) diff --git a/src/ecw_bot/conditions/dummy_codes.py b/src/ecw_bot/conditions/dummy_codes.py new file mode 100644 index 0000000..3f2364a --- /dev/null +++ b/src/ecw_bot/conditions/dummy_codes.py @@ -0,0 +1,63 @@ +r""" +Condition: "dummy" CPT codes DCONS and Dscprit are placeholders that should +never actually be billed — whenever one sits on a claim, checked or not, it +gets excluded the same way `j2930_j2001_update.py` excludes J2930/J2001: +unchecked and its Billed Fee zeroed. Codes are matched ignoring case and +surrounding spaces ("Dscprit" is mixed case as named). + +Operator, 2026-10-06: "DCONS and Dscprit — these two only are dummy codes". +BHT3/BHT4, which this rule was first built for, are NOT dummy codes and are +left alone here. + +Unconditional across payers — these codes are never billable regardless of +who the payer is. +""" + +from ..pages import claim_form +from .base import CPT_TAB_NOT_CONFIRMED, ConditionResult + +NAME = "dummy_codes" + +TARGET_CPT_CODES = {"DCONS", "DSCPRIT"} + + +def detect(claim_id: str, data: dict) -> bool: + for row in (data or {}).get("cpt") or []: + if (row.get("code") or "").strip().upper() in TARGET_CPT_CODES: + return True + return False + + +def apply(page, claim_id: str, data: dict) -> ConditionResult: + """ + Re-clicks CPT/HCPCS for the same reason `depression_dx_pointer.py`'s + and `j2930_j2001_update.py`'s `apply()` do — eCW can leave the form on + a different tab after the Prog. Notes viewer cycle. A no-op on the + professional/HCFA screen, which has no such tab to lose. + """ + try: + claim_form.open_cpt_hcpcs_tab(page) + except Exception as exc: + print(f"[COND {NAME} {claim_id}] Could not confirm the CPT/HCPCS " + f"tab before fixing: {exc} — leaving the claim as read") + # Not "nothing to do": the claim matched and was never + # checked, so it goes to a human (review item D2). + return ConditionResult.declined(NAME, CPT_TAB_NOT_CONFIRMED) + + changes = claim_form.zero_and_uncheck_cpt_rows(page, TARGET_CPT_CODES) + if not changes: + return ConditionResult.none(NAME) + + return ConditionResult(condition_name=NAME, changed=True, changes=changes) + + +def describe(result: ConditionResult) -> str: + lines = ["Dummy codes excluded from the claim:"] + for c in result.changes: + bits = [] + if c.get("unchecked"): + bits.append("checkbox unchecked") + if "fee" in c: + bits.append(f"fee {c['fee']['before']} -> {c['fee']['after']}") + lines.append(f" Row {c['row'] + 1} ({c['code']}): {', '.join(bits)}") + return "\n".join(lines) diff --git a/src/ecw_bot/conditions/injection_admin_code.py b/src/ecw_bot/conditions/injection_admin_code.py new file mode 100644 index 0000000..0f6f380 --- /dev/null +++ b/src/ecw_bot/conditions/injection_admin_code.py @@ -0,0 +1,277 @@ +r""" +Condition (document #20): injection codes billed without the injection +administration code get 96372 added. + +Per the client's Charge Condition Document, row 20, payer "All (Except +Medicare)": "If the Admin code is not available, we need to add 96372 code +manually." Operator, 2026-10-13: + + * **An injection row** is a CPT row whose code is on the client's + injection code list (`INJECTION_CODES`, 41 codes, J0561 ... J8499) — + by code only, the full list as the client gave it. + * **The admin code** is 96372 only. A billed 96372 means "admin already + billed"; nothing else does. + * **96372 is added once, Units 1**, however many injection lines the + claim has. + +"Billed" is the vaccine rule's (`vaccine_admin_codes.row_counts_as_billed`): +checked (or checkbox unknown) and not a checked line at a zero fee that +`zero_fee_uncheck` will uncheck first. J2001 and J2930 are on the injection +list, but `j2930_j2001_update` (#5) unchecks and zeroes them on every claim, +so they never count here — in `detect()` too, which sees the claim as read: +counted there, a Dental claim whose only injection is J2001 went to Review +for a 96372 it does not need. `apply()` also decides from the LIVE grid, +re-read after the conditions before it ran. + +A 96372 counts as billed also when it is UNCHECKED with a fee: the last +condition, `priced_line_check`, checks it (`will_check`), so adding another +would bill 96372 twice (review, 2026-10-14). An injection line in that state +is not counted (yet) — whether one that ends up checked gets 96372 is not +ruled on. + +Payer: never Medicare. A payer `payer_groups.json` cannot classify, or no +payer name read → nothing added and the claim is Review +(`needs_review`), the same safe default as the vaccine rule. + +Adding 96372 is the same step as the vaccine admin codes and G2025 +(`claim_form.add_cpt_row()`: Add → Fee Schedule picker → OK), through +`vaccine_admin_codes._add_admin_code()`, which reads the grid before and +after to prove the line landed and reports anything else that changed. The +Dental screen has no verified add flow: on a Dental claim this condition is +not applied and the claim goes to Review (`DENTAL_UNSUPPORTED`). +""" + +from ..core import payer_groups +from ..pages import claim_form +from . import priced_line_check +from . import vaccine_admin_codes as vac +from .j2930_j2001_update import TARGET_CPT_CODES as _UNCHECKED_BY_5 +from .base import (CPT_GRID_NOT_READ, CPT_TAB_NOT_CONFIRMED, + ConditionResult, has_cpt_rows) + +NAME = "injection_admin_code" + +DENTAL_UNSUPPORTED = ( + "Injection on a Dental claim — 96372 cannot be added on the Dental " + "screen; add it by hand") + +ADMIN_CODE = "96372" + +# The client's injection code list (operator, 2026-10-13), as given. +INJECTION_CODES = frozenset({ + "J0561", # Penicillin G benzathine 100,000 units + "J0696", # Rocephin (1 gm) + "J1000", # Depo-estradiol cypionate up to 5 mg + "J1010", # Methylprednisolone acetate 1 mg + "J1020", # Methylprednisolone acetate 20 mg + "J1030", # Methylprednisolone acetate 40 mg + "J1050", # Medroxyprogesterone SDV (150 mg/ml) + "J1055", # Depo-Provera + "J1100", # Dexamethasone sodium phosphate 1 mg + "J1200", # Diphenhydramine HCl up to 50 mg + "J1815", # Insulin per 5 units + "J1885", # Toradol (30 mg) ketorolac + "J2001", # Lidocaine injection + "J2003", # Lidocaine HCl 1 mg + "J2004", # Lidocaine 1% (with epi) + "J2315", # Vivitrol + "J2360", # Orphenadrine citrate up to 60 mg + "J2405", # Ondansetron HCl per 1 mg + "J2550", # Phenergan 12.5 mg + "J2788", # Rhogam (300 mcg) + "J2919", # Solu-Medrol (40 mg) + "J2920", # Methylprednisolone sodium up to 40 mg + "J2930", # Methylprednisolone sodium up to 125 mg + "J3030", # Sumatriptan succinate 6 mg + "J3140", # Testosterone suspension up to 50 mg + "J3301", # Kenalog 40 mg/ml + "J3420", # Vitamin B-12 cyanocobalamin up to 1000 mcg + "J3490", # Unclassified drugs + "J7296", # Kyleena 19.5 mg + "J7297", # Liletta system (52 mg) + "J7298", # Mirena LNG-releasing IUC 52 mg + "J7300", # Paragard IUD + "J7301", # Skyla IUD + "J7307", # Etonogestrel implant system + "J7512", # Prednisone immediate/delayed release oral 1 mg + "J7609", # Albuterol comp unit + "J7620", # Albuterol sulfate .083%/ml + "J7621", # (Levo)albuterol / ipratropium bromide + "J7633", # Budesonide inhalation solution 0.25 mg + "J7644", # Ipratropium bromide inhalation solution + "J8499", # Oral non-chemotherapeutic NOS +}) + +_EXCLUDED_GROUPS = {"Medicare"} + + +def _code(row) -> str: + return ((row or {}).get("code") or "").strip().upper() + + +def is_injection_row(row) -> bool: + """On the injection list, and not J2001 / J2930, which #5 always + unchecks and zeroes.""" + code = _code(row) + return code in INJECTION_CODES and code not in _UNCHECKED_BY_5 + + +def billed_injection_rows(cpt) -> list: + return [r for r in (cpt or []) + if is_injection_row(r) and vac.row_counts_as_billed(r)] + + +def _admin_state(cpt): + """"billed" — a billed 96372 on the claim; "priced" — none billed, but + one unchecked with a fee, which `priced_line_check` checks at the end; + None — no 96372 the claim will bill.""" + rows = [r for r in (cpt or []) if _code(r) == ADMIN_CODE] + if any(vac.row_counts_as_billed(r) for r in rows): + return "billed" + if any(priced_line_check.will_check(r) for r in rows): + return "priced" + return None + + +def admin_present(cpt) -> bool: + """A 96372 the claim bills — billed now, or checked by the last + condition. The only admin code here.""" + return _admin_state(cpt) is not None + + +def _payer_group(data: dict): + try: + mapping = payer_groups.load_mapping() + except Exception: + return None + return payer_groups.rule_group(vac._payer_name(data), mapping) + + +def _decide(data: dict, cpt) -> dict: + """`{"plan", "log", "review"}` — the vaccine rule's shape: `plan` is + None to leave the claim as read; `log` only for a claim with billed + injections; `review` when 96372 is missing for a reason a person has + to settle (never for Medicare, never when 96372 is billed).""" + injections = billed_injection_rows(cpt) + if not injections: + return {"plan": None, "log": None, "review": None} + codes = ", ".join(_code(r) for r in injections) + + admin = _admin_state(cpt) + if admin == "billed": + return {"plan": None, "review": None, + "log": f"Injection(s) {codes} already have {ADMIN_CODE} " + f"billed — nothing to add"} + if admin == "priced": + return {"plan": None, "review": None, + "log": f"Injection(s) {codes} have {ADMIN_CODE} unchecked " + f"with a fee — priced_line_check checks it at the " + f"end, so no second {ADMIN_CODE} is added"} + + group = _payer_group(data) + if group is None or group in _EXCLUDED_GROUPS: + payer = vac._payer_name(data) + if group == "Medicare": + why = f"payer {payer!r} is Medicare (document #20 excludes it)" + elif not payer: + why = "no primary payer name was read from this claim screen" + else: + why = (f"payer {payer!r} is not covered here " + f"(payer_groups.json cannot classify it)") + return {"plan": None, + "log": f"Injection(s) {codes} billed without {ADMIN_CODE}, " + f"but {why} — leaving the claim as read", + "review": (None if group == "Medicare" else + f"injection(s) {codes} billed without " + f"{ADMIN_CODE}, but {why} — add {ADMIN_CODE} by " + f"hand")} + + return {"plan": {"injections": [_code(r) for r in injections]}, + "log": None, "review": None} + + +def _plan(claim_id: str, data: dict, cpt): + decision = _decide(data, cpt) + if decision["log"]: + print(f"[COND {NAME} {claim_id}] {decision['log']}") + return decision["plan"] + + +def detect(claim_id: str, data: dict) -> bool: + return _plan(claim_id, data, (data or {}).get("cpt")) is not None + + +def needs_review(claim_id: str, data: dict): + """The reason 96372 is being left off for a person to add, else None — + the same `_decide()` as detect(), silent.""" + return _decide(data, (data or {}).get("cpt"))["review"] + + +def apply(page, claim_id: str, data: dict) -> ConditionResult: + """ + Re-opens CPT/HCPCS and re-reads the grid (an earlier condition may have + unchecked an injection line — J2001 / J2930), then adds 96372 x1. + Added → changed. Not added with the grid untouched → declined (Review, + "add it by hand"). Not added but the grid changed or could not be + re-read → changed with a review reason, so it is shown and never saved. + """ + tag = f"[COND {NAME} {claim_id}]" + try: + claim_form.open_cpt_hcpcs_tab(page) + except Exception as exc: + print(f"{tag} Could not confirm the CPT/HCPCS tab before fixing: " + f"{exc} — leaving the claim as read") + return ConditionResult.declined(NAME, CPT_TAB_NOT_CONFIRMED) + + live = claim_form.extract(page) + if has_cpt_rows(data) and not has_cpt_rows(live): + print(f"{tag} {CPT_GRID_NOT_READ} — leaving the claim as read") + return ConditionResult.declined(NAME, CPT_GRID_NOT_READ) + plan = _plan(claim_id, vac._facts(data, live), live.get("cpt")) + if plan is None: + print(f"{tag} Nothing to add on the live form — leaving the claim " + f"as read") + return ConditionResult.none(NAME) + + if not claim_form.writes_allowed(page): + # VERIFY adds no row: the picker's OK saves it in eCW at once. + review = f"{ADMIN_CODE} not added in VERIFY — add it by hand" + print(f"{tag} {review}") + return ConditionResult.declined(NAME, review) + + change = vac._add_admin_code(page, claim_id, ADMIN_CODE, 1, name=NAME) + change["injections"] = plan["injections"] + if not change["added"] and change["grid_changed"] is False: + return ConditionResult.declined( + NAME, f"{ADMIN_CODE} could not be added — add it by hand") + review = ("" if change["added"] else + f"{ADMIN_CODE} could not be added cleanly — check the CPT grid " + f"by hand") + return ConditionResult(condition_name=NAME, changed=True, + changes=[change], review=review) + + +def summary_comment(result: ConditionResult) -> str: + c = result.changes[0] if result.changes else {} + done = (f"{ADMIN_CODE} added" if c.get("added") + else f"{ADMIN_CODE} NOT added — add by hand") + return f"CPT Code Missing (injection admin code {done})" + + +def describe(result: ConditionResult) -> str: + c = result.changes[0] if result.changes else {} + injections = c.get("injections") or [] + lines = [f"Injection codes billed without {ADMIN_CODE} " + f"({len(injections)}): {', '.join(injections)}"] + if c.get("added"): + line = f" Added {c['code']} x{c['units']} (billed fee {c['billed_fee']})" + if c.get("error"): + line += f" — CHECK: {c['error']}" + else: + line = (f" FAILED to add {ADMIN_CODE} x1: {c.get('error')} — add it " + f"by hand") + lines.append(line) + for extra in c.get("unexpected") or []: + lines.append(f" CHECK BY HAND — the grid also changed while adding " + f"{ADMIN_CODE}: {extra}") + return "\n".join(lines) diff --git a/src/ecw_bot/conditions/j2930_j2001_update.py b/src/ecw_bot/conditions/j2930_j2001_update.py new file mode 100644 index 0000000..6bb0062 --- /dev/null +++ b/src/ecw_bot/conditions/j2930_j2001_update.py @@ -0,0 +1,74 @@ +r""" +Condition (document #5): J2930 (terminated as of 03/31/2024) and J2001 +should both be excluded from the claim entirely — unchecked and their +Billed Fee zeroed. + +Per the client's Charge Condition Document, row 5 (CPT `J2930 & J2001`, +payer `ALL`): + "CPT Code J2930. as it was term as of 03/31/2024 - The new code is + J2919 + + CPT code J2001 - Per provider update, this code can be unchecked in + the claim" + +The document's own wording for J2930 ("The new code is J2919") reads like +a code *replacement*, and an initial build explored that — remove the +J2930 row, add J2919 fresh via the Fee Schedule picker (the same +mechanism `add_cpt_row()` uses for G2025). The client corrected this +directly: no removal, no replacement code — J2930 gets the exact same +treatment as J2001, uncheck + zero its Billed Fee, nothing more. Simpler, +and it means both codes share one target set and one code path below. + +Unconditional across payers — the document lists "All". +""" + +from ..pages import claim_form +from .base import CPT_TAB_NOT_CONFIRMED, ConditionResult + +NAME = "j2930_j2001_update" + +TARGET_CPT_CODES = {"J2001", "J2930"} + + +def detect(claim_id: str, data: dict) -> bool: + for row in (data or {}).get("cpt") or []: + # Ignoring case, as zero_and_uncheck_cpt_rows() matches (review, + # 2026-10-12). + if (row.get("code") or "").strip().upper() in TARGET_CPT_CODES: + return True + return False + + +def apply(page, claim_id: str, data: dict) -> ConditionResult: + """ + Re-clicks CPT/HCPCS for the same reason `depression_dx_pointer.py`'s + and `medicare_telehealth.py`'s `apply()` do — eCW can leave the form + on a different tab after the Prog. Notes viewer cycle. A no-op on the + professional/HCFA screen, which has no such tab to lose. + """ + try: + claim_form.open_cpt_hcpcs_tab(page) + except Exception as exc: + print(f"[COND {NAME} {claim_id}] Could not confirm the CPT/HCPCS " + f"tab before fixing: {exc} — leaving the claim as read") + # Not "nothing to do": the claim matched and was never + # checked, so it goes to a human (review item D2). + return ConditionResult.declined(NAME, CPT_TAB_NOT_CONFIRMED) + + changes = claim_form.zero_and_uncheck_cpt_rows(page, TARGET_CPT_CODES) + if not changes: + return ConditionResult.none(NAME) + + return ConditionResult(condition_name=NAME, changed=True, changes=changes) + + +def describe(result: ConditionResult) -> str: + lines = ["J2930/J2001 excluded from the claim:"] + for c in result.changes: + bits = [] + if c.get("unchecked"): + bits.append("checkbox unchecked") + if "fee" in c: + bits.append(f"fee {c['fee']['before']} -> {c['fee']['after']}") + lines.append(f" Row {c['row'] + 1} ({c['code']}): {', '.join(bits)}") + return "\n".join(lines) diff --git a/src/ecw_bot/conditions/lines.py b/src/ecw_bot/conditions/lines.py new file mode 100644 index 0000000..99feb07 --- /dev/null +++ b/src/ecw_bot/conditions/lines.py @@ -0,0 +1,60 @@ +r""" +What a CPT line comes to by the end of a run — shared by the conditions +that ask "is this code billed?" of the claim as read: dental_payer_check +(#13) and medicare_vaccine_a6 (#19). + +The operator's common rule brackets every claim (`conditions/__init__.py`): +zero_fee_uncheck, first, unchecks a checked line billed at zero, and +priced_line_check, last, checks an unchecked line that carries a fee. But +`detect()` and `needs_review()` see the claim as READ, before either has +run. So the question is answered for the end of the run, not for the read +(review, 2026-10-19): an unchecked G0008 at 40.00 was not counted, then +priced_line_check checked it, and the claim billed G0008 to Medicare with +no A6 and no Review. `vaccine_admin_codes.row_counts_as_billed()` answers +only the first half of the rule, and stays as it is for #18 / #20, whose +rulings were made on it. +""" + +from ..pages import claim_form +from . import priced_line_check + + +def code(row) -> str: + """A line's code, upper-cased, spaces stripped — '' when it has none.""" + return str((row or {}).get("code") or "").strip().upper() + + +def ends_billed(row) -> bool: + """ + Whether the line is billed once the run is done: + + * a Billed Fee read as zero → not billed, whatever its box. A checked + one is unchecked by zero_fee_uncheck; a line with no Assign To + Patient box (`billed` None — some UB forms) is excluded by its zero + fee alone (ruling 2026-10-12, #14). A fee never read (`billed_fee` + None) is unknown, not zero. + * unchecked → billed only when priced_line_check checks it + (`will_check`: a real fee). + * checked, or no box → billed. + """ + row = row or {} + fee = row.get("billed_fee") + if fee is not None and claim_form.is_zero_fee(fee): + return False + if row.get("billed") is False: + return priced_line_check.will_check(row) + return True + + +def billed_codes(cpt, matches) -> list: + """The codes `matches(code)` picks among the lines that end billed — + upper-cased, each code once, in grid order.""" + return list(dict.fromkeys(code(r) for r in (cpt or []) + if matches(code(r)) and ends_billed(r))) + + +def primary_payer(data) -> str: + """The first insurance row's payer name, '' when none was read — the + payer every condition goes by.""" + rows = (data or {}).get("insurance") or [] + return str(rows[0].get("Name") or "").strip() if rows else "" diff --git a/src/ecw_bot/conditions/medicaid_pregnancy_th.py b/src/ecw_bot/conditions/medicaid_pregnancy_th.py new file mode 100644 index 0000000..f411e7e --- /dev/null +++ b/src/ecw_bot/conditions/medicaid_pregnancy_th.py @@ -0,0 +1,180 @@ +r""" +Condition (document #23): a pregnancy-related visit on Medicaid carries the +TH modifier. + +Document row 23 (E&M or other primary procedure, Medicaid): "Add a TH +modifier for E&M code or Other primary procedure if any Pregnancy-related +ICD code is available in the claim. It is not applicable if only the +Pregnancy test were performed" — Z32.00 / Z32.01 / Z32.02 do not count. + +Operator, 2026-10-20: + + * **Medicaid only** — payer_groups rule group "Medicaid"; NOT the RAE + (BH) payers, CHP+ or any other group. + * Z32.00 / Z32.01 / Z32.02 (pregnancy test) never make a visit a + pregnancy visit: TH is not added for them. + * TH goes in **M1**. + * A Medicaid telehealth pregnancy visit gets **TH, not GT**: the + telehealth condition (#21) writes TH in M1 for it instead of GT + (`telehealth.apply`), so this condition then finds TH already there. + +Confirmed by the operator 2026-10-21: + + * "Pregnancy-related ICD" = any ICD on the claim in chapter O (O00-O9A), + or Z33 (pregnant state), Z34 (supervision of normal pregnancy), Z36 + (antenatal screening), Z3A (weeks of gestation). Progress-note keywords + are not used: "pregnancy test" in a note would read as a pregnancy + visit. + * The line: the first billed E&M line (99202-99499), TH in its M1. With + no E&M line, the first billed line ("other primary procedure"). + * M1 already holding another modifier (25, 59 ...) → **overwritten** with + TH, not moved to M2-M4; the comment names what it replaced. + * TH anywhere on the line already → nothing (no second TH). + * Dental claims: no modifier columns → Review (DENTAL_UNSUPPORTED). + +A modifier typed into the grid is saved only with the claim's OK, like #12 +and #21 (no click-to-save hook on the modifier boxes); VERIFY closes with +Cancel. +""" + +import re + +from ..core import payer_groups +from ..pages import claim_form +from . import lines +from .base import CPT_GRID_NOT_READ, CPT_TAB_NOT_CONFIRMED, ConditionResult + +NAME = "medicaid_pregnancy_th" +MODIFIER = "TH" + +DENTAL_UNSUPPORTED = ("pregnancy visit on Medicaid: the Dental grid has no " + "modifier column — add TH by hand") + +# Pregnancy test only — never a pregnancy visit (document #23). +PREGNANCY_TEST_ICDS = {"Z32.00", "Z32.01", "Z32.02"} +_PREGNANCY_ICD = re.compile(r"^(O|Z33|Z34|Z36|Z3A)", re.I) + + +def _icd_codes(data) -> list: + out = [] + for row in (data or {}).get("icd") or []: + code = str((row or {}).get("Code") or (row or {}).get("code") + or "").strip().upper() + if code: + out.append(code) + return out + + +def pregnancy_icds(data) -> list: + """The claim's pregnancy-related ICD codes, in order (Z32.0x left out).""" + return [c for c in _icd_codes(data) + if c not in PREGNANCY_TEST_ICDS and _PREGNANCY_ICD.match(c)] + + +def is_medicaid(data) -> bool: + payer = lines.primary_payer(data) + if not payer: + return False + try: + mapping = payer_groups.load_mapping() + except Exception: + return False + return payer_groups.rule_group(payer, mapping) == "Medicaid" + + +def is_pregnancy_visit_on_medicaid(data) -> bool: + """Medicaid (not RAE) and a pregnancy-related ICD on the claim — what + #21 asks before choosing TH over GT.""" + return bool(pregnancy_icds(data)) and is_medicaid(data) + + +def _is_em(code: str) -> bool: + return code.isdigit() and 99202 <= int(code) <= 99499 + + +def target_code(data): + """The line that gets TH: the first billed E&M line, else the first + billed line; None when nothing is billed.""" + billed = [lines.code(r) for r in (data or {}).get("cpt") or [] + if lines.code(r) and lines.ends_billed(r)] + return next((c for c in billed if _is_em(c)), billed[0] if billed + else None) + + +def _has_th(row) -> bool: + return any(str((row or {}).get(f"mod{n}") or "").strip().upper() + == MODIFIER for n in range(1, 5)) + + +def detect(claim_id: str, data: dict) -> bool: + if not is_pregnancy_visit_on_medicaid(data): + return False + code = target_code(data) + if code is None: + return False + row = next(r for r in (data or {}).get("cpt") or [] + if lines.code(r) == code) + return not _has_th(row) + + +def apply(page, claim_id: str, data: dict) -> ConditionResult: + try: + claim_form.open_cpt_hcpcs_tab(page) + except Exception as exc: + print(f"[COND {NAME} {claim_id}] Could not confirm the CPT/HCPCS " + f"tab before fixing: {exc} — leaving the claim as read") + return ConditionResult.declined(NAME, CPT_TAB_NOT_CONFIRMED) + + code = target_code(data) + done = claim_form.put_row_m1(page, code, MODIFIER) + if done is None: + print(f"[COND {NAME} {claim_id}] {CPT_GRID_NOT_READ} — leaving the " + f"claim as read") + return ConditionResult.declined(NAME, CPT_GRID_NOT_READ) + if done.get("present"): + print(f"[COND {NAME} {claim_id}] {code} already has {MODIFIER}") + return ConditionResult.none(NAME) + icds = pregnancy_icds(data) + change = {"row": done["row"], "code": code, "box": done.get("box"), + "before": done.get("before"), "after": done.get("after"), + "icds": icds} + if done.get("box") is None: + review = (f"{code}: no M1 box on the line — add {MODIFIER} by hand " + f"(pregnancy ICD {icds[0]} on Medicaid)") + print(f"[COND {NAME} {claim_id}] {review}") + return ConditionResult.declined(NAME, review) + if not done.get("ok"): + review = (f"{MODIFIER} did not take on {code} (M{done['box']} read " + f"back {done.get('after')!r}) — add it by hand") + print(f"[COND {NAME} {claim_id}] {review}") + return ConditionResult(condition_name=NAME, changed=True, + changes=[change], review=review) + print(f"[COND {NAME} {claim_id}] {MODIFIER} added in M1 on {code}" + f"{_replaced(change)} (pregnancy ICD {', '.join(icds)})") + return ConditionResult(condition_name=NAME, changed=True, + changes=[change]) + + +def _replaced(change) -> str: + before = str(change.get("before") or "").strip() + return f", replacing {before}" if before else "" + + +def summary_comment(result: ConditionResult) -> str: + c = result.changes[0] if result.changes else {} + icd = (c.get("icds") or ["pregnancy ICD"])[0] + if result.review: + return (f"TH modifier NOT added on {c.get('code', 'the visit')} " + f"({icd} on Medicaid) — add it by hand") + return (f"TH modifier added on {c.get('code', 'the visit')}" + f"{_replaced(c)} (pregnancy visit, {icd} on Medicaid)") + + +def describe(result: ConditionResult) -> str: + out = ["Pregnancy visit on Medicaid — TH modifier (#23):"] + for c in result.changes: + out.append(f" Row {c['row'] + 1} ({c['code']}): M{c['box']} " + f"{c.get('before') or '(empty)'!s} -> {c.get('after')}") + if result.review: + out.append(f" {result.review}") + return "\n".join(out) diff --git a/src/ecw_bot/conditions/medicaid_uncheck_cpts.py b/src/ecw_bot/conditions/medicaid_uncheck_cpts.py new file mode 100644 index 0000000..3a0d679 --- /dev/null +++ b/src/ecw_bot/conditions/medicaid_uncheck_cpts.py @@ -0,0 +1,82 @@ +r""" +Condition (document #43, #44): CPT codes never billed to Medicaid — +unchecked and their Billed Fee zeroed, the same treatment +`dummy_codes.py` and `j2930_j2001_update.py` give their codes. + +Per the client's Charge Condition Document: + * #43 — 15851, 15852, payer Medicaid: "Non-billable to Medicaid, need to + uncheck". + * #44 — 73521, payer Medicaid: "Imaging should never be billed to + Medicaid, uncheck". + +Operator, 2026-10-13: one file for both rows; uncheck and Billed Fee 0. + +Payer: the first insurance row's payer, `payer_groups.rule_group() == +"Medicaid"` — the dashboard's "Medicaid" group (Medicaid, HCFA Medicaid, +Denver Health Medicaid Choice, ...) ONLY. The RAE payers ("RAE payer" +group, BH COLORADO ACCESS ...), CHP+ and every other group are NOT +included (operator, 2026-10-14: "Medicaid only, not RAE and others"); an +unmapped or unread payer never fires — the codes stay as they are rather +than be unchecked on a guess. +""" + +from ..core import payer_groups +from ..pages import claim_form +from .base import CPT_TAB_NOT_CONFIRMED, ConditionResult + +NAME = "medicaid_uncheck_cpts" + +TARGET_CPT_CODES = {"15851", "15852", "73521"} + +MEDICAID_GROUPS = {"Medicaid"} + + +def is_medicaid_claim(data: dict) -> bool: + rows = (data or {}).get("insurance") or [] + payer = (rows[0].get("Name") or "").strip() if rows else "" + if not payer: + return False + try: + mapping = payer_groups.load_mapping() + except Exception: + return False + return payer_groups.rule_group(payer, mapping) in MEDICAID_GROUPS + + +def detect(claim_id: str, data: dict) -> bool: + if not any((row.get("code") or "").strip().upper() in TARGET_CPT_CODES + for row in (data or {}).get("cpt") or []): + return False + return is_medicaid_claim(data) + + +def apply(page, claim_id: str, data: dict) -> ConditionResult: + """ + Re-clicks CPT/HCPCS for the same reason `dummy_codes.py`'s `apply()` + does — eCW can leave the form on a different tab after the Prog. Notes + viewer cycle. + """ + try: + claim_form.open_cpt_hcpcs_tab(page) + except Exception as exc: + print(f"[COND {NAME} {claim_id}] Could not confirm the CPT/HCPCS " + f"tab before fixing: {exc} — leaving the claim as read") + return ConditionResult.declined(NAME, CPT_TAB_NOT_CONFIRMED) + + changes = claim_form.zero_and_uncheck_cpt_rows(page, TARGET_CPT_CODES) + if not changes: + return ConditionResult.none(NAME) + + return ConditionResult(condition_name=NAME, changed=True, changes=changes) + + +def describe(result: ConditionResult) -> str: + lines = ["Not billable to Medicaid (15851/15852/73521) — excluded:"] + for c in result.changes: + bits = [] + if c.get("unchecked"): + bits.append("checkbox unchecked") + if "fee" in c: + bits.append(f"fee {c['fee']['before']} -> {c['fee']['after']}") + lines.append(f" Row {c['row'] + 1} ({c['code']}): {', '.join(bits)}") + return "\n".join(lines) diff --git a/src/ecw_bot/conditions/medicare_billing_screening.py b/src/ecw_bot/conditions/medicare_billing_screening.py new file mode 100644 index 0000000..ec9987c --- /dev/null +++ b/src/ecw_bot/conditions/medicare_billing_screening.py @@ -0,0 +1,148 @@ +r""" +Condition (document #14): Medicare Billing Screening — the depression +screening codes G8510 (negative) and G8431 (positive) are not billed to +Medicare. + +Per the client's Charge Condition Document, row 14 (CPT `G8510,G8431`, +payer `Medicare`): + "Do not bill Screening codes. - (If screening is only performed, then + we will move to provider clarification)" + +Operator, 2026-10-07: on a Medicare claim every G8510/G8431 line is +unchecked and its Billed Fee zeroed — those two codes only; every other CPT +line is left exactly as it is. The depression-pointer process (#6, +`depression_dx_pointer.py`) does not run on a Medicare claim at all: its +`detect()` asks `is_medicare_claim()` below. Otherwise it would re-check +the G8510 this condition excludes. + +"Screening only": when no other line on the claim is billed, the screening +codes are still zeroed, and the claim also goes to Review for provider +clarification — with them gone there is nothing left to bill. Judged on the +LIVE grid after the zeroing, not on the claim as first read (review, +2026-10-12): earlier conditions in the registry (zero_fee_uncheck, the +dummy and J-code exclusions) may have unchecked other lines since. + +A UB form with no Assign-To-Patient checkbox only has its fee zeroed; +operator, 2026-10-12: that is enough there, and `summary_comment()` says +"fee zeroed", not "unchecked". + +Medicare is decided the same way `medicare_telehealth.py` decides it: the +first insurance row's payer, `payer_groups.rule_group() == "Medicare"` +(Medicare Advantage plans are Commercial, as on the dashboard — ruling +2026-10-21). An unmapped or missing payer is +not Medicare here, so the #6 process runs for it as before. +""" + +from ..core import payer_groups +from ..pages import claim_form +from .base import (CPT_GRID_NOT_READ, CPT_TAB_NOT_CONFIRMED, + ConditionResult, has_cpt_rows) + +NAME = "medicare_billing_screening" + +TARGET_CPT_CODES = {"G8510", "G8431"} + +SCREENING_ONLY = ("Screening only (G8510/G8431) on a Medicare claim — " + "provider clarification needed") + + +def is_medicare_claim(data: dict) -> bool: + rows = (data or {}).get("insurance") or [] + payer = (rows[0].get("Name") or "").strip() if rows else "" + if not payer: + return False + try: + mapping = payer_groups.load_mapping() + except Exception: + return False + return payer_groups.rule_group(payer, mapping) == "Medicare" + + +def _code(row) -> str: + return (row.get("code") or "").strip().upper() + + +def detect(claim_id: str, data: dict) -> bool: + if not any(_code(r) in TARGET_CPT_CODES + for r in (data or {}).get("cpt") or []): + return False + return is_medicare_claim(data) + + +def _is_billed(row) -> bool: + """ + A line still billed: not unchecked, and a fee that is not zero. A UB + line with no checkbox (`billed` None) counts by its fee alone; a line + with no fee input (`billed_fee` None) only when it is checked. + """ + if row.get("billed") is False: + return False + fee = row.get("billed_fee") + if fee is None: + return row.get("billed") is True + return not claim_form.is_zero_fee(fee) + + +def _screening_only(rows) -> bool: + """No line other than G8510/G8431 still billed.""" + return not any(_code(r) and _code(r) not in TARGET_CPT_CODES + and _is_billed(r) for r in rows or []) + + +def apply(page, claim_id: str, data: dict) -> ConditionResult: + """ + Re-clicks CPT/HCPCS for the same reason `dummy_codes.py`'s `apply()` + does — eCW can leave the form on a different tab after the Prog. Notes + viewer cycle. + """ + try: + claim_form.open_cpt_hcpcs_tab(page) + except Exception as exc: + print(f"[COND {NAME} {claim_id}] Could not confirm the CPT/HCPCS " + f"tab before fixing: {exc} — leaving the claim as read") + return ConditionResult.declined(NAME, CPT_TAB_NOT_CONFIRMED) + + changes = claim_form.zero_and_uncheck_cpt_rows(page, TARGET_CPT_CODES) + live = claim_form.extract(page).get("cpt") or [] + if not has_cpt_rows({"cpt": live}): + if not changes and has_cpt_rows(data): + # The first read had CPT rows; the live grid showed none. The + # fix was never applied — not "nothing to do". + print(f"[COND {NAME} {claim_id}] {CPT_GRID_NOT_READ} — leaving " + f"the claim as read") + return ConditionResult.declined(NAME, CPT_GRID_NOT_READ) + live = (data or {}).get("cpt") or [] + review = SCREENING_ONLY if _screening_only(live) else "" + if review: + print(f"[COND {NAME} {claim_id}] {review}") + if not changes: + return ConditionResult.declined(NAME, review) if review \ + else ConditionResult.none(NAME) + + return ConditionResult(condition_name=NAME, changed=True, changes=changes, + review=review) + + +def summary_comment(result: ConditionResult) -> str: + """The Charge Summary comment, worded from what was actually done.""" + done = [] + if any(c.get("unchecked") for c in result.changes): + done.append("unchecked") + if any("fee" in c for c in result.changes): + done.append("fee zeroed") + return (f"Screening codes not billed to Medicare (G8510/G8431 " + f"{' and '.join(done) or 'excluded'})") + + +def describe(result: ConditionResult) -> str: + lines = ["Screening codes not billed to Medicare:"] + for c in result.changes: + bits = [] + if c.get("unchecked"): + bits.append("checkbox unchecked") + if "fee" in c: + bits.append(f"fee {c['fee']['before']} -> {c['fee']['after']}") + lines.append(f" Row {c['row'] + 1} ({c['code']}): {', '.join(bits)}") + if result.review: + lines.append(f" {result.review}") + return "\n".join(lines) diff --git a/src/ecw_bot/conditions/medicare_pps_code.py b/src/ecw_bot/conditions/medicare_pps_code.py new file mode 100644 index 0000000..5bb9018 --- /dev/null +++ b/src/ecw_bot/conditions/medicare_pps_code.py @@ -0,0 +1,279 @@ +r""" +Condition (document #25): a PPS Payer's claim must carry its PPS Code. + +Per the client's Charge Condition Document, row 25: "For these payers, we +need to submit the claim with Valid PPS codes. These payers won't accept the +claims without PPS Codes. PPS Codes: From G0466 to G0470." Operator, +2026-10-23 (spec EEWA-34; terms in GLOSSARY.md): + + * **PPS Payers**, by their exact eCW names (`PPS_PAYERS`): Medicare, + Medicare-Novitas, HCFA Medicare, HCFA Medicare Novitas, Humana + Medicare, Devoted MCR UB, Cigna Healthspring, Cigna Health Spring/ + Bravo. The two Cigna plans count here although ruling #22 makes them + Commercial for every other condition. + * The client's **Medicare G code crosswalk** maps each billed Qualifying + Code to the PPS Code the claim needs (`CROSSWALK`). + * G0468 is added only after #27–#29 (`medicare_wellness_history`) read + the preventive visit history and found no repeat. A PPS Payer that + condition never checks (the Cigna plans) → Review, not an add. +""" + +from ..pages import claim_form +from . import lines +from . import medicare_wellness_history as wellness +from . import vaccine_admin_codes as vac +from .base import (CPT_GRID_NOT_READ, CPT_TAB_NOT_CONFIRMED, + ConditionResult, has_cpt_rows) + +NAME = "medicare_pps_code" + +# The client's Medicare G code crosswalk (operator, 2026-10-23), as given. +_G0466 = ("92002 92004 97802 99201 99202 99203 99204 99205 99324 99325 " + "99326 99327 99328 G0101 G0102 G0108 G0117 G0118 G0436 G0437 " + "G0442 G0443 G0444 G0445 G0446 G0447") +_G0466H = "99341 99342 99343 99344 99345" +_G0467 = ("92012 92014 97802 97803 99212 99213 99214 99215 99334 99335 " + "99336 99337 99495 99496 G0101 G0102 G0108 G0117 G0118 G0270 " + "Q0091 G0436 G0437 G0442 G0443 G0444 G0445 G0446 G0447") +_G0467NH = "99304 99305 99306 99307 99308 99309 99310 99315 99316 99318" +_G0467H = "99347 99348 99349 99350" +_G0468 = "G0402 G0438 G0439" +_MENTAL = "90791 90792 90832 90834 90837 90839 90845" + +CROSSWALK = { + "G0466": frozenset(_G0466.split()), + "G0466H": frozenset(_G0466H.split()), + "G0467": frozenset(_G0467.split()), + "G0467NH": frozenset(_G0467NH.split()), + "G0467H": frozenset(_G0467H.split()), + "G0468": frozenset(_G0468.split()), + "G0469": frozenset(_MENTAL.split()), + "G0470": frozenset(_MENTAL.split()), +} + +PPS_PAYERS = frozenset(n.lower() for n in ( + "Medicare", "Medicare-Novitas", "HCFA Medicare", "HCFA Medicare Novitas", + "Humana Medicare", "Devoted MCR UB", "Cigna Healthspring", + "Cigna Health Spring/ Bravo")) + + +def is_pps_payer(data: dict) -> bool: + """The claim's primary payer is a PPS Payer — by its exact eCW name + (case and outer spaces aside), not by payer group.""" + return lines.primary_payer(data).lower() in PPS_PAYERS + + +PPS_CODES = frozenset(CROSSWALK) +_MEDICAL = ("G0466", "G0466H", "G0467", "G0467NH", "G0467H") + + +def _join(codes) -> str: + return ", ".join(codes) + + +def _medical(billed, billed_pps): + """The medical visit's PPS Code: `(add, review)`, at most one set. + G0402 / G0438 / G0439 → G0468 whatever else is billed. Else a code on + one list only decides new / established / home / nursing home; codes on + both the G0466 and G0467 lists decide nothing on their own. Conflicting + codes, or a different medical PPS Code billed (even beside the right + one), are a person's call.""" + billed_medical = [h for h in billed_pps if h in _MEDICAL or h == "G0468"] + awv = [c for c in billed if c in CROSSWALK["G0468"]] + if awv: + targets = {"G0468": awv} + on_both_lists = [] + else: + targets, on_both_lists = {}, [] + for code in billed: + homes = [p for p in _MEDICAL if code in CROSSWALK[p]] + if len(homes) == 1: + targets.setdefault(homes[0], []).append(code) + elif homes: + on_both_lists.append(code) + + if len(targets) > 1: + named = "; ".join(f"{_join(c)} ({t})" for t, c in targets.items()) + return None, (f"conflicting visit codes: {named} — add the PPS " + f"code by hand") + if targets: + (target, codes), = targets.items() + others = [h for h in billed_medical if h != target] + if others: + return None, (f"{_join(others)} billed, crosswalk says " + f"{target} ({_join(codes)}) — check the PPS code " + f"by hand") + if target in billed_pps: + return None, None + return (target, codes), None + if on_both_lists and not billed_medical: + return None, (f"{_join(on_both_lists)} on both the G0466 and G0467 " + f"lists: new or established? Add G0466 or G0467 by " + f"hand") + return None, None + + +def _mental(billed, billed_pps): + """Mental health: Review until the client rules G0469 vs G0470 (all its + codes are on both lists); either one billed → nothing to ask.""" + codes = [c for c in billed if c in CROSSWALK["G0469"]] + if not codes or "G0469" in billed_pps or "G0470" in billed_pps: + return None + return (f"mental health {_join(codes)}: G0469 or G0470? (new or " + f"established, waiting for the client's ruling) — add it by hand") + + +def _wellness_hold(data, medical_add): + """Why G0468 must not be added, or None. `medicare_wellness_history` + (#27–#29, earlier in the registry) leaves its verdict on the claim as + `_wellness_history`: a preventive visit billed too often, or a history + it could not read, is a person's call, not an add. No verdict yet: on a + claim #27–#29 checks (Medicare group), it comes before `apply()` — + nothing held in the detect pass; on any other PPS Payer (the Cigna + plans, Commercial there) the history is never checked → held + (operator, 2026-10-23).""" + if not medical_add or medical_add[0] != "G0468": + return None + verdict = (data or {}).get("_wellness_history") + if not verdict: + if wellness.detect("", data): + return None + why = "the preventive visit history not checked for this payer" + elif not verdict.get("read"): + why = "the preventive visit history not read" + elif verdict.get("conflicts"): + why = "; ".join(verdict["conflicts"]) + else: + return None + return (f"G0468 not added ({_join(medical_add[1])}): {why} — check the " + f"PPS code by hand") + + +def decide(data: dict) -> dict: + """What the claim needs: `{"add": [(PPS Code, [Qualifying Codes])], + "review": [reason]}`. Decided on the lines that end billed; nothing for + a payer that is not a PPS Payer or a Dental claim.""" + data = data or {} + if not is_pps_payer(data) or data.get("screen") == "dental": + return {"add": [], "review": []} + billed = lines.billed_codes(data.get("cpt"), lambda c: bool(c)) + billed_pps = [c for c in billed if c in PPS_CODES] + add, review = [], [] + medical_add, medical_review = _medical(billed, billed_pps) + held = _wellness_hold(data, medical_add) + if held: + review.append(held) + elif medical_add: + add.append(medical_add) + for reason in (medical_review, _mental(billed, billed_pps)): + if reason: + review.append(reason) + screen = data.get("screen") + if screen != "ub": + # The PPS Code is billed on the UB form (Medicare = UB): a HCFA + # claim with a Qualifying Code is a person's call — the wrong form + # is caught even when its PPS Code is there — never an add. + qualifying = [c for c in billed + if any(c in codes for codes in CROSSWALK.values())] + if add: + review = [f"{pps_code} ({_join(codes)}) on a {screen or 'non-UB'} " + f"claim — check the PPS code by hand" + for pps_code, codes in add] + review + add = [] + elif qualifying and not review: + review = [f"{_join(qualifying)} on a {screen or 'non-UB'} claim " + f"— check the PPS code by hand"] + return {"add": add, "review": review} + + +def detect(claim_id: str, data: dict) -> bool: + return bool(decide(data)["add"]) + + +def needs_review(claim_id: str, data: dict): + """Why a person must settle the PPS Code, when nothing is added.""" + decision = decide(data) + if decision["add"] or not decision["review"]: + return None + return "; ".join(decision["review"]) + + +def apply(page, claim_id: str, data: dict) -> ConditionResult: + """ + Re-opens CPT/HCPCS and decides again on the grid as it is now (an + earlier condition may have unchecked a line). VERIFY adds nothing — the + Fee Schedule picker's OK saves in eCW at once — so the claim is Review, + naming the PPS Code to add by hand. + """ + tag = f"[COND {NAME} {claim_id}]" + try: + claim_form.open_cpt_hcpcs_tab(page) + except Exception as exc: + print(f"{tag} Could not confirm the CPT/HCPCS tab: {exc} — leaving " + f"the claim as read") + return ConditionResult.declined(NAME, CPT_TAB_NOT_CONFIRMED) + + live = claim_form.extract(page) + if has_cpt_rows(data) and not has_cpt_rows(live): + print(f"{tag} {CPT_GRID_NOT_READ} — leaving the claim as read") + return ConditionResult.declined(NAME, CPT_GRID_NOT_READ) + facts = vac._facts(data, live) + # By now #27–#29 has run: no verdict means it raised or never got the + # claim, so the history is not read. + facts["_wellness_history"] = ((data or {}).get("_wellness_history") + or {"read": False, "conflicts": []}) + decision = decide(facts) + review = list(decision["review"]) + if not decision["add"]: + print(f"{tag} Nothing to add on the live form") + return (ConditionResult.declined(NAME, "; ".join(review)) if review + else ConditionResult.none(NAME)) + + if not claim_form.writes_allowed(page): + review = [f"{pps_code} not added in VERIFY — add it by hand " + f"({_join(codes)})" for pps_code, codes in decision["add"] + ] + review + print(f"{tag} {'; '.join(review)}") + return ConditionResult.declined(NAME, "; ".join(review)) + + # LIVE-WRITE: eCW's own Fee Schedule picker, Units 1, the grid read + # before and after (`vaccine_admin_codes._add_admin_code`). + changes = [] + for pps_code, codes in decision["add"]: + change = vac._add_admin_code(page, claim_id, pps_code, 1, name=NAME) + change["qualifying"] = codes + changes.append(change) + if not change["added"]: + review.append(f"{pps_code} could not be added — add it by hand " + f"({_join(codes)})") + if not any(c["added"] or c["grid_changed"] is not False for c in changes): + return ConditionResult.declined(NAME, "; ".join(review)) + return ConditionResult(condition_name=NAME, changed=True, + changes=changes, review="; ".join(review)) + + +def summary_comment(result: ConditionResult) -> str: + done = [f"PPS code {c['code']} added ({_join(c['qualifying'])})" + if c.get("added") else + f"PPS code {c['code']} NOT added — add by hand" + for c in result.changes] + return f"CPT Code Missing ({'; '.join(done)})" + + +def describe(result: ConditionResult) -> str: + out = ["PPS Code missing for a PPS Payer (#25):"] + for c in result.changes: + codes = _join(c.get("qualifying") or []) + if c.get("added"): + line = (f" Added {c['code']} x{c['units']} for {codes} (billed " + f"fee {c['billed_fee']})") + if c.get("error"): + line += f" — CHECK: {c['error']}" + else: + line = (f" FAILED to add {c['code']} for {codes}: " + f"{c.get('error')} — add it by hand") + out.append(line) + for extra in c.get("unexpected") or []: + out.append(f" CHECK BY HAND — the grid also changed while " + f"adding {c['code']}: {extra}") + return "\n".join(out) diff --git a/src/ecw_bot/conditions/medicare_telehealth.py b/src/ecw_bot/conditions/medicare_telehealth.py new file mode 100644 index 0000000..350ae59 --- /dev/null +++ b/src/ecw_bot/conditions/medicare_telehealth.py @@ -0,0 +1,329 @@ +r""" +Condition 2 (document #22): Medicare telehealth — modifier on the claim's +primary CPT row, plus the G2025 line item and cleanup of every other row. + +Per the client's Charge Condition Document: "For Telehealth Service, add +G2025 with 95 modifier if it is not available in the claim. Other Primary +CPT codes need to be unchecked and change the fee amount as Zero." The +modifier itself follows the same audio/video split as the general +telehealth condition (#21) — a Medicare visit is audio-only (telephone) +just as often as any other payer's, and that still means modifier 93, not +95 (seen live: a real progress note reading "THV Audio Only: Telehealth +audio phone call only. No video") — applied consistently to G2025 too, +not hardcoded to 95, since the document's "95" is the video case and says +nothing about audio-only Medicare visits one way or the other. Audio vs +video is decided by `claim_form.telehealth_mode_evidence()` (explicit +audio-only phrases, then explicit video, then "telephone", else video), +and the phrase that decided it is shown in `describe()`. + +Three steps, in order: + 1. Set POS=2 and M1=93/95 (per audio/video mode) on the claim's first + real CPT/HCPCS row — written only if it is not already right (review + item D3, 2026-10-05). + 2. Add G2025 (same modifier) if it is not already on the claim — + `claim_form.apply_medicare_telehealth_g2025()`, which types the code + into eCW's own add-row field and verifies the result rather than + assuming the add worked, then reads the new row's M1 back. + 3. Once G2025 is confirmed on the claim (already there, or just added), + uncheck and zero-fee every OTHER CPT row. Gated on that confirmation + deliberately — running step 3 against a claim where step 2 could not + be confirmed would leave nothing billable at all. + +**A failed G2025 is reported as failed** (review item D6, 2026-10-05). +Before this, an unconfirmed G2025 still came back `changed=True` with a +describe() that never mentioned G2025, and the fixed summary comment said +"G2025 added … other CPTs unchecked" — false on the UB screen, where the +Add flow has no evidence behind it. Now the result carries a +`{"code": "G2025", "failed": True}` record, describe() says so in plain +words, `summary_comment()` is built from what actually happened, and the +result's `review` keeps the save box away from the claim. With nothing +changed at all, the result is `declined(...)` — still a Review row. + +**VERIFY adds no G2025** (option C, 2026-10-16): the picker's OK saves a +row in eCW at once. Step 3 still runs, on screen only, so the rest of the +run sees the claim LIVE-WRITE would leave; the result carries a +`{"code": "G2025", "verify_only": True}` record and a review — add G2025 +and uncheck the other rows by hand (review, 2026-10-19). + +**An already-correct claim is not a change** (D3): G2025 already present +with this visit's modifier, the primary row already right and nothing left +to zero → `none()`. A G2025 already present with a different or blank M1 +is read, not rewritten, and sent to review (review fix round 1, +2026-10-05) — `declined(...)` if nothing else changed. + +**A primary-row write that did not take stops the condition there** +(review fix round 1): the changed row is returned with a review, and steps +2 and 3 do not run — no G2025 add, no other row zeroed — so the operator +fixes one row by hand, not a heavily edited form. + +A separate condition from `conditions/telehealth.py` (#21) rather than a +branch inside it: the document numbers them separately, and #21's own +`_choose_modifier` returns "not covered" for Medicare specifically so the +two conditions never both fire on the same row on the same claim. +""" + +from ..core import payer_groups +from ..pages import claim_form +from .base import (CPT_GRID_NOT_READ, CPT_TAB_NOT_CONFIRMED, + ConditionResult, fix_text, has_cpt_rows) + +NAME = "medicare_telehealth" + +# The Dental Claim screen has no per-row POS and no modifier columns, and its G2025 add flow is not verified: on a Dental claim this condition +# is not applied, and the claim goes to Review (operator, 2026-10-12). +DENTAL_UNSUPPORTED = ( + "Medicare telehealth visit on a Dental claim — the Dental screen " + "has no POS / modifier column and G2025 cannot be added there; fix " + "by hand") + +# Review reasons — one wording each, so the box, the log and the Charge +# Summary say the same thing. +G2025_NOT_ADDED = ("G2025 NOT added — add it by hand; the other CPT rows " + "were NOT unchecked") +G2025_MODIFIER_NOT_TAKEN = "G2025 added but its modifier did not take — check M1" +# VERIFY: adding a row saves it in eCW at once, so G2025 is left to a person; +# the other rows are unchecked / zeroed on screen only (review, 2026-10-19). +G2025_VERIFY_BY_HAND = ("G2025 not added in VERIFY — add it by hand and " + "uncheck / zero the other CPT rows") + + +def detect(claim_id: str, data: dict) -> bool: + note = (data or {}).get("progress_notes") or {} + if not claim_form.note_mentions_telehealth(note.get("text", "")): + return False + + rows = (data or {}).get("insurance") or [] + payer = (rows[0].get("Name") or "").strip() if rows else "" + try: + mapping = payer_groups.load_mapping() + except Exception: + return False + return payer_groups.rule_group(payer, mapping) == "Medicare" + + +def apply(page, claim_id: str, data: dict) -> ConditionResult: + """ + Re-clicks CPT/HCPCS for the same reason `telehealth.py`'s `apply()` + does — eCW can leave the form on a different tab after the Prog. Notes + viewer cycle (seen live, claim 9116000). + """ + try: + claim_form.open_cpt_hcpcs_tab(page) + except Exception as exc: + print(f"[COND {NAME} {claim_id}] Could not confirm the CPT/HCPCS " + f"tab before fixing: {exc} — leaving the claim as read") + # Not "nothing to do": the claim matched and was never + # checked, so it goes to a human (review item D2). + return ConditionResult.declined(NAME, CPT_TAB_NOT_CONFIRMED) + + note = (data or {}).get("progress_notes") or {} + text = note.get("text", "") + mode, phrase = claim_form.telehealth_mode_evidence(text) + signal = claim_form.telehealth_signal(text) + modifier = "93" if mode == "audio" else "95" + + rows = claim_form.set_cpt_pos_modifier( + page, pos="2", modifier=modifier, only_first=True) + if not rows: + if has_cpt_rows(data): + # The first read had CPT rows; the live grid showed none. The + # fix was never applied — not "nothing to do". + print(f"[COND {NAME} {claim_id}] {CPT_GRID_NOT_READ} — leaving " + f"the claim as read") + return ConditionResult.declined(NAME, CPT_GRID_NOT_READ) + print(f"[COND {NAME} {claim_id}] No real CPT/HCPCS row to fix — " + f"leaving the claim as read") + return ConditionResult.none(NAME) + + # Review item D3: an already-correct primary row is fine — it is just + # not a change. The G2025 step still runs either way. + primary = rows[0] + changes = [primary] if primary.get("changed") else [] + reviews = [] + if primary.get("changed") and not primary.get("ok", True): + # The primary write did not take: the claim is already known to be + # wrong and goes to a human. Stop here rather than also adding + # G2025 and zeroing/unchecking every other row (review fix round + # 1, 2026-10-05) — that would hand the operator a heavily edited + # form to unpick by hand on top of the row they must fix. The + # review alone already keeps Save away from this claim. + reason = (f"POS/modifier did not take on row {primary['row'] + 1} " + f"— set it by hand; G2025 was NOT added and the other CPT " + f"rows were NOT unchecked") + print(f"[COND {NAME} {claim_id}] {reason}") + for c in changes: + c["signal"] = signal + c["mode"] = mode + c["mode_phrase"] = phrase + if "before" in c: + c.setdefault("modifier", modifier) + return ConditionResult(condition_name=NAME, changed=True, + changes=changes, review=reason) + if not primary.get("changed"): + print(f"[COND {NAME} {claim_id}] Primary row already correct (POS " + f"{primary['before']['pos']}, M1 {primary['before']['mod1']})") + + g2025 = claim_form.apply_medicare_telehealth_g2025(page, modifier) + if g2025.get("g2025_present_modifier_ok") is False: + # G2025 was already on the claim, but not with this visit's + # modifier (blank, or 95 on an audio-only visit). Read, never + # rewritten — whoever put it there may know something the note + # does not — so it goes to a human (review fix round 1). + found = g2025.get("g2025_present_modifier") or "" + mismatch = (f"G2025 present but M1 is {found or 'blank'}, not " + f"{modifier} — check it") + print(f"[COND {NAME} {claim_id}] {mismatch}") + reviews.append(mismatch) + if g2025["g2025_added"]: + print(f"[COND {NAME} {claim_id}] Added G2025 (modifier {modifier})") + changes.append({"row": None, "code": "G2025", "added": True, + "modifier": modifier}) + if g2025.get("g2025_modifier_ok") is False: + print(f"[COND {NAME} {claim_id}] {G2025_MODIFIER_NOT_TAKEN}") + reviews.append(G2025_MODIFIER_NOT_TAKEN) + if g2025.get("g2025_verify_only"): + print(f"[COND {NAME} {claim_id}] {G2025_VERIFY_BY_HAND}") + reviews.append(G2025_VERIFY_BY_HAND) + changes.append({"row": None, "code": "G2025", "verify_only": True, + "modifier": modifier}) + elif not g2025["g2025_confirmed"]: + print(f"[COND {NAME} {claim_id}] Could not confirm G2025 on the " + f"claim — leaving every other CPT row untouched for review") + reviews.append(G2025_NOT_ADDED) + if not changes: + # Nothing changed at all — but the claim still lacks G2025, + # so it is a human's, not clean (D6). + return ConditionResult.declined(NAME, "; ".join(reviews)) + changes.append({"row": None, "code": "G2025", "failed": True, + "modifier": modifier}) + changes.extend(g2025["zeroed"]) + + if not changes: + if reviews: + # Nothing changed, but the pre-existing G2025's modifier is + # wrong — a Review row, not Pass. + return ConditionResult.declined(NAME, "; ".join(reviews)) + # G2025 already present with this visit's modifier, primary row + # already right, nothing left to zero: the claim already follows + # rule #22 (D3). Every part of that sentence was read back. + print(f"[COND {NAME} {claim_id}] already correct (POS " + f"{primary['before']['pos']}, M1 {primary['before']['mod1']}, " + f"G2025 present with M1 {g2025.get('g2025_present_modifier')}) " + f"— nothing to change") + return ConditionResult.none(NAME) + + if g2025.get("g2025_present_modifier_ok") is False: + # Shown alongside the rows that did change, so describe() and the + # summary comment carry it too. + changes.append({"row": None, "code": "G2025", "mismatch": True, + "found": g2025.get("g2025_present_modifier") or "", + "modifier": modifier}) + + # Stashed on every row so describe() can tell the operator WHY this + # fired — which of "telehealth" / "telephone" / "TH tag" was actually + # in the note, and what decided audio vs video — not just that it did. + for c in changes: + c["signal"] = signal + c["mode"] = mode + c["mode_phrase"] = phrase + # The modifier this visit called for, on the primary row too: the + # summary comment names it from here, not from the read-back, which + # is blank when the write did not take (final review, 2026-10-05). + # The primary row only — the zeroed rows' shape is unchanged. + if "before" in c: + c.setdefault("modifier", modifier) + + return ConditionResult(condition_name=NAME, changed=True, changes=changes, + review="; ".join(reviews)) + + +def describe(result: ConditionResult) -> str: + first = result.changes[0] if result.changes else {} + signal = first.get("signal") + matched = f" (matched via: {signal})" if signal else "" + head = f"Medicare Telehealth{matched}" + if first.get("mode"): + head += ", " + claim_form.describe_mode(first["mode"], + first.get("mode_phrase", "")) + lines = [f"{head}:"] + for c in result.changes: + if c.get("failed"): + lines.append(" G2025 NOT added — add it by hand; other rows " + "NOT unchecked") + elif c.get("verify_only"): + lines.append(f" G2025 (M1 {c['modifier']}) NOT added in VERIFY " + f"— add it by hand; the rows below are shown as " + f"LIVE-WRITE would leave them") + elif c.get("mismatch"): + lines.append(f" G2025 already present but M1 is " + f"{c['found'] or 'blank'}, not {c['modifier']} — " + f"check it") + elif c.get("added"): + lines.append(f" Added CPT {c['code']} with modifier " + f"{c['modifier']}") + elif "before" in c: + line = (f" Row {c['row'] + 1} ({c['code']}): POS " + f"{c['before']['pos']!r}->{c['after']['pos']!r}, " + f"M1 {c['before']['mod1']!r}->{c['after']['mod1']!r}") + if not c.get("ok", True): + line += " — did NOT take, set it by hand" + lines.append(line) + else: + parts = [] + if c.get("unchecked"): + parts.append("unchecked") + if "fee" in c: + parts.append(f"fee {c['fee']['before']!r}->" + f"{c['fee']['after']!r}") + lines.append(f" Row {c['row'] + 1} ({c['code']}): " + + ", ".join(parts)) + return "\n".join(lines) + + +def summary_comment(result: ConditionResult) -> str: + """ + The Charge Summary comment, from what actually happened (review item + D6) — e.g. `Medicare Telehealth (POS 02 / modifier 95 on 99213; G2025 + added; 2 other CPT(s) unchecked)`, or `(...; G2025 NOT added — add by + hand)` — in place of the fixed "G2025 added … other CPTs unchecked", + which was false whenever the add could not be confirmed. + """ + parts, zeroed = [], [] + for c in result.changes: + if c.get("failed"): + parts.append("G2025 NOT added — add by hand") + elif c.get("verify_only"): + parts.append("G2025 NOT added in VERIFY — add by hand") + elif c.get("mismatch"): + parts.append(f"G2025 present but M1 {c['found'] or 'blank'}, " + f"not {c['modifier']} — check it") + elif c.get("added"): + parts.append("G2025 added") + elif "before" in c: + # The modifier this visit called for (stashed by apply()), never + # a "?" for a blank read-back (final review, 2026-10-05); and + # only the field(s) that needed the fix (`fix_text`). + mod = (c.get("modifier") or c["after"].get("mod1") + or "the visit's modifier") + text = fix_text(c, "2", mod, sep=" / ") + if not c.get("ok", True): + text += " did NOT take — set by hand" + parts.append(text) + else: + zeroed.append(c) + if zeroed: + # The UB form's checkboxes are not wired (see + # SEL_CPT_ASSIGN_TO_PATIENT) — there only the fee is zeroed, and + # the comment must not claim an uncheck that did not happen: the + # unchecked rows and the fee-only rows are counted apart (final + # review, 2026-10-05 — "unchecked/fee zeroed" was printed even + # when not one row was unchecked). + unchecked = sum(1 for c in zeroed if c.get("unchecked")) + fee_only = sum(1 for c in zeroed + if not c.get("unchecked") and "fee" in c) + if unchecked: + parts.append(f"{unchecked} other CPT(s) unchecked") + if fee_only: + parts.append(f"{fee_only} other CPT fee(s) zeroed") + return (f"Medicare Telehealth ({'; '.join(parts)})" if parts + else "Medicare Telehealth") diff --git a/src/ecw_bot/conditions/medicare_vaccine_a6.py b/src/ecw_bot/conditions/medicare_vaccine_a6.py new file mode 100644 index 0000000..8f04e31 --- /dev/null +++ b/src/ecw_bot/conditions/medicare_vaccine_a6.py @@ -0,0 +1,151 @@ +r""" +Condition (document #19): a Medicare claim billing G0008 or G0009 carries +condition code A6. + +Per the client's Charge Condition Document, row 19 (Vaccine codes, payer +Medicare): "We have to add Admin code for Medicare as follows Flu Vaccine - +G0008, Pneumonia - G0009. A6 - Condition code is a must to append in +claim". + +Operator, 2026-10-16: + + * The trigger is G0008 / G0009 themselves — this rule has nothing to do + with #18's vaccine code list, and the bot does not add G0008 / G0009. + Only a line the claim ends up BILLING counts (`lines.ends_billed`): not + one at a zero fee, and an unchecked one only when priced_line_check + checks it at the end (review, 2026-10-19). + * Medicare is decided as `medicare_billing_screening.is_medicare_claim()` + does: the first insurance row's payer group. + * **UB form:** A6 already in one of the Condition Codes boxes (FL 18-28) + → nothing to do. Otherwise A6 is typed into the first EMPTY box and + read back (`claim_form.set_ub_condition_code`). All eleven boxes full + → Review, "add A6 by hand", nothing typed. Typed but not taken → the + box is reported as changed AND the claim is Review, so the + review-before-close box shows what was touched. + * Only the UB form ON SCREEN counts: eCW leaves closed forms in the DOM, + and a hidden UB form behind an HCFA claim must neither answer for it + nor be typed into (review, 2026-10-19). + * **HCFA / Dental screen:** Review, "check condition code A6 by hand" + (`needs_review`). Not to be mapped (operator, 2026-10-21): Medicare is + billed on the UB form (document #2), so a Medicare G0008 / G0009 claim + on another screen is itself the wrong form and goes to a person. + +Typing A6 changes the screen only: the boxes have no ng-blur / ng-change +save hook (Dom.txt, 2026-10-16), unlike the CPT line's Assign To Patient +box that eCW saves on click. A VERIFY run therefore writes nothing to eCW +here; a LIVE-WRITE run saves it with the claim's OK. To be confirmed once +live against the audit log. +""" + +from ..core.text import first_line +from ..pages import claim_form +from . import lines +from .base import CPT_TAB_NOT_CONFIRMED, ConditionResult +from .medicare_billing_screening import is_medicare_claim + +NAME = "medicare_vaccine_a6" + +TRIGGER_CODES = ("G0008", "G0009") +CONDITION_CODE = "A6" + + +def billed_trigger_codes(cpt) -> list: + """G0008 / G0009 the claim ends up billing, each once, in grid order.""" + return lines.billed_codes(cpt, lambda code: code in TRIGGER_CODES) + + +def _applies(data) -> bool: + return bool(billed_trigger_codes((data or {}).get("cpt"))) \ + and is_medicare_claim(data) + + +def _codes_read(data): + """The UB form's eleven condition codes, or None when not read.""" + data = data or {} + codes = data.get("condition_codes") + if data.get("screen") != "ub" or not isinstance(codes, list): + return None + return [(c or "").strip().upper() for c in codes] + + +def detect(claim_id: str, data: dict) -> bool: + if not _applies(data): + return False + codes = _codes_read(data) + return codes is not None and CONDITION_CODE not in codes + + +def needs_review(claim_id: str, data: dict): + """A screen whose condition codes are not read (HCFA, Dental).""" + if not _applies(data) or _codes_read(data) is not None: + return None + codes = ", ".join(billed_trigger_codes(data.get("cpt"))) + return (f"{codes} on Medicare: check condition code {CONDITION_CODE} " + f"by hand (not read on this screen)") + + +def apply(page, claim_id: str, data: dict) -> ConditionResult: + try: + claim_form.open_cpt_hcpcs_tab(page) + except Exception as exc: + print(f"[COND {NAME} {claim_id}] Could not confirm the CPT/HCPCS " + f"tab before fixing: {exc} — leaving the claim as read") + return ConditionResult.declined(NAME, CPT_TAB_NOT_CONFIRMED) + + try: + done = claim_form.set_ub_condition_code(page, CONDITION_CODE) + except Exception as exc: + done = {"present": False, "ok": False, + "why": f"could not type it: {first_line(exc)}"} + if done.get("present"): + print(f"[COND {NAME} {claim_id}] {CONDITION_CODE} already in " + f"Condition Code box {done.get('box')}") + return ConditionResult.none(NAME) + change = {"code": CONDITION_CODE, "box": done.get("box"), + "fl": done.get("fl"), "after": done.get("after"), + "triggers": billed_trigger_codes((data or {}).get("cpt"))} + if not done.get("ok"): + why = done.get("why") or (f"box {done.get('box')} read back " + f"{done.get('after')!r}") + review = (f"Condition code {CONDITION_CODE} not added ({why}) — " + f"add {CONDITION_CODE} by hand") + print(f"[COND {NAME} {claim_id}] {review}") + if done.get("box") is None: + return ConditionResult.declined(NAME, review) + # Typed, but it did not take: the box is touched all the same. + return ConditionResult(condition_name=NAME, changed=True, + changes=[change], review=review) + + print(f"[COND {NAME} {claim_id}] {CONDITION_CODE} typed into Condition " + f"Code box {done['box']} (FL {done['fl']})") + return ConditionResult(condition_name=NAME, changed=True, + changes=[change]) + + +def _triggers(result: ConditionResult) -> str: + return "/".join(dict.fromkeys( + t for c in result.changes for t in c.get("triggers") or [])) \ + or "/".join(TRIGGER_CODES) + + +def summary_comment(result: ConditionResult) -> str: + if result.review: + return (f"Condition code {CONDITION_CODE} NOT added " + f"({_triggers(result)} on Medicare) — add it by hand") + return (f"Condition code {CONDITION_CODE} added ({_triggers(result)} " + f"on Medicare)") + + +def describe(result: ConditionResult) -> str: + out = [f"Condition code {CONDITION_CODE} for " + f"{'/'.join(TRIGGER_CODES)} on Medicare:"] + for c in result.changes: + if result.review: + out.append(f" Condition Code box {c['box']} (FL {c['fl']}) " + f"typed, read back {c.get('after')!r}") + else: + out.append(f" {CONDITION_CODE} added in Condition Code box " + f"{c['box']} (FL {c['fl']})") + if result.review: + out.append(f" {result.review}") + return "\n".join(out) diff --git a/src/ecw_bot/conditions/medicare_wellness_history.py b/src/ecw_bot/conditions/medicare_wellness_history.py new file mode 100644 index 0000000..3bc0299 --- /dev/null +++ b/src/ecw_bot/conditions/medicare_wellness_history.py @@ -0,0 +1,158 @@ +r""" +Condition (document #27, #28, #29): Medicare's preventive visit codes are +billed only so often. + + * #27 **G0402** — Initial Preventive Physical Exam (IPPE): once per + lifetime. + * #28 **G0438** — first Annual Wellness Visit: once in 3 years (operator). + * #29 **G0439** — subsequent Annual Wellness Visit: once a year. + +Operator, 2026-10-20 (after asking the client): the bot checks and reports, +it never adds, changes or unchecks a line. + + * Fires on a **Medicare** claim (`medicare_billing_screening. + is_medicare_claim`) that bills G0402, G0438 or G0439 + (`lines.ends_billed`). These codes come on Medicare claims only. + * The patient's history is read in **Account Inquiry** (claim form side + menu → "Show CPT Payments" → every earlier claim's CPT lines, with the + claim's service date; `pages/account_inquiry.py`). The claim being + checked is left out. + * The window is counted from THIS claim's service date (operator), either + way: an earlier claim dated after this one counts too. A line exactly + 3 years / 12 months away is outside the window. + * Billed too often → the claim is **Review**, with the earlier claim's + date and number in the Comments ("G0438 already billed on 05/02/2025 + (claim 1234567) — once in 3 years"). Nothing on the claim changes. + * Account Inquiry not read → Review, "check … history by hand". +""" + +from datetime import date + +from ..core.text import first_line +from ..pages import account_inquiry, claim_form +from . import lines +from .base import CPT_TAB_NOT_CONFIRMED, ConditionResult +from .medicare_billing_screening import is_medicare_claim + +NAME = "medicare_wellness_history" + +# Code → (years in the window, None = lifetime; how the rule reads). +RULES = {"G0402": (None, "once per lifetime"), + "G0438": (3, "once in 3 years"), + "G0439": (1, "once a year")} + + +def billed_wellness_codes(cpt) -> list: + """G0402 / G0438 / G0439 the claim ends up billing, in grid order.""" + return lines.billed_codes(cpt, lambda code: code in RULES) + + +def claim_service_date(data): + """The service date of the first billed G04xx line, None if not read.""" + for row in (data or {}).get("cpt") or []: + if lines.code(row) in RULES and lines.ends_billed(row): + return account_inquiry.parse_date(row.get("sdos")) + return None + + +def _years_from(d: date, years: int) -> date: + try: + return d.replace(year=d.year + years) + except ValueError: # 29 February + return d.replace(year=d.year + years, day=28) + + +def _within(earlier: date, dos: date, years: int) -> bool: + return _years_from(dos, -years) < earlier < _years_from(dos, years) + + +def _fmt(d: date) -> str: + return d.strftime("%m/%d/%Y") + + +def conflicts(codes, dos, claim_no, history) -> list: + """One line per code billed too often — `history` as read by + `account_inquiry.history()`; this claim (`claim_no`) is left out.""" + own = str(claim_no or "").strip() + out = [] + for code in codes: + years, rule = RULES[code] + found = [h for h in history or [] + if code in (h.get("codes") or []) + and str(h.get("claim_no") or "").strip() != own] + dated = sorted((h for h in found if h.get("date")), + key=lambda h: h["date"], reverse=True) + undated = [h for h in found if not h.get("date")] + if years is None: + hit = dated[0] if dated else (undated[0] if undated else None) + elif dos is None: + hit = None + if found: + out.append(f"{code} found in the history but this claim's " + f"service date was not read — check {code} " + f"({rule}) by hand") + continue + else: + hit = next((h for h in dated + if _within(h["date"], dos, years)), None) + if hit is None and undated: + out.append(f"{code} on claim {undated[0]['claim_no']} with " + f"no service date read — check {code} ({rule}) " + f"by hand") + continue + if hit is not None: + when = _fmt(hit["date"]) if hit.get("date") else "an unread date" + out.append(f"{code} already billed on {when} (claim " + f"{hit['claim_no']}) — {rule}") + return out + + +def detect(claim_id: str, data: dict) -> bool: + return bool(billed_wellness_codes((data or {}).get("cpt"))) \ + and is_medicare_claim(data) + + +def _verdict(data, read: bool, found: list) -> None: + if isinstance(data, dict): + data["_wellness_history"] = {"read": read, "conflicts": list(found)} + + +def apply(page, claim_id: str, data: dict) -> ConditionResult: + """ + Re-clicks CPT/HCPCS first, as every condition does: it confirms the + claim form is the one on screen before its side menu is used. Leaves + its verdict on the claim as `_wellness_history` (`{"read", "conflicts"}`) + so `medicare_pps_code` (#25) does not add G0468 for a preventive visit + billed too often, or one whose history it could not read. + """ + try: + claim_form.open_cpt_hcpcs_tab(page) + except Exception as exc: + print(f"[COND {NAME} {claim_id}] Could not confirm the CPT/HCPCS " + f"tab: {exc} — leaving the claim as read") + return ConditionResult.declined(NAME, CPT_TAB_NOT_CONFIRMED) + codes = billed_wellness_codes((data or {}).get("cpt")) or list(RULES) + try: + history = account_inquiry.history(page) + except Exception as exc: + reason = (f"Account Inquiry not read ({first_line(exc)}) — check " + f"{'/'.join(codes)} history by hand") + print(f"[COND {NAME} {claim_id}] {reason}") + _verdict(data, read=False, found=[]) + return ConditionResult.declined(NAME, reason) + found = conflicts(codes, claim_service_date(data), + (data or {}).get("claim_no") or claim_id, history) + _verdict(data, read=True, found=found) + print(f"[COND {NAME} {claim_id}] {len(history)} earlier claim(s) read " + f"in Account Inquiry; {'/'.join(codes)}: " + f"{'; '.join(found) if found else 'not billed too often'}") + if found: + return ConditionResult.declined(NAME, "; ".join(found)) + return ConditionResult.none(NAME) + + +def describe(result: ConditionResult) -> str: + out = ["Medicare preventive visit history (G0402 / G0438 / G0439):"] + out.append(f" {result.review}" if result.review + else " not billed too often") + return "\n".join(out) diff --git a/src/ecw_bot/conditions/priced_line_check.py b/src/ecw_bot/conditions/priced_line_check.py new file mode 100644 index 0000000..f62aee3 --- /dev/null +++ b/src/ecw_bot/conditions/priced_line_check.py @@ -0,0 +1,157 @@ +r""" +Condition: the reverse half of the operator's common rule for every claim +(ruled 2026-09-30) — a CPT/HCPCS line that carries a billed fee but is +unchecked ("Assign To Patient" off, i.e. not billed) gets checked. + +The operator's words: "If a CPT line has a billed fee but is unchecked, +check it." `zero_fee_uncheck.py` is the other half — a checked line billed +at zero gets unchecked. The operator's first description of that rule +carried this direction too and then dropped it; it was asked for again, +explicitly, on 2026-09-30. Not a row of the client's Charge Condition +Document — like its other half, a rule the operator applies by hand on +every claim they review, every payer, every code, both claim screens this +bot corrects (the UB form and the professional/HCFA screen; the Dental +screen never reaches `run_all` — `is_dental_claim_form()` in +`flows/export_medcor_ready.py` closes it first). + +Live evidence: of 39 saved claim snapshots, NOT ONE had an unchecked +priced line — this fires rarely — and exactly one had the other half's +shape, a checked zero-fee line (85018, Hemoglobin, at 0.00). + +"Priced" is `claim_form.is_priced_fee()`: not zero by the shared +`is_zero_fee()`, AND a finite number greater than 0 once "$" and "," are +taken out ("150.00", "1,200.00", "$45.00"). A fee that does not parse, or +is negative, is not priced, and its line is left alone; so is a line with +no Billed Fee input or no "Assign To Patient" checkbox — its state is +unknown, and unknown must never read as "priced" or as "unchecked". A +checked line is never touched, whatever its fee. The fee itself is never +written, only read. Only ever checks. + +**Registered LAST** in `conditions.CONDITIONS`, after vaccine_admin_codes +— the one place in the registry where order decides the outcome, not only +the log. Every specific condition that EXCLUDES a line unchecks it AND +zeroes its Billed Fee: j2930_j2001_update's J2001 and J2930, dummy_codes' +DCONS/Dscprit, medicare_telehealth's "Other Primary CPT codes need to be +unchecked and change the fee amount as Zero", and depression_dx_pointer's +duplicate step. Any of those lines can sit unchecked but still priced on +the claim as read (the fixture's own G8510 is exactly that for +medicare_telehealth). Run first, this would check that J2001 (and the +rest), and J2001's own condition would uncheck it again straight after — +the same check/uncheck toggle as claim 9131000, only between two +conditions instead of two steps of one: a reported change that changed +nothing, "checked" and "unchecked" listed for one row in the review box, +and eCW's `updateBillToIns()` fired twice on a line that must stay out. +Run last, on the LIVE grid, every such line an exclusion condition has +actually excluded already reads 0.00 and is not priced any more — the +two exceptions are at the end of this docstring. + +Why a separate condition rather than a second step of zero_fee_uncheck: +that half must run FIRST — so every specific condition's `apply()` works +on a grid that already follows it and has the last word on its own lines, +and depression's re-include step never re-checks a 0.00 line it has just +unchecked — and this half must run LAST, for the reason above. One +condition cannot be both. + +Its `apply()`, not its `detect()`, is what the order protects: +`base.run_all()` runs EVERY `detect()` on the untouched claim before any +`apply()`, so `detect()` here fires on the excluded J2001 above too. The +common case of this condition, then, is to fire and find nothing — the +line it saw priced has been zeroed by the time `apply()` looks — and that +path says so out loud in the log rather than going quiet. + +Known gap, NOT yet ruled on by the operator — the mirror image of the one +zero_fee_uncheck used to open (fixed the same day in vaccine_admin_codes): +vaccine_admin_codes decides from the claim as read, where an UNCHECKED +priced admin line (or vaccine line) reads as not billed. It then adds an +admin code, and this — last — checks the old admin line as well: two admin +lines billed for one vaccine (or, for a vaccine line, one vaccine short of +the count the admin codes were chosen for). None of the 39 snapshots has +the shape — none has an unchecked priced line at all. +test_conditions_priced_line_check.py pins today's outcome. +injection_admin_code (#20) is closed against the admin half of it: an +unchecked 96372 with a fee counts as billed there (`will_check`), so no +second 96372 is added (review, 2026-10-14). Nor does the +order protect a line whose exclusion condition did not zero it: + + * one that RAISED before zeroing it — `run_all` logs that failure and + moves on, and this would then check the line it left priced; + * medicare_telehealth's G2025-UNCONFIRMED path, a normal one that does + not raise (review finding, 2026-09-30). When the G2025 add cannot be + confirmed — e.g. on the UB form, where the Fee Schedule picker flow is + not live-verified — `claim_form.apply_medicare_telehealth_g2025()` + returns `zeroed=[]` and medicare_telehealth logs "leaving every other + CPT row untouched for review". An "other" line sitting unchecked but + priced there (the fixture's G8510 at 65.00 is that shape) is then + checked by this condition, last: a line document #22 says to exclude + ends billed, and the review box shows "-> checked" after that log + line. NOT yet ruled on — whether those rows should stay as read for + review is a question for the operator; + test_conditions_priced_line_check.py pins today's outcome + (`pinned_needs_ruling_g2025_unconfirmed_other_line_gets_checked`). +""" + +from ..pages import claim_form +from .base import CPT_TAB_NOT_CONFIRMED, ConditionResult + +NAME = "priced_line_check" + + +def will_check(row) -> bool: + """ + Whether this condition, last, checks `row`: unchecked with a billed fee. + + `billed is False`, exactly: `extract()` reports None for a row with no + "Assign To Patient" checkbox, and an unknown state must not fire this — + `apply()` would leave that row alone anyway. Also asked by + injection_admin_code: a 96372 this will check is a 96372 the claim + bills, so a second one is not added (review, 2026-10-14). + """ + row = row or {} + return (row.get("billed") is False + and claim_form.is_priced_fee(row.get("billed_fee"))) + + +def detect(claim_id: str, data: dict) -> bool: + """Whether the claim as read has an unchecked line with a billed fee.""" + return any(will_check(row) for row in (data or {}).get("cpt") or []) + + +def apply(page, claim_id: str, data: dict) -> ConditionResult: + """ + Re-clicks CPT/HCPCS for the same reason `zero_fee_uncheck.py`'s and + `depression_dx_pointer.py`'s `apply()` do — eCW can leave the form on + a different tab after the Prog. Notes viewer cycle (seen live, claim + 9116000). A no-op on the professional/HCFA screen, which has no such + tab to lose. + + Acts on the live grid (`claim_form.check_priced_unchecked_cpt_rows()`), + not on `data`: `detect()` only decides whether this is worth running, + and by now every other condition has had its turn on the grid. + """ + try: + claim_form.open_cpt_hcpcs_tab(page) + except Exception as exc: + print(f"[COND {NAME} {claim_id}] Could not confirm the CPT/HCPCS " + f"tab before fixing: {exc} — leaving the claim as read") + # Not "nothing to do": the claim matched and was never + # checked, so it goes to a human (review item D2). + return ConditionResult.declined(NAME, CPT_TAB_NOT_CONFIRMED) + + changes = claim_form.check_priced_unchecked_cpt_rows(page) + if not changes: + # The common case, said out loud: detect() saw a priced line that + # an exclusion condition has zeroed since. Without this the log's + # last word is "about to apply: priced_line_check", which reads as + # a silent failure. + print(f"[COND {NAME} {claim_id}] No unchecked CPT line with a " + f"billed fee left on the grid — nothing to check") + return ConditionResult.none(NAME) + return ConditionResult(condition_name=NAME, changed=True, changes=changes) + + +def describe(result: ConditionResult) -> str: + lines = ["Unchecked CPT line(s) with a billed fee checked:"] + for c in result.changes: + lines.append(f" Row {c['row'] + 1} ({c['code']}): " + f"unchecked with billed fee {c['fee']} -> checked") + return "\n".join(lines) diff --git a/src/ecw_bot/conditions/telehealth.py b/src/ecw_bot/conditions/telehealth.py new file mode 100644 index 0000000..44ddece --- /dev/null +++ b/src/ecw_bot/conditions/telehealth.py @@ -0,0 +1,265 @@ +r""" +Condition 1: a telehealth visit needs POS=2 and a payer-specific modifier +on the claim's first (primary) real CPT/HCPCS row only. + +**Explicitly first-row-only, not every row** — a live run (claim 9130000) +showed GT applied to three rows (99213, G8510, 0502F) when only the first +was wanted; this was corrected per direct instruction. Same scope as +`conditions/medicare_telehealth.py`'s own first-row-only rule. + +Thin wrapper only — the DOM mechanics live in `pages/claim_form.py` +(`note_mentions_telehealth`, `telehealth_mode_evidence`, +`open_cpt_hcpcs_tab`, `set_cpt_pos_modifier`), exhaustively covered by +`tests/test_telehealth_fix.py`. This module shapes that logic to the +Condition Framework's contract (see `conditions/base.py`). + +Per the client's Charge Condition Document (condition #21): POS is +**always 2** for telehealth. The modifier depends on how the visit was +conducted and who the payer is: + + * Medicaid, and the "RAE payer" group (BH COLORADO ACCESS etc.) — **GT**, + audio-only or not. Except a **pregnancy visit on Medicaid** (not RAE; + a pregnancy-related ICD other than Z32.0x): **TH**, not GT (document + #23, operator 2026-10-20; `medicaid_pregnancy_th`). + * Commercial, audio-only — modifier **93**. + * Commercial, otherwise (explicit video evidence, or no audio-only + evidence at all) — **95**. + +The document reads "93 for Audio only" without naming a payer, but the +client's expected results on Medicaid telephone visits (claims 9123000, +9124000, 9122000) all keep or add GT — so 93 applies to Commercial only. + +**Medicare is deliberately excluded here** — document condition #22 gives +Medicare telehealth its own, more specific rule (`conditions +/medicare_telehealth.py`). A Medicare payer is "not covered" by this +condition (a plain `none()`, not a review): medicare_telehealth fires on +the same claim and handles it, and returning anything else here would +have the two conditions both write the same row. + +**Every other payer is declined, not guessed** (review item D5, +2026-10-05). Before this, the audio check ran before the payer lookup, so +an unmapped, blank, Program or Dentaquest payer on an audio visit got 93 +written — an unclassified Medicare plan got rule #21 where #22 applies, +an unmapped Medicaid MCO got 93 instead of GT. Now a payer with no +telehealth modifier rule (anything but Medicaid / RAE payer / Commercial / +Medicare) returns `ConditionResult.declined(...)`: nothing is written, and +the claim goes to the Charge Summary as Review with the payer named. + +**An already-correct claim is not a change** (review item D3, +2026-10-05): `set_cpt_pos_modifier` writes nothing to a row that already +holds POS 2 and the right modifier, and this condition then returns +`none()` — a correctly coded telehealth claim is Pass, not "Corrections +pending". + +Applies to **every** claim regardless of type — Medicaid/UB institutional +and Commercial/HCFA professional alike — detected straight from the +progress note's own text. Nothing here saves or shows a confirmation +dialog; that is `export_medcor_ready.py`'s `_confirm_and_save`, once per +claim, after every condition that fired has had its say. +""" + +from ..core import payer_groups +from ..pages import claim_form +from . import medicaid_pregnancy_th +from .base import (CPT_GRID_NOT_READ, CPT_TAB_NOT_CONFIRMED, + ConditionResult, fix_text, has_cpt_rows) + +NAME = "telehealth" + +# The Dental Claim screen has no per-row POS and no modifier columns: on a Dental claim this condition +# is not applied, and the claim goes to Review (operator, 2026-10-12). +DENTAL_UNSUPPORTED = ( + "Telehealth visit on a Dental claim — the Dental screen has no POS " + "/ modifier column; set them by hand") + +# Medicaid plans, whatever payer_groups.json calls them: the nine RAE payers +# (document #11) have their own "RAE payer" Group Name but the same +# telehealth rule as Medicaid — GT, audio-only or not. +_MEDICAID_GROUPS = {"Medicaid", "RAE payer"} + +# What `_choose_modifier` returns for a Medicare payer: not this +# condition's claim (medicare_telehealth.py owns it) — and not a review +# either, since that condition fires on the same claim. +NOT_COVERED = (None, "") + + +def detect(claim_id: str, data: dict) -> bool: + note = (data or {}).get("progress_notes") or {} + return claim_form.note_mentions_telehealth(note.get("text", "")) + + +def _payer_name(data: dict) -> str: + """The primary (first) insurance row's payer name, or "" if there is + none on the claim yet.""" + rows = (data or {}).get("insurance") or [] + return (rows[0].get("Name") or "").strip() if rows else "" + + +def _choose_modifier(mode: str, payer: str) -> tuple[str | None, str]: + """ + `(modifier, decline_reason)` — exactly one of three shapes, so + `apply()` can tell them apart (review item D5): + + * `("GT" | "93" | "95", "")` — write this modifier; + * `NOT_COVERED` = `(None, "")` — Medicare: not this condition's + claim, medicare_telehealth.py handles it; `apply()` returns none(); + * `(None, reason)` — matched, but no rule to act on: `apply()` + declines with `reason`, which the Charge Summary shows as Review. + + Payer group is checked BEFORE the audio/video split, not after: a + Medicare payer must be NOT_COVERED in every mode, audio included, or an + audio-only Medicare claim would get hit by both this condition (93) + and medicare_telehealth.py (which also fires on the same first row) — + exactly the double-write the Condition Framework exists to prevent. + The same ordering is what keeps an unmapped payer from getting 93 on + an audio visit (D5): only Commercial ever reaches the audio branch. + """ + try: + mapping = payer_groups.load_mapping() + except Exception: + return None, "payer_groups.json could not be loaded" + group = payer_groups.rule_group(payer, mapping) + if group == "Medicare": + return NOT_COVERED + # Medicaid is GT even for audio-only: the client's expected results on + # Medicaid telephone visits (9123000, 9124000, 9122000) keep GT, not 93. + if group in _MEDICAID_GROUPS: + return "GT", "" + if group == "Commercial": + return ("93" if mode == "audio" else "95"), "" + if not (payer or "").strip(): + # No payer name was READ (a known extractor gap on the HCFA screen): + # say so, rather than point the operator at payer_groups.json for a + # payer it was never given (final review, 2026-10-05). + return None, ("telehealth visit, but no primary payer name was read " + "— set POS/modifier by hand") + # Unmapped, Program, Dentaquest, ...: the document gives no telehealth + # modifier for these, so nothing is guessed. + return None, (f"telehealth visit, but payer {payer!r} " + f"({group or 'not in payer_groups.json'}) has no " + f"telehealth modifier rule — set POS/modifier by hand") + + +def apply(page, claim_id: str, data: dict) -> ConditionResult: + """ + Re-clicks CPT/HCPCS rather than trusting the tab that was active before + Prog. Notes was opened — eCW can leave (or put) the form back on + CPT/Revenue Codes after that viewer cycle (seen live, claim 9116000), + and `set_cpt_pos_modifier`'s fill() needs the pane actually visible or + it hangs waiting for a hidden field. + """ + try: + claim_form.open_cpt_hcpcs_tab(page) + except Exception as exc: + print(f"[COND {NAME} {claim_id}] Could not confirm the CPT/HCPCS " + f"tab before fixing: {exc} — leaving the claim as read") + # Not "nothing to do": the claim matched and was never + # checked, so it goes to a human (review item D2). + return ConditionResult.declined(NAME, CPT_TAB_NOT_CONFIRMED) + + note = (data or {}).get("progress_notes") or {} + text = note.get("text", "") + mode, phrase = claim_form.telehealth_mode_evidence(text) + signal = claim_form.telehealth_signal(text) + payer = _payer_name(data) + modifier, decline_reason = _choose_modifier(mode, payer) + if decline_reason: + print(f"[COND {NAME} {claim_id}] {decline_reason} — leaving the " + f"claim as read") + return ConditionResult.declined(NAME, decline_reason) + if modifier is None: + print(f"[COND {NAME} {claim_id}] Payer {payer!r} is Medicare — " + f"medicare_telehealth handles it, not this condition") + return ConditionResult.none(NAME) + + if modifier == "GT" and medicaid_pregnancy_th.is_pregnancy_visit_on_medicaid(data): + # Document #23: a Medicaid pregnancy visit carries TH, not GT. + modifier = medicaid_pregnancy_th.MODIFIER + + rows = claim_form.set_cpt_pos_modifier( + page, pos="2", modifier=modifier, only_first=True) + if not rows: + if has_cpt_rows(data): + # The first read had CPT rows; the live grid showed none. The + # fix was never applied — not "nothing to do". + print(f"[COND {NAME} {claim_id}] {CPT_GRID_NOT_READ} — leaving " + f"the claim as read") + return ConditionResult.declined(NAME, CPT_GRID_NOT_READ) + print(f"[COND {NAME} {claim_id}] Note mentions telehealth but no " + f"real CPT/HCPCS row to fix — leaving the claim as read") + return ConditionResult.none(NAME) + + changes = [r for r in rows if r.get("changed")] + if not changes: + # Review item D3: the claim already follows the rule — that is + # Pass, not a correction, so no box and no "Fail" summary row. + print(f"[COND {NAME} {claim_id}] already correct (POS " + f"{rows[0]['before']['pos']}, M1 {rows[0]['before']['mod1']}) " + f"— nothing to change") + return ConditionResult.none(NAME) + + # Stashed on every row so describe() can tell the operator WHY this + # fired — which of "telehealth" / "telephone" / "TH tag" was actually + # in the note, and what decided audio vs video — not just that it did. + for c in changes: + c["signal"] = signal + c["mode"] = mode + c["mode_phrase"] = phrase + c["modifier"] = modifier + + review = "" + failed = [c for c in changes if not c.get("ok", True)] + if failed: + # The write did not take: keep the rows (the operator must see + # what was attempted) but send the claim to a human — never offer + # Save on a fix the read-back does not show. + review = (f"POS/modifier did not take on row {failed[0]['row'] + 1}" + f" — set it by hand") + print(f"[COND {NAME} {claim_id}] {review}") + return ConditionResult(condition_name=NAME, changed=True, + changes=changes, review=review) + + +def describe(result: ConditionResult) -> str: + first = result.changes[0] if result.changes else {} + signal = first.get("signal") + matched = f" (matched via: {signal})" if signal else "" + mode_line = (claim_form.describe_mode(first["mode"], + first.get("mode_phrase", "")) + if first.get("mode") else "") + lines = [] + for c in result.changes: + if not c.get("changed", True): + continue # an already-correct row is not a change + line = (f" Row {c['row'] + 1} ({c['code']}): " + f"POS {c['before']['pos']!r}->{c['after']['pos']!r}, " + f"M1 {c['before']['mod1']!r}->{c['after']['mod1']!r}") + if not c.get("ok", True): + line += " — did NOT take, set it by hand" + lines.append(line) + head = f"Progress Note mentions Telehealth/Telephone{matched}" + if mode_line: + head += f", {mode_line}" + return (f"{head}. Set the following {len(lines)} CPT/HCPCS row(s):\n\n" + + "\n".join(lines)) + + +def summary_comment(result: ConditionResult) -> str: + """ + The Charge Summary comment, from what actually changed (review item + D6's "build the comment from the changes"), e.g. `Telehealth (POS 02, + modifier GT on 99213)` — in place of the fixed "93 or 95 updated", + which was wrong for every Medicaid GT claim. Only the field(s) that + needed the fix are named (`fix_text`): `Telehealth (modifier GT on + 99213)` when POS was already 2. + """ + parts = [] + for c in result.changes: + if not c.get("changed", True): + continue + mod = c.get("modifier") or c["after"]["mod1"] + text = fix_text(c, "2", mod) + if not c.get("ok", True): + text += " did NOT take — set by hand" + parts.append(text) + return f"Telehealth ({'; '.join(parts)})" if parts else "Telehealth" diff --git a/src/ecw_bot/conditions/vaccine_admin_codes.py b/src/ecw_bot/conditions/vaccine_admin_codes.py new file mode 100644 index 0000000..6678696 --- /dev/null +++ b/src/ecw_bot/conditions/vaccine_admin_codes.py @@ -0,0 +1,814 @@ +r""" +Condition (document #18): vaccine codes billed without an immunization- +administration code get the admin code(s) added. + +Per the client's Charge Condition Document, row 18, payer "All (Except +Medicare)": "If the Vaccine codes are received without Admin codes, add +90460 and 90461 (Age 18 and below) or 90471 and 90472 (Above 18). The Admin +Codes units will be calculated based on the vaccine code counts." One +vaccine -> 90460 x1 or 90471 x1; several -> that first code x1 PLUS the +"each additional" code (90461 / 90472) x1. + +That last part is the operator's simplified rule (2026-09-29), not the +document's: "only one vaccine -> one admin code; more than one -> add the +two codes, that's all". The second code is NOT given Units = vaccines - 1 +(what the document's "90461 (2)" example and live claims 9131000/9133000 +show); it keeps the Units the picker gives it, 1. One argument in apply() +restores the document's counting if the client asks for it. + +What counts as what — each a deliberate ruling, with the live evidence +behind it: + + * **A vaccine row** is a CHECKED ("Assign To Patient") CPT row whose code + is in `VACCINE_CODES` — the client's vaccine code list (Charge + Condition Document, operator 2026-10-13): 90633, 90648, 90651, 90656, + 90657, 90660, 90680, 90687, 90698, 90707, 90710, 90713, 90716, 90732, + 90734, 90744, 90746, 91321, 91322. By code only: the hover text is not + read, and a code outside the list is not a vaccine here, whatever it is + (90715 Tdap, 90677 PCV20, 90750 Shingrix ... are left alone — operator, + 2026-10-13). This replaced the earlier "VACC in the hover text, or a CPT + in 90476-90759" rule. + * **An admin row** is a row coded 90460, 90461, 90471 or 90472 — the only + admin codes of this condition (operator, 2026-10-13); by code only, no + hover text. Another administration code on the claim (90473, 90474, + 90480, G0008-G0010) is not an admin row here. Only a CHECKED admin row + means "admin already billed": live claim 9132000 has an UNCHECKED 90460 + at 0.00 on a 58-year-old, which bills nothing. + * **A checked row billed at zero does not count** — neither as a + vaccine nor as "admin already billed" (operator ruling, 2026-09-30). + `zero_fee_uncheck.py` is registered FIRST and unchecks every checked + line whose Billed Fee is zero by `claim_form.is_zero_fee()`, so by the + time this condition's `apply()` runs that line is not billed. But + `run_all` decides every `detect()` on the claim as read: counted as + "admin already billed", a vaccine claim whose only admin line was + checked at 0.00 was never fired, zero_fee_uncheck unchecked the admin + line, and the claim left with the vaccine billed and no admin code — + this document row's very case (none in the 39 snapshots; pinned as a + known gap until the ruling). The same claim with the admin line + ALREADY unchecked (9132000's shape) always did get its code added; now + both reach that grid. "Billed at zero" means `billed` exactly True + and a Billed Fee that was read and is zero — the rows zero_fee_uncheck + will actually uncheck. `detect()` and `apply()`'s live-grid plan use + the one rule (`_plan()` via `row_counts_as_billed()`), so they cannot + disagree. + * A row whose checkbox state is unknown (`billed` is None — no + "Assign To Patient" box found) counts as checked, whatever its fee: + zero_fee_uncheck never touches such a row. An unreadable screen must + never silently drop a billed vaccine, or hide a billed admin code. + Nor does a row whose fee was never read count as zero-fee: unknown is + not zero. That is `billed_fee` None — `extract()` reports None, not + "", for a row with NO Billed Fee input, which zero_fee_uncheck's + `apply()` skips (review finding, 2026-09-30: read as "", a checked + 90471 with no fee input was taken for "about to be unchecked" and a + second 90471 was added on top of it) — or no `billed_fee` key at all + (a hand-built row, or a snapshot from before `extract()` read fees). + * The vaccine count is checked vaccine ROWS, not their units. + +Not yet accounted for, and waiting on a ruling: the common rule's other +half, `priced_line_check.py`, runs LAST and checks an UNCHECKED line that +carries a fee. An unchecked priced admin line reads here as "no admin +billed", so an admin code is added and that old line is then checked too +(see that module's docstring; pinned by +test_conditions_priced_line_check.py). + +Also waiting on a ruling (review finding, 2026-09-30): an admin code the +Fee Schedule picker prices at 0.00. It lands checked at 0.00 and stays +so for the rest of the run — zero_fee_uncheck ran first, before it +existed, and priced_line_check only ever checks — and `describe()` +reports it with no warning. On the NEXT run of the same claim it is a +checked zero-fee admin line, which (by the 2026-09-30 ruling above) does +not count: zero_fee_uncheck unchecks it and another admin code is added, +again at 0.00 — one more line per run. Whether such a line should be +flagged, reported as a failure, or left is not ruled; +test_conditions_vaccine_admin_codes.py pins today's outcome +(`pinned_needs_ruling_an_admin_code_added_at_zero_adds_again_next_run`). + +Payer: fires only for a primary payer `payer_groups.json` classifies, and +never Medicare — document #19 is Medicare's own, separate vaccine rule. An +unmapped payer is left as read with a log line, the same safe default as +`telehealth.py`: a guess on billing data is worse than a human look — and +`needs_review()` asks for that look, so the claim is a Review row in the +Charge Summary rather than "Clean" (review item D2). A Medicare claim is +not: it is outside this rule, not unfinished by it. + +Age is whole years on the date of service — the first vaccine row's SDOS, +else the claim date — from the patient strip's DOB; when that is unusable, +from its age text ("50Y, M" -> 50; "12M 12D, F" -> 0). "Age 18 and below" +is `age <= 18`: someone whose 19th birthday IS the date of service is 19, +an adult. No usable age at all -> left as read, with a log line and a +`needs_review()` reason. + +Adding a code is the SAME step G2025 already uses (`claim_form +.add_cpt_row()`: Add -> search the Fee Schedule picker -> select -> OK), +per the operator: eCW fills the new line's POS, dates, ICD pointers and fee +itself, so none of those are hand-filled here. Both codes want Units 1; +Units is written (and blurred, so eCW's `ng-blur="padZero($index); +editCPTRow('UNITS')"` re-prices the line) only if the picker did not +already give the new line 1. + +Failure handling: if the FIRST code cannot be added, the second is not +attempted and the result honestly reports nothing changed — as +`ConditionResult.declined`, with "add it by hand" as the review reason, so +the Charge Summary does not call the claim clean (review item D2). If the first +lands and the second does not, the first is kept and `describe()` says the +second FAILED, so an operator can add it by hand. "Does not land" includes +a Playwright step RAISING mid-add — caught per code, never allowed out of +`apply()`, where `run_all` would drop the whole result and leave a line +already added on the form with no report. Whether a code landed is read +off the grid itself (a before/after diff by row content), which also names +the exact new line Units is written to, and surfaces anything else the +picker's OK changed as a "check by hand" line — so a first code that failed +while still changing the grid is reported too, not returned as "nothing +changed" — and that result also carries a review reason, which keeps the +save box away from the claim. A Fee Schedule picker left open is always +closed: open, it blocks the form's own Cancel for the rest of the claim. + +`apply()` decides from the LIVE grid, re-read after re-opening the +CPT/HCPCS tab, not only from the pre-read `data`: any condition that ran +earlier in the same claim has already changed the form by then. Nothing +here saves — `export_medcor_ready.py`'s `_confirm_and_save` owns that. +""" + +import re +from collections import Counter +from datetime import datetime + +from ..core import payer_groups +from ..core.text import first_line +from ..pages import claim_form +from .base import (CPT_GRID_NOT_READ, CPT_TAB_NOT_CONFIRMED, + ConditionResult, has_cpt_rows) + +NAME = "vaccine_admin_codes" + +# The Dental Claim screen has no verified way to add a line (the Fee Schedule picker flow is UB/HCFA only): on a Dental claim this condition +# is not applied, and the claim goes to Review (operator, 2026-10-12). +DENTAL_UNSUPPORTED = ( + "Vaccine on a Dental claim — admin codes cannot be added on the " + "Dental screen; add them by hand") + +# The admin codes of this condition — the ones it adds, and the only ones +# whose checked line means "admin already billed" (operator, 2026-10-13). +ADMIN_CODES = frozenset({"90460", "90461", "90471", "90472"}) + +# The client's vaccine code list (Charge Condition Document, operator +# 2026-10-13). Only these are vaccines here; nothing else is looked at. +VACCINE_CODES = frozenset({ + "90633", # Hepatitis A, pediatric/adolescent, 2 dose + "90648", # Hib PRP-T conjugate, 4 dose + "90651", # HPV 9-valent, IM + "90656", # Flu (tri), split, preservative free, 36 months and older + "90657", # Flu, split, 6-35 months + "90660", # Flu, live, intranasal + "90680", # Rotavirus, pentavalent, 3 dose, live + "90687", # Flu 4-valent, 6-35 months, IM + "90698", # DTaP-Hib-IPV, IM + "90707", # MMR, SC + "90710", # MMRV + "90713", # Poliovirus, inactivated (IPV) + "90716", # Varicella, live, SC + "90732", # Pneumococcal + "90734", # Meningococcal, IM + "90744", # Hepatitis B, pediatric/adolescent, 3 dose + "90746", # Hepatitis B, adult, IM + "91321", # COVID Moderna 2024/2025, 6 months-11 years + "91322", # COVID Moderna 2024/2025, 12+ (pre-filled) +}) + +PEDIATRIC_MAX_AGE = 18 +PEDIATRIC_ADMIN = ("90460", "90461") # (first, each additional) +ADULT_ADMIN = ("90471", "90472") + +# Document #18 is "All (Except Medicare)"; Medicare is document #19. +_EXCLUDED_GROUPS = {"Medicare"} + +# How long to keep re-reading the grid for the add's effect after the +# picker's OK — add_cpt_row() has already waited for the picker to close, +# but the new line can render after its own instant presence check. +_NEW_ROW_WAIT_MS = 3_000 + + +# ============================================================================= +# Row recognition +# ============================================================================= + +def _code(row) -> str: + return ((row or {}).get("code") or "").strip() + + +def row_is_checked(row) -> bool: + """True unless the row's "Assign To Patient" box is known to be + UNchecked — None (no box found) and an absent key both count as + checked; see the module docstring.""" + return (row or {}).get("billed") is not False + + +def unchecked_by_zero_fee_rule(row) -> bool: + """ + Whether `zero_fee_uncheck.py` — registered first — will uncheck this + row before this condition's `apply()` runs: its checkbox is known to be + checked (`billed` exactly True) and its Billed Fee was read and is zero + by the shared `claim_form.is_zero_fee()`. A fee of None (the row has + no Billed Fee input — `extract()` says None, not "") or no + `billed_fee` key was never read, and is not zero: zero_fee_uncheck + leaves such a row alone, so it stays billed. + """ + row = row or {} + fee = row.get("billed_fee") + return (row.get("billed") is True and fee is not None + and claim_form.is_zero_fee(fee)) + + +def row_counts_as_billed(row) -> bool: + """Checked (or unknown), and not a line the zero-fee rule will uncheck + — what a vaccine row or an "admin already billed" row must be, in + `detect()` and in `apply()`'s live-grid plan alike (2026-09-30).""" + return row_is_checked(row) and not unchecked_by_zero_fee_rule(row) + + +def is_admin_row(row) -> bool: + """One of this condition's four admin codes, by code only.""" + return _code(row).upper() in ADMIN_CODES + + +def is_vaccine_row(row) -> bool: + """A code on the client's vaccine list, by code only. Says nothing + about the checkbox — `checked_vaccine_rows()` adds that.""" + return _code(row) in VACCINE_CODES + + +def checked_vaccine_rows(cpt) -> list: + """The billed vaccine rows: checked (or unknown) and not billed at zero + — `row_counts_as_billed()`.""" + return [r for r in (cpt or []) + if is_vaccine_row(r) and row_counts_as_billed(r)] + + +def checked_admin_present(cpt) -> bool: + """Whether an admin code is already billed: an admin row that is + checked (or unknown) and not billed at zero — `row_counts_as_billed()`. + A checked 90471 at 0.00 is NOT "admin already billed" (2026-09-30).""" + return any(_code(r) and is_admin_row(r) and row_counts_as_billed(r) + for r in (cpt or [])) + + +# ============================================================================= +# Age +# ============================================================================= + +def _parse_date(text): + try: + return datetime.strptime((text or "").strip(), "%m/%d/%Y").date() + except ValueError: + return None + + +_AGE_YEARS = re.compile(r"(\d+)\s*Y", re.IGNORECASE) +_AGE_UNDER_A_YEAR = re.compile(r"\d+\s*[MWD]", re.IGNORECASE) + + +def age_from_age_sex(text: str): + """ + Whole years from the patient strip's age text — `" 50Y, M"` -> 50. + Months/weeks/days only (`"12M 12D, F"`, `"3D, M"`) is an infant -> 0. + None when there is no age in it at all. + """ + head = (text or "").split(",")[0] + m = _AGE_YEARS.search(head) + if m: + return int(m.group(1)) + if _AGE_UNDER_A_YEAR.search(head): + return 0 + return None + + +def patient_age(data: dict, vaccine_rows) -> tuple: + """ + `(age_in_whole_years, where_it_came_from)`, or `(None, "")`. + + DOB (MM/DD/YYYY) on the date of service first — the first vaccine + row's SDOS, else the claim date. A DOB that will not parse, or falls + after the date of service, is unusable; the age text on the claim + screen is the fallback. + """ + data = data or {} + patient = data.get("patient") or {} + header = data.get("header") or {} + + dob_text = (patient.get("dob") or "").strip() + dob = _parse_date(dob_text) + first_sdos = (vaccine_rows[0].get("sdos") or "") if vaccine_rows else "" + dos = _parse_date(first_sdos) or _parse_date(header.get("claim_date")) + if dob and dos and dob <= dos: + years = dos.year - dob.year - ((dos.month, dos.day) < (dob.month, dob.day)) + return years, f"DOB {dob_text} on {dos:%m/%d/%Y}" + + age_sex = (patient.get("age_sex") or "").strip() + years = age_from_age_sex(age_sex) + if years is not None: + return years, f"age {age_sex!r} on the claim screen" + return None, "" + + +def admin_codes_for_age(age: int) -> tuple: + """`(first, each_additional)` — 18 and below is pediatric.""" + return PEDIATRIC_ADMIN if age <= PEDIATRIC_MAX_AGE else ADULT_ADMIN + + +# ============================================================================= +# The decision, shared by detect() and apply() +# ============================================================================= + +def _payer_name(data: dict) -> str: + rows = (data or {}).get("insurance") or [] + return (rows[0].get("Name") or "").strip() if rows else "" + + +def _payer_group(data: dict): + try: + mapping = payer_groups.load_mapping() + except Exception: + return None + return payer_groups.rule_group(_payer_name(data), mapping) + + +def _decide(data: dict, cpt) -> dict: + """ + The decision itself, printing nothing — `_plan()` logs it, and + `needs_review()` reads its `review` without logging it a second time. + + `{"plan", "log", "review"}`: `plan` is what to add (None = leave the + claim as read); `log` is the WHY line, only for a claim that actually + has billed vaccines (every other claim is silent); `review` is set only + when billed vaccines are left without an admin code for a reason a + human can fix and this condition cannot — a payer it cannot classify, + no payer name read, no usable age (review item D2: those claims were + reported "Clean"). NOT for Medicare: document #19 is Medicare's own + vaccine rule, so a Medicare claim is "not covered here", not "needs a + look". NOT when an admin code is already billed — nothing is missing. + """ + vaccines = checked_vaccine_rows(cpt) + if not vaccines: + return {"plan": None, "log": None, "review": None} + codes = ", ".join(_code(r) for r in vaccines) + + if checked_admin_present(cpt): + return {"plan": None, "review": None, + "log": f"Vaccine(s) {codes} already have an admin code billed " + f"— nothing to add"} + + group = _payer_group(data) + if group is None or group in _EXCLUDED_GROUPS: + if group == "Medicare": + why = (f"payer {_payer_name(data)!r} is Medicare, which has its " + f"own vaccine rule, document #19") + elif not _payer_name(data): + # Not payer_groups.json's fault: nothing was read to classify. + # extract() reads only the UB form's #UBClaimTbl3 today, so on + # the professional/HCFA "Claim" screen (Commercial payers) this + # is every claim — a known extractor gap, shared with + # telehealth.py, not a mapping gap. + why = "no primary payer name was read from this claim screen" + else: + why = (f"payer {_payer_name(data)!r} is not covered here " + f"(payer_groups.json cannot classify it)") + return {"plan": None, + "log": f"Vaccine(s) {codes} billed without an admin code, but " + f"{why} — leaving the claim as read", + "review": (None if group == "Medicare" else + f"vaccine(s) {codes} billed without an admin code, " + f"but {why} — add the admin code by hand")} + + age, age_source = patient_age(data, vaccines) + if age is None: + why = "no usable DOB or age is on the claim" + return {"plan": None, + "log": f"Vaccine(s) {codes} billed without an admin code, but " + f"{why} — leaving the claim as read", + "review": f"vaccine(s) {codes} billed without an admin code, " + f"but {why} — add the admin code by hand"} + + first, additional = admin_codes_for_age(age) + return {"plan": {"vaccines": [_code(r) for r in vaccines], "age": age, + "age_source": age_source, "first": first, + "additional": additional}, + "log": None, "review": None} + + +def _plan(claim_id: str, data: dict, cpt): + """ + What to add, or None to leave the claim as read. Logs WHY only for a + claim that actually has billed vaccines — every other claim is silent. + """ + decision = _decide(data, cpt) + if decision["log"]: + print(f"[COND {NAME} {claim_id}] {decision['log']}") + return decision["plan"] + + +def detect(claim_id: str, data: dict) -> bool: + return _plan(claim_id, data, (data or {}).get("cpt")) is not None + + +def needs_review(claim_id: str, data: dict): + """ + `run_all`'s hook for a claim `detect()` turned down: the reason, when + billed vaccines are being left without an admin code that a human has + to add (unclassified payer, no payer name, no usable age), else None. + The same `_decide()` as detect(), so the two can never disagree; silent, + because detect() has just logged that very decision. + + `_decide()` does run twice for such a claim (once here, once in + detect()), so payer_groups.json is read twice. Deliberate (review of + unit D, 2026-10-05, finding 2): it is one small file read, and only on a + claim with billed vaccines; a cached decision would have to be keyed on + the `data` dict and trusted not to have changed between the two calls, + which is a staleness risk the read does not have. + """ + return _decide(data, (data or {}).get("cpt"))["review"] + + +# ============================================================================= +# apply() +# ============================================================================= + +def _same_units(value, wanted) -> bool: + try: + return float(str(value).strip()) == float(wanted) + except (TypeError, ValueError): + return False + + +def _grid(page) -> list: + return list(claim_form.extract(page).get("cpt") or []) + + +def _row_key(row) -> tuple: + return tuple(sorted((row or {}).items())) + + +def _grid_diff(before: list, after: list) -> dict: + """ + What changed between two reads of the CPT grid, matched by row CONTENT + (every field `extract()` reads), not by position or by code. + + `added` is `[(index_in_after, row)]` — a row with no identical partner + in `before`: the new line, or an existing line eCW changed. `removed` + is the `before` rows with no identical partner left in `after`. The + earliest identical copy is the one matched, so a new line identical to + an old one reads as the later of the two — where eCW appends. + """ + pool = Counter(_row_key(r) for r in before) + added = [] + for i, row in enumerate(after): + key = _row_key(row) + if pool[key] > 0: + pool[key] -= 1 + else: + added.append((i, row)) + removed = [] + for row in before: + key = _row_key(row) + if pool[key] > 0: + pool[key] -= 1 + removed.append(row) + return {"added": added, "removed": removed} + + +def _settled_diff(page, before: list) -> tuple: + """`(after, diff)` once the grid differs from `before`, or as it stands + after `_NEW_ROW_WAIT_MS` — the new line can render after + add_cpt_row()'s own check, and a failed add is re-read too, so a picker + that changed something without appending is never reported as + "nothing happened".""" + waited = 0 + while True: + after = _grid(page) + diff = _grid_diff(before, after) + if diff["added"] or diff["removed"] or waited >= _NEW_ROW_WAIT_MS: + return after, diff + page.wait_for_timeout(250) + waited += 250 + + +def _row_summary(row) -> str: + state = {True: "checked", False: "unchecked"}.get( + (row or {}).get("billed"), "checkbox unknown") + return (f"{_code(row) or '?'} x{row.get('units') or '?'} " + f"(fee {row.get('billed_fee') or '?'}, {state})") + + +def _add_admin_code(page, claim_id: str, code: str, units: int, + always_set_units: bool = False, name: str = NAME) -> dict: + """ + Add one admin code via eCW's own picker, set its Units, and read the + new line back. Never raises: every outcome — including a Playwright + timeout inside add_cpt_row() or set_cpt_units() — comes back as the + change record, so a code already added earlier in this claim is never + lost from the report by a later step blowing up (ruling 7). + + Whether it was added is decided from the GRID, snapshotted before the + Add and re-read after, not from add_cpt_row()'s return: that only + checks that SOME row carries the code afterward, which an unchecked + copy already on the claim (9132000's 90460) satisfies even when + nothing was added. The diff is by row content, so it also says WHICH + line is the new one — Units is written to that exact line, never to + "the last row with this code" — and catches anything else the picker's + OK changed (a duplicate line, an existing line re-checked or + re-priced), which is reported for a hand check rather than hidden. + + `always_set_units` is for the "each additional" code: its Units is + always filled and blurred, even when it already reads the count wanted + (2 vaccines -> 1), so eCW's own UNITS handler has run on it either way. + The first code is only written when its Units is NOT already 1 — the + picker's own default, which leaves the line exactly as eCW priced it. + + The record: `code, added, wanted_units, units, billed_fee, error`, + plus `grid_changed` (True / False / None when the grid could not be + re-read) and `unexpected` (readable lines for any change beyond the + one new line). + + `name` is the condition the log lines are tagged with — + `injection_admin_code.py` adds 96372 through this same function. + """ + tag = f"[COND {name} {claim_id}]" + change = {"code": code, "added": False, "wanted_units": units, + "units": None, "billed_fee": None, "error": None, + "grid_changed": False, "unexpected": []} + problems = [] + + def finish() -> dict: + change["error"] = "; ".join(problems) or None + if change["added"]: + print(f"{tag} Added {code} x{change['units']} " + f"(billed fee {change['billed_fee']})" + + (f" — CHECK: {change['error']}" if change["error"] else "")) + else: + print(f"{tag} Could not add {code}: {change['error']}") + for line in change["unexpected"]: + print(f"{tag} CHECK BY HAND — the grid also changed: {line}") + return change + + # VERIFY adds nothing: the picker's OK saves the new row in eCW at once + # (review 2026-10-16). The claim goes to Review, "add it by hand". + if not claim_form.writes_allowed(page): + problems.append(f"not added in VERIFY mode — adding a CPT row saves " + f"in eCW at once; add {code} by hand") + return finish() + + try: + before = _grid(page) + except Exception as exc: + problems.append(f"could not read the CPT grid before adding it " + f"({first_line(exc)})") + return finish() + + picked = False + add_error = None + try: + picked = claim_form.add_cpt_row(page, code) + except Exception as exc: + add_error = f"adding it raised: {first_line(exc)}" + # add_cpt_row() closes the picker on its own failure paths; this also + # covers an OK that "worked" but left the picker up — either way an open + # picker would block close_claim() on this whole claim. + if claim_form.close_fee_schedule_picker(page): + problems.append("eCW's Fee Schedule picker was left open and had " + "to be closed") + + try: + after, diff = _settled_diff(page, before) + except Exception as exc: + change["grid_changed"] = None + if add_error: + problems.insert(0, add_error) + problems.append(f"could not re-read the CPT grid afterward " + f"({first_line(exc)}) — check the claim by hand") + return finish() + + change["grid_changed"] = bool(diff["added"] or diff["removed"]) + net_new = (sum(1 for r in after if _code(r) == code) + - sum(1 for r in before if _code(r) == code)) + new_of_code = [(i, r) for i, r in diff["added"] if _code(r) == code] + target = new_of_code[0] if net_new == 1 and len(new_of_code) == 1 else None + change["added"] = net_new >= 1 + + # Everything beyond the one new line — an existing line eCW changed + # shows up as both its old state ("gone or changed") and its new one. + if change["added"] and target is None: + change["unexpected"].append( + f"{net_new} new line(s) for {code} — could not tell which one " + f"is the new one") + change["unexpected"] += ( + [f"new or changed line: {_row_summary(r)}" + for i, r in diff["added"] if target is None or i != target[0]] + + [f"line gone or changed: {_row_summary(r)}" + for r in diff["removed"]]) + + if not change["added"]: + problems.insert(0, add_error or ( + "the picker closed but no new line for it appeared on the claim" + if picked else "eCW's Fee Schedule picker did not add it")) + return finish() + if add_error: + problems.insert(0, add_error + " (but a new line for it is there)") + + if target is not None: + shown = target[1] + else: + shown = new_of_code[-1][1] if new_of_code else {} + change["units"] = shown.get("units") + change["billed_fee"] = shown.get("billed_fee") + if not always_set_units and _same_units(change["units"], units): + return finish() + if target is None: + problems.append(f"Units NOT set to {units}: could not tell which " + f"line is the new one") + return finish() + + index = target[0] + try: + wrote = claim_form.set_cpt_units(page, code, units, row_index=index) + except Exception as exc: + problems.append(f"setting Units to {units} raised: " + f"{first_line(exc)}") + try: # report what the line really reads now, not the pre-write read + row = _grid(page)[index] + if _code(row) == code: + change["units"] = row.get("units") + change["billed_fee"] = row.get("billed_fee") + except Exception: + pass + return finish() + + if wrote is None: + problems.append(f"could not set Units to {units} — the new line or " + f"its Units field was not found") + return finish() + change["units"] = wrote["units"] + change["billed_fee"] = wrote["billed_fee"] + if not _same_units(wrote["units"], units): + problems.append(f"Units read back {wrote['units']!r}, wanted {units}") + if wrote.get("fee_repriced") is False: + problems.append(f"the billed fee still reads {wrote['billed_fee']} " + f"after Units changed — eCW may not have re-priced " + f"the line") + return finish() + + +def _facts(data: dict, live: dict) -> dict: + """The live read, with the pre-read `data`'s patient/header/insurance + where it has them — the facts detect() decided on. Those never change + between conditions; the CPT grid is what does, so it always comes from + the live read.""" + merged = dict(live or {}) + for key in ("patient", "header", "insurance"): + if (data or {}).get(key): + merged[key] = data[key] + return merged + + +def apply(page, claim_id: str, data: dict) -> ConditionResult: + """ + Re-clicks CPT/HCPCS for the same reason every sibling condition's + `apply()` does — eCW can leave the form on a different tab after the + Prog. Notes viewer cycle (seen live, claim 9116000) — then re-reads the + grid, so an earlier condition's changes in this same run are seen. + + A result with `changed=True` comes back whenever the grid is (or may + be) different from before — a real add, or a failed one that still + changed something — so describe() always covers what the operator is + about to save; a failed one also carries `review` (review item D2, + 2026-10-05), so the claim is a Review row and is never offered for + save — and so does a first code that landed with the "each additional" + code failing after it, because the claim still lacks one. A first code that failed with the grid provably untouched is + `ConditionResult.declined` (ruling 7): nothing changed, but the claim + still lacks its admin code, which is not "clean". + """ + tag = f"[COND {NAME} {claim_id}]" + try: + claim_form.open_cpt_hcpcs_tab(page) + except Exception as exc: + print(f"{tag} Could not confirm the CPT/HCPCS tab before fixing: " + f"{exc} — leaving the claim as read") + # Not "nothing to do": the claim matched and was never + # checked, so it goes to a human (review item D2). + return ConditionResult.declined(NAME, CPT_TAB_NOT_CONFIRMED) + + live = claim_form.extract(page) + if has_cpt_rows(data) and not has_cpt_rows(live): + # detect() found billed vaccines in the first read; the live grid + # came back empty — it had not re-rendered. "Nothing to add" would + # call a claim clean that still lacks its admin code (final + # review, 2026-10-05). + print(f"{tag} {CPT_GRID_NOT_READ} — leaving the claim as read") + return ConditionResult.declined(NAME, CPT_GRID_NOT_READ) + plan = _plan(claim_id, _facts(data, live), live.get("cpt")) + if plan is None: + print(f"{tag} Nothing to add on the live form — leaving the claim " + f"as read") + return ConditionResult.none(NAME) + + if not claim_form.writes_allowed(page): + # VERIFY adds no row (the picker's OK saves it in eCW at once), so + # the claim is Review — naming EVERY code LIVE-WRITE would add, the + # "each additional" one too (review, 2026-10-19). + codes = [plan["first"]] + if len(plan["vaccines"]) >= 2: + codes.append(plan["additional"]) + review = (f"{' and '.join(codes)} not added in VERIFY — add " + f"{'them' if len(codes) > 1 else 'it'} by hand") + print(f"{tag} {review}") + return ConditionResult.declined(NAME, review) + + first = _add_admin_code(page, claim_id, plan["first"], 1) + changes = [first] + if not first["added"]: + if first["grid_changed"] is False: + print(f"{tag} {plan['first']} could not be added, so " + f"{plan['additional']} was not attempted — nothing added") + return ConditionResult.declined( + NAME, f"{plan['first']} could not be added — add it by hand") + print(f"{tag} {plan['first']} could not be added, so " + f"{plan['additional']} was not attempted — but the grid " + f"{'changed' if first['grid_changed'] else 'could not be re-read'}" + f" while trying, so this is reported for a hand check") + else: + # Operator's rule: more than one vaccine -> the "each additional" + # code once, Units 1 — not vaccines - 1 (see the module docstring). + if len(plan["vaccines"]) >= 2: + changes.append(_add_admin_code(page, claim_id, plan["additional"], + 1)) + + # Stashed on every change so describe() can say WHY — which vaccines, + # and which age picked the pediatric or adult codes — not just what. + for c in changes: + c["vaccines"] = plan["vaccines"] + c["age"] = plan["age"] + c["age_source"] = plan["age_source"] + + # A first code that did not land, on a grid that changed (or could not + # be re-read) while trying: reported as a change so describe() shows + # it, AND flagged, because what is on the form now is not a clean add. + review = ("" if first["added"] else + f"{plan['first']} could not be added cleanly — check the CPT " + f"grid by hand") + if not review: + # The first code landed but the "each additional" one did not: the + # claim is still missing an admin code, so it is not a Fail row + # reading "vaccine admin code added" (review of unit D, 2026-10-05, + # finding 3). Same wording split as the first code: a grid + # provably untouched only needs the code added; anything else + # needs the grid checked. + for c in changes[1:]: + if not c["added"]: + review = (f"{c['code']} could not be added — add it by hand" + if c["grid_changed"] is False else + f"{c['code']} could not be added cleanly — check " + f"the CPT grid by hand") + break + return ConditionResult(condition_name=NAME, changed=True, changes=changes, + review=review) + + +def summary_comment(result: ConditionResult) -> str: + """ + The Charge Summary comment, from what actually happened — e.g. + `CPT Code Missing (vaccine admin code 90471 added; 90472 NOT added — + add by hand)`. The fixed "( vaccine admin code added )" was false on a + claim whose admin code did NOT land: the row then said "added" right + next to the review reason saying it could not be (final review, + 2026-10-05). + """ + parts = [(f"{c['code']} added" if c.get("added") + else f"{c['code']} NOT added — add by hand") + for c in result.changes if c.get("code")] + return (f"CPT Code Missing (vaccine admin code {'; '.join(parts)})" + if parts else "CPT Code Missing") + + +def describe(result: ConditionResult) -> str: + ctx = result.changes[0] if result.changes else {} + vaccines = ctx.get("vaccines") or [] + age = ctx.get("age") + band = ("18 and below" if age is not None and age <= PEDIATRIC_MAX_AGE + else "above 18") + lines = [ + f"Vaccine codes billed without an admin code " + f"({len(vaccines)}): {', '.join(vaccines)}", + f" Patient age used: {age} ({ctx.get('age_source')}) — {band}", + ] + for c in result.changes: + if c.get("added"): + line = (f" Added {c['code']} x{c['units']} " + f"(billed fee {c['billed_fee']})") + if c.get("error"): + line += f" — CHECK: {c['error']}" + else: + line = (f" FAILED to add {c['code']} x{c['wanted_units']}: " + f"{c['error']} — add it by hand") + lines.append(line) + for extra in c.get("unexpected") or []: + lines.append(f" CHECK BY HAND — the grid also changed while " + f"adding {c['code']}: {extra}") + return "\n".join(lines) diff --git a/src/ecw_bot/conditions/zero_fee_uncheck.py b/src/ecw_bot/conditions/zero_fee_uncheck.py new file mode 100644 index 0000000..b31023e --- /dev/null +++ b/src/ecw_bot/conditions/zero_fee_uncheck.py @@ -0,0 +1,161 @@ +r""" +Condition: the operator's common rule for every claim (agreed 2026-09-30) +— a CPT/HCPCS line that is checked ("Assign To Patient", i.e. billed) but +whose Billed Fee is zero gets unchecked. + +The operator gave it as "one basic condition... our common rule for all +the claims": if a CPT line is checked but its billed fee is zero, uncheck +that line. Not a row of the client's Charge Condition Document — a rule +the operator applies by hand on every claim they review. + +**One direction here.** The operator's first description also carried the +reverse — an unchecked line that has a fee gets checked — and then +dropped it; on 2026-09-30 the operator asked for it again. It lives in +`priced_line_check.py`, NOT in this module: an unchecked line is still +never touched here, whatever its fee. The halves are split because they +need opposite ends of the registry. This one runs FIRST, so every +specific condition's `apply()` has the last word over a zero-fee line +(and depression's re-include step never checks back one this has just +unchecked). The reverse runs LAST, so every exclusion condition — J2001, +DCONS/Dscprit, Medicare telehealth's other lines, depression's duplicates — +has already unchecked and zeroed its line before it looks; run any +earlier, it would check that line and its own condition would uncheck it +straight after, claim 9131000's toggle between conditions. One condition +cannot sit at both ends. + +Live evidence: of 39 saved claim snapshots, exactly one checked zero-fee +line — 85018 (Hemoglobin) at 0.00. Rare, but the rule itself is general: +every payer, every code, both claim screens this bot corrects (the UB +form and the professional/HCFA screen). The Dental screen never reaches +`run_all` at all — `is_dental_claim_form()` in +`flows/export_medcor_ready.py` closes it first. + +"Zero" is `claim_form.is_zero_fee()`: blank, or a number equal to 0 once +"$" and "," are taken out ("0", "0.00", "0.0", ".00"). A fee that does +not parse is not zero, and its line is left alone; so is a line with no +Billed Fee input or no "Assign To Patient" checkbox at all — its state is +unknown, and unknown must never read as "zero" or as "billed". The fee +itself is never written, only read. + +**Registered FIRST** in `conditions.CONDITIONS`: its `apply()` runs on the +claim exactly as it was read, and every specific condition's `apply()` +(telehealth, medicare_telehealth, depression_dx_pointer, +j2930_j2001_update, dummy_codes, vaccine_admin_codes) runs after it, on a +grid that already follows the common rule — so a specific condition's +`apply()` has the last word on its own lines. Only the reverse half, +`priced_line_check`, comes after them all (above). + +Its `apply()`, not its `detect()`: `base.run_all()` runs EVERY +condition's `detect()` on the untouched claim before any `apply()`, so a +later condition decides WHETHER to run from the grid as read, zero-fee +lines still checked. Only one specific condition's `detect()` reads the +checkboxes at all — vaccine_admin_codes — and this rule used to open a +gap there: it counted a CHECKED admin line as "admin already billed" +whatever its fee, so a vaccine claim whose only admin line was checked at +0.00 — none in the 39 snapshots — was not fired; this then unchecked that +admin line, and the claim left with the vaccine billed and no admin code, +document #18's very case. Fixed by the operator's ruling of 2026-09-30: +vaccine_admin_codes no longer counts a checked zero-fee line (billed +exactly True, fee zero by the same `is_zero_fee()`) — as an admin line or +as a vaccine — in `detect()` or in `apply()`'s live-grid plan, because +this condition will have unchecked it. That claim now gets its admin code +added, the same as when the admin line is already unchecked (live claim +9132000's shape); test_conditions_zero_fee_uncheck.py asserts both. + +Where it meets depression_dx_pointer, on the G8510/G8431 rows: + * **Re-including one** (`claim_form.ensure_cpt_rows_checked()`) would + check straight back a 0.00 line this had just unchecked — the same + check/uncheck toggle already fixed once for claim 9131000, between two + steps of one condition — so that step never checks a zero-fee line, + judged by the same shared `is_zero_fee()`. + * **Which one is kept** (`claim_form.kept_cpt_row_per_code()`, asked by + both the re-include step and the duplicate step + `zero_and_uncheck_duplicate_cpt_rows()`, so they cannot disagree): + since the operator's ruling of 2026-09-30, the first copy whose fee is + NOT zero — the first by position only when every copy is zero-fee. So + a first G8510 checked at 0.00 with a later G8510 checked at 65.00 — a + shape none of the snapshots shows — ends with the 65.00 copy billed + with its fee: this unchecks the 0.00 one, and depression keeps the + 65.00 one and has nothing left to do. Before the ruling, "kept" was + the first by position, and the duplicate step unchecked and zeroed + the 65.00 copy: NEITHER was billed. test_conditions_zero_fee_uncheck.py + asserts the ruled outcome. + +Where it meets medicare_telehealth — NOT yet ruled on (review finding, +2026-09-30): a G2025 checked at 0.00 (one a previous run added and the +Fee Schedule priced at nothing) is unchecked here, and then +`claim_form.apply_medicare_telehealth_g2025()` still finds it through +`cpt_code_present()`, which ignores the checkbox. It counts G2025 as +confirmed and unchecks and zeroes every other line, so the claim is left +with NO billed line. Requiring a checked, non-zero G2025 there would +change document #22's own behaviour; test_conditions_zero_fee_uncheck.py +pins today's outcome +(`pinned_needs_ruling_a_g2025_checked_at_zero_leaves_no_line_billed`). +""" + +from ..pages import claim_form +from .base import CPT_TAB_NOT_CONFIRMED, ConditionResult + +NAME = "zero_fee_uncheck" + + +def detect(claim_id: str, data: dict) -> bool: + """ + Whether the claim as read has a checked line billed at zero. + + `billed is True`, not merely truthy-or-missing: `extract()` reports + None for a row with no "Assign To Patient" checkbox, and an unknown + state must not fire this — `apply()` would leave that row alone anyway. + + Likewise `billed_fee is not None`: `extract()` reports None (not "") + for a row with no Billed Fee input, and `is_zero_fee(None)` is True — + but `apply()` skips a row with no fee input, so without this guard + detect() fired on a line apply() could never uncheck (review finding, + 2026-09-30). vaccine_admin_codes' pre-read makes the same distinction. + """ + for row in (data or {}).get("cpt") or []: + fee = row.get("billed_fee") + if (row.get("billed") is True and fee is not None + and claim_form.is_zero_fee(fee)): + return True + return False + + +def apply(page, claim_id: str, data: dict) -> ConditionResult: + """ + Re-clicks CPT/HCPCS for the same reason `depression_dx_pointer.py`'s + and `j2930_j2001_update.py`'s `apply()` do — eCW can leave the form on + a different tab after the Prog. Notes viewer cycle (seen live, claim + 9116000). A no-op on the professional/HCFA screen, which has no such + tab to lose. + + Acts on the live grid (`claim_form.uncheck_zero_fee_cpt_rows()`), not + on `data`: `detect()` only decides whether this is worth running. + """ + try: + claim_form.open_cpt_hcpcs_tab(page) + except Exception as exc: + print(f"[COND {NAME} {claim_id}] Could not confirm the CPT/HCPCS " + f"tab before fixing: {exc} — leaving the claim as read") + # Not "nothing to do": the claim matched and was never + # checked, so it goes to a human (review item D2). + return ConditionResult.declined(NAME, CPT_TAB_NOT_CONFIRMED) + + changes = claim_form.uncheck_zero_fee_cpt_rows(page) + if not changes: + # Said out loud, the same as depression_dx_pointer's own no-change + # path: otherwise the log's last word is "about to apply: + # zero_fee_uncheck", which reads as a silent failure. + print(f"[COND {NAME} {claim_id}] No checked CPT line with a zero " + f"billed fee on the grid — nothing to uncheck") + return ConditionResult.none(NAME) + return ConditionResult(condition_name=NAME, changed=True, changes=changes) + + +def describe(result: ConditionResult) -> str: + lines = ["Checked CPT line(s) with a zero billed fee unchecked:"] + for c in result.changes: + fee = c.get("fee") or "(blank)" + lines.append(f" Row {c['row'] + 1} ({c['code']}): " + f"checked with billed fee {fee} -> unchecked") + return "\n".join(lines) diff --git a/src/ecw_bot/core/__init__.py b/src/ecw_bot/core/__init__.py new file mode 100644 index 0000000..d1a7f8c --- /dev/null +++ b/src/ecw_bot/core/__init__.py @@ -0,0 +1 @@ +"""Portal-agnostic plumbing: browser, page helpers, logging, prompts, guard.""" diff --git a/src/ecw_bot/core/browser.py b/src/ecw_bot/core/browser.py new file mode 100644 index 0000000..0129b69 --- /dev/null +++ b/src/ecw_bot/core/browser.py @@ -0,0 +1,351 @@ +""" +Browser launch. + +One line in here is the whole reason this bot can work at all: + + kwargs["proxy"] = proxy # chromium.launch(proxy=…) + +The proxy is set **per browser**, not on Windows. That dissolves a deadlock +that makes logging into this account by hand impossible. Measured 2026-09-01: + + | target | direct | through the proxy | + |----------------------------|-----------|-------------------| + | eCW portal | HTTP 400 | HTTP 200 | + | emailed approval link | HTTP 400 | HTTP 200 | + | webmail site (cPanel :2096)| HTTP 200 | 403, proxy refuses| + | mailbox over IMAP :993 | connects | not proxied at all| + +The portal needs the proxy; the webmail site is blocked *by* it. A human would +have to toggle the Windows proxy mid-login. The bot never opens the webmail +site: this browser gets the proxy, and `auth/mailbox.py` reads the mail with +`imaplib` over a raw socket that ignores WinINET and HTTP(S)_PROXY entirely. + +So the Windows system proxy must stay **off** +(`HKCU:\\...\\Internet Settings\\ProxyEnable = 0`). `tools\\check_net.py` +asserts that. + +With `BROWSER_CDP_URL` set the bot does not launch Playwright's browser: it +attaches to Google Chrome on that debug port (profile on D:, 127.0.0.1 +only), so the run can be watched with the Claude extension in that Chrome. +No Chrome there when the run starts → the bot opens it (`start_chrome`, +operator 2026-10-22: Start on the Portal is all it should take), and closes +it again at the end (`CDP_CLOSE_AFTER_RUN`) — never a Chrome it did not +open. The bot opens its own tab, clears eCW's cookies and storage when it +attaches and again when it is done, and closes only its own tabs. +""" + +import os +import sys +import time +import subprocess +import urllib.request +from pathlib import Path +from urllib.parse import urlsplit + +from .. import settings as cfg +from .page_utils import auto_dialog + + +# Chrome pauses a window it thinks nobody can see. The bot's review and +# per-claim boxes are Windows message boxes in front of it, and while one is +# up Chrome stopped the claim window's fade-out half-way (live run +# 2026-10-13: an HCFA window left at opacity 0.222, pointer-events none, in +# the DOM after its Cancel) — the next claim found that "form still open", +# and four Cancel clicks on it timed out (~45 s). These keep the page +# running behind a box. +_KEEP_RUNNING_BEHIND_A_BOX = [ + "--disable-backgrounding-occluded-windows", + "--disable-renderer-backgrounding", + "--disable-background-timer-throttling", +] + + +CHROME_ARGS = ["--disable-blink-features=AutomationControlled", + "--start-maximized", *_KEEP_RUNNING_BEHIND_A_BOX] + + +def launch(pw, incognito: bool = False): + """Launch a browser + context wired up with the dialog handler. + + With BROWSER_CDP_URL set, attach to that Chrome instead. The incognito + retry after a failed captcha still launches a fresh Chromium of its own: + a clean, cookie-less browser is the point of that retry. + """ + if cfg.BROWSER_CDP_URL and not incognito: + return _attach(pw, cfg.BROWSER_CDP_URL) + + args = list(CHROME_ARGS) + if incognito: + args.append("--incognito") + + kwargs = dict(headless=cfg.HEADLESS, args=args) + proxy = cfg.proxy_settings() + if proxy: + kwargs["proxy"] = proxy + print(f"[BROWSER] Proxy {proxy['server']}") + if cfg.CHROME_PATH: + kwargs["executable_path"] = cfg.CHROME_PATH + print(f"[BROWSER] Chrome {cfg.CHROME_PATH}") + try: + cfg.DOWNLOADS_TMP.mkdir(parents=True, exist_ok=True) + kwargs["downloads_path"] = str(cfg.DOWNLOADS_TMP) + except Exception: + pass + + browser = pw.chromium.launch(**kwargs) + context = browser.new_context( + no_viewport=True, # use the real maximized window + ignore_https_errors=True, + accept_downloads=True, + ) + context.on("page", lambda p: p.on("dialog", auto_dialog)) + page = context.new_page() + page.on("dialog", auto_dialog) + return browser, context, page + + +# ── Attached Chrome (BROWSER_CDP_URL) ───────────────────────────────────────── + +# The extension signs in and talks to these; the eCW proxy has no business +# with them (the Windows proxy stays off, so direct works). +PROXY_BYPASS = "claude.ai;*.claude.ai;*.anthropic.com" + +# Debug-port URLs of the Chromes this process opened — the only ones it +# closes when it is done. +_STARTED = set() + +# Started through a Python that exits at once: Chrome's parent is then +# gone, so a Stop on the Portal (taskkill /T on the bot) never takes Chrome +# with it. +_SPAWN = ("import subprocess, sys; subprocess.Popen(sys.argv[1:], " + "creationflags=0x00000008 | 0x00000200, close_fds=True, " + "stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL, " + "stderr=subprocess.DEVNULL)") + + +def find_chrome(): + """Google Chrome's chrome.exe: CHROME_PATH if it is set, else the usual + per-user and per-machine install folders.""" + candidates = [cfg.CHROME_PATH] if cfg.CHROME_PATH else [] + for base in ("LOCALAPPDATA", "PROGRAMFILES", "PROGRAMFILES(X86)"): + if os.environ.get(base): + candidates.append(os.path.join(os.environ[base], "Google", "Chrome", + "Application", "chrome.exe")) + for path in candidates: + if path and Path(path).is_file(): + return path + return None + + +def chrome_command(exe: str, port: int, profile: str, proxy) -> list: + cmd = [exe, f"--remote-debugging-port={port}", + "--remote-debugging-address=127.0.0.1", + f"--user-data-dir={profile}", + "--no-first-run", "--no-default-browser-check", *CHROME_ARGS] + if proxy: + cmd += [f"--proxy-server={proxy['server']}", + f"--proxy-bypass-list={PROXY_BYPASS}"] + return cmd + + +def _cmdline_on_port(port: int): + """The command line of the process listening on `port`, or None.""" + ps = (f"$c = Get-NetTCPConnection -LocalPort {int(port)} -State Listen " + f"-ErrorAction SilentlyContinue | Select-Object -First 1; " + f"if ($c) {{ (Get-CimInstance Win32_Process -Filter " + f"\"ProcessId=$($c.OwningProcess)\").CommandLine }}") + try: + out = subprocess.run(["powershell", "-NoProfile", "-Command", ps], + capture_output=True, text=True, + timeout=30).stdout.strip() + except Exception: + return None + return out or None + + +def chrome_proxy_problem(url: str): + """Why the Chrome on `url` must not be used for the eCW login, or None. + + eCW answers only through the proxy. A Chrome on the port without it + sends the login straight to eCW, which refuses it as "restricted" — and + the guard then locks the bot out (2026-10-22: a test opened such a + Chrome, and a Portal run attached to it). Checked on the Chrome's own + command line; when that cannot be read, the run goes on.""" + proxy = cfg.proxy_settings() + if not proxy: + return None + cmdline = _cmdline_on_port(urlsplit(url).port) + if cmdline is None: + print(f"[BROWSER] Could not read the command line of the Chrome at " + f"{url} — its proxy is not checked") + return None + if f"--proxy-server={proxy['server']}" in cmdline: + return None + return (f"The Chrome at {url} was started without the eCW proxy " + f"({proxy['server']}) — eCW would refuse the login. Close that " + f"Chrome; the run opens its own") + + +def start_chrome(url: str, extra_args=()) -> bool: + """Open Google Chrome on `url`'s debug port with CDP_PROFILE_DIR and the + eCW proxy, outside this process's tree. True once it answers.""" + if cdp_ready(url): + return True + exe = find_chrome() + if not exe: + print("[BROWSER] Google Chrome not found — set CHROME_PATH in .env") + return False + Path(cfg.CDP_PROFILE_DIR).mkdir(parents=True, exist_ok=True) + cmd = chrome_command(exe, urlsplit(url).port, cfg.CDP_PROFILE_DIR, + cfg.proxy_settings()) + list(extra_args) + subprocess.Popen([sys.executable, "-c", _SPAWN, *cmd], + creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0), + stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL) + for _ in range(60): + if cdp_ready(url): + _STARTED.add(url) + print(f"[BROWSER] Opened Chrome at {url} " + f"(profile {cfg.CDP_PROFILE_DIR})") + return True + time.sleep(0.5) + print(f"[BROWSER] Started {exe}, but nothing answers at {url} — a " + f"Chrome already open with that profile and no debug port? " + f"Close it and start again") + return False + + +def cdp_ready(url: str) -> bool: + """True if a Chrome answers on its debug port at `url`.""" + try: + with urllib.request.urlopen(url.rstrip("/") + "/json/version", + timeout=3) as resp: + return resp.status == 200 + except Exception: + return False + + +def ecw_sites(urls) -> set: + """eCW's registrable domains ("ecwcloud.com"), from its configured URLs. + + eCW sets cookies on the parent domain as well as on the app host, so the + clean-up goes by the domain, not the host. + """ + sites = set() + for url in urls: + host = urlsplit(url).hostname if url else None + if host: + sites.add(".".join(host.split(".")[-2:])) + return sites + + +def is_ecw_host(host: str, sites) -> bool: + host = host.lstrip(".").lower() + return any(host == s or host.endswith("." + s) for s in sites) + + +def clear_ecw_data(context, page) -> None: + """Remove eCW's cookies and site storage from `context`, nothing else. + + Only eCW's: the same profile holds claude.ai's sign-in, which is what + keeps the extension connected between runs. + """ + sites = ecw_sites([cfg.ECW_URL, cfg.ECW_LANDING_URL]) + if not sites: + return + hosts = {urlsplit(u).hostname for u in (cfg.ECW_URL, cfg.ECW_LANDING_URL) if u} + for p in context.pages: + host = urlsplit(p.url).hostname + if host and is_ecw_host(host, sites): + hosts.add(host) + for c in context.cookies(): + if is_ecw_host(c["domain"], sites): + hosts.add(c["domain"].lstrip(".")) + context.clear_cookies(name=c["name"], domain=c["domain"], + path=c["path"]) + # localStorage, IndexedDB, cache, service workers — per origin. + cdp = context.new_cdp_session(page) + try: + for host in sorted(h for h in hosts if h): + cdp.send("Storage.clearDataForOrigin", + {"origin": f"https://{host}", "storageTypes": "all"}) + finally: + cdp.detach() + + +class _AttachedBrowser: + """What `launch()` returns as "browser" when attached to Chrome. Callers + only ever `close()` it; here that means clear eCW's data, close the + bot's tabs, and disconnect — and close Chrome too if this run opened it + (CDP_CLOSE_AFTER_RUN), so its debug port is not left open.""" + + def __init__(self, browser, context, page, theirs, url=""): + self._browser, self._context, self._page = browser, context, page + self._theirs = theirs # tabs that were open before the bot + self._url = url + + def close(self) -> None: + try: + clear_ecw_data(self._context, self._page) + print("[BROWSER] eCW cookies and storage cleared") + except Exception as exc: + # Cleared again when the next run attaches. + print(f"[BROWSER] Could not clear eCW data: {exc}") + if self._url in _STARTED and cfg.CDP_CLOSE_AFTER_RUN: + try: + self._browser.new_browser_cdp_session().send("Browser.close") + print("[BROWSER] Closed the Chrome this run opened") + except Exception: + pass # the connection goes with it + _STARTED.discard(self._url) + try: + self._browser.close() + except Exception: + pass + return + try: + mine = [p for p in self._context.pages if p not in self._theirs] + if len(mine) == len(self._context.pages) and mine: + # Closing Chrome's last tab closes Chrome. Leave one, blank. + mine[0].goto("about:blank") + mine = mine[1:] + for p in mine: + p.close() + finally: + self._browser.close() # connected, not launched: disconnects + + +def _is_blank(url: str) -> bool: + """A tab showing nothing: about:blank or Chrome's New Tab page.""" + return (url or "about:blank").split("?")[0].rstrip("/") in ( + "about:blank", "chrome://newtab", "chrome://new-tab-page", + "chrome-search://local-ntp/local-ntp.html") + + +def _attach(pw, url: str): + """Attach to the operator's Chrome; same (browser, context, page) shape.""" + browser = pw.chromium.connect_over_cdp(url) + print(f"[BROWSER] Attached to Chrome at {url} (its own proxy and profile)") + # Chrome's own context: the one the extension can see. It cannot take + # new_context()'s options; a real window already has no viewport. + context = browser.contexts[0] + theirs = list(context.pages) + context.on("page", lambda p: p.on("dialog", auto_dialog)) + page = context.new_page() + page.on("dialog", auto_dialog) + if url in _STARTED: + # The Chrome this run opened shows eCW only (operator, 2026-10-23): + # its empty first tab goes, now that the bot's tab keeps Chrome open. + # The operator's own Chrome keeps every tab it had. + for p in [p for p in theirs if _is_blank(p.url)]: + try: + p.close() + theirs.remove(p) + except Exception as exc: + print(f"[BROWSER] Could not close the empty first tab: {exc}") + # A run killed half-way (Stop on the results page, a crash) never got to + # close(), so its session may still be here. + clear_ecw_data(context, page) + page.bring_to_front() + return (_AttachedBrowser(browser, context, page, theirs, url), context, + page) diff --git a/src/ecw_bot/core/charge_summary.py b/src/ecw_bot/core/charge_summary.py new file mode 100644 index 0000000..3f1b19f --- /dev/null +++ b/src/ecw_bot/core/charge_summary.py @@ -0,0 +1,462 @@ +r""" +The Charge Summary sheet: one row per claim in the run's report, in the +operator's own 24-column format ("Charge Summary File Format.xlsx", +2026-10-01), written to Output\Charge Summary_<date>.xlsx after every +claim of a claims run, so the sheet grows claim by claim — or Output\Charge Summary_TEST_<date>.xlsx when the claim +ids came from a TEST_CLAIM_IDS_FILE sheet (see file_name()). + +Columns A-R come from the downloaded claim report (15 of them by the same +header), plus three the report does not carry: + + S.No 1, 2, 3 ... in the report's order + Group Name payer_groups.json, blank when the payer is unmapped + Pmts/Refunds PMTS/ADJS - Adjustment (the format's own sample rows all + have PMTS/ADJS = Pmts/Refunds + Adjustment) + +The report's CLAIMTYPE column is dropped. Columns S-X are what this run +did with the claim, and stay blank for a claim it never opened (not found, +past CLAIM_PROCESS_LIMIT, or OPEN_CLAIM_FORM off). A Dental claim is read +and run through the conditions like any other since 2026-10-12, so its row +carries the condition comments and its high risk / sliding fee findings: + + Recevied date / Completed On the run date + Assigned To SUMMARY_ASSIGNED_TO (default "eCW Bot") + Comments one line per finding, then one per review + reason, "; "-joined + Current Status / Category see claim_outcome() — "Pass", "Fail", or + "Review" / "Needs manual review" + +"Review" is the third Category (review items D2/St1, 2026-10-05). Before it, +anything the bot did not actually check — a condition that raised or +declined, APPLY_CORRECTIONS=0, a progress note never read, a form that +would not read — came out "Clean claims / Ready to Submit / Pass": the +sheet claimed a check that never happened. + +Header spellings ("Recevied date", "Assigned To ") are the format's own, +kept exactly so the sheet lines up with the team's existing ones. +""" + +from datetime import datetime +from pathlib import Path + +from . import payer_groups, spreadsheet +from .text import first_line + +HEADERS = [ + "S.No", "CLAIMS#", "SERVICE DATE", "PVDR", "PATIENT", "PAYER", + "Group Name", "STATUS", "CHARGES", "PMTS/ADJS", "Pmts/Refunds", + "Adjustment", "WithHeld", "Balance", "Visit Type", "Account Number", + "Provider Name", "Guarantor Name", "Recevied date", "Assigned To ", + "Completed On", "Comments", "Current Status", "Category", +] + +# Copied straight from the downloaded report, by header name. +_FROM_REPORT = [ + "CLAIMS#", "SERVICE DATE", "PVDR", "PATIENT", "PAYER", "STATUS", + "CHARGES", "PMTS/ADJS", "Adjustment", "WithHeld", "Balance", + "Visit Type", "Account Number", "Provider Name", "Guarantor Name", +] + +# Operator, 2026-10-06: the team's wording for a claim with no findings. +CLEAN = ("Clean claims (claims that have no errors, placed in Pending with " + "Errors)") + +# Comment per fired condition, worded like the format's own samples. +COMMENTS = { + "depression_dx_pointer": ( + "ICD pointer issue (Z13.31 - Depression Diagnosis tagged under E/M " + "Procedure, Need to Tag under G8510 only )"), + # The two telehealth conditions word their own comment from what + # actually happened (`summary_comment`); these fixed texts are only the + # fallback when that hook raises, so they claim nothing that may not + # have happened — not "G2025 added" (it can fail), not "93 or 95" + # (Medicaid is GT), not "unchecked" (the UB form only zeroes fees) + # (final review, 2026-10-05). + "telehealth": "Telehealth (POS / modifier updated — see the log)", + "medicare_telehealth": ( + "Medicare Telehealth (POS / modifier and G2025 — see the log for " + "what changed)"), + "j2930_j2001_update": ( + "Invalid CPT codes (J2930/J2001 - checked that should be unchecked)"), + "dummy_codes": ( + "Invalid CPT codes (DCONS/Dscprit - checked that should be " + "unchecked)"), + "dental_dummy_codes": ( + "Invalid CPT codes (DPIMP/DFIMP dental dummy code - checked that should " + "be unchecked)"), + # Fallback only: the condition words its own comment (codes, payers, + # whether the Claim Status was set to Patient — EEWA-27, 2026-10-21). + "dental_payer_check": ( + "Dental codes on a payer other than DentaQuest / Delta Dental / " + "CHP+ Dental - Claim Status to Patient"), + "medicaid_uncheck_cpts": ( + "CPT not billable to Medicaid (15851/15852/73521 - unchecked, fee 0)"), + "medicare_billing_screening": ( + "Screening codes not billed to Medicare (G8510/G8431)"), + # Fallback only: the condition words its own comment (which codes). + "medicare_vaccine_a6": ( + "Condition code A6 added (G0008/G0009 on Medicare)"), + # Not read today: the condition never changes the claim; its Review + # reason (which code, billed when, on which claim) reaches the sheet. + "medicare_wellness_history": ( + "G0402/G0438/G0439 billed too often - check the history"), + # Fallback only: the condition words its own comment (line, ICD). + "medicaid_pregnancy_th": ( + "TH modifier added (pregnancy visit on Medicaid)"), + "vaccine_admin_codes": "CPT Code Missing ( vaccine admin code added )", + # Fallback only: the condition words its own comment (96372 added or not). + "injection_admin_code": "CPT Code Missing (injection admin code 96372)", + # Fallback only: the condition words its own comment by the path it + # took (BH payer: modifier removed; any other payer: line unchecked). + "bh_h_code_modifier": "H code corrected (#12) — see the log", + "zero_fee_uncheck": "Zero billed fee CPT unchecked", + "priced_line_check": "Billed CPT checked", +} + +HIGH_RISK = "High risk claim (submit through DentaQuest web portal)" + + +# A progress note in one of these states was never read (pages/claim_form +# .open_progress_notes): no button, a disabled one, or a click that opened +# nothing — or, "skipped", not opened at all because the form never finished +# loading (the flow, final review 2026-10-05). Telehealth and Medicare +# telehealth decide from the note's text, so on such a claim they were not +# checked at all — not checked, not clean. +_NOTE_NOT_READ = ("nothing", "absent", "disabled", "skipped") + +# Leads a Review row's Comments when the claim WAS written to eCW: "Needs +# manual review" alone would let the reviewer treat it as untouched and redo +# or undo edits already on the live claim (final review, 2026-10-05). +SAVED_NOTE = "Corrections SAVED to eCW" + +# The operator approved the save, but eCW never confirmed it: save_claim() +# answers False both when OK could not be clicked and when it WAS clicked +# and the form did not hide in time — so whether the claim was written is +# unknown, and "Corrections pending (not saved)" could be untrue (final +# review, 2026-10-05). The flow sets `_conditions["save_unconfirmed"]`. +SAVE_UNCONFIRMED = ("Save approved but not confirmed by eCW — check whether " + "the correction was written") + + +def is_dental(data: dict) -> bool: + """ + A Dental Claim screen read: the flow's `_dental_checks` results, or + (since 2026-10-12, Dental claims run the conditions) a read whose + `screen` is "dental". Public because the flow's per-claim box asks the + same question (review item D9) — one answer, not two that can drift + apart. + """ + return ("high_risk" in data or "sliding_fee" in data + or data.get("screen") == "dental") + + +def review_reasons(data: dict) -> list: + """ + Every reason a human must look at this claim — empty when the bot + really checked everything it was meant to. Order: why the claim could + not be evaluated first, then what the conditions reported, then Dental. + + Public because the flow's per-claim message box lists these same + reasons as its "Needs review" lines (review item D9, 2026-10-05): the + box and the sheet must give the same account of one claim. + """ + if data.get("_search_error"): + # The search raised (browser gone, a timeout): the claim was never + # found or opened, which is not "not found" (final review, + # 2026-10-05). + return [f"Bot could not search the claim ({data['_search_error']})"] + if data.get("_error"): + if data.get("_conditions") is not None: + # Raised AFTER the conditions ran (the flow keeps their account + # on the error dict): the claim was read and may have been + # changed, so "could not read" would misstate it. What the + # conditions themselves reported still follows. + cond = data["_conditions"] or {} + return ([f"Bot failed after the conditions ran " + f"({data['_error']})"] + + [f"{f.get('name')} failed: {f.get('error')}" + for f in cond.get("failed") or []] + + [f"{r.get('name')}: {r.get('reason')}" + for r in cond.get("review") or []]) + # The read itself failed: `data` is only {"claim_no", "_error"}, so + # every trigger below would just restate "nothing was read". + return [f"Bot could not read the claim ({data['_error']})"] + + reasons = [] + if data.get("_not_evaluated"): + reasons.append(data["_not_evaluated"]) + + cond = data.get("_conditions") + if not is_dental(data) or data.get("screen") == "dental": + # Every claim that was read for the conditions: UB/HCFA, and since + # 2026-10-12 a Dental claim too (`screen` "dental"). Only a bare + # Dental-checks result (high risk / sliding fee alone, no read) + # skips these — nothing here applies to it. + if cond is None and not data.get("_not_evaluated"): + # No `_conditions` on a claim that loaded = run_all never ran. + # A claim that did NOT load has none either — the flow skips the + # conditions on purpose and `_not_evaluated` already says why — + # so calling that "APPLY_CORRECTIONS=0" would be untrue. + reasons.append("Conditions not run (APPLY_CORRECTIONS=0)") + note = data.get("progress_notes") + state = None + if not isinstance(note, dict): + state = "not opened" # OPEN_PROGRESS_NOTES off, or never got there + elif note.get("error"): + state = f"error: {first_line(note['error'])}" + elif note.get("opened") in _NOTE_NOT_READ: + state = note["opened"] + if state: + reasons.append(f"Progress note not read ({state}) — telehealth " + f"not checked") + elif note.get("partial"): + # Read, but its sections never settled before the deadline + # (pages/claim_form._wait_for_note, unit C). With 0 sections only + # the header arrived — no Procedure Codes, so telehealth saw + # nothing: the D8 false Clean. With some sections the Procedure + # Codes may still be missing. Either way "clean" is unproven + # (review of unit D, 2026-10-05, finding 1). + n = note.get("partial_sections") + if n == 0: + reasons.append("Progress note partial (0 sections) — " + "telehealth not checked") + else: + # No "?" for an unknown count: these reasons are also the + # client's on-screen "Needs review" lines (review item D9). + count = ("section count unknown" if n is None + else f"{n} section(s)") + reasons.append(f"Progress note may be partial ({count}) — " + f"telehealth may not be fully checked") + if data.get("cpt") == []: + # Every CPT condition reads these rows; none of them saw one + # (review item D7: a half-loaded HCFA form read 0 and went Pass). + reasons.append("No CPT rows were read") + elif data.get("cpt") and data.get("icd") == []: + # CPT rows but not one diagnosis row: depression_dx_pointer + # (the Z13.31 pointer) reads the ICD list and saw nothing, so it + # passed without really running. A billed claim always carries + # a diagnosis; an empty list is a read that did not land — e.g. + # a partly rendered UB form (final review, 2026-10-05). + reasons.append("No ICD rows were read") + + cond = cond or {} + for f in cond.get("failed") or []: + reasons.append(f"{f.get('name')} failed: {f.get('error')}") + for r in cond.get("review") or []: + reasons.append(f"{r.get('name')}: {r.get('reason')}") + if cond.get("save_unconfirmed"): + reasons.append(SAVE_UNCONFIRMED) + + if "high_risk" in data and \ + (data["high_risk"] or {}).get("high_risk") is None: + reasons.append("High risk: Billing Notes could not be read") + sf = data.get("sliding_fee") or {} + if (sf.get("verdict") or "").lower() == "unknown": + reasons.append(f"Sliding fee UNKNOWN ({sf.get('reason', '')})") + return reasons + + +def claim_outcome(found: bool, data: dict) -> dict: + """ + Columns V-X for one claim, from what `_read_claim_form` returned. + + * not found / never opened -> {} (S-X left blank) + * Review -> Current Status "Needs manual review": the bot did not, or + could not, check everything (see `review_reasons`) — the search + raised (`_search_error`), the form would not read (`_error`) or + never finished loading (`_not_evaluated`); + on UB/HCFA the conditions not run, the progress note not read (or + read but marked partial), or zero CPT rows read; a condition that raised or asked for a human + (`_conditions` "failed" / "review"), or an approved save eCW did + not confirm (SAVE_UNCONFIRMED); Dental Billing Notes unreadable + or a sliding fee UNKNOWN. Comments = every finding, then every + reason, led by SAVED_NOTE when the correction was saved to eCW. + Review wins over Fail: a fired condition on a claim whose note was + never read is still a claim a human has to look at. + * Fail -> a condition changed something, HIGH RISK, or a sliding fee + EXPIRED / NOT_STARTED. A condition's comment is its own + `summary_comment()` wording when it has one, else COMMENTS[name]. + Current Status "Ready to Submit" if the correction was saved, else + "Corrections pending (not saved)" — with SAVE_CORRECTIONS=0 + nothing reaches eCW, and the sheet must not say otherwise + * Pass -> CLEAN, "Ready to Submit" (a Dental sliding fee NONE is + noted after it, but is not a finding) + """ + if data and data.get("_search_error"): + # Searched, and the search itself raised: Review, not the blank row + # of a claim that is really not in the queue. + return {"Comments": "; ".join(review_reasons(data)), + "Current Status": "Needs manual review", "Category": "Review"} + if not found or not data: + return {} + + comments, failed = [], False + cond = data.get("_conditions") or {} + worded = cond.get("comments") or {} + for name in cond.get("fired") or []: + comments.append(worded.get(name) or COMMENTS.get(name, name)) + failed = True + saved = bool(cond.get("saved")) + + hr = data.get("high_risk") + if hr and hr.get("high_risk"): + comments.append(HIGH_RISK) + failed = True + sf = data.get("sliding_fee") + if sf: + verdict = (sf.get("verdict") or "").lower() + if verdict in ("expired", "not_started"): + comments.append(f"Sliding fee {verdict.upper().replace('_', ' ')}" + f" ({sf.get('reason', '')})") + failed = True + elif verdict == "none": + # Noted, not a finding. "unknown" is a review reason instead. + comments.append(f"Sliding fee NONE ({sf.get('reason', '')})") + + reasons = review_reasons(data) + if reasons: + # A save that happened is never hidden behind "Needs manual review" + # (final review, 2026-10-05): it leads the Comments. + lead = [SAVED_NOTE] if saved else [] + return {"Comments": "; ".join(lead + comments + reasons), + "Current Status": "Needs manual review", "Category": "Review"} + if not failed: + return {"Comments": "; ".join([CLEAN] + comments), + "Current Status": "Ready to Submit", "Category": "Pass"} + return {"Comments": "; ".join(comments), + "Current Status": ("Ready to Submit" if saved + else "Corrections pending (not saved)"), + "Category": "Fail"} + + +def _money(text): + try: + return float(str(text).replace(",", "").replace("$", "").strip() or 0) + except ValueError: + return None + + +def build_rows(report_rows: list, header_row: int, claim_col: int, + outcomes: dict, run_date: str, assigned_to: str, + mapping: dict) -> list: + """ + Header + one row per claim. `report_rows` is the report as read; + `header_row`/`claim_col` are where claim_file found the claim-id + header and column (header_row -1: the file has no header row). + `outcomes` maps claim id -> claim_outcome(). A claim listed twice in + the report (eCW does, for a multi-line claim) gets one row. A report + missing a column (a hand-made test sheet with only claim ids) gets it + blank. + """ + header = ([str(h or "").strip() for h in report_rows[header_row]] + if header_row >= 0 else []) + ix = {h: i for i, h in enumerate(header)} + + out, seen = [HEADERS], set() + for row in report_rows[header_row + 1:]: + get = lambda h: (str(row[ix[h]]).strip() + if h in ix and ix[h] < len(row) else "") + claim = str(row[claim_col]).strip() if claim_col < len(row) else "" + if not claim or claim in seen: + continue + seen.add(claim) + + vals = {h: get(h) for h in _FROM_REPORT} + vals["CLAIMS#"] = claim + vals["S.No"] = len(seen) + vals["Group Name"] = payer_groups.classify(vals["PAYER"], mapping) or "" + pa, adj = _money(vals["PMTS/ADJS"]), _money(vals["Adjustment"]) + vals["Pmts/Refunds"] = (f"{pa - adj:,.2f}" if "PMTS/ADJS" in ix + and pa is not None and adj is not None else "") + done = outcomes.get(claim) or {} + if done: + vals.update(done) + vals["Recevied date"] = run_date + vals["Completed On"] = run_date + vals["Assigned To "] = assigned_to + out.append([vals.get(h, "") for h in HEADERS]) + return out + + +def file_name(today: str, test_mode: bool = False) -> str: + """ + `Charge Summary_<today>.xlsx`, or `Charge Summary_TEST_<today>.xlsx` for + a run whose claim ids came from TEST_CLAIM_IDS_FILE. + + Test runs get their own name (review item D1): a test sheet is a hand + list of a few claim ids, its summary is mostly blank columns, and under + the live name it pushed that day's real summary aside to `__HHMMSS` — + so the file the team opens by its usual name was the test one. One + function, so the flow's "[TEST MODE] the summary goes to ..." line names + exactly the file write() saves. + """ + return (f"Charge Summary_TEST_{today}.xlsx" if test_mode + else f"Charge Summary_{today}.xlsx") + + +# The last report read: (path, mtime_ns, size) -> (rows, header_row, +# claim_col). The flow builds the sheet after every claim, for the Excel +# and again for the database, from the same unchanged report — reading it +# once is enough (review, 2026-10-12). A changed file is read again. +_REPORT_CACHE = {} + + +def _read_report(report_path): + # claim_file's own header/column finder, so the summary reads the + # report exactly the way the claim ids were read from it. + from ..claim_file import _find_column + path = Path(report_path) + stat = path.stat() + key = (str(path.resolve()), stat.st_mtime_ns, stat.st_size) + if key not in _REPORT_CACHE: + rows = spreadsheet.read_rows(path) + _REPORT_CACHE.clear() + _REPORT_CACHE[key] = (rows, *_find_column(rows)) + return _REPORT_CACHE[key] + + +def build(report_path, outcomes: dict, assigned_to: str) -> list: + """ + The summary sheet (header row + one row per claim) for this report and + these outcomes — what `write()` saves to Excel, and what + `core/results_db.py` saves to SQL Server. + """ + rows, header_row, claim_col = _read_report(report_path) + run_date = datetime.now().strftime("%m/%d/%Y") + return build_rows(rows, header_row, claim_col, outcomes, run_date, + assigned_to, payer_groups.load_mapping()) + + +def write(report_path, outcomes: dict, out_dir, today: str, + assigned_to: str, *, test_mode: bool = False, into=None) -> Path: + """ + Read the report, build the sheet, save it in `out_dir` as file_name() + — `Charge Summary_<today>.xlsx`, or `Charge Summary_TEST_<today>.xlsx` + with `test_mode` (claim ids from TEST_CLAIM_IDS_FILE, not the live queue). + + A same-day file is kept aside, never overwritten (operators edit it); if + it is open in Excel, this run's sheet is saved under a `__HHMMSS` name + next to it instead of being lost (spreadsheet.write_xlsx_keep_old). + Returns the path actually written — report that one, not the usual name. + + `into`: the file this run's earlier claims were written to (the flow + writes after every claim, operator 2026-10-12). It is this run's own + file, so it is rewritten in place — no `__HHMMSS` copy per claim. If it + is open in Excel by now, the sheet goes to a new `__HHMMSS` name, which + the caller then writes into for the rest of the run. + """ + sheet = build(report_path, outcomes, assigned_to) + out_path = Path(out_dir) / file_name(today, test_mode) + if into is None: + return spreadsheet.write_xlsx_keep_old(out_path, sheet, "[SUMMARY]") + into = Path(into) + try: + spreadsheet.write_xlsx(into, sheet) + return into + except OSError as exc: + fallback = spreadsheet._first_free_name( + out_path, datetime.now().strftime("%H%M%S")) + spreadsheet.write_xlsx(fallback, sheet) + print(f"[SUMMARY] {into.name} is open or locked ({first_line(exc)}) " + f"— this run's summary continues in {fallback.name}") + return fallback diff --git a/src/ecw_bot/core/fallback.py b/src/ecw_bot/core/fallback.py new file mode 100644 index 0000000..50e9b87 --- /dev/null +++ b/src/ecw_bot/core/fallback.py @@ -0,0 +1,67 @@ +r""" +Fallback selectors (2026-10-21): the selectors a run cannot do without carry +one or two alternatives, so a renamed id does not stop the run. + +Each one is a CHAIN — the primary first, then alternatives taken from +attributes seen in the live DOM (ng-click, ng-model, type / value), never +guessed. The selector constant the code uses is the chain joined as a CSS +selector list ("primary, alternative, ..."), so every existing call site — +`page.click`, `locator(...)`, `:has(...)`, the JS `querySelectorAll` ones — +gets the fallback with no change. The alternatives are plain CSS for that +reason (no Playwright-only `:has-text` / `text=`). + +What a list cannot say is WHICH one matched. `report()` does: where the +primary is gone and an alternative stands in, it logs + + [SELECTOR] fallback: Lookup button — #btnclaimlookup not found, matched + [ng-click="getClaimsListFromLookup('lookup')"]; update selectors/... + +so the primary is fixed before the alternative breaks too. The pre-flight +check (pages/preflight.py) and the login call it, passing the chains +registered in selectors/fallbacks.py. +""" + +from . import run_events + +# Names already reported this run: one log line each, not one per claim. +_reported = set() + + +def reset() -> None: + _reported.clear() + + +def _shown(page, selector: str) -> bool: + return page.locator(selector).filter(visible=True).count() > 0 + + +def which(page, chain): + """Index of the first entry in `chain` shown on the page; None if none.""" + for i, selector in enumerate(chain): + if _shown(page, selector): + return i + return None + + +def report(page, names, chains) -> list: + """Log each chain in `names` whose primary is gone but an alternative + is shown; the names so reported (each once a run). Never raises. + `chains` is `selectors/fallbacks.CHAINS` (core imports no selectors).""" + out = [] + for name in names: + if name in _reported: + continue + module, chain = chains[name] + try: + i = which(page, chain) + except Exception: + continue + if i: + _reported.add(name) + out.append(name) + print(f"[SELECTOR] fallback: {name} — {chain[0]} not found, " + f"matched {chain[i]}; update " + f"src/ecw_bot/selectors/{module}.py") + run_events.warning("fallback", f"{name}: {chain[0]} not found, " + f"matched {chain[i]}") + return out diff --git a/src/ecw_bot/core/guard.py b/src/ecw_bot/core/guard.py new file mode 100644 index 0000000..c7be058 --- /dev/null +++ b/src/ecw_bot/core/guard.py @@ -0,0 +1,201 @@ +""" +Login attempt guard. + +A courtesy to the account, not a security boundary: attempts are recorded to +`Logs\\attempts.json` and refused when they come too close together or in a +burst. eCW does stop mailing approval links after a run of failed logins, and +nothing in the code otherwise knew how often it had been run. `ECW_FORCE=1` +skips every check here. + +Note on the restriction page — the cause was initially misread as rate +limiting. It is an IP check (see `config.TXT_RESTRICTED`): the same link answers +400 unproxied and 200 through the proxy. So a restriction the bot walks into +means "the browser lost the proxy" far more often than "the account is locked", +which is why `record_restricted()` does not impose a long cool-off. +""" + +import os +import json +import time +from datetime import datetime, timezone, timedelta + +from .. import settings as cfg + +STATE_PATH = cfg.LOGS / "attempts.json" + +# A restriction seen on the page pauses the bot for this long. Deliberately +# short: the usual cause is a proxy problem, which no amount of waiting fixes, +# and a long lockout would just block the run that has the proxy working again. +# Long enough to stop a retry loop, short enough to stay out of the way. +RESTRICTED_LOCKOUT = timedelta(minutes=5) + + +def _now(): + return datetime.now(timezone.utc) + + +def _parse(raw): + try: + return datetime.fromisoformat(raw) if raw else None + except Exception: + return None + + +def _read() -> dict: + try: + return json.loads(STATE_PATH.read_text(encoding="utf-8")) + except Exception: + return {} + + +def _write(state: dict) -> None: + try: + cfg.LOGS.mkdir(parents=True, exist_ok=True) + STATE_PATH.write_text(json.dumps(state, indent=2), encoding="utf-8") + except Exception as exc: + print(f"[GUARD] Could not write {STATE_PATH}: {exc}") + + +def _forced() -> bool: + return os.environ.get("ECW_FORCE", "").strip().lower() in ("1", "true", "yes") + + +def check() -> str: + """Return a reason to refuse this attempt, or "" to go ahead.""" + if _forced(): + print("[GUARD] ECW_FORCE set — skipping the cooldown check") + return "" + + state = _read() + now = _now() + + locked = _parse(state.get("locked_until")) + if locked and now < locked: + mins = (locked - now).total_seconds() / 60 + return (f"eCW restricted this account at " + f"{state.get('restricted_at', '?')[:19]} UTC. " + f"Waiting another {mins:.0f} min before trying again — " + f"more attempts extend the restriction. " + f"Set ECW_FORCE=1 to override.") + + last = _parse(state.get("last_attempt")) + if last: + elapsed = (now - last).total_seconds() + if elapsed < cfg.LOGIN_COOLDOWN_SECONDS: + wait = cfg.LOGIN_COOLDOWN_SECONDS - elapsed + return (f"Last login attempt was {elapsed:.0f}s ago. " + f"Waiting {wait:.0f}s more — rapid retries are what get the " + f"account restricted. Set ECW_FORCE=1 to override.") + + # Recent-attempt burst check: eCW tolerates the occasional retry, not a run + # of them. + window = now - timedelta(hours=1) + recent = [d for d in (_parse(x) for x in state.get("recent", [])) if d and d > window] + if len(recent) >= cfg.LOGIN_MAX_PER_HOUR: + # Say WHEN, not just "give it a rest". The window is a rolling hour, so + # it clears when the oldest attempt ages out — which is not obvious from + # the outside, and guessing at it means re-running to find out. + clears = min(recent) + timedelta(hours=1) + mins = (clears - now).total_seconds() / 60 + return (f"{len(recent)} attempts in the last hour " + f"(limit {cfg.LOGIN_MAX_PER_HOUR}). The oldest was at " + f"{min(recent).astimezone():%H:%M:%S}, so this clears at " + f"{clears.astimezone():%H:%M:%S} — {mins:.0f} min from now. " + f"Waiting is the fix: seven attempts in forty-five minutes is " + f"what got this account restricted. " + f"Run tools\\check_guard.py to see the clock without starting " + f"a run.") + return "" + + +def wait_if_cooling() -> None: + """ + Sleep out a plain cooldown instead of refusing the run over it. + + `check()` used to be the only gate, so a run started 297s into a 300s + cooldown died with "Waiting 3s more" — five of six consecutive runs ended + that way, and re-running by hand until the clock cleared is exactly the + pattern the cooldown was meant to discourage. Sleeping honours the cooldown + just as well as exiting does and actually gets the work done. + + Only a plain cooldown is waited out. A restriction lockout and the + per-hour burst limit still refuse through `check()`: those mean "stop", + not "not yet". + """ + if _forced() or cfg.COOLDOWN_MAX_WAIT_SECONDS <= 0: + return + + state = _read() + now = _now() + + locked = _parse(state.get("locked_until")) + if locked and now < locked: + return # a restriction — check() refuses it + + window = now - timedelta(hours=1) + recent = [d for d in (_parse(x) for x in state.get("recent", [])) + if d and d > window] + if len(recent) >= cfg.LOGIN_MAX_PER_HOUR: + return # a burst — check() refuses it + + last = _parse(state.get("last_attempt")) + if not last: + return + remaining = cfg.LOGIN_COOLDOWN_SECONDS - (now - last).total_seconds() + if remaining <= 0: + return + if remaining > cfg.COOLDOWN_MAX_WAIT_SECONDS: + # Too long to sit on. Let check() say so and exit, so the operator can + # decide rather than watch a silent process. + return + + print(f"[GUARD] Cooldown has {remaining:.0f}s left — waiting it out " + f"instead of quitting (COOLDOWN_MAX_WAIT_SECONDS=" + f"{cfg.COOLDOWN_MAX_WAIT_SECONDS})") + + # Measured against `_now()`, the same clock `check()` uses, plus a second of + # margin. An earlier version counted with `time.time()` while `check()` + # counted with `datetime.now()`; two clocks for one deadline is how you get + # a run that waits out its cooldown and is then refused for being a + # fraction of a second early. + target = last + timedelta(seconds=cfg.LOGIN_COOLDOWN_SECONDS + 1) + while True: + left = (target - _now()).total_seconds() + if left <= 0: + break + # Say something periodically: a silent process looks hung, which is what + # got earlier runs killed by hand. + print(f"[GUARD] {left:.0f}s to go") + time.sleep(min(30, left)) + print("[GUARD] Cooldown cleared — starting") + + +def record_attempt() -> None: + state = _read() + now = _now() + window = now - timedelta(hours=1) + recent = [x for x in state.get("recent", []) if (_parse(x) or now) > window] + recent.append(now.isoformat()) + state["last_attempt"] = now.isoformat() + state["recent"] = recent[-50:] + _write(state) + + +def record_restricted() -> None: + """Called when the portal shows its restriction page.""" + state = _read() + now = _now() + state["restricted_at"] = now.isoformat() + state["locked_until"] = (now + RESTRICTED_LOCKOUT).isoformat() + _write(state) + print(f"[GUARD] Restriction recorded — locked out until " + f"{(now + RESTRICTED_LOCKOUT):%H:%M:%S} UTC") + + +def record_success() -> None: + """A clean login clears the burst history — the account is evidently fine.""" + state = _read() + state["recent"] = [] + state["locked_until"] = None + state["last_success"] = _now().isoformat() + _write(state) diff --git a/src/ecw_bot/core/msgbox.py b/src/ecw_bot/core/msgbox.py new file mode 100644 index 0000000..695db5e --- /dev/null +++ b/src/ecw_bot/core/msgbox.py @@ -0,0 +1,175 @@ +""" +Operator prompts. + +Everything here can time out. An earlier run wedged for forty minutes on a +plain MessageBoxW: the mailbox poll found nothing, the code fell back to asking +a human, and nobody was at the desk — so the process sat holding a browser open +with no log output while eCW went on rate-limiting the account. A prompt that +can block forever is a prompt that will, so `MessageBoxTimeoutW` is used +instead and callers are handed an explicit TIMEOUT. + +`MessageBoxTimeoutW` is undocumented but has shipped in user32 since XP. If it +is ever missing, the plain call is used and the timeout silently does not +apply — better a hang than a crash at the moment a human is being asked for +help. +""" + +import ctypes +from ctypes import wintypes + +from . import watchdog + +# Return values +OK = "ok" +CANCEL = "cancel" +TIMEOUT = "timeout" + +_MB_OK = 0x00000000 +_MB_OKCANCEL = 0x00000001 +_MB_ICONINFO = 0x00000040 +_MB_ICONWARN = 0x00000030 +_MB_TOPMOST = 0x00040000 # otherwise it can open behind the browser window +# Second button (Cancel, on an OK/Cancel box) is the default one, so Enter or +# Space picks Cancel instead of OK. Without it, Enter on the per-claim save +# box meant OK — save the claim to live eCW (review item P1). +_MB_DEFBUTTON2 = 0x00000100 + +_IDOK = 1 +_IDTIMEOUT = 32000 + + +def _raw_call(text: str, caption: str, flags: int, timeout_s: int) -> int: + # A box waits on a person, not a frozen page: the claim watchdog's + # clock starts again when it opens and when it closes. + watchdog.beat("message box") + try: + return _call_box(text, caption, flags, timeout_s) + finally: + watchdog.beat("message box closed") + + +def _call_box(text: str, caption: str, flags: int, timeout_s: int) -> int: + user32 = ctypes.windll.user32 + try: + fn = user32.MessageBoxTimeoutW + fn.argtypes = [wintypes.HWND, wintypes.LPCWSTR, wintypes.LPCWSTR, + wintypes.UINT, wintypes.WORD, wintypes.DWORD] + fn.restype = ctypes.c_int + return fn(None, text, caption, flags, 0, int(timeout_s * 1000)) + except AttributeError: + return user32.MessageBoxW(None, text, caption, flags) + + +def info(title: str, msg: str, timeout_s: int, + needs_person: bool = False) -> str: + """Informational box. Returns OK or TIMEOUT. + + With QUIET_BOXES on (a run the results page started), a box that does + not need a person is one log line instead, and answers OK.""" + if cfg.QUIET_BOXES and not needs_person: + first = (msg or "").strip().splitlines()[0] if (msg or "").strip() \ + else "" + print(f"[BOX] {titled(title)}: {first}") + return OK + try: + rc = _raw_call(msg, titled(title), _MB_OK | _MB_ICONINFO | _MB_TOPMOST, timeout_s) + except Exception: + return TIMEOUT + return TIMEOUT if rc == _IDTIMEOUT else OK + + +def confirm(title: str, msg: str, timeout_s: int, + default_cancel: bool = False) -> str: + """ + OK/Cancel box. Returns OK, CANCEL or TIMEOUT. + + `default_cancel=True` makes Cancel the default button, so Enter/Space + answers Cancel. For boxes where OK writes to live eCW: there, a key + pressed out of habit must not count as a decision. Off by default so the + captcha and security-image prompts, where OK only lets a read continue, + behave as they always have. + """ + footer = (f"\n\n(If there's no reply, this will cancel automatically " + f"after {timeout_s}s.)") + flags = _MB_OKCANCEL | _MB_ICONWARN | _MB_TOPMOST + if default_cancel: + flags |= _MB_DEFBUTTON2 + try: + rc = _raw_call(msg + footer, titled(title), flags, timeout_s) + except Exception: + # No user32 (not Windows): nothing can be asked, so do not pretend a + # human agreed. + return TIMEOUT + if rc == _IDTIMEOUT: + return TIMEOUT + return OK if rc == _IDOK else CANCEL + + +# ============================================================================= +# Convenience wrappers that apply the configured timeout and log the timeout +# ============================================================================= +# These were private helpers in auth.py. They are here because every caller +# wants the same two things — the configured timeout, and a log line when a +# prompt goes unanswered — and duplicating that per module is how one of them +# ends up silently treating a timeout as consent. + +from .. import settings as cfg # noqa: E402 (kept below the ctypes core) + +TEST_PREFIX = "[TEST] " + + +def titled(title: str) -> str: + """ + `title` as the box shows it: with `[TEST] ` in front when the claim ids + come from a TEST_CLAIM_IDS_FILE sheet (review item D9, 2026-10-05). + + A test run drives the same live eCW as a real one, so a screen with a + box on it looks exactly like production to anyone watching — the client + in a demo included. The [MODE] banner says "TEST MODE" in the log only; + this puts it on every box. Applied in the raw `info()` / `confirm()`, + the two functions every box reaches `_raw_call` through, not only in + the `info_box` / `ok_cancel` wrappers: three callers use the raw ones + directly (the 2FA manual box in auth/mailbox.py, which a test run does + reach at login; "Run the Lookup?" in pages/claim_results.py; the export + report box in pages/claim_report.py). Done here rather than at each call + site so a box added later cannot forget it. The wrappers apply it too, + so their "timed out" log line names the title the box showed; that is + safe because the prefix is never doubled. Read at call time (this + module imports settings below the ctypes core, and settings can change + under a test). + """ + if cfg.TEST_CLAIM_IDS_FILE and not title.startswith(TEST_PREFIX): + return TEST_PREFIX + title + return title + + +def info_box(title: str, msg: str, needs_person: bool = False) -> None: + """Informational box on the configured timeout. Never blocks the run. + `needs_person=True` for a box a person must act on (it is never + quieted by QUIET_BOXES).""" + title = titled(title) + # The keyword is passed only when set, so a test that replaces info() + # with a three-argument stub keeps working. + extra = {"needs_person": True} if needs_person else {} + if info(title, msg, cfg.MSGBOX_TIMEOUT_SECONDS, **extra) == TIMEOUT: + print(f"[UI] '{title}' timed out with nobody at the desk — continuing") + + +def ok_cancel(title: str, msg: str, default_cancel: bool = False) -> bool: + """ + True only on an explicit OK. A timeout counts as 'no'. + + Note the asymmetry with `claim_results.run_lookup`, which treats a timeout + as "carry on": there, proceeding only reads. Here the prompts guard captcha + and security-image steps where proceeding without a human is meaningless, + so silence must not be read as agreement. + + `default_cancel=True` (see `confirm`) for any box whose OK saves to eCW: + the per-claim save box and the LIVE-WRITE start box. + """ + title = titled(title) + result = confirm(title, msg, cfg.MSGBOX_TIMEOUT_SECONDS, + default_cancel=default_cancel) + if result == TIMEOUT: + print(f"[UI] '{title}' timed out with nobody at the desk — treating as cancel") + return result == OK diff --git a/src/ecw_bot/core/page_utils.py b/src/ecw_bot/core/page_utils.py new file mode 100644 index 0000000..e457aea --- /dev/null +++ b/src/ecw_bot/core/page_utils.py @@ -0,0 +1,330 @@ +""" +Page helpers shared by every screen. No eCW knowledge lives here. + +These earned their edge cases the hard way, which is exactly why they are +in one place instead of being reimplemented per module: + + * `shot` writes into a **dated** screenshot folder and never raises — a + screenshot failing must not lose the run it was documenting. + A FAILURE shot (`is_failure_tag`) also saves the page's DOM beside + it as `<HHMMSS>_<tag>.html` (`SAVE_DOM_ON_FAILURE`, 2026-10-21): + when eCW changes a selector, the HTML to fix it from is already on + disk — no re-run, no hand copy from DevTools into Dom.txt. PHI, like + the screenshots: it stays in the gitignored Screenshots folder. + * `visible` is an INSTANT "is it on screen right now?" and `appears` is + "give it up to N ms to show up". They are two functions because + Playwright 1.62's `is_visible(timeout=...)` silently ignores its timeout + (review item P8, 2026-10-01): every caller that passed `timeout=10_000` + expecting a grace period was getting an instant check. Both look at + VISIBLE matches only rather than `.first` — eCW renders some ids twice + (`#jellybean-panelLink4` among them), and a hidden leftover copy first in + the DOM used to make `.first` answer False while the real one was on + screen. + * `goto_with_retry` retries `net::ERR_ABORTED` — the corporate proxy + intermittently kills the first navigation, and one try used to abort a whole + run. + * `reset_scroll` does NOT trust `window.scrollTo(0, 0)`. On a single-page app + the document itself is usually not the scroller — an inner container is — + so scrolling the window is a silent no-op on an element that was never + offset. It sweeps for whatever actually moved and names it in the log. + * `auto_dialog` answers every native dialog: alerts OK, confirms / prompts + OK only in LIVE-WRITE (Cancel in VERIFY: live eCW is read-only), and + beforeunload **dismissed**. An unhandled native dialog freezes the + renderer's JS thread, after which every Playwright call hangs with no + exception; accepting beforeunload means "yes, close this page", which has + been observed closing the working tab mid-flow. +""" + +import re +from datetime import datetime + +from playwright.sync_api import Page + +from .. import settings as cfg +from . import run_events + + +# A shot taken because something went wrong — by its tag, as every caller +# names it: `claims_status_failed`, `claim_form_no_modal_<id>`, +# `login_unconfirmed`, `search_not_ready_<id>` ... Not `logged_in`, +# `landing`, `claims_status`, `progress_note`, `claims_balance_cleared`. +_FAILURE_TAG = re.compile( + r"fail|missing|not_|notfound|nodownload|(^|_)no_|unconfirmed|wrong|" + r"leftover|unreadable|timed_out|locked|disabled|lost|restricted|" + r"still_open|expired|error") + +# DOM files written by this process (SAVE_DOM_LIMIT caps them per run). +_dom_saved = 0 + + +def is_failure_tag(tag: str) -> bool: + return bool(_FAILURE_TAG.search(tag or "")) + + +def save_dom(page: Page, path) -> bool: + """The page's HTML and every frame's, in one file. Never raises.""" + try: + parts = [page.content()] + for n, frame in enumerate(page.frames[1:], start=1): + try: + parts.append(f"\n<!-- ===== frame {n}: {frame.name or ''} " + f"===== -->\n{frame.content()}") + except Exception: + pass + path.write_text("".join(parts), encoding="utf-8") + return True + except Exception: + return False + + +def shot(page: Page, tag: str) -> None: + """Screenshot into Screenshots\\<MM-DD-YYYY>\\<HHMMSS>_<tag>.png — and, + for a failure (`is_failure_tag`), the DOM beside it as `.html`.""" + global _dom_saved + ts = datetime.now().strftime("%H%M%S") + png = cfg.SCREENSHOTS / f"{ts}_{tag}.png" + try: + cfg.SCREENSHOTS.mkdir(parents=True, exist_ok=True) + page.screenshot(path=str(png)) + if is_failure_tag(tag): + # The Runs page links a Run's failure shots. + run_events.emit("screenshot", path=str(png), tag=tag) + except Exception: + pass + if not (cfg.SAVE_DOM_ON_FAILURE and is_failure_tag(tag) + and _dom_saved < cfg.SAVE_DOM_LIMIT): + return + try: + cfg.SCREENSHOTS.mkdir(parents=True, exist_ok=True) + except Exception: + return + path = cfg.SCREENSHOTS / f"{ts}_{tag}.html" + if save_dom(page, path): + _dom_saved += 1 + print(f"[DOM] Saved the page's HTML for the failure: {path}") + + +def visible(page: Page, selector: str, timeout: int = 500) -> bool: + """ + Whether any element matching `selector` is visible RIGHT NOW. Instant. + + `timeout` is accepted for call compatibility and **ignored** — exactly as + it always was underneath: Playwright 1.62's `locator.is_visible(timeout=)` + does not wait (verified in this project; the review's item P8). Keeping + the parameter means no caller breaks, but nothing here pretends to wait. + A caller that means "give it time to show up" must use `appears()`. + + Counts visible matches instead of asking `.first`: when eCW leaves a + hidden copy of an element ahead of the live one in the DOM, `.first` is + the hidden copy and the answer came back False with the real element on + screen. Never raises — "is it there?" is a question, not an assertion. + """ + try: + return page.locator(selector).filter(visible=True).count() > 0 + except Exception: + return False + + +def appears(page: Page, selector: str, timeout: int = 5_000) -> bool: + """ + Wait up to `timeout` ms for an element matching `selector` to be visible. + + Built on `wait_for(state="visible")`, the call that actually waits — + `is_visible(timeout=...)` looks like it should and does not (Playwright + 1.62 ignores the timeout and answers instantly). That gotcha is why a slow + CPT tab was read short and a slow Prog. Notes button read as "absent" + (review items P8/D11). + + Filtered to visible matches before `.first`, for the same reason as + `visible()`: a hidden leftover copy earlier in the DOM must not be the + one that gets waited on. Returns at once when it is already showing, so + the cost on the happy path is nothing. Never raises. + """ + try: + page.locator(selector).filter(visible=True).first.wait_for( + state="visible", timeout=timeout) + return True + except Exception: + return False + + +# Marks the one match a click would really land on: the shown element whose +# own centre hit-tests to itself (nothing covering it), or, when none does — +# off screen, or covered by something else entirely — the last one in the +# document (eCW's dialogs are appended, so later is higher). Returns how +# many shown matches there were. The mark is an attribute, removed from any +# earlier match first, so the locator below finds exactly one element. +# +# A match that computes `pointer-events: none` is not counted at all: no +# click can land on it (a window eCW is still opening, or one whose fade-out +# Chrome paused — live run 2026-10-13, ~45 s of Cancel clicks timing out). +_MARK_TOPMOST_JS = r""" +(s) => { + for (const el of document.querySelectorAll('[data-ecwbot-top]')) + el.removeAttribute('data-ecwbot-top'); + const shown = el => { + const r = el.getBoundingClientRect(); + const cs = getComputedStyle(el); + return r.width > 0 && r.height > 0 + && cs.visibility !== 'hidden' && cs.pointerEvents !== 'none'; + }; + const all = [...document.querySelectorAll(s)].filter(shown); + if (!all.length) return 0; + const onTop = el => { + const r = el.getBoundingClientRect(); + const hit = document.elementFromPoint(r.left + r.width / 2, + r.top + r.height / 2); + return !!hit && (hit === el || el.contains(hit)); + }; + const top = all.filter(onTop).pop() || all[all.length - 1]; + top.setAttribute('data-ecwbot-top', '1'); + return all.length; +} +""" +_TOPMOST = '[data-ecwbot-top="1"]' + + +def count_shown(page: Page, selector: str) -> int: + """How many matches are on screen right now (0 on any page error).""" + try: + return int(page.evaluate(_MARK_TOPMOST_JS, selector) or 0) + except Exception: + return 0 + + +def click_topmost(page: Page, selector: str, timeout: int = 15_000) -> bool: + """ + Click the match that is on TOP — not `.first`, which is the earliest in + the document and, with two dialogs stacked, the one underneath. Live run + 2026-10-12: a second Progress Note viewer and a second copy of an HCFA + claim window sat over the first; every `.first` click (Close, Cancel) + was intercepted for its full timeout, and the dialog that stayed blocked + the next claims too. + + Returns False when nothing matching is on screen. A click that fails + raises, as `.click()` does — callers already handle that. + """ + if not count_shown(page, selector): + return False + page.locator(_TOPMOST).first.click(timeout=timeout) + return True + + +def text_of(page: Page, selector: str) -> str: + """ + An input's current value, or "?" when it cannot be read. + + "?" is deliberately not "" — an unreadable control is *unknown*, and code + that treats unknown as empty will happily skip a filter that is still live. + """ + try: + return page.locator(selector).first.input_value(timeout=5_000).strip() + except Exception: + return "?" + + +def is_disabled(page: Page, selector: str) -> bool: + """ + Whether a control is disabled — and **True when it cannot be read**. + + That default is deliberate and load-bearing. Its one caller decides between + typing into the Balance row and clearing it through Angular's scope; the + scope path works either way, so an unreadable control has to fall to the + safe side. Returning False here would make an unreadable control look + editable and the clear would silently do nothing. + """ + try: + return not page.locator(selector).first.is_enabled(timeout=5_000) + except Exception: + return True + + + +# Every element is walked rather than a known list of containers: which div +# scrolls is a property of the page, not something this codebase can hardcode, +# and the whole point of the sweep is that it reports the answer instead of +# requiring someone to already know it. +_RESET_SCROLL_JS = r"""() => { + const out = []; + window.scrollTo(0, 0); + document.querySelectorAll('*').forEach(el => { + if (el.scrollTop > 0) { + out.push((el.tagName || '?').toLowerCase() + + (el.id ? '#' + el.id : '') + + ' @' + el.scrollTop); + el.scrollTop = 0; + } + }); + return out; +}""" + + +def reset_scroll(page: Page) -> list: + """ + Scroll everything back to the top, and return what actually moved. + + `window.scrollTo(0, 0)` was tried on its own first and did nothing at all: + the page came back still scrolled, which is what proved the document is not + the scroller here. An inner container is, so the window call is a no-op on + an element that was never offset in the first place. + + The return value is the point as much as the scrolling is — each entry names + a container that was genuinely offset (`div#someGrid @240`), so the next + person reading a log does not have to rediscover which element scrolls. + + Never raises: this is tidying up after a step, not the step itself. + """ + try: + return page.evaluate(_RESET_SCROLL_JS) or [] + except Exception as exc: + print(f"[PAGE] Could not reset the scroll: " + f"{str(exc).splitlines()[0]}") + return [] + + +def goto_with_retry(page: Page, url: str) -> None: + """ + The first navigation through the corporate proxy intermittently dies with + net::ERR_ABORTED — a transient proxy/TLS hiccup, not a bad URL. + """ + for attempt in range(1, cfg.NAV_RETRIES + 1): + try: + page.goto(url, wait_until="domcontentloaded", timeout=cfg.NAV_TIMEOUT) + return + except Exception as exc: + if attempt == cfg.NAV_RETRIES: + raise + print(f"[NAV] Navigation {attempt}/{cfg.NAV_RETRIES} failed " + f"({str(exc).splitlines()[0]}) — retrying in 5s") + page.wait_for_timeout(5_000) + + +def auto_dialog(dialog) -> None: + """ + Answer every native dialog: one left open wedges the renderer, and in + tests crashed the tab outright ("Target crashed"). Until 2026-10-22 none + was answered — `dialog.message()` called a property, and the TypeError + was swallowed. + + * alert → OK: it only informs. + * confirm / prompt → OK only in LIVE-WRITE. In VERIFY, Cancel: live + eCW is read-only, and a confirm's OK may be a save. + * beforeunload → dismissed: accepting means "yes, close this page". + + Each one is logged and is a Warning on the Runs page, with the answer. + """ + kind = getattr(dialog, "type", "?") + message = str(getattr(dialog, "message", "") or "") + accept = kind == "alert" or (kind in ("confirm", "prompt") + and bool(cfg.SAVE_CORRECTIONS)) + answer = "OK" if accept else "Cancel" + print(f"[DIALOG] {kind}: {message!r} -> {answer}") + run_events.warning("dialog", f"{kind}: {message} -> {answer}"[:300]) + try: + if accept: + dialog.accept() + else: + dialog.dismiss() + except Exception: + pass # answered already by the other listener + # (core/browser.py wires context and page) diff --git a/src/ecw_bot/core/payer_groups.py b/src/ecw_bot/core/payer_groups.py new file mode 100644 index 0000000..a2daf92 --- /dev/null +++ b/src/ecw_bot/core/payer_groups.py @@ -0,0 +1,81 @@ +""" +Payer -> Group Name lookup for the categorization step. + +Exact-string match against a small hand-edited table (`payer_groups.json`, +next to this package) — no fuzzy or substring matching. eCW's own PAYER +values already differ by case and spacing for what is really the same +carrier (see `tests/test_payer_groups.py`'s docstring), so "close enough" +matching has no safe way to pick which known string a new one is closest to. +An unrecognized payer must come back as None and get a human's attention — +never a guess, on billing data. + +Since 2026-10-06 the table is the team's own template: the Payer Name -> +Payer Group Name pairs of their Claim Status Dashboard, imported with +`tools/import_payer_groups.py` (re-run it when the dashboard changes). Adding +a payer by hand is still a one-line edit to `payer_groups.json`. +""" + +import json +from pathlib import Path + +_CONFIG_PATH = Path(__file__).resolve().parent.parent / "payer_groups.json" + + +def load_mapping(path=None) -> dict: + """The payer -> group table. `path` defaults to the shipped config.""" + p = Path(path) if path else _CONFIG_PATH + if not p.exists(): + raise FileNotFoundError( + f"{p} not found — the payer group table is missing") + data = json.loads(p.read_text(encoding="utf-8")) + if not isinstance(data, dict): + raise ValueError( + f"{p} does not contain a JSON object (payer -> group name) — " + f"got {type(data).__name__}") + return data + + +def classify(payer: str, mapping: dict) -> str: + """The Group Name for this exact PAYER string, or None if unmapped.""" + return mapping.get((payer or "").strip()) + + +# ── Group names the CONDITIONS decide on ───────────────────────────────────── +# Since 2026-10-06 `payer_groups.json` is the team's own template — the +# "Payer Name" -> "Payer Group Name" pairs of their Claim Status Dashboard +# (tools/import_payer_groups.py) — so its group names are the dashboard's +# ("Commercial Ins", "RAE (Behavioral Health)", "Dental - CHP+/DentaQuest"...) +# and that is what the Group Name columns show. The condition modules were +# written against the earlier, shorter names, which stay the vocabulary of +# their rules; this table translates. A name not listed (including every +# earlier short name) passes through unchanged. +RULE_GROUP = { + "Commercial Ins": "Commercial", + "RAE (Behavioral Health)": "RAE payer", + "Regional Accountable Entity-RAE (Reporting Group)": "RAE payer", + "Dental - CHP+/DentaQuest": "Dentaquest", + "Self Pay": "Program", # the clinic's programs (Outreach, slides…) + "Payer Not In Group": None, # the dashboard's own "unclassified" +} + +# A payer whose condition rule differs from its dashboard group. Empty since +# 2026-10-21: the Medicare Advantage plans (AARP Medicare Advantage, Aetna +# Medicare ADV) were kept on Medicare here until the operator ruled; the +# ruling is "follow the dashboard", so they are Commercial for every +# condition (#21 95/93 not #22 G2025; no #14, #19, #27-#29; #18 / #20 add). +RULE_OVERRIDES = {} + + +def rule_group(payer: str, mapping: dict) -> str: + """ + The group a condition's rule should use for this PAYER, or None if + unmapped: the payer's own override if it has one, else its Group Name + translated through RULE_GROUP. + """ + key = (payer or "").strip() + if key in RULE_OVERRIDES: + return RULE_OVERRIDES[key] + group = classify(key, mapping) + if group is None: + return None + return RULE_GROUP.get(group, group) diff --git a/src/ecw_bot/core/results_db.py b/src/ecw_bot/core/results_db.py new file mode 100644 index 0000000..b84ebca --- /dev/null +++ b/src/ecw_bot/core/results_db.py @@ -0,0 +1,318 @@ +r""" +The Charge Summary rows, also saved to SQL Server for the results web page +(`web/`). + +Operator, 2026-10-08: server MECARE331\MECARE331, database +`Charge_Validation`, table `dbo.CV_ChargeSummary` (every table of this +project starts with `CV_`, operator 2026-10-12) — the team's Charge Summary +File Format's 24 columns (`charge_summary.HEADERS`), plus `run_at`, +`run_mode` and `details_json` (what the conditions found and changed). The +table is created by the operator from `tools/sql/create_tables.sql`; the bot +only inserts. + +The rows are the ones the Excel summary gets (`charge_summary.build`), so the +two always agree. Only rows the run filled in (a Category) are saved: a +claim not processed or not found is blank in the Excel too. + +Off unless `RESULTS_DB=1`. Never fatal: any database error is one +`[RESULTS-DB]` log line, and the run and its Excel summary carry on. + +Values are converted for their columns — amounts without `$` and commas, +dates from mm/dd/yyyy (a trailing time or an Excel serial day number +included) — and anything that does not convert is stored as NULL (the Excel +file still has the text as read). Text longer than its column is cut to the +column's width, so one long value cannot fail the insert. SQL is always +parameterized, and a database error is logged by its SQLSTATE only, never +the driver's message, which can quote a value (PHI) back. +""" + +import json +import re +from datetime import date, datetime, timedelta +from decimal import Decimal, InvalidOperation + +from . import charge_summary + +TABLE = "dbo.CV_ChargeSummary" + +# Charge Summary header -> DB column. `COLUMNS` is built from HEADERS, so a +# header added to the format without a column here fails at import, not +# silently at insert time. +_COLUMN_FOR = { + "S.No": "s_no", "CLAIMS#": "claim_no", "SERVICE DATE": "service_date", + "PVDR": "pvdr", "PATIENT": "patient", "PAYER": "payer", + "Group Name": "group_name", "STATUS": "status", "CHARGES": "charges", + "PMTS/ADJS": "pmts_adjs", "Pmts/Refunds": "pmts_refunds", + "Adjustment": "adjustment", "WithHeld": "withheld", "Balance": "balance", + "Visit Type": "visit_type", "Account Number": "account_number", + "Provider Name": "provider_name", "Guarantor Name": "guarantor_name", + "Recevied date": "received_date", "Assigned To ": "assigned_to", + "Completed On": "completed_on", "Comments": "comments", + "Current Status": "current_status", "Category": "category", +} +COLUMNS = [(h, _COLUMN_FOR[h]) for h in charge_summary.HEADERS] + +DB_COLUMNS = (["run_at", "run_mode"] + [c for _, c in COLUMNS] + + ["details_json"]) + +# NVARCHAR widths from tools/sql/create_tables.sql. MAX columns are not +# listed. +_MAX_LEN = { + "run_mode": 20, "claim_no": 20, "pvdr": 50, "patient": 200, + "payer": 200, "group_name": 100, "status": 100, "visit_type": 100, + "account_number": 50, "provider_name": 200, "guarantor_name": 200, + "assigned_to": 100, "current_status": 200, "category": 20, +} + +_MONEY = {"charges", "pmts_adjs", "pmts_refunds", "adjustment", "withheld", + "balance"} +_DATES = {"service_date", "received_date", "completed_on"} +_INTS = {"s_no"} + +# DECIMAL(12,2): ten digits before the point. +_MONEY_LIMIT = Decimal(10) ** 10 +_CENT = Decimal("0.01") + +_US_DATE = re.compile(r"^(\d{1,2})/(\d{1,2})/(\d{4})(?:\s|$)") +_SERIAL = re.compile(r"^\d{5}(?:\.0+)?$") +# Excel's day 0, and the serial range that is a plausible date +# (1954-10-03 .. 2119-01-08) — not some other five-digit number. +_EXCEL_EPOCH = date(1899, 12, 30) +_SERIAL_RANGE = (20_000, 80_000) +_ISO_DATE = re.compile(r"^(\d{4})-(\d{2})-(\d{2})") + +# What details_for() copies from the claim's `_conditions`. +_CONDITION_KEYS = ("fired", "detected", "changed", "saved", + "save_unconfirmed", "failed", "review", "comments", + "changes") + +# Keys dropped wherever they appear in a condition's changes: they carry +# patient text, not what the bot did — vaccine_admin_codes' age_source +# names the DOB it read; the Dental Billing Notes' match quotes the note. +_PHI_KEYS = {"age_source", "match", "dob"} + +# SQLSTATE -> what it means for the operator. +_SQLSTATE_HINT = { + "08001": "server not reachable", "08S01": "connection lost", + "HYT00": "timed out", "HYT01": "timed out", "28000": "login failed", + "42S02": "table missing — run tools/sql/create_tables.sql", + "42000": "no permission, or a SQL error", + "22001": "a value is too long for its column", + "22003": "a number is too large for its column", +} + + +def to_money(value): + """An amount as Decimal (2 places), or None when blank or not a number.""" + if value is None or isinstance(value, bool): + return None + if isinstance(value, (int, float, Decimal)): + text = str(value) + else: + text = str(value).replace("$", "").replace(",", "").strip() + negative = text.startswith("(") and text.endswith(")") + if negative: + text = text[1:-1].strip() + if not text: + return None + try: + amount = Decimal(text) + except InvalidOperation: + return None + if not amount.is_finite() or abs(amount) >= _MONEY_LIMIT: + return None + amount = amount.quantize(_CENT) + # 9999999999.995 rounds up to 10^10, past DECIMAL(12,2). + if abs(amount) >= _MONEY_LIMIT: + return None + return -amount if negative else amount + + +def to_date(value): + """A date from mm/dd/yyyy, yyyy-mm-dd or a date/datetime; else None.""" + if isinstance(value, datetime): + return value.date() + if isinstance(value, date): + return value + if isinstance(value, (int, float)) and not isinstance(value, bool): + value = str(value) + text = str(value or "").strip() + try: + m = _US_DATE.match(text) + if m: + return date(int(m[3]), int(m[1]), int(m[2])) + m = _ISO_DATE.match(text) + if m: + return date(int(m[1]), int(m[2]), int(m[3])) + if _SERIAL.match(text): + days = int(float(text)) + if _SERIAL_RANGE[0] <= days <= _SERIAL_RANGE[1]: + return _EXCEL_EPOCH + timedelta(days=days) + except ValueError: + return None + return None + + +def _to_int(value): + try: + return int(str(value).strip()) + except (TypeError, ValueError): + return None + + +def _to_text(value): + if value is None: + return None + text = str(value).strip() + return text or None + + +def _fit(col, text): + """Text cut to its column's width (NVARCHAR(n)); MAX columns as is.""" + limit = _MAX_LEN.get(col) + if text is None or limit is None or len(text) <= limit: + return text + return text[:limit] + + +def to_record(row: dict, *, run_at, run_mode, details) -> dict: + """One Charge Summary row ({header: value}) as {db column: value}.""" + rec = {"run_at": run_at, "run_mode": run_mode} + for header, col in COLUMNS: + value = row.get(header) + if col in _MONEY: + rec[col] = to_money(value) + elif col in _DATES: + rec[col] = to_date(value) + elif col in _INTS: + rec[col] = _to_int(value) + else: + rec[col] = _fit(col, _to_text(value)) + rec["run_mode"] = _fit("run_mode", run_mode) + rec["details_json"] = (json.dumps(details, default=str, ensure_ascii=False) + if details else None) + return rec + + +def details_for(data: dict) -> dict: + """ + What the run found on one claim, for `details_json`: the conditions' + account (fired / changes / review / failed / comments / saved), the + Dental checks, and why a claim could not be read. Never the claim's own + fields (patient, CPT grid...) — those are in the row's columns or not + stored at all. + """ + data = data or {} + out = {} + cond = data.get("_conditions") + if isinstance(cond, dict): + out.update({k: _without_phi(cond[k]) + for k in _CONDITION_KEYS if k in cond}) + # Dental: the verdicts only — not the Billing Notes text, not the + # sliding-fee dates. + hr = data.get("high_risk") + if isinstance(hr, dict): + out.setdefault("dental", {})["high_risk"] = hr.get("high_risk") + sf = data.get("sliding_fee") + if isinstance(sf, dict): + out.setdefault("dental", {})["sliding_fee"] = { + k: sf[k] for k in ("verdict", "reason", "error") if k in sf} + for key in ("_error", "_not_evaluated", "_search_error"): + if data.get(key): + out[key.lstrip("_")] = data[key] + return out + + +def _without_phi(value): + """`value` with every _PHI_KEYS key dropped, at any depth.""" + if isinstance(value, dict): + return {k: _without_phi(v) for k, v in value.items() + if k not in _PHI_KEYS} + if isinstance(value, (list, tuple)): + return [_without_phi(v) for v in value] + return value + + +def safe_error(exc) -> str: + """ + An error for the log without the driver's message: SQL Server's + "String or binary data would be truncated" (Msg 2628) quotes the value, + which can be a patient's name. pyodbc puts the SQLSTATE in args[0]. + """ + state = exc.args[0] if exc.args and isinstance(exc.args[0], str) else "" + if re.fullmatch(r"[0-9A-Z]{5}", state): + hint = _SQLSTATE_HINT.get(state, "database error") + return f"{type(exc).__name__} SQLSTATE {state} ({hint})" + return type(exc).__name__ + + +def has_column(conn, column: str) -> bool: + """True if the results table has `column`. False when it does not — or + the check itself fails: a column the table lacks must never cost the + run its rows.""" + try: + row = conn.cursor().execute("SELECT COL_LENGTH(?, ?)", + [TABLE, column]).fetchone() + return bool(row) and row[0] is not None + except Exception: + return False + + +def insert_rows(conn, records: list, started_by: str = "") -> int: + """One parameterized INSERT for every record, one commit. `started_by` + (EEWA-33) goes in only once tools/sql/create_tables.sql has added its + column: until then the rows are saved without it.""" + if not records: + return 0 + cols = list(DB_COLUMNS) + extra = [] + if started_by and has_column(conn, "started_by"): + cols.append("started_by") + extra = [started_by[:50]] + sql = (f"INSERT INTO {TABLE} ({', '.join(cols)}) " + f"VALUES ({', '.join('?' for _ in cols)})") + cur = conn.cursor() + cur.executemany(sql, [[rec[c] for c in DB_COLUMNS] + extra + for rec in records]) + conn.commit() + return len(records) + + +def save_summary(sheet: list, details: dict, *, run_at, run_mode, + conn_str: str, connect=None, started_by: str = ""): + """ + Save the rows of `sheet` (header row + rows, as `charge_summary.build` + returns it) that the run filled in. Returns the number saved, or None + when the database could not be written — never raises. + """ + try: + header = list(sheet[0]) + records = [] + for values in sheet[1:]: + row = dict(zip(header, values)) + if not str(row.get("Category") or "").strip(): + continue + claim = str(row.get("CLAIMS#") or "").strip() + records.append(to_record(row, run_at=run_at, run_mode=run_mode, + details=(details or {}).get(claim))) + if not records: + print("[RESULTS-DB] No claim rows to save") + return 0 + if connect is None: + import pyodbc + connect = pyodbc.connect + conn = connect(conn_str, timeout=15) + try: + # Query timeout: a blocked insert raises (and is logged) + # instead of holding the run up. + conn.timeout = 30 + saved = insert_rows(conn, records, started_by) + finally: + conn.close() + print(f"[RESULTS-DB] {saved} row(s) saved to {TABLE}") + return saved + except Exception as exc: + print(f"[RESULTS-DB] Could not save the charge summary to the " + f"database: {safe_error(exc)} — the Excel summary is not " + f"affected") + return None diff --git a/src/ecw_bot/core/run_events.py b/src/ecw_bot/core/run_events.py new file mode 100644 index 0000000..cc1179d --- /dev/null +++ b/src/ecw_bot/core/run_events.py @@ -0,0 +1,146 @@ +""" +Run events (spec docs/superpowers/specs/2026-10-22-run-tracking-and-portal- +sign-in-design.md, Stage 1; ADR docs/adr/0001-run-events-in-files.md). + +The run log has no times on its lines, so how long the eCW login took, or +when a claim went wrong, could not be told afterwards. Every Run now also +writes `Logs\\events_<run>.jsonl` beside `run_<run>.log`: one JSON object a +line, each with its time to the millisecond, flushed at once so a killed Run +keeps what it wrote. The Portal's Runs page reads these files. + +An event never stops a Run: anything that goes wrong writing one drops that +event and nothing else. Nothing secret goes in — no OTP code, no password, +no eCW username: the callers pass fixed fields, never a whole log line. +""" + +import os +import json +import getpass +import traceback +from datetime import datetime +from pathlib import Path + +_handle = None +_attempt = 0 + + +def close() -> None: + """End of the Run: let go of the file (Windows keeps an open one locked).""" + _reset() + + +def _reset() -> None: + """Close the file and forget the attempt count (tests; a new Run).""" + global _handle, _attempt + try: + if _handle: + _handle.close() + except Exception: + pass + _handle, _attempt = None, 0 + + +def run_id_of(log_path) -> str: + """`run_20261022_141530.log` → `20261022_141530`.""" + stem = Path(log_path).stem + return stem[len("run_"):] if stem.startswith("run_") else stem + + +def path_for(log_path) -> Path: + """The events file of the Run whose log is `log_path`.""" + log_path = Path(log_path) + return log_path.with_name(f"events_{run_id_of(log_path)}.jsonl") + + +def _windows_user() -> str: + try: + return getpass.getuser() + except Exception: + return os.environ.get("USERNAME", "") + + +def started_by() -> str: + """Started By: the Portal User the Portal passed (ECW_STARTED_BY), else + the Windows user of a terminal run.""" + return os.environ.get("ECW_STARTED_BY") or _windows_user() + + +def _now() -> str: + return datetime.now().isoformat(timespec="milliseconds") + + +def _line(kind: str, fields: dict) -> str: + # The time and kind last, so no field can overwrite them. + return json.dumps({**fields, "t": _now(), "kind": kind}, + ensure_ascii=False, default=str) + + +def start(log_path, mode: str, claim_limit=None, test_sheet: str = "") -> None: + """Open this Run's events file and write `run_start`.""" + global _handle + _reset() + try: + _handle = open(path_for(log_path), "a", encoding="utf-8", + buffering=1) + except Exception: + _handle = None + return + source = os.environ.get("ECW_RUN_SOURCE", "terminal") + emit("run_start", + source="portal" if source == "web" else source, + started_by=started_by(), + mode=mode, claim_limit=claim_limit, + test_sheet=Path(test_sheet).name if test_sheet else "", + pid=os.getpid(), log=str(log_path)) + + +def emit(kind: str, **fields) -> None: + """Write one event. Never raises.""" + if _handle is None: + return + try: + _handle.write(_line(kind, fields) + "\n") + _handle.flush() + except Exception: + pass + + +def append_to(log_path, kind: str, **fields) -> None: + """Add one event to another Run's file — the Portal, after a Stop.""" + try: + with open(path_for(log_path), "a", encoding="utf-8") as fh: + fh.write(_line(kind, fields) + "\n") + except Exception: + pass + + +def warning(code: str, message: str, **fields) -> None: + emit("warning", code=code, message=message, **fields) + + +def claim_error(claim: str, step: str, message: str) -> None: + emit("claim_error", claim=claim, step=step, message=message) + + +def run_error(step: str, message: str, exit_code: int, exc=None) -> None: + tb = "" + if exc is not None: + tb = "".join(traceback.format_exception(type(exc), exc, + exc.__traceback__)) + emit("run_error", step=step, exit_code=exit_code, message=message, + traceback=tb) + + +def login_attempt_start() -> None: + global _attempt + _attempt += 1 + emit("login_attempt_start", attempt=_attempt) + + +def login_step(step: str, **fields) -> None: + emit("login_step", attempt=_attempt, step=step, **fields) + + +def login_attempt_end(result: str, message: str = "") -> None: + emit("login_attempt_end", attempt=_attempt, result=result, + message=message) diff --git a/src/ecw_bot/core/run_lock.py b/src/ecw_bot/core/run_lock.py new file mode 100644 index 0000000..52f856a --- /dev/null +++ b/src/ecw_bot/core/run_lock.py @@ -0,0 +1,229 @@ +r""" +The run record — `Logs\bot_run.json`, the latest bot run (spec +docs/superpowers/specs/2026-10-14-start-bot-from-web-page-design.md). + +Written by the bot itself at the start of `main()` and finished when +`main()` returns, so the results page sees a run started in the terminal +as well as one it started. While a run is live a second `main()` refuses to +start: two runs would share one eCW login guard and one Charge Summary. + + {"pid", "started_at", "log_path", "source": "web" | "terminal", + "ended_at", "exit_code", "stopped_by_operator"} + +Live = no `ended_at`, the pid is a running python process, and that process +was created no later than the record's `started_at` (plus a few seconds) — +so a record left unended by a killed run, whose pid Windows later hands to +another python process, does not read as live. A killed run cannot finish +its own record; its pid is gone or reused, so it reads as ended. +""" + +import os +import time +import json +import ctypes +from ctypes import wintypes +from datetime import datetime, timedelta + +from .. import settings as cfg + +FILE_NAME = "bot_run.json" + +_PROCESS_QUERY_LIMITED_INFORMATION = 0x1000 +_STILL_ACTIVE = 259 +# started_at is written after the process exists, truncated to the second. +_CREATED_SLACK = timedelta(seconds=5) +_FILETIME_UNIX_EPOCH = 116444736000000000 # 1601-01-01 -> 1970-01-01, 100 ns + + +class Busy(Exception): + """Another bot run is live.""" + + +def path(): + """Read at call time: tests point cfg.LOGS elsewhere.""" + return cfg.LOGS / FILE_NAME + + +def _now() -> str: + return datetime.now().isoformat(timespec="seconds") + + +def pid_alive(pid) -> bool: + """ + A running process with this pid whose program is python. + + Never `os.kill(pid, 0)`: on Windows that TERMINATES the process. + The program-name check keeps a stale record whose pid Windows gave to + some other program from reading as a live bot run. + """ + try: + pid = int(pid) + except (TypeError, ValueError): + return False + if pid <= 0: + return False + if os.name != "nt": + try: + os.kill(pid, 0) + return True + except OSError: + return False + k32 = ctypes.WinDLL("kernel32", use_last_error=True) + k32.OpenProcess.restype = wintypes.HANDLE + k32.OpenProcess.argtypes = [wintypes.DWORD, wintypes.BOOL, wintypes.DWORD] + k32.GetExitCodeProcess.argtypes = [wintypes.HANDLE, + ctypes.POINTER(wintypes.DWORD)] + k32.QueryFullProcessImageNameW.argtypes = [ + wintypes.HANDLE, wintypes.DWORD, wintypes.LPWSTR, + ctypes.POINTER(wintypes.DWORD)] + k32.CloseHandle.argtypes = [wintypes.HANDLE] + handle = k32.OpenProcess(_PROCESS_QUERY_LIMITED_INFORMATION, False, pid) + if not handle: + return False + try: + code = wintypes.DWORD() + if not k32.GetExitCodeProcess(handle, ctypes.byref(code)): + return False + if code.value != _STILL_ACTIVE: + return False + buf = ctypes.create_unicode_buffer(1024) + size = wintypes.DWORD(len(buf)) + if not k32.QueryFullProcessImageNameW(handle, 0, buf, + ctypes.byref(size)): + return True + return "python" in os.path.basename(buf.value).lower() + finally: + k32.CloseHandle(handle) + + +def process_created(pid): + """ + When process `pid` was created, as a local naive datetime (the same + clock as the record's `started_at`); None when it cannot be read or + the process is no longer running. + """ + if os.name != "nt": + return None + try: + pid = int(pid) + except (TypeError, ValueError): + return None + if pid <= 0: + return None + k32 = ctypes.WinDLL("kernel32", use_last_error=True) + k32.OpenProcess.restype = wintypes.HANDLE + k32.OpenProcess.argtypes = [wintypes.DWORD, wintypes.BOOL, wintypes.DWORD] + k32.GetExitCodeProcess.argtypes = [wintypes.HANDLE, + ctypes.POINTER(wintypes.DWORD)] + k32.GetProcessTimes.argtypes = ( + [wintypes.HANDLE] + [ctypes.POINTER(wintypes.FILETIME)] * 4) + k32.CloseHandle.argtypes = [wintypes.HANDLE] + handle = k32.OpenProcess(_PROCESS_QUERY_LIMITED_INFORMATION, False, pid) + if not handle: + return None + try: + # An ended process can still be opened while some handle to it is + # held (its parent's, say) and still has times: not a running one. + code = wintypes.DWORD() + if (not k32.GetExitCodeProcess(handle, ctypes.byref(code)) + or code.value != _STILL_ACTIVE): + return None + created, exited, kernel, user = (wintypes.FILETIME() for _ in range(4)) + if not k32.GetProcessTimes(handle, ctypes.byref(created), + ctypes.byref(exited), ctypes.byref(kernel), + ctypes.byref(user)): + return None + ticks = (created.dwHighDateTime << 32) | created.dwLowDateTime + if ticks <= _FILETIME_UNIX_EPOCH: + return None + return datetime.fromtimestamp( + (ticks - _FILETIME_UNIX_EPOCH) / 10_000_000) + except (OSError, OverflowError, ValueError): + return None + finally: + k32.CloseHandle(handle) + + +def read(): + """The record, or None when there is none or it cannot be read.""" + try: + data = json.loads(path().read_text(encoding="utf-8")) + except (OSError, ValueError): + return None + return data if isinstance(data, dict) else None + + +def is_live(rec) -> bool: + if not rec or rec.get("ended_at") or not pid_alive(rec.get("pid")): + return False + # Same pid, but a younger process: the pid was reused after the run + # died without finishing its record. If either time cannot be read, + # trust the pid. + try: + started = datetime.fromisoformat(rec["started_at"]) + except (KeyError, TypeError, ValueError): + return True + created = process_created(rec.get("pid")) + try: + if created is not None and created > started + _CREATED_SLACK: + return False + except TypeError: + pass # started_at carries a UTC offset: keep the pid verdict + return True + + +def _write(rec: dict) -> None: + target = path() + target.parent.mkdir(parents=True, exist_ok=True) + tmp = target.with_suffix(".tmp") + tmp.write_text(json.dumps(rec, indent=2), encoding="utf-8") + # On Windows the replace is refused for a moment while another process + # (the page polling, a scanner) holds the file open. + for attempt in range(10): + try: + os.replace(tmp, target) + return + except PermissionError: + if attempt == 9: + raise + time.sleep(0.05) + + +def acquire(log_path, source=None) -> dict: + """This process's record, written; raises Busy over a live one.""" + rec = read() + if is_live(rec): + raise Busy(f"A bot run is already going (pid {rec.get('pid')}, " + f"started {rec.get('started_at') or '?'}) — not starting " + f"a second one") + rec = {"pid": os.getpid(), "started_at": _now(), + "log_path": str(log_path), + "source": source or os.environ.get("ECW_RUN_SOURCE") or "terminal", + # The Portal User for a Portal run (EEWA-32), else Windows. + "started_by": (os.environ.get("ECW_STARTED_BY") + or os.environ.get("USERNAME") or ""), + "ended_at": None, "exit_code": None, + "stopped_by_operator": False} + _write(rec) + return rec + + +def finish(exit_code) -> None: + """End this process's own record. Never raises.""" + try: + rec = read() + if not rec or rec.get("pid") != os.getpid(): + return + rec.update(ended_at=_now(), exit_code=exit_code) + _write(rec) + except Exception: + pass + + +def mark_stopped(pid) -> None: + """The page killed run `pid`: say so on its record.""" + rec = read() + if not rec or rec.get("pid") != pid or rec.get("ended_at"): + return + rec.update(ended_at=_now(), exit_code=None, stopped_by_operator=True) + _write(rec) diff --git a/src/ecw_bot/core/runlog.py b/src/ecw_bot/core/runlog.py new file mode 100644 index 0000000..5bb10f8 --- /dev/null +++ b/src/ecw_bot/core/runlog.py @@ -0,0 +1,76 @@ +""" +Run log. + +Python buffers stdout when it is not a terminal, so a background run that hung +produced a zero-byte log and there was no way to see how far it had got — the +only evidence left was a screenshot. Every run now tees to +`Logs\\run_<timestamp>.log`, flushed per line, so a stuck or killed run still +leaves a readable trail. +""" + +import sys +from datetime import datetime + +from .. import settings as cfg + + +class _Tee: + def __init__(self, stream, handle): + self._stream = stream + self._handle = handle + + def write(self, text): + try: + self._stream.write(text) + self._stream.flush() + except Exception: + pass + try: + self._handle.write(text) + self._handle.flush() # a killed run must still have its log + except Exception: + pass + return len(text) + + def flush(self): + for target in (self._stream, self._handle): + try: + target.flush() + except Exception: + pass + + def isatty(self): + return getattr(self._stream, "isatty", lambda: False)() + + +def _force_utf8_console() -> None: + """ + Make the console accept the characters this bot actually prints. + + Windows hands Python a cp1252 console, which cannot encode an em dash or an + arrow — and nearly every log line here has one. The tee below swallows the + resulting UnicodeEncodeError so a run never dies of it, which is right but + means those lines were being **silently dropped from the console** while + landing correctly in the log file. Watching a run and reading its log + afterwards showed different things, which is a nasty way to debug. + + `errors="replace"` as well as the encoding: a console that still cannot + represent a character should print a box, not raise. + """ + for stream in (sys.__stdout__, sys.__stderr__): + try: + stream.reconfigure(encoding="utf-8", errors="replace") + except Exception: + pass # not a real console, or too old to care + +def start() -> "object": + """Begin teeing stdout/stderr. Returns the log path.""" + _force_utf8_console() + cfg.LOGS.mkdir(parents=True, exist_ok=True) + path = cfg.LOGS / f"run_{datetime.now():%Y%m%d_%H%M%S}.log" + handle = open(path, "a", encoding="utf-8", buffering=1) + handle.write(f"\n{'=' * 70}\n{datetime.now():%Y-%m-%d %H:%M:%S} — run start\n" + f"{'=' * 70}\n") + sys.stdout = _Tee(sys.__stdout__, handle) + sys.stderr = _Tee(sys.__stderr__, handle) + return path diff --git a/src/ecw_bot/core/spreadsheet.py b/src/ecw_bot/core/spreadsheet.py new file mode 100644 index 0000000..d092c3b --- /dev/null +++ b/src/ecw_bot/core/spreadsheet.py @@ -0,0 +1,396 @@ +""" +Read rows out of a downloaded report. No eCW knowledge, no dependencies. + +**Why not openpyxl.** Same reason `settings.py` parses `.env` by hand and the +tests run on plain asserts: these machines are locked down and every added +package is one more approval to chase. An `.xlsx` is a zip of XML, and the one +eCW produces is about as simple as the format gets, so `zipfile` plus +`xml.etree` from the stdlib is enough. If eCW ever ships something genuinely +complicated, that is the moment to argue for openpyxl — not now. + +**Three formats, because eCW has sent more than one.** Its "EXCEL" export is a +real xlsx today (verified against the 391 KB file it produced on 2026-09-07), +but the same menu on other builds serves an HTML table with an `.xls` name, and +the CSV item next to it is one keystroke away. Sniffing the file's own bytes +rather than trusting its extension is what keeps a format change from reading as +a parsing bug. + +What the eCW xlsx actually looks like, since it shapes the parser: + + <row r="1"> + <c r="A1" t="str"><v></v></c> + <c r="C1" t="str"><v>CLAIMS#</v></c> + ... + +Every value is an inline `t="str"`, and there is **no `sharedStrings.xml`** — so +no string table to resolve. The shared-string path below is still implemented, +because "this build does not use it" is not the same as "no build will". +""" + +import csv +import re +import zipfile +from datetime import datetime +from html.parser import HTMLParser +from pathlib import Path +from xml.etree import ElementTree +from xml.sax.saxutils import escape + +from .text import first_line + + +def _tag(name: str) -> str: + """Local tag name, with the OOXML namespace stripped.""" + return name.rsplit("}", 1)[-1] + + +def _col_index(ref: str) -> int: + """`"D2"` → 3. Column letters are base-26 with no zero digit.""" + letters = re.match(r"[A-Z]+", ref or "") + if not letters: + return 0 + n = 0 + for ch in letters.group(0): + n = n * 26 + (ord(ch) - 64) + return n - 1 + + +def _shared_strings(zf: zipfile.ZipFile) -> list: + if "xl/sharedStrings.xml" not in zf.namelist(): + return [] + out = [] + root = ElementTree.fromstring(zf.read("xl/sharedStrings.xml")) + for si in root: + # A shared string can be split across several <r><t> runs. + out.append("".join(t.text or "" for t in si.iter() + if _tag(t.tag) == "t")) + return out + + +def _read_xlsx(path: Path) -> list: + with zipfile.ZipFile(path) as zf: + sheets = [n for n in zf.namelist() + if n.startswith("xl/worksheets/sheet")] + if not sheets: + raise ValueError(f"{path.name} is a zip but has no worksheet") + shared = _shared_strings(zf) + root = ElementTree.fromstring(zf.read(sorted(sheets)[0])) + + rows = [] + for row in root.iter(): + if _tag(row.tag) != "row": + continue + cells = {} + for c in row: + if _tag(c.tag) != "c": + continue + # Addressed by column letter, never by position: a cell with no + # value can be omitted entirely, and reading positionally would + # then shift every column after it. + idx = _col_index(c.get("r", "")) + kind = c.get("t") + text = "" + if kind == "s": # shared-string index + v = c.find("./{*}v") + if v is not None and (v.text or "").isdigit(): + i = int(v.text) + text = shared[i] if i < len(shared) else "" + elif kind == "inlineStr": + text = "".join(t.text or "" for t in c.iter() + if _tag(t.tag) == "t") + else: # str, n, b, or untyped + v = c.find("./{*}v") + text = (v.text or "") if v is not None else "" + cells[idx] = text.strip() + width = max(cells) + 1 if cells else 0 + rows.append([cells.get(i, "") for i in range(width)]) + return rows + + +class _TableParser(HTMLParser): + """Pull the first table's rows out of an HTML-as-.xls export.""" + + def __init__(self): + super().__init__(convert_charrefs=True) + self.rows, self._row, self._cell, self._in_cell = [], None, [], False + + def handle_starttag(self, tag, attrs): + if tag == "tr": + self._row = [] + elif tag in ("td", "th"): + self._in_cell, self._cell = True, [] + + def handle_endtag(self, tag): + if tag in ("td", "th") and self._row is not None: + self._row.append("".join(self._cell).strip()) + self._in_cell = False + elif tag == "tr" and self._row is not None: + if any(self._row): + self.rows.append(self._row) + self._row = None + + def handle_data(self, data): + if self._in_cell: + self._cell.append(data) + + +def _read_html(path: Path) -> list: + p = _TableParser() + p.feed(path.read_text(encoding="utf-8", errors="replace")) + return p.rows + + +def _read_csv(path: Path) -> list: + text = path.read_text(encoding="utf-8-sig", errors="replace") + # Sniff on the header line only: a patient name with a comma in it would + # otherwise outvote the real delimiter. + first = text.splitlines()[0] if text else "" + delim = "\t" if first.count("\t") > first.count(",") else "," + return [[c.strip() for c in row] + for row in csv.reader(text.splitlines(), delimiter=delim) + if any(c.strip() for c in row)] + + +def read_rows(path) -> list: + """ + Every row of the report as a list of strings, blank rows dropped. + + The format is decided by the file's first bytes, not its extension. + """ + path = Path(path) + head = path.open("rb").read(8) + + if head.startswith(b"PK"): + return _read_xlsx(path) + if head.lstrip()[:1] == b"<": + return _read_html(path) + if head.startswith(b"\xd0\xcf\x11\xe0"): + # Legacy OLE2 .xls. Not a zip and not XML — a real binary format that + # would need a parser of its own. eCW has not sent one, so this says so + # instead of silently returning nothing. + raise ValueError( + f"{path.name} is a legacy binary .xls (OLE2). Nothing here can " + f"read that. Set CLAIM_REPORT_FORMAT=CSV, which eCW offers in the " + f"same menu, or add a parser for it.") + return _read_csv(path) + + +# ============================================================================= +# Writing (the categorization step's output) +# ============================================================================= +# The read side above has no dependency on openpyxl; the write side keeps that +# constraint too. What follows is the minimal set of OOXML parts real Excel +# actually requires to open a file without a "repair" prompt — more than the +# reader above needs (it only ever looks at xl/worksheets/sheet*.xml), because +# this file is meant to be opened by a person, not just this codebase. + +_CONTENT_TYPES = ( + '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>' + '<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types">' + '<Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/>' + '<Default Extension="xml" ContentType="application/xml"/>' + '<Override PartName="/xl/workbook.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet.main+xml"/>' + '<Override PartName="/xl/worksheets/sheet1.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.worksheet+xml"/>' + '</Types>' +) + +_ROOT_RELS = ( + '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>' + '<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">' + '<Relationship Id="rId1" ' + 'Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" ' + 'Target="xl/workbook.xml"/>' + '</Relationships>' +) + +_WORKBOOK = ( + '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>' + '<workbook xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main" ' + 'xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships">' + '<sheets><sheet name="Sheet1" sheetId="1" r:id="rId1"/></sheets>' + '</workbook>' +) + +_WORKBOOK_RELS = ( + '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>' + '<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">' + '<Relationship Id="rId1" ' + 'Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/worksheet" ' + 'Target="worksheets/sheet1.xml"/>' + '</Relationships>' +) + + +# XML 1.0 does not allow these control characters anywhere in a document, +# escaped or not. `escape()` only handles &, <, > — a cell value carrying one +# of these would still produce a file that "wrote successfully" but is +# malformed XML, silently corrupt rather than caught. Stripped, not escaped: +# there is no valid XML representation for them at all. +_XML_ILLEGAL = re.compile("[\x00-\x08\x0b\x0c\x0e-\x1f]") + + +def _col_letter(idx: int) -> str: + """0 -> 'A', 25 -> 'Z', 26 -> 'AA'. Inverse of `_col_index` above.""" + letters = "" + idx += 1 + while idx > 0: + idx, rem = divmod(idx - 1, 26) + letters = chr(65 + rem) + letters + return letters + + +def write_xlsx(path, rows: list) -> None: + """ + Write `rows` (a header row plus data rows, each a list of values) as a + minimal single-sheet xlsx. + + Inline `t="str"` cells, no sharedStrings — the same shape `_read_xlsx` + above already parses, and what eCW's own export already sends (see the + module docstring). Every value is written through `str()`, so callers can + pass numbers or None without pre-formatting. + """ + path = Path(path) + body = [] + for r, row in enumerate(rows, start=1): + cells = [] + for c, value in enumerate(row): + text = escape(_XML_ILLEGAL.sub("", str(value))) + cells.append(f'<c r="{_col_letter(c)}{r}" t="str"><v>{text}</v></c>') + body.append(f'<row r="{r}">{"".join(cells)}</row>') + sheet = ( + '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>' + '<worksheet xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main">' + f'<sheetData>{"".join(body)}</sheetData></worksheet>' + ) + + path.parent.mkdir(parents=True, exist_ok=True) + with zipfile.ZipFile(path, "w", zipfile.ZIP_DEFLATED) as zf: + zf.writestr("[Content_Types].xml", _CONTENT_TYPES) + zf.writestr("_rels/.rels", _ROOT_RELS) + zf.writestr("xl/workbook.xml", _WORKBOOK) + zf.writestr("xl/_rels/workbook.xml.rels", _WORKBOOK_RELS) + zf.writestr("xl/worksheets/sheet1.xml", sheet) + + +# ============================================================================= +# Writing next to a file the operator may have open +# ============================================================================= +# Both dated outputs (Output\Medcor Ready_<date>.xlsx and Output\Charge +# Summary_<date>.xlsx) are files people work in: the operator hand-fills blank +# Group Names in one and reviews claims in the other, and a same-day re-run +# must neither overwrite that work nor lose its own rows over it. +# +# Until 2026-10-05 each caller had its own copy of "rename the old file aside, +# then write the new one". On Windows that rename FAILS while the old file is +# open in Excel ([WinError 32] "being used by another process"), the caller +# caught the error, and the whole run's sheet was thrown away (review item +# D10) — on exactly the second run of a day, when the first run's sheet is the +# one most likely to be open. This one helper replaces both copies, and its +# answer to a locked file is to write the new rows under a free name next to +# it instead. + +def _first_free_name(out_path: Path, stamp: str) -> Path: + """ + `<stem>__<HHMMSS>.xlsx`, else `<stem>__<HHMMSS>_2.xlsx`, `_3` ... — the + first one not already on disk. The `_<n>` suffix exists because two + writes inside the same wall-clock second do happen (back-to-back in this + project's own tests, and a fast manual re-run), and Windows refuses to + rename or write onto a path that is already taken. + """ + candidate = out_path.with_stem(f"{out_path.stem}__{stamp}") + n = 2 + while candidate.exists(): + candidate = out_path.with_stem(f"{out_path.stem}__{stamp}_{n}") + n += 1 + return candidate + + +def _move_back(out_path: Path, backup: Path, log_tag: str) -> None: + """ + The old file was renamed aside but the new one could not be written in + its place: put the old one back under the name the operator knows it by, + so that — when the move back succeeds — a failed write leaves the folder + as it was found. + + Whatever is at `out_path` now is the failed write's own half-file — the + old file was moved off that name a moment ago — so it is removed first; + a half-written zip under the real name would open as a "corrupt file" + error instead of the operator's sheet. If the removal or the move back + fails (a virus scanner holding the half-file, say), the old file is + still safe under `backup`, but the usual name may now hold that + incomplete file — so the log says both: which name not to trust, and + where the operator's sheet really is. + """ + try: + out_path.unlink(missing_ok=True) + backup.rename(out_path) + except OSError as exc: + print(f"{log_tag} {out_path.name} may be an incomplete file — your " + f"earlier sheet is {backup.name} (could not move it back: " + f"{first_line(exc)})") + + +def write_xlsx_keep_old(out_path, rows: list, log_tag: str) -> Path: + """ + `write_xlsx(out_path, rows)`, without ever overwriting a file already at + `out_path` and without ever losing `rows` because of one. Returns the + path actually written, which callers must report — it is not always + `out_path`. + + * nothing at `out_path` -> written there. + * a file already there -> renamed aside to the first free + `<stem>__<HHMMSS>[_<n>].xlsx` (kept, not deleted: it may hold the + operator's own edits), then `rows` written to `out_path`. Logged as + "<tag> <name> already existed — kept as <backup>". + * that rename, or the write after it, fails with OSError (the file is + open in Excel, or locked by a sync/virus scanner) -> `rows` go to the + first free `<stem>__<HHMMSS>[_<n>].xlsx` instead. Logged as "<tag> + <name> is open or locked (<error>) — saved this run as <fallback> + instead". The old file: when the rename itself failed, it never + moved; when the rename went through and the write after it failed, + it is put back under its usual name (`_move_back`) when possible — + and if that fails too, it stays at the `__HHMMSS` backup and the log + names both files, because the usual name may then hold the + incomplete write (final review, 2026-10-05: this bullet used to say + the old file was always "left where and as it was"). + + If the fallback write fails as well, that error propagates: both callers + already catch it and tell the operator, and there is nowhere left to put + the rows that would not be a guess. + + The fallback writes directly under its final name rather than to a temp + file moved into place (as the D10 review suggested): virus scanners + commonly open a freshly closed file for a moment, so an extra rename + would be one more step that can hit the same WinError 32 this exists to + get around. + """ + out_path = Path(out_path) + if not out_path.exists(): + write_xlsx(out_path, rows) + return out_path + + stamp = datetime.now().strftime("%H%M%S") + backup = _first_free_name(out_path, stamp) + moved = False + try: + out_path.rename(backup) + moved = True + write_xlsx(out_path, rows) + except Exception as exc: + # Any failure after the old file moved puts it back first — even one + # that is a bug rather than a lock — so nothing is left renamed + # behind a half-written file. + if moved: + _move_back(out_path, backup, log_tag) + if not isinstance(exc, OSError): + raise + fallback = _first_free_name(out_path, stamp) + write_xlsx(fallback, rows) + print(f"{log_tag} {out_path.name} is open or locked " + f"({first_line(exc)}) — saved this run as {fallback.name} " + f"instead") + return fallback + + print(f"{log_tag} {out_path.name} already existed — kept as " + f"{backup.name}") + return out_path diff --git a/src/ecw_bot/core/text.py b/src/ecw_bot/core/text.py new file mode 100644 index 0000000..b470c93 --- /dev/null +++ b/src/ecw_bot/core/text.py @@ -0,0 +1,23 @@ +""" +core/text.py — small text helpers shared by core/, conditions/ and flows/. + +Lives in core/ because core/ must not import from conditions/, pages/ or +flows/, and all three need it (review of unit D, 2026-10-05: the condition +runner, the Charge Summary and the flow each had their own copy). +""" + + +def first_line(value) -> str: + """ + The first non-blank line of `value`'s text, stripped. + + For an exception: Playwright's messages carry a whole call log after the + first line, which is all a log line or a summary cell wants. An exception + with an empty message gives its class name instead — a bare + `str(exc).splitlines()[0]` raises IndexError on one. Any other empty + value (an error string that came back blank) gives "". + """ + lines = str(value).strip().splitlines() + if lines: + return lines[0].strip() + return type(value).__name__ if isinstance(value, BaseException) else "" diff --git a/src/ecw_bot/core/watchdog.py b/src/ecw_bot/core/watchdog.py new file mode 100644 index 0000000..37d2342 --- /dev/null +++ b/src/ecw_bot/core/watchdog.py @@ -0,0 +1,148 @@ +""" +Claim watchdog (2026-10-22). + +A frozen page hangs the bot for good: Playwright's `evaluate` has no +timeout, and on 2026-10-21 / 22 a run sat 28 minutes on one claim after +its form opened — no error, no end, the Runs page frozen on its last event. + +While the claims are checked (`watching()`), the flow beats at every step +of a claim, and every message box beats as it opens and closes. When no +beat comes for the limit (`WATCHDOG_MINUTES`, never under a box's timeout +plus two minutes), a thread here: + + 1. closes this process's Playwright driver (`node.exe`, a child of this + process). The frozen call then returns with an error, so the claim + fails like any other and the flow carries on to its next step. With + BROWSER_CDP_URL only the driver goes: the operator's Chrome stays. + 2. marks the watchdog fired. The flow's next `check()` — at the top of + the next claim — raises `Stalled`, and the Run ends with exit 8, a Run + Error naming where it stopped, and the rest of the queue Not Reached. + 3. if the main thread still does not come back (GRACE_S), writes the Run + Error and the end itself and exits the process with code 8 — a hung + bot never again runs without an end. +""" + +import os +import time +import threading +import subprocess +from contextlib import contextmanager + +from .. import settings as cfg +from . import run_events, run_lock + +EXIT_CODE = 8 +POLL_S = 5.0 # how often the thread looks +GRACE_S = 120.0 # after the kill, how long the main thread gets + +_lock = threading.Lock() +_state = {"armed": False, "last": 0.0, "where": "", "limit": 0.0, + "fired": False, "answered": False, "message": ""} +_thread = None + + +class Stalled(RuntimeError): + """No progress for the limit: the Run stops (exit 8).""" + + +def limit_seconds() -> float: + """WATCHDOG_MINUTES in seconds, never under a message box's timeout + plus two minutes (a box left open is not a hang); 0 = off.""" + if not cfg.WATCHDOG_MINUTES: + return 0 + return max(cfg.WATCHDOG_MINUTES * 60, cfg.MSGBOX_TIMEOUT_SECONDS + 120) + + +def beat(where: str = "") -> None: + """Progress: the clock starts again, and `where` names the step.""" + with _lock: + _state["last"] = time.monotonic() + if where: + _state["where"] = where + + +def has_fired() -> bool: + return _state["fired"] + + +def message() -> str: + return _state["message"] + + +def check() -> None: + """Raise `Stalled` once the watchdog has fired.""" + if _state["fired"]: + _state["answered"] = True + raise Stalled(_state["message"]) + + +@contextmanager +def watching(limit_s: float): + """Watch the block; `limit_s` 0 or less = not at all.""" + global _thread + with _lock: + _state.update(armed=limit_s > 0, last=time.monotonic(), where="", + limit=float(limit_s), fired=False, answered=False, + message="") + if limit_s > 0 and (_thread is None or not _thread.is_alive()): + _thread = threading.Thread(target=_watch, name="claim-watchdog", + daemon=True) + _thread.start() + try: + yield + finally: + with _lock: + _state["armed"] = False + + +def _watch() -> None: + while True: + time.sleep(POLL_S) + with _lock: + due = (_state["armed"] and not _state["fired"] + and time.monotonic() - _state["last"] >= _state["limit"]) + if due: + minutes = _state["limit"] / 60 + _state["fired"] = True + _state["message"] = ( + f"No progress for {minutes:g} min at " + f"{_state['where'] or 'the claims'} — the page froze; " + f"the browser was closed and the run stopped") + if not due: + continue + print(f"[WATCHDOG] {_state['message']}") + run_events.warning("watchdog", _state["message"]) + try: + _kill() + except Exception as exc: + print(f"[WATCHDOG] Could not close the driver: {exc}") + end = time.monotonic() + GRACE_S + while time.monotonic() < end and not _state["answered"]: + if not _state["armed"]: # the claims ended another way + break + time.sleep(POLL_S / 5) + if not _state["answered"] and _state["armed"]: + print("[WATCHDOG] The run did not come back — ending it") + run_events.run_error("stalled", _state["message"], EXIT_CODE) + run_events.emit("run_end", exit_code=EXIT_CODE) + run_events.close() + run_lock.finish(EXIT_CODE) + _exit(EXIT_CODE) + + +def _kill() -> None: + """Close this process's Playwright driver (node.exe) and what it + started. Nothing else of this process is touched.""" + ps = (f"Get-CimInstance Win32_Process -Filter " + f"'ParentProcessId={os.getpid()} and Name=\"node.exe\"' | " + f"ForEach-Object {{ $_.ProcessId }}") + out = subprocess.run(["powershell", "-NoProfile", "-Command", ps], + capture_output=True, text=True, timeout=30).stdout + for pid in out.split(): + if pid.isdigit(): + subprocess.run(["taskkill", "/PID", pid, "/T", "/F"], + capture_output=True, timeout=30) + + +def _exit(code: int) -> None: + os._exit(code) diff --git a/src/ecw_bot/flows/__init__.py b/src/ecw_bot/flows/__init__.py new file mode 100644 index 0000000..1e5d2ed --- /dev/null +++ b/src/ecw_bot/flows/__init__.py @@ -0,0 +1,4 @@ +r""" +The business processes. A flow drives `pages\` in a particular order; the +pages themselves stay reusable by the next flow. +""" diff --git a/src/ecw_bot/flows/export_medcor_ready.py b/src/ecw_bot/flows/export_medcor_ready.py new file mode 100644 index 0000000..450f180 --- /dev/null +++ b/src/ecw_bot/flows/export_medcor_ready.py @@ -0,0 +1,1603 @@ +r""" +Flow: export the Medcor Ready claim queue to Excel. + + login + → left nav → Billing → Claims + → filters (Claim Status, date type, date range, Balance) + → Lookup + → Billing → View Claims Report → EXCEL + → Input\Claim Report_<MM-DD-YYYY>.xlsx + → read the file back, pull the CLAIMS# column + → categorize by payer → Output\Medcor Ready_<MM-DD-YYYY>.xlsx + → search each claim id on its own (CLAIM_PROCESS_LIMIT) + → open the UB form → CPT/HCPCS tab → Prog. Notes + → extract every field → Output\claims\<claim>.json + → run every registered condition → confirm (combined) → save + (APPLY_CORRECTIONS gates the check; SAVE_CORRECTIONS gates the write) + +The step order is this module's only real content, and one part of it is not +interchangeable: **Claim Status goes first.** It drives whether a claim-based +date type can match anything and whether the Balance row is editable, so the +three steps after it read the wrong state if it runs later. + +Exit codes are deliberately distinct, because the login is the expensive part — +a mailed approval link and one of a handful of hourly attempts — and "logged +in, menu broke" must never read like "login failed": + + 0 done + 1 login failed + 2 config incomplete or unreadable (TEST_CLAIM_IDS_FILE pointing at a + file that does not exist included), or LIVE-WRITE mode not confirmed + (all before login — costs nothing) + 3 attempt guard refused (costs nothing — no login, no 2FA mail) + 4 eCW restriction page + 5 logged in, but navigation or the claims screen failed, or the + TEST_CLAIM_IDS_FILE sheet could not be read + 6 eCW's 2FA screen timed out and the retries are used up + 7 pre-flight: eCW changed a screen, a selector is missing + 8 a claim stopped making progress (the page froze) — the watchdog + closed the browser and stopped the run (core/watchdog.py) +""" + +import os +import shutil +import sys +from datetime import datetime +from pathlib import Path + +from playwright.sync_api import sync_playwright + +from .. import settings as cfg +from .. import claim_file +from .. import conditions +from ..core import (guard, runlog, run_lock, msgbox, spreadsheet, + payer_groups, charge_summary, results_db) +from ..pages import preflight +from ..core.text import first_line +from ..core.browser import cdp_ready, chrome_proxy_problem, start_chrome +from ..core import run_events, watchdog +from ..auth.login import login +from ..auth.dead_end import LoginTimedOut +from ..auth.restricted import Restricted +from ..pages import (leftnav, claim_filters, claim_results, + claim_report, claim_form, sliding_fee, + dental_notes) + +CLIENT_NAME = "Salud" + +# Leave the browser open after a successful run so the next phase can be built +# against a live session instead of re-logging in on every iteration. Set +# ECW_KEEP_OPEN=0 for an unattended run: the prompt reads stdin, and a run with +# no console attached would otherwise die on EOF. +KEEP_OPEN = os.environ.get("ECW_KEEP_OPEN", "1").strip().lower() \ + not in ("0", "false", "no") + + +def _read_back_claim_ids(saved): + """ + Read the claim ids out of the file that was just saved, and show them. + Deliberately reads the **file on disk**, not the grid it came from. The + file is what the charge-entry phase will consume, so this checks the thing + that actually matters: that it parses, that the claim-id column is where it + is expected, and that the count matches the lookup. A grid read would prove + none of that. + Never fatal. The export succeeded and the file is saved; a problem reading + it back is worth reporting loudly, not worth throwing the report away over. + """ + if saved is None: + return None + try: + info = claim_file.claim_ids(saved) + except Exception as exc: + print(f"[FILE] Could not read the claim ids back: {exc}") + if cfg.SHOW_CLAIM_IDS: + msgbox.info_box( + "eCW Bot — Could not read claim ids", + f"File saved:\n {saved.name}\n\n" + f"But could not read the claim ids back:\n {exc}\n\n" + f"Open the file by hand to check.") + return None + claim_file.log_ids(info) + if cfg.SHOW_CLAIM_IDS: + msgbox.info_box("eCW Bot — Claim ids", claim_file.summary(info)) + return info + + +def _categorize_by_payer(saved): + """ + Add a Group Name column (Commercial / Medicaid / Medicare / Dentaquest / + Program / ...) next to the saved report, based on each row's PAYER, and + write it to Output\\Medcor Ready_<date>.xlsx. + + Never touches `saved` itself: claim_file.py and everything after it reads + that file's columns by position (see claim_file.py's module docstring), + so this always writes a SEPARATE file rather than editing it in place. + + Never fatal. An unmapped payer, a missing PAYER column, or a write + failure is worth reporting loudly, not worth losing the already-saved + report over — same reasoning as `_read_back_claim_ids` above. + """ + if saved is None: + return None + try: + rows = spreadsheet.read_rows(saved) + except Exception as exc: + print(f"[CATEGORIZE] Could not read {saved.name}: {exc}") + if cfg.NOTIFY_UNMAPPED_PAYERS: + msgbox.info_box( + "eCW Bot — Could not categorize", + f"Could not read {saved.name}:\n {exc}\n\n" + f"Skipping the Group Name column.") + return None + if not rows: + print(f"[CATEGORIZE] {saved.name} has no rows — skipping") + if cfg.NOTIFY_UNMAPPED_PAYERS: + msgbox.info_box( + "eCW Bot — Could not categorize", + f"{saved.name} has no rows. " + f"Skipping the Group Name column.") + return None + + header, data = rows[0], rows[1:] + if "PAYER" not in header: + print(f"[CATEGORIZE] No PAYER column in {saved.name} — skipping. " + f"Header reads: {header}") + if cfg.NOTIFY_UNMAPPED_PAYERS: + msgbox.info_box( + "eCW Bot — Could not categorize", + f"{saved.name} has no PAYER column:\n {header}\n\n" + f"Skipping the Group Name column.") + return None + payer_col = header.index("PAYER") + + try: + mapping = payer_groups.load_mapping() + except Exception as exc: + print(f"[CATEGORIZE] Could not load payer_groups.json: {exc}") + if cfg.NOTIFY_UNMAPPED_PAYERS: + msgbox.info_box( + "eCW Bot — Could not categorize", + f"Could not load payer_groups.json:\n {exc}\n\n" + f"Skipping the Group Name column. Check the file by hand.") + return None + + out_rows = [header + ["Group Name"]] + unmapped = {} + mapped_count = 0 + for row in data: + payer = row[payer_col] if payer_col < len(row) else "" + group = payer_groups.classify(payer, mapping) + if group: + mapped_count += 1 + else: + unmapped[payer] = unmapped.get(payer, 0) + 1 + out_rows.append(list(row) + [group or ""]) + + usual = cfg.ROOT / "Output" / f"Medcor Ready_{cfg.TODAY}.xlsx" + out_path = usual + try: + # A same-day re-run must not clobber this file: the operator hand-fills + # blank Group Name cells in it, and that work is not something to + # silently overwrite. Unlike Input\ (see claim_report.py's + # _archive_old()) this does not need a whole Archive\ folder — a + # same-directory rename-aside is enough. And if the earlier file is + # open in Excel (the rename is refused), this run's rows go to a + # `__HHMMSS` name next to it instead of being lost (review item D10) + # — so `out_path` is whatever the helper actually wrote. + out_path = spreadsheet.write_xlsx_keep_old(out_path, out_rows, + "[CATEGORIZE]") + except Exception as exc: + print(f"[CATEGORIZE] Could not write {out_path.name}: {exc}") + if cfg.NOTIFY_UNMAPPED_PAYERS: + msgbox.info_box( + "eCW Bot — Could not categorize", + f"Could not save {out_path.name}:\n {exc}\n\n" + f"Skipping the Group Name column.") + return None + + print(f"[CATEGORIZE] {mapped_count} of {len(data)} row(s) categorized " + f"-> {out_path.name}") + if out_path != usual and cfg.NOTIFY_UNMAPPED_PAYERS: + # The usual name could not be written, so this run's rows went to a + # `__HHMMSS` name (review item D10, fix round 1, 2026-10-05). Whoever + # has it open is at the desk and would otherwise go on filling in + # Group Names on the earlier run's sheet. + # + # The box says only what is certain: this run's sheet is NOT under + # the usual name, and where it is. It does not claim the earlier + # file was left untouched (fix round 2, 2026-10-05) — when the rename + # aside went through, the write after it failed and putting the + # earlier file back failed too, the usual name may hold a + # half-written file and the earlier sheet sits at a `__HHMMSS` + # backup. The [CATEGORIZE] log line from core/spreadsheet.py says + # which, so the box points there rather than guessing. + msgbox.info_box( + "eCW Bot — Medcor Ready saved under another name", + f"This run's categorized sheet could not be saved as " + f"{usual.name} (open in Excel or locked — see the log)." + f"\n\nThis run's categorized sheet is:\n {out_path.name}") + if unmapped: + listed = sorted(unmapped.items(), key=lambda kv: -kv[1]) + print(f"[CATEGORIZE] {sum(unmapped.values())} row(s) with no group " + f"in payer_groups.json:") + for payer, count in listed: + print(f"[CATEGORIZE] {count:4d} {payer!r}") + if cfg.NOTIFY_UNMAPPED_PAYERS: + body = (f"{sum(unmapped.values())} row(s) have no Group Name.\n\n" + f"{out_path.name} carries a blank Group Name for these — " + f"add them to payer_groups.json:\n\n" + + "\n".join(f" {p} ({n} row)" for p, n in listed[:15])) + if len(listed) > 15: + body += f"\n ... {len(listed) - 15} more" + msgbox.info_box("eCW Bot — Group Name not set", body) + + return out_path + + +def _describe_all(results: list) -> str: + """Every fired condition's own account of what it changed, one block each.""" + return "\n\n".join( + conditions.BY_NAME[r.condition_name].describe(r) for r in results) + + +def _log_before_apply(claim_id: str, fired: list) -> None: + """ + Log which conditions matched, before any of them has written anything — + from `conditions.run_all`'s own `before_apply` hook, while the claim on + screen is still exactly what `open_claim()` loaded. + + A log line only, no box (review item D9, 2026-10-05). It used to be a + message box of its own — "N condition(s) matched — about to apply" — + so a corrected claim took three OK clicks (this, the review-before-close + box, the per-claim box), up to 180 s each, in front of the client. The + names it showed are in the one review box that remains, next to what + each condition actually changed. The line is kept: it is the log's + record of what was about to run if `apply()` then raises. + + Logged whatever REVIEW_BEFORE_CLOSE says — a log line costs nothing. + """ + names = ", ".join(c.NAME for c in fired) + print(f"[COND {claim_id}] {len(fired)} condition(s) matched — about " + f"to apply: {names}") + + +RULE = "-" * 34 + + +def _claim_head(n: int, total: int, claim_id: str, r: dict) -> str: + """ + The top of a claim's one message box: where it is in the run and what + the search found. Built by `_process_claims` from the search result and + handed to `_read_claim_form`, so the review-before-close box (shown + while the form is open) and the per-claim box (shown after close) lead + with the same lines — whichever of the two the claim gets. + """ + # No Total Counts line when the search itself raised: _process_claims + # then records count "?", and D9 keeps "?" off the client's box (review + # of unit F, 2026-10-05, finding 4). The Found line says the search + # failed — not "No", which would state a fact about the queue that the + # failed search never established (final review, 2026-10-05). + count = r.get("count") + counts = ("" if count in ("?", None, "") + else f"Total Counts : {count}\n") + found = ("Yes" if r["found"] + else "Unknown — the search failed" if r.get("search_error") + else "No") + return (f"Claim {n}/{total}\n\n" + f"Claim No : {claim_id}\n" + f"Found : {found}\n" + + counts + + f"Time taken : {r['seconds']:.0f}s") + + +def _findings_lines(data: dict, outcome: dict | None) -> list: + """ + "Needs review : ..." per reason, then "Summary : <Category> — <Current + Status>": the same account the Charge Summary row gives (both come from + core/charge_summary.py), so the box can never call a claim clean that + the sheet sends to Review. + + The reasons leave out the ones a box already leads with: `_error` (its + own "Could not read this claim" line), `_search_error` (its "Could not + search this claim" line) and `_not_evaluated` (the NOT_LOADED_NOTE at + the top of the claim detail). + """ + lines = [] + if data and not data.get("_error") and not data.get("_search_error"): + skip = data.get("_not_evaluated") + lines += [f"Needs review : {why}" + for why in charge_summary.review_reasons(data) + if why != skip] + if outcome: + lines.append(f"Summary : {outcome['Category']} — " + f"{outcome['Current Status']}") + return lines + + +def _review_before_close(claim_id: str, results: list, + data: dict | None = None, + head: str | None = None) -> bool: + """ + Hold the claim form open on a message box so the operator can check what a + condition just filled in, before it is closed and the changes are gone. + + It is the claim's ONE box (review item D9, 2026-10-05). A corrected + claim used to get three — "about to apply", this one, then the + per-claim SHOW_EACH_CLAIM box after close — three OK clicks, 180 s + each, in front of the client. This is the one that matters, because the + form is still behind it: so it carries the per-claim box's header + (`head`, from `_claim_head`), what changed, any "Needs review" reasons + and the claim's Summary line, and the caller marks the data + (`_claim_box_shown`) so `_process_claims` does not show a second box. + + Only reached with `SAVE_CORRECTIONS` off. With it on, `_confirm_and_save` + has already shown a box and that box IS the pause before closing — a + second one would re-ask a decision that was just made. This is the pause + that was missing underneath it: with the save gate off, a condition filled + the form in and `_read_claim_form`'s `finally` clicked Cancel in the same + breath, so nothing stayed on screen long enough to look at. "Applied for + review" was reviewable in the log and the screenshot only — which is not + what verifying a condition against a live claim actually needs. + + Informational, never a decision. Nothing has been saved and nothing will + be; answering only lets the close proceed, and a timeout closes the claim + exactly the same way. That asymmetry is deliberate — this is the one path + where a stray click must not be able to write to a live claim, which is + the whole reason `SAVE_CORRECTIONS` exists (claim 9103000). + + The box is app-modal to this process, not to Chrome, so the form behind it + stays scrollable and inspectable the whole time it is up. + + Returns whether a box was actually shown, so a caller can tell "checked" + apart from "nothing fired" — and, since D9, whether the claim has had + its one box already. + + `data` is the claim as read so far (None or missing keys are fine: the + box then just has fewer lines). The Summary line is computed here from + it with the same `charge_summary.claim_outcome` the sheet uses; by the + time this runs `_read_claim_form` has stored `_conditions` on it. + """ + dental = is_dental_read(data) + if not results or not cfg.REVIEW_BEFORE_CLOSE or \ + (cfg.SAVE_CORRECTIONS and not dental): + return False + + print(f"[COND {claim_id}] Holding the form open for review before close") + # The header sits above a rule, then the box's own text directly under + # it — the same layout as the per-claim box (`_claim_box`). + parts = [(f"{head}\n\n{RULE}\n" if head else "") + + f"{len(results)} condition(s) filled in this form — it's " + f"still behind this box, scroll to look."] + parts.append(_describe_all(results)) + if data and data.get("_error"): + # Only when something raised AFTER the conditions ran: the changes + # above are on the form, but the rest of the read did not finish. + parts.append(_error_line(data)) + findings = _findings_lines( + data, charge_summary.claim_outcome(True, data) if data else None) + if findings: + parts.append("\n".join(findings)) + why = ("Dental claims are never saved" if dental + else "SAVE_CORRECTIONS=0") + parts.append( + f"Nothing is saved, and nothing will be ({why}). " + f"Click OK to close the claim with Cancel.\n" + f"If nothing is clicked, this will close automatically after " + f"{cfg.MSGBOX_TIMEOUT_SECONDS}s.") + msgbox.info_box(f"eCW Bot — Claim {claim_id}: review before close", + "\n\n".join(parts)) + return True + + +def is_dental_read(data) -> bool: + """A Dental Claim screen read that went through the conditions.""" + return isinstance(data, dict) and data.get("screen") == "dental" + + +def _taint_reasons(report: dict | None) -> list: + """ + Why this claim must not be saved, from `run_all`'s report: every + condition that raised (in detect, apply or its needs_review hook) and + every review reason. Empty = nothing stands in the way. + """ + report = report or {} + return ([f"{f['name']} failed ({f['stage']}): {f['error']}" + for f in report.get("failed") or []] + + [f"{r['name']}: {r['reason']}" + for r in report.get("review") or []]) + + +def _claim_taint(data: dict | None) -> list: + """ + The claim-level reasons this claim must not be saved: everything + `charge_summary.review_reasons` says about the claim itself — a progress + note partial, unread or errored, OPEN_PROGRESS_NOTES off, zero CPT rows + read, a form that never finished loading. The conditions' own failed / + review entries are left out here (`_taint_reasons` already words those + from the report), so no reason is listed twice. + + Final review, 2026-10-05: the gate read only run_all's report, so a + claim the Charge Summary itself sends to Review could still be offered + for Save. A partial note is the sharp case — "THV Audio Only ... No + video" in a section that never arrived, the mode falls back to video, + and one OK would write 95 instead of 93 to live eCW. + """ + if not data: + return [] + return charge_summary.review_reasons({**data, "_conditions": {}}) + + +def _confirm_and_save(page, claim_id: str, results: list, + report: dict | None = None, + data: dict | None = None) -> bool: + """ + Show one combined confirmation for every condition that fired on this + claim, then save once if approved. + + One dialog, not one per condition: if two conditions both want changes + on the same claim, saving after only reading half the operator's + decision is exactly the half-applied state the Condition Framework + exists to prevent (see docs\\superpowers\\specs\\ + 2026-09-16-condition-framework-design.md). The operator sees every + condition's fields before any single Save/Cancel decision. + + A tainted claim is never offered at all (review item P2, 2026-10-05): + `report` is `run_all`'s, and any `failed` or `review` entry in it means + the form is not what the box would describe. A condition that raised + half-way through `apply()` leaves its edits on the form with no result + to list them, and Save (`saveAllData`) writes the whole form — so the + box would ask the operator to approve changes it does not show. A + review entry means a human has to look at the claim first anyway. + + Nor is a claim the Charge Summary will send to Review for reasons of its + own (`data`, see `_claim_taint`): the sheet and the save box must not + disagree about whether the claim was fully checked. + + Returns True only if the claim was actually saved. On every other + path — nothing fired, `SAVE_CORRECTIONS` is off, the claim is tainted, + the operator declines, or the save itself fails — the caller must still + close with Cancel, so this never touches the form's own Cancel/OK + buttons itself. + """ + if not results: + return False + + body = _describe_all(results) + + if is_dental_read(data): + # Operator, 2026-10-12: the Dental grid's row writes have not been + # through a live verification run yet, so a Dental claim is never + # saved, whatever SAVE_CORRECTIONS says. Its changes are listed as + # "Corrections pending (not saved)". + print(f"[COND {claim_id}] Dental claim — never saved; applied for " + f"review, closing with Cancel:\n{body}") + return False + + if not cfg.SAVE_CORRECTIONS: + print(f"[COND {claim_id}] SAVE_CORRECTIONS is off — applied for " + f"review, closing with Cancel instead:\n{body}") + return False + + tainted = _taint_reasons(report) + _claim_taint(data) + if tainted: + print(f"[COND {claim_id}] Not offering Save: {'; '.join(tainted)} " + f"— closing with Cancel") + return False + + # default_cancel: Enter/Space answers Cancel here. OK writes this claim + # to live eCW, so only a deliberate click on OK may mean "save". + proceed = msgbox.ok_cancel( + f"eCW Bot — Claim {claim_id}: {len(results)} condition(s) fired", + f"{body}\n\nSave this? Cancel discards the typed changes (POS, " + f"modifiers, fees, pointers). Box ticks and added CPT rows were " + f"already saved by eCW when they were made — Cancel does not undo " + f"those.", + default_cancel=True) + if not proceed: + print(f"[COND {claim_id}] Operator declined — closing with " + f"Cancel, nothing saved") + return False + + saved = claim_form.save_claim(page) + if saved: + names = ", ".join(r.condition_name for r in results) + print(f"[COND {claim_id}] Saved: {names}") + else: + print(f"[COND {claim_id}] Confirmed but save failed — closing " + f"with Cancel instead") + # save_claim() also answers False when OK WAS clicked and the form + # just did not hide within its wait — eCW may have written the + # claim. "Corrections pending (not saved)" would then be a guess + # stated as fact, so the claim goes to Review saying the save is + # unconfirmed (final review, 2026-10-05). + cond = data.get("_conditions") if isinstance(data, dict) else None + if isinstance(cond, dict): + cond["save_unconfirmed"] = True + return saved + + +def _dental_checks(page, claim_id: str) -> dict: + """ + The Dental Claim screen's read-only checks. Dental claims only — + UB/HCFA claims never run these. Since 2026-10-12 the claim goes on to + the same read and conditions as UB/HCFA afterwards (`_read_claim_form`). + + * #15 HIGH RISK (CHECK_HIGH_RISK): "high risk" in the Billing Notes + means the claim goes through the DentaQuest web portal. + * #8 sliding fee (CHECK_SLIDING_FEE): before DCONS/Dscprit go to the + patient, is their sliding fee valid on the DOS? + + High risk is read first, while the claim screen is still on top; the + sliding fee check opens Patient Info over it and closes it again. + Nothing is changed in eCW. + + Shows NO box of its own (review items D9/P6, 2026-10-05). It used to + pop one per Dental claim with every review flag off — the one box an + unattended run could not turn off — followed by the per-claim box, + whose `claim_form.summary()` printed a page of "?" for a Dental read. + The results now go in that per-claim box instead (`_dental_detail`), + gated by SHOW_EACH_CLAIM like every other claim's, and to the Charge + Summary. + """ + data = {"claim_no": claim_id} + if cfg.CHECK_HIGH_RISK: + data["high_risk"] = dental_notes.check(page, claim_id) + if cfg.CHECK_SLIDING_FEE: + data["sliding_fee"] = sliding_fee.check(page, claim_id, {}) + if not charge_summary.is_dental(data): + print(f"[FORM {claim_id}] Dental Claim screen — no Dental check " + f"enabled; reading it for the conditions") + return {} + print(f"[FORM {claim_id}] Dental Claim screen — Dental checks done; " + f"reading it for the conditions") + return data + + +def _dental_detail(data: dict) -> str: + """ + A Dental claim's part of its per-claim box: the HIGH RISK and sliding + fee results, each module's own wording, then the read-only line. + + Leads with `NOT_LOADED_NOTE` when the form never finished loading, + because a "not high risk" read off blank Billing Notes is not a result + (review of unit C, 2026-10-05). + """ + parts = [] + if "high_risk" in data: + parts.append(dental_notes.message(data["high_risk"])) + if "sliding_fee" in data: + parts.append(sliding_fee.message(data["sliding_fee"])) + lead = (f"{claim_form.NOT_LOADED_NOTE}\n({data['_not_evaluated']})\n\n" + if data.get("_not_evaluated") else "") + tail = ("Read-only — nothing was changed in eCW." if not + (data.get("_conditions") or {}).get("fired") else + "Dental claims are never saved — the form was closed with " + "Cancel; the changes below are for a person to make.") + return lead + f"\n\n{RULE}\n".join(parts) + "\n\n" + tail + + +def _conditions_line(data: dict) -> list: + """ + "Changed : <names>" — what the conditions changed on this claim, or + that nothing needed changing. Empty when the conditions never ran + (`_conditions` absent; the Needs review lines say why). + + Was "Conditions : none matched", which read wrong right after a box had + said a condition was about to apply: it meant "none changed anything" + (review item D9). "nothing needed changing" is only said of a claim + every condition actually finished on: with a condition that raised or + declined it would call a claim clean whose check never completed. Then + there is no Changed line at all, the two forms the brief allows being + the only ones, and the Needs review lines under it say what was not + checked (review of unit F, 2026-10-05, finding 2: an earlier third + form, a bare "Changed : nothing", was dropped). + + The same holds for EVERY review reason, not only the conditions' own + (final review, 2026-10-05): a claim whose progress note was never read, + or whose CPT grid read 0 rows, was told "nothing needed changing" right + above "Needs review : Progress note not read". Any review reason at + all — `charge_summary.review_reasons`, the sheet's own list — means + the claim was not fully checked, so it gets no such line. + """ + cond = data.get("_conditions") if data else None + if cond is None: + return [] + fired = cond.get("fired") or [] + if fired: + return [f"Changed : {', '.join(fired)}"] + if charge_summary.review_reasons(data): + return [] + return ["Changed : nothing needed changing"] + + +def _error_line(data: dict) -> str: + """ + The box line for a claim that raised: "Could not read this claim" when + the read itself failed, "The bot failed after the conditions ran" when + it raised later — the conditions had run and changed the form, so + "could not read" would misstate what happened (final review, + 2026-10-05). `_read_claim_form` keeps `_conditions` on the error dict + in the second case. + """ + if data.get("_conditions") is not None: + return f"The bot failed after the conditions ran: {data['_error']}" + return f"Could not read this claim: {data['_error']}" + + +def _claim_box(head: str, claim_id: str, data: dict, + outcome: dict | None) -> tuple: + """ + (title, body) of the per-claim SHOW_EACH_CLAIM box, shown after close. + + Pure, so every claim type's box is testable without a page: + + * not found / never opened ({}) -> the header only + * could not be read (`_error`) -> "Could not read this claim: ..." + — never `claim_form.summary()`, which would print a form of blanks + that looks like a real read + * Dental -> the Dental results and the + read-only line (`_dental_detail`), never `claim_form.summary()`, + which has none of a Dental read's fields (review item D9) + * UB / HCFA -> `claim_form.summary(data)` + + then, under a rule, "Changed", the "Needs review" reasons and the + Summary line. The form detail goes under its own rule, so the search + result and what the claim actually says stay visually separate. + """ + title = f"eCW Bot — Claim {claim_id}" + if not data: + return title, head + if data.get("_search_error"): + detail = f"Could not search this claim: {data['_search_error']}" + elif data.get("_error"): + detail = _error_line(data) + elif charge_summary.is_dental(data): + detail = _dental_detail(data) + tags = [] + if (data.get("high_risk") or {}).get("high_risk"): + tags.append("HIGH RISK") + if "sliding_fee" in data: + verdict = (data["sliding_fee"] or {}).get("verdict") + if verdict: + tags.append(f"sliding {verdict.upper()}") + title = (f"eCW Bot — Dental claim {claim_id}" + + (f": {', '.join(tags)}" if tags else "")) + else: + detail = claim_form.summary(data) + body = f"{head}\n\n{RULE}\n{detail}" + tail = _conditions_line(data) + _findings_lines(data, outcome) + if tail: + body += f"\n\n{RULE}\n" + "\n".join(tail) + return title, body + + +def _read_claim_form(page, claim_id: str, head: str | None = None) -> dict: + """ + Open one claim's UB form, click through it, read it, close it. + + Read-only by default — the form closes with Cancel, never OK, because OK + is `saveAllData(true)` and would write every claim back to eCW. The one + exception is the Condition Framework (`conditions.run_all` + + `_confirm_and_save`), gated behind `APPLY_CORRECTIONS` and an explicit + per-claim confirmation: when it actually saves the claim, the form is + already closed by the time this function's `finally` runs, so Cancel + must not be clicked again on top of it. + + Never raises. A claim whose form will not open or read is a fact to record + and move past: stopping would mean the rest of the queue never gets looked + at, and the expensive part of the run — the login — is already spent. + + That fact is RETURNED, not swallowed (review item D2, 2026-10-05): + `{"claim_no", "_error": <first line>}`, which `_process_claims` counts + as not read and the Charge Summary reports as "Bot could not read the + claim". It used to return `{}` — the same blank row as "not found". + `{}` is still returned when the form is never opened on purpose + (OPEN_CLAIM_FORM off). A Dental claim is read like any other since + 2026-10-12, its high risk / sliding fee results merged into the read, + `screen` "dental" — and it is never saved (`_confirm_and_save`). + + `data["_conditions"]` is `run_all`'s whole account, not only what + changed: "fired" (the conditions that changed something — its meaning + since the start), "saved", and "failed" / "review" / "comments" from + the report (see conditions/base.py). The summary reads all of it, and + `_confirm_and_save` refuses to offer Save while "failed" or "review" + has an entry. + + `head` is `_claim_head()`'s per-claim header, for the review-before-close + box. When that box is shown, the returned data carries + `_claim_box_shown: True` and `_process_claims` shows no per-claim box + for the claim: one box per claim (review item D9, 2026-10-05). The flag + is set after `claim_form.save(data)`, so it never reaches the claim's + JSON snapshot. + """ + if not cfg.OPEN_CLAIM_FORM: + return {} + opened = False + saved_write = False + # Hoisted out of the `if APPLY_CORRECTIONS` block below so the `finally` + # can see what fired: the review box belongs at closing time, not where + # the conditions run, because closing is the moment the changes go away. + results = [] + # Hoisted too, and every path below that opened the form returns THIS + # name (the Dental result and the error dict are assigned to it, not + # returned as literals): the `finally` marks the very dict the caller + # gets back with `_claim_box_shown`. A `finally` runs after the return + # value is chosen but before the caller sees it, so mutating that + # object in place is what reaches the caller. (The OPEN_CLAIM_FORM-off + # `return {}` above never opens a form, so it has no box to mark.) + data = None + try: + try: + claim_form.open_claim(page, claim_id) + except Exception: + # open_claim() can raise with a form ON SCREEN — its UB + # wrong-claim check runs after the form opened — and that form + # must be Cancelled like any other (final review, 2026-10-05). + # Left open, it is the next claim's problem: the fallback after + # a failed click accepts ANY open claim form as the new one. A + # page error while asking is treated as "maybe open": Cancel on + # a closed form is a no-op (close_claim checks first). + try: + opened = claim_form.any_claim_form_open(page) + except Exception: + opened = True + raise + opened = True + dental_screen = claim_form.is_dental_claim_form(page) + # HCFA and Dental: open_claim() returns as soon as Cancel shows, + # before Angular has filled the form (review item D7 — a live claim + # read 0 CPT rows and went out as Pass). Waited for here rather than + # inside open_claim(), so open_claim() keeps its "open, or raise" + # contract and the reason lands on the claim's data, where the + # summary reads it. None = loaded. A wrong claim raises and is + # closed by `finally`. On UB an instant check, since open_claim() + # already waited up to 60s for the UB claim number. + not_loaded = claim_form.wait_claim_loaded(page, claim_id) + # eCW can open the same claim twice (live run 2026-10-12): the copy + # on top intercepts every click on the one the bot reads. Checked + # here and again before Prog. Notes — the copy came 648 ms later. + claim_form.close_duplicate_forms(page, claim_id) + # A Dental claim runs its own read-only checks first (#15 high risk, + # #8 sliding fee), then the same read and conditions as UB/HCFA: + # every "All payers" condition plus its payer's own (operator, + # 2026-10-07 / 2026-10-12). It used to stop after its own checks. + dental = (_dental_checks(page, claim_id) + if dental_screen and (cfg.CHECK_HIGH_RISK + or cfg.CHECK_SLIDING_FEE) else {}) + claim_form.open_cpt_hcpcs_tab(page) + # First claim of each screen type: the form's selectors (read-only). + # A missing one raises PreflightFailed past the except below. + preflight.check_form(page) + watchdog.beat(f"claim {claim_id}: read the form") + data = claim_form.extract(page) + if dental_screen: + data["screen"] = "dental" + data.update({k: v for k, v in dental.items() if k != "claim_no"}) + if not_loaded: + # Before save(), on purpose: the claim's JSON snapshot should say + # it was taken from a form that never finished loading. + data["_not_evaluated"] = not_loaded + if cfg.OPEN_PROGRESS_NOTES and not_loaded: + # Not opened on a form that never finished loading (final + # review, 2026-10-05): the conditions are skipped for this claim + # below and it is Review whatever the note says, so the up to + # ~45s the note can cost (10s for an absent button, 5s for a + # disabled one, 20s of polling + close for "nothing") bought + # nothing. The snapshot still says why. + print(f"[NOTE] Not opened — the form did not finish loading") + data["progress_notes"] = {"opened": "skipped", + "reason": "form did not finish loading"} + elif cfg.OPEN_PROGRESS_NOTES: + claim_form.close_duplicate_forms(page, claim_id) + watchdog.beat(f"claim {claim_id}: progress note") + data["progress_notes"] = claim_form.open_progress_notes(page) + # extract() only finds the claim number on the UB form; without this + # every HCFA claim overwrote the same Output\claims\unknown.json. + if not data.get("claim_no"): + data["claim_no"] = claim_id + target = claim_form.save(data) + print(f"[FORM {claim_id}] Read {len(data.get('cpt') or [])} CPT, " + f"{len(data.get('icd') or [])} ICD, " + f"{len(data.get('revenue') or [])} revenue line(s) " + f"-> {target.name}") + if cfg.APPLY_CORRECTIONS and data.get("_not_evaluated"): + # A form the bot has just logged as half-loaded is not corrected: + # the conditions would read the same blank rows extract() did, + # and with SAVE_CORRECTIONS=1 an approved OK (saveAllData) would + # write that half-loaded form back to live eCW. D7 asks for such + # a claim to be read and closed normally — so it is read, marked + # and closed with Cancel. No `_conditions` is stored: nothing + # ran, and `_not_evaluated` already says why (review of unit C, + # 2026-10-05). + print(f"[COND {claim_id}] Form did not finish loading — " + f"conditions not run") + elif cfg.APPLY_CORRECTIONS: + report = {} + watchdog.beat(f"claim {claim_id}: conditions") + results = conditions.run_all( + page, claim_id, data, + before_apply=lambda fired: _log_before_apply(claim_id, fired), + report=report) + # Stashed after claim_form.save(data) above, so it never leaks + # into the claim's own saved JSON snapshot — this is for the + # SHOW_EACH_CLAIM message box in _process_claims and for the + # Charge Summary (charge_summary.claim_outcome), which must + # tell a condition that raised or declined apart from a clean + # claim (review item D2). + # + # Stored BEFORE _confirm_and_save, not after (final review, + # 2026-10-05): anything raising in there (a describe() meeting + # a change shape it did not expect) used to replace `data` with + # the bare error dict and lose the account of what the + # conditions had already changed on the form. "saved" is filled + # in once the save decision is made. + data["_conditions"] = { + "fired": [r.condition_name for r in results], + # For the results database's details: every condition whose + # detect() fired, and of those the ones that changed the + # form (`fired` above is the same list; the Charge Summary + # reads that one). + "detected": report.get("fired") or [], + "changed": [r.condition_name for r in results if r.changed], + "saved": False, + # .get(): run_all always fills these, but a stand-in for it + # (tests) may not — a missing key is "nothing reported". + "failed": report.get("failed") or [], + "review": report.get("review") or [], + "comments": report.get("comments") or {}, + # What each condition changed, row by row — kept for the + # results database's details (core/results_db.py). + "changes": {r.condition_name: r.changes for r in results}, + } + saved_write = _confirm_and_save(page, claim_id, results, report, + data) + data["_conditions"]["saved"] = saved_write + return data + except preflight.PreflightFailed: + # Not this claim's problem but every claim's: the run stops (the + # form is still closed by `finally`). + raise + except Exception as exc: + error = first_line(exc) + print(f"[FORM {claim_id}] Could not read the form: {exc}") + ran = data.get("_conditions") if isinstance(data, dict) else None + data = {"claim_no": claim_id, "_error": error} + if ran is not None: + # The conditions had run (and may have changed the form): keep + # their account, so the summary and the box say "failed after + # the conditions ran", not "could not read" (final review, + # 2026-10-05). + data["_conditions"] = ran + return data + finally: + if opened and not saved_write: + # Before close_claim: the box is only worth anything while the + # form it describes is still on screen behind it. + try: + if _review_before_close(claim_id, results, data, head) \ + and data is not None: + data["_claim_box_shown"] = True + except Exception as exc: + # The box builds its body from each condition's describe(), + # review_reasons and claim_outcome; any of them meeting a + # shape it did not expect must not skip close_claim below. + # That would leave the form open with the applied edits on + # it and, since _process_claims does not catch, end the run + # with no Charge Summary, breaking "Never raises" above + # (review of unit F, 2026-10-05, finding 3). + # `_claim_box_shown` stays unset, so the claim still gets + # its per-claim box after close. + print(f"[COND {claim_id}] review box failed: " + f"{first_line(exc)}") + # A second copy left on top would take the Cancel below and + # leave this claim's form open for the next claim. + claim_form.close_duplicate_forms(page, claim_id) + watchdog.beat(f"claim {claim_id}: close the form") + if not claim_form.close_claim(page): + # Not ignored any more (final review, 2026-10-05): the next + # claim's open_claim() Cancels a form it finds still open + # before its own click, and refuses to go on over one that + # will not close — so it is never read as that claim's. + print(f"[FORM {claim_id}] The claim form did not close — the " + f"next claim will try Cancel again before its click") + run_events.warning("form_not_closed", + "The claim form did not close", + claim=str(claim_id)) + + +def _claim_events(n, claim_id, r, data, outcome) -> None: + """One claim's events for the Runs page: its result, and a Claim Error + (search raised / form not read / never finished loading) or the + not-found Warning. The Charge Summary is not touched: a Claim Error is + still Review there.""" + claim = str(claim_id) + run_events.emit("claim_result", n=n, claim=claim, + category=(outcome or {}).get("Category") or "Not found") + data = data or {} + if r.get("search_error"): + run_events.claim_error(claim, "search", r["search_error"]) + elif not r.get("found"): + run_events.warning("not_found", "Claim not found in eCW", claim=claim) + if data.get("_error"): + run_events.claim_error(claim, "form", data["_error"]) + if data.get("_not_evaluated"): + run_events.claim_error(claim, "load", str(data["_not_evaluated"])) + + +def _process_claims(page, info) -> None: + """`_check_claims` under the claim watchdog (core/watchdog.py): a + claim that stops making progress ends the Run with exit 8 and a record, + instead of hanging it for good (2026-10-22: 28 minutes on one claim).""" + with watchdog.watching(watchdog.limit_seconds()): + _check_claims(page, info) + + +def _check_claims(page, info) -> None: + """ + Search each claim id on its own, one at a time. + + The first step of the charge-entry phase: prove the bot can put a specific + claim in front of itself. Nothing is changed in eCW here — it types a claim + id, presses Lookup, and confirms the claim came back. + + Bounded by `CLAIM_PROCESS_LIMIT`, default 1. 541 ids at one server-side + lookup each is a run measured in hours, and that is not something to + stumble into while testing. + + A claim that is not found does not stop the loop. It is a fact about the + queue — the report was exported minutes ago and a claim can leave it — and + stopping at the first one would mean the other 540 never get looked at. + """ + ids = info["ids"] + if not ids: + return + limit = cfg.CLAIM_PROCESS_LIMIT or len(ids) + todo = ids[:limit] + if limit < len(ids): + print(f"[CLAIMS] Processing {len(todo)} of {len(ids)} claim ids " + f"(CLAIM_PROCESS_LIMIT={cfg.CLAIM_PROCESS_LIMIT}; 0 = all)") + # The whole queue up front, for the results page's Claim status panel + # (operator, 2026-10-15): it lists the claims still waiting, which no + # other line names until their turn comes. + print(f"[CLAIMS] Queue: {', '.join(str(c) for c in todo)}") + run_events.emit("queue", claims=[str(c) for c in todo]) + if cfg.RESULTS_DB: + # The run every claim's results row is saved under — the page ties + # its Claim status panel to that run, not to whichever run its table + # shows (review, 2026-10-19). The same value _save_results_db uses. + run_at = info.setdefault("_run_at", + datetime.now().replace(microsecond=0)) + print(f"[CLAIMS] Results run_at: {run_at.isoformat()}") + + # Selectors first (2026-10-21): a changed search page stops the run + # here, named, instead of failing every claim's search one by one. + preflight.reset() + preflight.check_search(page) + + results = [] + outcomes = {} + details = {} + # The tabs open now are the bot's own; any opened later (a note reader) + # is closed between claims. + try: + keep_pages = list(page.context.pages) + except Exception: + keep_pages = [] + for n, claim_id in enumerate(todo, start=1): + # A claim that froze the page stops the run here, before the next + # one: the rest of the queue is Not Reached, not 40 failed searches. + watchdog.check() + print(f"[CLAIMS] --- claim {n}/{len(todo)}: {claim_id} ---") + watchdog.beat(f"claim {n}/{len(todo)} {claim_id}: search") + run_events.emit("claim_start", n=n, total=len(todo), + claim=str(claim_id)) + # Every claim starts from a clean search page (operator, + # 2026-10-12): what the last claim left open is closed first, and + # search_claim() checks Claim No and Lookup are clear before it + # types. Live run that day: claim 1's viewer stayed open and claims + # 2 and 3 were never searched. A page that will not come clean + # makes this claim a failed search — Review — and the next claim + # clears and checks again. + claim_form.clear_screen(page, claim_id, keep_pages) + try: + r = claim_results.search_claim(page, claim_id) + except Exception as exc: + # One bad claim must not cost the other 540. + print(f"[CLAIM {claim_id}] Failed: {exc}") + # Not "not found" (final review, 2026-10-05): that is a fact + # about the queue, and a search that raised (browser gone, a + # timeout) says nothing about the queue. It is counted apart + # and its row is Review, not left blank like a missing claim. + r = {"id": claim_id, "count": "?", "found": False, "seconds": 0, + "search_error": first_line(exc)} + # Built before the read, not after: the review-before-close box is + # shown while the form is still open and leads with it too. + head = _claim_head(n, len(todo), claim_id, r) + if r["found"]: + watchdog.beat(f"claim {n}/{len(todo)} {claim_id}: open the form") + data = _read_claim_form(page, claim_id, head) + elif r.get("search_error"): + data = {"claim_no": claim_id, + "_search_error": r["search_error"]} + else: + data = {} + # `_error` = the form was opened (or tried) and could not be read: + # not "read", whatever the dict holds (review item D2). + error = data.get("_error") if data else None + r["read"] = r["found"] and bool(data) and not error + r["error"] = error + results.append(r) + outcome = charge_summary.claim_outcome(r["found"], data) + outcomes[claim_id] = outcome + # One fixed-format line per claim for the results page's live + # status (web/run_status.py, 2026-10-14). {} = no Charge Summary + # row: the claim was not found in the grid. + print(f"[RESULT] claim {n}/{len(todo)} {claim_id}: " + f"{(outcome or {}).get('Category') or 'Not found'}") + _claim_events(n, claim_id, r, data, outcome) + details[claim_id] = results_db.details_for(data) + # The summary grows claim by claim (operator, 2026-10-12): this + # claim's row is in the Excel sheet and the database before the + # next claim starts, so a run that stops halfway keeps what it did. + # Before the per-claim box below, which can wait on the operator. + _write_charge_summary(info, outcomes) + if outcome: + # Only this claim's row: the earlier ones are saved already. + _save_results_db(info, {claim_id: outcome}, + {claim_id: details[claim_id]}) + # One box per claim (review item D9, 2026-10-05). A claim whose + # review-before-close box was shown has had it — that box carried + # this header, what changed and the Summary line, with the form + # still behind it — so it gets no second box here. Read off the + # flag `_read_claim_form` set, not re-derived from the conditions: + # whether that box appeared depends on REVIEW_BEFORE_CLOSE and + # SAVE_CORRECTIONS too, and only the code that showed it knows. + if data and data.get("_claim_box_shown"): + print(f"[CLAIMS] --- claim {n}/{len(todo)} done (shown in its " + f"review box), moving to next ---") + elif cfg.SHOW_EACH_CLAIM: + msgbox.info_box(*_claim_box(head, claim_id, data, outcome)) + print(f"[CLAIMS] --- claim {n}/{len(todo)} done, moving to next ---") + if todo: + # The last claim's leftovers too: the run ends on a clean page. + watchdog.check() + watchdog.beat("clear the page after the last claim") + claim_form.clear_screen(page, todo[-1], keep_pages) + found = [r for r in results if r["found"]] + failed_search = [r for r in results if r.get("search_error")] + missed = [r for r in results + if not r["found"] and not r.get("search_error")] + read = [r for r in results if r.get("read")] + unread = [r for r in results if r.get("error")] + print(f"[CLAIMS] Searched {len(results)} claim(s): " + f"{len(found)} found, {len(missed)} not found, " + + (f"{len(failed_search)} search(es) failed, " + if failed_search else "") + + f"{len(read)} form(s) read" + + (f", {len(unread)} could not be read" if unread else "")) + if failed_search: + print(f"[CLAIMS] Search failed: " + f"{', '.join(r['id'] for r in failed_search)}") + if unread: + print(f"[CLAIMS] Could not read: " + f"{', '.join(r['id'] for r in unread)}") + if missed: + print(f"[CLAIMS] Not found: {', '.join(r['id'] for r in missed)}") + + if cfg.SHOW_CLAIM_IDS: + body = (f"Total searched : {len(results)}\n" + f"Found : {len(found)}\n" + f"Not found : {len(missed)}\n" + f"Forms read : {len(read)}") + if failed_search: + body += (f"\nSearch failed : {len(failed_search)} (Review in " + f"the charge summary)") + if unread: + body += (f"\nCould not read : {len(unread)} (Review in the " + f"charge summary)") + if missed: + body += ("\n\nNot-found claim ids:\n " + + "\n ".join(r["id"] for r in missed[:10])) + if len(missed) > 10: + body += f"\n ... {len(missed) - 10} more" + if len(results) < len(info["ids"]): + body += (f"\n\nOnly {len(results)} of {len(info['ids'])} ids were " + f"processed. Set CLAIM_PROCESS_LIMIT=0 in .env to do " + f"all of them.") + msgbox.info_box("eCW Bot — Claim search finished", body) + + # A leftover claim id in the box would narrow the next whole-queue lookup + # to one claim, and the run after this would report 1 row where it meant + # hundreds. + claim_filters.clear_claim_no(page) + + +def _run_mode() -> str: + """ + The run's mode in the [MODE] banner's words: LIVE-WRITE / VERIFY, led + by "TEST " for a TEST_CLAIM_IDS_FILE run — a test run can still write + to eCW, and "TEST" alone hid that (review, 2026-10-12). + """ + mode = "LIVE-WRITE" if cfg.SAVE_CORRECTIONS else "VERIFY" + return f"TEST {mode}" if cfg.TEST_CLAIM_IDS_FILE else mode + + +def _save_results_db(info, outcomes: dict, details: dict) -> None: + """ + The same Charge Summary rows, saved to SQL Server for the results web + page — core/results_db.py. Only with RESULTS_DB=1, and never fatal: the + claims and the Excel summary are already done, so a failure here is one + [RESULTS-DB] log line. + + Called after every claim with that claim's outcome only. Every call of + one run saves the same `run_at` (kept on `info`), so the page shows the + run as one, however long it takes. + """ + if not cfg.RESULTS_DB: + return + run_at = info.setdefault("_run_at", + datetime.now().replace(microsecond=0)) + try: + sheet = charge_summary.build(info["file"], outcomes, + cfg.SUMMARY_ASSIGNED_TO) + except Exception as exc: + print(f"[RESULTS-DB] Could not build the summary rows: " + f"{first_line(exc)} — nothing saved to the database") + return + results_db.save_summary(sheet, details, + run_at=run_at, + run_mode=_run_mode(), + conn_str=cfg.RESULTS_DB_CONN, + started_by=run_events.started_by()) + + +def _write_charge_summary(info, outcomes: dict) -> None: + """ + Output\\Charge Summary_<date>.xlsx for this run — see + core/charge_summary.py. Never fatal: the claims are already processed, + and a failed write is a log line and a box, not a lost run. + + A TEST_CLAIM_IDS_FILE run writes Charge Summary_TEST_<date>.xlsx instead + (review item D1), so a test run never takes the live summary's name. The + log line names the file actually written: if the usual one is open in + Excel, the sheet lands under a `__HHMMSS` name next to it. + + That case also gets a box (review item D10, fix round 1, 2026-10-05): + the person with the old file open in Excel is the one at the desk, and + the log line alone would not stop them presenting that stale sheet. + + Called after every claim (operator, 2026-10-12). The first call keeps + an earlier same-day file aside as above; the file it wrote is kept on + `info` and every later claim rewrites that same file (`into`). Each box + shows once per run, not once per claim: a box for a new name only when + the sheet moved to one, an error box only for the first failure. + """ + if not cfg.WRITE_CHARGE_SUMMARY: + return + test_mode = bool(cfg.TEST_CLAIM_IDS_FILE) + into = info.get("_summary_path") + try: + path = charge_summary.write(info["file"], outcomes, cfg.ROOT / "Output", + cfg.TODAY, cfg.SUMMARY_ASSIGNED_TO, + test_mode=test_mode, into=into) + except Exception as exc: + print(f"[SUMMARY] Could not write the charge summary: {exc}") + if not info.get("_summary_error_shown"): + info["_summary_error_shown"] = True + msgbox.info_box("eCW Bot — Could not write the charge summary", + f"{exc}") + return + info["_summary_path"] = path + done = sum(1 for o in outcomes.values() if o) + print(f"[SUMMARY] {done} claim(s) filled in -> {path.name}") + usual = charge_summary.file_name(cfg.TODAY, test_mode) + if path.name != usual and path != into: + # Only what is certain goes in the box (fix round 2, 2026-10-05): + # this run's sheet is not under the usual name, and where it is. It + # does not claim the earlier file was left as it was — if the rename + # aside went through, the write after it failed and the move back + # failed too, the usual name may hold a half-written file and the + # earlier sheet is at a `__HHMMSS` backup; the [SUMMARY] log line + # from core/spreadsheet.py says which, so the box points there. + msgbox.info_box( + "eCW Bot — Charge summary saved under another name", + f"This run's charge summary could not be saved as {usual} " + f"(open in Excel or locked — see the log).\n\n" + f"This run's charge summary is:\n {path.name}") + + +def _claims_phase(page) -> None: + """Everything on claimLookup.jsp, in the one order that works.""" + leftnav.open_claims(page) + print(f"[OK] On: {page.url}") + claim_filters.set_claim_status(page) # FIRST — see the module docstring + claim_filters.set_date_type(page) + claim_filters.set_date_range(page) + claim_filters.clear_balance(page) + + if cfg.TEST_CLAIM_IDS_FILE: + _process_test_claim_ids_file(page) + return + + claim_results.run_lookup(page) + saved = claim_report.export_claims_report(page) + info = _read_back_claim_ids(saved) + _categorize_by_payer(saved) + if info: + _process_claims(page, info) + + +def _process_test_claim_ids_file(page) -> None: + """ + TEMPORARY testing override (`TEST_CLAIM_IDS_FILE`, see settings.py): + skip the live bulk lookup and export, and process exactly the claim + ids in a fixed, hand-maintained file instead. + + Built for a "standard" sheet listing one known claim per implemented + condition: a run against it exercises every condition against a real + claim in one short pass, without waiting on the full live-queue's bulk + search and export — so a new condition, or a regression in an old one, + shows up in minutes. The claim-status/date/balance filters have + already been set by `_claims_phase` above, same as the live path — + only the *source of the id list* changes here, nothing about how each + one is searched, opened, or fixed. + + Two things set a test run apart from a live one (review item D1): it + says so in the log right here, and its Charge Summary goes to the + `_TEST_` file, never the live summary's name. + + A sheet that cannot be read RAISES (after the log line and the box): + `main()` turns that into exit code 5. It used to return quietly, so a + run that processed nothing at all exited 0 — "done" — after a login. + A missing path never gets this far: `settings.validate()` stops it with + exit 2 before login. + """ + path = Path(cfg.TEST_CLAIM_IDS_FILE) + print(f"[FILE] TEST_CLAIM_IDS_FILE set — skipping the live bulk search " + f"and export, reading claim ids from {path}") + summary = ("the charge summary goes to " + f"{charge_summary.file_name(cfg.TODAY, test_mode=True)}, not " + f"the live {charge_summary.file_name(cfg.TODAY)}" + if cfg.WRITE_CHARGE_SUMMARY else + "no charge summary is written (WRITE_CHARGE_SUMMARY=0)") + print(f"[TEST MODE] Claim ids come from the test sheet {path.name}, not " + f"the live {cfg.CLAIM_STATUS_LABEL} queue — {summary}") + try: + info = claim_file.claim_ids(path) + except Exception as exc: + print(f"[FILE] Could not read {path}: {exc}") + msgbox.info_box( + "eCW Bot — Could not read the test claim sheet", + f"{path}\n\nCould not read it:\n {exc}") + raise RuntimeError( + f"Could not read the test claim sheet {path}: {exc}") from exc + claim_file.log_ids(info) + if cfg.SHOW_CLAIM_IDS: + msgbox.info_box("eCW Bot — Test claim ids", claim_file.summary(info)) + _process_claims(page, info) + + +def _login_with_retry(pw): + """ + Log in, starting over if eCW's 2FA screen times out. + + A timeout is a dead end, not a failure to diagnose: the page has stopped + polling for the approval and the emailed link is spent, so the only thing + that helps is a new login in a new browser. `login()` closes the browser on + its way out, so each pass here really does start clean. + + **Every pass goes through the guard.** That is the whole reason this is a + loop in the flow rather than a `for` inside `login()`: an unguarded retry + against this portal is precisely what earned the account + "Access to the application has been restricted" once already. So a retry + waits out the cooldown, is refused if it would breach the hourly limit, and + is recorded as the attempt it is. + + Returns (browser, context, page), or raises. `GuardRefused` carries the + reason so the caller can exit 3 without duplicating the check. + """ + last = cfg.LOGIN_RETRY_ON_TIMEOUT + for attempt in range(last + 1): + # A plain cooldown with seconds left is waited out rather than refused — + # exiting there only moves the retrying into the operator's hands. + guard.wait_if_cooling() + # Refuse to be the thing that gets the account restricted again. + refusal = guard.check() + if refusal: + raise GuardRefused(refusal) + + guard.record_attempt() + run_events.login_attempt_start() + try: + logged_in = login(pw, CLIENT_NAME) + run_events.login_attempt_end("ok") + return logged_in + except LoginTimedOut as exc: + run_events.login_attempt_end("twofa_timeout", first_line(exc)) + print(f"[2FA] {exc}") + if attempt == last: + raise + print(f"[LOGIN] Browser closed. Starting over — " + f"attempt {attempt + 2} of {last + 1}") + except Restricted as exc: + run_events.login_attempt_end("restricted", first_line(exc)) + raise + except Exception as exc: + # login() raises "CAPTCHA not solved" when the operator gives up + # on the last captcha box. + run_events.login_attempt_end( + "captcha_failed" if "CAPTCHA" in str(exc) else "failed", + first_line(exc)) + raise + + +class GuardRefused(RuntimeError): + """The attempt guard said no. Carries the reason for the log.""" + + +def mode_banner() -> list: + """ + The `[MODE]` lines printed at start: whether this run can write to eCW, + how many claims it will take, and where the claim ids come from. + + Printed because nothing used to say which mode a run was in (review item + P1). The only way to tell VERIFY from LIVE-WRITE was to open .env — and + the .env reader could get that wrong silently. Now the first screen of + every run, and the top of its log, says it in words. + + Pure — reads `cfg` and returns the lines — so it can be checked without + a browser or a console. + """ + if cfg.SAVE_CORRECTIONS: + lines = ["[MODE] LIVE-WRITE — corrections are SAVED to eCW after the " + "per-claim OK; box ticks and added CPT rows save at once, " + "OK or not (SAVE_CORRECTIONS=1)"] + else: + lines = ["[MODE] VERIFY — corrections are filled in on screen for " + "review and NEVER saved (SAVE_CORRECTIONS=0)"] + if not cfg.APPLY_CORRECTIONS: + lines.append("[MODE] conditions are NOT run (APPLY_CORRECTIONS=0)") + lines.append(f"[MODE] CLAIM_PROCESS_LIMIT={cfg.CLAIM_PROCESS_LIMIT} " + f"(0 = all)") + if cfg.TEST_CLAIM_IDS_FILE: + # The file name only: the full path is in .env, and the name is what + # tells the operator which sheet it is. + lines.append(f"[MODE] TEST MODE — claim ids come from " + f"{Path(cfg.TEST_CLAIM_IDS_FILE).name}, not the live " + f"{cfg.CLAIM_STATUS_LABEL} queue") + return lines + + +def _confirm_live_write() -> bool: + """ + LIVE-WRITE mode only: one OK/Cancel before login, so a run that will save + to live eCW is never started by accident — a .env left at 1 after a test, + or copied from another machine. + + Asked BEFORE login because stopping there costs nothing: no login attempt + spent against the hourly guard, no 2FA mail. Cancel is the default button + and a timeout counts as Cancel, so only a deliberate click on OK goes on. + """ + return msgbox.ok_cancel( + "eCW Bot — LIVE-WRITE mode", + "SAVE_CORRECTIONS=1: this run will SAVE corrections to live eCW, " + "on each claim where you click OK in its save box. Box ticks and " + "added CPT rows are saved by eCW the moment they are made, even if " + "you then click Cancel.\n\n" + "Continue in LIVE-WRITE mode?\n\n" + "Cancel stops now, before login. To check claims without saving, " + "set SAVE_CORRECTIONS=0 in .env.", + default_cancel=True) + + +def start_mode_gate() -> bool: + """ + Print the `[MODE]` banner and, in LIVE-WRITE mode, ask the start + confirmation. True = go on to login; False = the operator did not + confirm LIVE-WRITE (logged), and the caller stops with exit 2. + + One helper for every entry point that logs in and reaches + `_process_claims` — this flow's `main()` and + tools/check_claims_from_excel.py (final review, 2026-10-05: the tool + only printed a WARNING line and went straight to login, so a .env left + at SAVE_CORRECTIONS=1 started a live-write session there with no + up-front OK/Cancel). + """ + for line in mode_banner(): + print(line) + if cfg.SAVE_CORRECTIONS and not _confirm_live_write(): + print("[MODE] Operator did not confirm LIVE-WRITE mode — stopping " + "before login") + return False + return True + + +def main() -> int: + """ + The run record first (`core/run_lock.py`, 2026-10-14): a second live + run refuses before login — exit 2, nothing spent — and every way out + of `_main()`, a raise included, ends the record with its exit code, so + the results page can say how the run ended. + """ + log_path = runlog.start() + run_events.start(log_path, + mode="LIVE-WRITE" if cfg.SAVE_CORRECTIONS else "VERIFY", + claim_limit=cfg.CLAIM_PROCESS_LIMIT, + test_sheet=cfg.TEST_CLAIM_IDS_FILE) + try: + run_lock.acquire(log_path) + except run_lock.Busy as exc: + print(f"[RUN] {exc}") + return _ended(_stop("config", str(exc), 2)) + except OSError as exc: + first = (str(exc).splitlines() or [type(exc).__name__])[0] + print(f"[RUN] Could not write the run record ({first}) — " + f"not starting") + return _ended(_stop("config", f"Could not write the run record " + f"({first})", 2)) + code = 1 + try: + code = _main(log_path) + return code + except BaseException as exc: + # Escaped _main's own handlers (or Ctrl+C): still a Run Error, with + # the traceback the log never shows. + _stop("unexpected", first_line(exc) or type(exc).__name__, 1, exc) + raise + finally: + run_lock.finish(code) + _ended(code) + + +def _stop(step: str, message: str, code: int, exc=None) -> int: + """Record a Run Error for the Runs page and hand back its exit code.""" + run_events.run_error(step, message, code, exc) + return code + + +def _ended(code: int) -> int: + """The Run's last event; the file is let go (Windows locks an open one).""" + run_events.emit("run_end", exit_code=code) + run_events.close() + return code + + +def _disk_check() -> bool: + """The system drive's free space (operator, 2026-10-22: C: at 0 MB + killed the browser mid-login on 2026-10-21). Under DISK_WARN_MB is a + Warning; under DISK_STOP_MB the Run stops before login. True = go on.""" + drive = os.environ.get("SystemDrive", "C:") + "\\" + try: + free_mb = shutil.disk_usage(drive).free // (1024 * 1024) + except OSError: + return True + run_events.emit("disk", drive=drive, free_mb=free_mb) + if free_mb < cfg.DISK_STOP_MB: + msg = (f"{drive} has {free_mb} MB free (under DISK_STOP_MB=" + f"{cfg.DISK_STOP_MB}) — free space before running; the " + f"browser dies mid-login when the drive fills") + print(f"[DISK] {msg}") + _stop("disk", msg, 2) + return False + if free_mb < cfg.DISK_WARN_MB: + msg = f"{drive} has only {free_mb} MB free" + print(f"[DISK] Warning: {msg}") + run_events.warning("disk_low", msg) + return True + + +def _main(log_path) -> int: + + problems = cfg.validate() + if problems: + print("[CONFIG] Not ready:") + for p in problems: + print(f" - {p}") + return _stop("config", "; ".join(problems), 2) + + print(f"[CONFIG] user={cfg.ECW_USERNAME} otp={cfg.resolved_otp_mode()} " + f"proxy={cfg.PROXY_IP}:{cfg.PROXY_PORT or '-'} headless={cfg.HEADLESS}") + print(f"[CONFIG] log={log_path}") + # Exit 2, like any other config problem: nothing has been spent yet. + if not start_mode_gate(): + return _stop("config", "LIVE-WRITE mode not confirmed", 2) + # Here, not at attach: by then the guard has counted a login attempt. + # No Chrome on the port: open it — Start on the Portal is all it takes. + opened_chrome = False + if cfg.BROWSER_CDP_URL and not cdp_ready(cfg.BROWSER_CDP_URL): + opened_chrome = True + print(f"[BROWSER] No Chrome at {cfg.BROWSER_CDP_URL} — opening it") + if not start_chrome(cfg.BROWSER_CDP_URL): + print(f"[CONFIG] Could not open Chrome at {cfg.BROWSER_CDP_URL} " + f"— blank BROWSER_CDP_URL to let the bot launch its own " + f"browser") + return _stop("config", f"Could not open Chrome at " + f"{cfg.BROWSER_CDP_URL}", 2) + if cfg.BROWSER_CDP_URL: + problem = chrome_proxy_problem(cfg.BROWSER_CDP_URL) + if problem: + print(f"[CONFIG] {problem}") + return _stop("config", problem, 2) + if opened_chrome: + # Operator, 2026-10-23: a moment to add the Claude extension before + # eCW opens. A person's box (shown on a Portal run too); after the + # usual timeout the run goes on. + msgbox.info_box("eCW Bot — Chrome open", + "Chrome is open. Add the Claude extension if you " + "need it, then click OK — the bot logs in to eCW " + "after this box.", needs_person=True) + if not _disk_check(): + return 2 + + started = datetime.now() + with sync_playwright() as pw: + browser = None + try: + browser, context, page = _login_with_retry(pw) + guard.record_success() + print(f"[OK] Logged in in " + f"{(datetime.now() - started).total_seconds():.0f}s") + print(f"[OK] Landed on: {page.url}") + + code = 0 + try: + _claims_phase(page) + except preflight.PreflightFailed as exc: + # eCW changed a screen: stopped on the first claim it + # showed, the selector named and the page's HTML saved. + print(f"[PREFLIGHT] Run stopped: {exc}") + code = _stop("preflight", first_line(exc), 7, exc) + msgbox.info_box("eCW Bot — eCW screen changed", str(exc)) + except watchdog.Stalled as exc: + print(f"[WATCHDOG] Run stopped: {exc}") + code = _stop("stalled", str(exc), watchdog.EXIT_CODE) + except Exception as exc: + if watchdog.has_fired(): + # The frozen call came back with an error somewhere + # the claim loop does not catch: still the stall. + print(f"[WATCHDOG] Run stopped: {watchdog.message()}") + code = _stop("stalled", watchdog.message(), + watchdog.EXIT_CODE, exc) + else: + print(f"[CLAIMS] Failed: {exc}") + code = _stop("claims", first_line(exc), 5, exc) + # Held open even on a failure — the live page is where the next + # selector gets read from, and re-running to see it costs another + # login attempt and another mailed link. + if KEEP_OPEN: + try: + input("\nBrowser open. Press Enter to close...\n") + except EOFError: + print("[INFO] No console attached — closing") + return code + except GuardRefused as exc: + print(f"[GUARD] {exc}") + return _stop("guard", first_line(exc), 3) + except LoginTimedOut as exc: + # Distinct from a login failure: nothing is wrong with the + # credentials or the bot. eCW's approval mail arrived too late, or + # not at all, more times than the retry budget allows. + print(f"[TIMEOUT] {exc}") + print(f"[TIMEOUT] Gave up after {cfg.LOGIN_RETRY_ON_TIMEOUT + 1} " + f"attempt(s). Raise LOGIN_RETRY_ON_TIMEOUT for more, but " + f"mind the {cfg.LOGIN_MAX_PER_HOUR}/hour guard.") + return _stop("twofa_timeout", first_line(exc), 6) + except Restricted as exc: + # Distinct exit code: this is not "the bot broke", it is "the portal + # is refusing us and retrying will make it worse". + print(f"[RESTRICTED] {exc}") + return _stop("restricted", first_line(exc), 4) + except Exception as exc: + print(f"[FAIL] {exc}") + return _stop("login", first_line(exc), 1, exc) + finally: + if browser: + try: + browser.close() + print("[BROWSER] Closed") + except Exception: + pass + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/src/ecw_bot/pages/__init__.py b/src/ecw_bot/pages/__init__.py new file mode 100644 index 0000000..0432758 --- /dev/null +++ b/src/ecw_bot/pages/__init__.py @@ -0,0 +1,4 @@ +r""" +One module per eCW screen. Each knows its own DOM and nothing about the +process — the order the screens get driven in belongs to `flows\`. +""" diff --git a/src/ecw_bot/pages/account_inquiry.py b/src/ecw_bot/pages/account_inquiry.py new file mode 100644 index 0000000..4991881 --- /dev/null +++ b/src/ecw_bot/pages/account_inquiry.py @@ -0,0 +1,176 @@ +r""" +Account Inquiry ("Patient Inquiry Detail"): a patient's earlier claims and +the CPT lines on them, read-only. + +Opened from the claim form's side menu, "Show CPT Payments" ticked (the +CPT lines are loaded under each posted payment / adjustment), every page +read, then closed with the X — `validateandclosePatientInquiryDetailModal +(false)`, which saves nothing. The claim form stays open behind it. + +Only lines under a posted payment or adjustment are listed there, so a line +billed but not yet adjudicated is not seen (operator's method, 2026-10-20). +""" + +import time +from datetime import datetime + +from playwright.sync_api import Page + +from ..selectors import account_inquiry as sel + + +class AccountInquiryError(Exception): + """Account Inquiry could not be opened, read or closed.""" + + +# One page's claims: [{claim_no, date (MM/DD/YYYY text), codes: [..]}], the +# modal ON SCREEN only (eCW leaves closed modals in the DOM). +_READ_JS = r""" +(s) => { + const shown = el => !!el && el.offsetParent !== null; + const close = [...document.querySelectorAll(s.close)].find(shown); + if (!close) return null; + const root = close.closest('.modal') || document; + const text = el => el ? (el.textContent || '').trim() : ''; + const out = []; + for (const tr of root.querySelectorAll(s.claim)) { + const codes = []; + for (const td of tr.querySelectorAll(s.cpt)) { + const m = text(td).match(/^([A-Za-z0-9]{4,7})\s*:/); + if (m && !codes.includes(m[1].toUpperCase())) + codes.push(m[1].toUpperCase()); + } + out.push({claim_no: text(tr.querySelector(s.claimNo)), + date: text(tr.querySelector(s.date)), codes}); + } + const pageNo = root.querySelector(s.pageNo); + return {claims: out, + page: pageNo ? parseInt(pageNo.value, 10) || 1 : 1, + pages: parseInt(text(root.querySelector(s.pageCount)), 10) || 1}; +} +""" + +_SELECTORS = {"close": sel.SEL_CLOSE, "claim": sel.SEL_CLAIM_ROW, + "cpt": sel.SEL_CPT_DESCRIPTION, "claimNo": sel.SEL_CLAIM_NO, + "date": sel.SEL_CLAIM_DATE, "pageNo": sel.SEL_PAGE_NO, + "pageCount": sel.SEL_PAGE_COUNT} + +MAX_PAGES = 20 + + +def parse_date(text): + """'MM/DD/YYYY' → date; None when empty or not a date.""" + try: + return datetime.strptime((text or "").strip(), "%m/%d/%Y").date() + except ValueError: + return None + + +def _snapshot(page: Page): + return page.evaluate(_READ_JS, _SELECTORS) + + +def _settle(page: Page, timeout_s: float = 15.0): + """Wait for the loader to go and the rows to stop changing; the last + snapshot, or None when the modal is not on screen.""" + end = time.monotonic() + timeout_s + last = None + while True: + page.locator(sel.SEL_LOADER).first.wait_for( + state="hidden", timeout=int(timeout_s * 1000)) + now = _snapshot(page) + if now is not None and now == last: + return now + if time.monotonic() > end: + return now + last = now + page.wait_for_timeout(500) + + +def open_inquiry(page: Page) -> None: + try: + page.locator(sel.SEL_LAUNCHER).filter(visible=True).first.click( + timeout=5_000) + page.locator(f"{sel.SEL_CLOSE}:visible").first.wait_for( + state="visible", timeout=20_000) + except Exception as exc: + raise AccountInquiryError(f"Account Inquiry did not open: " + f"{str(exc).splitlines()[0]}") from exc + + +def show_cpt_payments(page: Page) -> bool: + """Tick "Show CPT Payments"; True when this call ticked it.""" + box = page.locator(f"{sel.SEL_SHOW_CPT_PAYMENTS}:visible").first + try: + if box.is_checked(timeout=5_000): + return False + box.click(timeout=5_000) + return True + except Exception as exc: + raise AccountInquiryError(f"Show CPT Payments not ticked: " + f"{str(exc).splitlines()[0]}") from exc + + +def _untick(page: Page) -> None: + """Leave the filter as it was found (best effort: eCW may keep it as + the user's preference).""" + try: + box = page.locator(f"{sel.SEL_SHOW_CPT_PAYMENTS}:visible").first + if box.is_checked(timeout=2_000): + box.click(timeout=5_000) + _settle(page, timeout_s=5.0) + except Exception: + pass + + +def read_claims(page: Page) -> list: + """Every page's claims: [{claim_no, date (date or None), codes}].""" + claims = [] + snap = _settle(page) + for _ in range(MAX_PAGES): + if snap is None: + raise AccountInquiryError("Account Inquiry left the screen " + "while it was read") + claims.extend(snap["claims"]) + if snap["page"] >= snap["pages"]: + break + page.locator(f"{sel.SEL_NEXT}:visible").first.click(timeout=5_000) + snap = _settle(page) + else: + raise AccountInquiryError(f"more than {MAX_PAGES} pages of claims") + return [{"claim_no": c["claim_no"], "date": parse_date(c["date"]), + "codes": c["codes"]} for c in claims] + + +def close(page: Page) -> None: + try: + page.locator(f"{sel.SEL_CLOSE}:visible").first.click(timeout=5_000) + page.locator(f"{sel.SEL_CLOSE}:visible").first.wait_for( + state="hidden", timeout=10_000) + except Exception as exc: + raise AccountInquiryError(f"Account Inquiry did not close: " + f"{str(exc).splitlines()[0]}") from exc + + +def history(page: Page) -> list: + """Open, read every claim's CPT codes, close. Raises + AccountInquiryError; it always tries to close what it opened, and + unticks "Show CPT Payments" when it ticked it.""" + open_inquiry(page) + ticked = False + try: + ticked = show_cpt_payments(page) + claims = read_claims(page) + except Exception: + if ticked: + _untick(page) + try: + close(page) + except AccountInquiryError: + pass + raise + if ticked: + _untick(page) + close(page) + return claims + diff --git a/src/ecw_bot/pages/claim_filters.py b/src/ecw_bot/pages/claim_filters.py new file mode 100644 index 0000000..3cea197 --- /dev/null +++ b/src/ecw_bot/pages/claim_filters.py @@ -0,0 +1,520 @@ +""" +The Claim Lookup filter form — Claim Status, date type, date range, Balance. + +Order matters and is not arbitrary. **Claim Status runs first**, because it +decides two things everything after it depends on: whether a claim-based date +type can match anything at all (rows with no claim have no claim date), and +whether the Balance row is editable, since its `ng-change="enableInsurance()"` +is what sets `enableBalanceFlag`. Getting that order wrong is what produced a +lookup returning 0 rows against a grid still showing 936. + +Three properties of this screen shape the code below: + + * **The dropdown trigger's text is the current value** (`ng-bind`), so + reading it back is a real confirmation rather than "the click did not + throw". + * **Option ids are positional.** ngRepeat numbers them + `claimLookupLink2ngR0…ngR10`, so `ngR1` means "Claim Dt(s)" only while eCW + keeps the list in this order. Options are matched by label instead. + * **Angular owns the input values** (`ng-model`). `fill()` dispatches the + `input` event ng-model listens for; writing through `evaluate` would set + the DOM and leave the scope stale. +""" + +from datetime import datetime + +from playwright.sync_api import Page + +from .. import settings as cfg +from ..core.page_utils import shot, visible, text_of, is_disabled +from ..selectors import claim_lookup as sel + + +def _option(label: str) -> str: + """The option carrying `label`, scoped to the menu. + + Scoping matters: the trigger shows the selected type using the same text as + one of the options, so an unscoped text match can hit the trigger itself. + Exact text, because "Status Dates" and "Status Days" differ by one letter + and several labels are prefixes of nothing at all by luck rather than design. + """ + return f'{sel.SEL_DATE_TYPE_MENU} li a span:text-is("{label}")' + + +def claim_status(page: Page) -> str: + """The Claim Status currently selected, as its option value.""" + try: + return page.locator(sel.SEL_CLAIM_STATUS).first.input_value(timeout=10_000) + except Exception: + return "" + + +def claim_status_label(page: Page) -> str: + """The visible text of the selected Claim Status.""" + try: + return (page.locator(f"{sel.SEL_CLAIM_STATUS} option:checked").first + .inner_text(timeout=10_000).strip()) + except Exception: + return "" + + +def set_claim_status(page: Page, value: str = None, + expect_label: str = None) -> bool: + """ + Choose the Claim Status — "Medcor Ready" by default. + + Runs before the date type on purpose. It decides two things downstream: + whether a claim-based date type has anything to match (rows with no claim + have no claim date), and whether the Balance row is editable at all, since + its `ng-change="enableInsurance()"` is what sets `enableBalanceFlag`. + + Selected by option value with the label verified afterwards, because + picking the wrong status is a silent failure — the search still runs and + still returns claims, just the wrong queue. + """ + value = value or cfg.CLAIM_STATUS + expect = expect_label or cfg.CLAIM_STATUS_LABEL + + box = page.locator(sel.SEL_CLAIM_STATUS).first + try: + box.wait_for(state="visible", timeout=cfg.NAV_TIMEOUT) + except Exception: + shot(page, "claims_status_missing") + mode = "" + try: + mode = page.locator(sel.SEL_CLAIM_STATUS_MODE).first.inner_text().strip() + except Exception: + pass + raise RuntimeError( + f"{sel.SEL_CLAIM_STATUS} never appeared. It is shown only while the " + f"mode selector beside it reads 'Claim Status'" + + (f", and that reads {mode!r}" if mode else "") + + " — the status list is hidden, not missing") + + if not box.is_enabled(): + shot(page, "claims_status_locked") + raise RuntimeError( + "The Claim Status list is disabled — eCW does that while the " + "Claim No type is 'Rule Engine ID#'. Change that first") + + if box.input_value() == value: + print(f"[CLAIMS] Claim Status already {claim_status_label(page)!r}") + return True + + was = claim_status_label(page) + box.select_option(value=value, timeout=30_000) + # select_option fires `change`, so ng-change → enableInsurance() runs; give + # the digest a moment before reading anything back. + page.wait_for_timeout(1_000) + + got = box.input_value() + label = claim_status_label(page) + if got != value: + shot(page, "claims_status_failed") + raise RuntimeError( + f"Claim Status should be {value!r} but reads {got!r} ({label!r})") + if expect.lower() not in label.lower(): + shot(page, "claims_status_failed") + raise RuntimeError( + f"Claim Status {value!r} selected, but it is labelled {label!r} " + f"and {expect!r} was expected — eCW has reassigned that code, so " + f"this would search the wrong queue") + + print(f"[CLAIMS] Claim Status {was!r} → {label!r} ({value})") + # Worth stating: this is what decides whether the balance step can work. + if not is_disabled(page, sel.SEL_BAL_VAL1): + print("[CLAIMS] Balance row is now editable") + shot(page, "claims_status") + return True + + +def _warn_if_dateless(page: Page, label: str) -> None: + """ + Catch the filter pair that cannot match anything. + + "Encounters Without Claims" returns rows that have no claim, so they have no + claim date, no submitted date and no payment date either. Pairing that + status with any date type other than Service Dt(s) returns zero rows — and + it looks exactly like a broken search: the grid keeps showing the previous + result set while Total Counts drops to 0. Measured 2026-09-02: 936 rows on + Service Dt(s), 0 on Claim Dt(s), same range. + + A warning rather than a refusal — an empty result really is the answer to + that question, and the operator may want it. + """ + if label == sel.DATE_TYPE_SERVICE: + return + if claim_status(page) != sel.CLAIM_STATUS_NO_CLAIM: + return + print(f"[CLAIMS] WARNING: Claim Status is 'Encounters Without Claims' " + f"(rows with no claim) and the date type is {label!r}. Those rows " + f"have no {label} to match, so the lookup will return 0. " + f"{sel.DATE_TYPE_SERVICE!r} is the one that matches them.") + + +def current_date_type(page: Page) -> str: + """Whichever date type the form is filtering on right now.""" + try: + return (page.locator(sel.SEL_DATE_TYPE_TOGGLE).first + .inner_text(timeout=30_000).strip()) + except Exception: + return "" + + +def set_date_type(page: Page, label: str = None) -> bool: + """ + Point the claim search at a date type — "Claim Dt(s)" by default. + + Returns True once the trigger reads back `label`. + """ + label = label or cfg.CLAIM_DATE_TYPE + _warn_if_dateless(page, label) + + toggle = page.locator(sel.SEL_DATE_TYPE_TOGGLE).first + toggle.wait_for(state="visible", timeout=cfg.NAV_TIMEOUT) + + current = current_date_type(page) + if current == label: + print(f"[CLAIMS] Date type already {label!r}") + return True + print(f"[CLAIMS] Date type is {current!r} — switching to {label!r}") + + # Checked before clicking, because the failure is otherwise a bare + # pointer-events timeout that looks like a selector problem. + if toggle.evaluate("el => getComputedStyle(el).pointerEvents === 'none'"): + shot(page, "claims_datetype_locked") + raise RuntimeError( + "The date-type dropdown is disabled — eCW switches off " + "pointer-events on it while the claim-number type is " + "'Rule Engine ID#'. Change that first; the dropdown is fine") + + # data-toggle="dropdown" means this toggles: only click it shut-to-open. + if not visible(page, sel.SEL_DATE_TYPE_MENU, timeout=1_000): + toggle.click(timeout=30_000) + page.wait_for_selector(sel.SEL_DATE_TYPE_MENU, state="visible", + timeout=30_000) + + option = page.locator(_option(label)).first + try: + option.wait_for(state="visible", timeout=10_000) + except Exception: + # Worth listing what IS there: a renamed label is a one-line fix, and + # without this the message is just "selector not found". + try: + offered = page.locator( + f"{sel.SEL_DATE_TYPE_MENU} li a span").all_inner_texts() + except Exception: + offered = [] + shot(page, "claims_datetype_open") + raise RuntimeError( + f"No {label!r} in the date-type dropdown. It offers: {offered}") + + # The ng-click lives on the <li>; clicking the label bubbles up to it. + option.click(timeout=30_000) + + # The trigger is ng-bound to the selection, so this is a real confirmation + # and not just "the click did not throw". + try: + page.wait_for_function( + """([sel, want]) => { + const el = document.querySelector(sel); + return el && el.textContent.trim() === want; + }""", + arg=[sel.SEL_DATE_TYPE_TOGGLE, label], + timeout=30_000) + except Exception: + shot(page, "claims_datetype_failed") + raise RuntimeError( + f"Clicked {label!r} but the dropdown still reads " + f"{current_date_type(page)!r}") + + shot(page, "claims_datetype") + print(f"[CLAIMS] Date type set to {label!r}") + return True + + +# ============================================================================= +# Date range +# ============================================================================= + +def _close_datepicker(page: Page, box, what: str) -> None: + """ + Get jQuery UI's calendar off the screen. + + Escape on its own does not do it — on 2026-09-02 the calendar was still + open across the results grid when Lookup was clicked. So the field is + blurred as well, which is what actually triggers the widget's own hide, and + the result is checked instead of assumed. Left open it covers the grid and + a later click can land on a calendar cell, which silently rewrites the date. + """ + for attempt in (1, 2): + try: + page.keyboard.press("Escape") + box.evaluate("el => el.blur()") + except Exception: + pass + page.wait_for_timeout(300) + if not visible(page, sel.SEL_DATEPICKER, timeout=500): + return + if attempt == 2: + # Not fatal by itself: the value is verified either way, and the + # Lookup button sits clear of the calendar. Worth saying out loud + # because it means a stray click could hit a date cell. + print(f"[CLAIMS] Datepicker would not close after the " + f"{what} date — a stray click could hit a calendar cell") + return + # A click somewhere harmless is the last resort — the form's own label + # column, which has no handler. + try: + page.locator("#claimLookupTbl2").first.click( + position={"x": 2, "y": 2}, timeout=5_000) + except Exception: + pass + + +def _type_date(page: Page, selector: str, value: str, what: str) -> None: + """ + Put a date into one of the range inputs and prove it stuck. + + Two properties of these inputs drive the shape of this: + + * **jQuery UI is attached** (`hasDatepicker`). Focusing the field opens a + calendar, and that overlay can cover the next control — so it gets + dismissed with Escape rather than left for the following click to land + on a calendar cell. + * **Angular owns the value** (`ng-model`). `fill()` dispatches the `input` + event ng-model listens for, so the scope updates; typing round the back + with `evaluate` would set the DOM and leave the model stale. + """ + box = page.locator(selector).first + box.wait_for(state="visible", timeout=cfg.NAV_TIMEOUT) + box.fill("") # a leftover date would merge with the new one + box.fill(value) + _close_datepicker(page, box, what) + + got = box.input_value() + if got.strip() != value: + shot(page, "claims_daterange_failed") + raise RuntimeError( + f"{what} date should read {value!r} but reads {got!r} — eCW either " + f"reformatted it or the datepicker overwrote the typed value") + print(f"[CLAIMS] {what} date {value}") + + +def set_date_range(page: Page, from_date: str = None, to_date: str = None) -> bool: + """ + Set the claim search's date range. Defaults: `CLAIM_FROM_DATE` and today. + + `to_date` is computed at call time, not imported as a constant — a bot left + running overnight has to search up to the day it is running, not the day it + was started. + """ + from_date = from_date or cfg.CLAIM_FROM_DATE + to_date = to_date or cfg.CLAIM_TO_DATE or datetime.now().strftime("%m/%d/%Y") + + _type_date(page, sel.SEL_DATE_FROM, from_date, "From") + _type_date(page, sel.SEL_DATE_TO, to_date, "To") + shot(page, "claims_daterange") + return True + + +# ============================================================================= +# Claim No — the single-claim search +# ============================================================================= + +def set_claim_no(page: Page, claim_id: str) -> bool: + """ + Put one claim id into the Claim No box and prove it stuck. + + `fill("")` first, always. The box keeps the previous claim's id, and + appending rather than replacing would search for `55151455509935` — zero + rows, with the screen looking almost right. + + Written with `fill()`, never `evaluate`: the field is `ng-model="_InvId"` + and eCW builds its query from Angular's scope, not from the DOM. Setting + `.value` directly would put the new number on screen and search for the + old one — the worst shape of bug, because it looks correct. + """ + claim_id = str(claim_id).strip() + if not claim_id: + raise ValueError("set_claim_no called with an empty claim id") + + box = page.locator(sel.SEL_CLAIM_NO).first + box.wait_for(state="visible", timeout=cfg.NAV_TIMEOUT) + box.fill("") + box.fill(claim_id) + + got = box.input_value().strip() + if got != claim_id: + shot(page, f"claim_no_failed_{claim_id}") + raise RuntimeError( + f"Claim No should read {claim_id!r} but reads {got!r} — the field " + f"either reformatted it or kept the previous value") + return True + + +def clear_claim_no(page: Page) -> bool: + """ + Empty the Claim No box. + + Needed before any whole-queue search: a leftover claim id narrows the + lookup to one claim, so the run would report 1 row where it meant hundreds. + """ + try: + box = page.locator(sel.SEL_CLAIM_NO).first + if not box.input_value().strip(): + return True + box.fill("") + print("[CLAIMS] Claim No cleared") + return not box.input_value().strip() + except Exception as exc: + print(f"[CLAIMS] Could not clear Claim No: {str(exc).splitlines()[0]}") + return False + + +# ============================================================================= +# Balance filter +# ============================================================================= + +def _force_clear_balance(page: Page) -> dict: + """ + Blank the Balance filter through Angular's scope rather than the DOM. + + The last resort, and only reached when every control in the row is + disabled. It has to exist because the row arrives holding "> 0.0" with all + five controls disabled: the filter is live — eCW builds its query from the + scope, not from the inputs — while being unreachable by any click or + keystroke. Leaving it would silently narrow every search. + + Setting the models inside `$apply` is what keeps this honest: Angular + re-renders the inputs from the new values, so the form the operator + confirms is the form eCW will search on. Reaching in with `el.value` would + do the opposite — change the display and leave the query untouched. + """ + return page.evaluate( + """(args) => { + const [selector, models] = args; + const el = document.querySelector(selector); + if (!el) return {ok: false, why: 'the balance input is not on the page'}; + if (!window.angular) return {ok: false, why: 'angular is not loaded'}; + const sc = window.angular.element(el).scope(); + if (!sc) return {ok: false, why: 'no scope on that element — ' + + 'Angular debugInfo is probably disabled'}; + const before = {}, after = {}; + models.forEach(m => before[m] = sc[m]); + sc.$apply(() => models.forEach(m => sc[m] = '')); + models.forEach(m => after[m] = sc[m]); + return {ok: true, before: before, after: after}; + }""", + [sel.SEL_BAL_VAL1, list(sel.BAL_MODELS)], + ) + + +def _clear_balance_disabled(page: Page) -> bool: + """Handle the disabled row: force it clear, or explain why it stayed.""" + shown = text_of(page, sel.SEL_BAL_VAL1) + held = shown not in ("", "?") + + if not cfg.FORCE_CLEAR_BALANCE: + shot(page, "claims_balance_disabled") + print(f"[CLAIMS] Balance row is disabled and FORCE_CLEAR_BALANCE=0 — " + f"leaving it as it is" + + (f", still showing {shown!r}" if held else "")) + return False + + print(f"[CLAIMS] Balance row is disabled by eCW (enableBalanceFlag follows " + f"the Claim Status){f', holding {shown!r}' if held else ''} — " + f"clearing it through Angular's model") + + try: + res = _force_clear_balance(page) + except Exception as exc: + shot(page, "claims_balance_disabled") + print(f"[CLAIMS] Could not reach the scope: " + f"{str(exc).splitlines()[0]} — Balance NOT cleared") + return False + + if not res.get("ok"): + shot(page, "claims_balance_disabled") + print(f"[CLAIMS] Balance NOT cleared — {res.get('why')}") + return False + + was = {k: v for k, v in (res.get("before") or {}).items() if v not in (None, "")} + print(f"[CLAIMS] Balance models cleared" + + (f" (were {was})" if was else " (already empty)")) + + # Angular re-renders the inputs from the model, so this is a real check on + # the model and not just on what the box happens to display. + left = text_of(page, sel.SEL_BAL_VAL1) + if left not in ("", "?"): + shot(page, "claims_balance_failed") + raise RuntimeError( + f"Cleared the Balance scope but {sel.SEL_BAL_VAL1} still shows " + f"{left!r} — the model and the form disagree, so the query would " + f"not match what is on screen") + + shot(page, "claims_balance_cleared") + print("[CLAIMS] Balance filter cleared") + return True + + +def clear_balance(page: Page) -> bool: + """ + Empty the whole Balance row so no leftover condition narrows the search. + + All five controls, not just the visible pair: a stale second condition or a + dangling And/Or filters the results just as effectively as the first one, + and none of it is obvious from a screenshot. + + The row is usually **disabled** — every control carries + `ng-disabled="!enableBalanceFlag"`, which eCW drives off the Claim Status + selection. Disabled does not mean inactive: the row still holds "> 0.0" and + eCW still builds its query from those values, so the filter narrows every + search while being impossible to click or type into. That case goes through + `_clear_balance_disabled`, which clears Angular's model instead. + + An already-empty row is left completely alone — no select_option, no fill, + no write into Angular's scope. Nothing to clear is not a problem to solve, + and the quietest version of this step is the one that touches eCW least. + """ + # All five read back empty means there is no filter, so there is nothing to + # do. "?" is unknown rather than empty, so an unreadable control still goes + # down the clearing path — better to clear twice than to skip a live filter. + # `one`, not `sel`: `sel` is the selectors module at the top of this file, + # and binding it as a loop variable makes it local for the WHOLE function — + # so `sel.SEL_BAL_OP1` two lines up raises UnboundLocalError. That is + # exactly what broke the balance step on 2026-09-07. + current = {one: text_of(page, one) for one in ( + sel.SEL_BAL_OP1, sel.SEL_BAL_VAL1, sel.SEL_BAL_ANDOR, + sel.SEL_BAL_OP2, sel.SEL_BAL_VAL2)} + if all(v == "" for v in current.values()): + print("[CLAIMS] Balance row is already empty — no filter to clear") + shot(page, "claims_balance_cleared") + return True + + if is_disabled(page, sel.SEL_BAL_VAL1): + return _clear_balance_disabled(page) + + # Empty string is a real option in both cboBalance and cmbLogicalop2, so + # the operators can be blanked the same way a user would. + for one in (sel.SEL_BAL_OP1, sel.SEL_BAL_ANDOR, sel.SEL_BAL_OP2): + try: + page.locator(one).first.select_option(value="", timeout=10_000) + except Exception as exc: + print(f"[CLAIMS] Could not blank {one}: {str(exc).splitlines()[0]}") + + for one in (sel.SEL_BAL_VAL1, sel.SEL_BAL_VAL2): + try: + page.locator(one).first.fill("", timeout=10_000) + except Exception as exc: + print(f"[CLAIMS] Could not clear {one}: {str(exc).splitlines()[0]}") + + still = [s for s in (sel.SEL_BAL_VAL1, sel.SEL_BAL_VAL2) + if page.locator(s).first.input_value().strip()] + if still: + shot(page, "claims_balance_failed") + raise RuntimeError(f"Balance fields still hold a value: {still}") + + shot(page, "claims_balance_cleared") + print("[CLAIMS] Balance filter cleared") + return True diff --git a/src/ecw_bot/pages/claim_form.py b/src/ecw_bot/pages/claim_form.py new file mode 100644 index 0000000..94a44bd --- /dev/null +++ b/src/ecw_bot/pages/claim_form.py @@ -0,0 +1,3045 @@ +r""" +The UB Claim form: open it, read it, close it. + +Everything here is **read-only against eCW**. The form is closed with Cancel +(`#UBClaimBtn17`), never OK (`#UBClaimBtn18`, which calls `saveAllData(true)`) +— this phase gathers what a claim currently says, and writing is a separate +step that has not been asked for yet. + +Two properties of the screen shape all of it: + + * **Most ids carry a per-open timestamp.** See `selectors/claim_form.py`. + Nothing in here uses one. + * **Values live in inputs, not in text.** The ICD code reads + `<input value="R69">` while the description beside it is plain text, and a + claim status is a `<select>`. So the table reader below takes an input's + `value`, a select's selected label, or the cell's text — in that order. + Reading `textContent` alone silently returns blanks for half the form. +""" + +import json +import math +import re +import time +from datetime import datetime + +from playwright.sync_api import Page + +from .. import settings as cfg +from ..core import page_utils, run_events +from ..core.page_utils import shot, visible, appears +from ..core.text import first_line +from ..selectors import claim_form as sel +from . import progress_note + + +# ============================================================================= +# The extractor +# ============================================================================= +# One page.evaluate for the whole form rather than ~50 locator round trips. +# It is read-only, so there is no ordering risk, and on a claim with several +# CPT lines the difference is seconds per claim across hundreds of claims. +_EXTRACT_JS = r""" +() => { + const txt = el => (el ? (el.textContent || '').replace(/\s+/g, ' ').trim() : ''); + + // Value-first, text-last. Half this form's data is in inputs and selects; + // reading textContent alone returns blanks for the ICD code, every CPT + // column, the dates, and the claim status. + const cellValue = td => { + if (!td) return ''; + const inp = td.querySelector('input, textarea'); + if (inp) return (inp.value || '').trim(); + const s = td.querySelector('select'); + if (s) return s.selectedIndex >= 0 + ? (s.options[s.selectedIndex].textContent || '').trim() : ''; + return txt(td); + }; + + const one = s => document.querySelector(s); + const val = s => { const e = one(s); return e ? (e.value || '').trim() : ''; }; + const text = s => txt(one(s)); + + // Header names, then rows keyed by them. Keyed rather than positional so a + // reordered or inserted column cannot quietly shift every field. + const readTable = (s, root = document) => { + const tbl = root.querySelector(s); + if (!tbl) return []; + const heads = [...tbl.querySelectorAll('thead tr')]; + const hrow = heads.length ? heads[heads.length - 1] : null; + const names = hrow ? [...hrow.children].map((th, i) => + txt(th) || ('col_' + (i + 1))) : []; + const out = []; + for (const tr of tbl.querySelectorAll('tbody tr')) { + const cells = [...tr.children].map(cellValue); + // eCW pads these grids with blank ng-repeat rows to keep the box a + // fixed height. Nothing in them; drop them. + if (!cells.some(c => c && c !== ' ' && c !== '*')) continue; + const row = {}; + cells.forEach((c, i) => { row[names[i] || ('col_' + (i + 1))] = c; }); + out.push(row); + } + return out; + }; + + // The CPT/HCPCS grid is read by id instead, because its headers are cryptic + // (M1, ICD1, SDOS) while the input ids actually say what they hold. Those + // ids end in the row index and carry no timestamp — on the UB Claim form. + // + // The professional/HCFA "Claim" screen (Commercial payers) has the same + // CPTROW_ rows but entirely different, positional field ids + // (`billingClaimIpt4ngR0`, not `uBClaimPOS0`) — verified live, claim + // 9103000. `data-fieldname` is what is actually stable there, so it is + // the fallback when the UB-style prefix matches nothing in this row. + const HCFA_FIELDNAME = { + uBClaimCPTCode: 'claimCPTCode', + uBClaimPOS: 'ClaimCPTPOS', uBClaimTOS: 'ClaimCPTTOS', + uBClaimSDOS: 'ClaimCPTSDOS', uBClaimEDOS: 'ClaimCPTEDOS', + uBClaimMod1: 'ClaimCPTMOD1', uBClaimMod2: 'ClaimCPTMOD2', + uBClaimMod3: 'ClaimCPTMOD3', uBClaimMod4: 'ClaimCPTMOD4', + uBClaimICD1: 'ClaimCPTICD1', uBClaimICD2: 'ClaimCPTICD2', + uBClaimICD3: 'ClaimCPTICD3', uBClaimICD4: 'ClaimCPTICD4', + uBClaimUnits: 'ClaimCPTUnits', uBClaimBillFee: 'ClaimCPTBilledFee', + }; + const cptField = (tr, prefix) => tr.querySelector('[id^="' + prefix + '"]') + || tr.querySelector('[data-fieldname="' + HCFA_FIELDNAME[prefix] + '"]'); + const cptRows = []; + for (const tr of document.querySelectorAll('tr[id^="CPTROW_"]')) { + const pick = prefix => { + const e = cptField(tr, prefix); + return e ? (e.value || '').trim() : ''; + }; + const code = pick('uBClaimCPTCode'); + if (!code) continue; + const codeCell = cptField(tr, 'uBClaimCPTCode'); + const holder = codeCell ? codeCell.closest('td') : null; + // "Assign To Patient" — whether this line is actually billed. null, + // not false, when the row has no such checkbox (not yet confirmed on + // every UB form — see SEL_CPT_ASSIGN_TO_PATIENT): an unknown state + // must never read as "excluded" and quietly drop a billed line. + const billBox = tr.querySelector('[ng-click*="updateBillToIns("]'); + // Billed Fee: null, not '', when the row has no fee input at all — + // the same reasoning as `billed` above. '' is a fee that WAS read + // and is blank, which is_zero_fee() calls zero; a fee never read is + // unknown, and uncheck_zero_fee_cpt_rows() leaves that row alone. + // Told apart here, zero_fee_uncheck's detect() and + // vaccine_admin_codes' pre-read agree with it (review finding, + // 2026-09-30: a checked 90471 with no fee input read as '' and was + // taken for "unchecked by the zero-fee rule", so a second admin + // code was added on top of it). + const feeInput = cptField(tr, 'uBClaimBillFee'); + cptRows.push({ + code: code, + description: holder ? (holder.getAttribute('title') || '') : '', + pos: pick('uBClaimPOS'), tos: pick('uBClaimTOS'), + sdos: pick('uBClaimSDOS'), edos: pick('uBClaimEDOS'), + mod1: pick('uBClaimMod1'), mod2: pick('uBClaimMod2'), + mod3: pick('uBClaimMod3'), mod4: pick('uBClaimMod4'), + icd1: pick('uBClaimICD1'), icd2: pick('uBClaimICD2'), + icd3: pick('uBClaimICD3'), icd4: pick('uBClaimICD4'), + units: pick('uBClaimUnits'), + billed_fee: feeInput ? (feeInput.value || '').trim() : null, + billed: billBox ? !!billBox.checked : null, + }); + } + + const status = one('[ng-model="selectedClaimStatusCodes"]'); + const bind = n => text('[ng-bind="' + n + '"]'); + + // The Dental Claim screen (2026-10-12: Dental claims run the conditions + // too). Read only from the Dental window ON SCREEN — eCW leaves closed + // forms in the DOM — into the same shape as a UB/HCFA read, so every + // condition reads one shape. From Dom.txt's capture: service lines are + // tr[ng-repeat="CPT in CPTCodes ..."] with data-fieldname web_* inputs + // (no POS, no modifier columns: those keys are ''); the ICD list is + // #addTable (Code / Name, the pointer number in the first cell); the + // insurance grid #DentalClaimTbl2 has the same "Name" column. + const shown = el => { + if (!el || !el.getClientRects().length) return false; + const cs = getComputedStyle(el); + return cs.display !== 'none' && cs.visibility !== 'hidden'; + }; + const dental = [...document.querySelectorAll('.modal[id^="dentalClaim"]')] + .find(shown); + if (dental) { + const dRows = []; + for (const tr of dental.querySelectorAll('tr[ng-repeat^="CPT in CPTCodes"]')) { + const f = n => tr.querySelector('[data-fieldname="' + n + '"]'); + const v = n => { const e = f(n); return e ? (e.value || '').trim() : ''; }; + const code = v('web_cptcode'); + if (!code) continue; + const feeInput = f('web_billedFee'); + const billBox = tr.querySelector('[ng-click*="updateBillToIns("]'); + dRows.push({ + code: code, description: txt(tr.children[4]), + pos: '', tos: '', sdos: v('DentalClaimCPTSDOS'), edos: '', + mod1: '', mod2: '', mod3: '', mod4: '', + icd1: v('web_icd1'), icd2: v('web_icd2'), + icd3: v('web_icd3'), icd4: v('web_icd4'), + units: v('web_units'), + billed_fee: feeInput ? (feeInput.value || '').trim() : null, + billed: billBox ? !!billBox.checked : null, + }); + } + const notes = dental.querySelector('#dentalClaimNotes'); + const pos = dental.querySelector('[ng-model="ClaimData.pos"]'); + return { + screen: 'dental', + claim_no: txt(dental.querySelector('[ng-bind="ClaimData.claimNo"]')), + patient: {name: '', age_sex: '', dob: '', phone: '', + account_no: '', address: ''}, + header: {claim_pos: pos ? (pos.value || '').trim() : ''}, + insurance: readTable('#DentalClaimTbl2', dental), + payments: [], + icd: readTable('#addTable', dental).map(r => ({ + '#': r.col_1 || '', Code: r.Code || '', Name: r.Name || ''})), + revenue: [], errors: [], cpt: dRows, totals: {}, + notes: notes ? (notes.value || '').trim() : '', + }; + } + + return { + screen: one('#UBClaimDiv') ? 'ub' : 'hcfa', + claim_no: text('#UBClaimDiv .ubClaimNumberText span'), + patient: { + name: bind('showEllipsisForField(PatientInfo.namewithsuffix,25)'), + age_sex: bind("' ' + PatientInfo.Age + ', ' + PatientInfo.patSex"), + dob: bind('PatientInfo.dob'), + phone: bind('PatientInfo.phone'), + account_no: bind('showEllipsisForField(PatientInfo.ControlNo,10)'), + address: (() => { + const e = one('[ng-bind*="PatientInfo.address"]'); + // The title attribute holds the untruncated address; the text + // is ellipsised at 45 characters by eCW's own filter. + return e ? (e.getAttribute('title') || txt(e)) : ''; + })(), + }, + header: { + claim_date: val('[ng-model="ClaimData.ClaimDate"]'), + bill_type: val('#BillType'), + facility: val('#UBClaimIpt23'), + attending: val('#UBClaimIpt29'), + copay: val('#ClaimDataCopay'), + pt_uncovered: val('#ClaimDataPtUnCovAmt'), + claim_status_code: status ? status.value : '', + claim_status_label: status && status.selectedIndex >= 0 + ? (status.options[status.selectedIndex].textContent || '').trim() : '', + }, + // The HCFA "Claim" screen has no #UBClaimTbl3. Its "Insurances & + // Payment" grid, #billingClaimTbl8, has the same columns plus Ins Id + // (verified live, claims 9109000 and 9119000). Not #billingClaimTbl5 + // under "ICD & CPT": its Name cell also holds an edit input, which + // cellValue() would read instead of the payer's name. + insurance: (() => { + const ub = readTable('#UBClaimTbl3'); + return ub.length ? ub : readTable('#billingClaimTbl8'); + })(), + payments: readTable('#UBClaimTbl4'), + icd: readTable('[id^="icdCodesTable"]'), + revenue: readTable('#UBClaimTbl10'), + errors: readTable('#UBClaimTbl17'), + cpt: cptRows, + totals: { + total_charges: text('[ng-bind="TotalCharges"]'), + pt_charges: bind('InvBalances[0].PtResp'), + pt_payments: bind('InvBalances[0].PtPayment'), + pt_balance: bind('InvBalances[0].PtBalance'), + charges: bind('InvBalances[0].InvoiceAmount'), + payments: bind('InvBalances[0].Payment'), + balance: bind('InvBalances[0].Balance'), + }, + notes: val('#ubClaimNotes'), + // Condition Codes FL 18-28 (UB only): eleven values in box order, '' + // for an empty box; null when no UB form is ON SCREEN (HCFA) — a + // hidden UB form eCW left in the DOM must never answer for this + // claim (review, 2026-10-19). The same boxes as + // selectors SEL_UB_CONDITION_CODE / UB_CONDITION_CODE_BOXES. + condition_codes: (() => { + const ub = [...document.querySelectorAll('#UBClaimDiv')] + .find(shown); + if (!ub) return null; + const out = []; + for (let n = 1; n <= 11; n++) { + const e = ub.querySelector('[id="uBClaimCondtnCode' + n + '"]'); + if (!e) return null; + out.push((e.value || '').trim()); + } + return out; + })(), + }; +} +""" + + +# ============================================================================= +# Opening and closing +# ============================================================================= + +def _claim_no(page: Page) -> str: + try: + return page.locator(sel.SEL_CLAIM_NO_TEXT).first.inner_text( + timeout=5_000).strip() + except Exception: + return "" + + +def _claim_digits(text) -> str: + """A claim number's digits, leading zeros dropped ("" if it has none).""" + digits = re.sub(r"\D", "", str(text or "")) + return digits.lstrip("0") or ("0" if digits else "") + + +def same_claim_no(shown, claim_id) -> bool: + """ + Whether the claim number a form shows is the grid's claim id. + + Compared on digits, leading zeros dropped, not as raw text (final + review, 2026-10-05): the HCFA and Dental identity checks are new, the + Dental claim-number span is not yet live-verified, and any formatting + difference — a leading zero, a prefix, an inner non-breaking space — + would have raised "wrong claim" on every claim of that type, each one + then reported "Could not read this claim" in front of the client. A + value with no digits at all is compared as stripped text. + """ + a, b = _claim_digits(shown), _claim_digits(claim_id) + if a and b: + return a == b + return str(shown or "").strip() == str(claim_id or "").strip() + + +def _check_claim_no(page: Page, shown: str, claim_id: str) -> None: + """ + Raise when the form shows another claim (screenshot first); log when it + shows this one in a different spelling, so the live format is on record. + """ + if not shown or shown == claim_id: + return + if same_claim_no(shown, claim_id): + print(f"[FORM {claim_id}] The form shows the claim number as " + f"{shown!r} — the same claim, written differently") + return + shot(page, f"claim_form_wrong_claim_{claim_id}") + raise RuntimeError( + f"Opened the form for claim {shown!r} after clicking {claim_id!r} " + f"— the grid row and the form disagree, so this is the wrong claim") + + +# Bootstrap puts `modal-open` on <body> while a dialog is showing, and its own +# stylesheet hangs `overflow: hidden` off that class. The class is supposed to +# come back off in the modal's `hidden.bs.modal` teardown — which never runs if +# the dialog is destroyed some other way, and on this Angular shell there are +# several: a route change unmounting the panel, `$el.remove()`, or eCW hiding +# `#pnBackDrop` directly. What is left is a body that cannot scroll with no +# dialog anywhere to explain why — captured live on the Claim Lookup screen, +# `<body class="modal-open">` with not one `.modal-backdrop` in the document +# and every `.modal` still `fade` without `in`. +# +# The `showing` guard is the load-bearing part. A modal that is genuinely up +# owns that lock legitimately, and tearing it off underneath would let the page +# scroll around behind a live dialog — so the class only comes off when nothing +# is actually rendered. Measured off getClientRects() rather than the `in` +# class: mid-transition a modal is visible on screen before `in` lands. +_CLEAR_MODAL_LOCK_JS = r"""() => { + const body = document.body; + if (!body || !body.classList.contains('modal-open')) return false; + const showing = [...document.querySelectorAll('.modal')].some(m => { + const cs = getComputedStyle(m); + return cs.display !== 'none' + && cs.visibility !== 'hidden' + && m.getClientRects().length > 0; + }); + if (showing) return false; + body.classList.remove('modal-open'); + // Bootstrap also parks an inline padding-right here to compensate for the + // scrollbar it just hid; left behind it shifts the whole layout sideways. + body.style.removeProperty('padding-right'); + body.style.removeProperty('overflow'); + let orphans = 0; + document.querySelectorAll('.modal-backdrop').forEach(el => { + el.remove(); + orphans += 1; + }); + return {orphans: orphans}; +}""" + + +def clear_stale_modal_lock(page: Page) -> bool: + """ + Take Bootstrap's scroll lock off <body> when no modal is actually showing. + + See `_CLEAR_MODAL_LOCK_JS` for why the lock gets stranded and why the + "is anything really open?" guard is not optional. Returns whether the lock + was genuinely stale and has now been cleared. + + Worth doing even though Playwright scrolls an element's own container + before clicking it: a body stuck at `overflow: hidden` is the difference + between a screenshot that shows the operator the row that failed and one + that shows the top of a grid they cannot scroll. + """ + try: + result = page.evaluate(_CLEAR_MODAL_LOCK_JS) + except Exception as exc: + print(f"[FORM] Could not check the modal scroll lock: " + f"{str(exc).splitlines()[0]}") + return False + if not result: + return False + orphans = result.get("orphans", 0) if isinstance(result, dict) else 0 + extra = f" and {orphans} orphaned backdrop(s)" if orphans else "" + print(f"[FORM] Cleared a stale Bootstrap scroll lock on <body>{extra} " + f"— no modal was showing") + return True + + +def dismiss_stray_modal(page: Page, spare_claim_form: bool = False) -> bool: + """ + Close eCW's own "Account Inquiry" panel if it is sitting over the page, + and clear Bootstrap's scroll lock if a closed one left it behind. + + Not anything this codebase opens — eCW pops it up on its own, and its + backdrop (`SEL_STRAY_MODAL_BACKDROP`) covers the full page, blocking + clicks meant for whatever is underneath (see the selector's own comment + for the live evidence). Escape is the standard way to dismiss it — the + backdrop's own `ng-click="close($event)"` shows it is meant to be + dismissible, and Escape is the least presumptuous way to do that without + guessing at a close button's shape. + + The lock check runs **after** the Escape, and independently of whether + there was a backdrop at all. Both orders matter: Escape can close the + panel and still strand the lock, and the lock outlives its modal by + definition, so gating the check on a backdrop being present would only + ever catch the case that is already handled. + + `spare_claim_form` is for close_claim(), open_claim() and + clear_screen(): the HCFA form's own window matches the same class (see + `SEL_STRAY_MODAL_OVER_CLAIM`), and Escaping it closed the form without + closeClaimScreen() and left every Cancel attempt clicking a button that + was gone — ~45s and a false "Could not close" on every HCFA claim + (9109000, Logs\\run_20260929_160143.log). A claim form still up before + a claim's click is left to `_close_leftover_form()`'s Cancel (review, + 2026-10-14) — open_claim()'s retry after a failed click still sends + its own Escape, as before. + + Returns whether anything was actually there to deal with, so a caller can + tell "nothing to do" apart from "handled it". + """ + handled = False + backdrop = (sel.SEL_STRAY_MODAL_OVER_CLAIM if spare_claim_form + else sel.SEL_STRAY_MODAL_BACKDROP) + if visible(page, backdrop, timeout=500): + page.keyboard.press("Escape") + try: + # On the VISIBLE copy, the one the check above saw: a hidden + # leftover backdrop first in the DOM made `.first` "hidden" at + # once, and the next click fired while the real panel was still + # closing (final review, 2026-10-05). A visible-filtered locator + # is "hidden" once no visible match remains. + page.locator(backdrop).filter(visible=True).first.wait_for( + state="hidden", timeout=5_000) + except Exception: + pass + handled = True + + if clear_stale_modal_lock(page): + handled = True + return handled + + +def open_claim(page: Page, claim_id: str) -> bool: + """ + Click a claim number in the results grid and wait for its form to open. + + Waits on `SEL_ANY_CLAIM_FORM_OPEN` — either the UB Claim form or the + professional/HCFA "Claim" screen, since a claim id can open either one + and there is no single marker common to both. Waiting on the UB form's + own `#UBClaimDiv` alone meant an HCFA claim's form was genuinely open + on screen while this waited the full `NAV_TIMEOUT` (5 minutes) for a + selector that was never going to match, then raised as if nothing had + opened at all — seen live on claim 9128000. + + The claim-number read-back that follows is UB-specific + (`SEL_CLAIM_NO_TEXT` is scoped under `#UBClaimDiv`) and skipped entirely + for an HCFA claim — `is_ub_claim_form()` decides, and callers use it the + same way to decide what to do with the form once it is open. + """ + claim_id = str(claim_id).strip() + # Proactive: the stray panel has been seen already sitting over the grid + # before this function is even called (claim 9128000) — clear it before + # the click is attempted at all, not only after it fails. Sparing the + # claim window: a claim form already up here — left over, or the one + # eCW opens by itself after the Lookup following an HCFA or Dental + # claim (live run 2026-10-13) — is _close_leftover_form()'s to Cancel. + # Escape would drop an HCFA window without closeClaimScreen(). + dismiss_stray_modal(page, spare_claim_form=True) + _close_leftover_form(page, claim_id) + + last_exc = None + # 5s, not 30s, for the first try (review item D11, 2026-10-01): the + # "already open" fallback below fired on 43 of 92 opens, and every one + # of them first sat out the full 30s click timeout with the form open + # on screen — it looked hung. A real click lands in well under 5s; one + # that hasn't by then is blocked, and the fallback's own 5s wait is what + # catches a form that opened underneath it anyway. + for attempt, timeout in enumerate((5_000, 15_000)): + if attempt: + # Escape is safe to send regardless of what is actually + # intercepting the click — a known stray modal + # (.claimModalBackdrop, "Account Inquiry") or something this + # codebase has no captured selector for at all yet (eCW's + # "Follow up" side panel, seen live on claim 9129000 — a + # different overlay from Account Inquiry, so + # dismiss_stray_modal() alone does not catch it). + dismiss_stray_modal(page) + page.keyboard.press("Escape") + click_started = time.monotonic() + try: + page.get_by_text(claim_id, exact=True).first.click(timeout=timeout) + last_exc = None + break + except Exception as exc: + last_exc = exc + # The click can report failure even though eCW already opened + # the form underneath it — seen live (claim 9129000, + # Logs\run_20260916_162654.log): the screenshot taken right + # after this exact exception showed the UB form fully open and + # populated with real data. So a reported failure is not + # trusted on its own — the form itself is what actually matters. + # + # A real wait, not an instant check: this used to be + # `visible(timeout=1_000)`, and Playwright 1.62 ignores that + # timeout, so a form still opening was missed and attempt 2's + # Escape closed it — on HCFA without closeClaimScreen() (D11). + click_failed = time.monotonic() + if appears(page, sel.SEL_ANY_CLAIM_FORM_OPEN, timeout=5_000): + # The click's own first line and the timings are logged for + # the separate root-cause task: why does eCW's click report + # failure on a form that opened — and was it open already + # when the click gave up, or did it open after? + print(f"[FORM {claim_id}] Click reported failure, but the " + f"claim form is already open — continuing " + f"(click: {first_line(exc)}; failed after " + f"{click_failed - click_started:.1f}s, form seen " + f"{time.monotonic() - click_failed:.1f}s later)") + last_exc = None + break + + if last_exc is not None: + shot(page, f"claim_form_click_failed_{claim_id}") + raise RuntimeError( + f"Could not click claim {claim_id} in the results grid: " + f"{first_line(last_exc)}") + try: + page.wait_for_selector(sel.SEL_ANY_CLAIM_FORM_OPEN, state="visible", + timeout=cfg.NAV_TIMEOUT) + except Exception: + shot(page, f"claim_form_no_modal_{claim_id}") + raise RuntimeError( + f"Clicked claim {claim_id} but no claim form appeared " + f"({sel.SEL_ANY_CLAIM_FORM_OPEN}) — nothing opened") + + if not is_ub_claim_form(page): + # Dental is also "not UB"; name it so the log doesn't call it HCFA. + kind = ("Dental screen" if is_dental_claim_form(page) + else "HCFA/professional screen") + print(f"[FORM {claim_id}] Claim form open ({kind})") + return True + + # The modal renders before Angular has filled it, so the claim number is + # briefly blank. Waiting for it is what makes the extraction see data + # rather than an empty form. + try: + page.wait_for_function( + """(s) => { + const e = document.querySelector(s); + return e && e.textContent.trim().length > 0; + }""", + arg=sel.SEL_CLAIM_NO_TEXT, timeout=60_000) + except Exception: + print(f"[FORM {claim_id}] Claim No never filled in — reading anyway") + + _check_claim_no(page, _claim_no(page), claim_id) + print(f"[FORM {claim_id}] UB Claim form open") + return True + + +# (window selector, that window's own Cancel, label) for the screens eCW has +# been seen opening twice. The HCFA window is the angular-ui +# `.claimModalBackdrop` holding closeClaimScreen(); Dental its own modal. +_DUPLICABLE_WINDOWS = ( + (f'{sel.SEL_HCFA_WINDOW}:has({sel.SEL_CANCEL_HCFA})', sel.SEL_CANCEL_HCFA, + "HCFA"), + (f'{sel.SEL_DENTAL_WINDOW}:has({sel.SEL_CANCEL_DENTAL})', + sel.SEL_CANCEL_DENTAL, "Dental"), +) + + +def close_duplicate_forms(page: Page, claim_id: str) -> int: + """ + eCW sometimes opens the SAME claim twice — live run 2026-10-12, claim + 9110000: two HCFA claim windows 648 ms apart from one click (and the + same on 2026-10-06). Every locator here takes the first window in the + document, the LOWER one, so each click on it (Prog. Notes, Cancel) was + intercepted by the copy on top for its full timeout, the claim came + back unread, and the window left open blocked the next claim's search. + + Cancels every copy above the first, top first, with that window's own + Cancel — never OK, never Escape (Escape drops an HCFA window without + closeClaimScreen()). Returns how many were closed; never raises. + """ + closed = 0 + for windows, cancel, label in _DUPLICABLE_WINDOWS: + for _ in range(3): + if page_utils.count_shown(page, windows) <= 1: + break + try: + page_utils.click_topmost(page, cancel, timeout=10_000) + page.wait_for_timeout(800) + closed += 1 + except Exception as exc: + print(f"[FORM {claim_id}] Could not close a second copy of " + f"the {label} claim window: {first_line(exc)}") + break + if closed: + print(f"[FORM {claim_id}] eCW opened the claim twice — closed " + f"{closed} extra {('copy' if closed == 1 else 'copies')} with " + f"Cancel") + return closed + + +def _escape_stuck_hcfa_form(page: Page, claim_id: str, why) -> bool: + """ + The last resort for an HCFA claim window `_close_leftover_form` could + not close — Cancel failed, or the window still took no clicks after its + wait (operator, 2026-10-14): Escape, which drops the window WITHOUT + closeClaimScreen(). Left up, it covered the search page and every later + claim of the run went to Review; Escaped, the run goes on and the log + says loudly which claim's screen was dropped this way. Only the HCFA + window — Escape is what uib does to it; UB and Dental are left as they + are. Returns whether the window is gone; never raises. + """ + try: + if not visible(page, sel.SEL_HCFA_WINDOW): + return False + print(f"[CLEAR {claim_id}] LAST RESORT — the HCFA claim window is " + f"still up ({first_line(why)}); pressing Escape. " + f"eCW's closeClaimScreen() does NOT run this way — check the " + f"last claim's screen in eCW by hand") + _log_open_forms(claim_id, page, "Escaped") + page.keyboard.press("Escape") + live, inert = _wait_forms( + page, lambda live, inert: not live and not inert, _SETTLE_MS) + if live or inert: + print(f"[CLEAR {claim_id}] Escape did not close it either") + shot(page, f"claim_form_escape_failed_{claim_id}") + return False + print(f"[CLEAR {claim_id}] HCFA claim window gone after Escape") + return True + except Exception as exc: + print(f"[CLEAR {claim_id}] Escape as a last resort failed: " + f"{first_line(exc)}") + return False + + +def clear_screen(page: Page, claim_id: str, keep_pages=()) -> list: + """ + Put the page back to the bare claim search before a claim starts + (operator, 2026-10-12): whatever the last claim left on screen — + because it failed, timed out or half-closed — is closed here, so one + bad claim cannot cost the claims after it. Live run that day: claim 1's + viewer stayed up and claims 2 and 3 were never searched. + + In order: browser tabs opened since the claims started (`keep_pages` + are the ones open then, and `page` itself is always kept), progress + note viewers, extra copies of a claim window, a claim form still open + (Cancel — never OK; an HCFA window Cancel will not close is Escaped as + the last resort, `_escape_stuck_hcfa_form`), and eCW's stray panel / + scroll lock. Every + step on its own guard; never raises. Returns what was closed, for the + log. `claim_results.search_blocker` then says whether the search can go. + """ + done = [] + + def step(label, fn): + try: + if fn(): + done.append(label) + except Exception as exc: + print(f"[CLEAR {claim_id}] Could not close {label}: " + f"{first_line(exc)}") + + def tabs(): + keep = set(keep_pages) | {page} + extra = [p for p in page.context.pages if p not in keep] + for p in extra: + p.close() + return bool(extra) + + def viewer(): + if not visible(page, sel.SEL_PN_CLOSE): + return False + if not progress_note.close(page, wait_ms=0): + raise RuntimeError("it is still on screen") + return True + + def form(): + if not any_claim_form_open(page): + return False + close_duplicate_forms(page, claim_id) + try: + return _close_leftover_form(page, claim_id) + except Exception as exc: + if not _escape_stuck_hcfa_form(page, claim_id, exc): + raise + return True + + step("an extra browser tab", tabs) + step("a progress note viewer", viewer) + step("a claim form", form) + # Sparing the claim window: a form still up is the step above's — + # Cancel first, Escape only as its last resort. + step("a stray panel", + lambda: dismiss_stray_modal(page, spare_claim_form=True)) + if done: + print(f"[CLEAR {claim_id}] Left over from the last claim and closed: " + f"{', '.join(done)}") + return done + + +# For each shown match of a selector: the element, its window (the nearest +# .modal / dialog / claim window up from it) with the computed styles that +# decide whether a person can see and click it, and what a click at the +# element's centre would really hit. Names are tag, id and first class only +# — never text, which can be a patient's. +_DESCRIBE_SHOWN_JS = r""" +(s) => { + const name = el => !el ? 'nothing' : el.tagName.toLowerCase() + + (el.id ? '#' + el.id : '') + + (typeof el.className === 'string' && el.className.trim() + ? '.' + el.className.trim().split(/\s+/).join('.') : ''); + const style = el => { + const c = getComputedStyle(el); + return `display ${c.display}, visibility ${c.visibility}, ` + + `opacity ${c.opacity}, z-index ${c.zIndex}, ` + + `pointer-events ${c.pointerEvents}`; + }; + const box = r => `${Math.round(r.left)},${Math.round(r.top)} ` + + `${Math.round(r.width)}x${Math.round(r.height)}`; + const out = []; + for (const el of document.querySelectorAll(s)) { + const r = el.getBoundingClientRect(); + if (!(r.width > 0 && r.height > 0)) continue; + const win = el.closest('.modal, [role="dialog"], .claimModalBackdrop') + || el; + const inView = r.left >= 0 && r.top >= 0 + && r.left + r.width / 2 < innerWidth + && r.top + r.height / 2 < innerHeight; + const hit = inView ? document.elementFromPoint( + r.left + r.width / 2, r.top + r.height / 2) : null; + const hitWin = hit && hit.closest( + '.modal, [role="dialog"], .claimModalBackdrop'); + out.push(`${name(el)} at ${box(r)} (${style(el)}); window ` + + `${name(win)} at ${box(win.getBoundingClientRect())} ` + + `(${style(win)}); a click there hits ` + + (!inView ? 'nothing — outside the window' + : hit === el || el.contains(hit) ? 'it' + : name(hit) + (hitWin && hitWin !== win + ? ' in ' + name(hitWin) : ''))); + } + return out; +} +""" + + +def describe_shown(page: Page, selector: str) -> list: + """ + One line per shown match of `selector`, saying where it is, how its + window is styled and what a click on it would really hit — the + evidence for a form the bot sees as open that a person does not + (run 2026-10-12: after an HCFA claim's Cancel the screen was empty, + yet the next claim found a form "still open", and four Cancel clicks + timed out on it). No text from the page. Never raises. + """ + try: + return page.evaluate(_DESCRIBE_SHOWN_JS, selector) or [] + except Exception as exc: + return [f"could not be described ({first_line(exc)})"] + + +def _log_open_forms(claim_id: str, page: Page, when: str) -> None: + for line in describe_shown(page, sel.SEL_ANY_CLAIM_FORM_OPEN): + print(f"[FORM {claim_id}] Open form {when}: {line}") + + +# The claim-form markers on screen, split by whether a click could land on +# them. A window eCW is still animating — opening, or a fade-out Chrome +# paused behind a message box — computes `pointer-events: none` all the way +# down: shown (Playwright counts it visible), but a click passes through it +# and Cancel on it can only time out (live run 2026-10-13: four Cancel +# clicks, ~45 s, on an HCFA window left at opacity 0.222). +_FORM_STATE_JS = r""" +(s) => { + let live = 0, inert = 0; + for (const el of document.querySelectorAll(s)) { + const r = el.getBoundingClientRect(); + if (!(r.width > 0 && r.height > 0)) continue; + const cs = getComputedStyle(el); + if (cs.visibility === 'hidden') continue; + if (cs.pointerEvents === 'none') inert++; else live++; + } + return {live: live, inert: inert}; +} +""" + +# How long a claim window that takes no clicks is given to finish opening or +# closing before the bot stops waiting for it. +_SETTLE_MS = 10_000 +_SETTLE_POLL_MS = 250 + + +def _form_state(page: Page) -> tuple: + """(live, inert) claim-form markers on screen. Raises on a page error.""" + r = page.evaluate(_FORM_STATE_JS, sel.SEL_ANY_CLAIM_FORM_OPEN) or {} + return int(r.get("live") or 0), int(r.get("inert") or 0) + + +def _wait_forms(page: Page, done, timeout_ms: int) -> tuple: + """Poll `_form_state` until `done(live, inert)` or the time runs out; + returns the last state.""" + deadline = time.monotonic() + timeout_ms / 1000 + state = _form_state(page) + while not done(*state) and time.monotonic() < deadline: + page.wait_for_timeout(_SETTLE_POLL_MS) + state = _form_state(page) + return state + + +def _close_leftover_form(page: Page, claim_id: str) -> bool: + """ + Before the click: Cancel any claim form still on screen from an earlier + claim, and refuse to go on if it will not close. Returns whether one + was cancelled. + + Live run 2026-10-13 showed two kinds of window that look open here: + * one that takes no clicks (`pointer-events: none`) — opening, or a + fade-out Chrome paused behind the bot's message box. It is never + clicked: it is given up to `_SETTLE_MS` to finish. Gone → nothing to + do; clickable → cancelled as below; still unclickable → refused, + like a form that will not close (it would be read as this claim's). + * a live form eCW opened by itself after the Lookup, after an HCFA or + Dental claim — this claim's own form, opened before the bot's + click. Cancelled like any other; the click opens it again. + + Final review, 2026-10-05: the "click reported failure, but the claim + form is already open" fallback below accepts ANY open claim form as the + one just clicked. A form left open — the previous claim's close_claim() + gave up after all four attempts — would then be read, and corrected, + under this claim's id; the HCFA identity check cannot catch it on a + screen with no claim-number field. Nothing this function's caller does + leaves a form open on purpose, so one that is open here is a leftover. + + Cancel only, through close_claim() — never OK. A page error while + asking is not proof of a leftover: logged, and the click goes ahead as + before (the identity checks after it still apply). + """ + try: + leftover = any_claim_form_open(page) + if leftover: + live, inert = _wait_forms(page, lambda live, inert: not inert, + _SETTLE_MS) + except Exception as exc: + print(f"[FORM {claim_id}] Could not check for a claim form left open " + f"({first_line(exc)}) — clicking anyway") + return False + if not leftover: + return False + if not live and not inert: + print(f"[FORM {claim_id}] A claim window was still closing — it " + f"finished on its own") + return False + if not live: + _log_open_forms(claim_id, page, "that takes no clicks") + shot(page, f"claim_form_leftover_{claim_id}") + raise RuntimeError( + f"A claim window is still in the page after " + f"{_SETTLE_MS // 1000}s and takes no clicks (pointer-events " + f"none) — not clicking claim {claim_id} while it is there") + print(f"[FORM {claim_id}] A claim form is already open — left from an " + f"earlier claim, or opened by eCW itself after the Lookup — " + f"closing it with Cancel before clicking this one") + # What the bot is seeing, for the case a person sees no form at all. + _log_open_forms(claim_id, page, "found") + closed = close_claim(page) + if closed: + # close_claim()'s own "hidden" check is on the UB form's div, which + # an HCFA or Dental screen does not have — so it can answer True + # with one of those still up. Confirmed here on every screen: no + # form left, live or still fading out (a window mid-fade would + # otherwise pass for the next claim's form a moment later). + try: + live, inert = _wait_forms( + page, lambda live, inert: not live and not inert, _SETTLE_MS) + closed = not live and not inert + except Exception: + closed = False + if closed: + return True + _log_open_forms(claim_id, page, "left after Cancel") + shot(page, f"claim_form_leftover_{claim_id}") + raise RuntimeError( + f"A claim form from an earlier claim is still open and would not " + f"close — not clicking claim {claim_id}, so that form cannot be read " + f"as this claim's") + + +def is_ub_claim_form(page: Page) -> bool: + """ + Whether the open claim form is the UB Claim form (institutional — + Medicaid, in practice), as opposed to eCW's other "Claim" screen for + professional/HCFA claims (Commercial payers), which uses an entirely + different button set (verified against a live claim: `closeClaimScreen()` + instead of `closeUBClaimScreen()`, a timestamped Prog. Notes button + instead of the UB form's stable `#UBClaimProgressNoteBtn`, and + `saveAllData()` instead of `saveAllData(true)`). + + Everything in this module past `open_claim()` — the write path + especially — assumes the UB form's shape. Callers check this before + doing anything that depends on it. + """ + return visible(page, sel.SEL_IS_UB_FORM, timeout=5_000) + + +def any_claim_form_open(page: Page) -> bool: + """ + Whether any of the three claim screens is showing right now. + + Counts every visible match — a hidden form left in the DOM ahead of the + open one must not read as "nothing open". Instant, no wait — only called + on a form that already had its chance to render. + + Deliberately NOT `visible()`, although it asks the same question since + review item P8: `visible()` swallows exceptions and answers False, and + close_claim() reads False as "already closed — nothing to cancel". A + transient page error at close time would then skip Cancel and leave the + form open for the next claim. It raises instead, and close_claim() + treats a raise as "maybe open — try Cancel anyway" (review of unit C, + 2026-10-05). + """ + return page.locator(sel.SEL_ANY_CLAIM_FORM_OPEN).filter( + visible=True).count() > 0 + + +def is_dental_claim_form(page: Page) -> bool: + """ + Whether the open claim form is eCW's Dental Claim screen — a THIRD + shape, distinct from both the UB form and the professional/HCFA "Claim" + screen: its own modal entirely, `closeDntl()` instead of + `closeUBClaimScreen()`/`closeClaimScreen()`, and a completely different + tab set ("ICD & CDT" / "Services Rendered" rather than "ICD & CPT" / + "CPT/HCPCS"). + + Its CPT rows carry no `CPTROW_` id; since 2026-10-12 the row selectors + (`SEL_CPT_ROWS`, `SEL_CPT_CODE`, ...) carry a Dental alternative and + `extract()` reads this screen into the UB/HCFA row shape, so the + conditions run on it too. The flow asks this to run the Dental-only + checks (high risk, sliding fee) first and to never save the claim. + """ + return visible(page, sel.SEL_IS_DENTAL_FORM, timeout=5_000) + + +# One evaluate per poll, read-only. The claim number is text (ng-bind), the +# codes are inputs whose values Angular sets as PROPERTIES — `.value`, never +# getAttribute('value'), which still holds whatever the template shipped. +# +# Both are read INSIDE the window of the form on screen: the window that +# holds the screen's visible Cancel (`closest()` up from it). eCW leaves +# hidden forms in the DOM (see any_claim_form_open), and both selectors match +# document-wide — so a stale hidden copy of the previous claim's form could +# otherwise answer for this one: its claim number raising a false "wrong +# claim" (the claim then comes back unread), or its filled rows making a form +# still blank count as loaded (review of unit C, 2026-10-05). +# +# Inside that window, a visible claim-number element is preferred and a +# hidden one is still accepted (it belongs to this form — Dental can keep it +# in a tab pane that is not the active one). The codes are NOT filtered on +# visibility, for the same tab-pane reason. +# +# No visible Cancel inside a known window (the live markup moved): the whole +# document is read, but only a VISIBLE claim number counts — a hidden one +# there could be anyone's, so it is treated as "not filled yet" and the wait +# runs out to a not-evaluated reason, never a false wrong-claim raise. +_LOAD_STATE_JS = r""" +([anchorSel, windowSel, noSel, codeSel]) => { + const shownEl = e => e.getClientRects().length > 0; + const anchor = [...document.querySelectorAll(anchorSel)].find(shownEl); + const win = anchor ? anchor.closest(windowSel) : null; + const root = win || document; + const all = [...root.querySelectorAll(noSel)]; + const shown = all.filter(shownEl); + let claimNo = ''; + for (const e of (shown.length ? shown : (win ? all : []))) { + const t = (e.textContent || '').trim(); + if (t) { claimNo = t; break; } + } + let codes = 0; + for (const e of root.querySelectorAll(codeSel)) { + if ((e.value || '').trim()) codes++; + } + return {present: all.length > 0, claimNo: claimNo, codes: codes}; +} +""" + +# How long a non-zero code count must hold still before the form counts as +# loaded — two polls at least 500 ms apart (review item D7). Angular fills +# the rows one digest at a time, so the first non-zero count can be partial. +_LOAD_STABLE_SECONDS = 0.5 +_LOAD_POLL_MS = 250 + +# The line a message box leads with when wait_claim_loaded() gave up: the +# operator must not read a confident "not high risk" or "CPT 0" off a form +# the bot itself logged as half-loaded. Shared by summary() (HCFA) and the +# flow's Dental box so both say it the same way. +NOT_LOADED_NOTE = ("Form did not finish loading — results may be " + "incomplete; marked for manual review.") + + +def wait_claim_loaded(page: Page, claim_id: str, + timeout_ms: int = 20_000) -> str | None: + """ + Wait for the HCFA or Dental form to finish loading. None when it has; a + short human-readable reason when it has not. + + `open_claim()` returns the moment a screen's Cancel button is visible, and + on these two screens Angular has not filled the form yet: `extract()` then + reads 0 CPT rows, Prog. Notes reads "disabled", and empty Dental Billing + Notes read "not high risk" — so nothing fires and the claim is reported + Pass. Seen live (run_20260930_163006; review item D7). The UB form is a + no-op here: `open_claim()` already waits for its claim number. + + Called by the flow right after `open_claim()`, not from inside it, so + `open_claim()` keeps its one contract — "the form is open, or raise" — + and the not-loaded reason lands where the claim's data is built. + + Each ~250 ms poll: + + 1. **Identity.** The screen's claim-number element absent from the + form's window (or the DOM) entirely -> not checked (logged once). + Read only inside the window of the form on screen — a hidden form + left behind by an earlier claim never answers for this one. Present but blank -> wait for + it. Filled with a DIFFERENT claim id -> raise, exactly like the UB + wrong-claim check: reading it would attach another claim's data to + this one. + 2. **Rows.** At least one code input with a non-empty value, and the + same count held for 500 ms. + + A timeout is NOT an exception. The claim is still read and closed as + normal; the reason goes on its data (`_not_evaluated`) so the summary can + say a human must look, instead of calling a half-loaded claim clean. + """ + claim_id = str(claim_id).strip() + if is_ub_claim_form(page): + return _ub_not_loaded(page, claim_id) + if is_dental_claim_form(page): + kind, rows = "Dental", "service line" + js_args = [sel.SEL_CANCEL_DENTAL, sel.SEL_DENTAL_WINDOW, + sel.SEL_DENTAL_CLAIM_NO, sel.SEL_DENTAL_CODE] + else: + kind, rows = "HCFA", "CPT row" + js_args = [sel.SEL_CANCEL_HCFA, sel.SEL_HCFA_WINDOW, + sel.SEL_HCFA_CLAIM_NO, sel.SEL_HCFA_CPT_CODE] + + started = time.monotonic() + deadline = started + timeout_ms / 1000 + ever_present = False + shown_no = "" + count, count_since = 0, started + read_error = "" + while True: + try: + state = page.evaluate(_LOAD_STATE_JS, js_args) + read_error = "" + except Exception as exc: + # Not fatal on one poll — the page can be mid-digest. Kept, so a + # form that could never be read at all says so at the deadline. + state, read_error = None, first_line(exc) + now = time.monotonic() + if state: + present = bool(state.get("present")) + ever_present = ever_present or present + new_no = (state.get("claimNo") or "").strip() + if new_no != shown_no: + # Once per value: a differently spelled match is logged once, + # not on every poll. + _check_claim_no(page, new_no, claim_id) + shown_no = new_no + n = int(state.get("codes") or 0) + if n != count: + count, count_since = n, now + identity_ready = (not present) or bool(shown_no) + if (identity_ready and count >= 1 + and now - count_since >= _LOAD_STABLE_SECONDS): + if not ever_present: + print(f"[FORM {claim_id}] No claim-number field on this " + f"screen — identity not checked") + print(f"[FORM {claim_id}] {kind} form loaded after " + f"{now - started:.1f}s ({count} {rows}" + f"{'' if count == 1 else 's'})") + return None + if now >= deadline: + break + try: + page.wait_for_timeout(_LOAD_POLL_MS) + except Exception: + time.sleep(_LOAD_POLL_MS / 1000) + + if not ever_present: + print(f"[FORM {claim_id}] No claim-number field on this screen — " + f"identity not checked") + detail = [] + if read_error and not count: + detail.append(f"could not read the form: {read_error}") + if ever_present and not shown_no: + detail.append("claim number never filled") + detail.append(f"{count} {rows}{'' if count == 1 else 's'}") + reason = (f"{kind} form did not finish loading in " + f"{timeout_ms / 1000:.0f}s ({', '.join(detail)})") + shot(page, f"claim_form_not_loaded_{claim_id}") + print(f"[FORM {claim_id}] {reason} — reading it anyway; the claim is " + f"marked not evaluated") + return reason + + +def _ub_not_loaded(page: Page, claim_id: str) -> str | None: + """ + The UB form's half of `wait_claim_loaded`: an INSTANT check, because + `open_claim()` has already waited up to 60s for the UB claim number. + None when it filled; a reason when it never did. + + Before this, a UB claim number that never filled only logged "Claim No + never filled in — reading anyway": the wrong-claim check was skipped + (nothing to compare), no reason reached the claim's data, and the + conditions ran on a form that may be half-loaded — with + SAVE_CORRECTIONS=1 the save box could be offered on it. HCFA and Dental + claims skip the conditions in exactly that situation; UB now does too + (final review, 2026-10-05). + """ + try: + filled = page.evaluate( + """(s) => { + const e = document.querySelector(s); + return !!(e && e.textContent.trim()); + }""", sel.SEL_CLAIM_NO_TEXT) + except Exception: + # Unreadable is not proof of half-loaded: open_claim already logged + # what it saw, and the read below has its own checks. + return None + if filled: + return None + reason = "UB form did not finish loading (claim number never filled)" + print(f"[FORM {claim_id}] {reason} — the claim is marked not evaluated") + return reason + + +def close_claim(page: Page) -> bool: + """ + Close the form with **Cancel**, never OK. + + `#UBClaimBtn18` ("OK") calls `saveAllData(true)` — it writes the claim back + to eCW. This phase only reads, so closing with OK would turn a read into an + unintended write on every single claim. + + Tries the screen's own Cancel first — decided by `is_dental_claim_form()` + / `is_ub_claim_form()` — then falls back to every other screen's buttons + regardless: a claim can open any of the three screens depending on its + type, and guessing wrong here would leave the modal open for the next + claim in the loop to collide with. Checking first, rather than always + trying one screen's buttons before another's, matters because none of + the three screens share a selector — every wrong-order claim used to + burn the full click+hide timeout of each miss before ever reaching the + attempt that actually works. + + Returns at once if no claim form is showing at all — the operator can + close it by hand while the review box is up (claim 9123000, + Logs\\run_20260923_165056.log), and clicking after that only burned ~45s + on buttons that were gone before reporting a failure that wasn't one. + """ + # A page error while asking is NOT "closed": try Cancel anyway, through + # the full fallback list below. Skipping it would leave the form open for + # the next claim, whose stray-modal handling Escapes an HCFA form without + # closeClaimScreen(); raising would end the whole run from inside the + # flow's `finally` (review of unit C, 2026-10-05). + try: + is_open = any_claim_form_open(page) + except Exception as exc: + print(f"[FORM] Could not tell whether a claim form is open " + f"({first_line(exc)}) — trying Cancel anyway") + is_open = True + if not is_open: + print("[FORM] Claim form is already closed — nothing to cancel") + return True + + if is_dental_claim_form(page): + attempts = [ + (sel.SEL_CANCEL_DENTAL, 15_000, 60_000, "the Dental screen's Cancel"), + (sel.SEL_CANCEL, 10_000, 30_000, "Cancel"), + (sel.SEL_CLOSE_X, 10_000, 30_000, "X"), + (sel.SEL_CANCEL_HCFA, 10_000, 30_000, "the HCFA screen's Cancel"), + ] + elif is_ub_claim_form(page): + attempts = [ + (sel.SEL_CANCEL, 15_000, 60_000, "Cancel"), + (sel.SEL_CLOSE_X, 10_000, 30_000, "X"), + (sel.SEL_CANCEL_HCFA, 10_000, 30_000, "the HCFA screen's Cancel"), + (sel.SEL_CANCEL_DENTAL, 10_000, 30_000, "the Dental screen's Cancel"), + ] + else: + attempts = [ + (sel.SEL_CANCEL_HCFA, 15_000, 60_000, "the HCFA screen's Cancel"), + (sel.SEL_CANCEL, 10_000, 30_000, "Cancel"), + (sel.SEL_CLOSE_X, 10_000, 30_000, "X"), + (sel.SEL_CANCEL_DENTAL, 10_000, 30_000, "the Dental screen's Cancel"), + ] + + # Same guard open_claim() already runs before its own click attempts: + # a stray backdrop (or a stranded scroll lock) sitting over Cancel/X is + # indistinguishable from a genuinely slow page until it has burned the + # full click timeout — seen live on claim 9127000 + # (Logs\run_20260917_172622.log), where Cancel, X, and Cancel again all + # timed out in a row. Cleared before every attempt, not only the first: + # the backdrop from an earlier failed click can still be sitting there + # for the next one. + for i, (selector, click_timeout, hide_timeout, label) in enumerate(attempts): + dismiss_stray_modal(page, spare_claim_form=True) + try: + # The copy on top, not `.first` (live run 2026-10-12): with a + # second window or viewer stacked over the form, `.first` is the + # covered one and the click times out. Nothing of it shown yet: + # the plain wait-and-click, as before. + if not page_utils.click_topmost(page, selector, + timeout=click_timeout): + page.locator(selector).first.click(timeout=click_timeout) + page.wait_for_selector(sel.SEL_MODAL, state="hidden", + timeout=hide_timeout) + return True + except Exception as exc: + if i == len(attempts) - 1: + print(f"[FORM] Could not close the claim form: " + f"{str(exc).splitlines()[0]}") + return False + next_label = attempts[i + 1][3] + print(f"[FORM] {label} did not close the form " + f"({str(exc).splitlines()[0]}) — trying {next_label}") + run_events.warning("cancel_retry", + f"{label} did not close the form — trying " + f"{next_label}") + return False + + +# ============================================================================= +# The two clicks that were asked for +# ============================================================================= + +def open_cpt_hcpcs_tab(page: Page) -> bool: + """ + Switch to the CPT/HCPCS tab. + + Matched on `ng-click="cptTabUpdate('cptHcpcs')"`: its `data-target` is + `#cptHCPCS<timestamp>`, which is useless as a selector here. + + Clicked before extracting rather than after: the CPT grid starts inside an + inactive tab pane, and although its inputs already hold their values, eCW's + own `cptTabUpdate()` is what reflows the grid — so a claim with several + lines can render short until the tab has been visited. + + A no-op on the professional/HCFA "Claim" screen: it has no separate + CPT/HCPCS tab at all — ICD Codes and CPT/HCPCS are both already visible, + stacked, under its one "ICD & CPT" tab. Waiting the full 30s for a tab + that will never appear used to raise and abort the whole claim read + before Progress Notes was ever reached — seen live on claim 9103000 + (Logs\\run_20260917_162939.log). Checked first, not attempted-then-caught, + since a claim of this type needs no reflow at all. + """ + if not is_ub_claim_form(page): + return False + tab = page.locator(sel.SEL_TAB_CPT_HCPCS).first + try: + tab.wait_for(state="visible", timeout=30_000) + except Exception: + shot(page, "claim_form_no_cpt_tab") + raise RuntimeError( + f"No CPT/HCPCS tab matched {sel.SEL_TAB_CPT_HCPCS} — eCW has " + f"renamed cptTabUpdate() or removed the tab") + + label = "" + try: + label = tab.inner_text(timeout=5_000).strip() + except Exception: + pass + tab.click(timeout=30_000) + page.wait_for_timeout(800) + + # appears(), not visible(): the grid reflows after the tab click and the + # 10s was always meant as a grace period — visible() never waited, so a + # slow tab was reported missing and the claim read short (P8). + if not appears(page, sel.SEL_CPT_GRID, timeout=10_000): + shot(page, "claim_form_cpt_grid_missing") + print(f"[FORM] Clicked {label or 'CPT/HCPCS'} but {sel.SEL_CPT_GRID} " + f"is not visible — the grid may be empty") + return False + print(f"[FORM] {label or 'CPT/HCPCS'} tab opened") + return True + + +def _enabled_within(page: Page, btn, timeout_ms: int) -> bool: + """ + Whether `btn` is enabled now or becomes so within `timeout_ms`. + + True when its state cannot be read at all — the same choice the single + check here always made: an unreadable button is clicked and the note + search decides, rather than being reported "disabled" on no evidence. + """ + deadline = time.monotonic() + timeout_ms / 1000 + while True: + try: + if btn.is_enabled(timeout=1_000): + return True + except Exception: + return True + if time.monotonic() >= deadline: + return False + page.wait_for_timeout(250) + + +def _wait_for_note(page: Page, tabs_before: int): + """ + Poll for the note document: `(reader, where, partial)`, or + `(None, "nothing", None)`. `partial` is None for a note that settled, and + the section count at the deadline for one that never did. + + Accepts a reader only once it has at least one `[prisma-section]`, the + same section count on two polls in a row, AND a document that has + finished parsing (`readyState` not "loading") — the viewer frame exists + well before its document has finished arriving, and a reader taken too + early reads a note with no Procedure Codes in it, so telehealth is never + seen. The count alone was not enough (final review, 2026-10-05): the + note is a whole HTML document, and a server that stalls for more than + one poll after the first few sections shows the same count twice. + + Only a note that was NOT already on the page before the click counts + (`find(..., fresh_only=True)`; the caller ran `progress_note.mark_seen` + first): a viewer that Close only hid, or a reused iframe still showing + the previous claim's note, would otherwise be read as this claim's. + + `find()` is given a 1s load-state timeout per poll rather than its 60s + default, so a new tab still loading costs a second a poll, not a minute. + + Once, past half the wait, with nothing found, no new tab, no viewer open, + and the button still showing and enabled: the click evidently did not + take, so it is clicked again (logged). Not while a viewer is open — that + is the slow-document case, and a second click there would only stack a + second viewer on the first. "Open" is the viewer's Close button OR its + iframe (`SEL_PN_VIEWER_FRAME`): live, 2026-10-12, the viewer was up with + its document still loading and no Close drawn yet at 10 s, the second + click went through, and the two stacked viewers blocked three claims. + + At the deadline a reader whose sections never settled is still read, with + a log line saying it may be partial: a partial note is more use than + none. The count goes back to the caller too, which puts it on the note + (`partial`), because a log line alone cannot stop the summary calling the + claim clean — a frame with the note's header and 0 sections reads no + Procedure Codes, which is exactly the D8 false Clean. + """ + wait_ms = cfg.PROGRESS_NOTE_WAIT_MS + started = time.monotonic() + deadline = started + wait_ms / 1000 + halfway = started + wait_ms / 2000 + reclicked = False + last_reader, last_where, last_count = None, "nothing", -1 + while True: + reader, where = progress_note.find(page, tabs_before=tabs_before, + load_timeout=1_000, + fresh_only=True) + now = time.monotonic() + if reader is not None: + n, ready = progress_note.note_state(reader) + if (n >= 1 and ready != "loading" and reader is last_reader + and n == last_count): + print(f"[NOTE] Note loaded in the {where} after " + f"{now - started:.1f}s ({n} section(s))") + return reader, where, None + last_reader, last_where, last_count = reader, where, n + else: + last_reader, last_where, last_count = None, "nothing", -1 + if (not reclicked and now >= halfway + and len(page.context.pages) <= tabs_before + and not visible(page, sel.SEL_PN_CLOSE) + and not visible(page, sel.SEL_PN_VIEWER_FRAME) + and visible(page, sel.SEL_PROGRESS_NOTES)): + reclicked = True + try: + again = page.locator(sel.SEL_PROGRESS_NOTES).filter( + visible=True).first + if again.is_enabled(timeout=1_000): + print(f"[NOTE] Nothing opened after " + f"{now - started:.1f}s — clicking Prog. Notes " + f"once more") + again.click(timeout=5_000) + except Exception as exc: + print(f"[NOTE] Second Prog. Notes click failed: " + f"{first_line(exc)}") + if now >= deadline: + break + page.wait_for_timeout(250) + + if last_reader is not None: + print(f"[NOTE] The note's sections never settled in " + f"{wait_ms / 1000:.0f}s ({last_count} section(s) at the " + f"deadline) — reading it anyway; it may be partial") + return last_reader, last_where, max(last_count, 0) + return None, "nothing", None + + +def open_progress_notes(page: Page) -> dict: + """ + Click "Prog. Notes", read the note as structure, close the viewer. + + Two things about the button, both from its own markup: + + * `ng-disabled="!FormData.EnableProgressNote"` — it can be perfectly + visible and do nothing. Checked, and reported as a fact about the claim + rather than as an error. + * what it opens is a **whole printable document** with its own `<html>`, + so it lands in an iframe or a new tab. `progress_note.find()` looks in + all three places instead of assuming one. An earlier version read a + modal's `innerText` and would have come back empty for the iframe case. + + The viewer is always closed on the way out, including on failure: it sits + on top of the UB Claim form and blocks the Cancel button underneath, so a + note left open would make the next claim in the loop open its form behind + a stale one. + + Every wait here is a poll with a deadline, not a fixed sleep (review + items D7/D8, 2026-10-01): + + * the button gets 10s to appear (`appears`, a real wait — it used to be + `visible(timeout=10_000)`, which Playwright 1.62 answers instantly); + * "disabled" is re-checked for up to 5s, because Angular enables the + button only once the form has loaded — read too early, a claim with + a note was reported as having none; + * the note itself is polled every 250 ms for up to + `PROGRESS_NOTE_WAIT_MS` (see `_wait_for_note`). The old fixed 3s + sleep and single probe missed 3 of ~115 notes in the logs, every one + a viewer that had opened on a document still loading. + """ + if not appears(page, sel.SEL_PROGRESS_NOTES, timeout=10_000): + print("[NOTE] No Prog. Notes button on this claim") + return {"opened": "absent"} + # The visible one: a hidden leftover copy first in the DOM would report + # disabled (or swallow the click) while the live button sits on screen. + btn = page.locator(sel.SEL_PROGRESS_NOTES).filter(visible=True).first + if not _enabled_within(page, btn, 5_000): + print("[NOTE] Prog. Notes is disabled for this claim " + "(FormData.EnableProgressNote is false, still after 5s)") + return {"opened": "disabled"} + + # Before the click: any note already on the page (a viewer Close only + # hid, a reused iframe) belongs to an earlier claim, and the poll must + # not take it for this one — see progress_note.mark_seen. + stale = progress_note.mark_seen(page) + if stale: + print(f"[NOTE] {stale} earlier note document(s) still on the page — " + f"ignored while waiting for this claim's") + before = len(page.context.pages) + btn.click(timeout=30_000) + + reader, where, partial = _wait_for_note(page, before) + if reader is None: + shot(page, "progress_note_missing") + print("[NOTE] Prog. Notes clicked but no note document appeared — see " + "progress_note_missing.png") + # Instant: the poll just ran out its whole wait, so a viewer that is + # there has long since drawn its Close button. + progress_note.close(page, wait_ms=0) + return {"opened": "nothing"} + + try: + note = progress_note.extract(reader) + except Exception as exc: + shot(page, "progress_note_unreadable") + print(f"[NOTE] Found the note in the {where} but could not read it: " + f"{first_line(exc)}") + progress_note.close(page, wait_ms=0 if where == "tab" else 5_000) + return {"opened": where, "error": str(exc)} + + note["opened"] = where + if partial is not None: + # Marked on the note itself, not only in the log: its sections never + # settled, so what was read may be missing the Procedure Codes that + # telehealth is checked against. The count says how much there was — + # 0 means the header arrived and nothing under it. + note["partial"] = True + note["partial_sections"] = partial + got = [k for k, v in (note.get("sections") or {}).items() if v] + print(f"[NOTE] Read the note from the {where}: {len(got)} section(s), " + f"{len(note.get('assessments') or [])} assessment(s), " + f"{len(note.get('procedure_codes') or [])} procedure code(s), " + f"sign off {note.get('signoff_status') or '?'}") + + if where == "tab": + try: + shot(reader, "progress_note") + reader.close() + except Exception: + pass + else: + shot(page, "progress_note") + + # A note read from a new tab had no in-page viewer: an instant check, + # not a 5s wait for a Close button that is not there (final review, + # 2026-10-05 — it added 5s to every such claim). + progress_note.close(page, wait_ms=0 if where == "tab" else 5_000) + return note + + +# ============================================================================= +# Condition 1: telehealth -> POS=11, M1=GT +# ============================================================================= +# The bot's first WRITE path. Everything above this point only reads. This one +# closes the form with OK (`save_claim`) instead of Cancel, and only when a +# human has explicitly confirmed the change — a wrong guess on real billing +# data costs more than asking. + +_TH_TOKEN = re.compile(r"\bTH\b") + +# A `Modifiers:` list on a Visit Codes line, up to the end of that line only +# ([ \t], never \s — it must not swallow the next line). Stripped before the +# TH search: the obstetric HCPCS modifier TH ("obstetrical treatment/services, +# prenatal or postpartum" — the one document rule #23 adds for pregnancy) is +# written there, e.g. "99213 OB office visit level 3. Modifiers: TH", on +# in-person OB visits. Six-plus real in-person notes carried exactly that and +# nothing else telehealth-related, and the bare `\bTH\b` search turned every +# one of them into a telehealth claim (POS 2 + GT). Review item D4, +# 2026-10-05. +# +# The label is matched case-insensitively and in its "Modifier(s):" spelling +# too (review fix round 1, 2026-10-05): the evidence shows eCW writing +# "Modifiers:", but "MODIFIERS: TH" or "Modifier(s): TH" on an OB note is the +# same obstetric modifier and must not flip an in-person visit to telehealth. +# The codes after the colon stay upper-case only ([A-Z0-9], outside the (?i:) +# group) — the TH search itself is case-sensitive, so a lower-case "th" was +# never a signal to begin with. +_MODIFIERS_LIST = re.compile( + r"(?i:Modifiers?|Modifier\(s\)):[ \t]*[A-Z0-9][A-Z0-9, \t]*") + + +def note_mentions_telehealth(note_text: str) -> bool: + """ + Whether the progress note documents a telehealth visit. + + Three signals, checked in increasing order of ambiguity: + + * `"telehealth"`, case-insensitive substring — the note's own heading, + however it is capitalized in practice ("TELEHEALTH:", "BHTelehealth + Visit", "Session Setting (PoS): Telehealth"). + * `"telephone"`, case-insensitive substring — some notes document the + mode of treatment instead of the word "telehealth" itself + ("Mode of Treatment: Telephone"). + * a standalone `TH` token — matches the `(TH)` tag eCW appends to some + procedure code descriptions ("Behavioral Health Outreach Service + (TH)"). Deliberately **not** case-insensitive and **not** a plain + substring check: "th" is not a real English word on its own, but a + loose match would still risk a false positive buried in unrelated + text, so this requires the exact two-character token with a word + boundary on both sides (`\\bTH\\b`) — it will not match inside + "MONTH", "WITH", or similar. + + **A `TH` inside a `Modifiers:` list does not count** (review item D4, + 2026-10-05). That is the obstetric HCPCS modifier TH, not a + telehealth tag: real in-person OB notes read "99213 OB office visit + level 3. Modifiers: TH" (and "Modifiers: 25, TH") with nothing else + telehealth-related, and used to be billed as telehealth (POS 2 + GT) + on the strength of that token alone. The modifier list is cut from + the text, same line only, before the TH search — a `(TH)` tag or a + `Visit type: TH` elsewhere in the note still counts. + """ + return telehealth_signal(note_text) is not None + + +def telehealth_signal(note_text: str) -> str | None: + """ + Which of `note_mentions_telehealth()`'s three signals actually + matched — `"telehealth"`, `"telephone"`, or `"TH tag"` — or `None` if + none did. Same detection, just named, so a condition's message box + can tell an operator *why* it fired rather than only that it did. + + Checked in the same order `note_mentions_telehealth()` checks them, so + every note that returns True there gets a label here too. A note + carrying more than one signal reports the first in that order + ("telehealth" over "telephone" over the TH tag) — arbitrary, but + deterministic, and consistent with the boolean check treating any one + of them as sufficient. + + The TH tag is searched with every `Modifiers:` list removed first — the + obstetric TH modifier on in-person OB visits is not a telehealth tag + (see `note_mentions_telehealth`). + """ + text = note_text or "" + lower = text.lower() + if "telehealth" in lower: + return "telehealth" + if "telephone" in lower: + return "telephone" + if _TH_TOKEN.search(_MODIFIERS_LIST.sub(" ", text)): + return "TH tag" + return None + + +# Audio/video evidence, in precedence order (review item D4, 2026-10-05). +# Explicit audio-only first: a note that says "audio only" / "no video" / +# "Mode of Treatment: Telephone" is audio whatever else it says. Explicit +# video next. Then a bare "telephone" — a telephone visit, today's rule kept. +# Word-bounded throughout: the old bare `"audio" in note` check called +# "real-time audio and video", "Audiology" and "audiometry" audio-only, and +# billed those Commercial/Medicare visits 93 instead of 95. +_AUDIO_ONLY_EVIDENCE = [ + re.compile(r"mode of treatment\s*:\s*telephone", re.I), + re.compile(r"\baudio[\s\-]*only\b", re.I), + re.compile(r"\bno video\b", re.I), + # eCW's procedure-description tag on an audio-only code, e.g. + # "90834 Psychotherapy, 45 minutes (audio)." + re.compile(r"\(audio\)", re.I), + re.compile(r"\bphone call only\b", re.I), +] +_VIDEO_EVIDENCE = [ + re.compile(r"mode of treatment\s*:\s*video", re.I), + re.compile(r"\baudio\s*(?:and|&|/|\+)\s*video\b", re.I), + re.compile(r"\bvideo\s+(?:visit|call|conference|session)\b", re.I), + re.compile(r"\b(?:via|by)\s+video\b", re.I), +] +_TELEPHONE = re.compile(r"\btelephone\b", re.I) + + +def _evidence_phrase(match) -> str: + """The matched text, whitespace-collapsed and capped at 40 characters — + short enough for a describe() line.""" + return " ".join(match.group(0).split())[:40] + + +def telehealth_mode_evidence(note_text: str) -> tuple[str, str]: + """ + `(mode, phrase)`: "audio" or "video", plus the note text that decided + it ("" when nothing did and the default applied). Only meaningful once + `note_mentions_telehealth` has already returned True for this same + text; not a detector itself. + + Case-insensitive, word-bounded, first match wins, in this order (review + item D4, 2026-10-05): + + 1. explicit audio-only — "Mode of Treatment: Telephone", "audio only" + / "audio-only", "no video", a "(audio)" procedure tag, "phone call + only". Seen live: "THV Audio Only: Telehealth audio phone call + only. No video." never once says "telephone". + 2. explicit video — "Mode of Treatment: Video", "audio and video" + (also "&", "/", "+"), "video visit/call/conference/session", "via + video" / "by video". The review's own example, "real-time audio + and video", is a video visit, and used to read as audio-only. + 3. any other whole-word "telephone" → "audio" — a telephone visit + ("seen via telephone visit", "Telephone encounter regarding ..."); + the rule before this change, kept. + 4. otherwise `("video", "")` — the document's default (#21: 95 for + Commercial/Medicare unless audio only). + + A bare "audio" word is no longer evidence by itself: "referred to + Audiology" and "audiometry" are not telehealth at all. The BH consent + boilerplate "consented to behavioral health treatment by telehealth + (phone and/or video)" matches nothing here, deliberately — it is + signed on every BH telehealth visit, audio or video, so it must not + decide the mode either way. + """ + text = note_text or "" + for pattern in _AUDIO_ONLY_EVIDENCE: + m = pattern.search(text) + if m: + return "audio", _evidence_phrase(m) + for pattern in _VIDEO_EVIDENCE: + m = pattern.search(text) + if m: + return "video", _evidence_phrase(m) + m = _TELEPHONE.search(text) + if m: + return "audio", _evidence_phrase(m) + return "video", "" + + +def telehealth_mode(note_text: str) -> str: + """ + "audio" or "video" — `telehealth_mode_evidence(note_text)[0]`; see that + function for the rules. Kept for callers that only need the mode. + + Per the client's Charge Condition Document (condition #21): a plain + telehealth visit is billed with a payer-specific modifier (GT for + Medicaid, 95 for Commercial/Medicare), and an audio-only visit is 93 — + except Medicaid, which stays GT (see `conditions/telehealth.py`). + """ + return telehealth_mode_evidence(note_text)[0] + + +def describe_mode(mode: str, phrase: str) -> str: + """The mode line both telehealth conditions' `describe()` show, so the + operator sees WHAT in the note decided 93 vs 95: `mode audio (matched + "audio only")`, or `mode video (default — no audio-only evidence)`.""" + if phrase: + return f'mode {mode} (matched "{phrase}")' + return f"mode {mode} (default — no audio-only evidence)" + + +def _norm_pos(value) -> str: + """POS as compared, not as written: whitespace and leading zeros + stripped, so eCW's "02" and the bot's "2" are the same place of + service (review item D3).""" + return (value or "").strip().lstrip("0") + + +def _norm_mod(value) -> str: + """M1 as compared: stripped and upper-case, so "gt" equals "GT".""" + return (value or "").strip().upper() + + +def set_cpt_pos_modifier(page: Page, pos: str, modifier: str, + only_first: bool = False) -> list: + """ + Set POS and M1 on every real CPT/HCPCS row, and read each one back. + + `only_first`, when True, stops after the first real row — used by the + Medicare-telehealth condition (document #22), which touches only the + claim's primary row rather than every row the way plain telehealth + (#21) does. + + "Real" means it has a CPT code — the same filter `extract()`'s own reader + already applies, because eCW pads this grid with blank `ng-repeat` rows to + hold a fixed height, plus a `*` entry row at the bottom with no `CPTROW_` + id at all. Touching those would corrupt a blank row instead of a claim + line. + + Returns one entry per real row visited: `{"row", "code", "before", + "after", "changed"}`, plus `"ok"` on a changed row. The read-back matters + as much as the write — a fill() that silently did not take must be + visible here, not discovered on a saved claim. + + **A row that is already right is not touched** (review item D3, + 2026-10-05). Before this, every visited row was filled and reported, so + a correctly coded telehealth claim (POS '2'->'2') came back as a change, + got both review boxes, and went to the Charge Summary as Fail. Now: + + * POS is compared with whitespace and leading zeros stripped (eCW's + "02" is the bot's "2"), M1 stripped and upper-case ("gt" is "GT"). + Both already matching → nothing is written, and the row is reported + `changed: False` with `after` = `before`. + * Otherwise only the field(s) that differ are filled — M1 still with + its explicit blur — both are read back, and the row is reported + `changed: True` with `ok` = whether the read-back now matches the + target. `ok: False` is a write that did not take; the caller sends + the claim to review rather than claiming a fix. + + `only_first` still stops after the first REAL row, changed or not — an + already-correct primary row must not let the write slide on to row 2. + + **M1 needs an explicit blur.** Verified against the live DOM (a real UB + Claim form, claim 9120000): `uBClaimPOS*` is a plain `ng-model` — `fill()` + alone is enough, the same as every other input this module writes to. But + `uBClaimMod1*` also carries `ng-blur="updateCPTMod(cpt.mod1, 'MOD1', + $index)"` — the modifier is not fully committed to eCW's own model until + that handler runs, and nothing else in this function moves focus after + filling M1, so it is blurred explicitly rather than left to chance before + `save_claim()` clicks OK. + """ + changes = [] + rows = page.locator(sel.SEL_CPT_ROWS) + for i in range(rows.count()): + row = rows.nth(i) + code_input = row.locator(sel.SEL_CPT_CODE).first + if code_input.count() == 0: + continue + code = (code_input.input_value(timeout=5_000) or "").strip() + if not code: + continue + + pos_input = row.locator(sel.SEL_CPT_POS).first + mod1_input = row.locator(sel.SEL_CPT_MOD1).first + before = {"pos": pos_input.input_value(timeout=5_000), + "mod1": mod1_input.input_value(timeout=5_000)} + pos_right = _norm_pos(before["pos"]) == _norm_pos(pos) + mod_right = _norm_mod(before["mod1"]) == _norm_mod(modifier) + if pos_right and mod_right: + # Already correct: write nothing — not even a same-value fill, + # which would still fire eCW's own change/blur handlers. + changes.append({"row": i, "code": code, "before": before, + "after": dict(before), "changed": False}) + if only_first: + break + continue + + if not pos_right: + pos_input.fill(pos, timeout=10_000) + if not mod_right: + mod1_input.fill(modifier, timeout=10_000) + mod1_input.blur(timeout=5_000) + after = {"pos": pos_input.input_value(timeout=5_000), + "mod1": mod1_input.input_value(timeout=5_000)} + ok = (_norm_pos(after["pos"]) == _norm_pos(pos) + and _norm_mod(after["mod1"]) == _norm_mod(modifier)) + changes.append({"row": i, "code": code, "before": before, + "after": after, "changed": True, "ok": ok}) + if only_first: + break + return changes + + +def put_row_m1(page: Page, code: str, modifier: str): + """ + Put `modifier` in M1 of the first real CPT row whose code is `code`, + replacing whatever M1 holds (document #23, TH — operator 2026-10-21: + M1 in use is overwritten, never moved to M2-M4). Filled and blurred — + M1 carries `ng-blur="updateCPTMod(...)"` — then read back. Already in + any of M1-M4 → nothing typed. + + Returns None when no real row carries `code` (the live grid did not + show the line the read had), else `{"row", "present"}` plus, when not + already present, `"box"` (1, None when the row has no M1 box), + `"before"`, `"after"` and `"ok"`. + """ + want = _norm_mod(modifier) + rows = page.locator(sel.SEL_CPT_ROWS) + for i in range(rows.count()): + row = rows.nth(i) + code_input = row.locator(sel.SEL_CPT_CODE).first + if code_input.count() == 0: + continue + if (code_input.input_value(timeout=5_000) or "").strip().upper() != (code or "").strip().upper(): + continue + boxes = [row.locator(s).first for s in sel.SEL_CPT_MOD] + values = [b.input_value(timeout=5_000) if b.count() else None + for b in boxes] + if any(_norm_mod(v) == want for v in values if v is not None): + return {"row": i, "present": True} + if values[0] is None: + return {"row": i, "present": False, "box": None} + box = boxes[0] + box.fill(modifier, timeout=10_000) + box.blur(timeout=5_000) + after = box.input_value(timeout=5_000) + return {"row": i, "present": False, "box": 1, + "before": values[0], "after": after, + "ok": _norm_mod(after) == want} + return None + + +def set_claim_status(page: Page, code: str): + """ + Choose the option whose value is `code` (e.g. "PAT", Patient) in the + shown claim form's Claim Status dropdown (`sel.SEL_CLAIM_STATUS`), then + read it back. Angular may prefix a value ("string:PAT"); either matches. + + None when no Claim Status dropdown is shown; else `{"before", "after", + "label", "ok"}` — codes, the chosen option's text, and whether `code` + is what the dropdown now holds. Already `code` → nothing chosen, "ok". + """ + box = page.locator(sel.SEL_CLAIM_STATUS).filter(visible=True) + if box.count() == 0: + return None + box = box.first + want = (code or "").strip().upper() + + def bare(value): + return str(value or "").split(":")[-1].strip().upper() + + before = bare(box.input_value(timeout=5_000)) + if before != want: + value = box.evaluate( + """(s, want) => { + const o = [...s.options].find(o => + o.value.split(':').pop().trim().toUpperCase() === want); + return o ? o.value : null; + }""", want) + if value is None: + return {"before": before, "after": before, "label": "", + "ok": False} + box.select_option(value=value, timeout=10_000) + after = bare(box.input_value(timeout=5_000)) + label = box.evaluate( + "s => s.selectedIndex < 0 ? '' : s.options[s.selectedIndex].text.trim()") + return {"before": before, "after": after, "label": label, + "ok": after == want} + + +_MOD_SETTLE_MS = 500 + + +def clear_cpt_row_modifiers(page: Page, matches) -> list: + """ + Empty M1-M4 on every real CPT row whose code satisfies `matches(code)` + and that has at least one modifier set. Built for document #12 (an H + code carries no modifier on a BH payer's claim). + + Each emptied field is blurred, the same as M1 in + `set_cpt_pos_modifier()`: eCW's `updateCPTMod(...)` runs on blur and is + what commits the change to its own model. A row with nothing set is not + touched. A missing M2-M4 input reads as "" and is skipped. + + Returns `{"row", "code", "before": [m1..m4], "after": [m1..m4], "ok"}` + per row written; `ok` is False when the read-back still shows a + modifier, so the caller can send the claim to review. + + eCW may re-price the line once the modifier changes. The Billed Fee is + read before and after, waiting up to `_UNITS_REPRICE_WAIT_MS` the same + way `set_cpt_units()` does, and a fee that moved is added to the row's + change as `"fee": {"before", "after"}` (review, 2026-10-12). + + The modifiers are read back after that wait, not straight after the + blur: `updateCPTMod` is eCW's own handler and may put a value back a + moment later, and `ok` must judge what stayed. A row with no fee input + still gets a short settle (`_MOD_SETTLE_MS`). + """ + changes = [] + rows = page.locator(sel.SEL_CPT_ROWS) + for i in range(rows.count()): + row = rows.nth(i) + code_input = row.locator(sel.SEL_CPT_CODE).first + if code_input.count() == 0: + continue + code = (code_input.input_value(timeout=5_000) or "").strip() + if not code or not matches(code): + continue + + fields = [row.locator(s).first for s in sel.SEL_CPT_MOD] + read = lambda: [f.input_value(timeout=5_000) if f.count() else "" + for f in fields] + before = read() + if not any(m.strip() for m in before): + continue + fee = row.locator(sel.SEL_CPT_BILLED_FEE).first + read_fee = lambda: ((fee.input_value(timeout=5_000) or "").strip() + if fee.count() else None) + fee_before = read_fee() + for f, value in zip(fields, before): + if value.strip(): + f.fill("", timeout=10_000) + f.blur(timeout=5_000) + fee_after = read_fee() + waited = 0 + while (fee_before is not None and fee_after == fee_before + and waited < _UNITS_REPRICE_WAIT_MS): + page.wait_for_timeout(100) + waited += 100 + fee_after = read_fee() + if fee_before is None: + page.wait_for_timeout(_MOD_SETTLE_MS) + after = read() + change = {"row": i, "code": code, "before": before, "after": after, + "ok": not any(m.strip() for m in after)} + if fee_after != fee_before: + change["fee"] = {"before": fee_before, "after": fee_after} + changes.append(change) + return changes + + +def retag_cpt_icd_pointer(page: Page, *, target_pointer: str, + target_codes) -> list: + """ + Move one ICD pointer so it is tagged on exactly the given CPT codes' + rows, and nowhere else. + + Built for the depression-DX condition (document #6): the Z13.31 ICD + pointer must be the only thing tagged on a G8510/G8431 row, and must + never appear on any other row. `target_pointer` is that ICD code's + 1-based position in the ICD Codes table (`extract()`'s `icd[i]["#"]`) — + the same number a CPT row's ICD1-4 fields hold, not the ICD code itself. + + For a row whose code is in `target_codes`: ICD1 is set to + `target_pointer` and ICD2-4 are cleared, regardless of what was there + before — that row should carry nothing but this one pointer. + + For every other row: `target_pointer` is removed from wherever it + appears among ICD1-4, and the remaining values are shifted left to + close the gap (`["1", "2", "3", "4"]` losing `"2"` becomes + `["1", "3", "4", ""]`, not `["1", "", "3", "4"]`) — a blank hole in the + middle is not how eCW's own diagnosis-pointer list is meant to look. + A row that does not carry `target_pointer` at all is left untouched. + + Returns what changed, keyed by row, the same shape + `set_cpt_pos_modifier()` uses — `{"row", "code", "before", "after"}`, + with `before`/`after` holding `{"icd": [v1, v2, v3, v4]}`. A claim + already correct makes no writes at all. + + **No blur needed.** Unlike M1 (`ng-blur="updateCPTMod(...)"`), ICD1-4 + are plain `ng-model` fields on both screens (verified live, claim + 9103000) — `fill()` alone is enough, the same as POS. + """ + changes = [] + rows = page.locator(sel.SEL_CPT_ROWS) + for i in range(rows.count()): + row = rows.nth(i) + code_input = row.locator(sel.SEL_CPT_CODE).first + if code_input.count() == 0: + continue + code = (code_input.input_value(timeout=5_000) or "").strip() + if not code: + continue + + icd_inputs = [row.locator(s).first for s in sel.SEL_CPT_ICD] + before = [inp.input_value(timeout=5_000) for inp in icd_inputs] + + if code in target_codes: + after = [target_pointer, "", "", ""] + else: + if target_pointer not in before: + continue + remaining = [v for v in before if v != target_pointer] + after = (remaining + ["", "", "", ""])[:4] + + if after == before: + continue + for inp, value in zip(icd_inputs, after): + inp.fill(value, timeout=10_000) + changes.append({"row": i, "code": code, + "before": {"icd": before}, "after": {"icd": after}}) + return changes + + +def kept_cpt_row_per_code(rows) -> dict: + """ + Which row is KEPT for each code when the same code sits on more than + one real CPT row — the ONE answer shared by + `ensure_cpt_rows_checked()` (which may only ever re-include the kept + row) and `zero_and_uncheck_duplicate_cpt_rows()` (which unchecks and + zeroes every row for the code EXCEPT the kept one). One helper, not + two copies of the rule, so the two steps can never disagree about + which row is which — the disagreement that made claim 9131000's + check/uncheck toggle (Logs\\run_20260929_161613.log). + + `rows` is `(row_index, code, fee)` per row, in on-screen order; `fee` + is the Billed Fee as read, or None for a row with no Billed Fee input. + Returns `{code: row_index}` for every code that appears. + + Operator ruling (2026-09-30): the kept row is the FIRST row for that + code whose fee is NOT zero by `is_zero_fee()`. A row with no Billed + Fee input (None), or one whose fee does not parse, is not zero — its + fee is unknown — and is eligible. Only if every row for the code is + zero-fee is the kept row the first by position. Before the ruling it + was always the first by position, so a 0.00 first G8510 with a 65.00 + copy after it kept the 0.00 one — which zero_fee_uncheck unchecks — + and zeroed the 65.00 one: no G8510 billed at all. + """ + first_by_position = {} + first_not_zero = {} + for index, code, fee in rows: + first_by_position.setdefault(code, index) + if code not in first_not_zero and (fee is None or not is_zero_fee(fee)): + first_not_zero[code] = index + return {code: first_not_zero.get(code, index) + for code, index in first_by_position.items()} + + +def _target_cpt_rows(page: Page, target_codes) -> list: + """ + `(row_index, code, fee)` for every real CPT row whose code is in + `target_codes`, in on-screen order — `kept_cpt_row_per_code()`'s + input, read off the LIVE grid. `row_index` is the row's position among + `SEL_CPT_ROWS`, the same index every row-walking helper here reports + and `_zero_and_uncheck_cpt_rows_where()` matches on; `fee` is None + when the row has no Billed Fee input. + """ + found = [] + rows = page.locator(sel.SEL_CPT_ROWS) + for i in range(rows.count()): + row = rows.nth(i) + code_input = row.locator(sel.SEL_CPT_CODE).first + if code_input.count() == 0: + continue + code = (code_input.input_value(timeout=5_000) or "").strip() + if not code or code not in target_codes: + continue + fee_input = row.locator(sel.SEL_CPT_BILLED_FEE).first + fee = ((fee_input.input_value(timeout=5_000) or "").strip() + if fee_input.count() else None) + found.append((i, code, fee)) + return found + + +def writes_allowed(page: Page | None = None) -> bool: + """ + Whether this run may change anything in eCW — LIVE-WRITE only + (SAVE_CORRECTIONS=1), and never on the Dental Claim screen. + + Found 2026-10-15, confirmed in eCW's audit log 2026-10-16: an "Assign To + Patient" click runs eCW's updateBillToIns(), which saves on the server + at once, and adding a CPT row (the picker's OK, addClaimFSCPTCode('save')) + is the same kind of call. Closing the form with Cancel undoes neither — + so VERIFY runs had been changing live claims. In VERIFY a box is only set + on screen (`_set_bill_box`) and no row is added. + + Dental claims are never saved (ruling 2026-10-12), and the Dental grid's + box runs the same updateBillToIns() — so with `page` on a Dental claim + this is False in LIVE-WRITE too (review, 2026-10-19). Callers that + write pass their page; without one, only the mode is asked. + """ + if not cfg.SAVE_CORRECTIONS: + return False + return page is None or not is_dental_claim_form(page) + + +def _set_bill_box(checkbox, want: bool) -> None: + """ + Tick or untick one "Assign To Patient" box. LIVE-WRITE: eCW's own click + (which saves at once). VERIFY, or a Dental claim: the box's `checked` + set on screen with no click event, so updateBillToIns() never runs and + nothing reaches eCW; extract() and the conditions after this read the + screen, so they see it. + """ + if writes_allowed(checkbox.page): + if want: + checkbox.check(timeout=10_000) + else: + checkbox.uncheck(timeout=10_000) + else: + checkbox.evaluate("(el, v) => { el.checked = v; }", want) + + +def ensure_cpt_rows_checked(page: Page, target_codes) -> list: + """ + Check the "Assign To Patient" checkbox on the KEPT real CPT row for + each code in `target_codes` (`kept_cpt_row_per_code()`), if it is + currently unchecked and not billed at zero. + + Built for the depression-DX condition (document #6): a live claim + (9103000) had its G8510 row's checkbox unchecked — excluded from the + claim — which needs re-including once its ICD pointer is corrected. + + Only the kept row per code, never another row carrying the same code: + those are duplicates, and `zero_and_uncheck_duplicate_cpt_rows()` owns + them. Seen live (claim 9131000, Logs\\run_20260929_161613.log): the + first G8510 was already checked and a second G8510 sat unchecked at + 0.00 — already excluded. Checking every target row checked that + duplicate here, and the duplicate step unchecked it again straight + after: nothing changed on the claim, yet it was reported as changed, + the review box listed "checkbox checked" and "checkbox unchecked" for + the same row, and the check click fired eCW's own `updateBillToIns()` + on a line that must stay excluded. Which row is kept comes from the + same `kept_cpt_row_per_code()` the duplicate step asks, so the row + this may check is never one that step unchecks — a kept row already + checked means that code needs nothing here, not that another row + should be checked instead. + + Which row that is, per the operator's ruling of 2026-09-30: the first + row for the code whose Billed Fee is NOT zero by `is_zero_fee()`. So a + 0.00 first G8510 with a 65.00 copy after it re-includes the 65.00 + copy, and the 0.00 one is the duplicate — before the ruling, "first" + was by position alone and no G8510 ended up billed. + + And never a row billed at zero — only possible for the kept row when + EVERY row for the code is zero-fee (it is then the first by position): + left unchecked, with no change reported for it. The operator's common + rule (`conditions/zero_fee_uncheck.py`, 2026-09-30) is that a line + billed at zero is never billed, and that condition runs first: it + unchecks a checked 0.00 G8510, and checking it again here would be + claim 9131000's same check/uncheck toggle, only between two conditions + instead of two steps of one. Judged by the same shared `is_zero_fee()`. + + A row with no Billed Fee input at all has no fee to judge — eligible + to be kept, and checked as before, the same way + `uncheck_zero_fee_cpt_rows()` leaves such a row alone. + + A row with no such checkbox at all — this has not been independently + confirmed on the UB form the way the HCFA screen's has — is left + alone rather than raising: this only ever adds a check, never removes + one, so a screen where it does not apply is a safe no-op, not a + failure to report. + + Returns the rows actually changed, in on-screen order, `{"row", + "code"}` — no before/after needed, since a checkbox only ever moves + one way here. + """ + targets = _target_cpt_rows(page, target_codes) + kept = kept_cpt_row_per_code(targets) + fee_at = {index: fee for index, _, fee in targets} + + changes = [] + rows = page.locator(sel.SEL_CPT_ROWS) + for code, i in sorted(kept.items(), key=lambda item: item[1]): + if fee_at[i] is not None and is_zero_fee(fee_at[i]): + continue + checkbox = rows.nth(i).locator(sel.SEL_CPT_ASSIGN_TO_PATIENT).first + if checkbox.count() == 0 or checkbox.is_checked(timeout=5_000): + continue + _set_bill_box(checkbox, True) + changes.append({"row": i, "code": code}) + return changes + + +def cpt_code_present(page: Page, code: str) -> bool: + """Whether a real CPT/HCPCS row for `code` already exists on the claim.""" + rows = page.locator(sel.SEL_CPT_ROWS) + for i in range(rows.count()): + code_input = rows.nth(i).locator(sel.SEL_CPT_CODE).first + if code_input.count() == 0: + continue + if (code_input.input_value(timeout=5_000) or "").strip() == code: + return True + return False + + +def add_cpt_row(page: Page, code: str) -> bool: + """ + Add a new CPT/HCPCS line item the way an operator actually does it — + verified against a live "Claim" screen (claim 9109000): click "Add" to + open eCW's own Fee Schedule picker, search it by code, select the one + matching row, and click the picker's own OK. Nothing here fabricates + a row's fields directly; eCW's own `addClaimFSCPTCode('save')` does. + + Returns whether a real row for `code` is found on the claim's own CPT + table afterward — verified, not assumed. On anything that does not + end in a confirmed add (the picker never opening, the search coming + back empty, OK leaving no row for the code, or any step raising — + which is re-raised once the picker is closed), the picker is closed + with its own Cancel rather than left open: a stray modal here would block + every click on the claim form behind it, the exact failure class + `dismiss_stray_modal()` exists to recover from elsewhere in this + module — better not to cause it in the first place. + + In VERIFY (or on a Dental claim) nothing is attempted and this returns + False at once, with a "[FORM] VERIFY: not adding" log line: the picker's + OK saves the row in eCW straight away (`writes_allowed()`). A caller + that must word that case apart from a failed add asks `writes_allowed()` + itself. + """ + if not writes_allowed(page): + print(f"[FORM] VERIFY: not adding {code} — the Fee Schedule picker's " + f"OK (addClaimFSCPTCode('save')) saves the row in eCW at once") + return False + add_btn = page.locator(sel.SEL_CPT_ADD_BTN).first + if add_btn.count() == 0: + return False + add_btn.click(timeout=10_000) + + # From here on the picker may be up, so EVERY way out that is not a + # confirmed add — a False return or an exception — goes through + # close_fee_schedule_picker() first. A picker left open covers the + # form's own Cancel, and close_claim() then burns every attempt on it. + try: + search = page.locator(sel.SEL_FEE_SCHEDULE_CPT_SEARCH).first + try: + search.wait_for(state="visible", timeout=10_000) + except Exception: + close_fee_schedule_picker(page) + return False + search.fill(code, timeout=10_000) + + match = page.locator(sel.SEL_FEE_SCHEDULE_RESULTS).get_by_text( + code, exact=True) + try: + match.first.wait_for(state="visible", timeout=10_000) + except Exception: + close_fee_schedule_picker(page) + return False + match.first.click(timeout=10_000) + + page.locator(sel.SEL_FEE_SCHEDULE_OK).first.click(timeout=10_000) + try: + search.wait_for(state="hidden", timeout=10_000) + except Exception: + pass + + if cpt_code_present(page, code): + return True + # OK did not add it — eCW's own validation can hold the picker up. + close_fee_schedule_picker(page) + return False + except Exception: + close_fee_schedule_picker(page) + raise + + +def close_fee_schedule_picker(page: Page) -> bool: + """ + Close eCW's Fee Schedule picker if it is still showing — with its own + Cancel, then Escape only if it is STILL up (Escape goes to the topmost + Bootstrap modal, so it is only safe while the picker is that modal; + sent blind it could close the claim form itself). Never raises. + + Returns whether the picker was showing and is now closed. + """ + try: + search = page.locator(sel.SEL_FEE_SCHEDULE_CPT_SEARCH).first + cancel = page.locator(sel.SEL_FEE_SCHEDULE_CANCEL).first + + def showing(loc) -> bool: + try: + return loc.count() > 0 and loc.is_visible() + except Exception: + return False + + if not (showing(search) or showing(cancel)): + return False + if showing(cancel): + try: + cancel.click(timeout=5_000) + except Exception: + pass + try: + search.wait_for(state="hidden", timeout=3_000) + except Exception: + pass + if showing(search): + page.keyboard.press("Escape") + try: + search.wait_for(state="hidden", timeout=3_000) + except Exception: + pass + closed = not showing(search) + print("[FORM] Closed the Fee Schedule picker" if closed + else "[FORM] Could NOT close the Fee Schedule picker — it may " + "be blocking the claim form") + return closed + except Exception as exc: + print(f"[FORM] Could not check the Fee Schedule picker: " + f"{str(exc).splitlines()[0]}") + return False + + +def _set_row_modifier(page: Page, code: str, modifier: str) -> bool: + """Set M1 (with its required blur) on the one row matching `code`.""" + rows = page.locator(sel.SEL_CPT_ROWS) + for i in range(rows.count()): + row = rows.nth(i) + code_input = row.locator(sel.SEL_CPT_CODE).first + if code_input.count() == 0: + continue + if (code_input.input_value(timeout=5_000) or "").strip() != code: + continue + mod1 = row.locator(sel.SEL_CPT_MOD1).first + mod1.fill(modifier, timeout=10_000) + mod1.blur(timeout=5_000) + return True + return False + + +def _row_modifier(page: Page, code: str) -> str | None: + """M1 as it reads now on the one row matching `code` — the read-back + for `_set_row_modifier()` — or None if no such row is on the claim.""" + rows = page.locator(sel.SEL_CPT_ROWS) + for i in range(rows.count()): + row = rows.nth(i) + code_input = row.locator(sel.SEL_CPT_CODE).first + if code_input.count() == 0: + continue + if (code_input.input_value(timeout=5_000) or "").strip() != code: + continue + mod1 = row.locator(sel.SEL_CPT_MOD1).first + if mod1.count() == 0: + return None + return mod1.input_value(timeout=5_000) + return None + + +# How long set_cpt_units() waits for eCW to re-price a line after its Units +# blur. The fixture re-prices synchronously; the live `editCPTRow('UNITS')` +# is not confirmed to — an $http round trip would land after an immediate +# read, and the operator would approve a stale one-unit fee. +_UNITS_REPRICE_WAIT_MS = 2_000 + + +def set_cpt_units(page: Page, code: str, units, row_index: int | None = None + ) -> dict | None: + """ + Set Units on one real row carrying `code`, blur it, and read the row's + Units and Billed Fee back. + + Which row: `row_index`, when given, is the row's position in + `extract()["cpt"]` (real rows that have a code, in grid order) — the + caller has already worked out which line is the new one, so nothing is + guessed, and a row there that does not carry `code` is refused (None). + Without it, the LAST row carrying `code`: a line `add_cpt_row()` has + just appended lands at the bottom, and the same code can already sit + higher up (condition #18 adds a new 90460 even when an unchecked, + zero-fee 90460 is already on the claim, as on live claim 9132000's + shape). + + **Units needs an explicit blur**, same as M1: it carries + `ng-blur="padZero($index);editCPTRow('UNITS')"`, and that handler is + what re-prices the line — `fill()` alone would leave the old one-unit + fee against the new unit count. When the Units value actually changed, + the fee is polled for up to `_UNITS_REPRICE_WAIT_MS` until it moves. + + Returns `{"row", "code", "before", "units", "fee_before", "billed_fee", + "fee_repriced"}` (read back after the blur; `fee_repriced` is None when + the Units value did not change or the row has no fee field, else + whether the fee moved), or None when there is no such row or it has no + Units field — reported by the caller. + """ + rows = page.locator(sel.SEL_CPT_ROWS) + coded = [] # (locator index, code) for rows that have a code, in order + for i in range(rows.count()): + code_input = rows.nth(i).locator(sel.SEL_CPT_CODE).first + if code_input.count() == 0: + continue + value = (code_input.input_value(timeout=5_000) or "").strip() + if value: + coded.append((i, value)) + + if row_index is not None: + if not 0 <= row_index < len(coded) or coded[row_index][1] != code: + return None + target = coded[row_index][0] + else: + matches = [i for i, value in coded if value == code] + if not matches: + return None + target = matches[-1] + + row = rows.nth(target) + units_input = row.locator(sel.SEL_CPT_UNITS).first + if units_input.count() == 0: + return None + fee = row.locator(sel.SEL_CPT_BILLED_FEE).first + + def read_fee() -> str: + return ((fee.input_value(timeout=5_000) or "").strip() + if fee.count() else "") + + before = (units_input.input_value(timeout=5_000) or "").strip() + fee_before = read_fee() + units_input.fill(str(units), timeout=10_000) + units_input.blur(timeout=5_000) + after = (units_input.input_value(timeout=5_000) or "").strip() + + billed_fee = read_fee() + fee_repriced = None + if fee.count() and after != before: + waited = 0 + while billed_fee == fee_before and waited < _UNITS_REPRICE_WAIT_MS: + page.wait_for_timeout(100) + waited += 100 + billed_fee = read_fee() + fee_repriced = billed_fee != fee_before + return { + "row": target, "code": code, "before": before, "units": after, + "fee_before": fee_before, "billed_fee": billed_fee, + "fee_repriced": fee_repriced, + } + + +def is_zero_fee(value) -> bool: + """ + Whether a Billed Fee value means the line is billed at nothing — the + ONE zero rule shared by `uncheck_zero_fee_cpt_rows()` (the operator's + common rule, `conditions/zero_fee_uncheck.py`), that condition's own + `detect()`, `kept_cpt_row_per_code()` (which of several G8510/G8431 + rows depression_dx_pointer keeps, and so which one + `ensure_cpt_rows_checked()` may re-include), `is_priced_fee()` (the + reverse half, `conditions/priced_line_check.py`) and + vaccine_admin_codes' pre-read decision. One predicate, not several + copies, so the condition that unchecks a zero-fee line and the step + that would re-check it can never disagree about the same line — the + check/uncheck toggle claim 9131000 already showed between two steps + of one condition. + + Zero: blank once stripped (`None` included — `extract()` reads a + missing field as ""), or a number equal to 0 once "$" and "," are + taken out — "0", "0.00", "0.0", ".00", "$0.00". A value that does not + parse ("N/A", "0.00 USD") is NOT zero: an unreadable fee is no + evidence the line is unbilled, and every caller leaves that line as + it is. + + `_zero_and_uncheck_cpt_rows_where()`'s own `("", "0", "0.00")` check + below asks a different question — whether a fee still needs writing + to 0.00 — and is deliberately left as it is. + """ + text = "" if value is None else str(value).strip() + if not text: + return True + try: + return float(text.replace("$", "").replace(",", "")) == 0 + except ValueError: + return False + + +def is_priced_fee(value) -> bool: + """ + Whether a Billed Fee value means the line carries a real fee — the + reverse half of the operator's common rule + (`conditions/priced_line_check.py`, 2026-09-30: "if a CPT line has a + billed fee but is unchecked, check it"), shared by that condition's + `detect()` and `check_priced_unchecked_cpt_rows()`. + + Priced: NOT zero by `is_zero_fee()`, AND a finite number greater than + 0 once "$" and "," are taken out — "150.00", "0.01", "1,200.00", + "$45.00". Built on `is_zero_fee()` rather than beside it, so no value + can ever be both zero and priced: the two halves of the rule can never + claim the same line. + + Not priced, and so left alone: blank or zero (already `is_zero_fee()`'s), + a value that does not parse ("N/A", "0.00 USD"), a negative amount + ("-5.00" — an adjustment, not a charge; none in the 39 saved + snapshots), and "nan"/"inf", which Python's `float()` accepts but no + fee ever is. Unknown must never read as "priced": that would check a + line nobody has evidence is billable. + """ + if is_zero_fee(value): + return False + try: + amount = float(str(value).strip().replace("$", "").replace(",", "")) + except ValueError: + return False + return math.isfinite(amount) and amount > 0 + + +def uncheck_zero_fee_cpt_rows(page: Page) -> list: + """ + Uncheck "Assign To Patient" on every real CPT row that is checked but + whose Billed Fee is zero by `is_zero_fee()` — the operator's common + rule for every claim, every payer and every code + (`conditions/zero_fee_uncheck.py`, agreed 2026-09-30). Live evidence: + of 39 saved claim snapshots, exactly one such line — 85018 + (Hemoglobin), checked at 0.00. + + Walks the LIVE grid, the same row-walking idiom as + `_zero_and_uncheck_cpt_rows_where()` below, rather than trusting + `extract()`'s snapshot: whatever is on screen when this runs is what + gets judged. + + Only ever unchecks. Left exactly as they are: + * an unchecked row, whatever its fee — the reverse ("unchecked with + a fee -> check it") is `check_priced_unchecked_cpt_rows()` below, + run by a separate condition registered LAST rather than here (see + `conditions/priced_line_check.py` for why the halves are split); + * a row whose fee is not zero, or does not parse; + * a row with no Billed Fee input, or no "Assign To Patient" + checkbox (`SEL_CPT_ASSIGN_TO_PATIENT` — not yet confirmed on every + UB form, see its own comment): its state is unknown, and unknown + must read neither as "zero" nor as "billed". + + **Never writes the fee** — it is only read. That is the difference + from `_zero_and_uncheck_cpt_rows_where()`, which zeroes it: a line + this touches is already at zero by definition. + + Returns the rows actually unchecked: `{"row", "code", "fee"}`, `fee` + the value as read (stripped, the same way every other helper here + reports a fee) — so the review box can show why the line counted as + zero, a blank fee included. + """ + changes = [] + rows = page.locator(sel.SEL_CPT_ROWS) + for i in range(rows.count()): + row = rows.nth(i) + code_input = row.locator(sel.SEL_CPT_CODE).first + if code_input.count() == 0: + continue + code = (code_input.input_value(timeout=5_000) or "").strip() + if not code: + continue + + checkbox = row.locator(sel.SEL_CPT_ASSIGN_TO_PATIENT).first + fee_input = row.locator(sel.SEL_CPT_BILLED_FEE).first + if checkbox.count() == 0 or fee_input.count() == 0: + continue + if not checkbox.is_checked(timeout=5_000): + continue + fee = (fee_input.input_value(timeout=5_000) or "").strip() + if not is_zero_fee(fee): + continue + + _set_bill_box(checkbox, False) + changes.append({"row": i, "code": code, "fee": fee}) + return changes + + +def check_priced_unchecked_cpt_rows(page: Page) -> list: + """ + Check "Assign To Patient" on every real CPT row that is unchecked but + whose Billed Fee is priced by `is_priced_fee()` — the reverse half of + the operator's common rule (`conditions/priced_line_check.py`, + 2026-09-30: "if a CPT line has a billed fee but is unchecked, check + it"). Live evidence: of 39 saved claim snapshots, not one such line — + this is rare, but the rule itself is general. + + The mirror image of `uncheck_zero_fee_cpt_rows()` above, and the same + row-walking idiom: the LIVE grid, not `extract()`'s snapshot. That is + load-bearing here, not only tidy: this runs after every specific + condition, and a line an exclusion condition has just unchecked and + zeroed (J2001, DCONS/Dscprit, a Medicare telehealth "other" line, a + depression duplicate) reads 0.00 by now — not priced, left alone — + even though the claim as read showed it priced. + + Only ever checks. Left exactly as they are: + * a checked row, whatever its fee — a checked zero-fee line is + `uncheck_zero_fee_cpt_rows()`'s, never this function's; + * a row whose fee is zero, blank, negative, or does not parse; + * a row with no Billed Fee input, or no "Assign To Patient" + checkbox (`SEL_CPT_ASSIGN_TO_PATIENT` — not yet confirmed on every + UB form, see its own comment): its state is unknown, and unknown + must read neither as "priced" nor as "unchecked". + + **Never writes the fee** — it is only read. + + Returns the rows actually checked: `{"row", "code", "fee"}`, `fee` the + value as read (stripped), so the review box can show why the line + counted as priced. + """ + changes = [] + rows = page.locator(sel.SEL_CPT_ROWS) + for i in range(rows.count()): + row = rows.nth(i) + code_input = row.locator(sel.SEL_CPT_CODE).first + if code_input.count() == 0: + continue + code = (code_input.input_value(timeout=5_000) or "").strip() + if not code: + continue + + checkbox = row.locator(sel.SEL_CPT_ASSIGN_TO_PATIENT).first + fee_input = row.locator(sel.SEL_CPT_BILLED_FEE).first + if checkbox.count() == 0 or fee_input.count() == 0: + continue + if checkbox.is_checked(timeout=5_000): + continue + fee = (fee_input.input_value(timeout=5_000) or "").strip() + if not is_priced_fee(fee): + continue + + _set_bill_box(checkbox, True) + changes.append({"row": i, "code": code, "fee": fee}) + return changes + + +def _zero_and_uncheck_cpt_rows_where(page: Page, matches) -> list: + """ + Shared body for `zero_and_uncheck_other_cpt_rows()`, + `zero_and_uncheck_cpt_rows()` and `zero_and_uncheck_duplicate_cpt_rows()`: + uncheck "Assign To Patient" and zero the Billed Fee on every real CPT + row satisfying `matches(row_index, code)`. + + A row with no "Assign To Patient" checkbox at all (unconfirmed on the + UB form — see `SEL_CPT_ASSIGN_TO_PATIENT`'s own comment) is not a + failure here either: the fee is still zeroed, same as + `ensure_cpt_rows_checked` treats a missing checkbox as nothing to do + rather than raising. + + Returns the rows actually changed: `{"row", "code", "unchecked"?, + "fee": {"before", "after"}?}` — only the fields that actually moved. + """ + changes = [] + rows = page.locator(sel.SEL_CPT_ROWS) + for i in range(rows.count()): + row = rows.nth(i) + code_input = row.locator(sel.SEL_CPT_CODE).first + if code_input.count() == 0: + continue + code = (code_input.input_value(timeout=5_000) or "").strip() + if not code or not matches(i, code): + continue + + row_change = {"row": i, "code": code} + touched = False + + checkbox = row.locator(sel.SEL_CPT_ASSIGN_TO_PATIENT).first + if checkbox.count() and checkbox.is_checked(timeout=5_000): + _set_bill_box(checkbox, False) + row_change["unchecked"] = True + touched = True + + fee = row.locator(sel.SEL_CPT_BILLED_FEE).first + if fee.count(): + before_fee = (fee.input_value(timeout=5_000) or "").strip() + if before_fee not in ("", "0", "0.00"): + fee.fill("0.00", timeout=10_000) + fee.blur(timeout=5_000) + row_change["fee"] = {"before": before_fee, "after": "0.00"} + touched = True + + if touched: + changes.append(row_change) + return changes + + +def zero_and_uncheck_other_cpt_rows(page: Page, keep_codes) -> list: + """ + Uncheck "Assign To Patient" and zero the Billed Fee on every real CPT + row EXCEPT the codes in `keep_codes` — the "Other Primary CPT codes + need to be unchecked and change the fee amount as Zero" half of + document condition #22. + """ + return _zero_and_uncheck_cpt_rows_where( + page, lambda i, code: code not in keep_codes) + + +def zero_and_uncheck_cpt_rows(page: Page, target_codes) -> list: + """ + Uncheck "Assign To Patient" and zero the Billed Fee on every real CPT + row whose code IS in `target_codes` — the inverse of + `zero_and_uncheck_other_cpt_rows()`. Built for document condition #5's + J2001 exclusion: "this code can be unchecked in the claim", and its + billed fee zeroed the same way condition #22's cleanup step does. + + Codes are compared ignoring case: the dummy code "Dscprit" is mixed + case as named, and no real CPT/HCPCS code differs from another by case + alone. + """ + wanted = {c.upper() for c in target_codes} + return _zero_and_uncheck_cpt_rows_where( + page, lambda i, code: code.upper() in wanted) + + +def zero_and_uncheck_cpt_rows_matching(page: Page, matches) -> list: + """ + `zero_and_uncheck_cpt_rows()` for a rule rather than a code list: + every real CPT row whose code satisfies `matches(code)`. Built for + document #12's non-BH half (an H code is not billed off a BH payer). + """ + return _zero_and_uncheck_cpt_rows_where( + page, lambda i, code: matches(code)) + + +def zero_and_uncheck_duplicate_cpt_rows(page: Page, target_codes) -> list: + """ + When a code in `target_codes` sits on more than one real CPT row, the + KEPT row (`kept_cpt_row_per_code()`) is left alone and every other one + is unchecked and fee-zeroed the same way `zero_and_uncheck_cpt_rows()` + does — "only one is enough". Built as an extra step of the + depression-DX condition (document #6): a G8510 or G8431 row can end up + duplicated on the claim, and only one copy should stay billed. + + Which copy, per the operator's ruling of 2026-09-30: the first whose + Billed Fee is NOT zero by `is_zero_fee()` (a row with no fee input + counts as not zero), or the first by position when every copy is + zero-fee. So a 0.00 first G8510 with a 65.00 copy after it keeps the + 65.00 copy, with its fee, and it is the 0.00 copy that is unchecked + here (its fee is already 0.00, so not written). Before the ruling the + first row by position was always kept: that 65.00 copy was unchecked + and zeroed, and — once zero_fee_uncheck had unchecked the 0.00 one — + no G8510 was left billed at all. The same helper decides which row + `ensure_cpt_rows_checked()` may re-include, so a row that step checks + is never one this unchecks (claim 9131000's toggle). + + Every other row is a duplicate whatever its own checked state or fee: + an already-unchecked, already-zero duplicate is simply a no-op here — + no click, no fee write, no change reported (claim 9131000's + unchecked 0.00 second G8510). + """ + targets = _target_cpt_rows(page, target_codes) + kept = kept_cpt_row_per_code(targets) + duplicate_rows = {index for index, code, _ in targets + if kept[code] != index} + + if not duplicate_rows: + return [] + return _zero_and_uncheck_cpt_rows_where( + page, lambda i, code: i in duplicate_rows) + + +def apply_medicare_telehealth_g2025(page: Page, modifier: str) -> dict: + """ + The second half of document condition #22: add G2025 (with the same + audio/video modifier already stamped on the primary row) if it is not + already on the claim, then uncheck and zero-fee every OTHER CPT row. + + The uncheck/zero step only ever runs once G2025 is confirmed on the + claim — whether it was already there or was just added. Running it + against a claim that has nothing else billable yet would leave the + claim with no billable line at all, so an add that could not be + confirmed leaves every row untouched for a human to look at, rather + than guessing that it worked. + + In VERIFY (`writes_allowed()` False) G2025 is not added — the picker's + OK saves it in eCW at once — but the other rows ARE unchecked and zeroed + on screen, as LIVE-WRITE would after the add (review, 2026-10-19): the + box is set on screen only, a typed fee is not kept on Cancel, and + without it priced_line_check, last, would check a line #22 excludes — + so VERIFY would preview a different claim from the one LIVE-WRITE + leaves. `g2025_verify_only` says so; the claim is Review, add by hand. + + Returns `{"g2025_added", "g2025_confirmed", "g2025_verify_only", + "g2025_modifier_ok", "g2025_present_modifier", + "g2025_present_modifier_ok", "zeroed"}`. + `g2025_modifier_ok` is the M1 read-back on a just-added G2025 row + (True/False), None when nothing was added (review item D6, + 2026-10-05): `_set_row_modifier()` only says it found the row, not + that the modifier took — and a G2025 line without its 93/95 is a + billing error the operator must see, not a silent success. + + `g2025_present_modifier` / `g2025_present_modifier_ok` are the same + check for a G2025 that was ALREADY on the claim (review fix round 1, + 2026-10-05): its M1 as read ("" if blank or unreadable) and whether it + equals `modifier` — both None when G2025 was not there beforehand. + Without this, a pre-existing G2025 with a blank M1, or a 95 on an + audio-only visit, was logged "already correct" and passed. It is only + read, never rewritten: a G2025 already on the claim with a different + modifier is a human's call, not the bot's. + """ + already_present = cpt_code_present(page, "G2025") + added = False + verify_only = False + modifier_ok = None + present_modifier = None + present_modifier_ok = None + if already_present: + present_modifier = _row_modifier(page, "G2025") or "" + present_modifier_ok = (_norm_mod(present_modifier) + == _norm_mod(modifier)) + elif not writes_allowed(page): + verify_only = True + print("[FORM] VERIFY: G2025 not added — the other rows are " + "unchecked / zeroed on screen only, as LIVE-WRITE would") + else: + added = add_cpt_row(page, "G2025") + if added: + _set_row_modifier(page, "G2025", modifier) + modifier_ok = (_norm_mod(_row_modifier(page, "G2025")) + == _norm_mod(modifier)) + + confirmed = already_present or added + zeroed = (zero_and_uncheck_other_cpt_rows(page, keep_codes={"G2025"}) + if confirmed or verify_only else []) + return {"g2025_added": added, "g2025_confirmed": confirmed, + "g2025_verify_only": verify_only, + "g2025_modifier_ok": modifier_ok, + "g2025_present_modifier": present_modifier, + "g2025_present_modifier_ok": present_modifier_ok, + "zeroed": zeroed} + + +# What the claim's OK will save for a Condition Code box, as far as the page +# lets it be read: the box's Angular model (`searchRecord`, two-way bound to +# Ub92Data.ConditionCodeN through select-claim-code's isolate scope) and the +# directive's own "invalid" flag. model null when there is no Angular to ask +# (the test fixture), so only the screen value is checked there. +_CONDITION_CODE_TAKEN_JS = r""" +el => { + let model = null; + try { + if (window.angular) { + let s = angular.element(el).scope(); + if (!s || typeof s.searchRecord !== 'string') { + const host = el.closest('select-claim-code'); + s = host ? angular.element(host).isolateScope() : null; + } + if (s && typeof s.searchRecord === 'string') model = s.searchRecord; + } + } catch (e) { model = null; } + return {model: model, invalid: el.classList.contains('invalid')}; +} +""" + + +def set_ub_condition_code(page: Page, code: str) -> dict: + """ + Put `code` in the UB form's Condition Codes (FL 18-28), and read it back. + + Already in any of the eleven boxes (case and spaces ignored) → nothing is + written: `{"present": True, "box": n}`. Otherwise it is typed into the + first EMPTY box — never over a code already there — and read back: + `{"present": False, "box": n, "fl": 17 + n, "after": value, "ok": bool}`. + All eleven full, or the boxes not on the page (an HCFA screen) → + `{"present": False, "ok": False, "why": reason}`; nothing written. + + `fill()` alone, no key presses and no blur: the boxes carry only + ng-focus (loads the code list), ng-keydown and ng-keyup (shows it) — + no ng-blur / ng-change save hook like the CPT line's Assign To Patient + box (Dom.txt, 2026-10-16), so the code stays on screen until OK. + + Only the UB form ON SCREEN (`SEL_UB_CONDITION_CODE` is `:visible`): eCW + leaves closed forms in the DOM. "ok" needs more than the box showing the + code — fill() just put it there: the box's Angular model must hold it + too, where it can be read, and the directive must not mark it invalid + (review, 2026-10-19). Otherwise OK would save a claim without it. + """ + want = (code or "").strip().upper() + boxes, values = [], [] + for n in range(1, sel.UB_CONDITION_CODE_BOXES + 1): + box = page.locator(sel.SEL_UB_CONDITION_CODE.format(n=n)).first + if box.count() == 0: + return {"present": False, "ok": False, + "why": "Condition Codes boxes not found on the form"} + boxes.append(box) + values.append((box.input_value(timeout=5_000) or "").strip()) + for n, value in enumerate(values, start=1): + if value.upper() == want: + return {"present": True, "box": n} + empty = next((i for i, v in enumerate(values) if not v), None) + if empty is None: + return {"present": False, "ok": False, + "why": f"all {len(values)} Condition Code boxes are full"} + box = boxes[empty] + box.fill(want, timeout=10_000) + after = (box.input_value(timeout=5_000) or "").strip() + taken = box.evaluate(_CONDITION_CODE_TAKEN_JS) or {} + model = taken.get("model") + ok = (after.upper() == want and not taken.get("invalid") + and (model is None or model.strip().upper() == want)) + done = {"present": False, "box": empty + 1, "fl": 18 + empty, + "after": after, "ok": ok} + if after.upper() == want and not ok: + done["why"] = ("eCW marked it invalid" if taken.get("invalid") else + f"the box shows it but its model holds {model!r}") + return done + + +def save_claim(page: Page) -> bool: + """ + Close the form with **OK** — the one place in this module that writes. + + `#UBClaimBtn18` calls `saveAllData(true)`. Everything else in this module + closes with Cancel (`close_claim`); this is only ever reached after a + correction has been applied to the DOM AND a human has explicitly + confirmed it — never automatically. + + Tries the screen's own OK first — the UB form's, or the professional/ + HCFA "Claim" screen's differently-shaped one (`SEL_OK_HCFA` — verified + against a live claim: `saveAllData()`, no `true` argument, on a + per-open-timestamped id) — decided by `is_ub_claim_form()`, then falls + back to the other regardless. Mirrors `close_claim()`'s reasoning: the UB + selector does not exist at all on the HCFA screen, so trying it first on + every HCFA claim would waste 15s before ever reaching the one that works. + """ + if is_ub_claim_form(page): + primary, fallback = (sel.SEL_OK, "UB OK"), (sel.SEL_OK_HCFA, "the HCFA screen's OK") + else: + primary, fallback = (sel.SEL_OK_HCFA, "HCFA OK"), (sel.SEL_OK, "the UB screen's OK") + + try: + page.locator(primary[0]).first.click(timeout=15_000) + page.wait_for_selector(sel.SEL_MODAL, state="hidden", timeout=60_000) + return True + except Exception as exc: + print(f"[FORM] {primary[1]} did not save " + f"({str(exc).splitlines()[0]}) — trying {fallback[1]}") + try: + page.locator(fallback[0]).first.click(timeout=10_000) + page.wait_for_selector(sel.SEL_MODAL, state="hidden", timeout=30_000) + return True + except Exception as exc: + shot(page, "claim_form_save_failed") + print(f"[FORM] Could not save the claim: {str(exc).splitlines()[0]}") + return False + + +# ============================================================================= +# Extraction +# ============================================================================= + +def extract(page: Page) -> dict: + """Every field on the open UB Claim form, as one dict.""" + data = page.evaluate(_EXTRACT_JS) + data["read_at"] = datetime.now().isoformat(timespec="seconds") + return data + + +def save(data: dict): + r""" + Write one claim's data to `Output\claims\<claim_no>.json`. + + In `Output\`, not `Input\`: this *is* output. `Input\` holds the report the + run consumes, and mixing the two would leave the next phase unable to tell + what it is meant to read. + + Persisted as well as returned, because a dict in a variable is gone the + moment the run ends and this is the record of what a claim said at the + moment the bot looked at it. Gitignored — patient data. + """ + out = cfg.ROOT / "Output" / "claims" + out.mkdir(parents=True, exist_ok=True) + name = re.sub(r"[^0-9A-Za-z_-]", "_", str(data.get("claim_no") or "unknown")) + target = out / f"{name}.json" + target.write_text(json.dumps(data, indent=2, ensure_ascii=False), + encoding="utf-8") + return target + + +def _shown(value) -> str: + """ + A field's value as a message box should print it, or "" to leave the + line out. + + eCW leaves a field blank, or the extractor falls back to "?", whenever a + form shape does not carry it — the HCFA screen has no UB claim-number + field, and a Dental read carries none of these at all. A page of + "Patient : ? ... Charges : ?" in front of the client reads as a bot that + could not read the claim, when the field simply is not on that form + (review item D9, 2026-10-05). So a line with nothing real to say is + dropped instead. + """ + text = "" if value is None else str(value).strip() + return "" if text in ("", "?") else text + + +def _field_lines(*pairs) -> list: + """`label + value` for every (label, value) pair `_shown()` keeps.""" + return [f"{label}{_shown(value)}" for label, value in pairs + if _shown(value)] + + +def summary(data: dict) -> str: + """ + The extracted claim as text for a message box. + + A field that came back empty or "?" is left out (see `_shown`), never + printed as "?". The counts line and the CPT / progress-note blocks stay + as they are: a count of 0 is a fact worth seeing, not a blank. + """ + pat = data.get("patient") or {} + hdr = data.get("header") or {} + tot = data.get("totals") or {} + lines = [] + if data.get("_not_evaluated"): + # First, above the numbers it qualifies (see NOT_LOADED_NOTE). + lines += [NOT_LOADED_NOTE, f"({data['_not_evaluated']})", ""] + # Composite lines keep whichever half is real: "Name (34 F)", or just + # the name — never "Name ()" or "? (?)". + name, age_sex = _shown(pat.get("name")), _shown(pat.get("age_sex")) + label = _shown(hdr.get("claim_status_label")) + code = _shown(hdr.get("claim_status_code")) + fields = _field_lines( + ("Claim No : ", data.get("claim_no")), + ("Patient : ", f"{name} ({age_sex})" if name and age_sex + else name), + ("Acct No : ", pat.get("account_no")), + ("Claim Date : ", hdr.get("claim_date")), + ("Status : ", f"{label} ({code})" if label and code + else label or code), + ("Facility : ", hdr.get("facility")), + ) + if fields: + lines += fields + [""] + lines.append( + f"ICD {len(data.get('icd') or [])} " + f"CPT {len(data.get('cpt') or [])} " + f"Revenue {len(data.get('revenue') or [])} " + f"Ins {len(data.get('insurance') or [])} " + f"Pmts {len(data.get('payments') or [])} " + f"Errors {len(data.get('errors') or [])}") + money = _field_lines(("Charges : ", tot.get("charges")), + ("Balance : ", tot.get("balance"))) + if money: + lines += [""] + money + cpt = data.get("cpt") or [] + if cpt: + lines.append("") + lines.append("CPT/HCPCS:") + for row in cpt[:5]: + lines.append(f" {row.get('code', ''):<8} {row.get('sdos', ''):<11}" + f" u{row.get('units', ''):<4} {row.get('billed_fee') or ''}") + if len(cpt) > 5: + # English: the client reads these boxes (review item D9 — this + # used to be a Tamil word). + lines.append(f" ... and {len(cpt) - 5} more") + + # The note's own facts, not just "it opened". Its Assessments and + # Procedure Codes are the same ICD and CPT the form above shows, so + # putting both in one box is what lets a mismatch be spotted at a glance. + pn = data.get("progress_notes") or {} + if pn: + lines.append("") + opened = _shown(pn.get("opened")) + lines.append("Prog. Notes" + (f": {opened}" if opened else "")) + detail = progress_note.summary(pn) + if detail: + lines.extend(" " + one for one in detail.splitlines()) + return "\n".join(lines) diff --git a/src/ecw_bot/pages/claim_report.py b/src/ecw_bot/pages/claim_report.py new file mode 100644 index 0000000..cff050d --- /dev/null +++ b/src/ecw_bot/pages/claim_report.py @@ -0,0 +1,377 @@ +""" +Export — Billing → View Claims Report → EXCEL. + +Two traps on this menu, both of which produce a run that *looks* clean: + + * **CSV and EXCEL share an id.** Both are `#claimLookupBtn1002`, so `.first` + exports CSV: a real file downloads, the log reads fine, and the format is + wrong. The format is matched on `ng-click` instead — see + `selectors.claim_lookup.report_item`. + * **`download.save_as()` copies.** Chromium's `_pw_downloads\\<guid>` survives + it, verified byte-identical to the saved report. Left in place, the orphan + sweep at the top of the next export would adopt it and write a second copy + of a report already saved, so the temp file is deleted explicitly. + +`_restore_view` exists because Playwright scrolls an element into view before +clicking it, so reaching this toolbar scrolls the filter form off the top and +nothing scrolls it back — reported as "the portal looks broken" when the form +was simply above the viewport. +""" + +from datetime import datetime +from pathlib import Path + +from playwright.sync_api import Page + +from .. import settings as cfg +from ..core import msgbox as ui +from ..core.page_utils import shot, visible, appears, reset_scroll +from ..selectors import claim_lookup as sel +from .claim_results import _result_count + + +def _open_billing_menu(page: Page) -> None: + """Open the toolbar's Billing dropdown, unless it is already open.""" + if visible(page, sel.SEL_BILLING_MENU, timeout=1_000): + print("[CLAIMS] Billing menu already open") + return + page.locator(sel.SEL_BILLING_BTN).first.click(timeout=30_000) + page.wait_for_selector(sel.SEL_BILLING_MENU, state="visible", timeout=30_000) + print("[CLAIMS] Billing menu opened") + + +def _open_report_submenu(page: Page) -> None: + """ + Reveal the "View Claims Report" submenu. + + Hover first. It is a Bootstrap `dropdown-submenu` and its button has no + ng-click at all, so hovering is what a user actually does. A click is tried + only as a fallback, because on a submenu a click can just as easily close + the parent dropdown as open the child. + """ + item = page.locator(sel.SEL_VIEW_REPORT).first + item.wait_for(state="visible", timeout=30_000) + + item.hover(timeout=15_000) + page.wait_for_timeout(600) + # appears(), not visible(): the 1.5s was always meant as the hover's grace + # period, but visible() never waited (Playwright 1.62 ignores is_visible's + # timeout — review item P8), so a submenu slower than the 600 ms above + # fell through to the click this docstring warns against. + if appears(page, sel.SEL_REPORT_MENU, timeout=1_500): + print("[CLAIMS] Report submenu opened (hover)") + return + + item.click(timeout=15_000) + try: + page.wait_for_selector(sel.SEL_REPORT_MENU, state="visible", + timeout=15_000) + except Exception: + shot(page, "claims_report_submenu_failed") + raise RuntimeError( + f"{sel.SEL_VIEW_REPORT} would not reveal {sel.SEL_REPORT_MENU} on " + f"hover or click — the submenu never opened") + print("[CLAIMS] Report submenu opened (click)") + + +def _free_path(target): + """ + `target`, or `target` with a counter, so nothing on disk is overwritten. + + Used for the rescued orphans and for standing aside an earlier copy of + today's report. The files here hold patient data and feed the next phase, + so silently replacing one is not an acceptable way to handle a same-day + re-run — and same-day re-runs are the norm, not the exception. + """ + if not target.exists(): + return target + for n in range(2, 100): + alt = target.with_name(f"{target.stem} ({n}){target.suffix}") + if not alt.exists(): + return alt + return target.with_name(f"{target.stem} ({datetime.now():%H%M%S})" + f"{target.suffix}") + + +def _archive_old(): + r""" + Move every report already in `Input\` into `Input\Archive\`. + + Called just before the new download is saved, so `Input\` ends up holding + **exactly one report** - always the current one. + + Why a folder and not a suffix: the previous approach renamed the old file + to `Claim Report_09-07-2026 (2).xlsx` and left it there. After three days + of testing Input\ held six reports, and at that point "which one is + current?" is a question - to a person opening the folder, and to any code + that has to find the file without being told its name. One file in the + folder makes the answer structural instead of a convention. + + Moved, never deleted. They are patient data, and each one is the evidence + of what an earlier run actually worked from. + + The archived name carries the file's own modification time - + `Claim Report_09-07-2026__20260907_200644.xlsx` - so two runs on the same + day cannot collide and the order stays readable at a glance. + """ + if not cfg.ARCHIVE_OLD_INPUTS or not cfg.INPUTS.exists(): + return [] + + # Reports and rescued orphans. NOT _pw_downloads (Chromium owns it) and + # not Archive itself. + old = [f for f in cfg.INPUTS.iterdir() + if f.is_file() and (f.name.startswith(cfg.CLAIM_REPORT_NAME) + or f.name.startswith("rescued_"))] + if not old: + return [] + + cfg.INPUTS_ARCHIVE.mkdir(parents=True, exist_ok=True) + moved = [] + for f in sorted(old, key=lambda x: x.stat().st_mtime): + stamp = datetime.fromtimestamp(f.stat().st_mtime).strftime( + "%Y%m%d_%H%M%S") + target = _free_path( + cfg.INPUTS_ARCHIVE / f"{f.stem}__{stamp}{f.suffix}") + try: + f.replace(target) + moved.append(target) + except Exception as exc: + # Not fatal: the new report still saves. But say it loudly, because + # a report left behind is exactly the ambiguity this removes. + print(f"[CLAIMS] Could not archive {f.name}: " + f"{str(exc).splitlines()[0]}") + if moved: + print(f"[CLAIMS] Archived {len(moved)} earlier report(s) to " + f"{cfg.INPUTS_ARCHIVE.name}\\ - Input has only the new one now") + return moved + + +def _rescue_orphans() -> list: + """ + Move leftover Playwright downloads into `Input\\` with a real name. + + Chromium writes a download to `Input\\_pw_downloads\\<guid>` with no + extension and Playwright only gives it a name when `download.save_as()` + runs. A run killed between those two points therefore leaves a complete, + correct report on disk that looks like nothing at all — a 552 KB xlsx sat + there unnoticed while the export was being reported as broken. Anything in + that folder came from this bot's own browser, so sweeping it is safe. + """ + if not cfg.DOWNLOADS_TMP.exists(): + return [] + + cfg.INPUTS.mkdir(parents=True, exist_ok=True) + rescued = [] + for f in sorted(cfg.DOWNLOADS_TMP.iterdir()): + if not f.is_file() or f.stat().st_size == 0: + continue + head = f.open("rb").read(8) + # Sniffed, not assumed: eCW's "Excel" export has been both a real xlsx + # (a PK zip) and an HTML table served as .xls, and naming an HTML file + # .xlsx makes Excel refuse to open it. + if head.startswith(b"PK"): + ext = ".xlsx" + elif head.startswith(b"\xd0\xcf\x11\xe0"): + ext = ".xls" + elif head.lstrip()[:1] in (b"<",): + ext = ".xls" # HTML table — Excel opens it, the browser too + else: + ext = ".bin" + # Deliberately NOT the plain `Claim Report_<date>` name the export + # writes: an orphan came from a run that died, so it may be truncated + # or from a different filter set. The next phase must not pick it up by + # accident — it is kept so a human can look at it, and marked as + # rescued so nobody mistakes it for today's real report. + stamp = datetime.fromtimestamp(f.stat().st_mtime).strftime( + "%Y%m%d_%H%M%S") + target = _free_path(cfg.INPUTS / f"rescued_{stamp}{ext}") + f.replace(target) + rescued.append(target) + print(f"[CLAIMS] Rescued an orphaned download from a killed run: " + f"{target.name} ({target.stat().st_size:,} bytes)") + return rescued + + +def _restore_view(page: Page) -> None: + """ + Put the screen back the way the operator expects to find it. + + Playwright scrolls an element into view before clicking it, so reaching the + toolbar's Billing menu scrolls the whole filter form off the top — and + nothing scrolls it back. The run then ends on a page showing six grid rows, + an eCW spacer and a lot of white, with the report dropdown still hanging + open. That looks like a broken portal and is not: the form is simply above + the viewport. Reported as a bug once already, so it gets undone here rather + than explained again. + + Never fatal — the file is already saved by the time this runs. + """ + try: + page.keyboard.press("Escape") + page.wait_for_timeout(200) + # Escape does not always close a Bootstrap dropdown; toggling the button + # that opened it does. Only if it is genuinely still showing. + if visible(page, sel.SEL_BILLING_MENU, timeout=500): + page.locator(sel.SEL_BILLING_BTN).first.click(timeout=5_000) + page.wait_for_timeout(200) + except Exception as exc: + print(f"[CLAIMS] Could not close the Billing menu: " + f"{str(exc).splitlines()[0]}") + + # `window.scrollTo(0, 0)` was here and did nothing at all — claims_report_done + # came back still scrolled with the dropdown correctly closed, which is what + # ruled the scroll half out. On this SPA the document does not scroll; an + # inner container does, so scrolling the window is a no-op on an element + # that was never offset. Let Playwright find the right container instead: + # scroll_into_view_if_needed walks up to whatever actually scrolls. + try: + page.locator(sel.SEL_CLAIM_STATUS).first.scroll_into_view_if_needed( + timeout=5_000) + except Exception as exc: + print(f"[CLAIMS] Could not scroll the form back: " + f"{str(exc).splitlines()[0]}") + + # Belt and braces, and it names the real container in the log so the next + # person does not have to rediscover that the window is not the scroller. + moved = reset_scroll(page) + if moved: + print(f"[CLAIMS] Scrolled back to the top: {', '.join(moved[:4])}") + page.wait_for_timeout(300) + + +def _notify_export(page: Page, target, fmt: str) -> None: + """ + Show the operator how the step finished. + + Raised after the file is already on disk, so nothing here can lose the + export — if nobody clicks OK it times out and the run carries on. The row + count is read off the grid rather than remembered, so the box reports what + eCW actually returned. + """ + if not cfg.NOTIFY_AFTER_EXPORT: + return + count = _result_count(page) + if target is not None: + size = target.stat().st_size if target.exists() else 0 + body = (f"Claim Lookup finished.\n\n" + f"Total Counts : {count}\n" + f"Format : {fmt}\n" + f"File : {target.name}\n" + f"Size : {size:,} bytes\n" + f"Folder : {cfg.INPUTS}") + if size == 0: + body += ("\n\nThe file is empty — likely means no filter " + "matched anything.") + else: + body = (f"Lookup ran, but the {fmt} download did not arrive.\n\n" + f"Total Counts : {count}\n\n" + f"Check claims_report_nodownload.png in the screenshots " + f"folder — the log has the new tab's URL if there is one.") + + ui.info(f"eCW Bot — {fmt} report", body, cfg.MSGBOX_TIMEOUT_SECONDS) + + +def export_claims_report(page: Page, fmt: str = None): + """ + Export the current result set: Billing → View Claims Report → EXCEL. + + Returns the saved `Path`, or None when eCW answered with something other + than a download. It does not raise in that case: the search has already + succeeded and the browser is still open, so reporting what did happen is + more useful than throwing away the session. + """ + fmt = (fmt or cfg.CLAIM_REPORT_FORMAT).strip().upper() + + # Anything already sitting in the temp folder is from a previous run that + # died before it could name its file. Clear it first, so the "did a file + # arrive?" check below is about this run only. + _rescue_orphans() + _open_billing_menu(page) + _open_report_submenu(page) + shot(page, "claims_report_menu") + + item = page.locator(sel.report_item(fmt)).first + try: + item.wait_for(state="visible", timeout=15_000) + except Exception: + # Name what is on offer — a renamed format is a one-line fix. + try: + offered = page.locator( + f"{sel.SEL_REPORT_MENU} button").all_inner_texts() + except Exception: + offered = [] + shot(page, "claims_report_failed") + raise RuntimeError( + f"No {fmt!r} in the report submenu. It offers: {offered}") + + before = len(page.context.pages) + print(f"[CLAIMS] Exporting {fmt} — eCW builds this server-side, so it can " + f"take a while") + try: + with page.expect_download(timeout=cfg.DOWNLOAD_TIMEOUT) as dl: + item.click(timeout=30_000) + download = dl.value + except Exception as exc: + shot(page, "claims_report_nodownload") + print(f"[CLAIMS] No download arrived: {str(exc).splitlines()[0]}") + # eCW may have opened the report in a tab instead of sending a file. + # Say so with the URL rather than guessing — that is what the next step + # needs, and it costs no extra login attempt to find out. + extra = page.context.pages[before:] + if extra: + for p in extra: + print(f"[CLAIMS] eCW opened a new tab instead: {p.url}") + else: + print("[CLAIMS] No new tab either — check " + "claims_report_nodownload.png for a dialog or an error") + # The file may have landed anyway and only the event been missed. + late = _rescue_orphans() + _restore_view(page) + if late: + _notify_export(page, late[-1], fmt) + return late[-1] + _notify_export(page, None, fmt) + return None + + cfg.INPUTS.mkdir(parents=True, exist_ok=True) + + # `Claim Report_09-07-2026.xlsx` — a fixed, predictable name, because the + # charge-entry phase has to find this file without being told where it is. + # eCW's own name is a timestamp (`CwReport_1788438162717.xlsx`), which is + # useless for that. + # + # The suffix comes from what eCW actually sent, not from the format asked + # for: its "EXCEL" export has been a real xlsx (a PK zip) and, on other + # builds, an HTML table — and calling HTML `.xlsx` makes Excel refuse it. + suggested = download.suggested_filename or f"claims_{fmt.lower()}.xlsx" + suffix = Path(suggested).suffix or ".xlsx" + target = cfg.INPUTS / f"{cfg.CLAIM_REPORT_NAME}_{cfg.TODAY}{suffix}" + + # Every earlier report moves out of Input\ before the new one lands, so + # Input\ holds exactly one file and "which report is current?" has one + # answer rather than a rule someone has to remember. See _archive_old(). + _archive_old() + + download.save_as(str(target)) + # save_as COPIES — Chromium's `_pw_downloads\<guid>` survives it, verified + # byte-identical to the saved report. Left in place, _rescue_orphans() at + # the top of the next export would adopt it and write a second copy of a + # report that was already saved. Drop it now that the real file exists. + try: + download.delete() + except Exception as exc: + print(f"[CLAIMS] Temp copy not removed: {str(exc).splitlines()[0]}") + size = target.stat().st_size if target.exists() else 0 + print(f"[CLAIMS] Saved {target.name} ({size:,} bytes) to {cfg.INPUTS}") + if size == 0: + print("[CLAIMS] WARNING: the file is empty — eCW returned a download " + "with no content, which usually means the filter matched nothing") + + # Close the dropdown AND scroll back to the top — see _restore_view. The + # blind mouse.click(5, 5) that used to stand here closed nothing reliably + # and, on a scrolled page, 5,5 can land on a grid row's checkbox. + _restore_view(page) + shot(page, "claims_report_done") + + _notify_export(page, target, fmt) + return target diff --git a/src/ecw_bot/pages/claim_results.py b/src/ecw_bot/pages/claim_results.py new file mode 100644 index 0000000..4cb314a --- /dev/null +++ b/src/ecw_bot/pages/claim_results.py @@ -0,0 +1,385 @@ +""" +The results grid — press Lookup, wait for it honestly, report what came back. + +The waiting is the whole substance of this module. `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. +That call printed nothing for its full timeout, every run looked hung and was +killed at exactly that line — which is why the export step had never once run. + +So the grid's own "Total Counts" is polled instead, and a figure has to hold +across several reads before it is believed. A single changed reading is not +enough: eCW blanks the count to 0 the instant Lookup is pressed, and accepting +that transient logged "0 rows" for a search that returned 894. +""" + +import re +import time + +from playwright.sync_api import Page + +from .. import settings as cfg +from ..core import msgbox as ui +from ..core.page_utils import shot, visible, text_of, is_disabled +from ..selectors import claim_lookup as sel +from ..selectors import shell +from . import claim_filters +from .claim_filters import current_date_type + + +def _filter_summary(page: Page) -> str: + """ + What the form actually says, read back off the page. + + Read rather than reconstructed from config on purpose: the point is to show + the operator what eCW is about to search on, which is not necessarily what + the bot meant to set. + """ + status = "?" + try: + opt = page.locator(f"{sel.SEL_CLAIM_STATUS} option:checked").first + status = opt.inner_text(timeout=5_000).strip() + except Exception: + pass + # Reported off the row's real state: an empty value means no balance filter + # whether or not the control happens to be greyed out. + val1 = text_of(page, sel.SEL_BAL_VAL1) + off = " (disabled)" if is_disabled(page, sel.SEL_BAL_VAL1) else "" + if val1 in ("", "?"): + balance = f"empty — no filter{off}" + else: + balance = f"{text_of(page, sel.SEL_BAL_OP1)} {val1}{off}" + + return (f"Claim Status : {status}\n" + f"Date type : {current_date_type(page)}\n" + f"From : {text_of(page, sel.SEL_DATE_FROM)}\n" + f"To : {text_of(page, sel.SEL_DATE_TO)}\n" + f"Balance : {balance}") + + +def _result_count(page: Page, timeout: int = 10_000) -> str: + """ + The grid's own "Total Counts" figure. + + Worth pulling into the log: this run returned 0 and the only way to see + that was a screenshot. Matched on the label text because the footer's + element ids have not been captured — best effort, never fatal. The timeout + is a parameter because the polling loop below calls this repeatedly and must + not spend ten seconds per attempt. + """ + try: + el = page.locator("text=/Total Counts/i").first + el.wait_for(state="attached", timeout=timeout) + except Exception: + return "?" + + # The label and the figure are separate elements: the match itself is only + # "Total Counts :" and 828 lives in a sibling. Reading the label alone + # returned the label as the count, which then never changed between polls — + # so the grid always looked "settled". Walk up until the number is in scope. + node = el + for _ in range(4): + try: + txt = node.inner_text(timeout=timeout) + except Exception: + break + m = re.search(r"Total\s*Counts\s*:?\s*([\d,]+)", txt, re.I) + if m: + return m.group(1) + node = node.locator("xpath=..") + return "?" + + +# How many consecutive identical readings of Total Counts count as settled. +# At LOOKUP_POLL_SECONDS=3 that is ~6s of agreement, ~12s for a 0. +_STABLE_POLLS = 2 + + +def _wait_for_results(page: Page, was: str) -> str: + """ + Wait for the grid to come back after Lookup, and narrate the wait. + + Replaces `wait_for_load_state("networkidle")`, which was the reason this + step never finished: eCW's Angular shell polls in the background, so the + network is never idle for the 500 ms Playwright wants. That call printed + nothing for its full timeout, so every run looked hung and was killed at + exactly this line — which is why the export below had never once run. + + `was` is the count showing *before* the click, kept only to say in the log + whether the figure moved. + + **A single changed reading is not enough**, which cost a run: eCW blanks + Total Counts to 0 the instant Lookup is pressed, so the first poll saw + "0 != 894", declared "Results in after 0s" and logged a row count of 0 for + a search that returned 894. So a value has to hold across + `_STABLE_POLLS` consecutive reads before it is believed, and a 0 — the one + value that is both a legitimate answer and the transient — has to hold for + twice as long. + """ + deadline = time.time() + cfg.LOOKUP_TIMEOUT / 1000 + said_at = 0.0 + started = time.time() + seen = None + streak = 0 + + while time.time() < deadline: + now = time.time() + count = _result_count(page, timeout=2_000) + + if count != "?": + streak = streak + 1 if count == seen else 1 + seen = count + # 0 is indistinguishable from "still querying" on one reading, so it + # is held to a longer streak than any other figure. + need = _STABLE_POLLS * 2 if count == "0" else _STABLE_POLLS + settled = (streak >= need + and not visible(page, shell.SEL_ROUTE_LOADER, timeout=300)) + if settled: + how = "changed from " + was if was not in (count, "?") else \ + "unchanged at " + count + print(f"[CLAIMS] Results settled after {int(now - started)}s " + f"({how})") + return count + + if now - said_at >= 15: + said_at = now + print(f"[CLAIMS] Still running — {int(now - started)}s waited, " + f"{int(deadline - now)}s left, reading {seen or '?'}") + page.wait_for_timeout(cfg.LOOKUP_POLL_SECONDS * 1_000) + + print(f"[CLAIMS] Grid did not settle within " + f"{int(cfg.LOOKUP_TIMEOUT / 1000)}s — carrying on anyway. Raise " + f"LOOKUP_TIMEOUT_MS in .env if this range is genuinely that slow") + return _result_count(page) + + +def run_lookup(page: Page) -> bool: + """ + Press Lookup and wait for eCW to come back with the result set. + + Deliberately `#btnclaimlookup` and not a text match: `#btnclaimlookupClear` + sits directly beside it and would wipe the form that was just filled in. + """ + btn = page.locator(sel.SEL_LOOKUP_BTN).first + btn.wait_for(state="visible", timeout=cfg.NAV_TIMEOUT) + + summary = _filter_summary(page) + for line in summary.splitlines(): + print(f"[CLAIMS] {line}") + + if cfg.CONFIRM_BEFORE_LOOKUP: + shot(page, "claims_before_lookup") + result = ui.confirm( + "eCW Bot — Run the Lookup?", + f"Claim Lookup filters:\n\n{summary}\n\n" + f"Click OK if these look right.\n" + f"Click Cancel to stop.\n\n" + f"If nothing is clicked, Lookup will run automatically after " + f"{cfg.MSGBOX_TIMEOUT_SECONDS}s.", + cfg.MSGBOX_TIMEOUT_SECONDS, + ) + # A timeout carries on; only Cancel aborts. This used to raise, and it + # cost a completed run: a 165s login and a mailed 2FA approval link were + # thrown away because nobody was at the desk within 180s. Proceeding is + # safe — the lookup and the export only READ, nothing is written back to + # eCW — and the filters are in the log and the screenshot either way. + if result == ui.TIMEOUT: + print(f"[CLAIMS] Nobody answered in {cfg.MSGBOX_TIMEOUT_SECONDS}s " + f"— carrying on with the filters above. Set " + f"CONFIRM_BEFORE_LOOKUP=0 to skip the box entirely") + elif result != ui.OK: + raise RuntimeError("Lookup cancelled by operator") + + # Read before the click: the grid keeps showing the previous result set + # while the new query runs, so this is what "it changed" is measured against. + was = _result_count(page, timeout=2_000) + + btn.click(timeout=30_000) + print("[CLAIMS] Lookup clicked") + + count = _wait_for_results(page, was) + page.wait_for_timeout(1_000) + shot(page, "claims_results") + + print(f"[CLAIMS] Lookup returned — Total Counts: {count}") + if count == "0": + print("[CLAIMS] Zero rows. The grid may still show the previous " + "result set, so trust this number over the screenshot. Check " + "the Claim Status / date type pairing first.") + return True + + +# ============================================================================= +# Single-claim search +# ============================================================================= + +def _row_for(page: Page, claim_id: str): + """The grid cell showing `claim_id` — the claim number is a link there.""" + return page.get_by_text(str(claim_id), exact=True).first + + +# For each [name, selector]: the first shown match must be the element a +# click at its centre lands on. What covers it is named by tag, id and class +# only — never its text, which can be a patient's. +_SEARCH_BLOCKER_JS = r""" +(pairs) => { + const shown = el => { + const r = el.getBoundingClientRect(); + return r.width > 0 && r.height > 0 + && getComputedStyle(el).visibility !== 'hidden'; + }; + const name = el => el.tagName.toLowerCase() + + (el.id ? '#' + el.id : '') + + (typeof el.className === 'string' && el.className.trim() + ? '.' + el.className.trim().split(/\s+/)[0] : ''); + for (const [label, s] of pairs) { + const el = [...document.querySelectorAll(s)].find(shown); + if (!el) return label + ' is not on screen'; + if (el.disabled) return label + ' is disabled'; + el.scrollIntoView({block: 'nearest', inline: 'nearest'}); + const r = el.getBoundingClientRect(); + const hit = document.elementFromPoint(r.left + r.width / 2, + r.top + r.height / 2); + if (!hit) return label + ' is outside the window'; + // Nothing over it, but the click falls through to the page itself: + // eCW turns pointer events off while it loads (live run 2026-10-13, + // claim 9126000 — "covered by html.jelly-page" under its spinner). + if (hit === document.documentElement || hit === document.body) + return label + ' is not taking clicks yet (eCW still loading)'; + if (hit !== el && !el.contains(hit)) { + const over = hit.closest('.modal, [role="dialog"], ' + + '.claimModalBackdrop, .modal-backdrop') || hit; + return label + ' is covered by ' + name(over); + } + } + return null; +} +""" + + +def search_blocker(page: Page) -> str | None: + """ + Why a claim cannot be searched right now, or None when it can: Claim No + and Lookup are both on screen, enabled, and nothing lies over them. + + The check before every search (operator, 2026-10-12). Live run that + day: claim 1's progress note viewer stayed open and the next two claims + each spent 30 s on a Lookup click its iframe swallowed, then went down + as "search failed" with a timeout that said nothing about why. Asked + first, the answer is instant and names what is in the way. A page error + while asking is reported as the reason too — never raises. + + eCW's route spinner on screen is a reason too: the page is still + loading. One answer is one moment — `wait_search_ready` gives it time. + """ + if visible(page, shell.SEL_ROUTE_LOADER): + return "eCW is still loading (its spinner is on screen)" + try: + return page.evaluate(_SEARCH_BLOCKER_JS, [ + ["Claim No", sel.SEL_CLAIM_NO], ["Lookup", sel.SEL_LOOKUP_BTN]]) + except Exception as exc: + return f"the page could not be checked ({str(exc).splitlines()[0]})" + + +# How long a search page that is not ready is given to come free before the +# claim is a failed search. Live run 2026-10-13, claim 9126000: the claim +# before it closed after its review box, eCW was still loading behind its +# spinner, and the one instant check made the claim Review — a few seconds +# later the page was fine (claim 7 of that run, the same moment, passed). +_SEARCH_READY_MS = 20_000 +_SEARCH_READY_POLL_MS = 500 + + +def wait_search_ready(page: Page, claim_id: str) -> str | None: + """ + `search_blocker`, given up to `_SEARCH_READY_MS`: None as soon as the + page is ready, else the last reason it was not. Says in the log when it + had to wait, and how long it took. Never raises. + """ + blocker = search_blocker(page) + if not blocker: + return None + print(f"[CLAIM {claim_id}] Search page not ready yet ({blocker}) — " + f"waiting up to {_SEARCH_READY_MS // 1000}s") + started = time.monotonic() + deadline = started + _SEARCH_READY_MS / 1000 + while time.monotonic() < deadline: + page.wait_for_timeout(_SEARCH_READY_POLL_MS) + blocker = search_blocker(page) + if not blocker: + print(f"[CLAIM {claim_id}] Search page came free after " + f"{time.monotonic() - started:.1f}s") + return None + return blocker + + +def search_claim(page: Page, claim_id: str) -> dict: + """ + Type one claim id into Claim No, press Lookup, and confirm it came back. + + Returns `{"id", "count", "found", "seconds"}` and does not raise on a miss: + a claim that no longer matches the filters is a fact about the queue, not a + bot failure, and the loop over 541 of them must not stop at the first one. + + **The verification is the claim id appearing in the grid, not the count.** + Two reasons, and the first is the important one: + + * The Claim No box's meaning depends on a type selector beside it. eCW + switches the date-type dropdown's pointer-events off while that reads + "Rule Engine ID#" — the only observable sign that this box is not + searching claim numbers at all. No selector for it has been captured, + so rather than guess one and read it, this checks the outcome: if the + id typed comes back in the grid, the box meant what we assumed. + * A count is a poor signal here. Going from 541 rows to 1 is a change + `_wait_for_results` can see, but the *next* claim goes 1 → 1 and looks + identical to a grid that never refreshed. + + Raises, before typing anything, when the page is still not ready after + `wait_search_ready`'s wait: the loop records that as a failed search + (Review in the Charge Summary) and goes on to the next claim, which + clears the screen again first. + """ + claim_id = str(claim_id).strip() + blocker = wait_search_ready(page, claim_id) + if blocker: + shot(page, f"search_not_ready_{claim_id}") + raise RuntimeError(f"The search page is not ready after " + f"{_SEARCH_READY_MS // 1000}s: {blocker}") + print(f"[CLAIM {claim_id}] Search page ready — Claim No and Lookup on " + f"screen, nothing over them") + claim_filters.set_claim_no(page, claim_id) + + btn = page.locator(sel.SEL_LOOKUP_BTN).first + btn.wait_for(state="visible", timeout=cfg.NAV_TIMEOUT) + btn.click(timeout=30_000) + + started = time.time() + deadline = started + cfg.LOOKUP_TIMEOUT / 1000 + found = False + while time.time() < deadline: + if visible(page, shell.SEL_ROUTE_LOADER, timeout=200): + page.wait_for_timeout(cfg.LOOKUP_POLL_SECONDS * 1_000) + continue + try: + if _row_for(page, claim_id).is_visible(timeout=1_000): + found = True + break + except Exception: + pass + page.wait_for_timeout(cfg.LOOKUP_POLL_SECONDS * 1_000) + + took = time.time() - started + count = _result_count(page, timeout=3_000) + + if found: + print(f"[CLAIM {claim_id}] Found in {took:.0f}s — Total Counts: {count}") + else: + # Screenshot rather than a raise: this is the only record of what eCW + # actually showed, and the loop carries on to the next claim. + shot(page, f"claim_notfound_{claim_id}") + print(f"[CLAIM {claim_id}] NOT found after {took:.0f}s — " + f"Total Counts: {count}. Either it left the queue since the " + f"report was exported, or the date range excludes it, or the " + f"Claim No type is not set to claim numbers.") + + return {"id": claim_id, "count": count, "found": found, "seconds": took} diff --git a/src/ecw_bot/pages/dental_notes.py b/src/ecw_bot/pages/dental_notes.py new file mode 100644 index 0000000..fe8d0e9 --- /dev/null +++ b/src/ecw_bot/pages/dental_notes.py @@ -0,0 +1,104 @@ +r""" +Dental claim Billing Notes: is this a HIGH RISK claim? (document #15) + +"High risk" claims for a Dental payer go through the DentaQuest web portal, +not eCW (unless the provider isn't listed there or the patient isn't +eligible). The only marker is the words "HIGH RISK" in the claim's Billing +Notes — e.g. claim 9115000: "... : Eligible; HIGH RISK for ...". + +And the high risk entry must be long enough to be a real one (operator, +2026-10-12; counted from "HIGH RISK" since 2026-10-21): from where "HIGH +RISK" starts to the END of the Billing Notes at least `HIGH_RISK_MIN_CHARS` +(79) characters — spaces and line breaks included, trailing blanks not. +Text before "HIGH RISK" (an earlier line, an eligibility stamp) does not +count. Shorter than that is NOT a high risk claim (not Review either) — the +log says why. + +Temporary, read-only (operator, 2026-09-30): the bot only reports it, in +the per-claim Dental message box. Nothing is written to eCW or to the +output sheet yet. + +Billing Notes on the Dental Claim screen: textarea#dentalClaimNotes +(ng-model ClaimData.notes), mapped from Dom.txt. Its text is an Angular +property, so .value is read, never the attribute. +""" + +import re + +from playwright.sync_api import Page + +_NOTES_JS = r""" +() => { + const e = document.querySelector('[id^="dentalClaim"] #dentalClaimNotes') + || document.querySelector('#dentalClaimNotes'); + return e ? (e.value || e.textContent || '') : null; +} +""" + +# "HIGH RISK", "High-Risk", "highrisk" — any case, space/hyphen or none. +_HIGH_RISK = re.compile(r"high[\s\-]*risk", re.IGNORECASE) + +# From "HIGH RISK" to the end of the notes, at least this long (operator, +# 2026-10-12 / 2026-10-21). +HIGH_RISK_MIN_CHARS = 79 + + +def read_billing_notes(page: Page): + """The Billing Notes text, '' when empty, None when the box isn't there.""" + try: + return page.evaluate(_NOTES_JS) + except Exception: + return None + + +def high_risk_length(notes) -> int: + """Characters from the first "HIGH RISK" to the end of the notes, spaces + and line breaks included, trailing blanks not; 0 without "HIGH RISK".""" + m = _HIGH_RISK.search(notes or "") + return len(notes[m.start():].rstrip()) if m else 0 + + +def mentions_high_risk(notes) -> bool: + """The words alone, whatever the length.""" + return bool(_HIGH_RISK.search(notes or "")) + + +def is_high_risk(notes) -> bool: + """"HIGH RISK" in the notes, 79 characters or more from it to the end.""" + return high_risk_length(notes) >= HIGH_RISK_MIN_CHARS + + +def check(page: Page, claim_id: str) -> dict: + """{"high_risk": True/False/None, "match": "<text around it>", + "length": <characters from HIGH RISK to the end>}. None means the Billing Notes box + could not be read. "match" is only filled for a high risk claim.""" + notes = read_billing_notes(page) + if notes is None: + print(f"[HIGHRISK {claim_id}] UNKNOWN: no Billing Notes box found") + return {"high_risk": None, "match": "", "length": 0} + length = high_risk_length(notes) + m = _HIGH_RISK.search(notes) + if m and length < HIGH_RISK_MIN_CHARS: + print(f"[HIGHRISK {claim_id}] not high risk — 'HIGH RISK' is in the " + f"Billing Notes, but from it to the end is {length} characters " + f"({HIGH_RISK_MIN_CHARS} needed)") + return {"high_risk": False, "match": "", "length": length} + match = "" + if m: + match = " ".join(notes[max(0, m.start() - 40):m.end() + 40].split()) + print(f"[HIGHRISK {claim_id}] " + f"{'HIGH RISK' if m else 'not high risk'}" + f"{' — ' + repr(match) if m else ''}" + f"{f' ({length} characters)' if m else ''}") + return {"high_risk": bool(m), "match": match, "length": length} + + +def message(result: dict) -> str: + r = result or {} + if r.get("high_risk") is None: + return "High risk : UNKNOWN — Billing Notes could not be read" + if r["high_risk"]: + return ("High risk : YES — this is a HIGH RISK claim\n" + f" Billing Notes: ...{r.get('match', '')}...\n" + " (submit through the DentaQuest web portal)") + return "High risk : NO — this is not a high risk claim" diff --git a/src/ecw_bot/pages/leftnav.py b/src/ecw_bot/pages/leftnav.py new file mode 100644 index 0000000..dbce158 --- /dev/null +++ b/src/ecw_bot/pages/leftnav.py @@ -0,0 +1,143 @@ +""" +Post-login navigation inside the eCW V12 web app. + +Phase 2 of the process: from the logged-in home screen, open the left panel → +**Billing** → **Claims**, landing on `claimLookup.jsp`. + +Three things about eCW's left navigation shape everything below, and all three +were read off the live DOM rather than guessed: + + * **The hamburger is a toggle.** `#jellybean-panelLink4`'s handler is + `launchLeftPanel()`, which flips a `visible` class on `.response-nav-lp`. + Clicking it while the panel is already open *closes* it, so state is checked + before every click here. + * **Its id is duplicated.** eCW renders `#JellyBeanCountCntrl` twice, so that + id matches two elements and Playwright's strict mode refuses a plain + `page.click()`. Every locator is `.first`. + * **The panel is filled in late.** `getleftnavhtml()` injects the menu into + `#leftnavApp` on `DOMContentLoaded`, so `#Billing_menu` does not exist at + the moment login is confirmed. It gets waited for. + +The click path is what a human does and is worth exercising, but a menu-markup +change should not stop a run that already paid for a login and a 2FA approval — +so any failure along the way falls back to the hash route that the menu item's +own `openURL()` would have used. +""" + +from playwright.sync_api import Page + +from .. import settings as cfg +from ..selectors import shell as sel +from ..core.page_utils import shot, visible, goto_with_retry + + +def _claims_url() -> str: + """ + The claims screen as an absolute URL. + + `CLAIMS_HASH` is a fragment on the app shell, so it replaces whatever + fragment the current URL carries — `ECW_URL` already ends in `index.jsp`, + and `ECW_LANDING_URL` ends in the schedule route. No joining slash: adding + one would make it `index.jsp/#/…` and load nothing. + """ + return cfg.ECW_URL.split("#", 1)[0] + sel.CLAIMS_HASH + + +def _open_panel(page: Page) -> None: + """Open the left navigation panel, unless it is already open.""" + if visible(page, sel.SEL_NAV_OPEN): + print("[NAV] Left panel already open") + return + + # Two tries: the handler is bound delegated on the icon, and a click that + # lands mid-render occasionally hits the <i> before the binding is live. + for attempt in (1, 2): + page.locator(sel.SEL_NAV_TOGGLE).first.click(timeout=cfg.LOGIN_TIMEOUT) + try: + page.wait_for_selector(sel.SEL_NAV_OPEN, timeout=10_000) + print("[NAV] Left panel opened") + return + except Exception: + if attempt == 2: + raise RuntimeError( + f"Clicked {sel.SEL_NAV_TOGGLE} twice but {sel.SEL_NAV_PANEL} " + f"never got the `visible` class") + print("[NAV] Toggle click did not take — clicking once more") + + +def _open_billing(page: Page) -> None: + """Expand the Billing group in the left panel, unless it is already open.""" + # Not a timeout to be shortened: this is the wait for getleftnavhtml() to + # inject the whole menu, and it is a different failure from a renamed item. + try: + page.wait_for_selector(sel.SEL_MENU_BILLING, timeout=cfg.NAV_TIMEOUT) + except Exception: + raise RuntimeError( + f"The left nav never rendered — {sel.SEL_MENU_BILLING} did not " + f"appear. getleftnavhtml() failed or the page is not the app shell, " + f"which is a different problem from a moved menu item") + + if visible(page, sel.SEL_MENU_BILLING_OPEN) and visible(page, sel.SEL_CLAIMS): + print("[NAV] Billing menu already open") + return + + page.locator(sel.SEL_MENU_BILLING).first.click(timeout=cfg.LOGIN_TIMEOUT) + # Either signal counts as open: the `open` class is what eCW sets, and the + # child being visible is what actually matters to the next click. + page.wait_for_selector( + f"{sel.SEL_MENU_BILLING_OPEN}, {sel.SEL_CLAIMS}", timeout=30_000) + print("[NAV] Billing menu opened") + + +def _click_claims(page: Page) -> None: + """ + Click the Claims item. + + The label span, not the anchor: the anchor also holds `#favorite_16`, whose + own ng-click stars the menu item instead of navigating, and a click aimed at + the anchor's centre can land on it. The event bubbles to the anchor's + ng-click either way. + """ + try: + page.locator(sel.SEL_CLAIMS_LABEL).first.click(timeout=30_000) + except Exception as exc: + print(f"[NAV] Label click failed ({str(exc).splitlines()[0]}) — " + f"clicking the item itself") + page.locator(sel.SEL_CLAIMS).first.click(timeout=30_000) + print("[NAV] Claims clicked") + + +def _confirm_claims(page: Page) -> None: + """Wait until the claims view is actually the one on screen.""" + page.wait_for_url("**claimLookup.jsp**", timeout=cfg.NAV_TIMEOUT) + try: + # Angular's route spinner. Hidden means the view finished rendering; + # missing entirely also means done, hence the swallow. + page.wait_for_selector(sel.SEL_ROUTE_LOADER, state="hidden", + timeout=60_000) + except Exception: + pass + page.wait_for_timeout(2_000) + shot(page, "nav_claims") + print("[NAV] On claimLookup.jsp") + + +def open_claims(page: Page) -> bool: + """ + Navigate to Billing → Claims. Returns True, or raises if even the fallback + route fails to land on the claims screen. + """ + try: + _open_panel(page) + _open_billing(page) + shot(page, "nav_billing_open") + _click_claims(page) + except Exception as exc: + # Worth a screenshot before moving on: the fallback will land on the + # right page and hide whatever broke in the menu. + shot(page, "nav_menu_failed") + print(f"[NAV] Menu path failed ({exc}) — falling back to the hash route") + goto_with_retry(page, _claims_url()) + + _confirm_claims(page) + return True diff --git a/src/ecw_bot/pages/preflight.py b/src/ecw_bot/pages/preflight.py new file mode 100644 index 0000000..69fcb10 --- /dev/null +++ b/src/ecw_bot/pages/preflight.py @@ -0,0 +1,179 @@ +r""" +Pre-flight selector check (2026-10-21): find out that eCW changed a screen +on the FIRST claim, not after every claim of the run has gone to Review. + +Two checkpoints, read-only (nothing is clicked or typed): + + * **search** — the claims search page, once, before the first claim: + Claim No, Lookup, the Claim Status and date filters. + * **form** — the claim form, on the first claim of each screen type the + run opens (UB, HCFA, Dental), right after the CPT/HCPCS tab is opened: + the Cancel button, the CPT grid rows and, inside a row, the code, Billed + Fee and (UB / HCFA) POS and M1 boxes; on UB the CPT/HCPCS tab. + +A missing selector is named in the log, the page is shot as +`preflight_failed_<stage>` (which also saves its DOM, `page_utils.shot`) +and, with `PREFLIGHT_STOP` on, `PreflightFailed` is raised: the run stops +with exit 7 and says which selector to fix. Off, it only warns. + +A form with no CPT row at all cannot be checked row by row (a claim with +no lines is rare but real), so its row checks are put off to the next +claim of that screen; three such claims in a row and the rows themselves +count as missing — every claim reading no rows is what a changed row +selector looks like. + +A check that cannot run at all (no page, the browser gone) is logged and +skipped: the pre-flight must never be the thing that breaks a run. +""" + +from .. import settings as cfg +from ..selectors import claim_form as form_sel +from ..selectors import claim_lookup as lookup_sel +from ..core import fallback, run_events +from ..core.page_utils import shot +from ..selectors.fallbacks import CHAINS + + +class PreflightFailed(Exception): + """eCW no longer shows something the bot depends on.""" + + +# (name, selector) that must be visible somewhere on the page. +SEARCH = [ + ("Claim No box", lookup_sel.SEL_CLAIM_NO), + ("Lookup button", lookup_sel.SEL_LOOKUP_BTN), + ("Claim Status filter", lookup_sel.SEL_CLAIM_STATUS), + ("Date from", lookup_sel.SEL_DATE_FROM), + ("Date to", lookup_sel.SEL_DATE_TO), +] + +PAGE_CHECKS = { + "ub": [("UB Cancel button", form_sel.SEL_CANCEL), + ("CPT/HCPCS tab", form_sel.SEL_TAB_CPT_HCPCS)], + "hcfa": [("HCFA Cancel button", form_sel.SEL_CANCEL_HCFA)], + "dental": [("Dental Cancel button", form_sel.SEL_CANCEL_DENTAL)], +} + +# (name, selector) that must be inside a CPT row that has a code. +ROW_CHECKS = { + "ub": [("CPT code box", form_sel.SEL_CPT_CODE), + ("Billed Fee box", form_sel.SEL_CPT_BILLED_FEE), + ("POS box", form_sel.SEL_CPT_POS), + ("M1 box", form_sel.SEL_CPT_MOD1)], + "hcfa": [("CPT code box", form_sel.SEL_CPT_CODE), + ("Billed Fee box", form_sel.SEL_CPT_BILLED_FEE), + ("POS box", form_sel.SEL_CPT_POS), + ("M1 box", form_sel.SEL_CPT_MOD1)], + "dental": [("CPT code box", form_sel.SEL_CPT_CODE), + ("Billed Fee box", form_sel.SEL_CPT_BILLED_FEE)], +} + +NO_ROWS_LIMIT = 3 + +# This run's state: screens already checked, claims with no rows per screen. +_checked = set() +_no_rows = {} + + +def reset() -> None: + """A new run: every screen is checked again on its first claim.""" + _checked.clear() + _no_rows.clear() + fallback.reset() + + +# The fallback chains (selectors/fallbacks.py) each checkpoint reports on. +FALLBACKS = {"search": ["Claim No box", "Lookup button"], + "ub": ["UB Cancel button", "Prog. Notes button"], + "hcfa": ["Prog. Notes button"], + "dental": ["Dental Cancel button"]} + + +def _shown(page, selector: str) -> bool: + return page.locator(selector).filter(visible=True).count() > 0 + + +def missing_on_page(page, checks) -> list: + return [name for name, selector in checks if not _shown(page, selector)] + + +def missing_in_rows(page, checks): + """Names missing from the CPT rows; None when no row has a code.""" + rows = page.locator(form_sel.SEL_CPT_ROWS).filter(visible=True) + coded = rows.filter(has=page.locator(form_sel.SEL_CPT_CODE)) + if coded.count() == 0: + return None + return [name for name, selector in checks + if coded.filter(has=page.locator(selector)).count() == 0] + + +def _fail(page, stage: str, missing: list) -> None: + text = (f"eCW {stage}: not found — {', '.join(missing)}. The page " + f"changed, or it is not the expected screen; fix the selector(s) " + f"in src/ecw_bot/selectors/ (the page's HTML is saved beside " + f"the preflight_failed screenshot).") + print(f"[PREFLIGHT] FAIL {text}") + shot(page, "preflight_failed_" + stage.replace(" ", "_")) + if cfg.PREFLIGHT_STOP: + raise PreflightFailed(text) + print("[PREFLIGHT] PREFLIGHT_STOP=0 — carrying on anyway") + run_events.warning("preflight", text) + + +def check_search(page) -> None: + if not cfg.PREFLIGHT: + return + try: + missing = missing_on_page(page, SEARCH) + fallback.report(page, FALLBACKS["search"], CHAINS) + except Exception as exc: + print(f"[PREFLIGHT] search page not checked: " + f"{str(exc).splitlines()[0] if str(exc) else exc!r}") + return + if missing: + _fail(page, "search page", missing) + else: + print(f"[PREFLIGHT] Search page OK ({len(SEARCH)} selectors)") + + +def _screen(page) -> str: + from . import claim_form + if claim_form.is_dental_claim_form(page): + return "dental" + return "ub" if claim_form.is_ub_claim_form(page) else "hcfa" + + +def check_form(page) -> None: + """On the first claim of each screen type this run; read-only.""" + if not cfg.PREFLIGHT: + return + try: + screen = _screen(page) + if screen in _checked: + return + missing = missing_on_page(page, PAGE_CHECKS[screen]) + in_rows = missing_in_rows(page, ROW_CHECKS[screen]) + fallback.report(page, FALLBACKS[screen], CHAINS) + except Exception as exc: + print(f"[PREFLIGHT] claim form not checked: " + f"{str(exc).splitlines()[0] if str(exc) else exc!r}") + return + if in_rows is None: + _no_rows[screen] = _no_rows.get(screen, 0) + 1 + if _no_rows[screen] >= NO_ROWS_LIMIT: + missing = missing + [f"CPT rows ({_no_rows[screen]} {screen} " + f"claims in a row read none)"] + elif not missing: + print(f"[PREFLIGHT] {screen} form: no CPT row on this claim — " + f"its row checks wait for the next {screen} claim") + return + else: + _no_rows[screen] = 0 + missing = missing + in_rows + if missing: + _checked.add(screen) + _fail(page, f"{screen} claim form", missing) + return + if in_rows is not None: + _checked.add(screen) + print(f"[PREFLIGHT] {screen} claim form OK") diff --git a/src/ecw_bot/pages/progress_note.py b/src/ecw_bot/pages/progress_note.py new file mode 100644 index 0000000..dd1eb29 --- /dev/null +++ b/src/ecw_bot/pages/progress_note.py @@ -0,0 +1,386 @@ +r""" +The Progress Note viewer: find it, read it as structure, close it. + +`eCW tags every section of the note with a `prisma-section` attribute`, which is +the best thing about this screen: + + <tr prisma-section="Assessments"> + <tr prisma-section="Procedure Codes"> + <tr prisma-section="History of Present Illness"> + +So the note does not have to be scraped as a wall of text — it comes back as a +dict keyed by section name, straight from eCW's own labels. Those labels are +what its export/PRISMA feature uses, which makes them considerably more stable +than the surrounding table markup. + +Two sections carry what charge entry actually needs, and they are extracted +individually rather than left as prose: + + Assessments → "Illness, unspecified - R69 (Primary)" + Procedure Codes → "H0023 Behavioral Health Outreach Service (TH). + Modifiers: GT" + +Those are the same ICD and CPT the UB Claim form shows. Having both sides as +structure is what will later make "does the claim match the note?" a comparison +instead of a human reading two screens. + +**The note is a whole printable document** — it has its own `<html>`, so it +arrives in an iframe or a separate tab, never inline. `find(page)` looks in all +three places rather than assuming one; that is why `claim_form`'s earlier +in-page `.modal` innerText read would have come back empty for an iframe. +""" + +import re + +from playwright.sync_api import Page + +from ..core.page_utils import (shot, appears, visible, click_topmost, + count_shown) +from ..core.text import first_line +from ..selectors import claim_form as sel + + +# One evaluate over the whole note. It is read-only, and the note is a large +# document — forty locator round trips across an iframe boundary would be +# noticeably slower per claim. +_EXTRACT_JS = r""" +() => { + // innerText first, textContent second. Several things worth having are + // display:none — #chartLockedId among them — and innerText returns "" for + // those. Neither one alone reads this document correctly. + const read = el => { + if (!el) return ''; + const t = (el.innerText && el.innerText.trim()) + ? el.innerText : (el.textContent || ''); + return t.replace(/ /g, ' ') + .replace(/[ \t]+/g, ' ') + .replace(/\n{3,}/g, '\n\n') + .split('\n').map(s => s.trim()).join('\n').trim(); + }; + const one = s => document.querySelector(s); + + // Sections, keyed by eCW's own prisma-section labels. A label repeats + // (Footer appears four times), so matching ones are joined rather than + // overwritten — dropping the earlier ones would silently lose the + // signature block. + const sections = {}; + for (const el of document.querySelectorAll('[prisma-section]')) { + const name = (el.getAttribute('prisma-section') || '').trim(); + if (!name) continue; + const body = read(el); + if (!body) { if (!(name in sections)) sections[name] = ''; continue; } + sections[name] = sections[name] ? (sections[name] + '\n' + body) : body; + } + + // "1." then "Illness, unspecified - R69 (Primary)". The number is in its + // own cell, so the row is read whole and the leading numbering stripped. + const assessments = []; + for (const el of document.querySelectorAll('[prisma-section="Assessments"] tr')) { + const line = read(el).replace(/\s*\n\s*/g, ' ').trim(); + const m = line.match(/^(\d+)\.\s*(.+)$/); + if (!m) continue; + const text = m[2].trim(); + const code = (text.match(/-\s*([A-Z]\d{2}(?:\.\d+)?)\b/) || [])[1] || ''; + assessments.push({ + n: m[1], + text: text, + code: code, + primary: /\(Primary\)/i.test(text), + }); + } + + // "H0023 Behavioral Health Outreach Service (TH). Modifiers: GT" + const procedures = []; + for (const el of document.querySelectorAll( + '[prisma-section="Procedure Codes"] li')) { + const line = read(el).replace(/\s*\n\s*/g, ' ').trim(); + if (!line) continue; + const m = line.match(/^([A-Z0-9]{4,7})\s+(.*)$/); + const mods = (line.match(/Modifiers?:\s*([A-Z0-9,\s]+)/i) || [])[1] || ''; + procedures.push({ + code: m ? m[1] : '', + description: m ? m[2].replace(/\.?\s*Modifiers?:.*$/i, '').trim() : line, + modifiers: mods.split(',').map(s => s.trim()).filter(Boolean), + text: line, + }); + } + + const all = read(document.body); + + // Sign-off, from the footer prose. Worth pulling out: an unsigned or + // incomplete note is a reason not to bill, and it is the one field here + // that changes what should happen next. + const signed = all.match( + /Electronically signed by Provider:\s*(.+?)\s+on\s+(.+?)(?:\n|$)/i); + const status = all.match(/Sign off status:\s*(.+?)(?:\n|$)/i); + const dos = all.match(/\bDOS:\s*(\d{2}\/\d{2}\/\d{4})/); + const locked = one('#chartLockedId'); + + return { + patient_header: read(one('#mainThead')), + dos: dos ? dos[1] : '', + sections: sections, + assessments: assessments, + procedure_codes: procedures, + signed_by: signed ? signed[1].trim() : '', + signed_at: signed ? signed[2].trim() : '', + signoff_status: status ? status[1].trim() : '', + // textContent, not innerText: the span is display:none. + chart_locked: locked + ? (locked.textContent || '').trim().toLowerCase() === 'true' : null, + text: all, + }; +} +""" + + +def find(page: Page, tabs_before: int = None, load_timeout: int = 60_000, + fresh_only: bool = False): + """ + Where the note actually rendered: `(reader, where)` or `(None, "nothing")`. + + Looks in all three places rather than assuming one — the note is a full + printable document with its own `<html>`, so it can be an iframe, a new + tab, or (least likely) inline. Reading a modal's `innerText` finds nothing + when the note is in an iframe, which is exactly the trap here. + + `reader` is a `Page` or a `Frame`; both accept `evaluate`, which is all the + extraction needs. + + `load_timeout` bounds the wait for each new tab's `domcontentloaded`. A + single probe can afford the 60s default; a caller polling every 250 ms + (`claim_form._wait_for_note`) passes a short one, or a still-loading tab + would cost a full minute on every poll. + + `fresh_only` skips every note `mark_seen()` marked before the click, so + the reader found is only ever THIS claim's note — see `mark_seen`. + """ + # A new tab is the least ambiguous case, so check it first. + if tabs_before is not None: + for extra in page.context.pages[tabs_before:]: + try: + extra.wait_for_load_state("domcontentloaded", + timeout=load_timeout) + except Exception: + pass + if _has_note(extra, fresh_only): + return extra, "tab" + + # Then every frame on this page, main frame included. + for frame in page.frames: + if _has_note(frame, fresh_only): + return frame, ("page" if frame is page.main_frame else "frame") + return None, "nothing" + + +# A note marker this run has already seen carries this JS property — a +# property, not an attribute, so nothing in the DOM or in extract()'s text +# changes. A document that is replaced (a new navigation, a srcdoc, a +# document.open()) comes with new elements that do not carry it. +_MARK_SEEN_JS = r""" +(s) => { + let n = 0; + for (const el of document.querySelectorAll(s)) { + el.__ecwBotSeenNote = true; n++; + } + return n; +} +""" + +_HAS_NOTE_JS = r""" +([s, freshOnly]) => { + const all = document.querySelectorAll(s); + if (!freshOnly) return all.length > 0; + for (const el of all) if (!el.__ecwBotSeenNote) return true; + return false; +} +""" + + +def _has_note(reader, fresh_only: bool = False) -> bool: + """ + Does this page/frame hold a progress note? With `fresh_only`, one that + `mark_seen()` did not mark. + """ + try: + return bool(reader.evaluate(_HAS_NOTE_JS, + [sel.SEL_PN_MARKER, bool(fresh_only)])) + except Exception: + return False + + +def mark_seen(page: Page) -> int: + """ + Mark every progress note already in this page's frames (and in the + browser's other tabs) as seen, BEFORE Prog. Notes is clicked. Returns + how many documents held one. Never raises. + + What the poll then skips (`find(..., fresh_only=True)`). The old fixed 3s + sleep after the click hid a window the 250 ms poll does not (final + review, 2026-10-05): the viewer's Close may only hide it — the fixture + models Close as `display: none`, so its iframe stays in `page.frames` + with the previous claim's note in it — and a reused viewer iframe still + shows the previous note until its new document commits. Either one is a + note marker with a steady section count on the first two polls, and + would be read as THIS claim's note: telehealth decided on another + patient's visit. A marked element belongs to a document that was there + before the click; the new note's document brings new elements. + """ + readers = [] + try: + for p in page.context.pages: + readers += list(p.frames) + except Exception: + readers = [] + if not readers: + try: + readers = list(page.frames) + except Exception: + return 0 + marked = 0 + for reader in readers: + try: + if reader.evaluate(_MARK_SEEN_JS, sel.SEL_PN_MARKER): + marked += 1 + except Exception: + pass + return marked + + +def section_count(reader) -> int: + """ + How many `[prisma-section]` elements the note holds right now; 0 when it + cannot be read. Never raises. + + What the poll in `claim_form._wait_for_note` watches: the viewer's frame + exists well before its document has arrived, so "a note marker is there" + is not yet "the note is there" — the section count holding still across + two polls, on a document that has finished parsing, is (`note_state`). + """ + return note_state(reader)[0] + + +def note_state(reader) -> tuple: + """ + `(section count, document.readyState)` of the note right now; + `(0, "")` when it cannot be read. Never raises. + + The readyState is the other half of "settled" (final review, + 2026-10-05): the note is a whole HTML document, so a slow response + parses in pieces, and a server that stalls for longer than one poll + after the first few sections shows the SAME count twice. "loading" + means the parser has not reached the end of the document — the + Procedure Codes may still be on their way — so a count that holds + still while "loading" is not a settled note. + """ + try: + got = reader.evaluate( + "(s) => [document.querySelectorAll(s).length, " + "document.readyState]", sel.SEL_PN_SECTION) + return int(got[0] or 0), str(got[1] or "") + except Exception: + return 0, "" + + +def extract(reader) -> dict: + """The note as structure, keyed by eCW's own `prisma-section` labels.""" + return reader.evaluate(_EXTRACT_JS) + + +def close(page: Page, wait_ms: int = 5_000) -> bool: + """ + Close the viewer. + + Matched on `ng-click="closeProgressNoteViwer()"` — **eCW's own typo**, + Viwer not Viewer. Spelling it correctly matches nothing. + + Closing matters more than it looks: the viewer sits on top of the UB Claim + form, so leaving it open blocks the Cancel button underneath and the next + claim in the loop would open its form behind a stale note. + + `appears`, a real wait of `wait_ms`: the Close button renders with the + viewer, and `visible(timeout=5_000)` never actually waited (Playwright + 1.62 ignores the timeout — review item P8), so a viewer still drawing its + Close button would have been left open. + + `wait_ms=0` is an instant check, for a caller that already knows no + viewer is on its way: a note read from a new tab, or a poll that ran + out its whole wait. Waiting 5s there for a Close button that is not + coming added 5s to every such claim (final review, 2026-10-05). + + Every viewer on screen is closed, the one on top first + (`click_topmost`), until none is left. Live run 2026-10-12: two viewers + were stacked, Close was clicked on the lower one (`.first`), the upper + one covered it — 15 s timeout — and the viewer left behind intercepted + every click after it, the next two claims' searches included. + """ + there = (appears(page, sel.SEL_PN_CLOSE, timeout=wait_ms) if wait_ms + else visible(page, sel.SEL_PN_CLOSE)) + if not there: + return False + # Close BUTTONS, not viewers: the live viewer draws two (run + # 2026-10-12, one viewer logged as "2 viewers"). Only the bound. + first_count = count_shown(page, sel.SEL_PN_CLOSE) + # Viewers = the clicks it took: one click closes one whole viewer. + viewers = 0 + try: + # A bound, not a loop on hope: each pass closes one viewer. + for _ in range(max(first_count, 1) + 1): + if not click_topmost(page, sel.SEL_PN_CLOSE, timeout=15_000): + break + viewers += 1 + page.wait_for_timeout(800) + except Exception as exc: + shot(page, "progress_note_close_failed") + print(f"[NOTE] Could not close the progress note viewer: " + f"{first_line(exc)}") + return False + left = count_shown(page, sel.SEL_PN_CLOSE) + if left: + shot(page, "progress_note_close_failed") + print(f"[NOTE] {left} progress note viewer(s) still open after " + f"Close") + return False + if viewers > 1: + print(f"[NOTE] {viewers} progress note viewers were open — " + f"all closed, top first") + print("[NOTE] Progress note viewer closed") + return True + + +def summary(note: dict) -> str: + """ + The note's headline facts, for a message box. + + English only, and no "?" lines: the client watches these boxes (review + item D9, 2026-10-05). An assessment or procedure row whose code came + back empty is left out rather than printed as "?" — the Sections count + below still says how much of the note was read. + """ + if not note: + return "" + lines = [] + if note.get("dos"): + lines.append(f"DOS : {note['dos']}") + for a in (note.get("assessments") or [])[:3]: + code = str(a.get("code") or "").strip() + if not code or code == "?": + continue + mark = " (Primary)" if a.get("primary") else "" + lines.append(f"Assessment : {code}{mark}") + for pr in (note.get("procedure_codes") or [])[:3]: + code = str(pr.get("code") or "").strip() + if not code or code == "?": + continue + mods = ", ".join(pr.get("modifiers") or []) or "-" + lines.append(f"Procedure : {code} mod {mods}") + if note.get("signoff_status"): + lines.append(f"Sign off : {note['signoff_status']}") + if note.get("signed_by"): + lines.append(f"Signed by : {note['signed_by']}") + if note.get("chart_locked") is not None: + # Was Tamil for yes / no — review item D9. + lines.append(f"Chart locked: {'Yes' if note['chart_locked'] else 'No'}") + got = [k for k, v in (note.get("sections") or {}).items() if v] + lines.append(f"Sections : {len(got)}") + return "\n".join(lines) diff --git a/src/ecw_bot/pages/sliding_fee.py b/src/ecw_bot/pages/sliding_fee.py new file mode 100644 index 0000000..e958279 --- /dev/null +++ b/src/ecw_bot/pages/sliding_fee.py @@ -0,0 +1,249 @@ +r""" +Patient Info → Sliding Fee Schedule: is the patient's sliding fee still +valid on the claim's date of service? + +Temporary, read-only process (operator, 2026-09-30) for condition #8 +(DCONS/Dscprit, "All Dental Payer"): Dental claims only get this check, +and the answer is logged and kept on the claim's data as +`data["sliding_fee"]`. Nothing is changed in eCW — Patient Information is +closed with its own Cancel (never Save), and "Don't Save" is answered if +eCW raises its unsaved-data prompt. Writing the result to the Excel output +sheet is the next step, not this one. + +Path, from the operator's screenshots (claim 9124000): + + claim header "Info" -> Patient Information -> "Sliding Fee Schedule" + -> Assigned Sliding Fee Schedule: Date <from> to <to> + Scale Type, Date <from> to <to>, Fee Schedule + +`evaluate()` is the verdict, kept pure so it is tested without a browser: + + active DOS falls inside Date from..to (inclusive) + expired DOS after the "to" date + not_started DOS before the "from" date + none no sliding fee assigned (button disabled, or no dates) + unknown could not open/read the screen, or no DOS on the claim +""" + +import re +from datetime import date, datetime + +from playwright.sync_api import Page + +from ..core.page_utils import shot, visible, appears +from ..selectors import claim_form as sel + +# Read by ng-model, not by the slidingfeescheduleIpt<N> ids — those are +# positional and shift whenever eCW adds a field above them. FeeSchedule's +# attribute really is spelled "data--ng-model" in eCW's markup. +_READ_JS = r""" +(modal) => { + const root = document.querySelector(modal); + if (!root) return null; + const v = m => { + const e = root.querySelector('[data-ng-model="' + m + '"], [ng-model="' + + m + '"], [data--ng-model="' + m + '"]'); + return e ? (e.value || '').trim() : ''; + }; + return { + from_date: v('patientIncomeDetails.AssignedDate'), + to_date: v('patientIncomeDetails.ExpiryDate'), + }; +} +""" + + +def parse_date(text): + """mm/dd/yyyy -> date; anything else (blank, "__/__/____") -> None.""" + m = re.search(r"(\d{1,2})/(\d{1,2})/(\d{4})", text or "") + if not m: + return None + try: + return date(int(m.group(3)), int(m.group(1)), int(m.group(2))) + except ValueError: + return None + + +def claim_dos(data: dict) -> str: + """The claim's date of service: the first CPT line with a readable SDOS.""" + for row in (data or {}).get("cpt") or []: + if parse_date(row.get("sdos")): + return row["sdos"] + return "" + + +# The Dental Claim screen is never extract()ed, so its DOS is read here. +# Per service line: input#DentalClaimCPTSDOS<n> (ng-model CPT.SDOS), in +# #claimCPTCodesTable<ts>; the claim-level #servicedate (ClaimData.ServiceDt) +# is the fallback (Dom.txt, Dental claim, 2026-09-30). Values are Angular +# properties, so .value is read, never the attribute. +_DENTAL_DOS_JS = r""" +() => { + const ok = v => /\d{1,2}\/\d{1,2}\/\d{4}/.test(v || ''); + for (const e of document.querySelectorAll( + '[id^="dentalClaim"] [id^="DentalClaimCPTSDOS"]')) + if (ok(e.value)) return e.value.trim(); + const e = document.querySelector('[id^="dentalClaim"] #servicedate'); + return e && ok(e.value) ? e.value.trim() : ''; +} +""" + + +def dental_dos(page: Page) -> str: + """The open Dental claim's DOS: first service line's, else the claim's.""" + try: + return page.evaluate(_DENTAL_DOS_JS) or "" + except Exception: + return "" + + +def evaluate(info, dos: str) -> dict: + """`info` (as read, or None) + DOS -> {"verdict", "reason", ...info}.""" + out = dict(info or {}) + out["dos"] = dos or "" + if info is None or info.get("error"): + out.update(verdict="unknown", + reason=(info or {}).get("error") or "screen not read") + return out + if info.get("disabled"): + out.update(verdict="none", + reason="Sliding Fee Schedule button is disabled") + return out + + start = parse_date(info.get("from_date")) + end = parse_date(info.get("to_date")) + if not start and not end: + out.update(verdict="none", reason="no sliding fee dates assigned") + return out + + when = parse_date(dos) + if not when: + out.update(verdict="unknown", reason="claim has no readable DOS") + return out + span = f"{info.get('from_date') or '?'} to {info.get('to_date') or '?'}" + if end and when > end: + out.update(verdict="expired", reason=f"DOS {dos} is after {span}") + elif start and when < start: + out.update(verdict="not_started", reason=f"DOS {dos} is before {span}") + else: + out.update(verdict="active", reason=f"DOS {dos} is within {span}") + return out + + +# The private `_appears()` that lived here was the one place in the codebase +# that already waited correctly; it is now `core.page_utils.appears()`, shared +# (review item P8, 2026-10-01). Same calls, same timeouts, same behaviour — +# plus it now ignores a hidden leftover copy ahead of the live element. + + +def _wait_filled(page: Page, timeout_ms: int = 10_000) -> dict: + """The modal shows before Angular fills it; poll until it has values.""" + info = None + waited = 0 + while waited <= timeout_ms: + info = page.evaluate(_READ_JS, sel.SEL_SLIDING_FEE_MODAL) + if info and (info["from_date"] or info["to_date"]): + return info + page.wait_for_timeout(500) + waited += 500 + return info or {} + + +def _close_patient_info(page: Page) -> None: + """Sliding fee modal first (it sits on top), then Patient Information.""" + # Both clicks below take a VISIBLE match, like every other click in this + # file: visible() answers True when any copy is on screen (P8), and an + # unfiltered `.last`/`.first` could be a hidden leftover copy that sits + # out the 10s click timeout — leaving Patient Information open over the + # claim form (review of unit C, 2026-10-05). + if visible(page, sel.SEL_SLIDING_FEE_CLOSE, timeout=1_000): + try: + page.locator(sel.SEL_SLIDING_FEE_CLOSE).filter( + visible=True).last.click(timeout=10_000) + page.wait_for_timeout(500) + except Exception as exc: + print(f"[SLIDING] Could not close Sliding Fee Schedule: " + f"{str(exc).splitlines()[0]}") + cancel = f"{sel.SEL_PT_INFO_MODAL} {sel.SEL_PT_INFO_CANCEL}" + if visible(page, cancel, timeout=1_000): + try: + page.locator(cancel).filter(visible=True).first.click( + timeout=10_000) + page.wait_for_timeout(800) + except Exception as exc: + print(f"[SLIDING] Could not click Patient Information Cancel: " + f"{str(exc).splitlines()[0]}") + # Nothing was edited, but if eCW still asks — never Save. + if appears(page, sel.SEL_PT_INFO_DONT_SAVE, 1_500): + try: + # The visible one, the copy appears() just waited on: a hidden + # leftover first in the DOM would sit out the 10s click timeout. + page.locator(sel.SEL_PT_INFO_DONT_SAVE).filter( + visible=True).first.click(timeout=10_000) + page.wait_for_timeout(800) + print("[SLIDING] eCW asked to save Patient Information — " + "answered Don't Save") + except Exception as exc: + print(f"[SLIDING] Could not answer Don't Save: " + f"{str(exc).splitlines()[0]}") + if visible(page, sel.SEL_PT_INFO_MODAL, timeout=500): + shot(page, "sliding_fee_patient_info_still_open") + print("[SLIDING] Patient Information is still open — see " + "sliding_fee_patient_info_still_open.png") + + +def read(page: Page) -> dict: + """Open Info -> Sliding Fee Schedule, read it, close both. Never raises.""" + if not appears(page, sel.SEL_CLAIM_PATIENT_INFO, 5_000): + return {"error": "no Info button on this claim screen"} + try: + # The visible one, as in progress_note.close(): appears() above + # waited on the visible match, and an unfiltered `.first` would be a + # hidden leftover copy, if one comes first, and time out after 15s. + page.locator(sel.SEL_CLAIM_PATIENT_INFO).filter( + visible=True).first.click(timeout=15_000) + if not appears(page, sel.SEL_SLIDING_FEE_BTN, 30_000): + return {"error": "Patient Information did not open"} + # Angular sets the button's disabled state after load. + page.wait_for_timeout(1_000) + # Same: the visible button appears() waited on, not a hidden copy. + btn = page.locator(sel.SEL_SLIDING_FEE_BTN).filter(visible=True).first + if not btn.is_enabled(timeout=5_000): + return {"disabled": True} + btn.click(timeout=15_000) + if not appears(page, sel.SEL_SLIDING_FEE_MODAL, 20_000): + return {"error": "Sliding Fee Schedule did not open"} + info = _wait_filled(page) + shot(page, "sliding_fee") + return info + except Exception as exc: + shot(page, "sliding_fee_failed") + return {"error": str(exc).splitlines()[0]} + finally: + _close_patient_info(page) + + +def check(page: Page, claim_id: str, data: dict) -> dict: + """read() + evaluate() + one log line. The result goes on the claim.""" + started = datetime.now() + # DOS first, while the claim form is still the screen on top. + dos = claim_dos(data) or dental_dos(page) + result = evaluate(read(page), dos) + secs = (datetime.now() - started).total_seconds() + print(f"[SLIDING {claim_id}] {result['verdict'].upper()}: " + f"{result['reason']} ({secs:.1f}s)") + return result + + +def message(result: dict) -> str: + """The operator's message box for one claim — temporary, in place of + the Excel output for now.""" + r = result or {} + return "\n".join([ + f"Sliding fee : {r.get('verdict', '?').upper()}", + f"Why : {r.get('reason', '')}", + "", + f"Claim DOS : {r.get('dos') or '-'}", + f"Sliding from : {r.get('from_date') or '-'}", + f"Sliding to : {r.get('to_date') or '-'}", + ]) diff --git a/src/ecw_bot/payer_groups.json b/src/ecw_bot/payer_groups.json new file mode 100644 index 0000000..7f8d80b --- /dev/null +++ b/src/ecw_bot/payer_groups.json @@ -0,0 +1,274 @@ +{ + "AARP": "Commercial Ins", + "AARP Health": "Commercial Ins", + "AARP Medicare Advantage": "Commercial Ins", + "AARP Medicare Supplement Plan": "Commercial Ins", + "ACE Medicare Supplement": "Commercial Ins", + "Admin One LLC": "Commercial Ins", + "Administrative Concepts Inc": "Commercial Ins", + "Aetna": "Commercial Ins", + "Aetna Alaska Care": "Commercial Ins", + "Aetna Dental PPO": "Dental - Commercial", + "AETNA HEALTH AND LIFE INSURANCE": "Commercial Ins", + "Aetna Life Insurance Company": "Commercial Ins", + "Aetna Medicare ADV": "Commercial Ins", + "AETNA SENIOR SUPPLEMENTAL": "Medicare", + "Aetna Signature Administrators": "Commercial Ins", + "AFLAC": "Commercial Ins", + "Aither Health": "Commercial Ins", + "All Savers": "Commercial Ins", + "All State": "Commercial Ins", + "Allegiance Benefit Plan": "Commercial Ins", + "Alliance Health and Life Insurance Co": "Commercial Ins", + "Allied Benefit Systems": "Commercial Ins", + "Allstate Health Solutions": "Commercial Ins", + "Allstate Insurance": "Commercial Ins", + "American Business Coalition": "Commercial Ins", + "American Continental Insurance": "Commercial Ins", + "American Family Life Company": "Commercial Ins", + "American Medical Secu Sloans": "Commercial Ins", + "American Plan Administrators": "Commercial Ins", + "American Public Basic Core": "Commercial Ins", + "Anthem Blue Cross Blue Shield": "Commercial Ins", + "APM2 Medicaid": "Medicaid", + "BANKERS FIDELITY": "Commercial Ins", + "Bankers Life and Casualty": "Commercial Ins", + "BCBS of Texas": "Commercial Ins", + "BH COLORADO ACCESS": "RAE (Behavioral Health)", + "BH ROCK MTN UB": "RAE (Behavioral Health)", + "BH ROCKY MOUNTAIN HEALTH": "RAE (Behavioral Health)", + "BH ROCKY MOUNTAIN HEALTH PLANS": "Commercial Ins", + "BH ROCKY MTN HEALTH 2022": "RAE (Behavioral Health)", + "BH Rocky Mtn UB": "RAE (Behavioral Health)", + "BIND BENEFITS INC": "Commercial Ins", + "Boon Chapman": "Commercial Ins", + "Breckpoint INC": "Commercial Ins", + "Bridgepointe": "Commercial Ins", + "Bright Health": "Commercial Ins", + "BRMS": "Commercial Ins", + "Butt Savers": "Self Pay", + "Care Guard": "Commercial Ins", + "CareFirst Admin": "Commercial Ins", + "Carelon Behavioral Health, Inc": "RAE (Behavioral Health)", + "Cares Clinic Program": "Self Pay", + "CCH CO COMMUNITY HEALTH ALLIANCE": "RAE (Behavioral Health)", + "CCHA CO COMMUNITY HEALTH ALLIANCE": "RAE (Behavioral Health)", + "Central States Health": "Commercial Ins", + "Champva": "Commercial Ins", + "Child Health Plan Plus": "CHP+", + "CHP+ DENTAQUEST": "Dental - CHP+/DentaQuest", + "Cigna": "Commercial Ins", + "cigna": "Commercial Ins", + "Cigna Health Spring/ Bravo": "Commercial Ins", + "Cigna Healthcare of CO": "Commercial Ins", + "Cigna HealthCare Of Colorado": "Commercial Ins", + "Cigna Healthspring": "Commercial Ins", + "Cigna Medicare Access Plan": "Medicare", + "Cigna Medicare Supplement Solutions": "Medicare", + "Cigna Salud Employee": "Commercial Ins", + "Clear Spring Health": "Commercial Ins", + "Clear Spring Health MCR": "Medicare", + "Clinic Sliding Fee Scale": "Self Pay", + "Cofinity Health EZ": "Commercial Ins", + "Cofinity Sloan Lake": "Commercial Ins", + "Collaborative Care Clinic": "Self Pay", + "Colorado Laborers Cigna": "Commercial Ins", + "Coresource": "Commercial Ins", + "Coventry Health Care of Nebraska": "Commercial Ins", + "COVID19 HRSA Unisured Testing & Treatment Fund": "Commercial Ins", + "Cypress Benefit Admin / Cigna PPO": "Commercial Ins", + "Delta Dental": "Dental - Commercial", + "Delta Dental Ins Company": "Dental - Commercial", + "DELTA DENTAL INSURANCE COMPANY": "Dental - Commercial", + "Delta Dental of Arizona": "Dental - Commercial", + "Delta Dental of Arkansas": "Dental - Commercial", + "Delta Dental of California": "Dental - Commercial", + "Delta Dental of Colorado": "Dental - Commercial", + "Delta Dental Of Georgia": "Dental - Commercial", + "Delta Dental of Idaho": "Dental - Commercial", + "Delta Dental of Illinois": "Dental - Commercial", + "Delta Dental of Kansas": "Dental - Commercial", + "Delta Dental of Kentucky": "Dental - Commercial", + "Delta Dental of Minnesota": "Dental - Commercial", + "Delta Dental of Missouri": "Dental - Commercial", + "Delta Dental Of New Jersey, Inc.": "Dental - Commercial", + "Delta Dental of New Mexico": "Dental - Commercial", + "Delta Dental of Ohio": "Dental - Commercial", + "Delta Dental of Oklahoma": "Dental - Commercial", + "DELTA DENTAL OF OREGON": "Dental - Commercial", + "Delta Dental of Pennsylvania": "Dental - Commercial", + "Delta Dental of South Dakota": "Dental - Commercial", + "Delta Dental of Tennessee": "Dental - Commercial", + "Delta Dental of Virginia": "Dental - Commercial", + "Delta Dental of Washington": "Dental - Commercial", + "Delta Dental of Wisconsin": "Dental - Commercial", + "Delta Dental Of Wyoming": "Dental - Commercial", + "DELTA DENTAL PPO": "Dental - Commercial", + "Delta Dental PPO Plus Premier": "Dental - Commercial", + "Dental Anthem Services": "Dental - Commercial", + "Dental Cigna": "Dental - Commercial", + "Dental Slide D": "Self Pay", + "Dental Slide E": "Self Pay", + "Dental Slide F": "Self Pay", + "Dental Slide G": "Self Pay", + "Dental Slide H": "Self Pay", + "Dental Slide ZNABC": "Self Pay", + "Dental United Concordia Company": "Dental - Commercial", + "Dental United Healthcare": "Dental - Commercial", + "DentaQuest": "Dental - CHP+/DentaQuest", + "Denver Elevate Health Plan": "Commercial Ins", + "Denver Health Medicaid Choice": "Medicaid", + "DENVER HEALTH MEDICAL PLAN INC": "Commercial Ins", + "Denver Health Medicare Choice": "Medicare", + "Departmen of Veterans Affairs": "Commercial Ins", + "Dept. Of Vet Affairs": "Commercial Ins", + "Detego Health": "Commercial Ins", + "Devoted": "Commercial Ins", + "Devoted MCR UB": "Medicare", + "EBSO": "Commercial Ins", + "Emblem Health Professional Claims": "Commercial Ins", + "Emer MCD/Reprod Health Limited": "Medicaid", + "Employee Benefit Services": "Commercial Ins", + "Employee Benefits Mgmt Services": "Commercial Ins", + "Estes Park Foundation": "Commercial Ins", + "Family Planning Limited Medicaid": "Medicaid", + "First Administrators Inc": "Commercial Ins", + "First Choice Health Network": "Commercial Ins", + "First Health Network": "Commercial Ins", + "First Health Network Employer Plan Svcs": "Commercial Ins", + "FRIDAY HEALTH PLANS OF COLORADO": "Commercial Ins", + "Fringe Benefit Group": "Commercial Ins", + "GEHA": "Commercial Ins", + "GEHA ASA": "Commercial Ins", + "Golden Rule": "Commercial Ins", + "GPA": "Commercial Ins", + "Gravie Admin Services": "Commercial Ins", + "Group Administrators, Ltd": "Commercial Ins", + "HCFA Medicaid": "Medicaid", + "HCFA Medicaid Emergency": "Medicaid", + "HCFA Medicare": "Medicare", + "HCFA Medicare Novitas": "Medicare", + "HEALTH COLORADO INC": "RAE (Behavioral Health)", + "HEALTH COLORADO INC./ Carelon": "RAE (Behavioral Health)", + "Health Comp": "Commercial Ins", + "Health First TPA": "Commercial Ins", + "Health Partners": "Commercial Ins", + "HEALTH SCOPE BENEFITS": "Commercial Ins", + "Healthsmart Preferred Care": "Commercial Ins", + "HIGHMARK BLUE CROSS": "Commercial Ins", + "Humana Claims": "Commercial Ins", + "Humana Medicare": "Medicare", + "IMPACT Health Sharing": "Commercial Ins", + "Indecs Homestead": "Commercial Ins", + "INOVA CARE": "Commercial Ins", + "INSURANCE MANAGEMENT SERVICES": "Commercial Ins", + "KAISER COLORADO": "Commercial Ins", + "Kaiser Permanente": "Commercial Ins", + "KAISER PERMANENTE CLAIMS ADMINISTRATION": "Commercial Ins", + "Kaiser Permanente Colorado": "Commercial Ins", + "Key Benefit Administrators KBA": "Commercial Ins", + "KIP HAMPDEN LLLP": "Payer Not In Group", + "LINECO": "Commercial Ins", + "Lucent Health": "Commercial Ins", + "Lumico": "Commercial Ins", + "MBA": "Commercial Ins", + "MCS Advantage": "Medicare", + "Medi Share": "Commercial Ins", + "Medi-Share": "Commercial Ins", + "Medicaid": "Medicaid", + "Medicare": "Medicare", + "Medicare-Novitas": "Medicare", + "Merit Standard": "Commercial Ins", + "Meritain Health": "Commercial Ins", + "MetLife Dental Claims": "Dental - Commercial", + "Migrant Program": "Self Pay", + "MSGA Principal Advantage Limited Benefit": "Commercial Ins", + "MUTUAL OF OMAHA": "Commercial Ins", + "Mutual Of Omaha": "Commercial Ins", + "Nebraska Total Care": "Medicaid", + "Northeast Colorado Health Department": "RAE (Behavioral Health)", + "Northeast Health Partners": "RAE (Behavioral Health)", + "Old Surety Life Ins Co": "Commercial Ins", + "OPERATING ENGINEERS HW FOR CO/Cigna": "Commercial Ins", + "Optimed Health": "Commercial Ins", + "Optum Care Network": "Commercial Ins", + "OSCAR": "Commercial Ins", + "OTHER PRIMAY/MEDICAID": "Medicaid", + "OUTREACH PROGRAM": "Self Pay", + "OXFORD HEALTH INSURANCE": "Commercial Ins", + "Oxford Health Insurance UHC": "Commercial Ins", + "Pacificare Secure Horizons MEDICARE": "Medicare", + "Pan American Life Insurance Co": "Commercial Ins", + "Patient": "Patient", + "Pending medicaid": "Medicaid", + "PERENNIAL ADVANTAGE CO": "Commercial Ins", + "PHCS Benefit Administrative Systems": "Commercial Ins", + "PHCS Health Plan Services": "Commercial Ins", + "Phil Long Ford of Raton": "Commercial Ins", + "Physicians Mutual": "Commercial Ins", + "Pinnacol Assurance": "Commercial Ins", + "Preferred One": "Commercial Ins", + "Premera BCBS WA AK": "Commercial Ins", + "Presbyterian Health Plan": "Commercial Ins", + "Principal Life Insurance Co": "Commercial Ins", + "Railroad Retirement": "Medicare", + "Redirect Health Administration": "Commercial Ins", + "REGENCE BLUE SHIELD": "Commercial Ins", + "Reserve National Insurance Com": "Commercial Ins", + "Rocky Mountain Health Plans": "Commercial Ins", + "Rocky Mountain Health Prime": "Regional Accountable Entity-RAE (Reporting Group)", + "SALUD DENTAL EMPLOYEE DISCOUNT": "Self Pay", + "SBMA": "Commercial Ins", + "SDP Dental Program": "Self Pay", + "Secure Horizons Medicare": "Medicare", + "Select Health": "Commercial Ins", + "Self-Pay": "Self-Pay", + "Sentara": "Commercial Ins", + "Sierra Hlth and Life Ins Co": "Commercial Ins", + "Simplified Benefit Admin": "Commercial Ins", + "SISCO": "Commercial Ins", + "STATEFARM": "Commercial Ins", + "Student Resources": "Commercial Ins", + "SUNSHINE CLUB": "Self Pay", + "SUNSHINE HEALTH": "Commercial Ins", + "Teen Confidential": "Self Pay", + "The Boon Group": "Commercial Ins", + "The Kempton Group Administrators Inc": "Commercial Ins", + "The Loomis Company": "Commercial Ins", + "Thrivent Financial for Lutherans": "Commercial Ins", + "Transamerica": "Commercial Ins", + "Tricare": "Commercial Ins", + "Tricare For Life": "Commercial Ins", + "Tricare West Region": "Commercial Ins", + "Trinidad Phil Long- Toyota": "Commercial Ins", + "UCHealth Plan Administrators": "Commercial Ins", + "UHC Community Do not use": "Medicare", + "UHC Community Plan": "Medicare", + "UHC Global": "Commercial Ins", + "UHC shared Services": "Commercial Ins", + "UMR": "Commercial Ins", + "UMR-COST ASSIST HIAP": "Commercial Ins", + "UMWA Health And Retirement Funds": "Commercial Ins", + "United American Insurance": "Commercial Ins", + "United Health Care": "Commercial Ins", + "United Health Care Co plan": "Commercial Ins", + "United Health Integrated Services": "Commercial Ins", + "United healthcare": "Commercial Ins", + "United Healthcare": "Commercial Ins", + "united healthcare": "Medicare", + "United HealthCare": "Commercial Ins", + "United Healthcare Community MCR": "Medicare", + "United Healthcare MEDICARE": "Medicare", + "UNITED OF OMAHA LIFE INSURANCE": "Commercial Ins", + "Unitedhealth One": "Commercial Ins", + "US DEPARTMENT OF VETERANS AFFAIRS": "Commercial Ins", + "USAA LIFE INSURANCE COMPANY": "Commercial Ins", + "Vault Admin Service": "Commercial Ins", + "Vitori Health": "Commercial Ins", + "WEB TPA": "Commercial Ins", + "Wellcare": "Commercial Ins", + "WELLCARE MEDICARE": "Commercial Ins", + "WPS Health Insurance": "Commercial Ins", + "WWC/WiseWoman Programs": "Self Pay", + "Zenith Administrators": "Commercial Ins" +} diff --git a/src/ecw_bot/selectors/__init__.py b/src/ecw_bot/selectors/__init__.py new file mode 100644 index 0000000..999f84b --- /dev/null +++ b/src/ecw_bot/selectors/__init__.py @@ -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. +""" diff --git a/src/ecw_bot/selectors/account_inquiry.py b/src/ecw_bot/selectors/account_inquiry.py new file mode 100644 index 0000000..ce54393 --- /dev/null +++ b/src/ecw_bot/selectors/account_inquiry.py @@ -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()"]' diff --git a/src/ecw_bot/selectors/claim_form.py b/src/ecw_bot/selectors/claim_form.py new file mode 100644 index 0000000..b42eaf1 --- /dev/null +++ b/src/ecw_bot/selectors/claim_form.py @@ -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()"]') diff --git a/src/ecw_bot/selectors/claim_lookup.py b/src/ecw_bot/selectors/claim_lookup.py new file mode 100644 index 0000000..fb804d5 --- /dev/null +++ b/src/ecw_bot/selectors/claim_lookup.py @@ -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}"]' diff --git a/src/ecw_bot/selectors/fallbacks.py b/src/ecw_bot/selectors/fallbacks.py new file mode 100644 index 0000000..afcf7fc --- /dev/null +++ b/src/ecw_bot/selectors/fallbacks.py @@ -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), +} diff --git a/src/ecw_bot/selectors/login.py b/src/ecw_bot/selectors/login.py new file mode 100644 index 0000000..6afbd99 --- /dev/null +++ b/src/ecw_bot/selectors/login.py @@ -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" diff --git a/src/ecw_bot/selectors/shell.py b/src/ecw_bot/selectors/shell.py new file mode 100644 index 0000000..fd1f57a --- /dev/null +++ b/src/ecw_bot/selectors/shell.py @@ -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" diff --git a/src/ecw_bot/settings.py b/src/ecw_bot/settings.py new file mode 100644 index 0000000..c23e817 --- /dev/null +++ b/src/ecw_bot/settings.py @@ -0,0 +1,760 @@ +""" +Salud eCW Charge Entry Bot — settings. + +Single-client build. Everything that changes between environments lives in +`.env` at the project root (never committed). No credentials in code, and no +credentials in a shared spreadsheet the way the multi-client NAHMS bot did it. + +Read order for every setting: + 1. real process environment variable (wins — good for CI / scheduled runs) + 2. .env file at the project root + 3. the default in this file + +**Selectors do not live here.** They used to, and a 442-line module mixing DOM +ids with operational settings meant changing a date range and changing a +selector were edits to the same file. They are in `selectors\\` now; this +module holds only values a person would want to change without touching code. +""" + +import os +import re +import sys +from pathlib import Path +from datetime import datetime + +from .selectors.claim_lookup import DATE_TYPE_CLAIM + +# ── Project roots ───────────────────────────────────────────────────────────── +# Three parents, not one: this file is src\ecw_bot\settings.py, so +# settings.py → ecw_bot → src → the project root that holds .env and Logs\. +if getattr(sys, "frozen", False): + ROOT = Path(sys.executable).parent +else: + ROOT = Path(__file__).resolve().parent.parent.parent + +ENV_PATH = ROOT / ".env" +TODAY = datetime.now().strftime("%m-%d-%Y") +SCREENSHOTS = ROOT / "Screenshots" / TODAY +LOGS = ROOT / "Logs" +DOWNLOADS_TMP = ROOT / "Input" / "_pw_downloads" + + +# ── .env loader ─────────────────────────────────────────────────────────────── +# Deliberately dependency-free: these machines are locked down and adding +# python-dotenv is one more thing to get approved. The format is the usual +# KEY=value, one per line, '#' comments, optional surrounding quotes. +# +# Inline comments: an UNQUOTED value ends at the first whitespace followed by +# '#', so `SAVE_CORRECTIONS=0 # verification` reads as "0". Before 2026-10-05 +# the whole tail was kept, "0 # verification" was not a recognised yes/no +# word, and the setting fell back to its code default — which for +# SAVE_CORRECTIONS was ON. A comment meant to document "saving is off" turned +# saving on, silently (review item P1). `CLAIM_PROCESS_LIMIT=5 # demo` read as +# the default 1 the same way. +# +# A '#' with NO whitespace before it stays part of the value: real values here +# contain one — the landing URL's "#/..." fragment, and a mailbox password — +# and neither has a space in front of it. +# +# **Quote a value that needs ` #`** (space then hash) inside it: +# `KEY="a # b"` reads as `a # b`. For a quoted value the text between the +# quotes is the value and anything after the closing quote (` # note`) is +# dropped. A value that is quoted end to end with nothing after it reads +# exactly as it always did, inner quotes included (`"pa"ss"` → `pa"ss`, +# `"Ab"#12"` → `Ab"#12`): after a closing quote, as everywhere else, a '#' +# starts a comment only when whitespace comes before it. The one shape that +# cannot be told apart from a trailing comment is an inner quote of the SAME +# kind followed by whitespace and '#' (`"a" #b"` reads as `a`); write such a +# value in the other kind of quotes (`'a" #b'` reads as `a" #b`). +# +# Text after the closing quote that is NOT a comment (`"1"0`, `'1'x`) reads +# as the quoted part for a text setting, but stops the run (exit 2, before +# login) for a yes/no or whole-number one — see _QUOTE_TRAILING. + +# Opening quote, the shortest text that still lets the rest of the line be +# nothing but an optional ` #` comment, then the same quote. Shortest, so a +# quote inside the comment cannot pull the comment into the value; "still lets +# the rest be a comment", so a quote inside the value does not cut it short. +# The comment needs whitespace before its '#' — the same rule as an unquoted +# value — so `"Ab"#12"` is one value, not `Ab` plus a comment (fix round 1 of +# the unit-A review: the first version cut a password there, silently). +_QUOTED_VALUE = re.compile(r"""^(["'])(.*?)\1(?:\s+#.*)?$""") +_INLINE_COMMENT = re.compile(r"\s+#") + + +# Keys whose .env value was quoted and then followed by text that is NOT a +# comment (`SAVE_CORRECTIONS="1"0`), as {key: the text after the '='}. Such +# a value still reads as the quoted part — a password written that way keeps +# working as it always did — but a yes/no or whole-number setting must never +# be guessed at, so `_env_bool` / `_env_int` record it as a bad value (exit 2 +# before login) instead of reading `"1"0` as 1 = LIVE-WRITE. The same text +# unquoted (`10`, `1x`) was already refused (final review, 2026-10-05). +_QUOTE_TRAILING: dict = {} + + +def _parse_env_value(raw: str) -> tuple: + """ + `(value, trailing)` for one `KEY=value` line's text after the '='. + `trailing` is True when a quoted value has non-comment text after its + closing quote — read as the quoted part, but not a clean value. + """ + text = raw.strip() + if text[:1] in ("\"", "'"): + m = _QUOTED_VALUE.match(text) + if m: + return m.group(2), False + # Text after the closing quote that is not a comment (`"abc"xyz`): + # the quotes still say where the value ends. + end = text.find(text[0], 1) + if end != -1: + return text[1:end], True + # No closing quote at all: not a quoted value, so the quote character + # is just part of an unquoted one — what the loader always did. + # Cut on the raw text, not the stripped one, so `KEY= # nothing yet` + # (whitespace right after the '=') reads as blank rather than "# nothing". + return _INLINE_COMMENT.split(raw, maxsplit=1)[0].strip(), False + + +def _env_file_value(raw: str) -> str: + """The value part of one `KEY=value` line, as written after the '='.""" + return _parse_env_value(raw)[0] + + +# The keys this process took from .env, not from the real environment. A +# child process started from here — the results page's bot run, +# web/bot_runner.py — is given the environment WITHOUT them, so its own +# settings.py reads .env afresh: an .env edited after the web server started +# is seen, as a terminal run sees it (review, 2026-10-14). Inherited, the +# server's copy would win over the file through the setdefault below. +ENV_FILE_KEYS = set() + + +def _load_env_file(path: Path) -> None: + if not path.exists(): + return + for line in path.read_text(encoding="utf-8-sig").splitlines(): + line = line.strip() + if not line or line.startswith("#") or "=" not in line: + continue + key, _, raw = line.partition("=") + key = key.strip() + val, trailing = _parse_env_value(raw) + if trailing and key not in os.environ: + # Only when the file's value is the one used (real env wins). + _QUOTE_TRAILING[key] = raw.strip() + # Real environment wins over the file. + if key not in os.environ: + # As os.environ lists it: Windows stores keys upper-cased, and + # the child-env filter walks os.environ. + ENV_FILE_KEYS.add(key.upper() if os.name == "nt" else key) + os.environ.setdefault(key, val) + + +_load_env_file(ENV_PATH) + + +def _env(key: str, default: str = "") -> str: + return str(os.environ.get(key, default) or "").strip() + + +# Every yes/no or whole-number setting whose value could not be read, as +# (key, raw value, what it should have been). validate() turns each into a +# problem, so the run stops with exit 2 BEFORE login — where stopping costs +# nothing — instead of quietly running on the code default. +# +# Strict on purpose (P1, 2026-10-01 review): the old parsers fell back to the +# default for anything they did not recognise, so `SAVE_CORRECTIONS=O` (the +# letter O), a stray comment, or a typo all read as the default — which for +# SAVE_CORRECTIONS was "save to live eCW". A setting that controls writes must +# never be guessed at. Blank or unset is NOT recorded: that genuinely means +# "use the default". +_BAD_VALUES: list = [] + + +def _record_bad(key: str, raw: str, expected: str) -> None: + if (key, raw, expected) not in _BAD_VALUES: + _BAD_VALUES.append((key, raw, expected)) + + +def _env_int(key: str, default: int) -> int: + if key in _QUOTE_TRAILING: + _record_bad(key, _QUOTE_TRAILING[key], "a whole number") + return default + raw = _env(key) + if not raw: + return default + # isascii() too: "²" is a str.isdigit() digit that int() refuses, and a + # crash at import would hide the clear validate() message below. + if raw.isascii() and raw.isdigit(): + return int(raw) + _record_bad(key, raw, "a whole number") + return default + + +def _env_bool(key: str, default: bool = False) -> bool: + if key in _QUOTE_TRAILING: + _record_bad(key, _QUOTE_TRAILING[key], "a valid yes/no value") + return default + raw = _env(key) + word = raw.lower() + if word in ("1", "true", "yes", "y", "on"): + return True + if word in ("0", "false", "no", "n", "off"): + return False + if raw: + _record_bad(key, raw, "a valid yes/no value") + return default + + +# ── eCW portal ──────────────────────────────────────────────────────────────── +ECW_URL = _env("ECW_URL") +ECW_USERNAME = _env("ECW_USERNAME") +ECW_PASSWORD = _env("ECW_PASSWORD") +# Blank for this client — kept so a TOTP-enabled login needs no code change. +ECW_SECRET_KEY = _env("ECW_SECRET_KEY") + +# Optional deep link to open once login is confirmed (the provider schedule +# page). eCW drops the URL fragment during the login redirect, so navigating +# again after login is the only reliable way to land on it. +ECW_LANDING_URL = _env("ECW_LANDING_URL", ECW_URL) + + +# ── OTP / two-factor ────────────────────────────────────────────────────────── +# auto — TOTP if ECW_SECRET_KEY is set, else mail if configured, else manual +# totp — authenticator secret only +# mail — read the challenge out of the mailbox. eCW sends either a 6-digit +# code or a "Push Notification" approval link; auth/mailbox.py detects +# which, so one mode covers both. (`imap` is an accepted old alias.) +# manual — pause with a message box and let a human handle it +OTP_MODE = _env("OTP_MODE", "auto").lower() + +IMAP_HOST = _env("IMAP_HOST") +IMAP_PORT = _env_int("IMAP_PORT", 993) +IMAP_USERNAME = _env("IMAP_USERNAME") +IMAP_PASSWORD = _env("IMAP_PASSWORD") +IMAP_FOLDER = _env("IMAP_FOLDER", "INBOX") +IMAP_SSL = _env_bool("IMAP_SSL", True) + +# How long to keep polling the mailbox for the OTP mail, and how often. +# +# Ten minutes. eCW's push mail is DELAYED, not dropped — proven by delivery +# order on 2026-09-02: the message eCW stamped 11:19:10 arrived in the mailbox +# *after* the one stamped 11:26:10, so roughly seven minutes late, and ten has +# been seen. A short window gives up while the mail is still in flight and the +# run then blames the mailbox. Quitting early is what costs a login attempt. +OTP_WAIT_SECONDS = _env_int("OTP_WAIT_SECONDS", 600) +OTP_POLL_SECONDS = _env_int("OTP_POLL_SECONDS", 5) + +# A challenge older than this gets a warning in the log — it is NOT skipped. +# +# eCW's JWT is good for about ten minutes and the mail itself sometimes takes +# ten to arrive, so a link can land already near expiry. Refusing to click it +# then guarantees failure, while clicking it costs nothing that has not already +# been spent. So this only marks the age, giving the log a reason to point at +# when eCW answers "this verification link has expired" — which otherwise reads +# exactly like a wrong password. +OTP_LINK_AGE_WARN_SECONDS = _env_int("OTP_LINK_AGE_WARN_SECONDS", 540) + +# eCW's push notification screen offers a "Resend OTP" button, and pressing it +# beats handing the step to a human. +# +# But resend LATE, not early — a full ten minutes of waiting first. +# +# Ten because that is the longest delivery delay actually observed: the mail is +# late rather than missing, so before ten minutes have passed "still in flight" +# is the likeliest explanation, and a second message would only join the same +# slow queue behind the first. +# +# One resend, so the schedule is 600s → Resend → 600s = 20 minutes before a +# human is asked. Raise OTP_RESEND_ATTEMPTS for more rounds, but note the two +# ceilings that are NOT ours to raise: eCW's JWT is good for about ten minutes, +# and its login page may expire before the wait does — auth/twofactor.py checks +# for that and stops with it as the stated reason. +OTP_RESEND_ATTEMPTS = _env_int("OTP_RESEND_ATTEMPTS", 1) +OTP_RESEND_WAIT_SECONDS = _env_int("OTP_RESEND_WAIT_SECONDS", 600) +# Ignore anything already sitting in the inbox from before the login attempt. +OTP_MAX_AGE_SECONDS = _env_int("OTP_MAX_AGE_SECONDS", 300) + + +# ── Proxy ───────────────────────────────────────────────────────────────────── +PROXY_IP = _env("PROXY_IP") +PROXY_PORT = _env("PROXY_PORT") + + +def proxy_settings(): + """Playwright proxy dict, or None when no usable proxy is configured.""" + import re + if not (PROXY_IP and PROXY_PORT): + return None + ip = re.sub(r"^https?://", "", PROXY_IP).strip().rstrip("/") + port = PROXY_PORT.strip() + if not ip or not port.isdigit(): + return None + return {"server": f"http://{ip}:{port}"} + + +# ── Attempt guard ───────────────────────────────────────────────────────────── +# eCW restricts an account that is retried too hard — seven attempts in +# forty-five minutes was enough, and afterwards it stopped mailing approval +# links while the page still claimed it had sent one. core/guard.py enforces +# these. +LOGIN_COOLDOWN_SECONDS = _env_int("LOGIN_COOLDOWN_SECONDS", 300) # 5 min +LOGIN_MAX_PER_HOUR = _env_int("LOGIN_MAX_PER_HOUR", 4) + +# If a plain cooldown has no more than this left to run, the bot waits it out +# rather than exiting 3. Refusing a run over the last few seconds of a cooldown +# just moves the retrying into the operator's hands, which is worse: five of six +# consecutive runs died on this, one of them with 3s left. A restriction lockout +# and the per-hour burst limit are never waited out — those mean stop. +# 0 disables waiting and restores the old exit-immediately behaviour. +COOLDOWN_MAX_WAIT_SECONDS = _env_int("COOLDOWN_MAX_WAIT_SECONDS", + LOGIN_COOLDOWN_SECONDS) + +# How many times to start the login over when eCW's 2FA screen times out. +# +# "Your log in request has timed out. Please try again" is a dead end: the page +# has stopped polling for the approval and the emailed link is spent, so only a +# fresh login helps. The browser is closed and the whole login runs again. +# +# Each retry goes through the guard above — cooldown waited out, burst limit +# still enforced — because an unguarded retry loop against this portal is +# exactly what earned the account its restriction. With the defaults that means +# a retry costs a 5-minute wait and one of the four hourly attempts, so 1 is +# deliberately modest: two shots at a factor that is delayed rather than +# broken. 0 turns the retry off and the run stops with exit 6. +LOGIN_RETRY_ON_TIMEOUT = _env_int("LOGIN_RETRY_ON_TIMEOUT", 1) + + +# ── Operator prompts ────────────────────────────────────────────────────────── +# Every message box times out, so an unattended run fails instead of holding a +# browser open indefinitely. +MSGBOX_TIMEOUT_SECONDS = _env_int("MSGBOX_TIMEOUT_SECONDS", 180) + + +# ── Disk space ──────────────────────────────────────────────────────────────── +# Checked on the system drive before login (2026-10-22): with C: at 0 MB the +# browser died mid-login twice on 2026-10-21. Under WARN = a Warning on the +# Runs page; under STOP = the Run stops with exit 2, no login attempt spent. +DISK_WARN_MB = _env_int("DISK_WARN_MB", 2048) +DISK_STOP_MB = _env_int("DISK_STOP_MB", 500) + + +# ── Claim watchdog ──────────────────────────────────────────────────────────── +# No progress on a claim for this many minutes = the page froze (2026-10-22: +# a run sat 28 minutes on one claim). The driver is closed and the run stops +# with exit 8, recorded. Never under a message box's timeout + 2 minutes. +# 0 = off. core/watchdog.py. +WATCHDOG_MINUTES = _env_int("WATCHDOG_MINUTES", 10) + + +# ── Browser ─────────────────────────────────────────────────────────────────── +HEADLESS = _env_bool("HEADLESS", False) +CHROME_PATH = _env("CHROME_PATH") # blank → Playwright's bundled chromium + +# Attach to a Chrome the operator started with start_ecw_chrome.bat instead of +# launching one, so the run can be watched live (Claude extension in that +# Chrome). Blank → launch as before. Loopback only: whatever reaches this port +# can drive a browser logged in to eCW. In this mode the proxy is Chrome's own +# (the .bat passes it), and eCW's cookies are cleared at the start and end of +# every run. +BROWSER_CDP_URL = _env("BROWSER_CDP_URL") +# The profile that Chrome uses — on D:, because C: running out of space killed +# the browser mid-login (2026-10-21). +CDP_PROFILE_DIR = _env("CDP_PROFILE_DIR", r"D:\EcwChrome") +# A Chrome the bot opened itself (none on the port at Start) is closed again +# at the end, so its debug port is not left open. A Chrome the operator +# opened is never closed. 0 = leave it open. +CDP_CLOSE_AFTER_RUN = _env_bool("CDP_CLOSE_AFTER_RUN", True) + + +def cdp_url_problem(url: str): + """Why `url` is not a usable BROWSER_CDP_URL, or None if it is.""" + from urllib.parse import urlsplit + try: + parts = urlsplit(url) + port = parts.port + except ValueError: + return f"BROWSER_CDP_URL={url!r} is not a URL" + if parts.scheme != "http" or not port: + return f"BROWSER_CDP_URL={url!r} must look like http://127.0.0.1:9222" + if parts.hostname not in ("127.0.0.1", "localhost"): + return (f"BROWSER_CDP_URL={url!r} must be on 127.0.0.1 — anything that " + f"reaches the port can drive the logged-in browser") + return None + + +# ── Timeouts (ms — Playwright's unit) ───────────────────────────────────────── +LOGIN_TIMEOUT = _env_int("LOGIN_TIMEOUT_MS", 120_000) # 2 min +NAV_TIMEOUT = _env_int("NAV_TIMEOUT_MS", 300_000) # 5 min +NAV_RETRIES = _env_int("NAV_RETRIES", 3) # proxy ERR_ABORTED retries + +# Captcha is solved by a human via message box; two tries, then a fresh +# incognito browser, then one last try. Same escalation the NAHMS bot settled +# on after the OCR route proved unreliable. +CAPTCHA_ATTEMPTS = _env_int("CAPTCHA_ATTEMPTS", 2) + + +# ── Claim lookup: what to search for ────────────────────────────────────────── +# Selected by VALUE, with the label checked afterwards. The value is what eCW +# submits and the only unambiguous handle — the list holds "Medcor Ready" +# (108ME) and "Medcor Testing" (113ME), so a loose text match could pick the +# wrong queue and the search would still succeed. The label check then catches +# the opposite risk: eCW reassigning a code to a different status. +CLAIM_STATUS = _env("CLAIM_STATUS", "108ME") +CLAIM_STATUS_LABEL = _env("CLAIM_STATUS_LABEL", "Medcor Ready") + +# Which date the search filters on. The process asks for Claim Dt(s). +# +# Be aware of what it pairs with: Claim Status "Encounters Without Claims" +# returns rows that have no claim, so they have no claim date and this returns +# nothing. Measured 2026-09-02, same 06/01/2020 → today range — 936 rows over +# 47 pages on Service Dt(s), 0 on Claim Dt(s). claim_filters._warn_if_dateless() +# says so in the log rather than leaving an empty grid to be puzzled over. +CLAIM_DATE_TYPE = _env("CLAIM_DATE_TYPE", DATE_TYPE_CLAIM) + +# From is fixed by the process; To is "today" unless overridden. MM/DD/YYYY, +# matching the inputs' own placeholder and their idt-maxlength="10". +CLAIM_FROM_DATE = _env("CLAIM_FROM_DATE", "06/01/2020") +CLAIM_TO_DATE = _env("CLAIM_TO_DATE") # blank → today + +# The Balance row arrives holding "> 0.0" with every control disabled, and eCW +# queries on the Angular model rather than the inputs — so the filter is live +# and unclickable. 1 clears it through the scope; 0 leaves it alone. +# +# The ng-scope / ng-binding classes in the DOM confirm Angular's debugInfo is +# enabled, which is what makes angular.element(el).scope() available. If eCW +# ever ships with it disabled this stops working, and it says so rather than +# reporting a clear that did not happen. +FORCE_CLEAR_BALANCE = _env_bool("FORCE_CLEAR_BALANCE", True) + +# How long to wait for the results grid after Lookup is pressed. +# NOT wait_for_load_state("networkidle"): eCW's Angular shell keeps a heartbeat +# running, so the network never goes idle and that call sat silent for its whole +# timeout while the run looked frozen — every run was killed there, before the +# export step was ever reached. claim_results.py polls the grid's own +# "Total Counts" and says what it sees, so a slow query reads as slow. +LOOKUP_TIMEOUT = _env_int("LOOKUP_TIMEOUT_MS", 180_000) # 3 min +LOOKUP_POLL_SECONDS = _env_int("LOOKUP_POLL_SECONDS", 3) + + +# ── Export ──────────────────────────────────────────────────────────────────── +CLAIM_REPORT_FORMAT = _env("CLAIM_REPORT_FORMAT", "EXCEL") # EXCEL | CSV | TEXT + +# Where the exported report is saved: `Input\`, not `Output\`. +# +# It reads like output — the bot downloaded it — but it is the **input to the +# charge-entry phase**: the next step walks its claim ids one at a time. Naming +# it Output would invite someone to treat it as a finished deliverable and +# clear it out between runs, which would strand the phase that consumes it. +# +# Gitignored, like Output — it is a claims report, so it holds patient data. +INPUTS = ROOT / "Input" + +# The saved file is `<CLAIM_REPORT_NAME>_<MM-DD-YYYY>.<ext>`, e.g. +# `Claim Report_09-07-2026.xlsx`. A fixed, predictable name because the next +# phase has to find it without being told; the date because the queue is a +# daily one. The extension is whatever eCW actually sent — its "EXCEL" export +# has been both a real xlsx and an HTML table, and renaming HTML to .xlsx makes +# Excel refuse to open it. +CLAIM_REPORT_NAME = _env("CLAIM_REPORT_NAME", "Claim Report") + +# Where the previous run's report goes when a new one is downloaded. +# +# Input\ ends up holding EXACTLY ONE report, always the current one. Six of +# them accumulated over three days of testing, and once more than one is +# sitting there "which file is current?" stops being obvious - to a person +# reading the folder, and to any code that has to find it without being told. +# Moving the old ones out is what makes the answer unambiguous instead of a +# rule someone has to remember. +# +# Moved, not deleted: they are patient data and the earlier run's evidence. +INPUTS_ARCHIVE = INPUTS / "Archive" + +# 0 leaves old reports in Input\ alongside the new one. Only useful if +# something outside this bot is watching that folder for every file. +ARCHIVE_OLD_INPUTS = _env_bool("ARCHIVE_OLD_INPUTS", True) + +# After the export, read the claim ids back out of the saved file and show them +# in a message box — count, the first and last few, and any duplicates or blank +# rows skipped. The full list always goes to the log regardless of this. +# +# Worth doing as its own step: it proves the file the next phase will consume is +# actually readable and holds what it should, at the point where the browser is +# still open and a problem is cheap to look at. Set 0 for unattended runs. +SHOW_CLAIM_IDS = _env_bool("SHOW_CLAIM_IDS", True) + +# After categorizing by payer, tell the operator about any PAYER value with +# no entry in payer_groups.json — those rows are saved with a blank Group +# Name rather than a guess. Informational only, same reasoning as +# NOTIFY_AFTER_EXPORT above. Set 0 for unattended runs; the full list still +# goes to the log either way. +NOTIFY_UNMAPPED_PAYERS = _env_bool("NOTIFY_UNMAPPED_PAYERS", True) + +# TEMPORARY testing override. Blank (the default) leaves the live search + +# export flow above untouched. Set to a file path and `_claims_phase` skips +# the live bulk lookup and export entirely, reading claim ids straight from +# that file instead — the claim-status/date/balance filters are still set +# and every id is still searched and opened exactly the way a live-queue +# claim is, only the *source of the id list* changes. +# +# Built for a hand-maintained "standard" sheet listing one known claim per +# implemented condition: a run against it exercises every condition against +# a real claim in one short pass, so a new condition (or a regression in an +# old one) shows up in minutes instead of after a full live-queue run. +# `claim_file.claim_ids()` already reads any file with a recognizable +# claim-id header or column — this just points it at a fixed file instead +# of the report the export step just saved. +# +# Set, it also marks the run as a TEST run (review item D1, 2026-10-05): +# `[MODE]`/`[TEST MODE]` log lines say so, the Charge Summary goes to +# Charge Summary_TEST_<date>.xlsx (never the live summary's name), a path +# that does not exist stops the run before login (exit 2), and a sheet that +# exists but cannot be read stops it with exit 5 instead of a quiet exit 0. +# The exit-2 check lives in validate(), so it also stops +# tools/check_claims_from_excel.py, which does not use this setting: a stale +# path left here must be cleared (or fixed) before that tool will run either. +# Accepted on purpose — a path in .env that points at nothing is a config +# mistake whichever program reads the file (unit B fix round 1, 2026-10-05). +TEST_CLAIM_IDS_FILE = _env("TEST_CLAIM_IDS_FILE", "") + + +# ── Per-claim processing ────────────────────────────────────────────────────── +# How many claim ids to search, one at a time, after the export. 0 = all of +# them. +# +# **The default is 1 on purpose.** The report holds 541 ids and each one is a +# full server-side lookup, so "all" is a run measured in hours — not something +# to discover by accident while testing a new step. Raise it deliberately once +# the per-claim work actually does something. +CLAIM_PROCESS_LIMIT = _env_int("CLAIM_PROCESS_LIMIT", 1) + +# A message box after each claim. Fine at a limit of 1 or 2; at 50 it is 50 +# dialogs, so raise CLAIM_PROCESS_LIMIT and set this to 0 together. The +# end-of-run summary box appears either way, and every claim is logged. +# +# At most one box per claim (review item D9, 2026-10-05): a claim whose +# REVIEW_BEFORE_CLOSE box was shown (a condition changed it, saving off) has +# had its one box, with this box's header in it, and gets no second one. +# Dental claims show their HIGH RISK / sliding fee results here too — so 0 +# also turns off the Dental box, which used to appear whatever was set (P6). +SHOW_EACH_CLAIM = _env_bool("SHOW_EACH_CLAIM", True) +# Information boxes go to the log as one "[BOX] title: first line" line +# instead of the screen. The results page sets it for the runs it starts +# (2026-10-14). Boxes a person must act on — CAPTCHA, 2FA manual step, +# security image, LIVE-WRITE confirm — are never quieted. +QUIET_BOXES = _env_bool("QUIET_BOXES", False) + +# A failure screenshot (`page_utils.shot` with a failure tag) also saves the +# page's DOM beside it as .html (2026-10-21): the HTML to fix a changed +# selector from, without a re-run or a hand copy into Dom.txt. PHI, like the +# screenshots — it never leaves the Screenshots folder. SAVE_DOM_LIMIT caps +# the files per run (an eCW page is ~1 MB with its frames). +SAVE_DOM_ON_FAILURE = _env_bool("SAVE_DOM_ON_FAILURE", True) +SAVE_DOM_LIMIT = _env_int("SAVE_DOM_LIMIT", 30) + +# Pre-flight selector check (pages/preflight.py, 2026-10-21): before the +# first claim the claims search page's selectors, and on the first claim of +# each screen type (UB / HCFA / Dental) the claim form's, are checked. A +# missing one is named in the log, its DOM saved, and — PREFLIGHT_STOP on — +# the run stops (exit 7) instead of sending every claim after it to Review. +PREFLIGHT = _env_bool("PREFLIGHT", True) +PREFLIGHT_STOP = _env_bool("PREFLIGHT_STOP", True) + + +# For each claim, open the UB Claim form, read it, and close it again. +# +# Closed with Cancel (#UBClaimBtn17) unless SAVE_CORRECTIONS=1 and the +# operator clicks OK on that claim's save box (shown only when a condition +# fired), in which case that one claim closes with OK instead (#UBClaimBtn18, +# saveAllData(true)). See SAVE_CORRECTIONS for the guard. +OPEN_CLAIM_FORM = _env_bool("OPEN_CLAIM_FORM", True) + +# Apply the write-path corrections in pages/claim_form.py (telehealth +# POS/modifier is the first one) once their trigger condition is detected. +# +# This gates whether the conditions RUN at all — detect, then fill the open +# form in. It does not click OK: whether a filled-in form is ever saved is +# SAVE_CORRECTIONS below, and with that off the form is closed with Cancel. +# 0 restores the pre-correction behaviour: read, extract, always close with +# Cancel, check nothing. +# +# Default stays ON (operator ruling, 2026-10-05), unlike SAVE_CORRECTIONS. +# Applying only fills in a form that is then closed with Cancel while saving +# is off. Review item P16 is answered (audit log, 2026-10-16): typed edits +# (POS, modifiers, fees, pointers) are discarded by Cancel, but ticking a +# bill-to checkbox (updateBillToIns()) and adding a CPT row +# (addClaimFSCPTCode('save')) save on the server at once. So with +# SAVE_CORRECTIONS off neither is done — see SAVE_CORRECTIONS below. The demo +# .env does not set this, so defaulting it off would make every such run +# check nothing at all. +APPLY_CORRECTIONS = _env_bool("APPLY_CORRECTIONS", True) + +# The actual write gate, one level down from APPLY_CORRECTIONS — asked +# through `pages/claim_form.writes_allowed()`. With this at 0 (VERIFY) a +# condition still fires and fills the form ON SCREEN — so it stays visible +# and in the per-claim summary box, for review — but nothing reaches eCW: +# a bill-to box is set on screen with no click (the click saves at once), no +# CPT row is added (the picker's OK saves at once; the claim is Review, "add +# by hand"), the confirmation box never appears and the claim is always +# closed with Cancel. At 1 (LIVE-WRITE) box clicks and row adds save in eCW +# the moment they are made, before the per-claim OK, and Cancel does not +# undo them. Dental claims are never written in either mode. +# +# 2026-09-17: turned off on purpose in .env. A real claim (9103000) got saved +# for real during a test run because someone answered the confirmation box — +# this is the change that makes that impossible while conditions are still +# being verified against live claims. +# +# 2026-10-05: the CODE default is off too (review item P1). Until then only +# the .env line kept saving off, and the default behind it was ON — so a +# missing line, a blank value, a typo, or an inline comment the loader did not +# strip all turned writes back on without a word. Now a missing or blank line +# can never turn saving (the OK click) on; only an explicit SAVE_CORRECTIONS=1 +# can, and the run then asks for an OK at start (the LIVE-WRITE box in +# flows/export_medcor_ready.py) and before every save. P16 is answered (see +# APPLY_CORRECTIONS above): while this is off nothing is written (option C, +# 2026-10-16). Turn it on only once a condition has been confirmed correct +# and the client is ready for it to write. +SAVE_CORRECTIONS = _env_bool("SAVE_CORRECTIONS", False) + +# Pause on a message box before the claim form is closed, so the corrections a +# condition just filled in can be checked on screen while they are still there. +# +# Only reached when SAVE_CORRECTIONS is off. With it on, the save confirmation +# box is already the pause before closing — this is the one that was missing +# underneath it. Without this, SAVE_CORRECTIONS=0 fills the form in and then +# clicks Cancel in the same breath, so "applied for review" was only ever +# reviewable in the log and the screenshot, never on the actual screen. +# +# The box is informational: nothing is saved either way, and the claim still +# closes with Cancel the moment it is answered. It is app-modal to the bot's +# own thread, not to Chrome, so the form behind it stays scrollable and +# inspectable while it is up. Times out on MSGBOX_TIMEOUT_SECONDS like every +# other prompt — raise that if a claim needs longer than 180s to check. Set 0 +# for unattended runs, or it adds up to that per corrected claim. +# +# It is the corrected claim's ONE box (review item D9, 2026-10-05): it carries +# the SHOW_EACH_CLAIM header and Summary line, and the per-claim box after +# close is then skipped. The "N condition(s) matched — about to apply" box +# that used to come first is now a log line only. +REVIEW_BEFORE_CLOSE = _env_bool("REVIEW_BEFORE_CLOSE", True) + +# Also click "Prog. Notes" and capture whatever screen it opens. +# +# Separate from OPEN_CLAIM_FORM because it is the one part whose destination +# has never been seen: the button carries ng-disabled="!FormData. +# EnableProgressNote", so on some claims it does nothing at all, and what it +# opens (a modal or a new tab) is discovered at run time rather than assumed. +OPEN_PROGRESS_NOTES = _env_bool("OPEN_PROGRESS_NOTES", True) + +# How long, after clicking Prog. Notes, to keep polling (every 250 ms) for the +# note document before reporting "nothing". Replaces a fixed 3s sleep and one +# probe, which missed 3 of ~115 notes in the logs — every one a viewer that had +# opened on a document still loading, so the claim went out Clean with +# telehealth never checked (review item D8, 2026-10-01). A note that loads +# normally is accepted in well under a second; only a slow or missing one uses +# the full wait. 20s sits in the review's suggested 15-25s. +PROGRESS_NOTE_WAIT_MS = _env_int("PROGRESS_NOTE_WAIT_MS", 20_000) + +# Dental claims only (condition #8, "All Dental Payer"): open Info -> +# Sliding Fee Schedule and check the patient's sliding fee is still valid on the claim's DOS (active / expired / not_started / none). +# Read-only: Patient Information closes with Cancel, never Save. A temporary +# process for now (2026-09-30); the Excel output column comes next. +CHECK_SLIDING_FEE = _env_bool("CHECK_SLIDING_FEE", True) + +# Dental claims only (condition #15): report whether the Billing Notes say +# "HIGH RISK" — such claims go through the DentaQuest web portal. Read-only, +# shown with the sliding fee in the claim's SHOW_EACH_CLAIM box. +CHECK_HIGH_RISK = _env_bool("CHECK_HIGH_RISK", True) + +# At the end of every claims run, write Output\Charge Summary_<date>.xlsx in +# the operator's 24-column format: the run's report rows (A-R) plus what the +# run found per claim (S-X). See core/charge_summary.py. A TEST_CLAIM_IDS_FILE +# run writes Charge Summary_TEST_<date>.xlsx instead. If that day's file is +# open in Excel, the new sheet is saved as <name>__<HHMMSS>.xlsx next to it. +WRITE_CHARGE_SUMMARY = _env_bool("WRITE_CHARGE_SUMMARY", True) +# "Assigned To" on every claim the run read. +SUMMARY_ASSIGNED_TO = _env("SUMMARY_ASSIGNED_TO", "eCW Bot") + +# ── Results database (SQL Server) ───────────────────────────────────────────── +# Also save the Charge Summary rows to SQL Server (core/results_db.py) for the +# results web page (web/). Off by default; a database error is a log line +# only — the run and its Excel summary carry on. The default connection is +# the Windows login (no password) to the operator's Charge_Validation DB. +RESULTS_DB = _env_bool("RESULTS_DB", False) +RESULTS_DB_CONN = _env( + "RESULTS_DB_CONN", + "DRIVER={ODBC Driver 18 for SQL Server};SERVER=MECARE331\\MECARE331;" + "DATABASE=Charge_Validation;Trusted_Connection=yes;" + "TrustServerCertificate=yes;") + +# eCW builds the file server-side over a wide date range, so this is generous. +DOWNLOAD_TIMEOUT = _env_int("DOWNLOAD_TIMEOUT_MS", 300_000) # 5 min + + +# ── Operator checkpoints ────────────────────────────────────────────────────── +# Show the operator the filters and wait for an OK before running the search. +# Cancel aborts; a TIMEOUT carries on, because aborting there threw away a +# completed login and a mailed 2FA approval link when nobody was at the desk, +# and the lookup only reads. 0 skips the box entirely. +CONFIRM_BEFORE_LOOKUP = _env_bool("CONFIRM_BEFORE_LOOKUP", True) + +# Tell the operator how the export finished — row count, file name, size, and +# where it landed. Informational only, so it never blocks the result: the file +# is already saved by the time it appears. Set 0 for unattended runs, or it adds +# up to MSGBOX_TIMEOUT_SECONDS to every run with nobody there to click OK. +NOTIFY_AFTER_EXPORT = _env_bool("NOTIFY_AFTER_EXPORT", True) + + +def validate() -> list: + """Return a list of human-readable problems with the current config.""" + problems = [] + if not ENV_PATH.exists(): + problems.append(f".env not found at {ENV_PATH} — copy .env.example and fill it in") + for key in ("ECW_URL", "ECW_USERNAME", "ECW_PASSWORD"): + if not globals()[key]: + problems.append(f"{key} is not set") + if OTP_MODE in ("mail", "imap") and not (IMAP_HOST and IMAP_USERNAME and IMAP_PASSWORD): + problems.append(f"OTP_MODE={OTP_MODE} but IMAP_HOST / IMAP_USERNAME / IMAP_PASSWORD are incomplete") + if OTP_MODE == "totp" and not ECW_SECRET_KEY: + problems.append("OTP_MODE=totp but ECW_SECRET_KEY is not set") + if OTP_MODE not in ("auto", "totp", "mail", "imap", "manual"): + problems.append(f"OTP_MODE={OTP_MODE!r} is not one of auto/totp/mail/manual") + # Checked here, before login, because the run cannot do anything useful + # without it: the test path skips the live export, so a missing sheet + # used to cost a login and then exit 0 having processed nothing (review + # item D1). Resolved exactly as the flow resolves it (Path(...) as + # written, a relative path against the working folder), so this checks + # the same file the run would open. A file that exists but cannot be + # read still fails later, with exit 5. + if TEST_CLAIM_IDS_FILE and not Path(TEST_CLAIM_IDS_FILE).exists(): + problems.append(f"TEST_CLAIM_IDS_FILE={TEST_CLAIM_IDS_FILE} does not exist") + if BROWSER_CDP_URL and cdp_url_problem(BROWSER_CDP_URL): + problems.append(cdp_url_problem(BROWSER_CDP_URL)) + # Values the strict parsers could not read (see _BAD_VALUES). Every one + # stops the run here, before login: running on a guessed default is how + # SAVE_CORRECTIONS=O used to mean "save". + for key, raw, expected in _BAD_VALUES: + hint = (" (use 1/0, true/false, yes/no or on/off)" + if expected == "a valid yes/no value" else " (digits only)") + # This list is printed to the console and the run log. No PASSWORD/ + # SECRET/KEY setting is a yes/no or a number today, but a typo there + # must still never put the value itself on screen. + if any(s in key.upper() for s in ("PASSWORD", "SECRET", "KEY")): + problems.append(f"{key} (value hidden) is not {expected}{hint}") + else: + problems.append(f"{key}={raw!r} is not {expected}{hint}") + return problems + + +def resolved_otp_mode() -> str: + """Turn OTP_MODE=auto into the concrete mode that will actually be used.""" + if OTP_MODE == "imap": # pre-push-link name for the same thing + return "mail" + if OTP_MODE != "auto": + return OTP_MODE + if ECW_SECRET_KEY: + return "totp" + if IMAP_HOST and IMAP_USERNAME and IMAP_PASSWORD: + return "mail" + return "manual" diff --git a/start_ecw_chrome.bat b/start_ecw_chrome.bat new file mode 100644 index 0000000..5b27bc6 --- /dev/null +++ b/start_ecw_chrome.bat @@ -0,0 +1,7 @@ +@echo off +rem Chrome for the eCW bot to attach to (BROWSER_CDP_URL in .env). +rem Profile on D:, debug port on 127.0.0.1, the eCW proxy. Close that Chrome +rem when you are done: while it is open, any program on this PC can drive it. +cd /d "%~dp0" +python tools\start_ecw_chrome.py +if errorlevel 1 pause diff --git a/tests/fixtures/account_inquiry.html b/tests/fixtures/account_inquiry.html new file mode 100644 index 0000000..54224a6 --- /dev/null +++ b/tests/fixtures/account_inquiry.html @@ -0,0 +1,106 @@ +<!doctype html> +<!-- A claim form's side menu + eCW's Account Inquiry ("Patient Inquiry + Detail") modal, after the bindings in Dom.txt (2026-10-20). Made-up + claims only. A test sets window.HISTORY_PAGES (one array of claims per + page: {id, date, codes}) before clicking the launcher. The CPT rows are + loaded only once "Show CPT Payments" is ticked, as eCW reloads them. --> +<html><head><meta charset="utf-8"><title>Account Inquiry fixture + + + + + + + + diff --git a/tests/fixtures/dental_claim.html b/tests/fixtures/dental_claim.html new file mode 100644 index 0000000..9a88e43 --- /dev/null +++ b/tests/fixtures/dental_claim.html @@ -0,0 +1,208 @@ + + + + + + + + + + + + + + + diff --git a/tests/fixtures/hcfa_claim.html b/tests/fixtures/hcfa_claim.html new file mode 100644 index 0000000..19a3015 --- /dev/null +++ b/tests/fixtures/hcfa_claim.html @@ -0,0 +1,499 @@ + + + + + + + + + + + + + diff --git a/tests/fixtures/progress_note.html b/tests/fixtures/progress_note.html new file mode 100644 index 0000000..ebfa611 --- /dev/null +++ b/tests/fixtures/progress_note.html @@ -0,0 +1,331 @@ + + + + + + + +
+ + + + + + + + + + + + + + +
SAMPLE, Pat Q DOB: 05/11/1979 (47 yo F) + Acc No. 900123 DOS: 06/29/2026
+ + + + + + + + + + + + +
Sample, Pat Q
47 Y  old  Female, DOB: 05/11/1979
Account Number: 900123
100 SAMPLE ST, ANYTOWN, CO-80000-0000
Home: 555-010-0100
Guarantor: Sample, Pat Q + Insurance: BH COLORADO ACCESS Payer ID: UOACC
PCP: Jordan K Rivera
Appointment Facility: Brighton Womens Clinic
+ + + + + + + + + + + +
+ + + + + + + + + + + + + + + + + + + + + + + + + +
+ + + + + +
Past Medical History
+
    +
  • Current Tobacco Use- about 1 pack per day, started at age 14.
  • +
  • Generalized Anxiety Disorder.
  • +
  • Low Back Pain with Right-Sided Sciatica.
  • +
+
+
+ + + + + +
Surgical History
+
    +
  • Cyst Removal Right Breast - fibrous tissue 1997
  • +
+
+
+ + + + + +
Family History
+
    +
  • Mother: alive 66 yrs, diagnosed with Cancer
  • +
  • Father: alive 76 yrs, diagnosed with Hypertension
  • +
  • Siblings: alive, diagnosed with Hypertension
  • +
+
+
+ + + + + +
Social History
+ + + + + + + + +
Tobacco use
Tobacco Status: Current tobacco user pack a day
Depression Screening PHQ-2
Screening Test Result Positive - 3 or more
Follow Up Plan (required) Schedule f/u with Behavioral Health
+
+
+ + + + + +
Hospitalization/Major Diagnostic Procedure
+
    +
  • Childbirth x 1 vaginal, uncomplicated
  • +
+
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + + + + +
Reason for Appointment
+ + + + + +
  1. BH Outreach
+
+
+ + + + + +
History of Present Illness
+ + +
   TELEHEALTH: 
+ + + + + +
BH Visit Details
+ BHP conducted a scheduled outreach telephone call as part of + ongoing behavioral health outreach services.
+ Assessment
+ Columbia Suicide Severity Rating Scale (C-SSRS): Low Risk.
+ Outcome Rating Scale (ORS): Score of 4; Clinical.
+ Plan
+ No behavioral health follow-up appointment was scheduled at this time. +
+ + + + + + + + +
Session Start: 1:00pm.
Session End: 2:00pm.
Session Duration (Minutes): 60Minutes.
Session Setting (PoS) Telehealth
Mode of Treatment: Telephone
+
+
+ + + + +
Assessments
+ + + + + + + + + + + +
1.Illness, unspecified - R69 (Primary)   
2.Generalized anxiety disorder - F41.1   
+
+ + + + + + + + +
Labs 
Lab: BH - Outcome Rating Scale (ORS) + (Collection Date & Time - 06/29/2026)
+ + + + + + + +
     Individually1
     Total4
+
+
+ + + + + +
Procedure Codes
+
    +
  • H0023 Behavioral Health Outreach Service (TH). Modifiers: GT
  • +
  • 90791 Psychiatric diagnostic evaluation.
  • +
+
+
+
Care Plan Details
+ + +
+
Electronically signed by Provider: Morgan Lee Taylor , LPC + on 07/13/2026 at 07:52 PM MDT
Sign off status: Completed
+

Brighton Womens Clinic
30 S 20TH AVE
+ BRIGHTON, CO 80601-3705
Tel: 303-655-6180
+ Fax: 720-685-8714
+
+
+ +
+ + + + + +
+ Progress Note: Morgan Lee Taylor    06/29/2026
+ Note generated by eClinicalWorks EMR/PM Software
+
+ + + diff --git a/tests/fixtures/progress_note_delayed.html b/tests/fixtures/progress_note_delayed.html new file mode 100644 index 0000000..1dd84dc --- /dev/null +++ b/tests/fixtures/progress_note_delayed.html @@ -0,0 +1,162 @@ + + + +Prog. Notes, delayed + + + + + + + + diff --git a/tests/fixtures/progress_note_host.html b/tests/fixtures/progress_note_host.html new file mode 100644 index 0000000..c617a63 --- /dev/null +++ b/tests/fixtures/progress_note_host.html @@ -0,0 +1,50 @@ + + + +Progress Note Viewer + + + + + + + + diff --git a/tests/fixtures/sliding_fee.html b/tests/fixtures/sliding_fee.html new file mode 100644 index 0000000..60343d6 --- /dev/null +++ b/tests/fixtures/sliding_fee.html @@ -0,0 +1,93 @@ + + +sliding fee fixture + + + +
+ +
+ + + + + + + + diff --git a/tests/fixtures/ub_claim.html b/tests/fixtures/ub_claim.html new file mode 100644 index 0000000..e777ace --- /dev/null +++ b/tests/fixtures/ub_claim.html @@ -0,0 +1,458 @@ + + + + + + + + + + + diff --git a/tests/fixtures/vaccine_claim.html b/tests/fixtures/vaccine_claim.html new file mode 100644 index 0000000..22d1b87 --- /dev/null +++ b/tests/fixtures/vaccine_claim.html @@ -0,0 +1,506 @@ + + + + + + + + + + + diff --git a/tests/test_auto_dialog.py b/tests/test_auto_dialog.py new file mode 100644 index 0000000..0be57ab --- /dev/null +++ b/tests/test_auto_dialog.py @@ -0,0 +1,175 @@ +r""" +`core/page_utils.auto_dialog` — how the bot answers a page's native dialog +(fixed 2026-10-22; Context.md §10 had it as an open finding). + + python tests\test_auto_dialog.py + +The old handler called `dialog.message()`, but in Playwright Python +`message` is a property: the TypeError was swallowed and every alert / +confirm was left open — and an unanswered dialog crashed the tab ("Target +crashed", seen in tests). What has to hold now: + + * **Every dialog is answered**, so the tab never wedges or crashes. + * **alert** → OK: it only informs. + * **confirm / prompt** → OK only in LIVE-WRITE (SAVE_CORRECTIONS=1). In + VERIFY → Cancel: live eCW is read-only, and a confirm's OK may be a save. + * **beforeunload** → dismissed: accepting means "yes, close this page". + * Each one is in the log and is a Warning on the Runs page, with what the + bot answered. + * A second handler for the same dialog (it is registered on the context + and on the page) does not raise. + +Fake dialogs for the rules, then a real Chromium for the crash. +""" + +import io +import sys +import pathlib +import contextlib + +ROOT = pathlib.Path(__file__).resolve().parent.parent +sys.path.insert(0, str(ROOT / "src")) + +from ecw_bot import settings as cfg # noqa: E402 +from ecw_bot.core import page_utils # noqa: E402 + +CASES = [] + + +def case(fn): + CASES.append(fn) + return fn + + +class FakeDialog: + """`type` and `message` as properties, like Playwright's.""" + + def __init__(self, kind, message="Are you sure?", raises=False): + self._kind, self._message, self.raises = kind, message, raises + self.answer = None + + @property + def type(self): + return self._kind + + @property + def message(self): + return self._message + + def accept(self, prompt_text=None): + if self.raises: + raise RuntimeError("Cannot accept dialog which is already handled") + self.answer = "accept" + + def dismiss(self): + if self.raises: + raise RuntimeError("Cannot dismiss dialog which is already handled") + self.answer = "dismiss" + + +def _answer(kind, save=False, **kw): + saved = cfg.SAVE_CORRECTIONS + cfg.SAVE_CORRECTIONS = save + d = FakeDialog(kind, **kw) + out = io.StringIO() + try: + with contextlib.redirect_stdout(out): + page_utils.auto_dialog(d) + finally: + cfg.SAVE_CORRECTIONS = saved + return d.answer, out.getvalue() + + +@case +def an_alert_is_accepted_in_both_modes(): + assert _answer("alert")[0] == "accept" + assert _answer("alert", save=True)[0] == "accept" + + +@case +def a_confirm_or_prompt_is_cancelled_in_verify(): + assert _answer("confirm")[0] == "dismiss" + assert _answer("prompt")[0] == "dismiss" + + +@case +def a_confirm_or_prompt_is_accepted_in_live_write(): + assert _answer("confirm", save=True)[0] == "accept" + assert _answer("prompt", save=True)[0] == "accept" + + +@case +def beforeunload_is_always_dismissed(): + assert _answer("beforeunload")[0] == "dismiss" + assert _answer("beforeunload", save=True)[0] == "dismiss" + + +@case +def the_log_says_what_it_was_and_what_the_bot_answered(): + _, out = _answer("confirm", message="Save changes?") + assert "[DIALOG] confirm: 'Save changes?' -> Cancel" in out, out + _, out = _answer("alert", message="Done") + assert "-> OK" in out, out + + +@case +def an_already_answered_dialog_does_not_raise(): + _answer("confirm", raises=True) # would raise out of the handler + + +def _real_browser_cases() -> int: + from playwright.sync_api import sync_playwright + failures = 0 + saved = cfg.SAVE_CORRECTIONS + with sync_playwright() as pw: + browser = pw.chromium.launch(headless=True) + try: + for save, want in ((False, False), (True, True)): + name = (f"a real confirm answers {want} " + f"in {'LIVE-WRITE' if save else 'VERIFY'}, no crash") + cfg.SAVE_CORRECTIONS = save + ctx = browser.new_context() + # The way core/browser.py wires it: context AND page. + ctx.on("page", lambda p: p.on("dialog", page_utils.auto_dialog)) + page = ctx.new_page() + page.on("dialog", page_utils.auto_dialog) + page.set_default_timeout(10_000) + page.set_content("

x

") + try: + with contextlib.redirect_stdout(io.StringIO()): + page.evaluate("alert('note')") + got = page.evaluate("confirm('Save changes?')") + assert got is want, got + assert page.evaluate("1 + 1") == 2, "tab still alive" + print(f" ok {name}") + except Exception as exc: + print(f" FAIL {name}: {type(exc).__name__}: {exc}") + failures += 1 + ctx.close() + finally: + cfg.SAVE_CORRECTIONS = saved + browser.close() + return failures + + +def main() -> int: + failures = 0 + for fn in CASES: + name = fn.__name__.replace("_", " ") + try: + fn() + print(f" ok {name}") + except AssertionError as exc: + print(f" FAIL {name}: {exc}") + failures += 1 + except Exception as exc: + print(f" ERROR {name}: {type(exc).__name__}: {exc}") + failures += 1 + failures += _real_browser_cases() + total = len(CASES) + 2 + print(f"\n{total - failures}/{total} passed") + return 1 if failures else 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tests/test_bot_runner.py b/tests/test_bot_runner.py new file mode 100644 index 0000000..37e18f7 --- /dev/null +++ b/tests/test_bot_runner.py @@ -0,0 +1,426 @@ +r""" +web/bot_runner.py — start, watch and stop the bot as a child process. A +fake child script stands in for run.py: it takes the run record the way the +bot does, writes the env it was given, and waits. + + python tests\test_bot_runner.py +""" + +import os +import sys +import json +import time +import pathlib +import tempfile +import threading +import contextlib +import io + +ROOT = pathlib.Path(__file__).resolve().parent.parent +sys.path.insert(0, str(ROOT)) +sys.path.insert(0, str(ROOT / "src")) + +from ecw_bot import settings as cfg # noqa: E402 +from ecw_bot.core import run_lock # noqa: E402 +from web import bot_runner as br # noqa: E402 + +CASES = [] + + +def case(fn): + CASES.append(fn) + return fn + + +# The child: like run.py, it takes the record (cfg.LOGS from argv), dumps +# the env keys under test, then sleeps until killed or `seconds` pass. +CHILD = r''' +import os, sys, json, time, pathlib +sys.path.insert(0, sys.argv[1]) +from ecw_bot import settings as cfg +from ecw_bot.core import run_lock +cfg.LOGS = pathlib.Path(sys.argv[2]) +log = cfg.LOGS / "run_child.log" +log.write_text("[LOGIN] fake\n", encoding="utf-8") +run_lock.acquire(log) +keys = ["SAVE_CORRECTIONS", "ECW_KEEP_OPEN", "QUIET_BOXES", + "CONFIRM_BEFORE_LOOKUP", "ECW_RUN_SOURCE", "ECW_STARTED_BY", + "ECWBOT_TEST_STALE", "ECWBOT_TEST_REAL", "RESULTS_DB_CONN"] +(cfg.LOGS / "env.json").write_text( + json.dumps({k: os.environ.get(k) for k in keys}), encoding="utf-8") +time.sleep(float(sys.argv[3])) +run_lock.finish(0) +''' + + +def _runner(tmp, seconds=30): + script = pathlib.Path(tmp) / "child.py" + script.write_text(CHILD, encoding="utf-8") + cmd = [sys.executable, str(script), str(ROOT / "src"), str(cfg.LOGS), + str(seconds)] + return br.BotRunner(command=cmd, root=ROOT) + + +def _cleanup(r): + """Stop whatever a case left running.""" + try: + r.stop() + except (br.NotRunning, br.StopFailed): + pass + + +def _wait(pred, timeout=15): + end = time.monotonic() + timeout + while time.monotonic() < end: + if pred(): + return True + time.sleep(0.2) + return False + + +@case +def start_runs_the_child_with_the_forced_env(tmp): + r = _runner(tmp) + # The parent says LIVE-WRITE in its REAL environment (not from .env, so + # the .env-key filter does not drop it): FORCED_ENV must override it. + file_keys = getattr(cfg, "ENV_FILE_KEYS", set()) + was_file_key = "SAVE_CORRECTIONS" in file_keys + was_value = os.environ.get("SAVE_CORRECTIONS") + file_keys.discard("SAVE_CORRECTIONS") + os.environ["SAVE_CORRECTIONS"] = "1" + try: + st = r.start(started_by="asha") + assert st["running"] is True, st + assert _wait(lambda: (cfg.LOGS / "env.json").exists()) + env = json.loads((cfg.LOGS / "env.json").read_text(encoding="utf-8")) + forced = {k: env[k] for k in br.FORCED_ENV} + assert forced == {"SAVE_CORRECTIONS": "0", "ECW_KEEP_OPEN": "0", + "QUIET_BOXES": "1", "CONFIRM_BEFORE_LOOKUP": "0", + "ECW_RUN_SOURCE": "web"}, env + assert env["ECW_STARTED_BY"] == "asha", env + assert _wait(lambda: r.status()["starting"] is False) + st = r.status() + assert st["running"] is True and st["source"] == "web", st + assert st["log_path"].endswith("run_child.log"), st + assert st["started_by"] == "asha", "the card says who started it" + finally: + if was_value is None: + os.environ.pop("SAVE_CORRECTIONS", None) + else: + os.environ["SAVE_CORRECTIONS"] = was_value + if was_file_key: + file_keys.add("SAVE_CORRECTIONS") + _cleanup(r) + + +@case +def the_run_writes_to_the_database_the_page_reads(tmp): + """Review 2026-10-14: the child reads .env afresh, but the page keeps + the results database it was started with. An RESULTS_DB_CONN edited + since would send the run's rows where the page never looks — so the + child is given the page's own connection.""" + r = _runner(tmp) + was = cfg.RESULTS_DB_CONN + cfg.RESULTS_DB_CONN = "DSN=the-database-this-page-reads" + try: + r.start() + assert _wait(lambda: (cfg.LOGS / "env.json").exists()) + env = json.loads((cfg.LOGS / "env.json").read_text(encoding="utf-8")) + assert env["RESULTS_DB_CONN"] == "DSN=the-database-this-page-reads", \ + "the child does not use the page's results database" + finally: + cfg.RESULTS_DB_CONN = was + _cleanup(r) + + +@case +def the_child_reads_dotenv_afresh_not_the_servers_copy(tmp): + """Review 2026-10-14: the web server took .env into its own environment + when it started, and the child inherited that copy — settings.py's + setdefault then kept it, so an .env edited since (claim sheet, claim + limit) was not seen by a page run. A key that came from .env is left + out of the child's environment; a real environment key is kept.""" + r = _runner(tmp) + file_keys = getattr(cfg, "ENV_FILE_KEYS", set()) + os.environ["ECWBOT_TEST_STALE"] = "old value from .env" + file_keys.add("ECWBOT_TEST_STALE") + os.environ["ECWBOT_TEST_REAL"] = "set in the real environment" + try: + r.start() + assert _wait(lambda: (cfg.LOGS / "env.json").exists()) + env = json.loads((cfg.LOGS / "env.json").read_text(encoding="utf-8")) + assert env["ECWBOT_TEST_STALE"] is None, env + assert env["ECWBOT_TEST_REAL"] == "set in the real environment", env + finally: + os.environ.pop("ECWBOT_TEST_STALE", None) + os.environ.pop("ECWBOT_TEST_REAL", None) + file_keys.discard("ECWBOT_TEST_STALE") + _cleanup(r) + + +@case +def a_second_start_is_refused(tmp): + r = _runner(tmp) + other_runner = _runner(tmp) + try: + r.start() + assert _wait(lambda: run_lock.is_live(run_lock.read())) + for other in (r, other_runner): + try: + other.start() + except br.AlreadyRunning as exc: + assert "already going" in str(exc), exc + else: + raise AssertionError("second start allowed") + finally: + _cleanup(r) + + +@case +def stop_kills_the_run_and_marks_it_stopped(tmp): + r = _runner(tmp) + try: + r.start() + assert _wait(lambda: run_lock.is_live(run_lock.read())) + pid = run_lock.read()["pid"] + st = r.stop(stopped_by="olu") + assert st["running"] is False, st + assert st["stopped_by_operator"] is True, st + assert run_lock.pid_alive(pid) is False + # The killed bot cannot end its own events file: the page does. + events = cfg.LOGS / "events_child.jsonl" + last = json.loads(events.read_text(encoding="utf-8").splitlines()[-1]) + assert last["kind"] == "stopped" and last["stopped_by"] == "olu", last + finally: + _cleanup(r) + + +@case +def stop_while_still_starting_is_the_operator_not_a_crash(tmp): + """No record yet (the child is still importing): the runner stopped it, + so it must not read as a crash with exit code 1.""" + r = _runner(tmp) + try: + st = r.start() + assert st["starting"] is True, st + st = r.stop() + assert st["running"] is False and st["starting"] is False, st + assert st["stopped_by_operator"] is True, st + assert st["exit_code"] is None, st + finally: + _cleanup(r) + + +@case +def a_stop_that_does_not_kill_raises_and_leaves_the_record(tmp): + """taskkill refused (an elevated run): the record must not be marked + ended while the bot is still going.""" + class NoKill(br.BotRunner): + stop_wait = 1.0 + + def _kill(self, pid): + pass + + script = pathlib.Path(tmp) / "child.py" + script.write_text(CHILD, encoding="utf-8") + r = NoKill(command=[sys.executable, str(script), str(ROOT / "src"), + str(cfg.LOGS), "30"], root=ROOT) + try: + r.start() + assert _wait(lambda: run_lock.is_live(run_lock.read())) + pid = run_lock.read()["pid"] + try: + r.stop() + except br.StopFailed as exc: + assert str(pid) in str(exc) and "still running" in str(exc), exc + else: + raise AssertionError("stop did not raise StopFailed") + rec = run_lock.read() + assert not rec["ended_at"] and not rec["stopped_by_operator"], rec + assert run_lock.is_live(rec) is True + assert r.status()["running"] is True + finally: + br.BotRunner._kill(r, r._proc.pid) + _wait(lambda: not run_lock.pid_alive(r._proc.pid)) + + +@case +def two_starts_at_once_spawn_one_child(tmp): + r = _runner(tmp) + results = [] + gate = threading.Barrier(2) + + def go(): + gate.wait() + try: + r.start() + results.append("started") + except br.AlreadyRunning: + results.append("refused") + + threads = [threading.Thread(target=go) for _ in range(2)] + try: + for t in threads: + t.start() + for t in threads: + t.join(30) + assert sorted(results) == ["refused", "started"], results + finally: + _cleanup(r) + + +@case +def stop_never_kills_its_own_process(tmp): + """A live record whose pid is this process (the web server itself).""" + run_lock.acquire("self.log", source="web") + try: + _runner(tmp).stop() + except br.NotRunning: + pass + else: + raise AssertionError("stop went for its own pid") + assert run_lock.pid_alive(os.getpid()) is True + run_lock.finish(0) + + +@case +def stop_with_nothing_running_says_so(tmp): + try: + _runner(tmp).stop() + except br.NotRunning: + pass + else: + raise AssertionError("stop of nothing did not raise") + + +@case +def a_run_that_ends_reports_its_exit_code(tmp): + r = _runner(tmp, seconds=0.5) + try: + r.start() + assert _wait(lambda: r.status()["running"] is False) + st = r.status() + assert st["exit_code"] == 0 and st["stopped_by_operator"] is False, st + finally: + _cleanup(r) + + +@case +def a_terminal_run_is_seen_and_blocks_start(tmp): + """The record of a live run this runner did not start (this test + process stands in for a terminal run).""" + run_lock.acquire("terminal.log", source="terminal") + r = _runner(tmp) + st = r.status() + assert st["running"] is True and st["source"] == "terminal", st + try: + r.start() + except br.AlreadyRunning: + pass + else: + raise AssertionError("started over a terminal run") + run_lock.finish(0) + + +@case +def a_terminal_run_after_a_page_run_shows_as_itself(tmp): + """The page's child ended; a later terminal run (this test process + stands in for it) ended with exit 4. The page must show that run, not + the old child's exit 0.""" + r = _runner(tmp, seconds=0.5) + try: + r.start() + assert _wait(lambda: r.status()["running"] is False) + assert _wait(lambda: r._proc.poll() is not None) + assert r.status()["exit_code"] == 0 + run_lock.acquire("t.log", source="terminal") + run_lock.finish(4) + st = r.status() + assert st["source"] == "terminal", st + assert st["exit_code"] == 4 and st["running"] is False, st + assert st["log_path"] == "t.log", st + finally: + _cleanup(r) + + +class _NoKillRecorder(br.BotRunner): + """Records what stop() would kill; never kills anything.""" + stop_wait = 1.0 + + def __init__(self, *a, **k): + super().__init__(*a, **k) + self.killed = [] + + def _kill(self, pid): + self.killed.append(pid) + + +@case +def stop_refuses_a_run_started_in_the_terminal(tmp): + """A live terminal run (maybe LIVE-WRITE): the page cannot stop it.""" + import subprocess + script = pathlib.Path(tmp) / "child.py" + script.write_text(CHILD, encoding="utf-8") + env = {k: v for k, v in os.environ.items() if k != "ECW_RUN_SOURCE"} + p = subprocess.Popen([sys.executable, str(script), str(ROOT / "src"), + str(cfg.LOGS), "30"], env=env) + r = _NoKillRecorder(command=["unused"], root=ROOT) + try: + assert _wait(lambda: run_lock.is_live(run_lock.read())) + assert run_lock.read()["source"] == "terminal" + try: + r.stop() + except br.NotRunning as exc: + assert "started in the terminal" in str(exc), exc + else: + raise AssertionError("stop went for a terminal run") + assert r.killed == [], r.killed + assert run_lock.pid_alive(p.pid) is True + assert run_lock.is_live(run_lock.read()) is True + finally: + p.kill() + p.wait() + + +@case +def stop_refuses_a_terminal_record_of_this_process(tmp): + """The record's pid is the test process itself: refused with the + terminal message, and the test process is not killed.""" + run_lock.acquire("t.log", source="terminal") + r = _NoKillRecorder(command=["unused"], root=ROOT) + try: + r.stop() + except br.NotRunning as exc: + assert "started in the terminal" in str(exc), exc + else: + raise AssertionError("stop did not refuse") + assert r.killed == [] and run_lock.pid_alive(os.getpid()) is True + run_lock.finish(0) + + +def main() -> int: + failures = 0 + orig = cfg.LOGS + for fn in CASES: + name = fn.__name__.replace("_", " ") + with tempfile.TemporaryDirectory() as tmp: + cfg.LOGS = pathlib.Path(tmp) / "Logs" + cfg.LOGS.mkdir() + try: + with contextlib.redirect_stdout(io.StringIO()): + fn(tmp) # the runner's [WEB] prints stay out + print(f" ok {name}") + except AssertionError as exc: + print(f" FAIL {name}: {exc}") + failures += 1 + except Exception as exc: + print(f" ERROR {name}: {type(exc).__name__}: {exc}") + failures += 1 + finally: + cfg.LOGS = orig + print(f"\n{len(CASES) - failures}/{len(CASES)} passed") + return 1 if failures else 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tests/test_categorize_by_payer.py b/tests/test_categorize_by_payer.py new file mode 100644 index 0000000..8624f07 --- /dev/null +++ b/tests/test_categorize_by_payer.py @@ -0,0 +1,208 @@ +""" +export_medcor_ready._categorize_by_payer(): the payer -> Group Name step. + + python tests\\test_categorize_by_payer.py + +Builds a real xlsx (same helper shape as test_claim_file.py's `xlsx()`) so +this exercises the actual read -> classify -> write path, not a mock of it. +""" + +import sys +import zipfile +import pathlib +import tempfile + +sys.path.insert(0, str(pathlib.Path(__file__).resolve().parent.parent / "src")) + +from ecw_bot.flows import export_medcor_ready as flow # noqa: E402 +from ecw_bot.core import spreadsheet # noqa: E402 +from ecw_bot import settings as cfg # noqa: E402 + +TMP = pathlib.Path(tempfile.mkdtemp()) +NS = 'xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main"' + +# Hermetic before anything below runs: _categorize_by_payer() builds its +# output path from cfg.ROOT / "Output" / ..., and without this every test run +# would write into the REAL Output\ folder. It would also leave +# NOTIFY_UNMAPPED_PAYERS at its True default, which pops a real Windows +# message box (case_unmapped_payer_gets_blank_group_not_a_guess triggers it) +# and blocks for up to MSGBOX_TIMEOUT_SECONDS. Must run before CASES is built +# and before main() runs, so every case is affected. +cfg.ROOT = TMP +cfg.NOTIFY_UNMAPPED_PAYERS = False + + +def xlsx(header, data_rows, name="t.xlsx") -> pathlib.Path: + """A minimal real xlsx: header + data rows, both plain string lists.""" + rows = [header] + data_rows + body = [] + for r, row in enumerate(rows, start=1): + cells = "".join( + f'{v}' + for c, v in enumerate(row)) + body.append(f'{cells}') + sheet = (f'' + f'{"".join(body)}') + path = TMP / name + with zipfile.ZipFile(path, "w") as z: + z.writestr("xl/worksheets/sheet1.xml", sheet) + return path + + +HEADER = ["CLAIMS#", "PAYER", "CHARGES"] + + +def case_categorizes_known_payers(): + saved = xlsx(HEADER, [ + ["9107000", "Medicaid", "275.00"], + ["9106000", "DentaQuest", "117.00"], + ], name="known.xlsx") + out = flow._categorize_by_payer(saved) + assert out is not None and out.exists(), out + rows = spreadsheet.read_rows(out) + assert rows[0] == HEADER + ["Group Name"], rows[0] + assert rows[1] == ["9107000", "Medicaid", "275.00", "Medicaid"], rows[1] + assert rows[2] == ["9106000", "DentaQuest", "117.00", "Dental - CHP+/DentaQuest"], rows[2] + + +def case_unmapped_payer_gets_blank_group_not_a_guess(): + saved = xlsx(HEADER, [ + ["9108000", "Some Brand New Payer", "50.00"], + ], name="unmapped.xlsx") + out = flow._categorize_by_payer(saved) + rows = spreadsheet.read_rows(out) + assert rows[1][-1] == "", rows[1] # blank, not a guessed group + + +def case_none_input_is_a_no_op(): + assert flow._categorize_by_payer(None) is None + + +def case_missing_payer_column_does_not_raise(): + saved = xlsx(["CLAIMS#", "CHARGES"], [["9108000", "50.00"]], + name="nopayer.xlsx") + assert flow._categorize_by_payer(saved) is None # skipped, not a crash + + +def case_input_file_is_never_modified(): + saved = xlsx(HEADER, [["9107000", "Medicaid", "275.00"]], name="untouched.xlsx") + before_bytes = saved.read_bytes() + before_mtime = saved.stat().st_mtime + flow._categorize_by_payer(saved) + assert saved.read_bytes() == before_bytes, "Input file content changed" + assert saved.stat().st_mtime == before_mtime, "Input file was rewritten" + + +# The same-day output, under the temp cfg.ROOT set at the top of this file. +OUT = TMP / "Output" / f"Medcor Ready_{cfg.TODAY}.xlsx" +EARLIER = [HEADER + ["Group Name"], + ["9108000", "Some Brand New Payer", "50.00", "hand-filled"]] + + +def case_same_day_rerun_keeps_the_earlier_file(): + """The operator's hand-filled Group Names survive a second run.""" + spreadsheet.write_xlsx(OUT, EARLIER) + earlier_bytes = OUT.read_bytes() + before = set(OUT.parent.glob(f"{OUT.stem}__*.xlsx")) + saved = xlsx(HEADER, [["9107000", "Medicaid", "275.00"]], name="rerun.xlsx") + out = flow._categorize_by_payer(saved) + assert out == OUT, out + kept = set(OUT.parent.glob(f"{OUT.stem}__*.xlsx")) - before + assert len(kept) == 1, kept + assert kept.pop().read_bytes() == earlier_bytes, "the kept file changed" + + +def case_locked_same_day_file_does_not_lose_the_categorized_output(): + """ + Review item D10: the earlier file is open in Excel, so Windows refuses + to rename it (WinError 32; a Python `open(..., "rb")` handle does the + same). That used to throw this run's categorized rows away. Now they + land under a `__HHMMSS` name, the open file is untouched, and the path + returned is the new one. + """ + spreadsheet.write_xlsx(OUT, EARLIER) + earlier_bytes = OUT.read_bytes() + saved = xlsx(HEADER, [ + ["9107000", "Medicaid", "275.00"], + ["9106000", "DentaQuest", "117.00"], + ], name="locked.xlsx") + with open(OUT, "rb"): + out = flow._categorize_by_payer(saved) + assert out is not None, "the categorized output was lost" + assert out != OUT and out.parent == OUT.parent, out + assert out.name.startswith(f"{OUT.stem}__"), out.name + rows = spreadsheet.read_rows(out) + assert rows[1] == ["9107000", "Medicaid", "275.00", "Medicaid"], rows + assert rows[2] == ["9106000", "DentaQuest", "117.00", "Dental - CHP+/DentaQuest"], rows + assert OUT.read_bytes() == earlier_bytes, "the open file was changed" + + +def _categorize_with_boxes(saved): + """ + _categorize_by_payer with NOTIFY_UNMAPPED_PAYERS on and the message box + replaced by a recorder (never a real box). Returns (path, [(title, body)]). + """ + shown = [] + orig = (cfg.NOTIFY_UNMAPPED_PAYERS, flow.msgbox.info_box) + try: + cfg.NOTIFY_UNMAPPED_PAYERS = True + flow.msgbox.info_box = lambda t, b: shown.append((t, b)) + out = flow._categorize_by_payer(saved) + finally: + cfg.NOTIFY_UNMAPPED_PAYERS, flow.msgbox.info_box = orig + return out, shown + + +def case_locked_same_day_file_tells_the_operator_the_new_name(): + """ + Fix round 1 (review item D10, 2026-10-05): whoever has the usual file + open in Excel is at the desk, so the `__HHMMSS` name goes in a box too, + not only the log — else they keep filling in the earlier run's sheet. + """ + spreadsheet.write_xlsx(OUT, EARLIER) + saved = xlsx(HEADER, [["9107000", "Medicaid", "275.00"]], + name="locked-box.xlsx") + with open(OUT, "rb"): + out, shown = _categorize_with_boxes(saved) + assert out is not None and out != OUT, out + assert len(shown) == 1, shown + title, body = shown[0] + assert title == "eCW Bot — Medcor Ready saved under another name", title + assert f"could not be saved as {OUT.name}" in body, body + assert out.name in body, body + # Fix round 2: the box must not promise the earlier file was untouched — + # a failed move-back can leave a half-file under the usual name. + assert "left as it was" not in body, body + + +def case_unlocked_same_day_rerun_shows_no_new_name_box(): + spreadsheet.write_xlsx(OUT, EARLIER) + saved = xlsx(HEADER, [["9107000", "Medicaid", "275.00"]], + name="unlocked-box.xlsx") + out, shown = _categorize_with_boxes(saved) + assert out == OUT, out + assert shown == [], shown + + +CASES = [v for k, v in sorted(globals().items()) if k.startswith("case_")] + + +def main() -> int: + failures = 0 + for fn in CASES: + name = fn.__name__[5:].replace("_", " ") + try: + fn() + print(f" ok {name}") + except AssertionError as exc: + print(f" FAIL {name}: {exc}") + failures += 1 + except Exception as exc: + print(f" ERROR {name}: {type(exc).__name__}: {exc}") + failures += 1 + print(f"\n{len(CASES) - failures}/{len(CASES)} passed") + return 1 if failures else 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tests/test_cdp_browser.py b/tests/test_cdp_browser.py new file mode 100644 index 0000000..8736114 --- /dev/null +++ b/tests/test_cdp_browser.py @@ -0,0 +1,401 @@ +""" +Attaching to a Chrome the bot did not start (BROWSER_CDP_URL). + + python tests\\test_cdp_browser.py + +The operator starts Chrome with `start_ecw_chrome.bat` (profile on D:, port +9222 on 127.0.0.1) and adds the Claude extension to it, so a run can be +watched live. The bot then attaches over CDP instead of launching its own +Chromium. What has to hold, each checked against a real Chromium started the +same way the .bat starts Chrome: + + * **Only a loopback URL is accepted.** Anything that can reach the port can + drive a browser logged in to eCW. + * **eCW's cookies and storage go at the end of a run, and at the start** + (a run killed half-way leaves them behind). Only eCW's: claude.ai's + cookie is what keeps the extension signed in. + * **close() leaves Chrome running** and the operator's own tab open — it + closes the bot's tabs and disconnects. + * **Dialogs and downloads still work** on Chrome's own context, which the + bot cannot create with its usual options. +""" + +import io +import os +import sys +import contextlib +import time +import socket +import pathlib +import tempfile +import subprocess + +ROOT = pathlib.Path(__file__).resolve().parent.parent +sys.path.insert(0, str(ROOT / "src")) + +from ecw_bot import settings as cfg # noqa: E402 +from ecw_bot.core import browser # noqa: E402 + +ECW = "https://app1.ecwcloud.com" + + +def test_cdp_url_must_be_loopback(): + for ok in ("http://127.0.0.1:9222", "http://localhost:9222", + "http://127.0.0.1:9222/"): + assert cfg.cdp_url_problem(ok) is None, ok + for bad in ("http://10.100.0.23:9222", "http://0.0.0.0:9222", + "http://example.com:9222", "127.0.0.1:9222", + "http://127.0.0.1", "ws://127.0.0.1:9222"): + assert cfg.cdp_url_problem(bad), bad + + +def test_ecw_sites_are_the_registrable_domain(): + assert browser.ecw_sites(["https://abc.ecwcloud.com/mobiledoc/x", + "https://abc.ecwcloud.com", ""]) == {"ecwcloud.com"} + assert browser.is_ecw_host("abc.ecwcloud.com", {"ecwcloud.com"}) + assert browser.is_ecw_host(".ecwcloud.com", {"ecwcloud.com"}) + assert not browser.is_ecw_host("claude.ai", {"ecwcloud.com"}) + assert not browser.is_ecw_host("notecwcloud.com", {"ecwcloud.com"}) + + +def test_start_script_command(): + sys.path.insert(0, str(ROOT / "tools")) + import start_ecw_chrome as s + assert s.chrome_command is browser.chrome_command, "one command, two users" + cmd = s.chrome_command("chrome.exe", 9222, r"D:\EcwChrome", + {"server": "http://10.0.0.1:8091"}) + assert "--remote-debugging-port=9222" in cmd, cmd + assert r"--user-data-dir=D:\EcwChrome" in cmd, cmd + assert "--proxy-server=http://10.0.0.1:8091" in cmd, cmd + assert any(a.startswith("--proxy-bypass-list=") and "claude.ai" in a + for a in cmd), cmd + assert all(a in cmd for a in browser.CHROME_ARGS), cmd + assert not any(a.startswith("--proxy") + for a in s.chrome_command("c", 9222, "p", None)) + + +def test_a_chrome_with_the_proxy_passes_and_one_without_is_refused(): + saved = (browser._cmdline_on_port, cfg.PROXY_IP, cfg.PROXY_PORT) + try: + cfg.PROXY_IP, cfg.PROXY_PORT = "10.0.0.1", "8091" + browser._cmdline_on_port = lambda port: ( + "chrome.exe --remote-debugging-port=9222 " + "--proxy-server=http://10.0.0.1:8091") + assert browser.chrome_proxy_problem("http://127.0.0.1:9222") is None + browser._cmdline_on_port = lambda port: ( + "chrome.exe --remote-debugging-port=9222") + assert "without the eCW proxy" in browser.chrome_proxy_problem( + "http://127.0.0.1:9222") + browser._cmdline_on_port = lambda port: None # cannot tell + with contextlib.redirect_stdout(io.StringIO()): + assert browser.chrome_proxy_problem("http://127.0.0.1:9222") \ + is None + cfg.PROXY_IP = "" # no proxy at all + assert browser.chrome_proxy_problem("http://127.0.0.1:9222") is None + finally: + browser._cmdline_on_port, cfg.PROXY_IP, cfg.PROXY_PORT = saved + + +def test_cdp_ready_false_when_nothing_listens(): + assert browser.cdp_ready(f"http://127.0.0.1:{_free_port()}") is False + + +# ── Against a real Chromium on a debug port ─────────────────────────────────── + +def _free_port() -> int: + with socket.socket() as s: + s.bind(("127.0.0.1", 0)) + return s.getsockname()[1] + + +def _routes(context): + """Serve the fake eCW and claude.ai origins without a network.""" + def handle(route): + route.fulfill(status=200, content_type="text/html", + body="ok") + context.route(f"{ECW}/**", handle) + context.route("https://claude.ai/**", handle) + + +def _seed(pw, url): + """Leave eCW and claude.ai data in the profile, as a run would.""" + b = pw.chromium.connect_over_cdp(url) + ctx = b.contexts[0] + _routes(ctx) + p = ctx.new_page() + p.goto(f"{ECW}/mobiledoc/") + p.evaluate("localStorage.setItem('ecw', '1')") + ctx.add_cookies([ + {"name": "JSESSIONID", "value": "x", "domain": "app1.ecwcloud.com", + "path": "/", "secure": True}, + {"name": "dev", "value": "y", "domain": ".ecwcloud.com", "path": "/", + "secure": True}, + {"name": "sessionKey", "value": "z", "domain": "claude.ai", + "path": "/", "secure": True}, + ]) + p.close() + b.close() + + +def _cookie_names(ctx): + return sorted(c["name"] for c in ctx.cookies()) + + +def _ecw_local_storage(ctx): + _routes(ctx) + p = ctx.new_page() + p.goto(f"{ECW}/mobiledoc/") + val = p.evaluate("localStorage.getItem('ecw')") + p.close() + return val + + +def case_attach_clears_leftover_ecw_data(pw, url): + _seed(pw, url) + b, ctx, page = browser.launch(pw) + try: + assert _cookie_names(ctx) == ["sessionKey"], _cookie_names(ctx) + assert page.context is ctx + # The operator's Chrome: its blank tab is theirs, never closed. + assert len(ctx.pages) >= 2 and any( + p.url == "about:blank" for p in ctx.pages if p is not page), \ + [p.url for p in ctx.pages] + finally: + b.close() + + +def case_close_clears_ecw_only_and_keeps_chrome(pw, url): + b, ctx, page = browser.launch(pw) + _routes(ctx) + page.goto(f"{ECW}/mobiledoc/") + page.evaluate("localStorage.setItem('ecw', '1')") + ctx.add_cookies([ + {"name": "JSESSIONID", "value": "x", "domain": "app1.ecwcloud.com", + "path": "/", "secure": True}, + {"name": "sessionKey", "value": "z", "domain": "claude.ai", + "path": "/", "secure": True}, + ]) + popup = ctx.new_page() # a window eCW opened + b.close() + assert popup.is_closed() and page.is_closed() + + assert browser.cdp_ready(url), "Chrome went away with the bot" + look = pw.chromium.connect_over_cdp(url) + try: + ctx2 = look.contexts[0] + assert _cookie_names(ctx2) == ["sessionKey"], _cookie_names(ctx2) + assert _ecw_local_storage(ctx2) is None + assert len(ctx2.pages) >= 1, "the operator's own tab was closed" + finally: + look.close() + + +def case_dialog_handler_is_wired(pw, url): + # The same handler as a launched browser gets, on the bot's tab and on + # every tab eCW opens. A stand-in records it: what the real handler does + # with a dialog is page_utils' business, not this file's. + seen = [] + + def record(dialog): + seen.append(dialog.type) + dialog.accept() + + # Kept in place until the end: a new tab looks the handler up when it + # opens, not when the bot attached. + real, browser.auto_dialog = browser.auto_dialog, record + b = None + try: + b, ctx, page = browser.launch(pw) + page.set_content("

x

") + page.evaluate("alert('one')") + popup = ctx.new_page() + popup.set_content("

y

") + popup.evaluate("alert('two')") + assert seen.count("alert") >= 2, seen + finally: + if b: + b.close() + browser.auto_dialog = real + + +def case_download_over_cdp(pw, url): + b, ctx, page = browser.launch(pw) + try: + page.set_content('get') + with page.expect_download(timeout=15_000) as info: + page.click("#d") + out = pathlib.Path(tempfile.mkdtemp()) / "r.csv" + info.value.save_as(out) + assert out.read_text() == "a,b\n1,2", out.read_text() + finally: + b.close() + + +CASES = [ + case_attach_clears_leftover_ecw_data, + case_close_clears_ecw_only_and_keeps_chrome, + case_dialog_handler_is_wired, + case_download_over_cdp, +] + + +def _start_chrome(exe, port, profile): + proc = subprocess.Popen( + [exe, f"--remote-debugging-port={port}", f"--user-data-dir={profile}", + "--headless=new", "--no-first-run", "--no-default-browser-check", + "about:blank"], + stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) + url = f"http://127.0.0.1:{port}" + for _ in range(60): + if browser.cdp_ready(url): + return proc, url + time.sleep(0.25) + proc.kill() + raise RuntimeError("Chromium did not open its debug port") + + +def _run_live() -> int: + from playwright.sync_api import sync_playwright + + failures = 0 + base = "D:/claude_tmp" if os.path.isdir("D:/claude_tmp") else None + profile = tempfile.mkdtemp(prefix="cdp_profile_", dir=base) + saved = (cfg.BROWSER_CDP_URL, cfg.ECW_URL, cfg.ECW_LANDING_URL) + with sync_playwright() as pw: + proc, url = _start_chrome(pw.chromium.executable_path, _free_port(), + profile) + cfg.BROWSER_CDP_URL, cfg.ECW_URL, cfg.ECW_LANDING_URL = ( + url, f"{ECW}/mobiledoc/jsp/webemr/login/newLogin.jsp", ECW) + try: + for fn in CASES: + name = fn.__name__.replace("case_", "").replace("_", " ") + try: + fn(pw, url) + print(f" ok {name}") + except AssertionError as exc: + print(f" FAIL {name}: {exc}") + failures += 1 + except Exception as exc: + print(f" ERROR {name}: {type(exc).__name__}: {exc}") + failures += 1 + finally: + cfg.BROWSER_CDP_URL, cfg.ECW_URL, cfg.ECW_LANDING_URL = saved + proc.kill() + proc.wait() + return failures + + +def _pid_on_port(port: int): + """(pid, parent pid) of the process listening on `port`.""" + ps = (f"$c = Get-NetTCPConnection -LocalPort {port} -State Listen " + f"-ErrorAction SilentlyContinue | Select-Object -First 1; " + f"if ($c) {{ $p = Get-CimInstance Win32_Process -Filter " + f"\"ProcessId=$($c.OwningProcess)\"; " + f"\"$($p.ProcessId) $($p.ParentProcessId)\" }}") + out = subprocess.run(["powershell", "-NoProfile", "-Command", ps], + capture_output=True, text=True).stdout.split() + return (int(out[0]), int(out[1])) if len(out) == 2 else (None, None) + + +def _gone(url, seconds=15): + end = time.monotonic() + seconds + while time.monotonic() < end: + if not browser.cdp_ready(url): + return True + time.sleep(0.25) + return False + + +def _run_bot_starts_chrome() -> int: + """Start pressed, no Chrome on the port: the bot opens it, outside its + own process tree (a Stop's taskkill /T must not take it), attaches, and + closes it at the end — only the one it opened.""" + from playwright.sync_api import sync_playwright + + failures = 0 + base = "D:/claude_tmp" if os.path.isdir("D:/claude_tmp") else None + saved = (cfg.BROWSER_CDP_URL, cfg.CHROME_PATH, cfg.CDP_PROFILE_DIR, + cfg.PROXY_IP, cfg.CDP_CLOSE_AFTER_RUN, cfg.ECW_URL, + cfg.ECW_LANDING_URL, cfg.PROXY_PORT) + with sync_playwright() as pw: + try: + cfg.CHROME_PATH = pw.chromium.executable_path + cfg.PROXY_IP = "" + cfg.ECW_URL = cfg.ECW_LANDING_URL = ECW + for close_after in (True, False): + name = ("the bot opens Chrome, outside its tree, and " + + ("closes it after the run" if close_after + else "leaves it with CDP_CLOSE_AFTER_RUN=0")) + port = _free_port() + url = f"http://127.0.0.1:{port}" + cfg.BROWSER_CDP_URL = url + cfg.CDP_PROFILE_DIR = tempfile.mkdtemp(prefix="cdp_bot_", + dir=base) + cfg.CDP_CLOSE_AFTER_RUN = close_after + pid = None + try: + assert browser.start_chrome(url, ["--headless=new"]) + assert browser.cdp_ready(url) + pid, parent = _pid_on_port(port) + assert pid and parent != os.getpid(), (pid, parent) + # Opened with no proxy: fine while none is configured, + # refused once the eCW proxy is (the 2026-10-22 case). + assert browser.chrome_proxy_problem(url) is None + cfg.PROXY_IP, cfg.PROXY_PORT = "10.0.0.1", "8091" + problem = browser.chrome_proxy_problem(url) + cfg.PROXY_IP, cfg.PROXY_PORT = "", "" + assert problem and "without the eCW proxy" in problem, \ + problem + b, ctx, page = browser.launch(pw) + # The Chrome the run opened shows eCW only: its own + # empty first tab is closed (operator, 2026-10-23). + assert ctx.pages == [page], [p.url for p in ctx.pages] + page.set_content("

x

") + b.close() + if close_after: + assert _gone(url), "Chrome still answers" + else: + time.sleep(1) + assert browser.cdp_ready(url), "Chrome was closed" + print(f" ok {name}") + except Exception as exc: + print(f" FAIL {name}: {type(exc).__name__}: {exc}") + failures += 1 + finally: + if browser.cdp_ready(url): + pid, _ = _pid_on_port(port) + if pid: + subprocess.run(["taskkill", "/PID", str(pid), + "/T", "/F"], capture_output=True) + finally: + (cfg.BROWSER_CDP_URL, cfg.CHROME_PATH, cfg.CDP_PROFILE_DIR, + cfg.PROXY_IP, cfg.CDP_CLOSE_AFTER_RUN, cfg.ECW_URL, + cfg.ECW_LANDING_URL, cfg.PROXY_PORT) = saved + return failures + + +def main() -> int: + plain = [test_cdp_url_must_be_loopback, + test_ecw_sites_are_the_registrable_domain, + test_start_script_command, + test_a_chrome_with_the_proxy_passes_and_one_without_is_refused, + test_cdp_ready_false_when_nothing_listens] + failures = 0 + for fn in plain: + name = fn.__name__.replace("test_", "").replace("_", " ") + try: + fn() + print(f" ok {name}") + except AssertionError as exc: + print(f" FAIL {name}: {exc}") + failures += 1 + failures += _run_live() + failures += _run_bot_starts_chrome() + total = len(plain) + len(CASES) + 2 + print(f"\n{total - failures}/{total} passed") + return 1 if failures else 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tests/test_charge_summary.py b/tests/test_charge_summary.py new file mode 100644 index 0000000..3c34dad --- /dev/null +++ b/tests/test_charge_summary.py @@ -0,0 +1,865 @@ +r""" +core/charge_summary.py — the 24-column Charge Summary sheet written at the +end of every claims run. + + python tests\test_charge_summary.py + +No browser, no eCW: a fictional report is written to a temp folder. + +Review items D1 and D10 (2026-10-05): a TEST_CLAIM_IDS_FILE run writes +Charge Summary_TEST_.xlsx, and a same-day file held open (as Excel +holds it) no longer costs the run its sheet — it lands under a `__HHMMSS` +name instead. + +Review items D2/St1 (unit D, 2026-10-05): a third Category, "Review" / +"Needs manual review", for every claim the bot did not really check. The +Pass/Fail cases below now pass a fully-read UB claim (`_ub()`: CPT rows, a +progress note that was read, conditions that ran) — before, a bare +`{"_conditions": {"fired": []}}` was enough for Pass, and that bare dict +(no note, no rows) is exactly what now reads Review. +""" + +import io +import re +import sys +import pathlib +import tempfile +import contextlib + +ROOT = pathlib.Path(__file__).resolve().parent.parent +sys.path.insert(0, str(ROOT / "src")) + +from ecw_bot import settings as cfg # noqa: E402 +from ecw_bot.core import charge_summary as cs, spreadsheet # noqa: E402 +from ecw_bot.flows import export_medcor_ready as flow # noqa: E402 + +CASES = [] + +REPORT_HEADER = ["", "CLAIMTYPE", "CLAIMS#", "SERVICE DATE", "PVDR", "PATIENT", + "PAYER", "STATUS", "CHARGES", "PMTS/ADJS", "Adjustment", + "WithHeld", "Balance", "Visit Type", "Account Number", + "Provider Name", "Guarantor Name"] + + +def _row(claim, payer="Medicaid", pmts="35.00", adj="5.00", ctype="U"): + return ["", ctype, claim, "09/01/2026", "AB", "Test, Patient", payer, + "Medcor Ready", "275.00", pmts, adj, "0.00", "240.00", + "Acute Care Walk In", "123456", "Doe, Jane", "Test, Patient"] + + +REPORT = [REPORT_HEADER, _row("1000001"), _row("1000002", payer="DentaQuest", + ctype="D"), _row("1000001"), _row("1000003", payer="Unmapped Payer X")] +MAPPING = {"Medicaid": "Medicaid", "DentaQuest": "Dentaquest"} + + +def case(fn): + CASES.append(fn) + return fn + + +def _build(outcomes=None): + return cs.build_rows(REPORT, 0, 2, outcomes or {}, "10/01/2026", + "eCW Bot", MAPPING) + + +def _col(name): + return cs.HEADERS.index(name) + + +def _ub(fired=(), saved=False, **extra): + """A UB/HCFA claim the bot fully read: CPT rows, a progress note it + actually read, and the conditions run with `fired` changing something. + `extra` overrides or adds keys (`_conditions` included).""" + data = {"claim_no": "1", + "cpt": [{"code": "99213", "checked": True, "fee": "100.00"}], + "progress_notes": {"opened": "modal", "text": "Office visit."}, + "_conditions": {"fired": list(fired), "saved": saved, + "failed": [], "review": [], "comments": {}}} + data.update(extra) + return data + + +# ── claim_outcome ──────────────────────────────────────────────────────────── + +@case +def not_found_or_not_read_leaves_the_tracking_columns_blank(): + assert cs.claim_outcome(False, {}) == {} + assert cs.claim_outcome(True, {}) == {} + + +@case +def clean_comment_is_the_teams_exact_wording(): + # Operator, 2026-10-06 — copied from the team's sheet, character for + # character ("Pending with Errors", not the earlier "Medcor Ready"). + assert cs.CLEAN == ("Clean claims (claims that have no errors, placed " + "in Pending with Errors)"), cs.CLEAN + + +@case +def nothing_fired_is_a_clean_pass(): + o = cs.claim_outcome(True, _ub()) + assert o == {"Comments": cs.CLEAN, "Current Status": "Ready to Submit", + "Category": "Pass"}, o + + +@case +def a_fired_condition_is_a_fail_with_its_comment(): + o = cs.claim_outcome(True, _ub(["depression_dx_pointer", "dummy_codes"])) + assert o["Category"] == "Fail", o + assert "ICD pointer issue" in o["Comments"], o + assert "DCONS/Dscprit" in o["Comments"], o + assert o["Current Status"] == "Corrections pending (not saved)", o + + +@case +def a_saved_correction_reads_ready_to_submit(): + o = cs.claim_outcome(True, _ub(["telehealth"], saved=True)) + assert o["Current Status"] == "Ready to Submit", o + + +@case +def dental_high_risk_and_expired_sliding_fee_are_findings(): + o = cs.claim_outcome(True, { + "high_risk": {"high_risk": True}, + "sliding_fee": {"verdict": "expired", "reason": "DOS after range"}}) + assert o["Category"] == "Fail", o + assert cs.HIGH_RISK in o["Comments"], o + assert "Sliding fee EXPIRED" in o["Comments"], o + + +@case +def dental_clean_with_active_sliding_fee_is_a_pass(): + o = cs.claim_outcome(True, {"high_risk": {"high_risk": False}, + "sliding_fee": {"verdict": "active"}}) + assert o["Category"] == "Pass" and o["Comments"] == cs.CLEAN, o + + +@case +def dental_sliding_fee_none_is_noted_but_still_a_pass(): + o = cs.claim_outcome(True, {"high_risk": {"high_risk": False}, + "sliding_fee": {"verdict": "none", + "reason": "no dates"}}) + assert o["Category"] == "Pass", o + assert "Sliding fee NONE" in o["Comments"], o + + +# ── claim_outcome: Review (review items D2/St1, unit D) ───────────────────── + +REVIEW = {"Current Status": "Needs manual review", "Category": "Review"} + + +def _review(data): + o = cs.claim_outcome(True, data) + assert {k: o.get(k) for k in REVIEW} == REVIEW, o + return o["Comments"] + + +@case +def a_form_that_could_not_be_read_is_review_not_blank(): + c = _review({"claim_no": "1", "_error": "Timeout 30000ms exceeded."}) + assert c == "Bot could not read the claim (Timeout 30000ms exceeded.)", c + + +@case +def a_form_that_never_finished_loading_is_review_with_its_reason(): + c = _review(_ub(_not_evaluated="HCFA form: no CPT row after 20s", + _conditions=None, cpt=[])) + assert c.startswith("HCFA form: no CPT row after 20s"), c + # The flow skipped the conditions ON PURPOSE — not APPLY_CORRECTIONS=0. + assert "APPLY_CORRECTIONS" not in c, c + + +@case +def apply_corrections_off_is_review(): + data = _ub() + del data["_conditions"] + assert _review(data) == "Conditions not run (APPLY_CORRECTIONS=0)" + + +@case +def a_progress_note_never_read_is_review_in_every_state(): + for note, state in ((None, "not opened"), ({"opened": "nothing"}, "nothing"), + ({"opened": "absent"}, "absent"), + ({"opened": "disabled"}, "disabled")): + data = _ub() + if note is None: + del data["progress_notes"] + else: + data["progress_notes"] = note + c = _review(data) + assert c == (f"Progress note not read ({state}) — telehealth not " + f"checked"), (note, c) + + +@case +def a_progress_note_that_raised_is_review_with_its_first_line(): + c = _review(_ub(progress_notes={"opened": "iframe", + "error": "Target closed\nCall log: ..."})) + assert c == ("Progress note not read (error: Target closed) — telehealth " + "not checked"), c + + +@case +def zero_cpt_rows_read_is_review(): + assert _review(_ub(cpt=[])) == "No CPT rows were read" + + +@case +def a_condition_that_raised_is_review(): + c = _review(_ub(_conditions={"fired": [], "saved": False, "comments": {}, + "review": [], + "failed": [{"name": "telehealth", + "stage": "apply", + "error": "Timeout"}]})) + assert c == "telehealth failed: Timeout", c + + +@case +def a_condition_review_reason_is_review(): + c = _review(_ub(_conditions={ + "fired": [], "saved": False, "comments": {}, "failed": [], + "review": [{"name": "dummy_codes", + "reason": "CPT/HCPCS tab could not be opened — not " + "checked"}]})) + assert c == ("dummy_codes: CPT/HCPCS tab could not be opened — not " + "checked"), c + + +@case +def dental_unreadable_billing_notes_is_review(): + c = _review({"claim_no": "1", "high_risk": {"high_risk": None, + "match": ""}}) + assert c == "High risk: Billing Notes could not be read", c + + +@case +def dental_sliding_fee_unknown_is_review_not_a_pass(): + c = _review({"high_risk": {"high_risk": False}, + "sliding_fee": {"verdict": "unknown", + "reason": "no Patient Info"}}) + assert c == "Sliding fee UNKNOWN (no Patient Info)", c + + +@case +def a_dental_claim_is_not_asked_for_conditions_notes_or_cpt_rows(): + """Dental never runs the conditions or opens a note — by design.""" + o = cs.claim_outcome(True, {"claim_no": "1", + "high_risk": {"high_risk": False}}) + assert o["Category"] == "Pass" and o["Comments"] == cs.CLEAN, o + + +@case +def a_dental_claim_that_ran_the_conditions_carries_both_in_one_row(): + """ + Operator, 2026-10-12: a Dental claim runs the conditions too — its row + has the condition comments AND the Dental findings, and the same + claim-level checks as UB/HCFA (it did read a note and CPT rows). + """ + data = _ub(["dummy_codes"], screen="dental", + high_risk={"high_risk": True, "match": ""}, + sliding_fee={"verdict": "active"}) + o = cs.claim_outcome(True, data) + assert o["Category"] == "Fail", o + assert "DCONS/Dscprit" in o["Comments"], o + assert cs.HIGH_RISK in o["Comments"], o + assert o["Current Status"] == "Corrections pending (not saved)", o + assert cs.is_dental(data) + + +@case +def a_dental_read_is_asked_for_its_note_and_rows_like_any_claim(): + data = _ub(screen="dental", high_risk={"high_risk": False, "match": ""}, + progress_notes={"opened": "disabled"}) + c = _review(data) + assert "Progress note not read (disabled)" in c, c + no_cond = {k: v for k, v in data.items() if k != "_conditions"} + no_cond["progress_notes"] = {"opened": "modal", "text": "x"} + assert "Conditions not run" in _review(no_cond), _review(no_cond) + + +@case +def a_dental_claim_that_never_finished_loading_is_review(): + c = _review({"high_risk": {"high_risk": True}, + "_not_evaluated": "Dental form: no service line after 20s"}) + # The finding first, then the reason. + assert c == (f"{cs.HIGH_RISK}; Dental form: no service line after 20s"), c + + +@case +def review_wins_over_fail_and_carries_both(): + """A fired condition on a claim whose note was never read.""" + c = _review(_ub(["dummy_codes"], progress_notes={"opened": "disabled"})) + assert c == (f"{cs.COMMENTS['dummy_codes']}; Progress note not read " + f"(disabled) — telehealth not checked"), c + + +@case +def every_reason_is_listed_in_order(): + data = _ub(["telehealth"], cpt=[], progress_notes={"opened": "nothing"}) + data["_conditions"]["failed"] = [{"name": "j2930_j2001_update", + "stage": "detect", "error": "boom"}] + data["_conditions"]["review"] = [{"name": "vaccine_admin_codes", + "reason": "payer unmapped"}] + c = _review(data) + assert c.split("; ") == [ + cs.COMMENTS["telehealth"], + "Progress note not read (nothing) — telehealth not checked", + "No CPT rows were read", + "j2930_j2001_update failed: boom", + "vaccine_admin_codes: payer unmapped"], c + + +@case +def a_fail_uses_the_conditions_own_comment_when_it_has_one(): + data = _ub(["telehealth", "dummy_codes"]) + data["_conditions"]["comments"] = {"telehealth": "Telehealth (POS 02 set)"} + o = cs.claim_outcome(True, data) + assert o["Category"] == "Fail", o + assert o["Comments"] == ("Telehealth (POS 02 set); " + + cs.COMMENTS["dummy_codes"]), o + + +@case +def a_partial_note_with_no_sections_is_review(): + """ + Unit D review, finding 1 (2026-10-05): the header arrived and nothing + under it — no Procedure Codes, so telehealth saw nothing (the D8 false + Clean). This test pinned Pass before; it is Review now. + """ + c = _review(_ub(progress_notes={"opened": "modal", "text": "x", + "partial": True, "partial_sections": 0})) + assert c == "Progress note partial (0 sections) — telehealth not checked", c + + +@case +def a_partial_note_with_some_sections_is_review_too(): + """Sections never settled: the Procedure Codes may still be missing.""" + c = _review(_ub(progress_notes={"opened": "modal", "text": "x", + "partial": True, "partial_sections": 3})) + assert c == ("Progress note may be partial (3 section(s)) — telehealth " + "may not be fully checked"), c + + +@case +def a_partial_note_with_an_unknown_section_count_has_no_question_mark(): + """ + Unit F review, finding 4 (2026-10-05): these reasons are also the + on-screen "Needs review" lines, and D9 keeps "?" off the client's box. + """ + c = _review(_ub(progress_notes={"opened": "modal", "text": "x", + "partial": True})) + assert c == ("Progress note may be partial (section count unknown) — " + "telehealth may not be fully checked"), c + assert "?" not in c, c + + +@case +def a_partial_note_keeps_the_fired_conditions_comment_first(): + c = _review(_ub(["telehealth"], + progress_notes={"opened": "tab", "text": "x", + "partial": True, "partial_sections": 0})) + assert c == (f"{cs.COMMENTS['telehealth']}; Progress note partial " + f"(0 sections) — telehealth not checked"), c + + +@case +def first_line_is_the_one_shared_helper(): + """ + Unit D review, finding 4: core/text.first_line replaced three copies + (conditions/base, core/charge_summary, the flow's inline one). + """ + from ecw_bot.core.text import first_line + from ecw_bot.conditions import base + assert first_line(TimeoutError("Timeout 30000ms\n call log:\n x")) == \ + "Timeout 30000ms" + assert first_line(ValueError()) == "ValueError" # empty message + assert first_line(" boom \nmore") == "boom" + assert first_line("") == "" + assert base.first_line is first_line and cs.first_line is first_line + assert flow.first_line is first_line + # Final review, 2026-10-05: the three private copies that were left + # (claim_form, spreadsheet, vaccine_admin_codes) are gone too. Two of + # them returned "" for a message that starts with a newline. + from ecw_bot.pages import claim_form, progress_note + from ecw_bot.core import spreadsheet + from ecw_bot.conditions import vaccine_admin_codes + for mod in (claim_form, progress_note, spreadsheet, vaccine_admin_codes): + assert mod.first_line is first_line, mod.__name__ + assert not hasattr(mod, "_first_line"), mod.__name__ + assert first_line(RuntimeError("\nreal first line\nmore")) == \ + "real first line" + + +# ── Final review, 2026-10-05 ───────────────────────────────────────────────── + +@case +def a_saved_claim_that_is_review_says_it_was_saved(): + """ + "Needs manual review" alone hid a save that had happened: the reviewer + could treat the claim as untouched and redo or undo live edits. + """ + o = cs.claim_outcome(True, _ub(["zero_fee_uncheck"], saved=True, + progress_notes={"opened": "disabled"})) + assert o["Category"] == "Review", o + assert o["Current Status"] == "Needs manual review", o + assert o["Comments"].startswith(f"{cs.SAVED_NOTE}; " + f"{cs.COMMENTS['zero_fee_uncheck']}"), o + unsaved = cs.claim_outcome(True, _ub(["zero_fee_uncheck"], + progress_notes={"opened": "disabled"})) + assert cs.SAVED_NOTE not in unsaved["Comments"], unsaved + + +@case +def a_search_that_raised_is_review_not_a_blank_row(): + data = {"claim_no": "1", "_search_error": "Lookup never became visible"} + o = cs.claim_outcome(False, data) + assert o == {"Comments": "Bot could not search the claim (Lookup never " + "became visible)", + "Current Status": "Needs manual review", + "Category": "Review"}, o + # A claim really not in the queue stays blank. + assert cs.claim_outcome(False, {}) == {} + + +@case +def an_error_after_the_conditions_ran_is_worded_by_stage(): + data = {"claim_no": "1", "_error": "describe blew up", + "_conditions": {"fired": ["dummy_codes"], "saved": False, + "failed": [], "review": [], "comments": {}}} + o = cs.claim_outcome(True, data) + assert o["Category"] == "Review", o + assert o["Comments"] == (f"{cs.COMMENTS['dummy_codes']}; Bot failed after " + f"the conditions ran (describe blew up)"), o + plain = cs.claim_outcome(True, {"claim_no": "1", "_error": "x"}) + assert plain["Comments"] == "Bot could not read the claim (x)", plain + + +@case +def cpt_rows_with_no_icd_rows_is_review(): + """depression_dx_pointer reads the ICD list; an empty one is unchecked.""" + o = cs.claim_outcome(True, _ub(icd=[])) + assert o["Category"] == "Review", o + assert o["Comments"] == "No ICD rows were read", o + assert cs.claim_outcome(True, _ub(icd=[{"code": "R69"}]))["Category"] \ + == "Pass" + # Zero CPT rows already says it; no second ICD line on top. + reasons = cs.review_reasons(_ub(cpt=[], icd=[])) + assert reasons == ["No CPT rows were read"], reasons + + +@case +def a_note_skipped_on_a_form_that_never_loaded_is_named(): + reasons = cs.review_reasons(_ub(progress_notes={"opened": "skipped"})) + assert reasons == ["Progress note not read (skipped) — telehealth not " + "checked"], reasons + + +@case +def the_telehealth_fallback_comments_claim_nothing_that_may_not_have_happened(): + """ + Used only when a summary_comment hook raises: they must not say "G2025 + added" (it can fail), "93 or 95" (Medicaid is GT) or "unchecked". + """ + for name in ("telehealth", "medicare_telehealth"): + text = cs.COMMENTS[name] + for false_claim in ("G2025 added", "93 or 95", "unchecked"): + assert false_claim not in text, (name, text) + assert "see the log" in text, text + + +@case +def a_settled_note_is_still_a_pass(): + """Control: `partial` absent (the note settled) — Pass as before.""" + o = cs.claim_outcome(True, _ub(progress_notes={"opened": "modal", + "text": "x"})) + assert o["Category"] == "Pass", o + + +# ── build_rows ─────────────────────────────────────────────────────────────── + +@case +def header_is_the_formats_24_columns_in_order(): + rows = _build() + assert rows[0] == cs.HEADERS and len(rows[0]) == 24 + assert "CLAIMTYPE" not in rows[0] + + +@case +def one_row_per_claim_numbered_in_report_order(): + rows = _build() + assert [r[_col("CLAIMS#")] for r in rows[1:]] == ["1000001", "1000002", + "1000003"] + assert [r[_col("S.No")] for r in rows[1:]] == [1, 2, 3] + + +@case +def report_columns_are_copied_and_group_and_refunds_are_derived(): + r = _build()[1] + assert r[_col("PAYER")] == "Medicaid" and r[_col("CHARGES")] == "275.00" + assert r[_col("Guarantor Name")] == "Test, Patient" + assert r[_col("Group Name")] == "Medicaid" + assert r[_col("Pmts/Refunds")] == "30.00", r # 35.00 - 5.00 + + +@case +def unmapped_payer_gets_a_blank_group(): + assert _build()[3][_col("Group Name")] == "" + + +@case +def tracking_columns_fill_only_for_claims_the_run_read(): + outcomes = {"1000001": cs.claim_outcome(True, _ub())} + rows = _build(outcomes) + read, unread = rows[1], rows[2] + assert read[_col("Category")] == "Pass" + assert read[_col("Completed On")] == "10/01/2026" + assert read[_col("Recevied date")] == "10/01/2026" + assert read[_col("Assigned To ")] == "eCW Bot" + for h in ("Recevied date", "Assigned To ", "Completed On", "Comments", + "Current Status", "Category"): + assert unread[_col(h)] == "", (h, unread) + + +@case +def a_claim_id_only_test_sheet_still_builds(): + rows = cs.build_rows([["Claim No"], ["9000002"], ["9000001"]], 0, 0, + {}, "10/01/2026", "eCW Bot", MAPPING) + assert [r[_col("CLAIMS#")] for r in rows[1:]] == ["9000002", "9000001"] + assert rows[1][_col("PAYER")] == "" and rows[1][_col("Pmts/Refunds")] == "" + + +# ── write ──────────────────────────────────────────────────────────────────── + +@case +def write_saves_a_readable_sheet_and_keeps_a_same_day_file_aside(): + with tempfile.TemporaryDirectory() as tmp: + tmp = pathlib.Path(tmp) + report = tmp / "report.xlsx" + spreadsheet.write_xlsx(report, REPORT) + out = cs.write(report, {}, tmp, "10-01-2026", "eCW Bot") + assert out.name == "Charge Summary_10-01-2026.xlsx" + back = spreadsheet.read_rows(out) + # read_rows strips cells, so the format's "Assigned To " reads back + # without its trailing space. + assert back[0] == [h.strip() for h in cs.HEADERS], back[0] + assert len(back) == 4, back + cs.write(report, {}, tmp, "10-01-2026", "eCW Bot") + kept = list(tmp.glob("Charge Summary_10-01-2026__*.xlsx")) + assert len(kept) == 1, kept + + +@case +def file_name_is_live_or_TEST(): + assert cs.file_name("10-01-2026") == "Charge Summary_10-01-2026.xlsx" + assert cs.file_name("10-01-2026", test_mode=True) == \ + "Charge Summary_TEST_10-01-2026.xlsx" + + +@case +def test_mode_writes_the_TEST_file_and_leaves_the_live_one_alone(): + """D1: a test run must never push the live same-day summary aside.""" + with tempfile.TemporaryDirectory() as tmp: + tmp = pathlib.Path(tmp) + report = tmp / "report.xlsx" + spreadsheet.write_xlsx(report, REPORT) + live = cs.write(report, {}, tmp, "10-01-2026", "eCW Bot") + live_bytes = live.read_bytes() + out = cs.write(report, {}, tmp, "10-01-2026", "eCW Bot", + test_mode=True) + assert out == tmp / "Charge Summary_TEST_10-01-2026.xlsx", out + assert len(spreadsheet.read_rows(out)) == 4 + assert live.read_bytes() == live_bytes, "the live summary changed" + assert not list(tmp.glob("Charge Summary_10-01-2026__*.xlsx")), \ + "the live summary was pushed aside by a test run" + + +@case +def a_same_day_summary_open_in_excel_does_not_lose_this_runs_sheet(): + """ + D10: the earlier summary is held open (on Windows a Python handle + refuses the rename with WinError 32, as Excel does). The new sheet goes + to a `__HHMMSS` name, the open file is untouched, nothing raises, and + the path returned is the new file — the one the flow logs. + """ + with tempfile.TemporaryDirectory() as tmp: + tmp = pathlib.Path(tmp) + report = tmp / "report.xlsx" + spreadsheet.write_xlsx(report, REPORT) + first = cs.write(report, {}, tmp, "10-01-2026", "eCW Bot") + first_bytes = first.read_bytes() + outcomes = {"1000001": cs.claim_outcome(True, _ub())} + with open(first, "rb"): + out = cs.write(report, outcomes, tmp, "10-01-2026", "eCW Bot") + assert out != first, "wrote over the open file's name" + assert re.fullmatch(r"Charge Summary_10-01-2026__\d{6}\.xlsx", + out.name), out.name + assert first.read_bytes() == first_bytes, "the open file was changed" + back = spreadsheet.read_rows(out) + assert len(back) == 4, back + assert back[1][_col("Category")] == "Pass", back[1] + + +FALLBACK_NAME = "Charge Summary_10-01-2026__101010.xlsx" + + +def _flow_write(test_file, returns=FALLBACK_NAME): + """ + _write_charge_summary with charge_summary.write replaced by a recorder + that returns `returns`, and msgbox.info_box by one that records instead + of popping a real Windows box. Returns (args, kwargs it was called with, + printed text, [(title, body)] of boxes shown). + """ + calls = [] + shown = [] + + def _record(*args, **kwargs): + calls.append((args, kwargs)) + # By default, as if the usual name was open in Excel: the log must + # name THIS, and the operator must be told. + return pathlib.Path(returns) + + orig = (cs.write, cfg.WRITE_CHARGE_SUMMARY, cfg.TEST_CLAIM_IDS_FILE, + flow.msgbox.info_box) + printed = io.StringIO() + try: + cs.write = _record + cfg.WRITE_CHARGE_SUMMARY = True + cfg.TEST_CLAIM_IDS_FILE = test_file + flow.msgbox.info_box = lambda t, b: shown.append((t, b)) + with contextlib.redirect_stdout(printed): + flow._write_charge_summary({"file": pathlib.Path("r.xlsx")}, + {"1": {"Category": "Pass"}}) + finally: + (cs.write, cfg.WRITE_CHARGE_SUMMARY, cfg.TEST_CLAIM_IDS_FILE, + flow.msgbox.info_box) = orig + assert len(calls) == 1, calls + return calls[0][0], calls[0][1], printed.getvalue(), shown + + +@case +def flow_passes_test_mode_when_the_ids_came_from_a_test_sheet(): + _, kwargs, _, _ = _flow_write("C:/somewhere/Standard Claims.xlsx") + assert kwargs.get("test_mode") is True, kwargs + + +@case +def flow_passes_test_mode_off_for_a_live_run(): + _, kwargs, _, _ = _flow_write("") + assert kwargs.get("test_mode") is False, kwargs + + +@case +def flow_logs_the_file_actually_written(): + _, _, printed, _ = _flow_write("") + line = "[SUMMARY] 1 claim(s) filled in -> " + FALLBACK_NAME + assert line in printed, printed + + +@case +def flow_shows_a_box_when_the_summary_went_to_another_name(): + """ + Fix round 1 (review item D10, 2026-10-05): the person with the usual + file open in Excel is at the desk, so the fallback name goes in a box, + not only the log — else they present the stale sheet they have open. + """ + _, _, _, shown = _flow_write("") + assert len(shown) == 1, shown + title, body = shown[0] + assert title == "eCW Bot — Charge summary saved under another name", title + usual = cs.file_name(cfg.TODAY) + assert f"could not be saved as {usual}" in body, body + assert FALLBACK_NAME in body, body + # Fix round 2: the box must not promise the earlier file was untouched — + # a failed move-back can leave a half-file under the usual name. + assert "left as it was" not in body, body + + +@case +def flow_shows_no_box_when_the_summary_has_its_usual_name(): + _, _, _, shown = _flow_write("", returns=cs.file_name(cfg.TODAY)) + assert shown == [], shown + + +@case +def flow_shows_no_box_for_a_test_run_under_the_TEST_name(): + _, _, _, shown = _flow_write("C:/somewhere/Standard Claims.xlsx", + returns=cs.file_name(cfg.TODAY, True)) + assert shown == [], shown + + +@case +def flow_write_never_raises_on_a_bad_report(): + shown = [] + old = flow.msgbox.info_box + flow.msgbox.info_box = lambda t, b: shown.append(t) + try: + flow._write_charge_summary({"file": pathlib.Path("no_such_file.xlsx")}, + {}) + finally: + flow.msgbox.info_box = old + assert shown, "a failed write should show a box, not raise" + + +# ── per-claim writes (2026-10-12: the summary grows claim by claim) ───────── + +@case +def the_report_is_read_once_while_it_is_unchanged(): + """Built after every claim, twice (Excel + DB): one read, not 2 x N.""" + with tempfile.TemporaryDirectory() as tmp: + report = pathlib.Path(tmp) / "report.xlsx" + spreadsheet.write_xlsx(report, REPORT) + reads = [] + real = spreadsheet.read_rows + spreadsheet.read_rows = lambda p: reads.append(p) or real(p) + try: + first = cs.build(report, {}, "eCW Bot") + cs.build(report, {"1000001": cs.claim_outcome(True, _ub())}, + "eCW Bot") + assert len(reads) == 1, reads + spreadsheet.write_xlsx(report, REPORT[:2]) + again = cs.build(report, {}, "eCW Bot") + finally: + spreadsheet.read_rows = real + assert len(reads) == 2, "a changed report must be read again" + assert len(first) == 4 and len(again) == 2, (first, again) + +@case +def write_into_rewrites_this_runs_own_file_with_no_backup(): + """ + After the first claim, the sheet goes back into the file that claim + wrote — no `__HHMMSS` copy per claim; only the first write keeps an + earlier same-day file aside. + """ + with tempfile.TemporaryDirectory() as tmp: + tmp = pathlib.Path(tmp) + report = tmp / "report.xlsx" + spreadsheet.write_xlsx(report, REPORT) + first = cs.write(report, {"1000001": cs.claim_outcome(True, _ub())}, + tmp, "10-01-2026", "eCW Bot") + out = cs.write(report, {"1000001": cs.claim_outcome(True, _ub()), + "1000002": cs.claim_outcome(True, _ub())}, + tmp, "10-01-2026", "eCW Bot", into=first) + assert out == first, out + assert not list(tmp.glob("Charge Summary_10-01-2026__*.xlsx")), \ + "a backup was made for this run's own file" + back = spreadsheet.read_rows(out) + assert back[1][_col("Category")] == "Pass", back[1] + assert back[2][_col("Category")] == "Pass", back[2] + + +@case +def write_into_a_locked_file_goes_to_a_new_name(): + """The operator opened the in-progress summary in Excel mid-run.""" + with tempfile.TemporaryDirectory() as tmp: + tmp = pathlib.Path(tmp) + report = tmp / "report.xlsx" + spreadsheet.write_xlsx(report, REPORT) + first = cs.write(report, {}, tmp, "10-01-2026", "eCW Bot") + first_bytes = first.read_bytes() + real = spreadsheet.write_xlsx + + def locked(path, rows): + if pathlib.Path(path) == first: + raise PermissionError(13, "Permission denied") + return real(path, rows) + + spreadsheet.write_xlsx = locked + try: + with contextlib.redirect_stdout(io.StringIO()) as printed: + out = cs.write(report, {"1000001": cs.claim_outcome(True, + _ub())}, + tmp, "10-01-2026", "eCW Bot", into=first) + finally: + spreadsheet.write_xlsx = real + assert re.fullmatch(r"Charge Summary_10-01-2026__\d{6}\.xlsx", + out.name), out.name + assert first.read_bytes() == first_bytes, "the locked file changed" + assert spreadsheet.read_rows(out)[1][_col("Category")] == "Pass" + assert "open or locked" in printed.getvalue(), printed.getvalue() + + +def _flow_write_twice(first_returns, second_returns): + """_write_charge_summary called for two claims of one run.""" + calls, shown = [], [] + returns = [pathlib.Path(first_returns), pathlib.Path(second_returns)] + + def _record(*args, **kwargs): + calls.append(kwargs) + return returns[len(calls) - 1] + + orig = (cs.write, cfg.WRITE_CHARGE_SUMMARY, cfg.TEST_CLAIM_IDS_FILE, + flow.msgbox.info_box) + info = {"file": pathlib.Path("r.xlsx")} + try: + cs.write = _record + cfg.WRITE_CHARGE_SUMMARY = True + cfg.TEST_CLAIM_IDS_FILE = "" + flow.msgbox.info_box = lambda t, b: shown.append((t, b)) + with contextlib.redirect_stdout(io.StringIO()): + flow._write_charge_summary(info, {"1": {"Category": "Pass"}}) + flow._write_charge_summary(info, {"1": {"Category": "Pass"}, + "2": {"Category": "Fail"}}) + finally: + (cs.write, cfg.WRITE_CHARGE_SUMMARY, cfg.TEST_CLAIM_IDS_FILE, + flow.msgbox.info_box) = orig + return calls, shown + + +@case +def flow_second_claim_writes_into_the_file_the_first_one_wrote(): + usual = cs.file_name(cfg.TODAY) + calls, shown = _flow_write_twice(usual, usual) + assert calls[0].get("into") is None, calls + assert calls[1].get("into") == pathlib.Path(usual), calls + assert shown == [], shown + + +@case +def flow_shows_the_another_name_box_once_not_per_claim(): + calls, shown = _flow_write_twice(FALLBACK_NAME, FALLBACK_NAME) + assert calls[1].get("into") == pathlib.Path(FALLBACK_NAME), calls + assert len(shown) == 1, shown + + +@case +def flow_shows_the_box_again_when_the_run_moves_to_a_new_name(): + """Mid-run, the operator opened this run's file: the sheet moves on.""" + usual = cs.file_name(cfg.TODAY) + _, shown = _flow_write_twice(usual, FALLBACK_NAME) + assert len(shown) == 1 and FALLBACK_NAME in shown[0][1], shown + + +@case +def flow_write_error_box_shows_once_per_run(): + shown = [] + old = flow.msgbox.info_box + flow.msgbox.info_box = lambda t, b: shown.append(t) + info = {"file": pathlib.Path("no_such_file.xlsx")} + try: + with contextlib.redirect_stdout(io.StringIO()): + flow._write_charge_summary(info, {}) + flow._write_charge_summary(info, {}) + finally: + flow.msgbox.info_box = old + assert len(shown) == 1, shown + + +def main() -> int: + failures = 0 + for fn in CASES: + name = fn.__name__.replace("_", " ") + try: + fn() + print(f" ok {name}") + except AssertionError as exc: + print(f" FAIL {name}: {exc}") + failures += 1 + except Exception as exc: + print(f" ERROR {name}: {type(exc).__name__}: {exc}") + failures += 1 + print(f"\n{len(CASES) - failures}/{len(CASES)} passed") + return 1 if failures else 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tests/test_claim_boxes.py b/tests/test_claim_boxes.py new file mode 100644 index 0000000..7133cd6 --- /dev/null +++ b/tests/test_claim_boxes.py @@ -0,0 +1,599 @@ +r""" +export_medcor_ready - what the client sees: one box per claim, in English, +with no "?" pages (review item D9, and P6's ungated Dental box). + + python tests\test_claim_boxes.py + +Before D9 (2026-10-05), with the operator's settings (SAVE_CORRECTIONS=0, +REVIEW_BEFORE_CLOSE=1, SHOW_EACH_CLAIM=1): + + * a corrected UB/HCFA claim showed THREE boxes - "N condition(s) matched + - about to apply", "review before close", then the per-claim box - three + OK clicks, up to 180 s each, in a client demo; + * a Dental claim showed its own Dental box (even with every flag off), + then a per-claim box of "Patient : ? ... Charges : ?"; + * the boxes said "innum N" and "Chart locked: Aamaa/Illa", and + "Conditions : none matched" right after a box said one was about to + apply. + +Every case drives the real `_process_claims` / `_read_claim_form` with each +page step stubbed and `msgbox.info_box` recording (title, body) into the +same event list as `close_claim`, so the ORDER is checked, not assumed. +save_claim and the save box are forbidden throughout: nothing here may +write. No browser, no eCW. +""" + +import io +import re +import sys +import types +import pathlib +import contextlib + +ROOT = pathlib.Path(__file__).resolve().parent.parent +sys.path.insert(0, str(ROOT / "src")) + +from ecw_bot import settings as cfg # noqa: E402 +from ecw_bot.core import msgbox # noqa: E402 +from ecw_bot.pages import claim_form, progress_note # noqa: E402 +from ecw_bot.conditions.base import ConditionResult # noqa: E402 +from ecw_bot.flows import export_medcor_ready as flow # noqa: E402 + +CASES = [] + + +def case(fn): + CASES.append(fn) + return fn + + +def _forbidden(*a, **k): + raise AssertionError("must not be called on this path") + + +# Every attribute any case replaces, restored after each case. The real .env +# is loaded at import, so every setting a case depends on is set here. +_PATCHED = [ + (cfg, "SAVE_CORRECTIONS"), (cfg, "REVIEW_BEFORE_CLOSE"), + (cfg, "SHOW_EACH_CLAIM"), (cfg, "SHOW_CLAIM_IDS"), + (cfg, "CLAIM_PROCESS_LIMIT"), (cfg, "OPEN_CLAIM_FORM"), + (cfg, "OPEN_PROGRESS_NOTES"), (cfg, "APPLY_CORRECTIONS"), + (cfg, "TEST_CLAIM_IDS_FILE"), (cfg, "CHECK_HIGH_RISK"), + (cfg, "CHECK_SLIDING_FEE"), (cfg, "MSGBOX_TIMEOUT_SECONDS"), + (msgbox, "info_box"), (msgbox, "ok_cancel"), (msgbox, "info"), + (claim_form, "open_claim"), (claim_form, "wait_claim_loaded"), + (claim_form, "is_dental_claim_form"), (claim_form, "open_cpt_hcpcs_tab"), + (claim_form, "extract"), (claim_form, "save"), (claim_form, "close_claim"), + (claim_form, "save_claim"), (claim_form, "open_progress_notes"), + (flow.conditions, "CONDITIONS"), (flow.conditions, "BY_NAME"), + (flow.claim_results, "search_claim"), + (flow.claim_filters, "clear_claim_no"), (flow, "_write_charge_summary"), + # Never the real results database, whatever this machine's .env says. + (cfg, "RESULTS_DB"), + (flow.dental_notes, "check"), (flow.sliding_fee, "check"), +] +_ORIG = [(obj, name, getattr(obj, name)) for obj, name in _PATCHED] + + +def _restore(): + for obj, name, value in _ORIG: + setattr(obj, name, value) + + +# A UB/HCFA read that leaves nothing for the Charge Summary to send to +# Review: one CPT row, one ICD row (an empty ICD list next to CPT rows is a +# review reason since the final review, 2026-10-05) and a progress note +# that was actually read. +_UB = {"claim_no": "9103000", + "patient": {"name": "Doe, Jane", "age_sex": "34 F", + "account_no": "A1"}, + "header": {"claim_date": "09/01/2026", + "claim_status_label": "Medcor Ready", + "claim_status_code": "MR"}, + "totals": {"charges": "224.00", "balance": "224.00"}, + "cpt": [{"code": "99213", "sdos": "09/01/2026", "units": "1"}], + "icd": [{"#": "1", "code": "R69"}], "revenue": []} +_NOTE = {"opened": "modal", "sections": {"Assessments": "x"}} + +_RESULT = ConditionResult( + condition_name="depression_dx_pointer", changed=True, + changes=[{"row": 0, "code": "G8510", + "before": {"icd": ["1", "", "", ""]}, + "after": {"icd": ["2", "", "", ""]}}]) + + +def _fake_condition(fires, detect_raises=False): + mod = types.ModuleType("fake_cond") + mod.NAME = "fake_cond" + if detect_raises: + def detect(claim_id, data): + raise KeyError("rows") + mod.detect = detect + else: + mod.detect = lambda claim_id, data: fires + mod.apply = lambda page, claim_id, data: _RESULT + return mod + + +class _DescribeRaises: + """Stands in for depression_dx_pointer in BY_NAME: describe() raises.""" + NAME = "depression_dx_pointer" + + @staticmethod + def describe(result): + raise ValueError("unexpected change shape") + + +def _search_raises(page, cid): + raise RuntimeError("Lookup button never became visible") + + +def _run(*, fires=False, review=True, show_each=True, dental=False, + open_claim=None, test_file="", high_risk=True, save=False, + detect_raises=False, describe_raises=False, search=None): + """ + One claim through the real `_process_claims`. Returns (events, printed + text, outcomes). events: ("box", title, body) and ("close",), in order. + """ + events, written = [], [] + cfg.SAVE_CORRECTIONS = save + cfg.REVIEW_BEFORE_CLOSE = review + cfg.SHOW_EACH_CLAIM = show_each + cfg.SHOW_CLAIM_IDS = False + cfg.CLAIM_PROCESS_LIMIT = 0 + cfg.OPEN_CLAIM_FORM = True + cfg.OPEN_PROGRESS_NOTES = True + cfg.APPLY_CORRECTIONS = True + cfg.TEST_CLAIM_IDS_FILE = test_file + cfg.CHECK_HIGH_RISK = True + cfg.CHECK_SLIDING_FEE = True + cfg.MSGBOX_TIMEOUT_SECONDS = 7 + if test_file: + # The [TEST] prefix is added inside msgbox itself (titled()), so + # here the layer under info_box records instead - the title the + # user would actually see. + msgbox.info = lambda t, m, s: events.append(("box", t, m)) or \ + msgbox.OK + else: + msgbox.info_box = lambda t, m: events.append(("box", t, m)) + # SAVE_CORRECTIONS=1 only: the save decision box, recorded and declined + # (so save_claim, still forbidden, is never reached). + msgbox.ok_cancel = ( + (lambda t, m, default_cancel=False: + events.append(("decision", t, m)) or False) if save + else _forbidden) + claim_form.open_claim = open_claim or (lambda page, cid: True) + claim_form.wait_claim_loaded = lambda page, cid: None + claim_form.is_dental_claim_form = lambda page: dental + claim_form.open_cpt_hcpcs_tab = lambda page: None + claim_form.extract = lambda page: {k: (dict(v) if isinstance(v, dict) + else list(v) if isinstance(v, list) + else v) + for k, v in _UB.items()} + claim_form.save = lambda data: pathlib.Path("unused.json") + claim_form.close_claim = lambda page: events.append(("close",)) + claim_form.save_claim = _forbidden + claim_form.open_progress_notes = lambda page: dict(_NOTE) + flow.conditions.CONDITIONS = [_fake_condition(fires, detect_raises)] + if describe_raises: + flow.conditions.BY_NAME = dict(flow.conditions.BY_NAME, + depression_dx_pointer=_DescribeRaises) + flow.claim_results.search_claim = search or (lambda page, cid: { + "id": cid, "count": 1, "found": True, "seconds": 3.0}) + flow.claim_filters.clear_claim_no = lambda page: None + cfg.RESULTS_DB = False + flow._write_charge_summary = lambda info, outcomes: written.append( + outcomes) + flow.dental_notes.check = lambda page, cid: ( + {"high_risk": True, "match": "HIGH RISK"} if high_risk + else {"high_risk": False, "match": ""}) + flow.sliding_fee.check = lambda page, cid, data: { + "verdict": "active", "reason": "DOS inside the range", + "dos": "09/01/2026", "from_date": "01/01/2026", + "to_date": "12/31/2026"} + out = io.StringIO() + try: + with contextlib.redirect_stdout(out): + flow._process_claims(None, {"ids": ["9103000"], "file": None}) + finally: + _restore() + return events, out.getvalue(), written[0] + + +def _boxes(events): + return [e for e in events if e[0] == "box"] + + +# ── corrected UB/HCFA claim ───────────────────────────────────────────────── + +@case +def corrected_claim_with_review_on_gets_exactly_one_box_before_close(): + events, out, outcomes = _run(fires=True) + boxes = _boxes(events) + assert len(boxes) == 1, events + # Shown while the form is still open: before close_claim, not after. + assert events[0][0] == "box" and events[1] == ("close",), events + _, title, body = boxes[0] + assert title == "eCW Bot — Claim 9103000: review before close", title + # The per-claim header _process_claims builds ... + for want in ("Claim 1/1", "Claim No : 9103000", "Found : Yes", + "Total Counts : 1", "Time taken : 3s"): + assert want in body, f"{want!r} missing:\n{body}" + # ... what the condition changed (its own describe()) ... + assert "G8510" in body, body + # ... the claim's Summary line, as the sheet will say it ... + oc = outcomes["9103000"] + assert oc["Category"] == "Fail", oc + assert (f"Summary : Fail — {oc['Current Status']}") in body, body + # ... and the footer: nothing saved, OK closes with Cancel, auto-close. + assert "Nothing is saved" in body and "close the claim with Cancel" \ + in body and "after 7s" in body, body + # The before-apply box is gone; its log line stays. + assert "1 condition(s) matched — about to apply: fake_cond" in out, out + + +@case +def corrected_claim_with_review_off_gets_one_box_after_close(): + events, _, _ = _run(fires=True, review=False) + boxes = _boxes(events) + assert len(boxes) == 1, events + assert events[0] == ("close",) and events[1][0] == "box", events + _, title, body = boxes[0] + assert title == "eCW Bot — Claim 9103000", title + assert "Changed : depression_dx_pointer" in body, body + assert "Summary : Fail — Corrections pending (not saved)" in body, \ + body + + +@case +def save_on_keeps_the_decision_box_then_the_per_claim_box(): + """ + SAVE_CORRECTIONS=1 (brief item 4): the save decision box is unchanged + and the per-claim info box still follows after close. No review box: + the decision box is already the pause before closing. + """ + events, _, _ = _run(fires=True, save=True) + kinds = [e[0] for e in events] + assert kinds == ["decision", "close", "box"], events + assert events[0][1] == "eCW Bot — Claim 9103000: 1 condition(s) fired", \ + events + assert events[2][1] == "eCW Bot — Claim 9103000", events + + +@case +def corrected_claim_with_every_box_off_shows_nothing(): + events, _, _ = _run(fires=True, review=False, show_each=False) + assert _boxes(events) == [], events + + +@case +def clean_claim_says_nothing_needed_changing_and_pass(): + events, _, outcomes = _run(fires=False) + boxes = _boxes(events) + assert len(boxes) == 1, events + assert events[0] == ("close",), events + _, title, body = boxes[0] + assert title == "eCW Bot — Claim 9103000", title + assert "Changed : nothing needed changing" in body, body + assert outcomes["9103000"]["Category"] == "Pass", outcomes + assert "Summary : Pass — Ready to Submit" in body, body + # The old wording that read as "nothing fired" is gone. + assert "none matched" not in body, body + # The form detail is there, with no "?" lines. + assert "Patient : Doe, Jane (34 F)" in body, body + assert not re.search(r":\s*\?\s*$", body, re.M), body + + +@case +def a_review_box_that_raises_still_closes_the_claim(): + """ + Unit F review, finding 3: the review box is built in + _read_claim_form's `finally`, before close_claim. A condition whose + describe() raises must not skip close_claim (the form would stay open + with the edits on it) nor end the run. The claim gets its per-claim box + after close instead, and the Charge Summary is still written. + + describe() raises first inside the read (_confirm_and_save logs it), so + the claim is an error; the `finally` then calls describe() again + for the review box, and before the fix that second raise escaped + _read_claim_form and _process_claims both. + + Updated in the final review (2026-10-05): the error came AFTER the + conditions ran, and their account is now kept on the error dict — so + the box and the sheet say "failed after the conditions ran", not + "Could not read this claim", and the condition that changed the form + is still named (this case used to assert the "Could not read" line). + """ + events, out, outcomes = _run(fires=True, describe_raises=True) + assert events[0] == ("close",), events + boxes = _boxes(events) + assert len(boxes) == 1, events + _, title, body = boxes[0] + assert title == "eCW Bot — Claim 9103000", title + assert ("The bot failed after the conditions ran: unexpected change " + "shape") in body, body + assert "Could not read this claim" not in body, body + assert "Changed : depression_dx_pointer" in body, body + c = outcomes["9103000"]["Comments"] + assert ("Bot failed after the conditions ran (unexpected change shape)" + in c and c.startswith("ICD pointer issue")), c + assert "[COND 9103000] review box failed: unexpected change shape" \ + in out, out + # The run went on to the Charge Summary, with the claim on it. + assert outcomes["9103000"]["Category"] == "Review", outcomes + + +@case +def a_failed_condition_with_nothing_changed_has_no_changed_line(): + """ + Unit F review, finding 2: the brief allows two forms of the line, + "" and "nothing needed changing". When nothing fired but a + condition raised, the second would call an unchecked claim clean, so + there is no Changed line; the Needs review line says what failed. + """ + events, _, outcomes = _run(detect_raises=True) + _, _, body = _boxes(events)[0] + assert "Changed" not in body, body + assert "Needs review : fake_cond failed:" in body, body + assert outcomes["9103000"]["Category"] == "Review", outcomes + assert "Summary : Review — Needs manual review" in body, body + + +def _box_for(**extra): + """The per-claim box body for a _UB read with `extra` merged in, with + the conditions run and nothing changed.""" + data = {k: (dict(v) if isinstance(v, dict) else + list(v) if isinstance(v, list) else v) + for k, v in _UB.items()} + data["progress_notes"] = dict(_NOTE) + data["_conditions"] = {"fired": [], "saved": False, "failed": [], + "review": [], "comments": {}} + data.update(extra) + outcome = flow.charge_summary.claim_outcome(True, data) + return flow._claim_box("Claim 1/1", "9103000", data, outcome)[1] + + +@case +def nothing_needed_changing_is_never_said_of_a_claim_that_was_not_checked(): + """ + Final review, 2026-10-05: the box said "Changed : nothing needed + changing" right above "Needs review : Progress note not read" — any + review reason at all means the claim was not fully checked. + """ + for extra, reason in ( + ({"progress_notes": {"opened": "nothing"}}, + "Needs review : Progress note not read (nothing)"), + ({"cpt": []}, "Needs review : No CPT rows were read")): + body = _box_for(**extra) + assert "Changed" not in body, body + assert reason in body, body + assert "Summary : Review — Needs manual review" in body, body + # The control: a fully read claim still says it. + body = _box_for() + assert "Changed : nothing needed changing" in body, body + assert "Summary : Pass" in body, body + + +@case +def a_not_loaded_reason_is_shown_once_not_twice(): + """ + The box leads with NOT_LOADED_NOTE and the reason under it, so + _findings_lines leaves `_not_evaluated` out of its Needs review lines; + without that filter the client saw the same reason twice. + """ + why = "HCFA form did not finish loading in 20s (0 CPT rows)" + data = {"claim_no": "9103000", "cpt": [], "icd": [], "revenue": [], + "_not_evaluated": why, + "progress_notes": {"opened": "skipped"}} + outcome = flow.charge_summary.claim_outcome(True, data) + body = flow._claim_box("Claim 1/1", "9103000", data, outcome)[1] + assert body.count(why) == 1, body + assert claim_form.NOT_LOADED_NOTE in body, body + + +@case +def a_search_that_raised_shows_no_question_mark_count(): + """ + Unit F review, finding 4: _process_claims records count "?" when + search_claim raises; the box leaves the line out. + + Final review, 2026-10-05: and it no longer says "Found : No" — a search + that raised established nothing about the queue. The box says the + search failed and why (updated: this case used to assert "Found : No"). + """ + events, _, _ = _run(search=_search_raises) + boxes = _boxes(events) + assert len(boxes) == 1, events + _, _, body = boxes[0] + assert "Found : Unknown — the search failed" in body, body + assert "Found : No" not in body, body + assert "Could not search this claim:" in body, body + # Said once, by that line — not again as a "Needs review" line. + assert "Needs review : Bot could not search" not in body, body + assert "Summary : Review — Needs manual review" in body, body + assert "Total Counts" not in body and "?" not in body, body + + +@case +def a_zero_count_is_still_shown(): + """Control: 0 is a fact (searched, nothing came back), not a blank.""" + head = flow._claim_head(1, 1, "9103000", + {"count": 0, "found": False, "seconds": 2.0}) + assert "Total Counts : 0" in head, head + + +# ── Dental ────────────────────────────────────────────────────────────────── + +@case +def dental_claim_gets_one_box_with_its_results_and_no_question_marks(): + events, _, _ = _run(dental=True) + boxes = _boxes(events) + assert len(boxes) == 1, events + # After close, like every per-claim box: _dental_checks has no box now. + assert events[0] == ("close",), events + _, title, body = boxes[0] + assert title == "eCW Bot — Dental claim 9103000: HIGH RISK, " \ + "sliding ACTIVE", title + assert "High risk : YES" in body, body + assert "Sliding fee : ACTIVE" in body, body + assert "Read-only — nothing was changed in eCW." in body, body + # claim_form.summary() is not used for a Dental read: no form fields. + for gone in ("Patient", "Charges", "Acct No", "ICD 0"): + assert gone not in body, f"{gone!r} in:\n{body}" + assert "?" not in body, body + assert "Summary : Fail" in body, body + + +@case +def dental_claim_shows_no_box_at_all_with_show_each_claim_off(): + """P6: the Dental box used to appear whatever was set.""" + events, _, outcomes = _run(dental=True, show_each=False) + assert _boxes(events) == [], events + # Still evaluated and recorded - only the box is off. + assert outcomes["9103000"]["Category"] == "Fail", outcomes + + +# ── a claim that could not be read ────────────────────────────────────────── + +def _cannot_open(page, cid): + raise RuntimeError("Timeout 30000ms exceeded.\nCall log:\n - waiting") + + +@case +def read_error_gets_one_box_with_the_error_line(): + events, _, _ = _run(open_claim=_cannot_open) + boxes = _boxes(events) + assert len(boxes) == 1, events + _, title, body = boxes[0] + assert "Could not read this claim: Timeout 30000ms exceeded." in body, \ + body + assert "Summary : Review — Needs manual review" in body, body + assert "Patient" not in body and "?" not in body, body + + +# ── test mode ─────────────────────────────────────────────────────────────── + +@case +def test_mode_prefixes_every_box_title(): + events, _, _ = _run(fires=False, test_file="C:/x/Standard Claims.xlsx") + boxes = _boxes(events) + assert len(boxes) == 1, events + assert boxes[0][1] == "[TEST] eCW Bot — Claim 9103000", boxes + + +@case +def test_mode_prefixes_the_review_before_close_box_too(): + events, _, _ = _run(fires=True, test_file="C:/x/Standard Claims.xlsx") + boxes = _boxes(events) + assert len(boxes) == 1, events + assert boxes[0][1] == ("[TEST] eCW Bot — Claim 9103000: review before " + "close"), boxes + + +@case +def a_live_run_has_no_test_prefix(): + events, _, _ = _run(fires=False) + assert not _boxes(events)[0][1].startswith("[TEST]"), events + + +# ── the strings themselves ────────────────────────────────────────────────── + +@case +def summary_leaves_out_lines_that_came_back_empty_or_question_mark(): + text = claim_form.summary({ + "claim_no": "9109000", + "patient": {"name": "?", "age_sex": "", "account_no": ""}, + "header": {"claim_date": "?", "facility": None}, + "totals": {}, + "cpt": [], "icd": [], "revenue": []}) + assert "Claim No : 9109000" in text, text + for gone in ("Patient", "Acct No", "Claim Date", "Status", "Facility", + "Charges", "Balance"): + assert gone not in text, f"{gone!r} in:\n{text}" + assert "?" not in text, text + # The counts line stays: 0 is a fact, not a blank. + assert "ICD 0 CPT 0 Revenue 0" in text, text + + +@case +def summary_of_an_empty_dict_has_no_question_marks(): + """What a Dental dict used to get: a page of '?'.""" + text = claim_form.summary({}) + assert "?" not in text, text + + +@case +def summary_keeps_a_status_with_only_its_label(): + text = claim_form.summary({"header": {"claim_status_label": "Medcor Ready", + "claim_status_code": ""}}) + assert "Status : Medcor Ready" in text, text + assert "()" not in text, text + + +@case +def summary_says_and_n_more_in_english(): + rows = [{"code": f"9921{i}", "sdos": "09/01/2026", "units": "1"} + for i in range(7)] + text = claim_form.summary({"cpt": rows}) + assert " ... and 2 more" in text, text + + +@case +def progress_note_summary_says_yes_or_no_for_chart_locked(): + assert "Chart locked: Yes" in progress_note.summary( + {"chart_locked": True}) + assert "Chart locked: No" in progress_note.summary( + {"chart_locked": False}) + + +@case +def progress_note_summary_skips_rows_with_no_code(): + text = progress_note.summary({ + "assessments": [{"code": ""}, {"code": "R69", "primary": True}], + "procedure_codes": [{"code": None}, {"code": "H0023", + "modifiers": ["GT"]}]}) + assert "Assessment : R69 (Primary)" in text, text + assert "Procedure : H0023 mod GT" in text, text + assert "?" not in text, text + assert text.count("Assessment") == 1 and text.count("Procedure") == 1, \ + text + + +@case +def no_tamil_word_is_left_anywhere_in_src(): + """ + The words the review found in message-box strings, and the others the + brief names. Scanned over all of src/, not just the boxes found so far, + so a new box cannot bring one back. + """ + words = re.compile(r"\b(innum|aamaa|illa|aagum|pannu)\b", re.I) + hits = [] + for path in (ROOT / "src").rglob("*.py"): + for n, line in enumerate(path.read_text(encoding="utf-8") + .splitlines(), start=1): + if words.search(line): + hits.append(f"{path.relative_to(ROOT)}:{n}: {line.strip()}") + assert not hits, "\n".join(hits) + + +def main() -> int: + failures = 0 + for fn in CASES: + name = fn.__name__.replace("_", " ") + try: + fn() + print(f" ok {name}") + except AssertionError as exc: + print(f" FAIL {name}: {exc}") + failures += 1 + except Exception as exc: + print(f" ERROR {name}: {type(exc).__name__}: {exc}") + failures += 1 + finally: + _restore() + print(f"\n{len(CASES) - failures}/{len(CASES)} passed") + return 1 if failures else 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tests/test_claim_file.py b/tests/test_claim_file.py new file mode 100644 index 0000000..ae0b5aa --- /dev/null +++ b/tests/test_claim_file.py @@ -0,0 +1,189 @@ +""" +Claim-id extraction from the exported report. + + python tests\\test_claim_file.py + +Built xlsx files, not fixtures checked into the repo: the real report is patient +data and must not be committed, but the *shape* of it can be reproduced exactly. +Each case below is written as a genuine xlsx — zip, worksheet XML, inline +`t="str"` cells — matching what eCW actually produced on 2026-09-07: + + + CLAIMS# ... + +The cases that matter, and why each one is here: + + * the real header is **`CLAIMS#`**, not `CLAIM #` as the on-screen grid calls + it — reading the column name off the screen would have found nothing; + * the claim id is in column **C** with a blank column A before it, so + anything that reads positionally from the left is wrong; + * a **sparse row** (eCW omitting an empty cell entirely) must not shift the + columns after it — the one failure mode of parsing cells in order; + * shared strings, which this build does not use but another may; + * an HTML table served with an .xls name, which other eCW builds send. +""" + +import io +import sys +import zipfile +import pathlib +import tempfile + +sys.path.insert(0, str(pathlib.Path(__file__).resolve().parent.parent / "src")) + +from ecw_bot import claim_file # noqa: E402 +from ecw_bot.core import spreadsheet # noqa: E402 + +TMP = pathlib.Path(tempfile.mkdtemp()) +NS = 'xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main"' + + +def xlsx(rows, shared=None, name="t.xlsx") -> pathlib.Path: + """ + Write a real xlsx. `rows` is a list of {column letter: cell} dicts. + + A cell is either a plain string (inline `t="str"`, what eCW sends) or an + int, meaning an index into `shared` (`t="s"`). + """ + body = [] + for r, cells in enumerate(rows, start=1): + parts = [] + for letter, value in cells.items(): + if isinstance(value, int): + parts.append(f'{value}') + else: + parts.append(f'{value}') + body.append(f'{"".join(parts)}') + sheet = (f'' + f'{"".join(body)}') + + path = TMP / name + with zipfile.ZipFile(path, "w") as z: + z.writestr("xl/worksheets/sheet1.xml", sheet) + if shared is not None: + items = "".join(f"{s}" for s in shared) + z.writestr("xl/sharedStrings.xml", + f'{items}') + return path + + +def html(name="t.xls") -> pathlib.Path: + path = TMP / name + path.write_text( + "" + "" + "" + "" + "
CLAIMTYPECLAIMS#PATIENT
U9104000Sample, Pat, Q
U9101000Example, Robin
", encoding="utf-8") + return path + + +# Column A is blank in eCW's report; the claim id is in C. +REAL = [ + {"A": "", "B": "CLAIMTYPE", "C": "CLAIMS#", "D": "SERVICE DATE"}, + {"A": "", "B": "U", "C": "9104000", "D": "06/29/2026"}, + {"A": "", "B": "U", "C": "9101000", "D": "07/01/2026"}, + {"A": "", "B": "U", "C": "9105000", "D": "07/02/2026"}, +] + + +def case_real_shape(): + info = claim_file.claim_ids(xlsx(REAL, name="real.xlsx")) + assert info["header"] == "CLAIMS#", info["header"] + assert info["column"] == 3, info["column"] + assert info["ids"] == ["9104000", "9101000", "9105000"], info["ids"] + + +def case_sparse_row(): + """eCW omitting an empty cell must not shift the columns after it.""" + rows = list(REAL) + [{"B": "U", "C": "9102000"}] # no A at all + info = claim_file.claim_ids(xlsx(rows, name="sparse.xlsx")) + assert info["ids"][-1] == "9102000", info["ids"][-1] + assert info["column"] == 3, info["column"] + + +def case_duplicates_and_blanks(): + rows = list(REAL) + [ + {"A": "", "B": "U", "C": "9104000"}, # duplicate of row 2 + {"A": "", "B": "U", "C": ""}, # blank claim id + ] + info = claim_file.claim_ids(xlsx(rows, name="dupes.xlsx")) + assert info["duplicates"] == ["9104000"], info["duplicates"] + assert info["blank_rows"] == 1, info["blank_rows"] + assert len(info["ids"]) == 3, info["ids"] + # Reported, not folded into the total — a count alone hides both. + text = claim_file.summary(info) + assert "1 duplicate(s) skipped" in text, text + assert "1 blank row(s) skipped" in text, text + + +def case_shared_strings(): + """This build inlines every string, but another may use the table.""" + shared = ["", "CLAIMTYPE", "CLAIMS#", "U"] + rows = [{"A": 0, "B": 1, "C": 2}, + {"A": 0, "B": 3, "C": "9104000"}, + {"A": 0, "B": 3, "C": "9101000"}] + info = claim_file.claim_ids(xlsx(rows, shared=shared, name="shared.xlsx")) + assert info["header"] == "CLAIMS#", info["header"] + assert info["ids"] == ["9104000", "9101000"], info["ids"] + + +def case_html_export(): + """Other eCW builds serve an HTML table with an .xls name.""" + info = claim_file.claim_ids(html()) + assert info["header"] == "CLAIMS#", info["header"] + assert info["ids"] == ["9104000", "9101000"], info["ids"] + + +def case_no_header_falls_back_to_shape(): + rows = [{"A": "", "B": "U", "C": str(9104000 + i)} for i in range(8)] + info = claim_file.claim_ids(xlsx(rows, name="noheader.xlsx")) + assert len(info["ids"]) == 8, info["ids"] + assert info["column"] == 3, info["column"] + + +def case_no_claim_column_is_an_error(): + rows = [{"A": "PATIENT", "B": "PAYER"}, + {"A": "Sample, Pat", "B": "Medicaid"}] + try: + claim_file.claim_ids(xlsx(rows, name="nocol.xlsx")) + except ValueError as exc: + assert "No claim-id column" in str(exc), exc + return + raise AssertionError("expected a ValueError naming the missing column") + + +def case_summary_does_not_dump_everything(): + """541 ids in a MessageBox would be a wall of digits. Ends only.""" + rows = [{"A": "", "C": "CLAIMS#"}] + \ + [{"A": "", "C": str(5500000 + i)} for i in range(541)] + info = claim_file.claim_ids(xlsx(rows, name="big.xlsx")) + assert len(info["ids"]) == 541 + text = claim_file.summary(info) + assert text.count("55000") < 30, "summary is dumping the whole list" + assert "541" in text, text + assert info["ids"][0] in text and info["ids"][-1] in text, text + + +CASES = [v for k, v in sorted(globals().items()) if k.startswith("case_")] + + +def main() -> int: + failures = 0 + for fn in CASES: + name = fn.__name__[5:].replace("_", " ") + try: + fn() + print(f" ok {name}") + except AssertionError as exc: + print(f" FAIL {name}: {exc}") + failures += 1 + except Exception as exc: + print(f" ERROR {name}: {type(exc).__name__}: {exc}") + failures += 1 + print(f"\n{len(CASES) - failures}/{len(CASES)} passed") + return 1 if failures else 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tests/test_claim_form.py b/tests/test_claim_form.py new file mode 100644 index 0000000..e9883fe --- /dev/null +++ b/tests/test_claim_form.py @@ -0,0 +1,251 @@ +r""" +UB Claim form extraction, against a real browser. + + python tests\test_claim_form.py + +This one launches a **local headless chromium** and loads +`tests\fixtures\ub_claim.html`. It never touches eCW: no proxy, no login, no +2FA mail, no attempt against the guard. So unlike a live run it costs nothing +and can be run as often as you like — which matters, because the alternative +way to test this code is a three-minute login and one of four hourly attempts. + +What it is really checking is the thing most likely to break silently: + + * **No selector depends on eCW's per-open timestamp.** The fixture uses + `9999999999999` where the captured DOM had `1788790287089`. A selector + with the number baked in matches nothing here, and the assertions fail. + * **Values are read from inputs, not text.** The ICD code, every CPT column, + the dates and the claim status all live in inputs or selects. Reading + `textContent` returns blanks for them and the run looks successful. + * **eCW's blank padding rows are dropped.** Every grid is padded with empty + `ng-repeat` rows to hold a fixed height, plus a `*` entry row. Counting + those would report five CPT lines on a claim that has two. +""" + +import sys +import pathlib + +ROOT = pathlib.Path(__file__).resolve().parent.parent +sys.path.insert(0, str(ROOT / "src")) + +FIXTURE = pathlib.Path(__file__).resolve().parent / "fixtures" / "ub_claim.html" + +from ecw_bot.pages import claim_form # noqa: E402 +from ecw_bot.selectors import claim_form as sel # noqa: E402 + +CHECKS = [] + + +def check(fn): + CHECKS.append(fn) + return fn + + +@check +def claim_and_patient(page, data): + assert data["claim_no"] == "9104000", data["claim_no"] + p = data["patient"] + assert p["name"] == "Sample, Pat, Q", p["name"] + assert p["dob"] == "05/11/1979", p["dob"] + assert p["account_no"] == "900123", p["account_no"] + assert p["phone"] == "555-010-0100", p["phone"] + # The visible text is ellipsised by eCW's own filter; title= has it whole. + assert p["address"] == "100 SAMPLE ST, , CO 80000-0000", p["address"] + + +@check +def header_reads_inputs_and_the_select(page, data): + h = data["header"] + assert h["claim_date"] == "06/29/2026", h["claim_date"] + assert h["bill_type"] == "131", h["bill_type"] + assert h["facility"] == "MWC:Brighton Womens Clinic", h["facility"] + assert h["attending"] == "Clinician, Casey M", h["attending"] + # Both halves of the status: the code is what eCW submits, the label is what + # a person recognises, and only checking one of them has burned us before. + assert h["claim_status_code"] == "108ME", h["claim_status_code"] + assert h["claim_status_label"] == "Medcor Ready", h["claim_status_label"] + + +@check +def icd_code_comes_from_the_input(page, data): + icd = data["icd"] + assert len(icd) == 1, f"expected 1 ICD row, got {len(icd)}: {icd}" + row = icd[0] + assert row["Code"] == "R69", row + assert row["Name"] == "Illness, unspecified", row + + +@check +def cpt_grid_is_keyed_by_input_id(page, data): + cpt = data["cpt"] + # Two real rows. The bottom "*" entry row has no CPTROW_ id and must not + # become a third. + assert len(cpt) == 2, f"expected 2 CPT rows, got {len(cpt)}: {cpt}" + a, b = cpt + assert a["code"] == "H0023" and a["mod1"] == "GT", a + assert a["pos"] == "11" and a["units"] == "1" and a["billed_fee"] == "74.00", a + assert a["sdos"] == "06/29/2026" and a["edos"] == "06/29/2026", a + assert a["icd1"] == "1" and a["icd2"] == "", a + assert "Behavioral Health Outreach" in a["description"], a + assert b["code"] == "90791" and b["units"] == "2", b + assert b["billed_fee"] == "150.00", b + + +@check +def revenue_and_totals(page, data): + rev = data["revenue"] + assert len(rev) == 1, f"expected 1 revenue row, got {len(rev)}: {rev}" + row = rev[0] + assert row["42 RV CC"] == "0900", row + assert row["43 Description"] == "Psychological Services", row + assert row["44 HCPCS/Rates"] == "H0023 GT", row + assert row["47 Total Charges"] == "74.00", row + + t = data["totals"] + assert t["total_charges"] == "74.00", t + assert t["charges"] == "224.00" and t["balance"] == "224.00", t + assert t["pt_balance"] == "0.00", t + + +@check +def insurance_and_payments(page, data): + ins = data["insurance"] + assert len(ins) == 1, f"expected 1 insurance row, got {len(ins)}: {ins}" + assert ins[0]["Name"] == "BH COLORADO ACCESS", ins[0] + assert ins[0]["Subscriber No."] == "J000000", ins[0] + assert ins[0]["State"] == "CO", ins[0] + + pay = data["payments"] + assert len(pay) == 1, f"expected 1 payment row, got {len(pay)}: {pay}" + assert pay[0]["Id"] == "2000001", pay[0] + assert pay[0]["Date"] == "06/29/2026", pay[0] + assert pay[0]["From"] == "Patient", pay[0] + + +@check +def notes_and_empty_error_table(page, data): + assert data["notes"] == "Awaiting coding review.", data["notes"] + # "Error Logs ()" on the live claim — an empty tbody must be [], not a row + # of blanks. + assert data["errors"] == [], data["errors"] + + +@check +def cpt_hcpcs_tab_opens(page, data): + assert claim_form.open_cpt_hcpcs_tab(page) is True + assert page.locator(sel.SEL_CPT_GRID).first.is_visible() + + +@check +def progress_notes_is_discovered_not_assumed(page, data): + """ + The whole chain: click, find the note wherever it landed, read it, close. + + The fixture serves the note in an iframe, as eCW does. `opened == "frame"` + is the assertion that matters - an earlier version of this code read the + hosting modal's innerText and would have come back with nothing, because + iframe content is not part of it. + """ + pn = claim_form.open_progress_notes(page) + assert pn["opened"] == "frame", pn + # Structure, not a text blob: eCW's own prisma-section labels. + assert pn["assessments"][0]["code"] == "R69", pn["assessments"] + assert pn["procedure_codes"][0]["code"] == "H0023", pn["procedure_codes"] + assert pn["procedure_codes"][0]["modifiers"] == ["GT"], pn["procedure_codes"] + assert pn["signoff_status"] == "Completed", pn["signoff_status"] + assert pn["chart_locked"] is True, pn["chart_locked"] + # And the viewer is gone, so the Cancel button underneath is reachable. + assert page.locator(sel.SEL_PN_CLOSE).count() == 0 or page.locator(".modal#progressNoteViewerDiv9999999999999").first .is_visible() is False + + +@check +def summary_is_readable(page, data): + data = dict(data) + data["progress_notes"] = {"opened": "modal", "text": "x" * 120} + text = claim_form.summary(data) + for want in ("9104000", "Sample, Pat, Q", "Medcor Ready", "H0023", + "90791", "224.00", "Prog. Notes"): + assert want in text, f"{want!r} missing from summary:\n{text}" + + +@check +def no_selector_carries_the_timestamp(page, data): + """ + No selector VALUE in selectors/claim_form.py contains a long digit run. + + Parsed with ast rather than scanned as text: the module docstring quotes the + real timestamped ids on purpose, to explain the hazard, and a text scan + flagged those quotes as the bug they warn about. Only actual assignments + are checked. + + The fixture stamps 9999999999999 where the captured DOM had 1788790287089, + so a selector with the number baked in also fails the assertions above — + this check is what names the cause instead of leaving eleven empty fields. + """ + import ast + import re + src = (ROOT / "src" / "ecw_bot" / "selectors" / "claim_form.py").read_text( + encoding="utf-8") + for node in ast.parse(src).body: + if not isinstance(node, ast.Assign): + continue + for const in ast.walk(node.value): + if isinstance(const, ast.Constant) and isinstance(const.value, str): + assert not re.search(r"\d{6,}", const.value), ( + f"timestamped selector: " + f"{[t.id for t in node.targets if isinstance(t, ast.Name)]}" + f" = {const.value!r}") + + +def _run() -> int: + if not FIXTURE.exists(): + print(f" FAIL fixture missing: {FIXTURE}") + return 1 + try: + from playwright.sync_api import sync_playwright + except ImportError: + print(" SKIP playwright is not installed") + return 0 + + failures = 0 + with sync_playwright() as pw: + browser = pw.chromium.launch(headless=True) # local only, no proxy + page = browser.new_page() + page.goto(FIXTURE.as_uri()) + data = claim_form.extract(page) + + for fn in CHECKS: + name = fn.__name__.replace("_", " ") + try: + fn(page, data) + print(f" ok {name}") + except AssertionError as exc: + print(f" FAIL {name}: {exc}") + failures += 1 + except Exception as exc: + print(f" ERROR {name}: {type(exc).__name__}: {exc}") + failures += 1 + browser.close() + + print(f"\n{len(CHECKS) - failures}/{len(CHECKS)} passed") + return 1 if failures else 0 + + +def main() -> int: + """ + shot() writes into the operator's dated Screenshots\ folder; a test + run must not, so the cases run with it pointed at a temp folder. + """ + import tempfile + from ecw_bot import settings as _cfg + orig = _cfg.SCREENSHOTS + with tempfile.TemporaryDirectory() as tmp: + _cfg.SCREENSHOTS = pathlib.Path(tmp) + try: + return _run() + finally: + _cfg.SCREENSHOTS = orig + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tests/test_claim_loaded.py b/tests/test_claim_loaded.py new file mode 100644 index 0000000..1636da4 --- /dev/null +++ b/tests/test_claim_loaded.py @@ -0,0 +1,690 @@ +r""" +Waiting for the HCFA and Dental forms to finish loading (review item D7). + + python tests\test_claim_loaded.py + +Seen live (run_20260930_163006): `open_claim()` returned the moment the HCFA +form's Cancel was visible, Angular had not filled the form yet, `extract()` +read 0 CPT rows, Prog. Notes read "disabled" — and the claim went to the +summary as Pass. `wait_claim_loaded()` waits for the claim number and for the +service-line codes to be filled and steady; a timeout is a reason string on +the claim's data, not an exception. + +Local headless chromium against `tests\fixtures\hcfa_claim.html` and +`dental_claim.html`, whose opt-in `?load=` / `?pn=` query strings simulate +Angular filling the form late (or never, or with the wrong claim). No eCW. +Screenshots go to a temp folder, never the real Screenshots\. +""" + +import io +import sys +import time +import pathlib +import inspect +import tempfile +import contextlib + +ROOT = pathlib.Path(__file__).resolve().parent.parent +sys.path.insert(0, str(ROOT / "src")) + +FIXTURES = pathlib.Path(__file__).resolve().parent / "fixtures" +HCFA = FIXTURES / "hcfa_claim.html" +DENTAL = FIXTURES / "dental_claim.html" +UB = FIXTURES / "ub_claim.html" + +from ecw_bot import settings as cfg # noqa: E402 +from ecw_bot.pages import claim_form # noqa: E402 +from ecw_bot.flows import export_medcor_ready as flow # noqa: E402 + +CASES = [] + + +def case(fn): + CASES.append(fn) + return fn + + +def _go(page, fixture, query=""): + page.goto(fixture.as_uri() + (f"?{query}" if query else "")) + + +def _timed(fn, *args, **kwargs): + """(result, seconds, captured stdout).""" + buf = io.StringIO() + started = time.monotonic() + with contextlib.redirect_stdout(buf): + out = fn(*args, **kwargs) + return out, time.monotonic() - started, buf.getvalue() + + +# ── HCFA ────────────────────────────────────────────────────────────────────── + +@case +def hcfa_already_filled_costs_about_half_a_second(page): + """The normal claim: the only cost is the 500 ms stability window.""" + _go(page, HCFA) + out, took, log = _timed(claim_form.wait_claim_loaded, page, "9103000") + assert out is None, out + assert took < 1.5, f"took {took:.1f}s on a form that was already loaded" + assert "HCFA form loaded" in log and "3 CPT rows" in log, log + + +@case +def hcfa_filled_late_is_waited_for(page): + _go(page, HCFA, "load=delayed") + # Proves the fixture: right now the form is blank, the way eCW serves it. + assert claim_form.extract(page)["cpt"] == [], "fixture is not blank" + out, took, log = _timed(claim_form.wait_claim_loaded, page, "9103000") + assert out is None, out + assert took >= 0.7, f"returned after {took:.2f}s — before the form filled" + assert len(claim_form.extract(page)["cpt"]) == 3, "read short after wait" + + +@case +def hcfa_rows_filled_one_at_a_time_are_waited_for_until_they_hold_still(page): + """ + The stability rule (D7): the same non-zero count held for 500 ms. Angular + fills the rows one digest at a time — here row 1 at 0.8 s, row 2 at + 1.1 s, row 3 at 1.4 s. A wait that took the first non-zero count would + return at about 0.8-1.0 s and extract() would read 1 or 2 rows; only the + 500 ms window keeps it until 1.4 s + 0.5 s. + """ + _go(page, HCFA, "load=staggered") + out, took, log = _timed(claim_form.wait_claim_loaded, page, "9103000") + assert out is None, out + assert took >= 1.9, f"returned after {took:.2f}s — rows still filling" + assert len(claim_form.extract(page)["cpt"]) == 3, "read short after wait" + assert "3 CPT rows" in log, log + + +@case +def hcfa_hidden_leftover_form_of_another_claim_is_not_read(page): + """ + eCW leaves closed forms in the DOM. A hidden copy of claim 5000002's form + sits ahead of this one, and this form's claim number is still hidden + while Angular fills it: the old document-wide read fell back to the + hidden copy and raised a false "wrong claim" — the claim came back + unread. Only the window holding the visible Cancel is read now. + """ + _go(page, HCFA, "load=delayed&stale=other") + out, took, log = _timed(claim_form.wait_claim_loaded, page, "9103000") + assert out is None, out + assert took >= 0.7, f"returned after {took:.2f}s — before the form filled" + assert "3 CPT rows" in log, log + + +@case +def hcfa_hidden_leftover_rows_do_not_count_as_loaded(page): + """The same hidden leftover, showing this claim's number and one filled + row, in front of a form that never fills: its row must not make the + blank form count as loaded (the old read returned None here).""" + _go(page, HCFA, "load=never&stale=same") + out, _, _ = _timed(claim_form.wait_claim_loaded, page, "9103000", + timeout_ms=1_000) + assert out == ("HCFA form did not finish loading in 1s " + "(claim number never filled, 0 CPT rows)"), out + + +@case +def hcfa_never_filled_returns_a_reason_not_an_exception(page): + _go(page, HCFA, "load=never") + out, took, log = _timed(claim_form.wait_claim_loaded, page, "9103000", + timeout_ms=2_000) + assert out == ("HCFA form did not finish loading in 2s " + "(claim number never filled, 0 CPT rows)"), out + assert 1.8 <= took < 5.0, f"took {took:.1f}s" + assert out in log, log + + +@case +def hcfa_wrong_claim_raises(page): + _go(page, HCFA, "load=wrong") + try: + _timed(claim_form.wait_claim_loaded, page, "9103000") + except RuntimeError as exc: + assert "5000001" in str(exc) and "wrong claim" in str(exc), exc + else: + raise AssertionError("a form showing another claim did not raise") + + +@case +def hcfa_without_a_claim_number_field_still_waits_on_cpt_rows(page): + _go(page, HCFA, "load=noclaimno") + out, took, log = _timed(claim_form.wait_claim_loaded, page, "9103000") + assert out is None, out + assert took >= 0.7, f"returned after {took:.2f}s — rows were still blank" + assert log.count("identity not checked") == 1, log + + +# ── Dental ──────────────────────────────────────────────────────────────────── + +@case +def dental_filled_late_is_waited_for(page): + _go(page, DENTAL, "load=delayed") + out, took, log = _timed(claim_form.wait_claim_loaded, page, "5000000") + assert out is None, out + assert took >= 0.7, f"returned after {took:.2f}s — before the form filled" + assert "Dental form loaded" in log and "2 service lines)" in log, log + + +@case +def dental_cancel_inside_a_dentalclaim_id_wrapper_still_reads_the_form(page): + """ + The window is found by walking up from the visible Cancel to the + nearest SEL_DENTAL_WINDOW match. With an id-prefix-only selector, a + `dentalClaimFooter…` wrapper around Cancel was that nearest match: no + claim number and no service lines inside it, so the form read as blank + and every Dental claim waited out the timeout as not evaluated. The + window is the `.modal` itself now. + """ + _go(page, DENTAL, "load=delayed&footer=wrapped") + assert page.locator("#dentalClaimFooter7777777777777 " + "#cancelDentalClaimButton").count() == 1, "fixture did not wrap Cancel" + out, took, log = _timed(claim_form.wait_claim_loaded, page, "5000000", + timeout_ms=3_000) + assert out is None, out + assert took >= 0.7, f"returned after {took:.2f}s — before the form filled" + assert "identity not checked" not in log, log + assert "Dental form loaded" in log and "2 service lines)" in log, log + + +@case +def dental_never_filled_returns_a_reason(page): + _go(page, DENTAL, "load=never") + out, _, _ = _timed(claim_form.wait_claim_loaded, page, "5000000", + timeout_ms=1_000) + assert out == ("Dental form did not finish loading in 1s " + "(claim number never filled, 0 service lines)"), out + + +@case +def dental_wrong_claim_raises(page): + _go(page, DENTAL, "load=wrong") + try: + _timed(claim_form.wait_claim_loaded, page, "5000000") + except RuntimeError as exc: + assert "5000001" in str(exc), exc + else: + raise AssertionError("a Dental form showing another claim did not raise") + + +@case +def ub_form_is_left_to_open_claims_own_wait(page): + """No-op on UB: open_claim() already waits for its claim number.""" + _go(page, UB) + out, took, _ = _timed(claim_form.wait_claim_loaded, page, "anything") + assert out is None and took < 1.0, (out, took) + + +# ── Elements that render late are waited for (final review, 2026-10-05) ────── +# Both waits were `visible(timeout=10_000)` before P8 and answered instantly; +# these fail if either is turned back into visible(). + +@case +def a_cpt_grid_that_renders_late_is_waited_for(page): + """open_cpt_hcpcs_tab gives the grid 10s after the tab click.""" + _go(page, UB) + page.evaluate("""() => { + const g = document.querySelector('#CPTCodesDiv'); + g.style.display = 'none'; + setTimeout(() => { g.style.display = ''; }, 1800); + }""") + out, took, log = _timed(claim_form.open_cpt_hcpcs_tab, page) + assert out is True, log + assert took >= 1.5, f"returned after {took:.1f}s — before the grid showed" + + +@case +def a_prog_notes_button_that_renders_late_is_not_absent(page): + """open_progress_notes gives the button 10s to appear.""" + _go(page, HCFA) + page.evaluate("""() => { + const b = document.querySelector('[ng-click="viewProgNote()"]'); + b.style.display = 'none'; + setTimeout(() => { b.style.display = ''; }, 1500); + }""") + note, _, log = _timed(claim_form.open_progress_notes, page) + assert note.get("opened") == "frame", (note, log) + + +# ── Prog. Notes "disabled" is re-checked ────────────────────────────────────── + +@case +def prog_notes_enabled_late_is_not_reported_disabled(page): + """Angular enables the button only after load; read too early, a claim + with a note was reported as having none.""" + _go(page, HCFA, "pn=late") + assert page.locator('[ng-click="viewProgNote()"]').is_disabled() + note, _, _ = _timed(claim_form.open_progress_notes, page) + assert note.get("opened") == "frame", note + + +@case +def prog_notes_disabled_for_good_is_reported_after_about_5s(page): + _go(page, HCFA, "pn=off") + note, took, log = _timed(claim_form.open_progress_notes, page) + assert note == {"opened": "disabled"}, note + assert 4.5 <= took < 9.0, f"took {took:.1f}s" + + +# ── The flow keeps the reason on the claim's data ───────────────────────────── + +def _flow_read(dental: bool, not_loaded, dental_result, apply=False, + calls=None, notes=False, dental_checks_on=True, + results=(), save=False): + """ + Run the real _read_claim_form with every page step stubbed. `calls`, when + given, collects ("run_all" | "save_claim" | "dental" | "wait" | + "notes", ...) as they happen. `notes` turns OPEN_PROGRESS_NOTES on; + `dental_checks_on` is CHECK_HIGH_RISK (CHECK_SLIDING_FEE stays off). + """ + calls = [] if calls is None else calls + + def wait(page, cid): + calls.append("wait") + return not_loaded + + def run_all(page, cid, data, before_apply=None, **kw): + calls.append("run_all") + return list(results) + + def dental_checks(page, cid): + calls.append("dental") + return dict(dental_result) + + stubs = { + "open_claim": lambda page, cid: True, + "wait_claim_loaded": wait, + "open_progress_notes": lambda page: calls.append("notes") or { + "opened": "frame", "sections": {"Procedure Codes": "x"}}, + "is_dental_claim_form": lambda page: dental, + "open_cpt_hcpcs_tab": lambda page: None, + "extract": lambda page: {"cpt": [{"code": "99213"}], "icd": [], + "revenue": []}, + "save": lambda data: pathlib.Path("unused.json"), + "close_claim": lambda page: True, + "save_claim": lambda page: calls.append("save_claim") or True, + } + orig_cf = {k: getattr(claim_form, k) for k in stubs} + orig_cfg = (cfg.OPEN_CLAIM_FORM, cfg.OPEN_PROGRESS_NOTES, + cfg.APPLY_CORRECTIONS, cfg.CHECK_HIGH_RISK, + cfg.CHECK_SLIDING_FEE, cfg.SAVE_CORRECTIONS, + cfg.REVIEW_BEFORE_CLOSE) + orig_box = (flow.msgbox.ok_cancel, flow.msgbox.info_box) + orig_dental, orig_run_all = flow._dental_checks, flow.conditions.run_all + for k, v in stubs.items(): + setattr(claim_form, k, v) + cfg.OPEN_CLAIM_FORM, cfg.OPEN_PROGRESS_NOTES = True, notes + cfg.APPLY_CORRECTIONS = apply + # Not read from the operator's .env: a Dental case needs a check on to + # reach _dental_checks at all (final review, 2026-10-05). + cfg.CHECK_HIGH_RISK, cfg.CHECK_SLIDING_FEE = dental_checks_on, False + cfg.SAVE_CORRECTIONS, cfg.REVIEW_BEFORE_CLOSE = save, False + # Save, if ever offered, is answered OK — so "not saved" is the code's + # doing, not the operator's. + flow.msgbox.ok_cancel = lambda *a, **k: calls.append("save_box") or True + flow.msgbox.info_box = lambda *a, **k: None + flow._dental_checks = dental_checks + flow.conditions.run_all = run_all + try: + with contextlib.redirect_stdout(io.StringIO()): + return flow._read_claim_form(None, "9103000") + finally: + for k, v in orig_cf.items(): + setattr(claim_form, k, v) + (cfg.OPEN_CLAIM_FORM, cfg.OPEN_PROGRESS_NOTES, + cfg.APPLY_CORRECTIONS, cfg.CHECK_HIGH_RISK, + cfg.CHECK_SLIDING_FEE, cfg.SAVE_CORRECTIONS, + cfg.REVIEW_BEFORE_CLOSE) = orig_cfg + flow.msgbox.ok_cancel, flow.msgbox.info_box = orig_box + flow._dental_checks = orig_dental + flow.conditions.run_all = orig_run_all + + +@case +def flow_marks_an_hcfa_claim_not_evaluated_and_still_reads_it(): + why = "HCFA form did not finish loading in 20s (0 CPT rows)" + data = _flow_read(False, why, {}) + assert data.get("_not_evaluated") == why, data + assert data.get("cpt"), "the claim was not read after the timeout" + + +@case +def flow_leaves_a_loaded_claim_unmarked(): + data = _flow_read(False, None, {}) + assert "_not_evaluated" not in data, data + + +@case +def flow_marks_the_dental_result_not_evaluated(): + why = "Dental form did not finish loading in 20s (0 service lines)" + data = _flow_read(True, why, {"claim_no": "9103000", + "high_risk": {"high_risk": False}}) + assert data.get("_not_evaluated") == why, data + + +@case +def flow_runs_no_conditions_on_a_form_that_did_not_finish_loading(): + """ + APPLY_CORRECTIONS=1: a half-loaded form is read and closed, never + corrected — run_all() would read the same blank rows, and with + SAVE_CORRECTIONS=1 an approved OK would save the half-loaded form back + to live eCW. Nothing ran, so no `_conditions` either. + """ + calls = [] + why = "HCFA form did not finish loading in 20s (0 CPT rows)" + data = _flow_read(False, why, {}, apply=True, calls=calls) + assert "run_all" not in calls and "save_claim" not in calls, calls + assert data.get("_not_evaluated") == why, data + assert "_conditions" not in data, data + + +@case +def flow_still_runs_conditions_on_a_loaded_form(): + """The control for the case above: the run_all stub is reachable.""" + calls = [] + data = _flow_read(False, None, {}, apply=True, calls=calls) + # "wait": _flow_read records the load wait too since the final review. + assert calls == ["wait", "run_all"], calls + # Unit D (2026-10-05): run_all's report rides along too, so the summary + # can tell "raised / declined" from "clean". + # `changes` (2026-10-08): what each condition changed, for the results DB. + # `detected` / `changed` (2026-10-12): the same, split for the DB. + assert data.get("_conditions") == {"fired": [], "detected": [], + "changed": [], "saved": False, + "failed": [], "review": [], + "comments": {}, "changes": {}}, data + + +@case +def flow_tells_the_dental_box_the_form_did_not_finish_loading(): + """ + Updated for review item D9 (2026-10-05): `_dental_checks` no longer + shows a box, so the reason is no longer passed into it. It reaches the + claim's one box (`_claim_box`, after close) on the data instead, and + that box's Dental part leads with NOT_LOADED_NOTE. + """ + calls = [] + why = "Dental form did not finish loading in 20s (0 service lines)" + data = _flow_read(True, why, {"claim_no": "9103000", + "high_risk": {"high_risk": False, + "match": ""}}, + calls=calls) + assert calls == ["wait", "dental"], calls + assert data["_not_evaluated"] == why, data + _, body = flow._claim_box("Claim 1/1", "9103000", data, None) + detail = body.split(flow.RULE + "\n", 1)[1].splitlines() + assert detail[0] == claim_form.NOT_LOADED_NOTE, body + assert detail[1] == f"({why})", body + + +@case +def claim_summary_leads_with_the_not_loaded_line(): + """The SHOW_EACH_CLAIM box must not show a confident "CPT 0" read off a + form the bot logged as half-loaded.""" + why = "HCFA form did not finish loading in 20s (0 CPT rows)" + text = claim_form.summary({"claim_no": "9103000", "cpt": [], + "_not_evaluated": why}) + first = text.splitlines() + assert first[0] == claim_form.NOT_LOADED_NOTE, text + assert first[1] == f"({why})", text + plain = claim_form.summary({"claim_no": "9103000", "cpt": []}) + assert claim_form.NOT_LOADED_NOTE not in plain, plain + assert plain.splitlines()[0].startswith("Claim No"), plain + + +@case +def flow_reads_a_dental_claim_even_with_no_dental_check_on(): + """ + Operator, 2026-10-12: a Dental claim runs every applicable condition, + not only high risk / sliding fee — so it is waited for and read like + any claim, whatever CHECK_HIGH_RISK / CHECK_SLIDING_FEE say. (It used + to come back {} and blank in the summary.) + """ + calls = [] + data = _flow_read(True, None, {}, calls=calls, dental_checks_on=False, + apply=True) + assert calls == ["wait", "run_all"], calls + assert data.get("screen") == "dental", data + assert data.get("cpt"), data + assert data.get("_conditions") is not None, data + + +@case +def flow_runs_the_conditions_on_a_dental_claim_after_its_own_checks(): + calls = [] + data = _flow_read(True, None, {"claim_no": "9103000", + "high_risk": {"high_risk": True, + "match": "x"}}, + calls=calls, apply=True, notes=True) + assert calls == ["wait", "dental", "notes", "run_all"], calls + assert data["high_risk"]["high_risk"] is True, "Dental result kept" + assert data.get("screen") == "dental" and data.get("cpt"), data + + +@case +def flow_never_saves_a_dental_claim_even_with_save_on(): + """Operator, 2026-10-12: Dental row writes are not live-verified yet.""" + from ecw_bot.conditions.base import ConditionResult + fired = ConditionResult(condition_name="dummy_codes", changed=True, + changes=[{"row": 1, "code": "DCONS", + "unchecked": True}]) + calls = [] + data = _flow_read(True, None, {}, calls=calls, apply=True, save=True, + results=[fired], dental_checks_on=False) + assert "save_box" not in calls and "save_claim" not in calls, calls + assert data["_conditions"]["saved"] is False, data + + # The gate itself, and its control: the same result on a read that is + # not Dental IS offered for Save. + asked = [] + orig = (cfg.SAVE_CORRECTIONS, flow.msgbox.ok_cancel) + cfg.SAVE_CORRECTIONS = True + flow.msgbox.ok_cancel = lambda *a, **k: asked.append(1) or False + try: + with contextlib.redirect_stdout(io.StringIO()): + flow._confirm_and_save(None, "1", [fired], {}, + {"screen": "dental"}) + assert asked == [], "a Dental claim was offered for Save" + flow._confirm_and_save(None, "1", [fired], {}, None) + finally: + cfg.SAVE_CORRECTIONS, flow.msgbox.ok_cancel = orig + assert asked == [1], "the control was not offered" + + +@case +def flow_still_waits_on_a_dental_form_when_a_check_is_on(): + calls = [] + _flow_read(True, None, {"claim_no": "9103000", + "high_risk": {"high_risk": False}}, calls=calls) + assert calls == ["wait", "dental"], calls + + +@case +def flow_does_not_open_the_note_of_a_form_that_did_not_finish_loading(): + """ + Final review, 2026-10-05: the conditions are skipped on such a claim + and it is Review whatever the note says, so the note's up to ~45s + (absent 10s, disabled 5s, nothing 20s + close) is not spent. The note + is recorded as skipped, and the summary says it was not read. + """ + from ecw_bot.core import charge_summary as cs + calls = [] + why = "HCFA form did not finish loading in 20s (0 CPT rows)" + data = _flow_read(False, why, {}, apply=True, calls=calls, notes=True) + assert "notes" not in calls, calls + assert data["progress_notes"]["opened"] == "skipped", data + reasons = cs.review_reasons(data) + assert reasons[0] == why, reasons + assert "Progress note not read (skipped) — telehealth not checked" in \ + reasons, reasons + + +@case +def flow_opens_the_note_of_a_loaded_form(): + """The control: the stub is reachable and the note is read.""" + calls = [] + data = _flow_read(False, None, {}, calls=calls, notes=True) + assert "notes" in calls, calls + assert data["progress_notes"]["opened"] == "frame", data + + +# ── The claim number is compared as a number (final review, 2026-10-05) ────── + +@case +def same_claim_no_ignores_formatting_but_not_a_different_number(): + same = claim_form.same_claim_no + assert same("5000000", "5000000") + assert same("05000000", "5000000") + assert same("\u00a05000\u00a0000 ", "5000000") + assert same("Claim # 5000000", "5000000") + assert not same("5000001", "5000000") + assert not same("50000001", "5000000") + assert not same("", "5000000") + assert same("abc", "abc") and not same("abc", "abd") + + +@case +def dental_claim_number_written_differently_is_not_a_wrong_claim(page): + """ + The Dental claim-number span is not yet live-verified: a leading zero + or an inner non-breaking space must not raise "wrong claim" on every + Dental claim. It is logged once, so the live format is on record. + """ + _go(page, DENTAL) + page.evaluate("""() => { + document.querySelector('[ng-bind="ClaimData.claimNo"]') + .textContent = '0500\u00a00000'; + }""") + out, _, log = _timed(claim_form.wait_claim_loaded, page, "5000000", + timeout_ms=3_000) + assert out is None, (out, log) + assert log.count("the same claim, written differently") == 1, log + + +@case +def ub_claim_number_that_never_filled_is_a_not_loaded_reason(page): + """ + Final review, 2026-10-05: open_claim() only logged "Claim No never + filled in — reading anyway", so the conditions ran on a UB form that + may be half-loaded. The reason now reaches the claim's data, and the + flow's not-evaluated branch skips the conditions as for HCFA/Dental. + """ + _go(page, UB) + page.evaluate("""() => { + for (const e of document.querySelectorAll( + '#UBClaimDiv .ubClaimNumberText span')) e.textContent = ''; + }""") + out, took, _ = _timed(claim_form.wait_claim_loaded, page, "9104000") + assert out == ("UB form did not finish loading (claim number never " + "filled)"), out + assert took < 1.0, f"took {took:.1f}s — open_claim already waited" + + +@case +def flow_reports_a_wrong_claim_as_unread_and_still_closes_it(): + closed = [] + + def wrong(page, cid): + raise RuntimeError("Opened the form for claim '5000001'") + + orig = (claim_form.open_claim, claim_form.wait_claim_loaded, + claim_form.close_claim, cfg.OPEN_CLAIM_FORM) + claim_form.open_claim = lambda page, cid: True + claim_form.wait_claim_loaded = wrong + claim_form.close_claim = lambda page: closed.append(True) + cfg.OPEN_CLAIM_FORM = True + try: + with contextlib.redirect_stdout(io.StringIO()): + data = flow._read_claim_form(None, "9103000") + finally: + (claim_form.open_claim, claim_form.wait_claim_loaded, + claim_form.close_claim, cfg.OPEN_CLAIM_FORM) = orig + # Unit D (2026-10-05): not {} any more — {} read exactly like "not + # found". The error rides on the data and the summary says Review. + assert data == {"claim_no": "9103000", + "_error": "Opened the form for claim '5000001'"}, data + assert closed == [True], "the wrong claim's form was left open" + + + +# ── close_claim() on a page error ──────────────────────────────────────────── + +@case +def any_claim_form_open_raises_on_a_page_error_rather_than_reading_closed(): + """ + visible() swallows errors and answers False; close_claim() reads False as + "already closed — nothing to cancel". any_claim_form_open() therefore + keeps its own raising check (review of unit C, 2026-10-05). + """ + class Broken: + def locator(self, selector): + raise RuntimeError("Target page, context or browser has been closed") + try: + claim_form.any_claim_form_open(Broken()) + except RuntimeError: + pass + else: + raise AssertionError("a page error read as an answer, not an error") + + +@case +def close_claim_still_clicks_cancel_when_the_open_check_errors(page): + """A transient error while asking is not "closed": Cancel is still + clicked (the form would otherwise stay open for the next claim), and the + run is not ended by a raise from the flow's `finally`.""" + _go(page, DENTAL) + orig = claim_form.any_claim_form_open + + def broken(page): + raise RuntimeError("Execution context was destroyed") + + claim_form.any_claim_form_open = broken + try: + out, _, log = _timed(claim_form.close_claim, page) + finally: + claim_form.any_claim_form_open = orig + assert out is True, (out, log) + assert "trying Cancel anyway" in log, log + assert "already closed" not in log, log + assert not page.locator("#cancelDentalClaimButton").is_visible(), "Cancel was not clicked — the form is still open" + + +def main() -> int: + try: + from playwright.sync_api import sync_playwright + except ImportError: + print(" SKIP playwright is not installed") + return 0 + + failures = 0 + orig_shots = cfg.SCREENSHOTS + with tempfile.TemporaryDirectory() as tmp, sync_playwright() as pw: + cfg.SCREENSHOTS = pathlib.Path(tmp) + browser = pw.chromium.launch(headless=True) + page = browser.new_page() + try: + for fn in CASES: + name = fn.__name__.replace("_", " ") + try: + if len(inspect.signature(fn).parameters) == 0: + fn() + else: + fn(page) + print(f" ok {name}") + except AssertionError as exc: + print(f" FAIL {name}: {exc}") + failures += 1 + except Exception as exc: + print(f" ERROR {name}: {type(exc).__name__}: {exc}") + failures += 1 + finally: + cfg.SCREENSHOTS = orig_shots + browser.close() + + print(f"\n{len(CASES) - failures}/{len(CASES)} passed") + return 1 if failures else 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tests/test_clean_between_claims.py b/tests/test_clean_between_claims.py new file mode 100644 index 0000000..1432acf --- /dev/null +++ b/tests/test_clean_between_claims.py @@ -0,0 +1,410 @@ +r""" +Every claim starts from a clean search page (operator, 2026-10-12): what +the last claim left open is closed, Claim No and Lookup are checked to be +on screen with nothing over them, and only then is the claim searched. A +page that will not come clean makes that ONE claim Review — the next claim +clears and checks again. Live run that day: claim 1's viewer stayed open +and claims 2 and 3 were never searched. + + python tests\test_clean_between_claims.py +""" + +import io +import sys +import pathlib +import inspect +import contextlib + +ROOT = pathlib.Path(__file__).resolve().parent.parent +sys.path.insert(0, str(ROOT / "src")) + +FIXTURES = pathlib.Path(__file__).resolve().parent / "fixtures" +HCFA = FIXTURES / "hcfa_claim.html" + +from ecw_bot import settings as cfg # noqa: E402 +from ecw_bot.flows import export_medcor_ready as flow # noqa: E402 +from ecw_bot.pages import claim_form, claim_results # noqa: E402 +from ecw_bot.selectors import claim_lookup as lsel # noqa: E402 + +CASES = [] + + +def case(fn): + CASES.append(fn) + return fn + + +def _quiet(fn, *a, **k): + with contextlib.redirect_stdout(io.StringIO()) as out: + result = fn(*a, **k) + return result, out.getvalue() + + +# The two controls the search needs, as eCW's Claim Lookup has them. +_LOOKUP = """ +
+ + +
+""" + +# A viewer-like dialog over the whole page, with a patient's name in it. +_COVER = """ + +""" + + +# ── the check before the search ────────────────────────────────────────────── + +@case +def a_clean_search_page_is_ready(page): + page.set_content(_LOOKUP) + assert claim_results.search_blocker(page) is None + + +@case +def a_dialog_over_lookup_is_named_without_its_text(page): + page.set_content(_LOOKUP + _COVER) + why = claim_results.search_blocker(page) + assert why and "covered by" in why and "pnViewer" in why, why + assert "DOE" not in why and "JANE" not in why, why + + +@case +def a_missing_lookup_button_is_named(page): + page.set_content('') + why = claim_results.search_blocker(page) + assert why == "Lookup is not on screen", why + + +@case +def a_disabled_lookup_button_is_named(page): + page.set_content(_LOOKUP.replace("PATIENT DOE, JANE +
grid
+ """) + lines = claim_form.describe_shown(page, claim_form.sel.SEL_CANCEL_HCFA) + assert len(lines) == 1, lines + line = lines[0] + assert "opacity 0" in line, line + assert "div#ghost" in line, line + assert "hits div#cover" in line, line + assert "DOE" not in line and "JANE" not in line, line + + +# ── a claim window is Cancelled, never Escaped ─────────────────────────────── +# Escape drops the HCFA window without closeClaimScreen() (the fixture's own +# keydown handler does what uib does live); `closeClaimScreenRan` tells a +# real Cancel apart from that. + +class _Stop(Exception): + pass + + +def _clear_with_cancel_failing(page): + orig = claim_form._close_leftover_form + + def will_not_close(page, claim_id): + raise RuntimeError("would not close") + claim_form._close_leftover_form = will_not_close + try: + return _quiet(claim_form.clear_screen, page, "9103000", + page.context.pages) + finally: + claim_form._close_leftover_form = orig + + +@case +def clear_screen_cancels_a_claim_form_and_does_not_escape_it(page): + page.goto(HCFA.as_uri()) + done, out = _quiet(claim_form.clear_screen, page, "9103000", + page.context.pages) + assert "a claim form" in done, (done, out) + assert page.evaluate("window.closeClaimScreenRan") is True, out + assert "LAST RESORT" not in out, out + + +@case +def an_hcfa_form_cancel_will_not_close_is_escaped_as_the_last_resort(page): + """Operator, 2026-10-14: left up, it covered the search page and every + later claim of the run went to Review.""" + page.goto(HCFA.as_uri()) + done, out = _clear_with_cancel_failing(page) + assert "LAST RESORT" in out and "still up (would not close)" in out, out + assert "closeClaimScreen() does NOT run" in out, out + assert "a claim form" in done, (done, out) + assert not claim_form.any_claim_form_open(page), out + assert page.evaluate("window.closeClaimScreenRan") is False + + +@case +def a_ub_form_cancel_will_not_close_is_not_escaped(page): + page.goto((FIXTURES / "ub_claim.html").as_uri()) + assert claim_form.any_claim_form_open(page) + done, out = _clear_with_cancel_failing(page) + assert "LAST RESORT" not in out, out + assert "Could not close a claim form" in out, out + assert "a claim form" not in done, (done, out) + assert claim_form.any_claim_form_open(page), out + + +@case +def open_claim_cancels_a_form_already_up_and_never_escapes_it(page): + """eCW opens the next HCFA claim's form by itself after the Lookup (live + run 2026-10-13): open_claim()'s stray-panel check came first and would + have Escaped it.""" + page.goto(HCFA.as_uri()) + orig = claim_form._close_leftover_form + + def then_stop(page, claim_id): + orig(page, claim_id) + raise _Stop() + claim_form._close_leftover_form = then_stop + try: + _quiet(claim_form.open_claim, page, "9103000") + except _Stop: + pass + finally: + claim_form._close_leftover_form = orig + assert page.evaluate("window.closeClaimScreenRan") is True, \ + "the form was gone before _close_leftover_form() could Cancel it" + assert not claim_form.any_claim_form_open(page) + + +# ── the loop ───────────────────────────────────────────────────────────────── + +def _run(ids, search, events=None): + """ + `_process_claims` with the page stubbed out. Clears are appended to + `events` (the search can append to the same list); returns it and the + outcomes. + """ + events = [] if events is None else events + outcomes = {} + orig = (cfg.SHOW_EACH_CLAIM, cfg.SHOW_CLAIM_IDS, cfg.CLAIM_PROCESS_LIMIT, + flow.claim_results.search_claim, flow._read_claim_form, + flow.claim_filters.clear_claim_no, flow._write_charge_summary, + flow._save_results_db, flow.claim_form.clear_screen) + cfg.SHOW_EACH_CLAIM = False + cfg.SHOW_CLAIM_IDS = False + cfg.CLAIM_PROCESS_LIMIT = 0 + flow.claim_results.search_claim = search + flow._read_claim_form = lambda page, cid, head: { + "claim_no": cid, + "cpt": [{"code": "99213", "checked": True, "fee": "100.00"}], + "icd": [{"code": "Z00.00"}], + "progress_notes": {"opened": "modal", "text": "Office visit."}, + "_conditions": {"fired": [], "saved": False, "failed": [], + "review": [], "comments": {}}} + flow.claim_filters.clear_claim_no = lambda page: None + flow._write_charge_summary = lambda info, o: outcomes.update(o) + flow._save_results_db = lambda info, o, d: None + flow.claim_form.clear_screen = ( + lambda page, cid, keep=(): events.append(("clear", cid)) or []) + try: + with contextlib.redirect_stdout(io.StringIO()): + flow._process_claims(None, {"ids": list(ids), "file": None}) + finally: + (cfg.SHOW_EACH_CLAIM, cfg.SHOW_CLAIM_IDS, cfg.CLAIM_PROCESS_LIMIT, + flow.claim_results.search_claim, flow._read_claim_form, + flow.claim_filters.clear_claim_no, flow._write_charge_summary, + flow._save_results_db, flow.claim_form.clear_screen) = orig + return events, outcomes + + +@case +def every_claim_is_cleared_before_its_search_and_the_run_ends_clear(): + events = [] + + def search(page, cid): + events.append(("search", cid)) + return {"id": cid, "count": 1, "found": True, "seconds": 1.0} + _run(["1", "2"], search, events) + assert events == [("clear", "1"), ("search", "1"), + ("clear", "2"), ("search", "2"), + ("clear", "2")], events + + +@case +def a_page_that_will_not_clear_costs_one_claim_not_the_rest(): + searched = [] + + def search(page, cid): + if cid == "1": + raise RuntimeError("The search page is not ready: Lookup is " + "covered by div#pnViewer.modal") + searched.append(cid) + return {"id": cid, "count": 1, "found": True, "seconds": 1.0} + _, outcomes = _run(["1", "2", "3"], search) + assert searched == ["2", "3"], searched + assert outcomes["1"]["Category"] == "Review", outcomes["1"] + assert "not ready" in outcomes["1"]["Comments"], outcomes["1"] + assert outcomes["2"]["Category"] == "Pass", outcomes["2"] + assert outcomes["3"]["Category"] == "Pass", outcomes["3"] + + +def _run_cases() -> int: + from playwright.sync_api import sync_playwright + + failures = 0 + with sync_playwright() as pw: + browser = pw.chromium.launch(headless=True) + # A context of its own, so a test can open a second tab in it. + page = browser.new_context( + viewport={"width": 1600, "height": 1000}).new_page() + for fn in CASES: + name = fn.__name__.replace("_", " ") + try: + if len(inspect.signature(fn).parameters) == 0: + fn() + else: + fn(page) + print(f" ok {name}") + except AssertionError as exc: + print(f" FAIL {name}: {exc}") + failures += 1 + except Exception as exc: + print(f" ERROR {name}: {type(exc).__name__}: {exc}") + failures += 1 + browser.close() + + print(f"\n{len(CASES) - failures}/{len(CASES)} passed") + return 1 if failures else 0 + + +def main() -> int: + """ + shot() writes into the operator's dated Screenshots\\ folder; a test + run must not, so the cases run with it pointed at a temp folder. + """ + import tempfile + from ecw_bot import settings as _cfg + orig = _cfg.SCREENSHOTS + with tempfile.TemporaryDirectory() as tmp: + _cfg.SCREENSHOTS = pathlib.Path(tmp) + try: + return _run_cases() + finally: + _cfg.SCREENSHOTS = orig + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tests/test_conditions_bh_h_code_modifier.py b/tests/test_conditions_bh_h_code_modifier.py new file mode 100644 index 0000000..8610039 --- /dev/null +++ b/tests/test_conditions_bh_h_code_modifier.py @@ -0,0 +1,459 @@ +r""" +conditions/bh_h_code_modifier.py — document #12: on a claim whose payer's +name starts with "BH", an H code line (H0001-H2037) carries no modifier; +telehealth visits are excluded (their GT stays). + + python tests\test_conditions_bh_h_code_modifier.py + +Uses hcfa_claim.html with an injected H code row, whose M1-M4 inputs carry +the same data-fieldnames as the fixture's own rows. +""" + +import sys +import pathlib +import inspect + +ROOT = pathlib.Path(__file__).resolve().parent.parent +sys.path.insert(0, str(ROOT / "src")) + +FIXTURE = pathlib.Path(__file__).resolve().parent / "fixtures" / "hcfa_claim.html" +UB_FIXTURE = FIXTURE.with_name("ub_claim.html") + +from ecw_bot.conditions import bh_h_code_modifier as bh # noqa: E402 +from ecw_bot.conditions.base import run_all # noqa: E402 +from ecw_bot.pages import claim_form # noqa: E402 +from ecw_bot.selectors import claim_form as sel # noqa: E402 + +CASES = [] + +BH = [{"Name": "BH COLORADO ACCESS"}] +MEDICAID = [{"Name": "APM2 Medicaid"}] +OFFICE_NOTE = {"opened": "tab", "text": "Office visit. Session Setting: Clinic."} +TELE_NOTE = {"opened": "tab", "text": "Session Setting (PoS): TeleHealth"} + + +def case(fn): + CASES.append(fn) + return fn + + +def _inject_h_row(page, code="H0023", mods=("GT", "", "", ""), + row_id="CPTROW_3"): + page.evaluate( + """(a) => { + const tbody = document.querySelector( + '#claimCPTCodesTable8888888888888 tbody'); + const last = tbody.querySelector('tr:last-child'); + const tr = document.createElement('tr'); + tr.id = a.rowId; + let html = '' + + '' + + ''; + a.mods.forEach((m, i) => { + html += ''; + }); + html += ''; + tr.innerHTML = html; + tbody.insertBefore(tr, last); + tr.querySelectorAll('[data-fieldname^="ClaimCPTMOD"]').forEach(el => + el.addEventListener('blur', () => { el.dataset.blurred = '1'; })); + }""", {"code": code, "mods": list(mods), "rowId": row_id}) + + +def _mods(page, index): + row = page.locator(sel.SEL_CPT_ROWS).nth(index) + return [row.locator(s).first.input_value() for s in sel.SEL_CPT_MOD] + + +def _data(page, ins=BH, note=OFFICE_NOTE): + data = claim_form.extract(page) + data["insurance"] = ins + # The fixture's Z13.31 would fire depression_dx_pointer in run_all, and + # the injected row has no ICD inputs for it to read. + data["icd"] = [] + if note is not None: + data["progress_notes"] = note + return data + + +# ── detect ─────────────────────────────────────────────────────────────────── + +def _cpt(code="H0023", mod1="GT", mod2=""): + return [{"code": "99213", "mod1": "25"}, + {"code": code, "mod1": mod1, "mod2": mod2}] + + +@case +def detect_true_for_a_bh_payer_h_code_with_a_modifier(): + for payer in ("BH COLORADO ACCESS", "BH ROCKY MTN HEALTH 2022", + "BH ROCKY MOUNTAIN HEALTH PLANS", " bh rock mtn ub "): + data = {"insurance": [{"Name": payer}], "cpt": _cpt(), + "progress_notes": OFFICE_NOTE} + assert bh.detect("C1", data) is True, payer + + +@case +def detect_true_when_only_a_later_modifier_is_set(): + data = {"insurance": BH, "cpt": _cpt(mod1="", mod2="HO"), + "progress_notes": OFFICE_NOTE} + assert bh.detect("C1", data) is True + + +def _billed_h(billed=True, fee="150.00", mod1="GT"): + return [{"code": "99213", "billed": True, "billed_fee": "210.00"}, + {"code": "H0023", "mod1": mod1, "billed": billed, + "billed_fee": fee}] + + +@case +def detect_true_for_a_billed_h_code_on_a_non_bh_payer(): + """ + Operator, 2026-10-12: an H code is billed to BH payers only; on any + other payer it is unchecked and zeroed — telehealth or not, modifier + or not. + """ + for payer in ("Carelon Behavioral Health, Inc", "APM2 Medicaid", + "HCFA Medicare", "Rocky Mountain Health Prime"): + for note in (OFFICE_NOTE, TELE_NOTE): + for mod1 in ("GT", ""): + data = {"insurance": [{"Name": payer}], + "cpt": _billed_h(mod1=mod1), "progress_notes": note} + assert bh.detect("C1", data) is True, (payer, note, mod1) + + +@case +def detect_true_for_a_non_bh_ub_h_line_with_a_fee_and_no_checkbox(): + data = {"insurance": MEDICAID, "cpt": _billed_h(billed=None)} + assert bh.detect("C1", data) is True + + +@case +def detect_false_for_a_non_bh_h_code_already_unchecked_and_zeroed(): + for billed, fee in ((False, "150.00"), (None, "0.00"), (True, "0.00")): + data = {"insurance": MEDICAID, "cpt": _billed_h(billed, fee)} + assert bh.detect("C1", data) is False, (billed, fee) + + +@case +def detect_false_when_the_payer_was_not_read(): + for ins in ([{"Name": ""}], []): + data = {"insurance": ins, "cpt": _billed_h()} + assert bh.detect("C1", data) is False, ins + + +@case +def detect_false_when_the_h_code_has_no_modifier(): + data = {"insurance": BH, "cpt": _cpt(mod1=""), + "progress_notes": OFFICE_NOTE} + assert bh.detect("C1", data) is False + + +@case +def detect_false_for_codes_that_only_start_with_h(): + """Only H0001-H2037 style codes: 'H' plus four digits.""" + for code in ("HCG", "H00231", "H002"): + data = {"insurance": BH, "cpt": _cpt(code=code), + "progress_notes": OFFICE_NOTE} + assert bh.detect("C1", data) is False, code + + +@case +def detect_false_on_a_telehealth_visit(): + data = {"insurance": BH, "cpt": _cpt(), "progress_notes": TELE_NOTE} + assert bh.detect("C1", data) is False + + +@case +def detect_true_when_the_note_was_not_read(): + """Unknown telehealth: it fires, and apply() sends it to Review.""" + for note in (None, {"opened": "disabled"}, {"opened": "absent"}, + {"opened": "tab", "error": "boom"}): + data = {"insurance": BH, "cpt": _cpt()} + if note is not None: + data["progress_notes"] = note + assert bh.detect("C1", data) is True, note + + +# ── apply ──────────────────────────────────────────────────────────────────── + +@case +def apply_clears_every_modifier_on_the_h_code_row_only(page): + page.goto(FIXTURE.as_uri()) + _inject_h_row(page, mods=("GT", "HO", "", "")) + other_before = [_mods(page, i) for i in (0, 1, 2)] + + result = bh.apply(page, "C1", _data(page)) + assert result.changed is True, result + assert result.review == "", result.review + assert result.changes == [{"row": 3, "code": "H0023", + "before": ["GT", "HO", "", ""], + "after": ["", "", "", ""], "ok": True}], \ + result.changes + assert _mods(page, 3) == ["", "", "", ""] + assert [_mods(page, i) for i in (0, 1, 2)] == other_before, \ + "non-H-code rows must be left exactly as they were" + row = page.locator(sel.SEL_CPT_ROWS).nth(3) + assert row.locator(sel.SEL_CPT_MOD1).first.get_attribute("data-blurred") == "1", \ + "M1 must be blurred so eCW's updateCPTMod commits the change" + + +@case +def an_empty_live_grid_declines_instead_of_passing(page): + """Review finding 2 (2026-10-12): the grid did not re-render.""" + page.goto(FIXTURE.as_uri()) + _inject_h_row(page) + data = _data(page) + page.evaluate("""() => document.querySelectorAll('tr[id^="CPTROW_"]') + .forEach(tr => tr.remove())""") + result = bh.apply(page, "C1", data) + assert result.changed is False + assert result.review == bh.CPT_GRID_NOT_READ, result.review + + +@case +def a_fee_eCW_reprices_after_the_modifier_change_is_recorded(page): + """Review finding 4: eCW re-prices the line on the modifier's blur.""" + page.goto(FIXTURE.as_uri()) + _inject_h_row(page) + page.evaluate("""() => { + const tr = document.getElementById('CPTROW_3'); + const fee = tr.querySelector('[data-fieldname="ClaimCPTBilledFee"]'); + tr.querySelector('[data-fieldname="ClaimCPTMOD1"]').addEventListener( + 'blur', () => setTimeout(() => { fee.value = '120.00'; }, 300)); + }""") + result = bh.apply(page, "C1", _data(page)) + assert result.changes[0]["fee"] == {"before": "150.00", + "after": "120.00"}, result.changes + text = bh.describe(result) + assert "150.00" in text and "120.00" in text, text + + +@case +def a_partial_note_counts_as_not_read_on_a_bh_payer(page): + """ + Review, 2026-10-12: a note whose sections never settled may be missing + the telehealth line, so GT must not be removed on its word. + """ + partial = dict(OFFICE_NOTE, partial=True, partial_sections=0) + assert bh.detect("C1", {"insurance": BH, "cpt": _cpt(), + "progress_notes": partial}) is True + page.goto(FIXTURE.as_uri()) + _inject_h_row(page) + result = bh.apply(page, "C1", _data(page, note=partial)) + assert result.changed is False + assert result.review == bh.NOTE_NOT_READ, result.review + assert _mods(page, 3)[0] == "GT" + + +@case +def a_modifier_eCW_puts_back_after_the_blur_is_not_removed(page): + """ + The read-back is judged after eCW has had time to react, not straight + after the blur: here the modifier comes back 300 ms later. + """ + page.goto(FIXTURE.as_uri()) + _inject_h_row(page) + page.evaluate("""() => { + const m1 = document.getElementById('CPTROW_3') + .querySelector('[data-fieldname="ClaimCPTMOD1"]'); + m1.addEventListener('blur', () => + setTimeout(() => { m1.value = 'GT'; }, 300)); + }""") + result = bh.apply(page, "C1", _data(page)) + assert result.changes[0]["ok"] is False, result.changes + assert result.review == bh.NOT_REMOVED, result.review + assert "STILL SET" in bh.describe(result) + assert "NOT removed" in bh.summary_comment(result), \ + bh.summary_comment(result) + + +@case +def apply_on_the_ub_form_clears_and_blurs_every_set_modifier(page): + """The UB screen's own ids (uBClaimModN), M2 seeded as well as M1.""" + page.goto(UB_FIXTURE.as_uri()) + page.evaluate("""() => { + const tr = document.querySelectorAll('tr[id^="CPTROW_"]')[0]; + const m2 = tr.querySelector('[id^="uBClaimMod2"]'); + m2.value = 'HO'; + for (const el of tr.querySelectorAll('[id^="uBClaimMod"]')) + el.addEventListener('blur', () => { el.dataset.blurred = '1'; }); + }""") + data = _data(page) + assert data["cpt"][0]["code"] == "H0023" + result = bh.apply(page, "C1", data) + assert result.changes and result.changes[0]["before"][:2] == ["GT", "HO"], \ + result.changes + assert result.changes[0]["ok"] is True + assert _mods(page, 0) == ["", "", "", ""] + tr = page.locator(sel.SEL_CPT_ROWS).nth(0) + for n in (1, 2): + assert tr.locator(f'[id^="uBClaimMod{n}"]').first \ + .get_attribute("data-blurred") == "1", f"M{n} not blurred" + + +@case +def apply_sends_an_unread_note_to_review_and_changes_nothing(page): + page.goto(FIXTURE.as_uri()) + _inject_h_row(page, mods=("GT", "", "", "")) + result = bh.apply(page, "C1", _data(page, note={"opened": "disabled"})) + assert result.changed is False + assert result.review == bh.NOTE_NOT_READ, result.review + assert _mods(page, 3) == ["GT", "", "", ""] + + +@case +def apply_is_a_no_op_when_the_h_code_has_no_modifier(page): + page.goto(FIXTURE.as_uri()) + _inject_h_row(page, mods=("", "", "", "")) + result = bh.apply(page, "C1", _data(page)) + assert result.changed is False and result.review == "", result + + +@case +def describe_names_the_code_and_the_removed_modifiers(page): + page.goto(FIXTURE.as_uri()) + _inject_h_row(page, mods=("GT", "HO", "", "")) + text = bh.describe(bh.apply(page, "C1", _data(page))) + assert "H0023" in text and "GT" in text and "HO" in text, text + + +@case +def run_all_on_a_bh_office_visit_fires_this_condition(page): + page.goto(FIXTURE.as_uri()) + _inject_h_row(page, mods=("GT", "", "", "")) + report = {} + results = run_all(page, "C1", _data(page), report=report) + assert "bh_h_code_modifier" in report["fired"], report + assert "bh_h_code_modifier" in [r.condition_name for r in results], results + assert _mods(page, 3) == ["", "", "", ""] + + +def _h_row_state(page, index=3): + row = page.locator(sel.SEL_CPT_ROWS).nth(index) + return (row.locator(sel.SEL_CPT_ASSIGN_TO_PATIENT).first.is_checked(), + row.locator(sel.SEL_CPT_BILLED_FEE).first.input_value()) + + +@case +def apply_on_a_non_bh_payer_unchecks_and_zeroes_the_h_code_only(page): + page.goto(FIXTURE.as_uri()) + _inject_h_row(page, mods=("GT", "", "", "")) + others = [_h_row_state(page, i) for i in (0, 1, 2)] + result = bh.apply(page, "C1", _data(page, ins=MEDICAID, note=TELE_NOTE)) + assert result.changed is True and result.review == "", result + assert result.changes == [{"row": 3, "code": "H0023", "unchecked": True, + "fee": {"before": "150.00", + "after": "0.00"}}], result.changes + assert _h_row_state(page) == (False, "0.00") + assert _mods(page, 3) == ["GT", "", "", ""], "modifier is not this path's" + assert [_h_row_state(page, i) for i in (0, 1, 2)] == others + + +@case +def apply_on_a_non_bh_payer_with_an_unread_note_still_unchecks(page): + """Telehealth does not matter off a BH payer, so the note does not.""" + page.goto(FIXTURE.as_uri()) + _inject_h_row(page) + result = bh.apply(page, "C1", + _data(page, ins=MEDICAID, note={"opened": "disabled"})) + assert result.changed is True, result + assert _h_row_state(page) == (False, "0.00") + + +@case +def apply_on_a_non_bh_payer_with_an_empty_live_grid_declines(page): + page.goto(FIXTURE.as_uri()) + _inject_h_row(page) + data = _data(page, ins=MEDICAID) + page.evaluate("""() => document.querySelectorAll('tr[id^="CPTROW_"]') + .forEach(tr => tr.remove())""") + result = bh.apply(page, "C1", data) + assert result.changed is False + assert result.review == bh.CPT_GRID_NOT_READ, result.review + + +@case +def summary_comment_and_describe_follow_the_path_taken(page): + page.goto(FIXTURE.as_uri()) + _inject_h_row(page) + off = bh.apply(page, "C1", _data(page, ins=MEDICAID)) + assert bh.summary_comment(off) == ("H code not billed to a non-BH payer " + "(unchecked and fee zeroed)"), \ + bh.summary_comment(off) + # UB, no Assign-To-Patient checkbox: only the fee moved (option A). + fee_only = bh.ConditionResult( + condition_name=bh.NAME, changed=True, + changes=[{"row": 0, "code": "H0023", + "fee": {"before": "74.00", "after": "0.00"}}]) + assert bh.summary_comment(fee_only) == ("H code not billed to a non-BH " + "payer (fee zeroed)"), \ + bh.summary_comment(fee_only) + text = bh.describe(off) + assert "H0023" in text and "150.00" in text and "non-BH" in text, text + + page.goto(FIXTURE.as_uri()) + _inject_h_row(page) + on = bh.apply(page, "C1", _data(page)) + assert bh.summary_comment(on) == bh.MODIFIER_COMMENT, \ + bh.summary_comment(on) + + +@case +def run_all_on_a_non_bh_payer_leaves_the_h_code_unchecked_at_zero(page): + """priced_line_check (last) must not re-check it: its fee is 0.00.""" + page.goto(FIXTURE.as_uri()) + _inject_h_row(page) + report = {} + run_all(page, "C1", _data(page, ins=MEDICAID), report=report) + assert "bh_h_code_modifier" in report["fired"], report + assert _h_row_state(page) == (False, "0.00") + + +@case +def run_all_on_a_bh_telehealth_visit_keeps_gt(page): + page.goto(FIXTURE.as_uri()) + _inject_h_row(page, mods=("GT", "", "", "")) + report = {} + run_all(page, "C1", _data(page, note=TELE_NOTE), report=report) + assert "bh_h_code_modifier" not in report["fired"], report + assert _mods(page, 3)[0] == "GT" + + +def main() -> int: + if not FIXTURE.exists(): + print(f" FAIL fixture missing: {FIXTURE}") + return 1 + try: + from playwright.sync_api import sync_playwright + except ImportError: + print(" SKIP playwright is not installed") + return 0 + + failures = 0 + with sync_playwright() as pw: + browser = pw.chromium.launch(headless=True) + page = browser.new_page() + + for fn in CASES: + name = fn.__name__.replace("_", " ") + try: + if len(inspect.signature(fn).parameters) == 0: + fn() + else: + fn(page) + print(f" ok {name}") + except AssertionError as exc: + print(f" FAIL {name}: {exc}") + failures += 1 + except Exception as exc: + print(f" ERROR {name}: {type(exc).__name__}: {exc}") + failures += 1 + browser.close() + + print(f"\n{len(CASES) - failures}/{len(CASES)} passed") + return 1 if failures else 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tests/test_conditions_dental_dummy_codes.py b/tests/test_conditions_dental_dummy_codes.py new file mode 100644 index 0000000..b5a530a --- /dev/null +++ b/tests/test_conditions_dental_dummy_codes.py @@ -0,0 +1,259 @@ +r""" +conditions/dental_dummy_codes.py — document #48: DPIMP is a dummy code on +Dental payers. On a Dental payer's claim every DPIMP line is unchecked and +its Billed Fee zeroed; on any other payer nothing is changed and the claim +goes to Review with a note naming the payer group (operator, 2026-10-13). + + python tests\test_conditions_dental_dummy_codes.py + +Uses tests/fixtures/dental_claim.html (D0120 row 0, DCONS row 1) and adds +a DPIMP line per case, cloned from row 0. +""" + +import io +import sys +import pathlib +import inspect +import contextlib + +ROOT = pathlib.Path(__file__).resolve().parent.parent +sys.path.insert(0, str(ROOT / "src")) + +FIXTURE = pathlib.Path(__file__).resolve().parent / "fixtures" / "dental_claim.html" + +from ecw_bot.conditions import dental_dummy_codes as cond # noqa: E402 +from ecw_bot.conditions import CONDITIONS # noqa: E402 +from ecw_bot.conditions.base import ConditionResult, run_all # noqa: E402 +from ecw_bot.core import charge_summary as cs # noqa: E402 +from ecw_bot.pages import claim_form # noqa: E402 +from ecw_bot.selectors import claim_form as sel # noqa: E402 + +CASES = [] + + +def case(fn): + CASES.append(fn) + return fn + + +def _claim(payer, *codes): + return {"insurance": [{"Name": payer}] if payer is not None else [], + "cpt": [{"code": c, "checked": True, "fee": "25.00"} + for c in codes]} + + +def _add_row(page, code, fee="25.00", checked=True): + """One more real Dental service line, cloned from row 0.""" + page.evaluate( + """(a) => { + const rows = document.querySelectorAll( + 'tr[ng-repeat^="CPT in CPTCodes"]'); + const tr = rows[0].cloneNode(true); + tr.querySelector('[data-fieldname="web_cptcode"]').value = a.code; + tr.querySelector('[data-fieldname="web_billedFee"]').value = a.fee; + const box = tr.querySelector('[ng-click*="updateBillToIns("]'); + box.checked = a.checked; + rows[rows.length - 1].after(tr); + }""", {"code": code, "fee": fee, "checked": checked}) + + +def _row(page, index): + tr = page.locator(sel.SEL_CPT_ROWS).nth(index) + return (tr.locator(sel.SEL_CPT_CODE).first.input_value(), + tr.locator(sel.SEL_CPT_ASSIGN_TO_PATIENT).first.is_checked(), + tr.locator(sel.SEL_CPT_BILLED_FEE).first.input_value()) + + +def _dental_read(page, payer): + data = claim_form.extract(page) + data["screen"] = "dental" + data["insurance"] = [{"Name": payer}] + data["progress_notes"] = {"opened": "tab", "text": "Routine dental visit."} + return data + + +# ── detect / needs_review (no browser) ─────────────────────────────────────── + +@case +def detect_dpimp_on_each_dental_payer_group(): + for payer in ("Delta Dental", "CHP+ DENTAQUEST", "DentaQuest"): + assert cond.detect("C1", _claim(payer, "D0120", "DPIMP")), payer + assert cond.needs_review("C1", _claim(payer, "DPIMP")) is None, payer + + +@case +def detect_ignores_case_and_spaces_of_the_code(): + assert cond.detect("C1", _claim("Delta Dental", " dpimp ")) is True + + +@case +def dfimp_is_the_same_dummy_code(): + # The live Dental grid carries DFIMP ("FINAL IMPRESSION"); the document + # says DPIMP. Both are #48 (operator, 2026-10-20). + for payer in ("Delta Dental", "DentaQuest"): + assert cond.detect("C1", _claim(payer, "D0120", "DFIMP")), payer + assert cond.needs_review("C1", _claim(payer, "DFIMP")) is None, payer + assert cond.detect("C1", _claim("DentaQuest", " dfimp ")) is True + + +@case +def dfimp_on_another_payer_is_noted_by_its_own_code(): + note = cond.needs_review("C1", _claim("Medicaid", "DFIMP")) + assert note and "DFIMP" in note and "DPIMP" not in note, note + assert "a Medicaid payer claim" in note, note + + +@case +def no_dpimp_means_nothing_at_all(): + for payer in ("Delta Dental", "Medicaid"): + data = _claim(payer, "D0120", "DCONS") + assert cond.detect("C1", data) is False, payer + assert cond.needs_review("C1", data) is None, payer + assert cond.detect("C1", {}) is False + assert cond.needs_review("C1", {}) is None + + +@case +def dpimp_on_another_payer_is_not_fired_but_noted_with_its_group(): + for payer, group in (("Medicaid", "a Medicaid payer claim"), + ("Medicare", "a Medicare payer claim"), + ("AARP", "a Commercial Ins payer claim")): + data = _claim(payer, "DPIMP") + assert cond.detect("C1", data) is False, payer + note = cond.needs_review("C1", data) + assert note and group in note and "DPIMP" in note, note + assert "not changed" in note, note + + +@case +def dpimp_on_an_unmapped_or_unread_payer_is_noted_too(): + note = cond.needs_review("C1", _claim("NO SUCH PAYER XYZ", "DPIMP")) + assert "not in the Claim Status Dashboard" in note, note + note = cond.needs_review("C1", _claim("", "DPIMP")) + assert "no payer read" in note, note + note = cond.needs_review("C1", _claim(None, "DPIMP")) + assert "no payer read" in note, note + + +# ── apply on the Dental screen (browser) ───────────────────────────────────── + +@case +def apply_unchecks_and_zeroes_dpimp_only(page): + page.goto(FIXTURE.as_uri()) + _add_row(page, "DPIMP", fee="25.00") + data = _dental_read(page, "Delta Dental") + result = cond.apply(page, "C1", data) + assert isinstance(result, ConditionResult) + assert result.condition_name == "dental_dummy_codes" + assert result.changes == [{"row": 2, "code": "DPIMP", "unchecked": True, + "fee": {"before": "25.00", + "after": "0.00"}}], result.changes + assert _row(page, 2) == ("DPIMP", False, "0.00") + assert _row(page, 0) == ("D0120", True, "45.00"), "D0120 untouched" + + +@case +def apply_is_a_no_op_once_already_excluded(page): + page.goto(FIXTURE.as_uri()) + _add_row(page, "DPIMP", fee="0.00", checked=False) + result = cond.apply(page, "C1", _dental_read(page, "Delta Dental")) + assert result.changed is False and result.changes == [] + + +@case +def apply_unchecks_and_zeroes_dfimp_too(page): + page.goto(FIXTURE.as_uri()) + _add_row(page, "DFIMP", fee="25.00") + result = cond.apply(page, "C1", _dental_read(page, "DentaQuest")) + assert result.changes == [{"row": 2, "code": "DFIMP", "unchecked": True, + "fee": {"before": "25.00", + "after": "0.00"}}], result.changes + assert _row(page, 2) == ("DFIMP", False, "0.00") + text = cond.describe(result) + assert "DFIMP" in text and "DPIMP" not in text, text + + +@case +def describe_names_dpimp_and_the_fee_change(page): + page.goto(FIXTURE.as_uri()) + _add_row(page, "DPIMP", fee="25.00") + text = cond.describe(cond.apply(page, "C1", + _dental_read(page, "Delta Dental"))) + assert "DPIMP" in text and "25.00" in text and "0.00" in text, text + + +# ── run_all and the Charge Summary ─────────────────────────────────────────── + +@case +def it_is_registered_before_priced_line_check(): + names = [m.NAME for m in CONDITIONS] + assert names.index("dental_dummy_codes") < names.index( + "priced_line_check"), names + + +@case +def a_dental_payer_claim_is_fixed_and_fails_with_its_comment(page): + page.goto(FIXTURE.as_uri()) + _add_row(page, "DPIMP", fee="25.00") + data = _dental_read(page, "Delta Dental") + report = {} + with contextlib.redirect_stdout(io.StringIO()): + results = run_all(page, "C1", data, report=report) + assert "dental_dummy_codes" in [r.condition_name for r in results] + assert _row(page, 2) == ("DPIMP", False, "0.00") + assert not [r for r in report["review"] + if r["name"] == "dental_dummy_codes"], report["review"] + assert "DPIMP" in cs.COMMENTS["dental_dummy_codes"] + + +@case +def a_medicaid_claim_keeps_dpimp_and_is_review_with_the_note(page): + page.goto(FIXTURE.as_uri()) + _add_row(page, "DPIMP", fee="25.00") + data = _dental_read(page, "Medicaid") + report = {} + with contextlib.redirect_stdout(io.StringIO()): + results = run_all(page, "C1", data, report=report) + assert "dental_dummy_codes" not in [r.condition_name for r in results] + assert _row(page, 2) == ("DPIMP", True, "25.00"), "left as it is" + notes = [r["reason"] for r in report["review"] + if r["name"] == "dental_dummy_codes"] + assert len(notes) == 1 and "Medicaid" in notes[0], report["review"] + data["_conditions"] = {"fired": [r.condition_name for r in results], + "saved": False, "failed": report["failed"], + "review": report["review"], + "comments": report["comments"]} + outcome = cs.claim_outcome(True, data) + assert outcome["Category"] == "Review", outcome + assert "DPIMP on a Medicaid payer claim" in outcome["Comments"], outcome + + +def main() -> int: + from playwright.sync_api import sync_playwright + + failures = 0 + with sync_playwright() as pw: + browser = pw.chromium.launch(headless=True) + page = browser.new_page() + for fn in CASES: + name = fn.__name__.replace("_", " ") + try: + if len(inspect.signature(fn).parameters) == 0: + fn() + else: + fn(page) + print(f" ok {name}") + except AssertionError as exc: + print(f" FAIL {name}: {exc}") + failures += 1 + except Exception as exc: + print(f" ERROR {name}: {type(exc).__name__}: {exc}") + failures += 1 + browser.close() + + print(f"\n{len(CASES) - failures}/{len(CASES)} passed") + return 1 if failures else 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tests/test_conditions_dental_payer_check.py b/tests/test_conditions_dental_payer_check.py new file mode 100644 index 0000000..f9d513b --- /dev/null +++ b/tests/test_conditions_dental_payer_check.py @@ -0,0 +1,269 @@ +r""" +conditions/dental_payer_check.py — document #13: Dental codes are billed +only to DentaQuest, Delta Dental or CHP+ Dental — on any insurance row, +secondary included. None of them on a claim with a billed Dental code: the +Claim Status is set to Patient (PAT) where the run may write, else Review +"set it by hand"; no payer read: Review (operator, 2026-10-16 / 2026-10-21). + + python tests\test_conditions_dental_payer_check.py + +Uses tests/fixtures/dental_claim.html (D0120 row 0, DCONS row 1) for the +run_all case. +""" + +import io +import sys +import pathlib +import inspect +import contextlib + +ROOT = pathlib.Path(__file__).resolve().parent.parent +sys.path.insert(0, str(ROOT / "src")) + +FIXTURE = pathlib.Path(__file__).resolve().parent / "fixtures" / "dental_claim.html" + +from ecw_bot.conditions import dental_payer_check as cond # noqa: E402 +from ecw_bot.conditions import CONDITIONS # noqa: E402 +from ecw_bot.conditions.base import run_all # noqa: E402 +from ecw_bot.core import charge_summary as cs # noqa: E402 +from ecw_bot.pages import claim_form # noqa: E402 + +CASES = [] + + +def case(fn): + CASES.append(fn) + return fn + + +def _row(code, billed=True, fee="85.00"): + return {"code": code, "billed": billed, "billed_fee": fee, "units": "1"} + + +def _claim(payer, *rows): + return {"insurance": [{"Name": payer}] if payer is not None else [], + "cpt": list(rows)} + + +# ── recognition ────────────────────────────────────────────────────────────── + +@case +def a_dental_code_is_d_and_four_digits(): + for code in ("D0120", "D1110", " d0274 ", "D9999"): + assert cond.is_dental_code(code), code + for code in ("DCONS", "Dscprit", "DPIMP", "D012", "D01200", "99213", + "G8510", "", None): + assert not cond.is_dental_code(code), code + + +@case +def the_three_payers_are_allowed_and_no_other(): + for payer in ("DentaQuest", "CHP+ DENTAQUEST", "Delta Dental", + "Delta Dental of Colorado", "DELTA DENTAL PPO", + " delta dental of oregon "): + assert cond.is_allowed_payer(payer), payer + for payer in ("Aetna Dental PPO", "Dental Cigna", "MetLife Dental Claims", + "Medicaid", "Anthem Blue Cross Blue Shield", + "Dental Slide D", "", None): + assert not cond.is_allowed_payer(payer), payer + + +# ── needs_review (no browser) ──────────────────────────────────────────────── + +@case +def allowed_payers_need_nothing(): + for payer in ("DentaQuest", "CHP+ DENTAQUEST", "Delta Dental of Colorado"): + data = _claim(payer, _row("D0120"), _row("D1110")) + assert cond.detect("C1", data) is False + assert cond.needs_review("C1", data) is None, payer + + +@case +def an_allowed_secondary_payer_is_enough(): + """Operator 2026-10-21: secondary insurance counts.""" + data = _claim("Medicaid", _row("D0120")) + data["insurance"].append({"Name": "Delta Dental of Colorado"}) + assert cond.detect("C1", data) is False + assert cond.needs_review("C1", data) is None + + +@case +def another_payer_is_detected(): + for payer in ("Aetna Dental PPO", "Medicaid", "Cigna HealthCare Of Colorado"): + data = _claim(payer, _row("D0120"), _row("DCONS"), _row("D1110")) + assert cond.detect("C1", data) is True, payer + assert cond.needs_review("C1", data) is None, payer + + +@case +def no_payer_read_is_review_not_a_status_change(): + for data in (_claim("", _row("D0120")), _claim(None, _row("D0120"))): + assert cond.detect("C1", data) is False + why = cond.needs_review("C1", data) + assert why and "no payer read" in why and "D0120" in why, why + + +@case +def only_billed_dental_lines_count(): + # Unchecked at 0.00, or checked at 0.00 (zero_fee_uncheck unchecks it): + # nothing billed, nothing to do. + for row in (_row("D0120", billed=False, fee="0.00"), + _row("D0120", billed=True, fee="0.00")): + assert cond.detect("C1", _claim("Medicaid", row)) is False, row + # Dummy codes only: not dental codes. + data = _claim("Medicaid", _row("DCONS"), _row("DPIMP"), _row("99213")) + assert cond.detect("C1", data) is False + + +@case +def it_is_registered(): + assert "dental_payer_check" in [m.NAME for m in CONDITIONS] + assert cs.COMMENTS.get("dental_payer_check") + + +# ── run_all on the Dental screen (browser) ────────────────────────────────── + +def _dental_read(page, payer): + data = claim_form.extract(page) + data["screen"] = "dental" + data["insurance"] = [{"Name": payer}] + data["progress_notes"] = {"opened": "tab", "text": "Routine dental visit."} + return data + + +def _outcome(page, payer): + page.goto(FIXTURE.as_uri()) + data = _dental_read(page, payer) + report = {} + with contextlib.redirect_stdout(io.StringIO()): + results = run_all(page, "C1", data, report=report) + data["_conditions"] = {"fired": [r.condition_name for r in results], + "saved": False, "failed": report["failed"], + "review": report["review"], + "comments": report["comments"]} + return cs.claim_outcome(True, data), report + + +@case +def a_dental_claim_to_aetna_dental_is_review_set_by_hand(page): + # The Dental screen is never written (claims not saved): Review. + outcome, report = _outcome(page, "Aetna Dental PPO") + notes = [r["reason"] for r in report["review"] + if r["name"] == "dental_payer_check"] + assert len(notes) == 1 and "D0120" in notes[0], report["review"] + assert "set it to Patient by hand" in notes[0], notes + assert outcome["Category"] == "Review", outcome + assert "Patient" in outcome["Comments"], outcome + + +# ── the Claim Status dropdown (browser) ───────────────────────────────────── + +_STATUS = """ + + +""" + + +def _write(page, html, data, save=True): + from ecw_bot import settings as _cfg + page.set_content(html) + orig = _cfg.SAVE_CORRECTIONS + _cfg.SAVE_CORRECTIONS = save + try: + with contextlib.redirect_stdout(io.StringIO()): + return cond.apply(page, "C1", data) + finally: + _cfg.SAVE_CORRECTIONS = orig + + +def _value(page, selector): + return page.locator(selector).input_value() + + +@case +def live_write_sets_the_claim_status_to_patient(page): + data = _claim("Aetna Dental PPO", _row("D0120")) + result = _write(page, _STATUS, data) + assert result.changed is True and result.review == "", result + assert _value(page, "#claimStatusSel1") == "string:PAT" + assert _value(page, "#status") == "A", "the other status field untouched" + c = result.changes[0] + assert (c["before"], c["after"], c["label"]) == ("108ME", "PAT", "Patient") + assert cond.summary_comment(result) == ( + "Dental code(s) D0120 billed to Aetna Dental PPO — not DentaQuest / " + "Delta Dental / CHP+ Dental - Claim Status set to Patient") + assert "108ME -> PAT (Patient)" in cond.describe(result) + + +@case +def the_ub_dropdown_with_plain_values_works_too(page): + html = ('') + result = _write(page, html, _claim("Medicaid", _row("D1110"))) + assert result.changed and _value(page, "#claimStatusSel2") == "PAT" + + +@case +def verify_changes_nothing_and_says_set_it_by_hand(page): + result = _write(page, _STATUS, _claim("Medicaid", _row("D0120")), + save=False) + assert result.changed is False + assert "set it to Patient by hand" in result.review, result.review + assert _value(page, "#claimStatusSel1") == "string:108ME" + + +@case +def no_patient_option_or_no_dropdown_is_review(page): + html = ('') + result = _write(page, html, _claim("Medicaid", _row("D0120"))) + assert result.changed is False and "set it by hand" in result.review + result = _write(page, "

no form

", _claim("Medicaid", _row("D0120"))) + assert result.changed is False and "no Claim Status box" in result.review + + +@case +def a_dental_claim_to_dentaquest_gets_no_payer_review(page): + _, report = _outcome(page, "DentaQuest") + assert not [r for r in report["review"] + if r["name"] == "dental_payer_check"], report["review"] + + +def main() -> int: + from playwright.sync_api import sync_playwright + + failures = 0 + with sync_playwright() as pw: + browser = pw.chromium.launch(headless=True) + page = browser.new_page() + for fn in CASES: + name = fn.__name__.replace("_", " ") + try: + if len(inspect.signature(fn).parameters) == 0: + fn() + else: + fn(page) + print(f" ok {name}") + except AssertionError as exc: + print(f" FAIL {name}: {exc}") + failures += 1 + except Exception as exc: + print(f" ERROR {name}: {type(exc).__name__}: {exc}") + failures += 1 + browser.close() + + print(f"\n{len(CASES) - failures}/{len(CASES)} passed") + return 1 if failures else 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tests/test_conditions_depression_dx_pointer.py b/tests/test_conditions_depression_dx_pointer.py new file mode 100644 index 0000000..e781cad --- /dev/null +++ b/tests/test_conditions_depression_dx_pointer.py @@ -0,0 +1,628 @@ +r""" +conditions/depression_dx_pointer.py — document condition #6. + + python tests\test_conditions_depression_dx_pointer.py + +Uses hcfa_claim.html rather than ub_claim.html: the real live claim this +condition was built for (9103000) is the professional/HCFA screen, and that +fixture already carries the full scenario (a wrongly-tagged 99213, a +wrongly-tagged G8510, and an untouched J7298) — see its own comments. +test_telehealth_fix.py separately proves the underlying primitive +(`retag_cpt_icd_pointer`) against the UB form's own id scheme. + +G8431 (positive depression) has no row of its own in that fixture — only +G8510 — so `_inject_g8431_row()` adds one the same way the +medicare_telehealth tests inject a G2025 row, rather than growing the +shared fixture and risking every other test's row-count assumptions +(`CPT/HCPCS (3)`, `len(cpt) == 3`, ...). G8431 shares the SAME Z13.31 +pointer as G8510 — there is no separate ICD code for "positive" depression, +only for the underlying CPT/HCPCS code itself. +""" + +import io +import sys +import pathlib +import inspect +import contextlib + +ROOT = pathlib.Path(__file__).resolve().parent.parent +sys.path.insert(0, str(ROOT / "src")) + +FIXTURE = pathlib.Path(__file__).resolve().parent / "fixtures" / "hcfa_claim.html" + +from ecw_bot.conditions import depression_dx_pointer # noqa: E402 +from ecw_bot.conditions.base import ConditionResult, run_all # noqa: E402 +from ecw_bot.pages import claim_form # noqa: E402 +from ecw_bot.selectors import claim_form as sel # noqa: E402 + + +CASES = [] + + +def case(fn): + CASES.append(fn) + return fn + + +_ICD_WITH_DEPRESSION = [ + {"#": "1", "Code": "Z30.430", "Name": "Encounter for insertion..."}, + {"#": "2", "Code": "Z13.31", "Name": "Negative depression screening"}, +] + + +def _inject_g8431_row(page, icd1="1", checked=False): + """ + hcfa_claim.html has no G8431 row of its own (only G8510) — injected the + same way the medicare_telehealth tests inject a G2025 row, rather than + growing the shared fixture and risking every other test's row-count + assumptions (`CPT/HCPCS (3)`, `len(cpt) == 3`, ...). + """ + page.evaluate( + """(args) => { + const tbody = document.querySelector( + '#claimCPTCodesTable8888888888888 tbody'); + const addRowTr = tbody.querySelector('tr:last-child'); + const tr = document.createElement('tr'); + tr.id = 'CPTROW_3'; + tr.innerHTML = + '' + + '' + + '' + + '' + + '' + + ''; + tbody.insertBefore(tr, addRowTr); + }""", {"icd1": icd1, "checked": checked}) + + +def _inject_duplicate_target_row(page, code, row_id="CPTROW_3", + icd1="1", checked=True, fee="75.00"): + """ + Injects a SECOND row for a code already on the fixture (G8510, at + CPTROW_2) — or for G8431 alongside `_inject_g8431_row` — to prove the + duplicate-row suppression step: whichever row is later in DOM order is + the one that should get unchecked and fee-zeroed, the first left alone. + Carries a Billed Fee field, unlike `_inject_g8431_row`, since that is + exactly what this step targets. + """ + page.evaluate( + """(args) => { + const tbody = document.querySelector( + '#claimCPTCodesTable8888888888888 tbody'); + const addRowTr = tbody.querySelector('tr:last-child'); + const tr = document.createElement('tr'); + tr.id = args.rowId; + tr.innerHTML = + '' + + '' + + '' + + '' + + '' + + '' + + ''; + tbody.insertBefore(tr, addRowTr); + }""", {"code": code, "rowId": row_id, "icd1": icd1, + "checked": checked, "fee": fee}) + + +def _make_claim_already_correct(page): + """ + Puts the fixture into the state live claim 9131000 was already in + before the bot touched it: the first G8510 (CPTROW_2) checked and + carrying nothing but the Z13.31 pointer ("2"), and 99213 no longer + carrying it — so the retag and check steps have nothing left to do on + the fixture's own rows, and anything reported comes from whatever a + test injects on top. + """ + page.evaluate( + """() => { + const set = (rowId, field, value) => { + document.querySelector( + '#' + rowId + ' [data-fieldname="' + field + '"]').value = value; + }; + set('CPTROW_0', 'ClaimCPTICD2', '3'); + set('CPTROW_0', 'ClaimCPTICD3', ''); + set('CPTROW_2', 'ClaimCPTICD1', '2'); + document.querySelector( + '#CPTROW_2 [ng-click*="updateBillToIns("]').checked = true; + }""") + + +def _count_checkbox_clicks(page, row_id): + """ + Counts clicks on one row's "Assign To Patient" checkbox, read back as + `window.clicksOn[row_id]`. On the live screen that click is what fires + eCW's own `updateBillToIns()`, so a row that must stay excluded should + never receive one at all — not merely end up unchecked again. + """ + page.evaluate( + """(rowId) => { + window.clicksOn = window.clicksOn || {}; + window.clicksOn[rowId] = 0; + document.querySelector('#' + rowId + ' [ng-click*="updateBillToIns("]') + .addEventListener('click', () => { window.clicksOn[rowId] += 1; }); + }""", row_id) + + +@case +def detect_true_when_z13_31_is_on_the_claim(): + data = {"icd": _ICD_WITH_DEPRESSION} + assert depression_dx_pointer.detect("C1", data) is True + + +@case +def detect_false_when_z13_31_is_absent(): + data = {"icd": [{"#": "1", "Code": "Z30.430", "Name": "..."}]} + assert depression_dx_pointer.detect("C1", data) is False + + +@case +def detect_false_with_no_icd_key_at_all(): + assert depression_dx_pointer.detect("C1", {}) is False + + +@case +def apply_retags_g8510_and_strips_99213_on_the_real_claim_shape(page): + """ + The full real scenario (claim 9103000): the ICD-pointer fix on 99213 + and G8510, PLUS the separately-reported bug — G8510's checkbox sat + unchecked and needed checking once its pointer was corrected. Three + changes total: two ICD-pointer edits and one checkbox check, both + against G8510's own row. + """ + page.goto(FIXTURE.as_uri()) + data = {"icd": _ICD_WITH_DEPRESSION} + result = depression_dx_pointer.apply(page, "9103000", data) + assert isinstance(result, ConditionResult) + assert result.condition_name == "depression_dx_pointer" + assert result.changed is True + assert len(result.changes) == 3, result.changes + + icd_changes = [c for c in result.changes if "before" in c] + checkbox_changes = [c for c in result.changes if "before" not in c] + + by_code = {c["code"]: c for c in icd_changes} + assert set(by_code) == {"99213", "G8510"}, icd_changes + assert by_code["G8510"]["after"]["icd"] == ["2", "", "", ""] + assert by_code["99213"]["after"]["icd"] == ["1", "3", "", ""] + + assert checkbox_changes == [{"row": 2, "code": "G8510"}], checkbox_changes + + +@case +def apply_retags_g8431_with_the_same_z13_31_pointer_as_g8510(page): + """ + G8510 (negative depression) and G8431 (positive depression) are + different CPT codes but share the SAME one ICD pointer, Z13.31 — there + is no separate "positive depression" ICD code (an earlier attempt to + use Z13.32 for this was the client's own uncertain guess and has been + reverted; only Z13.31 was ever actually given). Both rows end up + carrying pointer "2", and 99213 keeps carrying nothing but its own + diagnosis once the pointer is retagged onto both target rows. + """ + page.goto(FIXTURE.as_uri()) + _inject_g8431_row(page, icd1="1", checked=False) + + result = depression_dx_pointer.apply(page, "9103000", {"icd": _ICD_WITH_DEPRESSION}) + assert result.changed is True + + icd_changes = [c for c in result.changes if "before" in c] + checkbox_changes = [c for c in result.changes if "before" not in c] + + by_code = {c["code"]: c for c in icd_changes} + assert set(by_code) == {"99213", "G8510", "G8431"}, icd_changes + assert by_code["G8510"]["after"]["icd"] == ["2", "", "", ""], by_code + assert by_code["G8431"]["after"]["icd"] == ["2", "", "", ""], by_code + assert by_code["99213"]["after"]["icd"] == ["1", "3", "", ""], by_code + + checkbox_codes = {c["code"] for c in checkbox_changes} + assert checkbox_codes == {"G8510", "G8431"}, checkbox_changes + + g8431_row = page.locator(sel.SEL_CPT_ROWS).nth(3) + assert [g8431_row.locator(s).first.input_value() for s in sel.SEL_CPT_ICD] \ + == ["2", "", "", ""] + assert g8431_row.locator(sel.SEL_CPT_ASSIGN_TO_PATIENT).first.is_checked() is True + + +@case +def apply_suppresses_a_duplicate_g8510_row_keeping_the_first(page): + """ + The client's follow-up: G8510 duplicated on the same claim, both + checked. The first (CPTROW_2, from the fixture) is retagged and left + billed exactly as the base scenario proves; the duplicate (injected, + later in DOM order) gets its ICD pointer retagged too but is then + unchecked and its Billed Fee zeroed — "only one is enough". + """ + page.goto(FIXTURE.as_uri()) + _inject_duplicate_target_row(page, "G8510", icd1="1", checked=True, fee="75.00") + + result = depression_dx_pointer.apply(page, "9103000", {"icd": _ICD_WITH_DEPRESSION}) + assert result.changed is True + assert len(result.changes) == 5, result.changes + + icd_changes = [c for c in result.changes if "before" in c] + checked_changes = [c for c in result.changes + if "before" not in c and "unchecked" not in c and "fee" not in c] + duplicate_changes = [c for c in result.changes + if "before" not in c and ("unchecked" in c or "fee" in c)] + + assert {c["row"] for c in icd_changes} == {0, 2, 3}, icd_changes + assert checked_changes == [{"row": 2, "code": "G8510"}], checked_changes + assert duplicate_changes == [{ + "row": 3, "code": "G8510", + "unchecked": True, "fee": {"before": "75.00", "after": "0.00"}, + }], duplicate_changes + + first_row = page.locator(sel.SEL_CPT_ROWS).nth(2) + assert first_row.locator(sel.SEL_CPT_ASSIGN_TO_PATIENT).first.is_checked() is True + assert first_row.locator(sel.SEL_CPT_BILLED_FEE).first.input_value() == "65.00", \ + "the first G8510 row must keep its own fee — only the duplicate is zeroed" + + duplicate_row = page.locator(sel.SEL_CPT_ROWS).nth(3) + assert duplicate_row.locator(sel.SEL_CPT_ASSIGN_TO_PATIENT).first.is_checked() is False + assert duplicate_row.locator(sel.SEL_CPT_BILLED_FEE).first.input_value() == "0.00" + + +@case +def apply_leaves_a_single_g8510_row_alone_no_duplicate_suppression(page): + """ + No second G8510/G8431 row on the claim — the duplicate step must be a + no-op, proven directly against the base fixture's change count (the + same 3 changes the non-duplicate test already asserts). + """ + page.goto(FIXTURE.as_uri()) + result = depression_dx_pointer.apply(page, "9103000", {"icd": _ICD_WITH_DEPRESSION}) + assert len(result.changes) == 3, result.changes + assert not any("unchecked" in c or "fee" in c for c in result.changes), result.changes + + +@case +def apply_leaves_an_already_excluded_duplicate_g8510_alone(page): + """ + Live claim 9131000 (Logs\\run_20260929_161613.log): the first G8510 was + already checked and tagged with Z13.31 alone, and a second G8510 further + down sat unchecked at 0.00 — already excluded. The check step used to + check that duplicate and the duplicate step unchecked it again straight + after: no net change to the claim, but it was reported as changed, and + the review box listed "checkbox checked (was excluded from the claim)" + and "checkbox unchecked (duplicate row)" for the same row. There is + nothing to do on this claim at all. + """ + page.goto(FIXTURE.as_uri()) + _make_claim_already_correct(page) + _inject_duplicate_target_row(page, "G8510", icd1="2", checked=False, fee="0.00") + _count_checkbox_clicks(page, "CPTROW_3") + + result = depression_dx_pointer.apply(page, "9131000", {"icd": _ICD_WITH_DEPRESSION}) + duplicate_changes = [c for c in result.changes if c["row"] == 3] + assert duplicate_changes == [], duplicate_changes + assert result.changed is False, result.changes + assert result.changes == [], result.changes + + duplicate_row = page.locator(sel.SEL_CPT_ROWS).nth(3) + assert duplicate_row.locator(sel.SEL_CPT_ASSIGN_TO_PATIENT).first.is_checked() is False + assert page.evaluate("window.clicksOn['CPTROW_3']") == 0, \ + "the excluded duplicate's checkbox was clicked — updateBillToIns() would fire" + + +@case +def apply_checks_only_the_first_g8510_not_an_excluded_duplicate(page): + """ + The same already-excluded duplicate as above, but with the first G8510 + still unchecked (the fixture's own 9103000 shape): that first row is the + one to re-include, and only it. The duplicate — unchecked at 0.00 and + already carrying Z13.31 — is left exactly as it was. + """ + page.goto(FIXTURE.as_uri()) + _inject_duplicate_target_row(page, "G8510", icd1="2", checked=False, fee="0.00") + _count_checkbox_clicks(page, "CPTROW_3") + + result = depression_dx_pointer.apply(page, "9103000", {"icd": _ICD_WITH_DEPRESSION}) + checked_changes = [c for c in result.changes + if "before" not in c and "unchecked" not in c and "fee" not in c] + assert checked_changes == [{"row": 2, "code": "G8510"}], checked_changes + assert [c for c in result.changes if c["row"] == 3] == [], result.changes + + rows = page.locator(sel.SEL_CPT_ROWS) + assert rows.nth(2).locator(sel.SEL_CPT_ASSIGN_TO_PATIENT).first.is_checked() is True + assert rows.nth(3).locator(sel.SEL_CPT_ASSIGN_TO_PATIENT).first.is_checked() is False + assert rows.nth(3).locator(sel.SEL_CPT_BILLED_FEE).first.input_value() == "0.00" + assert page.evaluate("window.clicksOn['CPTROW_3']") == 0, \ + "the excluded duplicate's checkbox was clicked — updateBillToIns() would fire" + + +# ============================================================================= +# Which row is kept per code — the ONE shared helper both the re-include and +# the duplicate step use (operator ruling, 2026-09-30) +# ============================================================================= + +@case +def kept_row_is_the_first_row_for_a_code_when_it_is_priced(): + rows = [(2, "G8510", "65.00"), (3, "G8510", "75.00")] + assert claim_form.kept_cpt_row_per_code(rows) == {"G8510": 2} + + +@case +def kept_row_skips_zero_fee_copies_for_the_first_priced_one(): + rows = [(2, "G8510", "0.00"), (3, "G8510", ""), (4, "G8510", "65.00"), + (5, "G8510", "75.00")] + assert claim_form.kept_cpt_row_per_code(rows) == {"G8510": 4} + + +@case +def kept_row_is_the_first_by_position_when_every_copy_is_zero_fee(): + rows = [(2, "G8510", "0.00"), (3, "G8510", ""), (4, "G8510", "$0.00")] + assert claim_form.kept_cpt_row_per_code(rows) == {"G8510": 2} + + +@case +def kept_row_counts_a_row_with_no_fee_input_as_eligible(): + # None: no Billed Fee input at all — unknown, not zero. + rows = [(2, "G8510", "0.00"), (3, "G8510", None), (4, "G8510", "65.00")] + assert claim_form.kept_cpt_row_per_code(rows) == {"G8510": 3} + + +@case +def kept_row_counts_a_non_numeric_fee_as_eligible(): + # is_zero_fee("N/A") is False — an unreadable fee is not zero. + rows = [(2, "G8510", "0.00"), (3, "G8510", "N/A"), (4, "G8510", "65.00")] + assert claim_form.kept_cpt_row_per_code(rows) == {"G8510": 3} + + +@case +def kept_row_is_chosen_per_code(): + rows = [(1, "G8431", "0.00"), (2, "G8510", "65.00"), (3, "G8431", "40.00"), + (4, "G8510", "0.00")] + assert claim_form.kept_cpt_row_per_code(rows) == {"G8431": 3, "G8510": 2} + + +@case +def kept_row_of_no_rows_is_empty(): + assert claim_form.kept_cpt_row_per_code([]) == {} + + +def _set_fixture_g8510(page, *, checked=None, fee=None): + """Sets the fixture's own G8510 (CPTROW_2) checkbox and/or Billed Fee.""" + page.evaluate( + """(a) => { + const tr = document.getElementById('CPTROW_2'); + if (a.checked !== null) + tr.querySelector('[ng-click*="updateBillToIns("]').checked = a.checked; + if (a.fee !== null) + tr.querySelector('[data-fieldname="ClaimCPTBilledFee"]').value = a.fee; + }""", {"checked": checked, "fee": fee}) + + +def _state(page, index): + row = page.locator(sel.SEL_CPT_ROWS).nth(index) + return (row.locator(sel.SEL_CPT_ASSIGN_TO_PATIENT).first.is_checked(), + row.locator(sel.SEL_CPT_BILLED_FEE).first.input_value()) + + +@case +def apply_keeps_the_priced_g8510_when_the_first_copy_is_zero_fee(page): + """ + Operator ruling (2026-09-30): the G8510 kept is the first whose fee is + not zero. The fixture's G8510 (unchecked) is set to 0.00 and a later + G8510 sits unchecked at 65.00 — that one is re-included, and the 0.00 + copy is left exactly as it is: already unchecked, already 0.00, never + clicked. Before, the re-include step skipped the 0.00 first copy and + no G8510 was billed at all. + """ + page.goto(FIXTURE.as_uri()) + _set_fixture_g8510(page, fee="0.00") + _inject_duplicate_target_row(page, "G8510", icd1="1", checked=False, fee="65.00") + _count_checkbox_clicks(page, "CPTROW_2") + _count_checkbox_clicks(page, "CPTROW_3") + + result = depression_dx_pointer.apply(page, "9103000", {"icd": _ICD_WITH_DEPRESSION}) + not_retags = [c for c in result.changes if "before" not in c] + assert not_retags == [{"row": 3, "code": "G8510"}], result.changes + assert (_state(page, 2), _state(page, 3)) == ((False, "0.00"), (True, "65.00")) + assert page.evaluate("window.clicksOn") == {"CPTROW_2": 0, "CPTROW_3": 1} + + +@case +def apply_unchecks_a_checked_zero_fee_first_copy_as_the_duplicate(page): + """ + The same ruling from the other side: the first G8510 checked at 0.00, + a later one checked at 75.00. The 75.00 copy is kept and keeps its fee; + the 0.00 first copy is now the duplicate, unchecked by the duplicate + step (its fee is already 0.00, so it is not written). Called straight + here — in a real run zero_fee_uncheck has unchecked it first + (test_conditions_zero_fee_uncheck.py). + """ + page.goto(FIXTURE.as_uri()) + _set_fixture_g8510(page, checked=True, fee="0.00") + _inject_duplicate_target_row(page, "G8510", icd1="1", checked=True, fee="75.00") + + result = depression_dx_pointer.apply(page, "9103000", {"icd": _ICD_WITH_DEPRESSION}) + not_retags = [c for c in result.changes if "before" not in c] + assert not_retags == [{"row": 2, "code": "G8510", "unchecked": True}], result.changes + assert (_state(page, 2), _state(page, 3)) == ((False, "0.00"), (True, "75.00")) + + +@case +def apply_never_checks_a_zero_fee_g8510_even_when_every_copy_is_zero_fee(page): + # Every copy at zero: the first by position is the one kept, and it is + # still never checked — a line billed at zero is never billed. + page.goto(FIXTURE.as_uri()) + _set_fixture_g8510(page, fee="0.00") + _inject_duplicate_target_row(page, "G8510", icd1="1", checked=False, fee="0.00") + _count_checkbox_clicks(page, "CPTROW_2") + _count_checkbox_clicks(page, "CPTROW_3") + + result = depression_dx_pointer.apply(page, "9103000", {"icd": _ICD_WITH_DEPRESSION}) + assert [c for c in result.changes if "before" not in c] == [], result.changes + assert (_state(page, 2), _state(page, 3)) == ((False, "0.00"), (False, "0.00")) + assert page.evaluate("window.clicksOn") == {"CPTROW_2": 0, "CPTROW_3": 0} + + +@case +def re_include_and_duplicate_steps_always_agree_on_the_kept_row(page): + """ + `ensure_cpt_rows_checked()` and `zero_and_uncheck_duplicate_cpt_rows()` + decide "which G8510 is kept" through the one shared helper, so across + every shape the row the first may check is never a row the second + unchecks — claim 9131000's toggle can not come back through them + disagreeing. `(first checked, first fee, later checked, later fee)` -> + the row kept. + """ + shapes = [ + ((True, "65.00", False, "0.00"), 2), # live claim 9131000 + ((False, "65.00", False, "0.00"), 2), + ((False, "0.00", False, "65.00"), 3), + ((True, "0.00", True, "65.00"), 3), + ((False, "", True, "75.00"), 3), + ((True, "65.00", True, "75.00"), 2), + ((False, "0.00", False, "0.00"), 2), # every copy zero: first by position + ] + for (first_checked, first_fee, later_checked, later_fee), kept in shapes: + page.goto(FIXTURE.as_uri()) + _set_fixture_g8510(page, checked=first_checked, fee=first_fee) + _inject_duplicate_target_row(page, "G8510", checked=later_checked, + fee=later_fee) + before = {2: _state(page, 2), 3: _state(page, 3)} + shape = (first_checked, first_fee, later_checked, later_fee) + + checked = claim_form.ensure_cpt_rows_checked(page, {"G8510", "G8431"}) + duplicate = claim_form.zero_and_uncheck_duplicate_cpt_rows( + page, {"G8510", "G8431"}) + + assert {c["row"] for c in checked} <= {kept}, (shape, checked) + assert kept not in {c["row"] for c in duplicate}, (shape, duplicate) + other = 5 - kept + other_checked, other_fee = _state(page, other) + assert other_checked is False and claim_form.is_zero_fee(other_fee), \ + (shape, "the other copy must end excluded", _state(page, other)) + assert _state(page, kept)[1] == before[kept][1], \ + (shape, "the kept row's fee must never be written") + + +@case +def apply_logs_why_when_the_claim_is_already_correct(page): + """ + Live claim 9132000: Z13.31 was already only on G8431, so apply() + returned ConditionResult.none — but the log's last word on it was + "1 condition(s) matched — about to apply: depression_dx_pointer", which + reads as a silent failure. Other conditions name their own "nothing + to ..." reason; this one does too, and only on that path — not when + the claim did change, and not when there is no Z13.31 to act on. + """ + page.goto(FIXTURE.as_uri()) + _make_claim_already_correct(page) + out = io.StringIO() + with contextlib.redirect_stdout(out): + result = depression_dx_pointer.apply(page, "9132000", {"icd": _ICD_WITH_DEPRESSION}) + assert result.changed is False, result.changes + logged = out.getvalue() + assert "[COND depression_dx_pointer 9132000]" in logged, logged + assert "nothing to change" in logged, logged + + page.goto(FIXTURE.as_uri()) + out = io.StringIO() + with contextlib.redirect_stdout(out): + result = depression_dx_pointer.apply(page, "9103000", {"icd": _ICD_WITH_DEPRESSION}) + assert result.changed is True, result.changes + assert "nothing to change" not in out.getvalue(), out.getvalue() + + page.goto(FIXTURE.as_uri()) + out = io.StringIO() + with contextlib.redirect_stdout(out): + depression_dx_pointer.apply( + page, "9103000", {"icd": [{"#": "1", "Code": "Z30.430", "Name": "..."}]}) + assert "nothing to change" not in out.getvalue(), out.getvalue() + + +@case +def apply_is_a_no_op_when_the_claim_has_no_depression_dx(page): + page.goto(FIXTURE.as_uri()) + data = {"icd": [{"#": "1", "Code": "Z30.430", "Name": "..."}]} + result = depression_dx_pointer.apply(page, "9103000", data) + assert result.changed is False + assert result.changes == [] + + +@case +def describe_names_each_retagged_row(page): + page.goto(FIXTURE.as_uri()) + result = depression_dx_pointer.apply(page, "9103000", {"icd": _ICD_WITH_DEPRESSION}) + text = depression_dx_pointer.describe(result) + assert "G8510" in text and "99213" in text, text + + +@case +def describe_names_g8431_too_when_it_is_on_the_claim(page): + page.goto(FIXTURE.as_uri()) + _inject_g8431_row(page, icd1="1", checked=False) + result = depression_dx_pointer.apply(page, "9103000", {"icd": _ICD_WITH_DEPRESSION}) + text = depression_dx_pointer.describe(result) + assert "G8431" in text, text + + +@case +def run_all_fires_depression_dx_pointer_alongside_telehealth_independently(page): + """ + Against the REAL registry, not fakes - proves this condition is wired + into conditions.CONDITIONS and does not collide with the telehealth + conditions, which key off progress_notes/insurance, not icd. + """ + page.goto(FIXTURE.as_uri()) + data = {"icd": _ICD_WITH_DEPRESSION} + results = run_all(page, "9103000", data) + assert len(results) == 1, results + assert results[0].condition_name == "depression_dx_pointer", results + + +def _run_cases() -> int: + if not FIXTURE.exists(): + print(f" FAIL fixture missing: {FIXTURE}") + return 1 + try: + from playwright.sync_api import sync_playwright + except ImportError: + print(" SKIP playwright is not installed") + return 0 + + failures = 0 + with sync_playwright() as pw: + browser = pw.chromium.launch(headless=True) + page = browser.new_page() + + for fn in CASES: + name = fn.__name__.replace("_", " ") + try: + if len(inspect.signature(fn).parameters) == 0: + fn() + else: + fn(page) + print(f" ok {name}") + except AssertionError as exc: + print(f" FAIL {name}: {exc}") + failures += 1 + except Exception as exc: + print(f" ERROR {name}: {type(exc).__name__}: {exc}") + failures += 1 + browser.close() + + print(f"\n{len(CASES) - failures}/{len(CASES)} passed") + return 1 if failures else 0 + + +def main() -> int: + from ecw_bot import settings as _cfg + # These cases exercise eCW's own click and Add flow, as a LIVE-WRITE run + # does. VERIFY clicks no box and adds no row (eCW saves both at once, + # 2026-10-16): that is tests/test_verify_no_live_writes.py. + orig = _cfg.SAVE_CORRECTIONS + _cfg.SAVE_CORRECTIONS = True + try: + return _run_cases() + finally: + _cfg.SAVE_CORRECTIONS = orig + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tests/test_conditions_dummy_codes.py b/tests/test_conditions_dummy_codes.py new file mode 100644 index 0000000..59b348d --- /dev/null +++ b/tests/test_conditions_dummy_codes.py @@ -0,0 +1,241 @@ +r""" +conditions/dummy_codes.py — the dummy codes DCONS and Dscprit get unchecked + +zero-fee excluded, unconditionally, the same treatment j2930_j2001_update.py +gives its own target codes. BHT3/BHT4 are NOT dummy codes (operator, +2026-10-06) and must be left alone. + + python tests est_conditions_dummy_codes.py + +Uses hcfa_claim.html, same as the j2930_j2001_update tests: no dummy code has +a row of its own in that fixture, so `_inject_cpt_row()` adds one for +whichever code a case needs, rather than growing the shared fixture. +""" + +import sys +import pathlib +import inspect + +ROOT = pathlib.Path(__file__).resolve().parent.parent +sys.path.insert(0, str(ROOT / "src")) + +FIXTURE = pathlib.Path(__file__).resolve().parent / "fixtures" / "hcfa_claim.html" + +from ecw_bot.conditions import dummy_codes # noqa: E402 +from ecw_bot.conditions.base import ConditionResult, run_all # noqa: E402 +from ecw_bot.pages import claim_form # noqa: E402 +from ecw_bot.selectors import claim_form as sel # noqa: E402 + +CASES = [] + + +def case(fn): + CASES.append(fn) + return fn + + +def _inject_cpt_row(page, code, row_id="CPTROW_3", checked=True, fee="45.00"): + page.evaluate( + """(args) => { + const tbody = document.querySelector( + '#claimCPTCodesTable8888888888888 tbody'); + const addRowTr = tbody.querySelector('tr:last-child'); + const tr = document.createElement('tr'); + tr.id = args.rowId; + tr.innerHTML = + '' + + '' + + ''; + tbody.insertBefore(tr, addRowTr); + }""", {"code": code, "rowId": row_id, "checked": checked, "fee": fee}) + + +@case +def detect_true_when_dcons_is_on_the_claim(): + data = {"cpt": [{"code": "99213"}, {"code": "DCONS"}]} + assert dummy_codes.detect("C1", data) is True + + +@case +def detect_true_when_dscprit_is_on_the_claim(): + data = {"cpt": [{"code": "Dscprit"}]} + assert dummy_codes.detect("C1", data) is True + + +@case +def detect_ignores_case_and_spaces_of_the_code(): + assert dummy_codes.detect("C1", {"cpt": [{"code": " DSCPRIT "}]}) is True + assert dummy_codes.detect("C1", {"cpt": [{"code": "dcons"}]}) is True + + +@case +def detect_false_for_bht3_and_bht4_which_are_not_dummy_codes(): + data = {"cpt": [{"code": "BHT3"}, {"code": "BHT4"}]} + assert dummy_codes.detect("C1", data) is False + + +@case +def detect_false_when_no_dummy_code_is_present(): + data = {"cpt": [{"code": "99213"}, {"code": "J7298"}]} + assert dummy_codes.detect("C1", data) is False + + +@case +def detect_false_with_no_cpt_key_at_all(): + assert dummy_codes.detect("C1", {}) is False + + +@case +def apply_unchecks_and_zeroes_dcons_only(page): + page.goto(FIXTURE.as_uri()) + _inject_cpt_row(page, "DCONS", checked=True, fee="45.00") + + data = {"cpt": [{"code": "DCONS"}]} + result = dummy_codes.apply(page, "C1", data) + assert isinstance(result, ConditionResult) + assert result.condition_name == "dummy_codes" + assert result.changed is True + assert len(result.changes) == 1, result.changes + + c = result.changes[0] + assert c["code"] == "DCONS", c + assert c["unchecked"] is True, c + assert c["fee"] == {"before": "45.00", "after": "0.00"}, c + + dcons_row = page.locator(sel.SEL_CPT_ROWS).nth(3) + assert dcons_row.locator(sel.SEL_CPT_ASSIGN_TO_PATIENT).first.is_checked() is False + assert dcons_row.locator(sel.SEL_CPT_BILLED_FEE).first.input_value() == "0.00" + + row0_fee = page.locator(sel.SEL_CPT_ROWS).nth(0).locator( + sel.SEL_CPT_BILLED_FEE).first + assert row0_fee.input_value() == "210.00", "99213 must be left alone — only DCONS is targeted" + + +@case +def apply_handles_both_dummy_codes_on_the_same_claim(page): + page.goto(FIXTURE.as_uri()) + _inject_cpt_row(page, "DCONS", row_id="CPTROW_3", checked=True, fee="75.00") + _inject_cpt_row(page, "Dscprit", row_id="CPTROW_4", checked=True, fee="30.00") + + data = {"cpt": [{"code": "DCONS"}, {"code": "Dscprit"}]} + result = dummy_codes.apply(page, "C1", data) + assert result.changed is True + + by_code = {c["code"]: c for c in result.changes} + assert set(by_code) == {"DCONS", "Dscprit"}, result.changes + assert by_code["DCONS"]["unchecked"] is True, by_code + assert by_code["DCONS"]["fee"] == {"before": "75.00", "after": "0.00"} + assert by_code["Dscprit"]["unchecked"] is True, by_code + assert by_code["Dscprit"]["fee"] == {"before": "30.00", "after": "0.00"} + + assert claim_form.cpt_code_present(page, "Dscprit"), "Dscprit's row must still exist — unchecked/zeroed, never removed" + + +@case +def apply_matches_a_dummy_code_whatever_its_case(page): + page.goto(FIXTURE.as_uri()) + _inject_cpt_row(page, "DSCPRIT", checked=True, fee="30.00") + result = dummy_codes.apply(page, "C1", {"cpt": [{"code": "DSCPRIT"}]}) + assert result.changed is True + assert [c["code"] for c in result.changes] == ["DSCPRIT"], result.changes + + +@case +def apply_leaves_bht3_and_bht4_alone(page): + page.goto(FIXTURE.as_uri()) + _inject_cpt_row(page, "BHT3", row_id="CPTROW_3", checked=True, fee="45.00") + _inject_cpt_row(page, "BHT4", row_id="CPTROW_4", checked=True, fee="120.00") + result = dummy_codes.apply(page, "C1", {"cpt": [{"code": "BHT3"}, + {"code": "BHT4"}]}) + assert result.changed is False + assert result.changes == [] + for i, fee in ((3, "45.00"), (4, "120.00")): + row = page.locator(sel.SEL_CPT_ROWS).nth(i) + assert row.locator(sel.SEL_CPT_ASSIGN_TO_PATIENT).first.is_checked() is True + assert row.locator(sel.SEL_CPT_BILLED_FEE).first.input_value() == fee + + +@case +def apply_is_a_no_op_once_dcons_is_already_excluded(page): + page.goto(FIXTURE.as_uri()) + _inject_cpt_row(page, "DCONS", checked=False, fee="0.00") + + data = {"cpt": [{"code": "DCONS"}]} + result = dummy_codes.apply(page, "C1", data) + assert result.changed is False + assert result.changes == [] + + +@case +def apply_is_a_no_op_when_no_dummy_code_is_on_the_claim(page): + page.goto(FIXTURE.as_uri()) + data = {"cpt": [{"code": "99213"}]} + result = dummy_codes.apply(page, "C1", data) + assert result.changed is False + assert result.changes == [] + + +@case +def describe_names_dcons_and_the_fee_change(page): + page.goto(FIXTURE.as_uri()) + _inject_cpt_row(page, "DCONS", checked=True, fee="45.00") + data = {"cpt": [{"code": "DCONS"}]} + result = dummy_codes.apply(page, "C1", data) + text = dummy_codes.describe(result) + assert "DCONS" in text, text + assert "45.00" in text and "0.00" in text, text + + +@case +def run_all_fires_dummy_codes_alongside_others_independently(page): + """ + Against the REAL registry, not fakes — proves this condition is wired + into conditions.CONDITIONS and does not collide with the depression-DX + or telehealth conditions, which key off icd/progress_notes, not cpt. + """ + page.goto(FIXTURE.as_uri()) + _inject_cpt_row(page, "DCONS", checked=True, fee="45.00") + data = {"cpt": [{"code": "DCONS"}]} + results = run_all(page, "C1", data) + assert len(results) == 1, results + assert results[0].condition_name == "dummy_codes", results + + +def main() -> int: + if not FIXTURE.exists(): + print(f" FAIL fixture missing: {FIXTURE}") + return 1 + try: + from playwright.sync_api import sync_playwright + except ImportError: + print(" SKIP playwright is not installed") + return 0 + + failures = 0 + with sync_playwright() as pw: + browser = pw.chromium.launch(headless=True) + page = browser.new_page() + + for fn in CASES: + name = fn.__name__.replace("_", " ") + try: + if len(inspect.signature(fn).parameters) == 0: + fn() + else: + fn(page) + print(f" ok {name}") + except AssertionError as exc: + print(f" FAIL {name}: {exc}") + failures += 1 + except Exception as exc: + print(f" ERROR {name}: {type(exc).__name__}: {exc}") + failures += 1 + browser.close() + + print(f"\n{len(CASES) - failures}/{len(CASES)} passed") + return 1 if failures else 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tests/test_conditions_injection_admin_code.py b/tests/test_conditions_injection_admin_code.py new file mode 100644 index 0000000..3f80c76 --- /dev/null +++ b/tests/test_conditions_injection_admin_code.py @@ -0,0 +1,306 @@ +r""" +conditions/injection_admin_code.py — document #20: an injection code +billed without 96372 gets 96372 added, Units 1, on every payer except +Medicare (operator, 2026-10-13: the client's 41-code list as given; 96372 +is the only admin code; always Units 1). + + python tests\test_conditions_injection_admin_code.py + +Uses tests/fixtures/vaccine_claim.html — its Fee Schedule picker lists +96372 — reshaped per case with `seedCptRows`. +""" + +import io +import sys +import pathlib +import inspect +import contextlib + +ROOT = pathlib.Path(__file__).resolve().parent.parent +sys.path.insert(0, str(ROOT / "src")) + +FIXTURE = pathlib.Path(__file__).resolve().parent / "fixtures" / "vaccine_claim.html" + +from ecw_bot.conditions import injection_admin_code as inj # noqa: E402 +from ecw_bot.conditions import CONDITIONS # noqa: E402 +from ecw_bot.conditions.base import ConditionResult, run_all # noqa: E402 +from ecw_bot.core import charge_summary as cs # noqa: E402 +from ecw_bot.pages import claim_form # noqa: E402 + + +CASES = [] + + +def case(fn): + CASES.append(fn) + return fn + + +def _quiet(fn, *a, **k): + with contextlib.redirect_stdout(io.StringIO()) as out: + result = fn(*a, **k) + return result, out.getvalue() + + +def _row(code, billed=True, fee="40.00"): + return {"code": code, "billed": billed, "billed_fee": fee, "units": "1"} + + +def _data(cpt, payer="Medicaid"): + return {"cpt": cpt, + "insurance": [{"Name": payer}] if payer is not None else []} + + +_CLIENT_LIST = ( + "J0561 J0696 J1000 J1010 J1020 J1030 J1050 J1055 J1100 J1200 J1815 " + "J1885 J2001 J2003 J2004 J2315 J2360 J2405 J2550 J2788 J2919 J2920 " + "J2930 J3030 J3140 J3301 J3420 J3490 J7296 J7297 J7298 J7300 J7301 " + "J7307 J7512 J7609 J7620 J7621 J7633 J7644 J8499").split() + + +# ── recognition (no browser) ───────────────────────────────────────────────── + +@case +def the_list_is_the_clients_41_codes(): + assert len(_CLIENT_LIST) == 41 + assert inj.INJECTION_CODES == frozenset(_CLIENT_LIST) + + +@case +def every_listed_code_but_j2001_and_j2930_is_an_injection_row(): + for code in _CLIENT_LIST: + expected = code not in ("J2001", "J2930") + assert inj.is_injection_row(_row(code)) is expected, code + assert inj.is_injection_row(_row(" j1885 ")) is True, "case and spaces" + for code in ("96372", "90734", "99213", "J9999"): + assert inj.is_injection_row(_row(code)) is False, code + + +@case +def only_a_96372_the_claim_will_bill_counts_as_admin_billed(): + assert inj.admin_present([_row("96372")]) is True + assert inj.admin_present([_row("96372", fee="0.00")]) is False, \ + "a checked 96372 at 0.00 is unchecked by zero_fee_uncheck first" + assert inj.admin_present([_row("96372", billed=False, fee="0.00")]) \ + is False, "unchecked at 0.00 bills nothing" + for other in ("96373", "96374", "90471"): + assert inj.admin_present([_row(other)]) is False, other + + +@case +def an_unchecked_96372_with_a_fee_counts_it_is_checked_at_the_end(): + """Review, 2026-10-14: priced_line_check (last) checks it, so a second + 96372 added here would bill 96372 twice.""" + row = _row("96372", billed=False, fee="25.00") + assert inj.admin_present([row]) is True + data = _data([_row("J1885"), row]) + (fired, out) = _quiet(inj.detect, "C1", data) + assert fired is False, out + assert "priced_line_check checks it" in out, out + assert inj.needs_review("C1", data) is None + # No payer read: still nothing to add, and no Review asking for one by + # hand — the 96372 is there. + assert inj.needs_review("C1", _data([_row("J1885"), row], payer="")) \ + is None + + +# ── detect / needs_review ─────────────────────────────────────────────────── + +@case +def detect_an_injection_without_96372(): + assert inj.detect("C1", _data([_row("99213"), _row("J1885")])) is True + + +@case +def detect_nothing_when_96372_is_billed(): + data = _data([_row("J1885"), _row("96372")]) + (fired, _) = _quiet(inj.detect, "C1", data) + assert fired is False + assert inj.needs_review("C1", data) is None + + +@case +def an_unchecked_or_zero_fee_injection_is_not_billed(): + assert inj.detect("C1", _data([_row("J1885", billed=False)])) is False + assert inj.detect("C1", _data([_row("J1885", fee="0.00")])) is False + + +@case +def j2001_or_j2930_alone_never_needs_96372(): + for code in ("J2001", "J2930"): + data = _data([_row(code)]) + assert inj.detect("C1", data) is False, code + assert inj.needs_review("C1", data) is None, code + + +@case +def medicare_is_left_alone_and_not_review(): + data = _data([_row("J1885")], payer="Medicare") + (fired, _) = _quiet(inj.detect, "C1", data) + assert fired is False + assert inj.needs_review("C1", data) is None + + +@case +def an_unmapped_or_unread_payer_is_review(): + for payer, words in (("NO SUCH PAYER XYZ", "cannot classify"), + ("", "no primary payer name")): + data = _data([_row("J1885")], payer=payer) + (fired, _) = _quiet(inj.detect, "C1", data) + assert fired is False, payer + why = inj.needs_review("C1", data) + assert why and words in why and "96372" in why, why + + +# ── apply (browser) ───────────────────────────────────────────────────────── + +def _fresh(page, rows): + page.goto(FIXTURE.as_uri()) + page.evaluate("rows => window.seedCptRows(rows)", rows) + + +def _rows_for(page, code): + return [r for r in claim_form.extract(page)["cpt"] if r["code"] == code] + + +_TWO_INJECTIONS = [ + {"code": "99213", "title": "OFFICE VISIT EST LEVEL 3", "fee": "120.00"}, + {"code": "J1885", "title": "Toradol (30mg) ketorolac", "fee": "15.00"}, + {"code": "J3420", "title": "INJ VIT B-12 CYNOCOBLMN TO 1000 MCG", + "fee": "10.00"}, +] + + +@case +def apply_adds_96372_once_with_units_1_for_two_injections(page): + _fresh(page, _TWO_INJECTIONS) + data = claim_form.extract(page) + result, out = _quiet(inj.apply, page, "C1", data) + assert isinstance(result, ConditionResult) + assert result.changed is True and result.review == "", (result, out) + added = _rows_for(page, "96372") + assert len(added) == 1, added + assert added[0]["units"] == "1", added + c = result.changes[0] + assert c["code"] == "96372" and c["added"] is True, c + assert c["injections"] == ["J1885", "J3420"], c + assert "96372 added" in inj.summary_comment(result) + text = inj.describe(result) + assert "J1885, J3420" in text and "Added 96372" in text, text + + +@case +def apply_adds_nothing_when_96372_is_already_billed(page): + _fresh(page, _TWO_INJECTIONS + [ + {"code": "96372", "title": "THER/PROPH/DIAG INJ SC/IM", + "fee": "25.00"}]) + result, _ = _quiet(inj.apply, page, "C1", claim_form.extract(page)) + assert result.changed is False + assert len(_rows_for(page, "96372")) == 1 + + +@case +def a_96372_the_picker_cannot_find_is_review_add_by_hand(page): + _fresh(page, _TWO_INJECTIONS) + page.evaluate("() => window.removeFromFeeSchedule('96372')") + result, _ = _quiet(inj.apply, page, "C1", claim_form.extract(page)) + assert result.changed is False, result + assert "add it by hand" in result.review, result + assert _rows_for(page, "96372") == [] + + +@case +def run_all_adds_96372_and_the_summary_says_so(page): + _fresh(page, _TWO_INJECTIONS) + data = claim_form.extract(page) + data["progress_notes"] = {"opened": "modal", "text": "Office visit."} + report = {} + results, _ = _quiet(run_all, page, "C1", data, report=report) + assert "injection_admin_code" in [r.condition_name for r in results] + assert report["review"] == [] and report["failed"] == [], report + data["_conditions"] = {"fired": [r.condition_name for r in results], + "saved": False, "failed": [], "review": [], + "comments": report["comments"]} + outcome = cs.claim_outcome(True, data) + assert outcome["Category"] == "Fail", outcome + assert "injection admin code 96372 added" in outcome["Comments"], outcome + + +@case +def run_all_bills_96372_once_when_it_sat_unchecked_with_a_fee(page): + """The duplicate the review found: J1885 checked, 96372 unchecked at + 25.00. Nothing is added, and priced_line_check checks the 96372 — one + 96372 billed, not two.""" + _fresh(page, [ + {"code": "99213", "title": "OFFICE VISIT EST LEVEL 3", "fee": "120.00"}, + {"code": "J1885", "title": "Toradol (30mg) ketorolac", "fee": "15.00"}, + {"code": "96372", "title": "THER/PROPH/DIAG INJ SC/IM", + "fee": "25.00", "checked": False}]) + data = claim_form.extract(page) + data["progress_notes"] = {"opened": "modal", "text": "Office visit."} + report = {} + results, out = _quiet(run_all, page, "C1", data, report=report) + changed = [r.condition_name for r in results if r.changed] + assert "injection_admin_code" not in changed, out + assert "priced_line_check" in changed, out + admin = _rows_for(page, "96372") + assert len(admin) == 1, admin + assert admin[0]["billed"] is True, admin + assert report["review"] == [], report + + +@case +def it_is_registered_after_j2930_and_before_priced_line_check(): + names = [m.NAME for m in CONDITIONS] + i = names.index("injection_admin_code") + assert names.index("j2930_j2001_update") < i < names.index( + "priced_line_check"), names + assert inj.DENTAL_UNSUPPORTED + + +def _run_cases() -> int: + from playwright.sync_api import sync_playwright + + failures = 0 + with sync_playwright() as pw: + browser = pw.chromium.launch(headless=True) + page = browser.new_page() + for fn in CASES: + name = fn.__name__.replace("_", " ") + try: + if len(inspect.signature(fn).parameters) == 0: + fn() + else: + fn(page) + print(f" ok {name}") + except AssertionError as exc: + print(f" FAIL {name}: {exc}") + failures += 1 + except Exception as exc: + print(f" ERROR {name}: {type(exc).__name__}: {exc}") + failures += 1 + browser.close() + + print(f"\n{len(CASES) - failures}/{len(CASES)} passed") + return 1 if failures else 0 + + +def main() -> int: + """shot() writes into the operator's Screenshots\\ folder; not here.""" + import tempfile + from ecw_bot import settings as _cfg + orig = (_cfg.SCREENSHOTS, _cfg.SAVE_CORRECTIONS) + with tempfile.TemporaryDirectory() as tmp: + _cfg.SCREENSHOTS = pathlib.Path(tmp) + # These cases exercise eCW's own click and Add flow, as a LIVE-WRITE + # run does. VERIFY clicks no box and adds no row (eCW saves both at + # once, 2026-10-16): that is tests/test_verify_no_live_writes.py. + _cfg.SAVE_CORRECTIONS = True + try: + return _run_cases() + finally: + _cfg.SCREENSHOTS, _cfg.SAVE_CORRECTIONS = orig + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tests/test_conditions_j2930_j2001_update.py b/tests/test_conditions_j2930_j2001_update.py new file mode 100644 index 0000000..8542c70 --- /dev/null +++ b/tests/test_conditions_j2930_j2001_update.py @@ -0,0 +1,251 @@ +r""" +conditions/j2930_j2001_update.py — document condition #5 (CPT `J2930 & +J2001`): both codes get the same treatment, uncheck + zero the Billed +Fee. See that module's own docstring for why J2930 is NOT replaced with +J2919 despite the document's wording — the client corrected an earlier +build that attempted the replacement. + + python tests\test_conditions_j2930_j2001_update.py + +Uses hcfa_claim.html: this is the screen the checkbox/fee primitive +(`zero_and_uncheck_cpt_rows`) is actually verified against (see +test_hcfa_claim.py's own note on ub_claim.html's checkboxes carrying no +ng-click at all). Neither J2001 nor J2930 has a row of its own in that +fixture, so `_inject_cpt_row()` adds one for whichever code a case needs, +the same way the other condition test files inject their own rows, +rather than growing the shared fixture. +""" + +import sys +import pathlib +import inspect + +ROOT = pathlib.Path(__file__).resolve().parent.parent +sys.path.insert(0, str(ROOT / "src")) + +FIXTURE = pathlib.Path(__file__).resolve().parent / "fixtures" / "hcfa_claim.html" + +from ecw_bot.conditions import j2930_j2001_update # noqa: E402 +from ecw_bot.conditions.base import ConditionResult, run_all # noqa: E402 +from ecw_bot.pages import claim_form # noqa: E402 +from ecw_bot.selectors import claim_form as sel # noqa: E402 + +CASES = [] + + +def case(fn): + CASES.append(fn) + return fn + + +def _inject_cpt_row(page, code, row_id="CPTROW_3", checked=True, fee="45.00"): + page.evaluate( + """(args) => { + const tbody = document.querySelector( + '#claimCPTCodesTable8888888888888 tbody'); + const addRowTr = tbody.querySelector('tr:last-child'); + const tr = document.createElement('tr'); + tr.id = args.rowId; + tr.innerHTML = + '' + + '' + + ''; + tbody.insertBefore(tr, addRowTr); + }""", {"code": code, "rowId": row_id, "checked": checked, "fee": fee}) + + +def _inject_j2001_row(page, checked=True, fee="45.00"): + _inject_cpt_row(page, "J2001", checked=checked, fee=fee) + + +@case +def detect_true_when_j2001_is_on_the_claim(): + data = {"cpt": [{"code": "99213"}, {"code": "J2001"}]} + assert j2930_j2001_update.detect("C1", data) is True + + +@case +def detect_false_when_j2001_is_absent(): + data = {"cpt": [{"code": "99213"}, {"code": "J7298"}]} + assert j2930_j2001_update.detect("C1", data) is False + + +@case +def detect_true_when_j2930_is_on_the_claim_alone(): + data = {"cpt": [{"code": "J2930"}]} + assert j2930_j2001_update.detect("C1", data) is True + + +@case +def detect_ignores_case_and_spaces_like_the_helper_does(): + """ + Review, 2026-10-12: zero_and_uncheck_cpt_rows() matches ignoring case, + so detect() must too — or a "j2001" is never excluded. + """ + for code in ("j2001", " J2930 ", "j2930"): + data = {"cpt": [{"code": code}]} + assert j2930_j2001_update.detect("C1", data) is True, code + + +@case +def detect_false_with_no_cpt_key_at_all(): + assert j2930_j2001_update.detect("C1", {}) is False + + +@case +def apply_unchecks_and_zeroes_j2001_only(page): + page.goto(FIXTURE.as_uri()) + _inject_j2001_row(page, checked=True, fee="45.00") + + data = {"cpt": [{"code": "J2001"}]} + result = j2930_j2001_update.apply(page, "9103000", data) + assert isinstance(result, ConditionResult) + assert result.condition_name == "j2930_j2001_update" + assert result.changed is True + assert len(result.changes) == 1, result.changes + + c = result.changes[0] + assert c["code"] == "J2001", c + assert c["unchecked"] is True, c + assert c["fee"] == {"before": "45.00", "after": "0.00"}, c + + j2001_row = page.locator(sel.SEL_CPT_ROWS).nth(3) + assert j2001_row.locator(sel.SEL_CPT_ASSIGN_TO_PATIENT).first.is_checked() is False + assert j2001_row.locator(sel.SEL_CPT_BILLED_FEE).first.input_value() == "0.00" + + row0_fee = page.locator(sel.SEL_CPT_ROWS).nth(0).locator( + sel.SEL_CPT_BILLED_FEE).first + assert row0_fee.input_value() == "210.00", \ + "99213 must be left alone — only J2001 is targeted" + + +@case +def apply_unchecks_and_zeroes_j2930_the_same_way(page): + """ + The client's correction, proven directly: J2930 gets exactly the same + uncheck + zero-fee treatment as J2001 — no row removal, no J2919 + ever added. + """ + page.goto(FIXTURE.as_uri()) + _inject_cpt_row(page, "J2930", checked=True, fee="120.00") + + data = {"cpt": [{"code": "J2930"}]} + result = j2930_j2001_update.apply(page, "9103000", data) + assert result.changed is True + assert len(result.changes) == 1, result.changes + + c = result.changes[0] + assert c["code"] == "J2930", c + assert c["unchecked"] is True, c + assert c["fee"] == {"before": "120.00", "after": "0.00"}, c + + assert claim_form.cpt_code_present(page, "J2930"), \ + "J2930's row must still exist — unchecked/zeroed, never removed" + + +@case +def apply_handles_both_codes_on_the_same_claim(page): + page.goto(FIXTURE.as_uri()) + _inject_cpt_row(page, "J2001", row_id="CPTROW_3", checked=True, fee="45.00") + _inject_cpt_row(page, "J2930", row_id="CPTROW_4", checked=True, fee="120.00") + + data = {"cpt": [{"code": "J2001"}, {"code": "J2930"}]} + result = j2930_j2001_update.apply(page, "9103000", data) + assert result.changed is True + + by_code = {c["code"]: c for c in result.changes} + assert set(by_code) == {"J2001", "J2930"}, result.changes + assert by_code["J2001"]["fee"]["after"] == "0.00", by_code + assert by_code["J2930"]["fee"]["after"] == "0.00", by_code + + row0_fee = page.locator(sel.SEL_CPT_ROWS).nth(0).locator( + sel.SEL_CPT_BILLED_FEE).first + assert row0_fee.input_value() == "210.00", \ + "99213 must be left alone — neither target code" + + +@case +def apply_is_a_no_op_once_j2001_is_already_excluded(page): + page.goto(FIXTURE.as_uri()) + _inject_j2001_row(page, checked=False, fee="0.00") + + data = {"cpt": [{"code": "J2001"}]} + result = j2930_j2001_update.apply(page, "9103000", data) + assert result.changed is False + assert result.changes == [] + + +@case +def apply_is_a_no_op_when_j2001_is_not_on_the_claim(page): + page.goto(FIXTURE.as_uri()) + data = {"cpt": [{"code": "99213"}]} + result = j2930_j2001_update.apply(page, "9103000", data) + assert result.changed is False + assert result.changes == [] + + +@case +def describe_names_j2001_and_the_fee_change(page): + page.goto(FIXTURE.as_uri()) + _inject_j2001_row(page, checked=True, fee="45.00") + data = {"cpt": [{"code": "J2001"}]} + result = j2930_j2001_update.apply(page, "9103000", data) + text = j2930_j2001_update.describe(result) + assert "J2001" in text, text + assert "45.00" in text and "0.00" in text, text + + +@case +def run_all_fires_j2930_j2001_update_alongside_others_independently(page): + """ + Against the REAL registry, not fakes — proves this condition is wired + into conditions.CONDITIONS and does not collide with the depression-DX + or telehealth conditions, which key off icd/progress_notes, not cpt. + """ + page.goto(FIXTURE.as_uri()) + _inject_j2001_row(page, checked=True, fee="45.00") + data = {"cpt": [{"code": "J2001"}]} + results = run_all(page, "9103000", data) + assert len(results) == 1, results + assert results[0].condition_name == "j2930_j2001_update", results + + +def main() -> int: + if not FIXTURE.exists(): + print(f" FAIL fixture missing: {FIXTURE}") + return 1 + try: + from playwright.sync_api import sync_playwright + except ImportError: + print(" SKIP playwright is not installed") + return 0 + + failures = 0 + with sync_playwright() as pw: + browser = pw.chromium.launch(headless=True) + page = browser.new_page() + + for fn in CASES: + name = fn.__name__.replace("_", " ") + try: + if len(inspect.signature(fn).parameters) == 0: + fn() + else: + fn(page) + print(f" ok {name}") + except AssertionError as exc: + print(f" FAIL {name}: {exc}") + failures += 1 + except Exception as exc: + print(f" ERROR {name}: {type(exc).__name__}: {exc}") + failures += 1 + browser.close() + + print(f"\n{len(CASES) - failures}/{len(CASES)} passed") + return 1 if failures else 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tests/test_conditions_medicaid_pregnancy_th.py b/tests/test_conditions_medicaid_pregnancy_th.py new file mode 100644 index 0000000..8c9f4b6 --- /dev/null +++ b/tests/test_conditions_medicaid_pregnancy_th.py @@ -0,0 +1,256 @@ +r""" +conditions/medicaid_pregnancy_th.py — document #23: a pregnancy visit on +Medicaid (not RAE) gets the TH modifier on its E&M / primary line, in M1 +(overwriting what M1 held — operator 2026-10-21); Z32.00-Z32.02 (pregnancy test) do not count; a +Medicaid telehealth pregnancy visit gets TH instead of GT (telehealth #21). + + python tests\test_conditions_medicaid_pregnancy_th.py + +Uses hcfa_claim.html with injected rows (data-fieldname inputs, like +test_conditions_bh_h_code_modifier.py). +""" + +import io +import sys +import inspect +import pathlib +import contextlib + +ROOT = pathlib.Path(__file__).resolve().parent.parent +sys.path.insert(0, str(ROOT / "src")) + +FIXTURE = pathlib.Path(__file__).resolve().parent / "fixtures" / "hcfa_claim.html" + +from ecw_bot.conditions import medicaid_pregnancy_th as cond # noqa: E402 +from ecw_bot.conditions import telehealth # noqa: E402 +from ecw_bot.conditions import CONDITIONS # noqa: E402 +from ecw_bot.conditions.base import ConditionResult # noqa: E402 +from ecw_bot.core import charge_summary as cs # noqa: E402 +from ecw_bot.selectors import claim_form as sel # noqa: E402 + +CASES = [] + +MEDICAID = [{"Name": "APM2 Medicaid"}] +RAE = [{"Name": "BH COLORADO ACCESS"}] +COMMERCIAL = [{"Name": "AARP"}] + + +def case(fn): + CASES.append(fn) + return fn + + +def _data(ins=MEDICAID, icds=("O09.90",), lines=(("99214", ""),)): + cpt = [{"code": c, "billed_fee": "100.00", "billed": True, "mod1": m1, + "mod2": "", "mod3": "", "mod4": ""} for c, m1 in lines] + return {"screen": "hcfa", "insurance": list(ins), + "icd": [{"Code": i} for i in icds], "cpt": cpt} + + +def _inject(page, code="99213", mods=("", "", "", ""), row_id="CPTROW_7"): + page.evaluate( + """(a) => { + const tbody = document.querySelector( + '#claimCPTCodesTable8888888888888 tbody'); + const last = tbody.querySelector('tr:last-child'); + const tr = document.createElement('tr'); + tr.id = a.rowId; + let html = '' + + '' + + ''; + a.mods.forEach((m, i) => { + html += ''; + }); + html += ''; + tr.innerHTML = html; + tbody.insertBefore(tr, last); + }""", {"code": code, "mods": list(mods), "rowId": row_id}) + + +def _mods_of(page, code): + rows = page.locator(sel.SEL_CPT_ROWS) + for i in range(rows.count()): + row = rows.nth(i) + c = row.locator(sel.SEL_CPT_CODE).first + if c.count() and c.input_value().strip() == code: + return [row.locator(s).first.input_value() for s in sel.SEL_CPT_MOD] + return None + + +def _apply(page, data): + with contextlib.redirect_stdout(io.StringIO()): + return cond.apply(page, "C1", data) + + +# ── detect (no browser) ────────────────────────────────────────────────────── + +@case +def a_pregnancy_icd_on_medicaid_fires(): + for icd in ("O09.90", "O26.891", "Z34.81", "Z33.1", "Z3A.12", "Z36.89"): + assert cond.detect("C1", _data(icds=("Z00.00", icd))), icd + + +@case +def pregnancy_test_icds_alone_do_not_fire(): + assert not cond.detect("C1", _data(icds=("Z32.00",))) + assert not cond.detect("C1", _data(icds=("Z32.01", "Z32.02", "R10.9"))) + assert cond.detect("C1", _data(icds=("Z32.01", "O09.90"))) + + +@case +def medicaid_only_not_rae_or_others(): + assert not cond.detect("C1", _data(ins=RAE)) + assert not cond.detect("C1", _data(ins=COMMERCIAL)) + assert not cond.detect("C1", _data(ins=[])) + assert not cond.detect("C1", {}) + + +@case +def th_already_on_the_line_is_nothing(): + data = _data() + data["cpt"][0]["mod2"] = "th" + assert not cond.detect("C1", data) + + +@case +def the_e_and_m_line_is_the_target_else_the_first_billed(): + data = _data(lines=(("90471", ""), ("99214", ""), ("99213", ""))) + assert cond.target_code(data) == "99214" + data = _data(lines=(("59025", ""), ("81025", ""))) + assert cond.target_code(data) == "59025" + data = _data(lines=(("99213", ""),)) + data["cpt"][0]["billed_fee"] = "0.00" + assert cond.target_code(data) is None + assert not cond.detect("C1", data) + + +# ── apply (browser) ────────────────────────────────────────────────────────── + +@case +def th_goes_in_an_empty_m1(page): + page.goto(FIXTURE.as_uri()) + _inject(page, "99214", ("", "", "", "")) + result = _apply(page, _data()) + assert isinstance(result, ConditionResult) and result.changed, result + assert result.review == "", result.review + assert _mods_of(page, "99214") == ["TH", "", "", ""] + c = result.changes[0] + assert c["code"] == "99214" and c["box"] == 1 and c["icds"] == ["O09.90"] + assert "TH modifier added on 99214" in cond.summary_comment(result) + assert "M1" in cond.describe(result) + + +@case +def m1_in_use_is_overwritten_with_th(page): + page.goto(FIXTURE.as_uri()) + _inject(page, "99214", ("25", "", "", "")) + result = _apply(page, _data(lines=(("99214", "25"),))) + assert _mods_of(page, "99214") == ["TH", "", "", ""] + c = result.changes[0] + assert c["box"] == 1 and c["before"] == "25" and result.review == "" + assert "TH modifier added on 99214, replacing 25" in cond.summary_comment(result) + assert "M1 25 -> TH" in cond.describe(result) + + +@case +def all_four_boxes_full_still_overwrites_m1_only(page): + page.goto(FIXTURE.as_uri()) + _inject(page, "99214", ("25", "59", "XU", "GA")) + result = _apply(page, _data(lines=(("99214", "25"),))) + assert result.changed is True and result.review == "", result + assert _mods_of(page, "99214") == ["TH", "59", "XU", "GA"] + + +@case +def th_in_another_box_is_left_alone(page): + page.goto(FIXTURE.as_uri()) + _inject(page, "99214", ("25", "TH", "", "")) + result = _apply(page, _data(lines=(("99214", "25"),))) + assert result.changed is False and result.review == "", result + assert _mods_of(page, "99214") == ["25", "TH", "", ""] + + +@case +def th_already_on_the_live_line_is_nothing(page): + page.goto(FIXTURE.as_uri()) + _inject(page, "99214", ("TH", "", "", "")) + result = _apply(page, _data()) + assert result.changed is False and result.review == "", result + + +@case +def a_line_missing_from_the_live_grid_is_review(page): + page.goto(FIXTURE.as_uri()) + result = _apply(page, _data(lines=(("99215", ""),))) + assert result.changed is False and "could not be read" in result.review + + +# ── telehealth #21: TH, not GT ─────────────────────────────────────────────── + +@case +def telehealth_writes_th_not_gt_on_a_medicaid_pregnancy_visit(page): + page.goto(FIXTURE.as_uri()) + data = _data() + data["progress_notes"] = {"opened": "tab", + "text": "Session Setting (PoS): TeleHealth"} + rows = page.locator(sel.SEL_CPT_ROWS) + first = rows.nth(0).locator(sel.SEL_CPT_CODE).first.input_value().strip() + with contextlib.redirect_stdout(io.StringIO()): + result = telehealth.apply(page, "C1", data) + assert result.changed, result + assert result.changes[0]["modifier"] == "TH", result.changes + assert _mods_of(page, first)[0] == "TH" + + +@case +def telehealth_keeps_gt_for_rae_and_for_no_pregnancy(page): + for ins, icds in ((RAE, ("O09.90",)), (MEDICAID, ("Z32.01",))): + page.goto(FIXTURE.as_uri()) + data = _data(ins=ins, icds=icds) + data["progress_notes"] = {"opened": "tab", + "text": "Session Setting (PoS): TeleHealth"} + with contextlib.redirect_stdout(io.StringIO()): + result = telehealth.apply(page, "C1", data) + assert result.changes[0]["modifier"] == "GT", (ins, icds) + + +# ── registry and Charge Summary ────────────────────────────────────────────── + +@case +def it_is_registered_after_telehealth_and_before_priced_line_check(): + names = [m.NAME for m in CONDITIONS] + assert names.index("telehealth") < names.index("medicaid_pregnancy_th") \ + < names.index("priced_line_check"), names + assert "medicaid_pregnancy_th" in cs.COMMENTS + + +def main() -> int: + from playwright.sync_api import sync_playwright + + failures = 0 + with sync_playwright() as pw: + browser = pw.chromium.launch(headless=True) + page = browser.new_page() + for fn in CASES: + name = fn.__name__.replace("_", " ") + try: + if len(inspect.signature(fn).parameters) == 0: + fn() + else: + fn(page) + print(f" ok {name}") + except AssertionError as exc: + print(f" FAIL {name}: {exc}") + failures += 1 + except Exception as exc: + print(f" ERROR {name}: {type(exc).__name__}: {exc}") + failures += 1 + browser.close() + + print(f"\n{len(CASES) - failures}/{len(CASES)} passed") + return 1 if failures else 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tests/test_conditions_medicaid_uncheck_cpts.py b/tests/test_conditions_medicaid_uncheck_cpts.py new file mode 100644 index 0000000..efef217 --- /dev/null +++ b/tests/test_conditions_medicaid_uncheck_cpts.py @@ -0,0 +1,214 @@ +r""" +conditions/medicaid_uncheck_cpts.py — document #43 (15851, 15852) and #44 +(73521): never billed to Medicaid, so on a Medicaid claim each is +unchecked and its Billed Fee zeroed (operator, 2026-10-13). Any other +payer — Medicare, Commercial, a RAE payer, an unmapped or unread one — +keeps them. + + python tests\test_conditions_medicaid_uncheck_cpts.py + +Uses hcfa_claim.html like the dummy codes tests: none of the three codes +has a row there, so `_inject_cpt_row()` adds one per case. +""" + +import io +import sys +import pathlib +import inspect +import contextlib + +ROOT = pathlib.Path(__file__).resolve().parent.parent +sys.path.insert(0, str(ROOT / "src")) + +FIXTURE = pathlib.Path(__file__).resolve().parent / "fixtures" / "hcfa_claim.html" + +from ecw_bot.conditions import medicaid_uncheck_cpts as cond # noqa: E402 +from ecw_bot.conditions import CONDITIONS # noqa: E402 +from ecw_bot.conditions.base import ConditionResult, run_all # noqa: E402 +from ecw_bot.core import charge_summary as cs # noqa: E402 +from ecw_bot.selectors import claim_form as sel # noqa: E402 + +CASES = [] + + +def case(fn): + CASES.append(fn) + return fn + + +def _claim(payer, *codes): + return {"insurance": [{"Name": payer}] if payer is not None else [], + "cpt": [{"code": c, "checked": True, "fee": "45.00"} + for c in codes]} + + +def _inject_cpt_row(page, code, row_id="CPTROW_3", checked=True, fee="45.00"): + page.evaluate( + """(args) => { + const tbody = document.querySelector( + '#claimCPTCodesTable8888888888888 tbody'); + const addRowTr = tbody.querySelector('tr:last-child'); + const tr = document.createElement('tr'); + tr.id = args.rowId; + tr.innerHTML = + '' + + '' + + ''; + tbody.insertBefore(tr, addRowTr); + }""", {"code": code, "rowId": row_id, "checked": checked, "fee": fee}) + + +# ── detect (no browser) ────────────────────────────────────────────────────── + +@case +def detect_each_code_on_a_medicaid_claim(): + for code in ("15851", "15852", "73521"): + assert cond.detect("C1", _claim("Medicaid", "99213", code)), code + + +@case +def detect_any_payer_in_the_medicaid_group(): + assert cond.detect("C1", _claim("HCFA Medicaid", "73521")) is True + + +@case +def detect_ignores_spaces_around_the_code(): + assert cond.detect("C1", _claim("Medicaid", " 15851 ")) is True + + +@case +def detect_false_on_any_other_payer(): + # Operator, 2026-10-14: "Medicaid only, not RAE and others" — a payer of + # either RAE group (BH, Reporting Group) or CHP+ keeps the codes too. + for payer in ("Medicare", "AARP", "BH COLORADO ACCESS", + "Rocky Mountain Health Prime", "Child Health Plan Plus"): + assert cond.detect("C1", _claim(payer, "15851", "73521")) is False, \ + payer + + +@case +def detect_false_on_an_unmapped_or_unread_payer(): + assert cond.detect("C1", _claim("NO SUCH PAYER XYZ", "15851")) is False + assert cond.detect("C1", _claim("", "15851")) is False + assert cond.detect("C1", _claim(None, "15851")) is False + + +@case +def detect_false_on_a_medicaid_claim_without_the_codes(): + assert cond.detect("C1", _claim("Medicaid", "99213", "73520")) is False + assert cond.detect("C1", {}) is False + + +# ── apply (browser) ────────────────────────────────────────────────────────── + +@case +def apply_unchecks_and_zeroes_the_code_only(page): + page.goto(FIXTURE.as_uri()) + _inject_cpt_row(page, "73521", checked=True, fee="120.00") + result = cond.apply(page, "C1", _claim("Medicaid", "73521")) + assert isinstance(result, ConditionResult) + assert result.condition_name == "medicaid_uncheck_cpts" + assert result.changed is True and len(result.changes) == 1, result + c = result.changes[0] + assert c["code"] == "73521" and c["unchecked"] is True, c + assert c["fee"] == {"before": "120.00", "after": "0.00"}, c + row = page.locator(sel.SEL_CPT_ROWS).nth(3) + assert row.locator(sel.SEL_CPT_ASSIGN_TO_PATIENT).first.is_checked() is False + assert row.locator(sel.SEL_CPT_BILLED_FEE).first.input_value() == "0.00" + first = page.locator(sel.SEL_CPT_ROWS).nth(0).locator( + sel.SEL_CPT_BILLED_FEE).first + assert first.input_value() == "210.00", "99213 must be left alone" + + +@case +def apply_handles_15851_and_15852_together(page): + page.goto(FIXTURE.as_uri()) + _inject_cpt_row(page, "15851", row_id="CPTROW_3", fee="80.00") + _inject_cpt_row(page, "15852", row_id="CPTROW_4", fee="60.00") + result = cond.apply(page, "C1", _claim("Medicaid", "15851", "15852")) + by_code = {c["code"]: c for c in result.changes} + assert set(by_code) == {"15851", "15852"}, result.changes + assert by_code["15851"]["fee"] == {"before": "80.00", "after": "0.00"} + assert by_code["15852"]["fee"] == {"before": "60.00", "after": "0.00"} + + +@case +def apply_is_a_no_op_once_already_excluded(page): + page.goto(FIXTURE.as_uri()) + _inject_cpt_row(page, "15851", checked=False, fee="0.00") + result = cond.apply(page, "C1", _claim("Medicaid", "15851")) + assert result.changed is False and result.changes == [] + + +@case +def describe_names_the_code_and_the_fee_change(page): + page.goto(FIXTURE.as_uri()) + _inject_cpt_row(page, "15852", fee="60.00") + text = cond.describe(cond.apply(page, "C1", _claim("Medicaid", "15852"))) + assert "15852" in text and "60.00" in text and "0.00" in text, text + + +# ── wiring ─────────────────────────────────────────────────────────────────── + +@case +def it_is_registered_before_priced_line_check(): + names = [m.NAME for m in CONDITIONS] + assert "medicaid_uncheck_cpts" in names + assert names.index("medicaid_uncheck_cpts") < names.index( + "priced_line_check"), names + + +@case +def the_charge_summary_has_its_comment(): + assert "15851/15852/73521" in cs.COMMENTS["medicaid_uncheck_cpts"] + + +@case +def run_all_fires_it_on_a_medicaid_claim_and_not_on_medicare(page): + page.goto(FIXTURE.as_uri()) + _inject_cpt_row(page, "73521", fee="120.00") + with contextlib.redirect_stdout(io.StringIO()): + results = run_all(page, "C1", _claim("Medicaid", "73521")) + assert [r.condition_name for r in results] == ["medicaid_uncheck_cpts"], \ + results + page.goto(FIXTURE.as_uri()) + _inject_cpt_row(page, "73521", fee="120.00") + with contextlib.redirect_stdout(io.StringIO()): + results = run_all(page, "C1", _claim("Medicare", "73521")) + assert "medicaid_uncheck_cpts" not in [r.condition_name + for r in results], results + row = page.locator(sel.SEL_CPT_ROWS).nth(3) + assert row.locator(sel.SEL_CPT_BILLED_FEE).first.input_value() == "120.00" + + +def main() -> int: + from playwright.sync_api import sync_playwright + + failures = 0 + with sync_playwright() as pw: + browser = pw.chromium.launch(headless=True) + page = browser.new_page() + for fn in CASES: + name = fn.__name__.replace("_", " ") + try: + if len(inspect.signature(fn).parameters) == 0: + fn() + else: + fn(page) + print(f" ok {name}") + except AssertionError as exc: + print(f" FAIL {name}: {exc}") + failures += 1 + except Exception as exc: + print(f" ERROR {name}: {type(exc).__name__}: {exc}") + failures += 1 + browser.close() + + print(f"\n{len(CASES) - failures}/{len(CASES)} passed") + return 1 if failures else 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tests/test_conditions_medicare_billing_screening.py b/tests/test_conditions_medicare_billing_screening.py new file mode 100644 index 0000000..48dd8b8 --- /dev/null +++ b/tests/test_conditions_medicare_billing_screening.py @@ -0,0 +1,343 @@ +r""" +conditions/medicare_billing_screening.py — document #14: on a Medicare claim +the depression screening codes G8510/G8431 are not billed (unchecked, Billed +Fee zeroed), and the depression-pointer process (#6) does not run. Only the +screening codes are touched; every other CPT line is left as it is. + + python tests\test_conditions_medicare_billing_screening.py + +Uses hcfa_claim.html: 99213 (row 0), J7298 (row 1), and G8510 (row 2, +unchecked at 65.00 in the fixture — checked here where a case needs it). +""" + +import sys +import pathlib +import inspect + +ROOT = pathlib.Path(__file__).resolve().parent.parent +sys.path.insert(0, str(ROOT / "src")) + +FIXTURE = pathlib.Path(__file__).resolve().parent / "fixtures" / "hcfa_claim.html" + +from ecw_bot.conditions import medicare_billing_screening as mbs # noqa: E402 +from ecw_bot.conditions import depression_dx_pointer # noqa: E402 +from ecw_bot.conditions.base import run_all # noqa: E402 +from ecw_bot.pages import claim_form # noqa: E402 +from ecw_bot.selectors import claim_form as sel # noqa: E402 + +CASES = [] + +MEDICARE = [{"Name": "HCFA Medicare"}] +MEDICARE_ADVANTAGE = [{"Name": "AARP Medicare Advantage"}] +MEDICAID = [{"Name": "APM2 Medicaid"}] + + +def case(fn): + CASES.append(fn) + return fn + + +def _check_row(page, index): + page.evaluate( + """(i) => { + const rows = document.querySelectorAll('tr[id^="CPTROW_"]'); + const box = rows[i].querySelector('[ng-click*="updateBillToIns("]'); + box.checked = true; + box.setAttribute('checked', ''); + }""", index) + + +def _row_state(page, index): + row = page.locator(sel.SEL_CPT_ROWS).nth(index) + return (row.locator(sel.SEL_CPT_ASSIGN_TO_PATIENT).first.is_checked(), + row.locator(sel.SEL_CPT_BILLED_FEE).first.input_value()) + + +def _remove_rows_except(page, keep_codes): + page.evaluate( + """(keep) => { + for (const tr of document.querySelectorAll('tr[id^="CPTROW_"]')) { + const c = tr.querySelector('[data-fieldname="claimCPTCode"]'); + if (c && !keep.includes(c.value)) tr.remove(); + } + }""", keep_codes) + + +# ── detect ─────────────────────────────────────────────────────────────────── + +@case +def detect_true_for_medicare_with_g8510(): + data = {"insurance": MEDICARE, "cpt": [{"code": "99213"}, {"code": "G8510"}]} + assert mbs.detect("C1", data) is True + + +@case +def detect_true_for_medicare_with_g8431(): + data = {"insurance": MEDICARE, "cpt": [{"code": "G8431"}]} + assert mbs.detect("C1", data) is True + + +@case +def detect_false_for_a_medicare_advantage_plan(): + # Ruling 2026-10-21: the dashboard's Commercial Ins, not Medicare. + data = {"insurance": MEDICARE_ADVANTAGE, "cpt": [{"code": "G8510"}]} + assert mbs.detect("C1", data) is False + + +@case +def detect_false_for_a_non_medicare_payer(): + data = {"insurance": MEDICAID, "cpt": [{"code": "G8510"}]} + assert mbs.detect("C1", data) is False + + +@case +def detect_false_for_an_unmapped_or_missing_payer(): + assert mbs.detect("C1", {"insurance": [{"Name": "Nobody Knows"}], + "cpt": [{"code": "G8510"}]}) is False + assert mbs.detect("C1", {"cpt": [{"code": "G8510"}]}) is False + + +@case +def detect_false_for_medicare_without_screening_codes(): + data = {"insurance": MEDICARE, "cpt": [{"code": "99213"}]} + assert mbs.detect("C1", data) is False + + +# ── the depression process (#6) stays off for Medicare ────────────────────── + +_DEPRESSION_ICD = [{"#": "2", "Code": "Z13.31"}] + + +@case +def depression_process_does_not_run_for_medicare(): + data = {"insurance": MEDICARE, "icd": _DEPRESSION_ICD, + "cpt": [{"code": "99213"}, {"code": "G8510"}]} + assert depression_dx_pointer.detect("C1", data) is False + + +@case +def depression_process_still_runs_for_other_payers(): + for ins in (MEDICAID, [{"Name": "Nobody Knows"}], []): + data = {"insurance": ins, "icd": _DEPRESSION_ICD, + "cpt": [{"code": "99213"}, {"code": "G8510"}]} + assert depression_dx_pointer.detect("C1", data) is True, ins + + +# ── apply ──────────────────────────────────────────────────────────────────── + +@case +def apply_unchecks_and_zeroes_g8510_and_leaves_other_codes(page): + page.goto(FIXTURE.as_uri()) + _check_row(page, 2) + before = [_row_state(page, i) for i in (0, 1)] + data = {"insurance": MEDICARE, "cpt": claim_form.extract(page)["cpt"]} + + result = mbs.apply(page, "C1", data) + assert result.changed is True + assert result.review == "", result.review + assert result.changes == [{"row": 2, "code": "G8510", "unchecked": True, + "fee": {"before": "65.00", "after": "0.00"}}], \ + result.changes + assert _row_state(page, 2) == (False, "0.00") + assert [_row_state(page, i) for i in (0, 1)] == before, \ + "99213 and J7298 must be left exactly as they were" + + +@case +def apply_zeroes_an_already_unchecked_priced_g8510(page): + page.goto(FIXTURE.as_uri()) + data = {"insurance": MEDICARE, "cpt": claim_form.extract(page)["cpt"]} + result = mbs.apply(page, "C1", data) + assert result.changed is True + assert result.changes == [{"row": 2, "code": "G8510", + "fee": {"before": "65.00", "after": "0.00"}}], \ + result.changes + + +@case +def apply_screening_only_zeroes_it_and_asks_for_provider_clarification(page): + page.goto(FIXTURE.as_uri()) + _remove_rows_except(page, ["G8510"]) + _check_row(page, 0) + data = {"insurance": MEDICARE, "cpt": claim_form.extract(page)["cpt"]} + + result = mbs.apply(page, "C1", data) + assert result.changed is True + assert _row_state(page, 0) == (False, "0.00") + assert result.review == mbs.SCREENING_ONLY, result.review + + +@case +def apply_is_a_no_op_once_screening_is_already_excluded(page): + page.goto(FIXTURE.as_uri()) + page.evaluate("""() => { + const rows = document.querySelectorAll('tr[id^="CPTROW_"]'); + rows[2].querySelector('[data-fieldname="ClaimCPTBilledFee"]').value = '0.00'; + }""") + data = {"insurance": MEDICARE, "cpt": claim_form.extract(page)["cpt"]} + result = mbs.apply(page, "C1", data) + assert result.changed is False + assert result.changes == [] + + +def _set_row(page, index, *, checked=None, fee=None, drop_checkbox=False): + page.evaluate( + """(a) => { + const tr = document.querySelectorAll('tr[id^="CPTROW_"]')[a.i]; + const box = tr.querySelector('[ng-click*="updateBillToIns("]'); + if (a.drop) box.remove(); + else if (a.checked !== null) { + box.checked = a.checked; + if (a.checked) box.setAttribute('checked', ''); + else box.removeAttribute('checked'); + } + if (a.fee !== null) + tr.querySelector('[data-fieldname="ClaimCPTBilledFee"]').value = a.fee; + }""", {"i": index, "checked": checked, "fee": fee, + "drop": drop_checkbox}) + + +@case +def screening_only_is_judged_on_the_grid_after_the_other_conditions(page): + """ + Review finding 1 (2026-10-12): the read said 99213 was billed, but an + earlier condition (zero_fee_uncheck, a dummy/J-code exclusion) has + unchecked it since — once G8510 is gone too, nothing is billed. + """ + page.goto(FIXTURE.as_uri()) + _remove_rows_except(page, ["99213", "G8510"]) + _check_row(page, 1) + data = {"insurance": MEDICARE, "cpt": claim_form.extract(page)["cpt"]} + assert data["cpt"][0]["billed"] is True + _set_row(page, 0, checked=False, fee="0.00") + + result = mbs.apply(page, "C1", data) + assert result.changed is True + assert result.review == mbs.SCREENING_ONLY, result.review + + +@case +def a_ub_line_with_no_checkbox_at_zero_is_not_billed(page): + """UB: no Assign-To-Patient checkbox (billed None) at 0.00.""" + page.goto(FIXTURE.as_uri()) + _remove_rows_except(page, ["99213", "G8510"]) + _check_row(page, 1) + _set_row(page, 0, drop_checkbox=True, fee="0.00") + data = {"insurance": MEDICARE, "cpt": claim_form.extract(page)["cpt"]} + result = mbs.apply(page, "C1", data) + assert result.review == mbs.SCREENING_ONLY, result.review + + +@case +def a_billed_line_left_on_the_grid_is_not_screening_only(page): + page.goto(FIXTURE.as_uri()) + _remove_rows_except(page, ["99213", "G8510"]) + _check_row(page, 1) + _set_row(page, 0, drop_checkbox=True, fee="210.00") + data = {"insurance": MEDICARE, "cpt": claim_form.extract(page)["cpt"]} + result = mbs.apply(page, "C1", data) + assert result.changed is True and result.review == "", result.review + + +@case +def an_empty_live_grid_declines_instead_of_passing(page): + """Review finding 2: the grid did not re-render after the tab click.""" + page.goto(FIXTURE.as_uri()) + _check_row(page, 2) + data = {"insurance": MEDICARE, "cpt": claim_form.extract(page)["cpt"]} + _remove_rows_except(page, []) + result = mbs.apply(page, "C1", data) + assert result.changed is False + assert result.review == mbs.CPT_GRID_NOT_READ, result.review + + +@case +def summary_comment_says_fee_zeroed_when_no_checkbox_was_unchecked(): + """ + Review finding 3, operator 2026-10-12 (option A): on a UB form with no + Assign-To-Patient checkbox, zeroing the fee is the fix — the comment + must not claim "unchecked". + """ + fee_only = mbs.ConditionResult( + condition_name=mbs.NAME, changed=True, + changes=[{"row": 0, "code": "G8510", + "fee": {"before": "65.00", "after": "0.00"}}]) + text = mbs.summary_comment(fee_only) + assert "fee zeroed" in text and "unchecked" not in text, text + + both = mbs.ConditionResult( + condition_name=mbs.NAME, changed=True, + changes=[{"row": 0, "code": "G8510", "unchecked": True, + "fee": {"before": "65.00", "after": "0.00"}}]) + text = mbs.summary_comment(both) + assert "unchecked" in text and "fee zeroed" in text, text + + +@case +def describe_names_the_code_and_the_fee(page): + page.goto(FIXTURE.as_uri()) + _check_row(page, 2) + data = {"insurance": MEDICARE, "cpt": claim_form.extract(page)["cpt"]} + text = mbs.describe(mbs.apply(page, "C1", data)) + assert "G8510" in text and "Medicare" in text, text + assert "65.00" in text and "0.00" in text, text + + +@case +def run_all_on_medicare_runs_this_and_not_the_depression_process(page): + """ + Against the REAL registry: on a Medicare claim with Z13.31, only this + condition fires — depression_dx_pointer would otherwise re-check the + G8510 this one just excluded. + """ + page.goto(FIXTURE.as_uri()) + _check_row(page, 2) + data = claim_form.extract(page) + data["insurance"] = MEDICARE + data["icd"] = _DEPRESSION_ICD + + report = {} + results = run_all(page, "C1", data, report=report) + assert "depression_dx_pointer" not in report["fired"], report + assert [r.condition_name for r in results] == ["medicare_billing_screening"], \ + results + assert _row_state(page, 2) == (False, "0.00") + + +def main() -> int: + if not FIXTURE.exists(): + print(f" FAIL fixture missing: {FIXTURE}") + return 1 + try: + from playwright.sync_api import sync_playwright + except ImportError: + print(" SKIP playwright is not installed") + return 0 + + failures = 0 + with sync_playwright() as pw: + browser = pw.chromium.launch(headless=True) + page = browser.new_page() + + for fn in CASES: + name = fn.__name__.replace("_", " ") + try: + if len(inspect.signature(fn).parameters) == 0: + fn() + else: + fn(page) + print(f" ok {name}") + except AssertionError as exc: + print(f" FAIL {name}: {exc}") + failures += 1 + except Exception as exc: + print(f" ERROR {name}: {type(exc).__name__}: {exc}") + failures += 1 + browser.close() + + print(f"\n{len(CASES) - failures}/{len(CASES)} passed") + return 1 if failures else 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tests/test_conditions_medicare_pps_code.py b/tests/test_conditions_medicare_pps_code.py new file mode 100644 index 0000000..96dcaa9 --- /dev/null +++ b/tests/test_conditions_medicare_pps_code.py @@ -0,0 +1,380 @@ +r""" +conditions/medicare_pps_code.py — document #25: a PPS Payer's UB claim must +carry the PPS Code its billed Qualifying Codes map to on the client's +Medicare G code crosswalk (G0466–G0470; operator, 2026-10-23, spec EEWA-34). + + python tests\test_conditions_medicare_pps_code.py + +Terms (GLOSSARY.md): PPS Code, Qualifying Code, PPS Payer. Claim numbers +here are made up. +""" + +import io +import sys +import pathlib +import inspect +import contextlib + +ROOT = pathlib.Path(__file__).resolve().parent.parent +sys.path.insert(0, str(ROOT / "src")) + +from ecw_bot.conditions import medicare_pps_code as pps # noqa: E402 + +CASES = [] + + +def case(fn): + CASES.append(fn) + return fn + + +def _quiet(fn, *a, **k): + with contextlib.redirect_stdout(io.StringIO()) as out: + result = fn(*a, **k) + return result, out.getvalue() + + +def _row(code, billed=True, fee="120.00"): + return {"code": code, "billed": billed, "billed_fee": fee, "units": "1"} + + +def _data(codes, payer="Medicare", screen="ub"): + cpt = [c if isinstance(c, dict) else _row(c) for c in codes] + return {"cpt": cpt, "screen": screen, + "insurance": [{"Name": payer}] if payer is not None else []} + + +# ── decisions (no browser) ─────────────────────────────────────────────────── + +@case +def an_office_visit_without_its_pps_code_is_detected(): + fired, _ = _quiet(pps.detect, "C1", _data(["99213"])) + assert fired is True + + +def _adds(data): + return [pps_code for pps_code, _ in pps.decide(data)["add"]] + + +@case +def each_visit_kind_maps_to_its_pps_code(): + for codes, want in ((["99203"], "G0466"), (["92002"], "G0466"), + (["99341"], "G0466H"), (["99213"], "G0467"), + (["Q0091"], "G0467"), (["99309"], "G0467NH"), + (["99318"], "G0467NH"), (["99348"], "G0467H"), + (["99336"], "G0467"), (["G0439"], "G0468")): + assert _adds(_data(codes)) == [want], (codes, pps.decide(_data(codes))) + assert pps.decide(_data(["99203"]))["add"] == [("G0466", ["99203"])] + + +@case +def an_awv_or_ippe_takes_g0468_even_with_an_office_visit(): + assert _adds(_data(["99213", "G0438"])) == ["G0468"] + + +@case +def the_right_pps_code_already_billed_needs_nothing(): + for codes in (["99213", "G0467"], ["99203", "G0466"], + ["99309", "G0467NH"], ["G0439", "G0468"]): + data = _data(codes) + fired, _ = _quiet(pps.detect, "C1", data) + assert fired is False, codes + assert pps.needs_review("C1", data) is None, codes + + +@case +def a_code_that_does_not_end_billed_does_not_qualify(): + assert _adds(_data([_row("99213", billed=False, fee="0.00")])) == [] + assert _adds(_data([_row("99213", fee="0.00")])) == [] + assert _adds(_data(["36415"])) == [], "not on the crosswalk" + + +def _review(codes, **kw): + data = _data(codes, **kw) + fired, _ = _quiet(pps.detect, "C1", data) + assert fired is False, (codes, pps.decide(data)) + return pps.needs_review("C1", data) or "" + + +@case +def codes_on_both_lists_only_are_review_new_or_established(): + for codes in (["G0444"], ["97802"], ["G0444", "G0101"]): + why = _review(codes) + assert "new or established" in why and "G0466" in why \ + and "G0467" in why and codes[0] in why, (codes, why) + + +@case +def a_code_on_one_list_settles_the_ones_on_both(): + assert _adds(_data(["99213", "G0444"])) == ["G0467"] + assert pps.decide(_data(["99213", "G0444"]))["add"] == \ + [("G0467", ["99213"])] + + +@case +def conflicting_visit_codes_are_review_and_nothing_is_added(): + for codes, a, b in ((["99203", "99213"], "G0466", "G0467"), + (["99213", "99348"], "G0467", "G0467H")): + why = _review(codes) + assert "conflicting" in why and a in why and b in why, (codes, why) + assert _adds(_data(codes)) == [], codes + + +@case +def conflicting_codes_stay_review_with_one_of_their_pps_codes_billed(): + why = _review(["99203", "99213", "G0467"]) + assert "conflicting" in why and "G0466" in why, why + + +@case +def a_different_pps_code_billed_is_review_and_not_changed(): + why = _review(["99203", "G0467"]) + assert "G0467 billed" in why and "G0466" in why, why + + +@case +def a_different_pps_code_next_to_the_right_one_is_review_too(): + why = _review(["99213", "G0467", "G0466"]) + assert "G0466 billed" in why and "crosswalk says G0467" in why, why + why = _review(["G0439", "G0468", "G0467"]) + assert "G0467 billed" in why and "crosswalk says G0468" in why, why + + +@case +def mental_health_is_review_until_the_client_rules(): + why = _review(["90834"]) + assert "G0469 or G0470" in why and "90834" in why, why + for billed in ("G0469", "G0470"): + data = _data(["90834", billed]) + assert pps.needs_review("C1", data) is None, billed + + +@case +def hcfa_is_review_by_hand_and_dental_is_skipped(): + why = _review(["99213"], screen="hcfa") + assert "by hand" in why and "PPS" in why and "G0467" in why, why + why = _review(["99213", "G0467"], screen="hcfa") + assert "hcfa" in why and "99213" in why and "by hand" in why, \ + "the wrong form is caught with the PPS code there too" + assert _review(["36415"], screen="hcfa") == "", "no Qualifying Code" + data = _data(["99213"], screen="dental") + assert _quiet(pps.detect, "C1", data)[0] is False + assert pps.needs_review("C1", data) is None + + +@case +def g0468_on_a_payer_whose_history_is_never_checked_is_review(): + """Operator, 2026-10-23: the Cigna plans are Commercial for #27–#29, + so their preventive visit history is never read — Review, not an add. + On Medicare #27–#29 reads it first, so the claim still fires.""" + for payer in ("Cigna Healthspring", "Cigna Health Spring/ Bravo"): + why = _review(["G0439"], payer=payer) + assert "G0468 not added" in why and "history not checked" in why \ + and "G0439" in why, (payer, why) + assert _adds(_data(["G0439"])) == ["G0468"] + + +@case +def only_pps_payers_by_their_exact_names_cigna_healthspring_included(): + for payer in ("Medicare", "Medicare-Novitas", "HCFA Medicare Novitas", + "Humana Medicare", "Devoted MCR UB", "Cigna Healthspring", + "Cigna Health Spring/ Bravo", " medicare "): + fired, _ = _quiet(pps.detect, "C1", _data(["99213"], payer=payer)) + assert fired is True, payer + for payer in ("Medicaid", "Cigna Medicare Access Plan", "Devoted", + "Humana Claims", "", None): + data = _data(["99213"], payer=payer) + fired, _ = _quiet(pps.detect, "C1", data) + assert fired is False, payer + assert pps.needs_review("C1", data) is None, payer + + +# ── apply (browser) ───────────────────────────────────────────────────────── + +FIXTURE = pathlib.Path(__file__).resolve().parent / "fixtures" / "vaccine_claim.html" + +from ecw_bot import settings as cfg # noqa: E402 +from ecw_bot.conditions.base import ConditionResult # noqa: E402 +from ecw_bot.pages import claim_form # noqa: E402 + +_VISIT = [{"code": "99213", "title": "OFFICE VISIT EST LEVEL 3", "fee": "120.00"}] + + +def _fresh(page, rows, payer="Medicare"): + page.goto(FIXTURE.as_uri()) + page.evaluate("rows => window.seedCptRows(rows)", rows) + data = claim_form.extract(page) + data["insurance"] = [{"Name": payer}] + return data + + +def _rows_for(page, code): + return [r for r in claim_form.extract(page)["cpt"] if r["code"] == code] + + +@case +def verify_adds_nothing_and_says_which_pps_code_to_add_by_hand(page): + data = _fresh(page, _VISIT) + saved = cfg.SAVE_CORRECTIONS + cfg.SAVE_CORRECTIONS = False + try: + result, _ = _quiet(pps.apply, page, "C1", data) + finally: + cfg.SAVE_CORRECTIONS = saved + assert isinstance(result, ConditionResult) + assert result.changed is False, result + assert "G0467" in result.review and "99213" in result.review \ + and "by hand" in result.review, result.review + assert _rows_for(page, "G0467") == [] + + +@case +def live_write_adds_the_pps_code_once_with_units_1(page): + result, out = _quiet(pps.apply, page, "C1", _fresh(page, _VISIT)) + assert result.changed is True and result.review == "", (result, out) + added = _rows_for(page, "G0467") + assert len(added) == 1 and added[0]["units"] == "1", added + assert len(claim_form.extract(page)["cpt"]) == 2, "nothing else changed" + assert pps.summary_comment(result) == \ + "CPT Code Missing (PPS code G0467 added (99213))", \ + pps.summary_comment(result) + text = pps.describe(result) + assert "G0467" in text and "99213" in text and "Added" in text, text + + +@case +def a_pps_code_the_picker_cannot_find_is_review_add_by_hand(page): + data = _fresh(page, [{"code": "99348", "title": "HOME VISIT EST", + "fee": "130.00"}]) + result, _ = _quiet(pps.apply, page, "C1", data) + assert result.changed is False, result + assert "G0467H could not be added" in result.review \ + and "99348" in result.review, result.review + assert _rows_for(page, "G0467H") == [] + + +from ecw_bot.conditions import CONDITIONS # noqa: E402 +from ecw_bot.conditions.base import run_all # noqa: E402 +from ecw_bot.core import charge_summary as cs # noqa: E402 + + +def _outcome(page, data, live_write): + data["progress_notes"] = {"opened": "modal", "text": "Office visit."} + saved = cfg.SAVE_CORRECTIONS + cfg.SAVE_CORRECTIONS = live_write + try: + report = {} + results, out = _quiet(run_all, page, "C1", data, report=report) + finally: + cfg.SAVE_CORRECTIONS = saved + data["_conditions"] = {"fired": [r.condition_name for r in results], + "saved": False, "failed": report["failed"], + "review": report["review"], + "comments": report["comments"]} + return cs.claim_outcome(True, data), report, out + + +@case +def it_is_registered_after_the_wellness_history_and_before_priced_line_check(): + names = [m.NAME for m in CONDITIONS] + i = names.index("medicare_pps_code") + assert names.index("medicare_wellness_history") < i < names.index( + "priced_line_check"), names + + +@case +def a_verify_run_makes_the_claim_review_naming_the_pps_code(page): + outcome, report, out = _outcome(page, _fresh(page, _VISIT), False) + assert outcome["Category"] == "Review", (outcome, out) + mine = [r for r in report["review"] if r["name"] == "medicare_pps_code"] + assert mine and "G0467" in mine[0]["reason"], report["review"] + assert "G0467" in outcome["Comments"], outcome + + +@case +def a_live_write_run_adds_it_and_the_claim_is_fail_saying_so(page): + outcome, report, out = _outcome(page, _fresh(page, _VISIT), True) + assert outcome["Category"] == "Fail", (outcome, out) + assert "PPS code G0467 added (99213)" in outcome["Comments"], outcome + assert not [r for r in report["review"] + if r["name"] == "medicare_pps_code"], report["review"] + assert len(_rows_for(page, "G0467")) == 1 + + +@case +def g0468_is_held_back_when_the_wellness_history_found_a_problem(page): + awv = [{"code": "G0439", "title": "ANNUAL WELLNESS VISIT SUBSEQ", + "fee": "160.00"}] + for verdict, words in ( + ({"read": True, "conflicts": [ + "G0439 already billed on 01/05/2026 (claim 9000001) — once " + "a year"]}, "already billed"), + ({"read": False, "conflicts": []}, "history not read")): + data = _fresh(page, awv) + data["_wellness_history"] = verdict + result, _ = _quiet(pps.apply, page, "C1", data) + assert result.changed is False, (verdict, result) + assert "G0468 not added" in result.review and words in \ + result.review, result.review + assert _rows_for(page, "G0468") == [], verdict + data = _fresh(page, awv) + data["_wellness_history"] = {"read": True, "conflicts": []} + result, _ = _quiet(pps.apply, page, "C1", data) + assert result.changed is True and _rows_for(page, "G0468"), result + + +@case +def g0468_with_no_wellness_verdict_by_apply_is_not_added(page): + """#27–#29 raised or never reached the claim: its history is unchecked.""" + data = _fresh(page, [{"code": "G0439", "title": "ANNUAL WELLNESS VISIT " + "SUBSEQ", "fee": "160.00"}]) + result, _ = _quiet(pps.apply, page, "C1", data) + assert result.changed is False, result + assert "G0468 not added" in result.review, result.review + assert _rows_for(page, "G0468") == [] + + +def _run_cases() -> int: + from playwright.sync_api import sync_playwright + + failures = 0 + with sync_playwright() as pw: + browser = pw.chromium.launch(headless=True) + page = browser.new_page() + for fn in CASES: + name = fn.__name__.replace("_", " ") + try: + if len(inspect.signature(fn).parameters) == 0: + fn() + else: + fn(page) + print(f" ok {name}") + except AssertionError as exc: + print(f" FAIL {name}: {exc}") + failures += 1 + except Exception as exc: + print(f" ERROR {name}: {type(exc).__name__}: {exc}") + failures += 1 + browser.close() + + print(f"\n{len(CASES) - failures}/{len(CASES)} passed") + return 1 if failures else 0 + + +def main() -> int: + """shot() writes into the operator's Screenshots\\ folder; not here. + The add cases run as LIVE-WRITE, the way a run that adds does.""" + import tempfile + from ecw_bot import settings as _cfg + orig = (_cfg.SCREENSHOTS, _cfg.SAVE_CORRECTIONS) + with tempfile.TemporaryDirectory() as tmp: + _cfg.SCREENSHOTS = pathlib.Path(tmp) + _cfg.SAVE_CORRECTIONS = True + try: + return _run_cases() + finally: + _cfg.SCREENSHOTS, _cfg.SAVE_CORRECTIONS = orig + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tests/test_conditions_medicare_telehealth.py b/tests/test_conditions_medicare_telehealth.py new file mode 100644 index 0000000..bf50fbb --- /dev/null +++ b/tests/test_conditions_medicare_telehealth.py @@ -0,0 +1,601 @@ +r""" +conditions/medicare_telehealth.py — document condition #22: POS=2/M1=93-or-95 +(per audio/video mode) on the claim's primary CPT row, PLUS adding G2025 with +the same modifier and zeroing/unchecking every other CPT row once G2025 is +confirmed on the claim. + + python tests\test_conditions_medicare_telehealth.py + +Two fixtures, deliberately: ub_claim.html for the base detect/POS/M1 shape +(same as test_conditions_telehealth.py), and hcfa_claim.html for the full +G2025 flow — the Add-button-plus-Fee-Schedule-picker interaction is only +verified evidence on the "Claim"/HCFA and Dental screens (see +hcfa_claim.html's own comment), not the UB form. On ub_claim.html, the +condition correctly reports "could not confirm G2025" and skips the +cleanup step rather than guessing at a flow with no evidence behind it +there — that honest degradation is itself part of what these tests prove. + +Two other things this file exists to prove, beyond the basic +detect/apply/describe shape: + + * **this condition and the general telehealth condition never both fire + on the same claim** — Medicare is excluded from telehealth.py's own + modifier map specifically so this doesn't happen (see + test_conditions_telehealth.py's `video_modifier_is_none_for_medicare_too`); + * **G2025 gets the SAME audio/video modifier as the primary row**, not a + hardcoded 95 — the document's own wording ("add G2025 with 95 modifier") + is the video case; an audio-only Medicare visit needs 93 on both rows, + the same reasoning `telehealth_mode()` already applies elsewhere. +""" + +import sys +import pathlib +import inspect + +ROOT = pathlib.Path(__file__).resolve().parent.parent +sys.path.insert(0, str(ROOT / "src")) + +FIXTURE = pathlib.Path(__file__).resolve().parent / "fixtures" / "ub_claim.html" +HCFA_FIXTURE = pathlib.Path(__file__).resolve().parent / "fixtures" / "hcfa_claim.html" + +from ecw_bot.conditions import medicare_telehealth # noqa: E402 +from ecw_bot.conditions.base import ConditionResult, run_all # noqa: E402 +from ecw_bot.selectors import claim_form as sel # noqa: E402 +from ecw_bot.pages import claim_form # noqa: E402 + + +CASES = [] + + +def case(fn): + CASES.append(fn) + return fn + + +_MEDICARE_DATA = { + "progress_notes": {"text": "Session Setting (PoS): TeleHealth"}, + "insurance": [{"Name": "Humana Medicare"}], +} + + +@case +def detect_true_for_telehealth_plus_medicare(): + assert medicare_telehealth.detect("C1", _MEDICARE_DATA) is True + + +@case +def detect_false_when_the_note_does_not_mention_telehealth(): + data = { + "progress_notes": {"text": "Regular in-office visit."}, + "insurance": [{"Name": "Humana Medicare"}], + } + assert medicare_telehealth.detect("C1", data) is False + + +@case +def detect_false_for_a_non_medicare_payer(): + data = { + "progress_notes": {"text": "Session Setting (PoS): TeleHealth"}, + "insurance": [{"Name": "BH COLORADO ACCESS"}], # Medicaid + } + assert medicare_telehealth.detect("C1", data) is False + + +@case +def detect_false_with_no_insurance_row_at_all(): + data = {"progress_notes": {"text": "Session Setting (PoS): TeleHealth"}} + assert medicare_telehealth.detect("C1", data) is False + + +@case +def apply_sets_pos_2_and_mod1_95_on_the_first_row_only(page): + """ + ub_claim.html has no Add-flow evidence (see this file's module + docstring), so G2025 must come back "could not confirm" here — the + POS/M1 change on the primary row must still land regardless, and the + honest failure to confirm G2025 must mean the cleanup step is SKIPPED + entirely, not applied against a claim with nothing else billable. + """ + page.goto(FIXTURE.as_uri()) + result = medicare_telehealth.apply(page, "9120000", _MEDICARE_DATA) + assert isinstance(result, ConditionResult) + assert result.condition_name == "medicare_telehealth" + assert result.changed is True + + primary = result.changes[0] + assert primary["code"] == "H0023", result.changes + assert primary["after"]["pos"] == "2", result.changes + assert primary["after"]["mod1"] == "95", result.changes + + assert [c for c in result.changes if c.get("added")] == [], result.changes + assert [c for c in result.changes if "fee" in c] == [], result.changes + + +@case +def apply_uses_93_for_an_audio_only_medicare_visit(page): + """ + Real live example (a progress note reading "THV Audio Only: Telehealth + audio phone call only. No video."): Medicare telehealth is NOT always + modifier 95 — an audio-only visit still needs 93 on the primary row, + the same as any other payer's audio-only visit. + """ + page.goto(FIXTURE.as_uri()) + data = { + "progress_notes": {"text": "THV Audio Only: Telehealth audio " + "phone call only. No video."}, + "insurance": [{"Name": "Humana Medicare"}], + } + result = medicare_telehealth.apply(page, "9120000", data) + assert result.changed is True + assert result.changes[0]["after"]["mod1"] == "93", result.changes + + +@case +def apply_adds_g2025_via_the_real_fee_schedule_picker_on_the_hcfa_screen(page): + """ + The full real flow, on the screen where it is actually verified (see + hcfa_claim.html's own comment: claim 9109000's live evidence): click + Add, search the Fee Schedule picker for G2025, select the one exact + match, click its OK — never typing straight into the grid's trailing + "*" row. "Other Primary CPT codes" then means every code but G2025, + INCLUDING 99213 (which just got POS=2/M1=95) and G8510 (already + excluded, but its fee is not yet zero) — per the document's own + wording, not a guess. + """ + page.goto(HCFA_FIXTURE.as_uri()) + result = medicare_telehealth.apply(page, "9103000", _MEDICARE_DATA) + assert result.changed is True + + primary = result.changes[0] + assert primary["code"] == "99213", result.changes + + added = [c for c in result.changes if c.get("added")] + assert len(added) == 1, result.changes + assert added[0]["code"] == "G2025" and added[0]["modifier"] == "95", added + + zeroed = {c["code"] for c in result.changes if "fee" in c} + assert zeroed == {"99213", "J7298", "G8510"}, result.changes + + g2025_row = page.locator(sel.SEL_CPT_ROWS).last + assert g2025_row.locator(sel.SEL_CPT_CODE).first.input_value() == "G2025" + assert g2025_row.locator(sel.SEL_CPT_MOD1).first.input_value() == "95" + + +@case +def apply_leaves_g2025_alone_and_still_cleans_up_when_it_is_already_present(page): + """ + G2025 already on the claim (a prior run, or an operator added it by + hand): add_cpt_row() must never be attempted a second time, but the + unchecked/zero-fee cleanup on every OTHER row still has to run. G2025 + is seeded directly here (not via add_cpt_row) - this case is about + what apply() does once G2025 already exists, not about the add + mechanism itself. + """ + page.goto(HCFA_FIXTURE.as_uri()) + page.evaluate( + """(code) => { + const tbody = document.querySelector( + '#claimCPTCodesTable8888888888888 tbody'); + const addRowTr = tbody.querySelector('tr:last-child'); + const tr = document.createElement('tr'); + tr.id = 'CPTROW_3'; + tr.innerHTML = + '' + + '' + + ''; + tbody.insertBefore(tr, addRowTr); + }""", "G2025") + + result = medicare_telehealth.apply(page, "9103000", _MEDICARE_DATA) + added = [c for c in result.changes if c.get("added")] + assert added == [], result.changes # never re-added + + zeroed = {c["code"] for c in result.changes if "fee" in c} + assert zeroed == {"99213", "J7298", "G8510"}, result.changes + g2025_row = page.locator(sel.SEL_CPT_ROWS).last + assert g2025_row.locator(sel.SEL_CPT_MOD1).first.input_value() == "95", \ + "the pre-existing G2025 row's own modifier must be left untouched" + + +@case +def describe_names_the_row_and_the_modifier(page): + page.goto(HCFA_FIXTURE.as_uri()) + result = medicare_telehealth.apply(page, "9103000", _MEDICARE_DATA) + text = medicare_telehealth.describe(result) + assert "99213" in text, text + assert "95" in text, text + assert "G2025" in text, text + # _MEDICARE_DATA's note text is "Session Setting (PoS): TeleHealth" — + # the "telehealth" signal — so the operator can see WHY this fired. + assert "telehealth" in text, text + + +@case +def run_all_fires_only_medicare_telehealth_not_the_general_condition(page): + """ + Against the REAL registry (conditions.CONDITIONS), not fakes — proves + telehealth.py and medicare_telehealth.py do not both write to the same + Medicare claim's primary row. If they did, the second condition's fill() + would overwrite the first's, and the confirmation dialog would show the + same row twice. + """ + page.goto(FIXTURE.as_uri()) + results = run_all(page, "9120000", _MEDICARE_DATA) + assert len(results) == 1, results + assert results[0].condition_name == "medicare_telehealth", results + + +@case +def run_all_still_fires_only_medicare_on_an_audio_only_medicare_claim(page): + """ + The exact scenario that would have double-fired before telehealth.py's + _choose_modifier checked payer group before the audio/video split: an + audio-only Medicare visit. Both conditions' detect() would return True + for this claim, so this proves only one of them actually writes. + """ + page.goto(FIXTURE.as_uri()) + data = { + "progress_notes": {"text": "THV Audio Only: Telehealth audio " + "phone call only. No video."}, + "insurance": [{"Name": "Humana Medicare"}], + } + results = run_all(page, "9120000", data) + assert len(results) == 1, results + assert results[0].condition_name == "medicare_telehealth", results + assert results[0].changes[0]["after"]["mod1"] == "93", results + + +@case +def a_g2025_that_cannot_be_added_is_reported_failed_and_sent_to_review(page): + """ + Review item D6: on ub_claim.html the Add flow has no evidence, so + G2025 cannot be confirmed. The primary row DID change (POS 11 -> 2), + so the result is still changed=True — the operator must see it — but + it carries a G2025 failed record, a review reason (which keeps Save + away), a plain describe() line, and a summary comment that says so + instead of the fixed "G2025 added … other CPTs unchecked". + """ + page.goto(FIXTURE.as_uri()) + result = medicare_telehealth.apply(page, "9120000", _MEDICARE_DATA) + assert result.changed is True, result + assert result.review == medicare_telehealth.G2025_NOT_ADDED == ( + "G2025 NOT added — add it by hand; the other CPT rows were NOT " + "unchecked"), result.review + failed = [c for c in result.changes if c.get("failed")] + assert len(failed) == 1 and failed[0]["code"] == "G2025" \ + and failed[0]["row"] is None and failed[0]["modifier"] == "95", \ + result.changes + + text = medicare_telehealth.describe(result) + assert " G2025 NOT added — add it by hand; other rows NOT unchecked" \ + in text, text + assert medicare_telehealth.summary_comment(result) == ( + "Medicare Telehealth (POS 02 / modifier 95 on H0023; G2025 NOT " + "added — add by hand)") + + # Through the runner: the review reaches the report (what the save + # gate and the Charge Summary read), the comment replaces the fixed one. + page.goto(FIXTURE.as_uri()) + report = {} + run_all(page, "9120000", _MEDICARE_DATA, + conditions=[medicare_telehealth], report=report) + assert report["review"] == [{"name": "medicare_telehealth", + "reason": medicare_telehealth.G2025_NOT_ADDED} + ], report + assert "G2025 NOT added" in report["comments"]["medicare_telehealth"] + + +@case +def a_failed_g2025_with_nothing_else_changed_is_declined(page): + """Primary row already right (POS 02 / 95) and G2025 cannot be added: + nothing changed, but the claim still lacks G2025 — declined, not + none(), so it is a Review row rather than Pass.""" + page.goto(FIXTURE.as_uri()) + row0 = page.locator(sel.SEL_CPT_ROWS).nth(0) + row0.locator(sel.SEL_CPT_POS).first.fill("02") + row0.locator(sel.SEL_CPT_MOD1).first.fill("95") + result = medicare_telehealth.apply(page, "9120000", _MEDICARE_DATA) + assert result == ConditionResult.declined( + "medicare_telehealth", medicare_telehealth.G2025_NOT_ADDED), result + + +@case +def a_g2025_modifier_that_does_not_take_is_sent_to_review(page): + """ + The add worked but M1 on the new G2025 row did not take (read back + after `_set_row_modifier`, review item D6). Simulated by making the + modifier write a no-op: the G2025 row reads back with a blank M1. + """ + page.goto(HCFA_FIXTURE.as_uri()) + original = claim_form._set_row_modifier + claim_form._set_row_modifier = lambda page, code, modifier: True + try: + result = medicare_telehealth.apply(page, "9103000", _MEDICARE_DATA) + finally: + claim_form._set_row_modifier = original + assert result.changed is True, result + assert result.review == ("G2025 added but its modifier did not take — " + "check M1"), result.review + assert [c for c in result.changes if c.get("added")], result.changes + + +@case +def g2025_modifier_ok_is_true_when_the_modifier_took(page): + page.goto(HCFA_FIXTURE.as_uri()) + g = claim_form.apply_medicare_telehealth_g2025(page, "95") + assert g["g2025_added"] is True and g["g2025_modifier_ok"] is True, g + + +@case +def apply_twice_on_the_hcfa_screen_is_none_the_second_time(page): + """ + Review item D3: once G2025 is present, the primary row is POS 2 / 95 + and every other row is already unchecked at 0.00, the claim follows + rule #22 — the second apply is none(), not a change and not a review. + """ + page.goto(HCFA_FIXTURE.as_uri()) + first = medicare_telehealth.apply(page, "9103000", _MEDICARE_DATA) + assert first.changed is True and first.review == "", first + # Unchecked rows and fee-only rows are counted apart (final review, + # 2026-10-05; this used to expect "3 other CPT(s) unchecked/fee + # zeroed", which suggested an uncheck on the row that only had its fee + # zeroed). + assert medicare_telehealth.summary_comment(first) == ( + "Medicare Telehealth (POS 02 / modifier 95 on 99213; G2025 added; " + "2 other CPT(s) unchecked; 1 other CPT fee(s) zeroed)"), \ + medicare_telehealth.summary_comment(first) + second = medicare_telehealth.apply(page, "9103000", _MEDICARE_DATA) + assert second == ConditionResult.none("medicare_telehealth"), second + + +@case +def an_already_correct_primary_row_still_runs_the_g2025_step(page): + """The primary row is already POS 02 / 95: it is not a change, but + G2025 is still added and the other rows still zeroed.""" + page.goto(HCFA_FIXTURE.as_uri()) + row0 = page.locator(sel.SEL_CPT_ROWS).nth(0) + row0.locator(sel.SEL_CPT_POS).first.fill("02") + row0.locator(sel.SEL_CPT_MOD1).first.fill("95") + result = medicare_telehealth.apply(page, "9103000", _MEDICARE_DATA) + assert result.changed is True and result.review == "", result + assert not [c for c in result.changes if "before" in c], result.changes + assert [c for c in result.changes if c.get("added")], result.changes + assert medicare_telehealth.summary_comment(result).startswith( + "Medicare Telehealth (G2025 added; "), \ + medicare_telehealth.summary_comment(result) + + +def _seed_g2025(page, mod1): + """Put a G2025 row with the given M1 on hcfa_claim.html directly — the + same seeding as the already-present case above.""" + page.evaluate( + """(mod1) => { + const tbody = document.querySelector( + '#claimCPTCodesTable8888888888888 tbody'); + const addRowTr = tbody.querySelector('tr:last-child'); + const tr = document.createElement('tr'); + tr.id = 'CPTROW_3'; + tr.innerHTML = + '' + + '' + + ''; + tbody.insertBefore(tr, addRowTr); + }""", mod1) + + +@case +def an_already_present_g2025_with_the_right_modifier_reads_ok(page): + """Review fix round 1: a pre-existing G2025's M1 is read back too.""" + page.goto(HCFA_FIXTURE.as_uri()) + _seed_g2025(page, "95") + g = claim_form.apply_medicare_telehealth_g2025(page, "95") + assert g["g2025_added"] is False and g["g2025_confirmed"] is True, g + assert g["g2025_present_modifier"] == "95", g + assert g["g2025_present_modifier_ok"] is True, g + assert g["g2025_modifier_ok"] is None, g + + +@case +def an_already_present_g2025_with_a_blank_modifier_is_sent_to_review(page): + """ + Review fix round 1 (2026-10-05): G2025 already on the claim with a + blank M1. Before, its M1 was never read, and once nothing else was left + to change the log said "already correct … G2025 present" and the claim + passed. Now: the other rows are still cleaned up (G2025 IS on the + claim), but the result carries a review, a describe() line and a + summary comment naming the wrong M1 — and the M1 itself is left as is. + """ + page.goto(HCFA_FIXTURE.as_uri()) + _seed_g2025(page, "") + result = medicare_telehealth.apply(page, "9103000", _MEDICARE_DATA) + assert result.changed is True, result + assert result.review == "G2025 present but M1 is blank, not 95 — check it", \ + result.review + zeroed = {c["code"] for c in result.changes if "fee" in c} + assert zeroed == {"99213", "J7298", "G8510"}, result.changes + assert [c for c in result.changes if c.get("mismatch")], result.changes + + text = medicare_telehealth.describe(result) + assert " G2025 already present but M1 is blank, not 95 — check it" \ + in text, text + assert "G2025 present but M1 blank, not 95 — check it" in \ + medicare_telehealth.summary_comment(result), \ + medicare_telehealth.summary_comment(result) + g2025_row = page.locator(sel.SEL_CPT_ROWS).last + assert g2025_row.locator(sel.SEL_CPT_MOD1).first.input_value() == "", \ + "a pre-existing G2025's modifier is read, never rewritten" + + +@case +def a_wrong_g2025_modifier_with_nothing_else_to_change_is_declined_not_none(page): + """ + The exact case the reviewer named: the first apply adds G2025 (95) and + cleans up; then the G2025 M1 is blanked. The second apply has nothing + to change — but the claim does NOT follow rule #22, so it is declined + (a Review row), not none() (a Pass). + """ + page.goto(HCFA_FIXTURE.as_uri()) + first = medicare_telehealth.apply(page, "9103000", _MEDICARE_DATA) + assert first.changed is True and first.review == "", first + page.locator(sel.SEL_CPT_ROWS).last.locator( + sel.SEL_CPT_MOD1).first.fill("") + second = medicare_telehealth.apply(page, "9103000", _MEDICARE_DATA) + assert second == ConditionResult.declined( + "medicare_telehealth", + "G2025 present but M1 is blank, not 95 — check it"), second + + +@case +def a_95_g2025_on_an_audio_only_visit_is_sent_to_review(page): + """G2025 already present with 95, but the note is audio-only (93): + the primary row is moved to 93 and the G2025 mismatch goes to review.""" + page.goto(HCFA_FIXTURE.as_uri()) + _seed_g2025(page, "95") + data = {"progress_notes": {"text": "THV Audio Only: Telehealth audio " + "phone call only. No video."}, + "insurance": [{"Name": "Humana Medicare"}]} + result = medicare_telehealth.apply(page, "9103000", data) + assert result.changed is True, result + assert result.review == "G2025 present but M1 is 95, not 93 — check it", \ + result.review + + +@case +def a_primary_write_that_does_not_take_stops_before_g2025(page): + """ + Review fix round 1 (2026-10-05): when the primary row's POS/M1 write + does not take, the claim is already known to be wrong — no G2025 add + and no other row zeroed/unchecked, just the changed primary row and a + review. Simulated by making set_cpt_pos_modifier report ok False. + """ + page.goto(HCFA_FIXTURE.as_uri()) + before_rows = page.locator(sel.SEL_CPT_ROWS).count() + before_fees = [page.locator(sel.SEL_CPT_ROWS).nth(i) + .locator(sel.SEL_CPT_BILLED_FEE).first.input_value() + for i in range(before_rows)] + original = claim_form.set_cpt_pos_modifier + claim_form.set_cpt_pos_modifier = lambda page, pos, modifier, only_first=False: [ + {"row": 0, "code": "99213", "before": {"pos": "11", "mod1": ""}, + "after": {"pos": "11", "mod1": ""}, "changed": True, "ok": False}] + try: + result = medicare_telehealth.apply(page, "9103000", _MEDICARE_DATA) + finally: + claim_form.set_cpt_pos_modifier = original + assert result.changed is True, result + assert result.review == ( + "POS/modifier did not take on row 1 — set it by hand; G2025 was NOT " + "added and the other CPT rows were NOT unchecked"), result.review + assert [c["code"] for c in result.changes] == ["99213"], result.changes + assert not claim_form.cpt_code_present(page, "G2025"), \ + "G2025 must not be added after a primary write that did not take" + assert page.locator(sel.SEL_CPT_ROWS).count() == before_rows + after_fees = [page.locator(sel.SEL_CPT_ROWS).nth(i) + .locator(sel.SEL_CPT_BILLED_FEE).first.input_value() + for i in range(before_rows)] + assert after_fees == before_fees, (before_fees, after_fees) + assert "did NOT take" in medicare_telehealth.describe(result) + + +@case +def describe_shows_the_mode_evidence(page): + page.goto(FIXTURE.as_uri()) + data = {"progress_notes": {"text": "THV Audio Only: Telehealth audio " + "phone call only. No video."}, + "insurance": [{"Name": "Humana Medicare"}]} + result = medicare_telehealth.apply(page, "9120000", data) + text = medicare_telehealth.describe(result) + assert 'mode audio (matched "Audio Only")' in text, text + + +@case +def a_live_grid_that_reads_empty_after_a_full_first_read_is_declined(page): + """Final review, 2026-10-05: as for telehealth — declined, not none().""" + from ecw_bot.conditions.base import CPT_GRID_NOT_READ + page.goto(FIXTURE.as_uri()) + page.evaluate("""() => document.querySelectorAll('tr[id^="CPTROW_"]') + .forEach(r => r.remove())""") + result = medicare_telehealth.apply( + page, "9120000", dict(_MEDICARE_DATA, cpt=[{"code": "99213"}])) + assert result == ConditionResult.declined("medicare_telehealth", + CPT_GRID_NOT_READ), result + + +@case +def summary_comment_counts_unchecked_and_fee_only_rows_apart(): + """ + Final review, 2026-10-05: on the UB form only fees are zeroed, and + "N other CPT(s) unchecked/fee zeroed" suggested an uncheck that never + happened. Each kind is counted on its own. A blank M1 read-back names + the visit's modifier, never "?", and only the field that was fixed. + """ + sc = medicare_telehealth.summary_comment + fee = lambda n: {"row": n, "code": f"9000{n}", + "fee": {"before": "10.00", "after": "0.00"}} + both = lambda n: dict(fee(n), unchecked=True) + primary = {"row": 0, "code": "99213", "changed": True, "ok": False, + "before": {"pos": "2", "mod1": ""}, + "after": {"pos": "2", "mod1": ""}, "modifier": "95"} + added = {"row": None, "code": "G2025", "added": True, "modifier": "95"} + r = ConditionResult("medicare_telehealth", True, + [primary, added, fee(1), fee(2)]) + text = sc(r) + assert text == ("Medicare Telehealth (modifier 95 on 99213 did NOT take " + "— set by hand; G2025 added; 2 other CPT fee(s) " + "zeroed)"), text + assert "unchecked" not in text and "?" not in text, text + r = ConditionResult("medicare_telehealth", True, + [added, both(1), both(2)]) + assert sc(r) == ("Medicare Telehealth (G2025 added; 2 other CPT(s) " + "unchecked)"), sc(r) + + +def _run_cases() -> int: + if not FIXTURE.exists(): + print(f" FAIL fixture missing: {FIXTURE}") + return 1 + try: + from playwright.sync_api import sync_playwright + except ImportError: + print(" SKIP playwright is not installed") + return 0 + + failures = 0 + with sync_playwright() as pw: + browser = pw.chromium.launch(headless=True) + page = browser.new_page() + + for fn in CASES: + name = fn.__name__.replace("_", " ") + try: + if len(inspect.signature(fn).parameters) == 0: + fn() + else: + fn(page) + print(f" ok {name}") + except AssertionError as exc: + print(f" FAIL {name}: {exc}") + failures += 1 + except Exception as exc: + print(f" ERROR {name}: {type(exc).__name__}: {exc}") + failures += 1 + browser.close() + + print(f"\n{len(CASES) - failures}/{len(CASES)} passed") + return 1 if failures else 0 + + +def main() -> int: + from ecw_bot import settings as _cfg + # These cases exercise eCW's own click and Add flow, as a LIVE-WRITE run + # does. VERIFY clicks no box and adds no row (eCW saves both at once, + # 2026-10-16): that is tests/test_verify_no_live_writes.py. + orig = _cfg.SAVE_CORRECTIONS + _cfg.SAVE_CORRECTIONS = True + try: + return _run_cases() + finally: + _cfg.SAVE_CORRECTIONS = orig + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tests/test_conditions_medicare_vaccine_a6.py b/tests/test_conditions_medicare_vaccine_a6.py new file mode 100644 index 0000000..8976638 --- /dev/null +++ b/tests/test_conditions_medicare_vaccine_a6.py @@ -0,0 +1,330 @@ +r""" +conditions/medicare_vaccine_a6.py — document #19: a Medicare claim billing +G0008 or G0009 carries condition code A6 (operator, 2026-10-16: those two +codes only, not #18's vaccine list; the bot does not add G0008 / G0009). + + python tests\test_conditions_medicare_vaccine_a6.py + +Uses tests/fixtures/ub_claim.html — its Condition Codes boxes (FL 18-28) +are uBClaimCondtnCode1..11 as on the real form, set per case with +setConditionCodes. +""" + +import io +import sys +import pathlib +import inspect +import contextlib + +ROOT = pathlib.Path(__file__).resolve().parent.parent +sys.path.insert(0, str(ROOT / "src")) + +FIXTURE = pathlib.Path(__file__).resolve().parent / "fixtures" / "ub_claim.html" +HCFA_FIXTURE = (pathlib.Path(__file__).resolve().parent / "fixtures" + / "hcfa_claim.html") + +from ecw_bot.conditions import medicare_vaccine_a6 as cond # noqa: E402 +from ecw_bot.conditions import CONDITIONS # noqa: E402 +from ecw_bot.conditions.base import (CPT_TAB_NOT_CONFIRMED, # noqa: E402 + ConditionResult, run_all) +from ecw_bot.core import charge_summary as cs # noqa: E402 +from ecw_bot.pages import claim_form # noqa: E402 + +CASES = [] + + +def case(fn): + CASES.append(fn) + return fn + + +def _quiet(fn, *a, **k): + with contextlib.redirect_stdout(io.StringIO()) as out: + result = fn(*a, **k) + return result, out.getvalue() + + +MEDICARE = "HCFA Medicare" + + +def _row(code, billed=True, fee="40.00"): + return {"code": code, "billed": billed, "billed_fee": fee, "units": "1"} + + +def _data(cpt, payer=MEDICARE, screen="ub", codes=None): + return {"screen": screen, + "insurance": [{"Name": payer}] if payer is not None else [], + "cpt": cpt, + "condition_codes": ([""] * 11 if codes is None else codes) + if screen == "ub" else None} + + +# ── detect / needs_review (no browser) ─────────────────────────────────────── + +@case +def g0008_or_g0009_on_medicare_without_a6_fires(): + for code in ("G0008", "G0009", " g0009 "): + assert cond.detect("C1", _data([_row("99213"), _row(code)])), code + + +@case +def a6_already_there_does_not_fire(): + codes = ["", "", "a6"] + [""] * 8 + data = _data([_row("G0008")], codes=codes) + assert cond.detect("C1", data) is False + assert cond.needs_review("C1", data) is None + + +@case +def not_medicare_or_no_trigger_does_not_fire(): + for data in (_data([_row("G0008")], payer="BH COLORADO ACCESS"), + _data([_row("G0008")], payer=""), + _data([_row("90656"), _row("90732"), _row("G0010")]), + _data([_row("G0008", billed=False, fee="0.00")]), + _data([_row("G0009", fee="0.00")]), + _data([_row("G0009", billed=None, fee="0.00")])): + assert cond.detect("C1", data) is False, data + assert cond.needs_review("C1", data) is None, data + + +@case +def an_unchecked_priced_trigger_counts_priced_line_check_checks_it(): + """Review, 2026-10-19: priced_line_check, last, checks an unchecked + G0008 with a fee — so it is billed, and needs A6 like a checked one.""" + data = _data([_row("99213"), _row("G0008", billed=False, fee="40.00")]) + assert cond.detect("C1", data) is True + assert cond.billed_trigger_codes(data["cpt"]) == ["G0008"] + + +@case +def a_trigger_billed_twice_is_named_once(): + data = _data([_row("G0009"), _row("G0009")], screen="hcfa") + assert cond.needs_review("C1", data).startswith("G0009 on Medicare") + + +@case +def hcfa_or_dental_medicare_claim_is_review_by_hand(): + for screen in ("hcfa", "dental"): + data = _data([_row("G0009"), _row("G0008")], screen=screen) + assert cond.detect("C1", data) is False, screen + why = cond.needs_review("C1", data) + assert why and "A6 by hand" in why and "G0009, G0008" in why, why + + +@case +def a_ub_read_without_condition_codes_is_review(): + data = _data([_row("G0008")]) + data["condition_codes"] = None + assert cond.detect("C1", data) is False + assert "A6 by hand" in cond.needs_review("C1", data) + + +# ── apply (browser) ────────────────────────────────────────────────────────── + +def _fresh(page, codes=None): + page.goto(FIXTURE.as_uri()) + if codes is not None: + page.evaluate("c => window.setConditionCodes(c)", codes) + + +def _codes(page): + return claim_form.extract(page)["condition_codes"] + + +@case +def extract_reads_the_eleven_boxes(page): + _fresh(page, ["09", "", "40"]) + assert _codes(page) == ["09", "", "40"] + [""] * 8 + + +@case +def apply_types_a6_into_the_first_empty_box(page): + _fresh(page, ["09", "", "40"]) + result, out = _quiet(cond.apply, page, "C1", _data([_row("G0008")])) + assert result.changed is True and result.review == "", (result, out) + assert _codes(page) == ["09", "A6", "40"] + [""] * 8 + c = result.changes[0] + assert c["box"] == 2 and c["fl"] == 19, c + assert cond.summary_comment(result) == \ + "Condition code A6 added (G0008 on Medicare)" + assert "FL 19" in cond.describe(result) + + +@case +def a_hidden_ub_form_left_in_the_dom_is_not_read(page): + """eCW leaves closed forms in the DOM: a hidden UB form must not answer + for the claim on screen (review, 2026-10-19).""" + _fresh(page, ["A6"]) + page.evaluate("() => { document.getElementById(" + "'UBClaimDiv9999999999999').style.display = 'none'; }") + assert _codes(page) is None + data = _data([_row("G0008")]) + data["condition_codes"] = None + assert "A6 by hand" in cond.needs_review("C1", data) + result, _ = _quiet(cond.apply, page, "C1", _data([_row("G0008")])) + assert result.changed is False + assert "Condition Codes boxes not found" in result.review, result + + +@case +def a_box_marked_invalid_is_changed_and_review(page): + """Typed, but eCW's directive marks it invalid: the box IS touched, so + the result is changed (the review box shows it) AND Review.""" + _fresh(page) + page.evaluate("() => { window.conditionCodeInvalid = true; }") + result, _ = _quiet(cond.apply, page, "C1", _data([_row("G0008")])) + assert result.changed is True, result + assert "marked it invalid" in result.review, result + assert "add A6 by hand" in result.review + assert result.changes[0]["box"] == 1 + assert "NOT added" in cond.summary_comment(result) + assert "read back 'A6'" in cond.describe(result) + + +@case +def a_box_whose_angular_model_did_not_take_is_review(page): + """The box shows A6 but its model (what OK saves) does not hold it.""" + _fresh(page) + page.evaluate("""() => { window.angular = { element: () => ({ + scope: () => ({ searchRecord: '' }), + isolateScope: () => ({ searchRecord: '' }) }) }; }""") + result, _ = _quiet(cond.apply, page, "C1", _data([_row("G0009")])) + assert result.changed is True and "model holds ''" in result.review, result + + +@case +def a_box_whose_angular_model_took_is_ok(page): + _fresh(page) + page.evaluate("""() => { window.angular = { element: el => ({ + scope: () => ({ searchRecord: el.value }) }) }; }""") + result, _ = _quiet(cond.apply, page, "C1", _data([_row("G0009")])) + assert result.changed is True and result.review == "", result + + +@case +def apply_leaves_a6_already_there(page): + _fresh(page, ["09", "", "A6"]) + result, _ = _quiet(cond.apply, page, "C1", _data([_row("G0009")])) + assert result == ConditionResult.none(cond.NAME), result + assert _codes(page) == ["09", "", "A6"] + [""] * 8 + + +@case +def all_boxes_full_is_review_and_nothing_overwritten(page): + full = [f"{n:02d}" for n in range(1, 12)] + _fresh(page, full) + result, _ = _quiet(cond.apply, page, "C1", _data([_row("G0008")])) + assert result.changed is False + assert "all 11 Condition Code boxes are full" in result.review, result + assert "add A6 by hand" in result.review + assert _codes(page) == full + + +@case +def apply_declines_when_the_cpt_tab_cannot_open(page): + real = claim_form.open_cpt_hcpcs_tab + + def raises(_page): + raise RuntimeError("simulated") + claim_form.open_cpt_hcpcs_tab = raises + try: + result, _ = _quiet(cond.apply, page, "C1", _data([_row("G0008")])) + finally: + claim_form.open_cpt_hcpcs_tab = real + assert result == ConditionResult.declined(cond.NAME, CPT_TAB_NOT_CONFIRMED) + + +@case +def run_all_adds_a6_and_the_summary_says_so(page): + _fresh(page) + page.evaluate("""() => { + document.getElementById('uBClaimCPTCode0').value = 'G0008'; + document.querySelector('#UBClaimTbl3 [ng-bind="insurance.InsName"]') + .textContent = 'HCFA Medicare'; + }""") + data = claim_form.extract(page) + assert data["screen"] == "ub" and data["condition_codes"] == [""] * 11 + data["progress_notes"] = {"opened": "modal", "text": "Office visit."} + report = {} + results, out = _quiet(run_all, page, "C1", data, report=report) + assert "medicare_vaccine_a6" in [r.condition_name for r in results], out + assert _codes(page)[0] == "A6" + data["_conditions"] = {"fired": [r.condition_name for r in results], + "saved": False, "failed": report["failed"], + "review": report["review"], + "comments": report["comments"]} + outcome = cs.claim_outcome(True, data) + assert "Condition code A6 added (G0008 on Medicare)" in \ + outcome["Comments"], outcome + + +@case +def hcfa_claim_read_through_run_all_is_review(page): + """End to end on the HCFA fixture: no Condition Codes are read there, + so a Medicare claim billing G0008 is Review, nothing typed.""" + page.goto(HCFA_FIXTURE.as_uri()) + page.evaluate("""() => { + document.getElementById('claimCPTCode0').value = 'G0008'; + document.querySelectorAll('[ng-bind="insurance.InsName"]') + .forEach(e => { e.textContent = 'HCFA Medicare'; }); + }""") + data = claim_form.extract(page) + assert data["condition_codes"] is None, data["screen"] + data["progress_notes"] = {"opened": "modal", "text": "Office visit."} + report = {} + results, out = _quiet(run_all, page, "C1", data, report=report) + assert "medicare_vaccine_a6" not in [r.condition_name for r in results] + reasons = " | ".join(str(r) for r in report["review"]) + assert "check condition code A6 by hand" in reasons, report["review"] + + +@case +def it_is_registered_between_the_common_rule_halves(): + names = [m.NAME for m in CONDITIONS] + i = names.index("medicare_vaccine_a6") + assert names.index("zero_fee_uncheck") < i < names.index( + "priced_line_check"), names + + +def _run_cases() -> int: + from playwright.sync_api import sync_playwright + + failures = 0 + with sync_playwright() as pw: + browser = pw.chromium.launch(headless=True) + page = browser.new_page() + for fn in CASES: + name = fn.__name__.replace("_", " ") + try: + if len(inspect.signature(fn).parameters) == 0: + fn() + else: + fn(page) + print(f" ok {name}") + except AssertionError as exc: + print(f" FAIL {name}: {exc}") + failures += 1 + except Exception as exc: + print(f" ERROR {name}: {type(exc).__name__}: {exc}") + failures += 1 + browser.close() + + print(f"\n{len(CASES) - failures}/{len(CASES)} passed") + return 1 if failures else 0 + + +def main() -> int: + """shot() writes into the operator's Screenshots\\ folder; not here.""" + import tempfile + from ecw_bot import settings as _cfg + orig = _cfg.SCREENSHOTS + with tempfile.TemporaryDirectory() as tmp: + _cfg.SCREENSHOTS = pathlib.Path(tmp) + try: + return _run_cases() + finally: + _cfg.SCREENSHOTS = orig + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tests/test_conditions_medicare_wellness_history.py b/tests/test_conditions_medicare_wellness_history.py new file mode 100644 index 0000000..d6d3255 --- /dev/null +++ b/tests/test_conditions_medicare_wellness_history.py @@ -0,0 +1,314 @@ +r""" +conditions/medicare_wellness_history.py — document #27 G0402 (once per +lifetime), #28 G0438 (once in 3 years), #29 G0439 (once a year) on Medicare; +the history comes from Account Inquiry (pages/account_inquiry.py). Check +only: nothing on the claim changes (operator, 2026-10-20). + + python tests\test_conditions_medicare_wellness_history.py + +Browser cases use tests/fixtures/account_inquiry.html (made-up claims). +""" + +import io +import sys +import inspect +import pathlib +import contextlib +from datetime import date + +ROOT = pathlib.Path(__file__).resolve().parent.parent +sys.path.insert(0, str(ROOT / "src")) + +FIXTURE = (pathlib.Path(__file__).resolve().parent / "fixtures" + / "account_inquiry.html") + +from ecw_bot.conditions import medicare_wellness_history as cond # noqa: E402 +from ecw_bot.conditions import CONDITIONS # noqa: E402 +from ecw_bot.conditions.base import ConditionResult # noqa: E402 +from ecw_bot.core import charge_summary as cs # noqa: E402 +from ecw_bot.pages import account_inquiry # noqa: E402 + +CASES = [] +DOS = date(2026, 10, 1) + + +def case(fn): + CASES.append(fn) + return fn + + +def _claim(payer, *lines, claim_no="9000001"): + """lines: (code, fee, billed) or a bare code (10.00, billed).""" + cpt = [] + for ln in lines: + code, fee, billed = (ln, "10.00", True) if isinstance(ln, str) else ln + cpt.append({"code": code, "billed_fee": fee, "billed": billed, + "sdos": "10/01/2026"}) + return {"claim_no": claim_no, "screen": "ub", + "insurance": [{"Name": payer}] if payer else [], "cpt": cpt} + + +def _h(claim_no, when, *codes): + return {"claim_no": claim_no, "date": when, "codes": list(codes)} + + +# ── detect (no browser) ────────────────────────────────────────────────────── + +@case +def detect_fires_on_medicare_with_a_wellness_code(): + for code in ("G0402", "G0438", "G0439"): + assert cond.detect("C1", _claim("Medicare", "99213", code)), code + + +@case +def detect_ignores_other_payers_and_other_codes(): + assert not cond.detect("C1", _claim("Medicaid", "G0438")) + assert not cond.detect("C1", _claim("", "G0438")) + assert not cond.detect("C1", _claim("Medicare", "99213", "G0008")) + assert not cond.detect("C1", {}) + + +@case +def a_line_that_is_not_billed_does_not_count(): + assert not cond.detect("C1", _claim("Medicare", ("G0438", "0.00", True))) + assert not cond.detect("C1", _claim("Medicare", ("G0438", "0.00", False))) + # unchecked with a fee: priced_line_check checks it at the end. + assert cond.detect("C1", _claim("Medicare", ("G0438", "175.00", False))) + + +@case +def the_service_date_is_the_wellness_lines(): + assert cond.claim_service_date(_claim("Medicare", "G0439")) == DOS + data = _claim("Medicare", "G0439") + data["cpt"][0]["sdos"] = "" + assert cond.claim_service_date(data) is None + + +# ── the rule (no browser) ──────────────────────────────────────────────────── + +@case +def g0402_billed_before_at_any_time_is_reported(): + out = cond.conflicts(["G0402"], DOS, "9000001", + [_h("8000001", date(2015, 3, 4), "99213", "G0402")]) + assert out == ["G0402 already billed on 03/04/2015 (claim 8000001) — " + "once per lifetime"], out + assert cond.conflicts(["G0402"], DOS, "9000001", + [_h("8000001", date(2025, 3, 4), "99213")]) == [] + + +@case +def g0438_inside_3_years_is_reported_older_is_fine(): + inside = [_h("8000002", date(2024, 6, 1), "G0438")] + out = cond.conflicts(["G0438"], DOS, "9000001", inside) + assert out == ["G0438 already billed on 06/01/2024 (claim 8000002) — " + "once in 3 years"], out + older = [_h("8000003", date(2022, 6, 1), "G0438")] + assert cond.conflicts(["G0438"], DOS, "9000001", older) == [] + + +@case +def exactly_3_years_or_12_months_away_is_outside_the_window(): + assert cond.conflicts(["G0438"], DOS, "9000001", + [_h("8", date(2023, 10, 1), "G0438")]) == [] + assert cond.conflicts(["G0439"], DOS, "9000001", + [_h("8", date(2025, 10, 1), "G0439")]) == [] + assert cond.conflicts(["G0439"], DOS, "9000001", + [_h("8", date(2025, 10, 2), "G0439")]) != [] + + +@case +def g0439_inside_12_months_is_reported_older_is_fine(): + out = cond.conflicts(["G0439"], DOS, "9000001", + [_h("8000004", date(2026, 1, 15), "G0439")]) + assert out == ["G0439 already billed on 01/15/2026 (claim 8000004) — " + "once a year"], out + assert cond.conflicts(["G0439"], DOS, "9000001", + [_h("8000005", date(2025, 8, 1), "G0439")]) == [] + + +@case +def the_most_recent_earlier_claim_is_named(): + hist = [_h("A", date(2024, 1, 1), "G0438"), + _h("B", date(2025, 5, 5), "G0438")] + out = cond.conflicts(["G0438"], DOS, "9000001", hist) + assert "05/05/2025 (claim B)" in out[0], out + + +@case +def this_claim_itself_is_left_out(): + hist = [_h("9000001", DOS, "G0439", "99213")] + assert cond.conflicts(["G0439"], DOS, "9000001", hist) == [] + assert cond.conflicts(["G0402"], DOS, " 9000001 ", hist) == [] + + +@case +def a_later_claim_inside_the_window_counts_too(): + out = cond.conflicts(["G0439"], DOS, "9000001", + [_h("8000006", date(2026, 12, 1), "G0439")]) + assert out and "12/01/2026" in out[0], out + + +@case +def unread_dates_are_sent_to_a_person(): + hist = [_h("8000007", None, "G0438")] + out = cond.conflicts(["G0438"], DOS, "9000001", hist) + assert out and "no service date read" in out[0] and "by hand" in out[0] + out = cond.conflicts(["G0438"], None, "9000001", + [_h("8000007", date(2025, 1, 1), "G0438")]) + assert out and "service date was not read" in out[0], out + # G0402 is lifetime: no date needed either side. + out = cond.conflicts(["G0402"], None, "9000001", + [_h("8000008", None, "G0402")]) + assert out == ["G0402 already billed on an unread date (claim 8000008) " + "— once per lifetime"], out + + +@case +def each_code_on_the_claim_is_checked_on_its_own(): + hist = [_h("8000009", date(2026, 3, 1), "G0439"), + _h("8000010", date(2010, 3, 1), "G0402")] + out = cond.conflicts(["G0402", "G0439"], DOS, "9000001", hist) + assert len(out) == 2 and out[0].startswith("G0402") \ + and out[1].startswith("G0439"), out + + +# ── Account Inquiry and apply (browser) ────────────────────────────────────── + +def _load(page, pages, **flags): + page.goto(FIXTURE.as_uri()) + page.evaluate("(a) => { window.HISTORY_PAGES = a.pages; " + "Object.assign(window, a.flags); }", + {"pages": pages, "flags": flags}) + + +@case +def account_inquiry_reads_every_page_and_closes_with_the_x(page): + _load(page, [[{"id": "8000011", "date": "02/03/2024", + "codes": ["99213", "G0438"]}], + [{"id": "8000012", "date": "11/12/2019", + "codes": ["G0402"]}, + {"id": "8000013", "date": "", "codes": []}]]) + hist = account_inquiry.history(page) + assert hist == [ + {"claim_no": "8000011", "date": date(2024, 2, 3), + "codes": ["99213", "G0438"]}, + {"claim_no": "8000012", "date": date(2019, 11, 12), + "codes": ["G0402"]}, + {"claim_no": "8000013", "date": None, "codes": []}], hist + assert page.evaluate("window.CLOSED_WITH") == "X" + assert not page.locator('input[data-f="cpt"]').is_checked(), "Show CPT Payments left as it was found" + assert not page.locator("#piModal").is_visible() + assert page.locator("#formCancel").is_visible(), "claim form stays" + + +@case +def apply_reports_a_repeat_and_changes_nothing(page): + _load(page, [[{"id": "8000014", "date": "01/15/2026", + "codes": ["G0439"]}]]) + with contextlib.redirect_stdout(io.StringIO()): + result = cond.apply(page, "9000001", _claim("Medicare", "G0439")) + assert isinstance(result, ConditionResult) + assert result.changed is False and result.changes == [] + assert result.review == ("G0439 already billed on 01/15/2026 (claim " + "8000014) — once a year"), result.review + assert "G0439 already billed" in cond.describe(result) + + +@case +def apply_with_a_clean_history_is_nothing(page): + _load(page, [[{"id": "8000015", "date": "01/15/2024", + "codes": ["G0439"]}]]) + with contextlib.redirect_stdout(io.StringIO()): + result = cond.apply(page, "9000001", _claim("Medicare", "G0439")) + assert result.changed is False and result.review == "", result + + +@case +def account_inquiry_that_does_not_open_is_review_by_hand(page): + _load(page, [[]], NO_INQUIRY=True) + with contextlib.redirect_stdout(io.StringIO()): + result = cond.apply(page, "9000001", _claim("Medicare", "G0438")) + assert result.changed is False + assert "Account Inquiry not read" in result.review, result.review + assert "check G0438 history by hand" in result.review, result.review + + +@case +def account_inquiry_that_will_not_close_is_review_too(page): + _load(page, [[]], STUCK_OPEN=True) + with contextlib.redirect_stdout(io.StringIO()): + result = cond.apply(page, "9000001", _claim("Medicare", "G0402")) + assert "did not close" in result.review, result.review + + +@case +def apply_leaves_its_verdict_on_the_claim_for_the_pps_code(page): + """#25 must not add G0468 for a preventive visit billed too often, or + one whose history was not read: the verdict stays on the claim.""" + _load(page, [[{"id": "8000014", "date": "01/15/2026", + "codes": ["G0439"]}]]) + data = _claim("Medicare", "G0439") + with contextlib.redirect_stdout(io.StringIO()): + cond.apply(page, "9000001", data) + assert data["_wellness_history"] == {"read": True, "conflicts": [ + "G0439 already billed on 01/15/2026 (claim 8000014) — once a " + "year"]}, data.get("_wellness_history") + + _load(page, [[{"id": "8000015", "date": "01/15/2024", + "codes": ["G0439"]}]]) + data = _claim("Medicare", "G0439") + with contextlib.redirect_stdout(io.StringIO()): + cond.apply(page, "9000001", data) + assert data["_wellness_history"] == {"read": True, "conflicts": []} + + _load(page, [[]], NO_INQUIRY=True) + data = _claim("Medicare", "G0438") + with contextlib.redirect_stdout(io.StringIO()): + cond.apply(page, "9000001", data) + assert data["_wellness_history"] == {"read": False, "conflicts": []} + + +# ── registry and Charge Summary ────────────────────────────────────────────── + +@case +def it_is_registered_before_priced_line_check(): + names = [m.NAME for m in CONDITIONS] + assert "medicare_wellness_history" in names + assert names.index("medicare_wellness_history") < names.index( + "priced_line_check"), names + + +@case +def the_charge_summary_knows_it(): + assert "medicare_wellness_history" in cs.COMMENTS + + +def main() -> int: + from playwright.sync_api import sync_playwright + + failures = 0 + with sync_playwright() as pw: + browser = pw.chromium.launch(headless=True) + page = browser.new_page() + for fn in CASES: + name = fn.__name__.replace("_", " ") + try: + if len(inspect.signature(fn).parameters) == 0: + fn() + else: + fn(page) + print(f" ok {name}") + except AssertionError as exc: + print(f" FAIL {name}: {exc}") + failures += 1 + except Exception as exc: + print(f" ERROR {name}: {type(exc).__name__}: {exc}") + failures += 1 + browser.close() + + print(f"\n{len(CASES) - failures}/{len(CASES)} passed") + return 1 if failures else 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tests/test_conditions_priced_line_check.py b/tests/test_conditions_priced_line_check.py new file mode 100644 index 0000000..9987416 --- /dev/null +++ b/tests/test_conditions_priced_line_check.py @@ -0,0 +1,972 @@ +r""" +conditions/priced_line_check.py — the reverse half of the operator's common +rule for every claim (2026-09-30): a CPT line that carries a billed fee but +is unchecked gets checked. Only that direction — a checked line is never +touched, and the fee is never written. + + python tests\test_conditions_priced_line_check.py + +Pure-data cases first (the shared priced-fee predicate, then detect()), then +page cases against BOTH claim screens, with the same injection helpers and +fixtures as test_conditions_zero_fee_uncheck.py: hcfa_claim.html, whose own +rows carry the live-verified "Assign To Patient" checkbox, and ub_claim.html, +whose own rows carry a plain checkbox with no ng-click at all — so on the UB +form a UB-style row is injected with the checkbox vaccine_claim.html's rows +use, and the fixture's own rows stand in for a row whose checkbox state is +unknown. + +hcfa_claim.html's own G8510 (CPTROW_2) sits UNCHECKED at 65.00 — the 9103000 +shape depression_dx_pointer re-includes, and exactly the line this rule +checks. Cases about some other line call `_neutralise_fixture_g8510()` +first, so that line cannot answer for them. + +The run_all() cases are the point of registering this LAST. Each exclusion +condition — j2930_j2001_update (J2001), dummy_codes (DCONS), +medicare_telehealth ("other primary CPT codes") and depression_dx_pointer's +duplicate step — starts from an original grid where the line it excludes is +UNCHECKED but still PRICED. That line must end unchecked at 0.00, its +checkbox never clicked, and no result may report a check on it: the +check/uncheck toggle claim 9131000 showed between two steps of one +condition, here between two conditions. One contrast case runs this +condition FIRST instead, to prove that order is what prevents it. + +Live evidence: 0 of the 39 saved claim snapshots had an unchecked priced +line — this is rare — and exactly one had a checked zero-fee line (85018 at +0.00, zero_fee_uncheck's half). +""" + +import io +import sys +import pathlib +import inspect +import contextlib + +ROOT = pathlib.Path(__file__).resolve().parent.parent +sys.path.insert(0, str(ROOT / "src")) + +FIXTURE = pathlib.Path(__file__).resolve().parent / "fixtures" / "hcfa_claim.html" +UB_FIXTURE = pathlib.Path(__file__).resolve().parent / "fixtures" / "ub_claim.html" +VACCINE_FIXTURE = pathlib.Path(__file__).resolve().parent / "fixtures" / "vaccine_claim.html" + +from ecw_bot import conditions # noqa: E402 +from ecw_bot.conditions import priced_line_check as plc # noqa: E402 +from ecw_bot.conditions import (bh_h_code_modifier, # noqa: E402 + dental_dummy_codes, + dental_payer_check, + depression_dx_pointer, dummy_codes, + injection_admin_code, + medicare_pps_code, + j2930_j2001_update, medicaid_uncheck_cpts, + medicare_billing_screening, + medicare_telehealth, + medicare_vaccine_a6, medicare_wellness_history, + medicaid_pregnancy_th, + telehealth, vaccine_admin_codes, + zero_fee_uncheck) +from ecw_bot.conditions.base import ( # noqa: E402 + CPT_TAB_NOT_CONFIRMED, ConditionResult, run_all) +from ecw_bot.pages import claim_form # noqa: E402 +from ecw_bot.selectors import claim_form as sel # noqa: E402 + + +CASES = [] + + +def case(fn): + CASES.append(fn) + return fn + + +def _cpt(code, billed=False, fee="150.00"): + """One `extract()["cpt"]` row, cut down to what detect() reads.""" + return {"code": code, "billed": billed, "billed_fee": fee} + + +def _inject_row(page, code, row_id, *, screen="hcfa", checked=False, + fee="150.00", has_checkbox=True, has_fee=True): + """ + Adds one real CPT row (a `CPTROW_` id) above the grid's trailing "*" + entry row — the same helper as test_conditions_zero_fee_uncheck.py's, + defaulting to this rule's shape (unchecked, priced). `screen` picks the + row's own markup: + + * "hcfa" — hcfa_claim.html's shape: `data-fieldname` fields. + * "ub" — the UB form's `uBClaim*` id prefixes, with the + "Assign To Patient" checkbox vaccine_claim.html's rows carry. + + `has_checkbox` / `has_fee` drop the checkbox or the Billed Fee input + entirely — the rows whose state is unknown, which must be left alone. + """ + page.evaluate( + """(a) => { + const n = a.rowId.replace('CPTROW_', ''); + const tbody = document.querySelector(a.screen === 'ub' + ? '#claimCPTCodesTable9999999999999 tbody' + : '#claimCPTCodesTable8888888888888 tbody'); + const entryRow = tbody.querySelector('tr:last-child'); + const esc = s => String(s).replace(/&/g, '&').replace(/"/g, '"'); + const box = !a.hasCheckbox ? '' : (a.screen === 'ub' + ? ''; + const codeInput = a.screen === 'ub' + ? '' + : ''; + const feeInput = !a.hasFee ? '' : (a.screen === 'ub' + ? '' + : ''); + const tr = document.createElement('tr'); + tr.id = a.rowId; + tr.setAttribute('ng-repeat', 'cpt in CPTCodes'); + tr.innerHTML = '' + box + '' + codeInput + '' + + '' + feeInput + ''; + tbody.insertBefore(tr, entryRow); + }""", {"code": code, "rowId": row_id, "screen": screen, + "checked": checked, "fee": fee, + "hasCheckbox": has_checkbox, "hasFee": has_fee}) + + +def _inject_full_g8510_row(page, row_id, *, checked, fee, icd1="1"): + """ + A second G8510 on hcfa_claim.html carrying all four ICD pointer fields + — depression_dx_pointer's retag step reads every one — the same helper + as test_conditions_zero_fee_uncheck.py's, plus `icd1` for a copy that + already carries the Z13.31 pointer ("2") alone. + """ + page.evaluate( + """(a) => { + const tbody = document.querySelector( + '#claimCPTCodesTable8888888888888 tbody'); + const entryRow = tbody.querySelector('tr:last-child'); + const n = a.rowId.replace('CPTROW_', ''); + const tr = document.createElement('tr'); + tr.id = a.rowId; + tr.innerHTML = + '' + + '' + + '' + + '' + + '' + + '' + + ''; + tbody.insertBefore(tr, entryRow); + }""", {"rowId": row_id, "checked": checked, "fee": fee, "icd1": icd1}) + + +def _set_row(page, row_id, *, checked=None, fee=None): + """Sets one existing row's checkbox and/or Billed Fee in place.""" + page.evaluate( + """(a) => { + const tr = document.getElementById(a.rowId); + if (a.checked !== null) + tr.querySelector('[ng-click*="updateBillToIns("]').checked = a.checked; + if (a.fee !== null) + tr.querySelector('[id^="uBClaimBillFee"], [data-fieldname="ClaimCPTBilledFee"]') + .value = a.fee; + }""", {"rowId": row_id, "checked": checked, "fee": fee}) + + +def _neutralise_fixture_g8510(page): + """ + hcfa_claim.html's own G8510 (CPTROW_2) is unchecked at 65.00 — itself + an unchecked priced line. Checked here, so a case about some other line + is not answered by that one. + """ + _set_row(page, "CPTROW_2", checked=True) + + +def _count_checkbox_clicks(page, row_id): + """ + Counts clicks on one row's "Assign To Patient" checkbox, read back as + `window.clicksOn[row_id]` — the same helper the depression and + zero_fee_uncheck tests use. On the live screen that click is what fires + eCW's own `updateBillToIns()`, so a line that must stay excluded should + never receive one at all — not merely end up unchecked again. + """ + page.evaluate( + """(rowId) => { + window.clicksOn = window.clicksOn || {}; + window.clicksOn[rowId] = 0; + document.querySelector('#' + rowId + ' [ng-click*="updateBillToIns("]') + .addEventListener('click', () => { window.clicksOn[rowId] += 1; }); + }""", row_id) + + +def _watch_fee_writes(page): + """ + Counts `input`/`change` events on every Billed Fee input on the grid as + `window.feeWrites` — a zero proves the fee was never written, not + merely left with the same value. Call after every row has been injected. + """ + page.evaluate( + """(s) => { + window.feeWrites = 0; + document.querySelectorAll('tr[id^="CPTROW_"]').forEach(tr => { + tr.querySelectorAll(s).forEach(el => { + el.addEventListener('input', () => { window.feeWrites += 1; }); + el.addEventListener('change', () => { window.feeWrites += 1; }); + }); + }); + }""", sel.SEL_CPT_BILLED_FEE) + + +def _row_state(page, index): + """(checked or None, fee or None) for the index-th real CPT row.""" + row = page.locator(sel.SEL_CPT_ROWS).nth(index) + box = row.locator(sel.SEL_CPT_ASSIGN_TO_PATIENT).first + fee = row.locator(sel.SEL_CPT_BILLED_FEE).first + return (box.is_checked() if box.count() else None, + fee.input_value() if fee.count() else None) + + +def _run_all_logged(page, claim_id, data, **kwargs): + """run_all(), with everything it printed.""" + out = io.StringIO() + with contextlib.redirect_stdout(out): + results = run_all(page, claim_id, data, **kwargs) + return results, out.getvalue() + + +def _checks_reported_on(results, row): + """ + Every change in `results` that reports CHECKING `row`: any change + priced_line_check makes (it only ever checks), and depression's + re-include step, whose change is `{"row", "code"}` alone. + """ + return [(r.condition_name, c) for r in results for c in r.changes + if c.get("row") == row + and (r.condition_name == plc.NAME + or (r.condition_name == depression_dx_pointer.NAME + and set(c) == {"row", "code"}))] + + +def _assert_priced_line_check_ran_and_found_nothing(log, claim_id): + """ + The common case this rule is registered last for: detect() saw the + excluded line priced on the claim as read, and by the time apply() + looked, another condition had zeroed it. + """ + assert f"[COND priced_line_check {claim_id}]" in log, log + assert "nothing to check" in log, log + + +# ============================================================================= +# The shared priced-fee predicate +# ============================================================================= + +@case +def is_priced_fee_reads_a_positive_amount_as_priced(): + for value in ("150.00", "65", "0.01", "1,200.00", "$45.00", "$1,200.00", + " 65.00 "): + assert claim_form.is_priced_fee(value) is True, repr(value) + + +@case +def is_priced_fee_reads_zero_blank_non_numeric_and_negative_as_not_priced(): + for value in ("", " ", None, "0", "0.00", ".00", "$0.00", "0,000.00", + "N/A", "abc", "--", "0.00 USD", "-5.00", "nan", "inf"): + assert claim_form.is_priced_fee(value) is False, repr(value) + + +@case +def is_priced_fee_and_is_zero_fee_never_both_hold(): + # The two halves of the common rule can never both claim one line. + for value in ("", None, "0", "0.00", ".00", "$0.00", "65.00", "0.01", + "1,200.00", "$45.00", "N/A", "-5.00", "nan", "inf"): + assert not (claim_form.is_priced_fee(value) + and claim_form.is_zero_fee(value)), repr(value) + + +# ============================================================================= +# detect() +# ============================================================================= + +@case +def detect_true_for_an_unchecked_priced_line(): + data = {"cpt": [_cpt("99213", billed=True, fee="210.00"), + _cpt("90791", billed=False, fee="150.00")]} + assert plc.detect("C1", data) is True + + +@case +def detect_true_for_a_thousands_separated_fee(): + assert plc.detect("C1", {"cpt": [_cpt("J7298", fee="1,200.00")]}) is True + + +@case +def detect_true_for_a_dollar_fee(): + assert plc.detect("C1", {"cpt": [_cpt("J2001", fee="$45.00")]}) is True + + +@case +def detect_false_for_an_unchecked_zero_fee_line(): + # Already excluded, and correctly — the 9131000 duplicate's shape. + for fee in ("0.00", "", "0", ".00", "$0.00"): + assert plc.detect("C1", {"cpt": [_cpt("G8510", fee=fee)]}) is False, fee + + +@case +def detect_false_for_a_checked_priced_line(): + data = {"cpt": [_cpt("99213", billed=True, fee="210.00"), + _cpt("J7298", billed=True, fee="1200.00")]} + assert plc.detect("C1", data) is False + + +@case +def detect_false_when_the_checkbox_state_is_unknown(): + # billed None: extract() found no "Assign To Patient" checkbox on the + # row. Unknown must never read as "unchecked". + assert plc.detect("C1", {"cpt": [_cpt("90791", billed=None)]}) is False + assert plc.detect("C1", {"cpt": [{"code": "90791", + "billed_fee": "150.00"}]}) is False + + +@case +def detect_false_for_a_non_numeric_or_negative_fee(): + for fee in ("N/A", "abc", "0.00 USD", "-5.00"): + assert plc.detect("C1", {"cpt": [_cpt("90791", fee=fee)]}) is False, fee + + +@case +def detect_false_with_no_cpt_lines_or_no_fee_read(): + assert plc.detect("C1", {"cpt": []}) is False + assert plc.detect("C1", {}) is False + assert plc.detect("C1", None) is False + # No billed_fee key at all: nothing was read, so nothing is priced. + assert plc.detect("C1", {"cpt": [{"code": "90791", "billed": False}]}) is False + + +# ============================================================================= +# apply() — the professional/HCFA screen +# ============================================================================= + +@case +def apply_checks_unchecked_priced_lines_on_the_hcfa_screen(page): + """ + The fixture's own G8510 (unchecked, 65.00) plus two injected lines + priced the ways eCW can print a fee — "$45.00" and "1,200.00". Each is + checked and reported with its fee exactly as read; the fee itself is + never written. The checked 99213 and J7298 are never clicked. + """ + page.goto(FIXTURE.as_uri()) + _inject_row(page, "85025", "CPTROW_3", fee="$45.00") + _inject_row(page, "36415", "CPTROW_4", fee="1,200.00") + for row_id in ("CPTROW_0", "CPTROW_1", "CPTROW_2", "CPTROW_3", "CPTROW_4"): + _count_checkbox_clicks(page, row_id) + _watch_fee_writes(page) + + result = plc.apply(page, "9103000", {}) + assert isinstance(result, ConditionResult) + assert result.condition_name == "priced_line_check" + assert result.changed is True + assert result.changes == [ + {"row": 2, "code": "G8510", "fee": "65.00"}, + {"row": 3, "code": "85025", "fee": "$45.00"}, + {"row": 4, "code": "36415", "fee": "1,200.00"}, + ], result.changes + + assert [_row_state(page, i) for i in range(5)] == [ + (True, "210.00"), (True, "1200.00"), (True, "65.00"), + (True, "$45.00"), (True, "1,200.00")] + clicks = page.evaluate("window.clicksOn") + assert clicks == {"CPTROW_0": 0, "CPTROW_1": 0, "CPTROW_2": 1, + "CPTROW_3": 1, "CPTROW_4": 1}, clicks + assert page.evaluate("window.feeWrites") == 0, "the Billed Fee was written" + + # A second pass has nothing left to check. + assert plc.apply(page, "9103000", {}).changed is False + + +@case +def apply_leaves_zero_fee_non_numeric_and_negative_lines_alone(page): + page.goto(FIXTURE.as_uri()) + _neutralise_fixture_g8510(page) + _inject_row(page, "85018", "CPTROW_3", fee="0.00") + _inject_row(page, "85025", "CPTROW_4", fee="") + _inject_row(page, "81002", "CPTROW_5", fee="N/A") + _inject_row(page, "36415", "CPTROW_6", fee="-5.00") + for row_id in ("CPTROW_3", "CPTROW_4", "CPTROW_5", "CPTROW_6"): + _count_checkbox_clicks(page, row_id) + _watch_fee_writes(page) + + result = plc.apply(page, "9103000", {}) + assert result.changed is False, result.changes + assert result.changes == [], result.changes + assert [_row_state(page, i) for i in (3, 4, 5, 6)] == [ + (False, "0.00"), (False, ""), (False, "N/A"), (False, "-5.00")] + assert set(page.evaluate("Object.values(window.clicksOn)")) == {0}, \ + page.evaluate("window.clicksOn") + assert page.evaluate("window.feeWrites") == 0 + + +@case +def apply_never_touches_a_checked_line_zero_fee_or_priced(page): + # A checked 0.00 line is zero_fee_uncheck's, never this rule's: only + # ever checks, never unchecks. + page.goto(FIXTURE.as_uri()) + _neutralise_fixture_g8510(page) + _inject_row(page, "85018", "CPTROW_3", checked=True, fee="0.00") + for row_id in ("CPTROW_0", "CPTROW_1", "CPTROW_2", "CPTROW_3"): + _count_checkbox_clicks(page, row_id) + + result = plc.apply(page, "9103000", {}) + assert result.changed is False, result.changes + assert [_row_state(page, i) for i in range(4)] == [ + (True, "210.00"), (True, "1200.00"), (True, "65.00"), (True, "0.00")] + assert set(page.evaluate("Object.values(window.clicksOn)")) == {0} + + +@case +def apply_leaves_an_unchecked_row_with_no_fee_input_alone(page): + # No Billed Fee input at all: the fee is unknown, not priced. + page.goto(FIXTURE.as_uri()) + _neutralise_fixture_g8510(page) + _inject_row(page, "90791", "CPTROW_3", has_fee=False) + _count_checkbox_clicks(page, "CPTROW_3") + + result = plc.apply(page, "9103000", {}) + assert result.changed is False, result.changes + assert _row_state(page, 3) == (False, None) + assert page.evaluate("window.clicksOn['CPTROW_3']") == 0 + + +@case +def apply_leaves_a_priced_row_with_no_checkbox_alone(page): + # No "Assign To Patient" checkbox: whether it is billed is unknown. + page.goto(FIXTURE.as_uri()) + _neutralise_fixture_g8510(page) + _inject_row(page, "90791", "CPTROW_3", has_checkbox=False) + _watch_fee_writes(page) + + result = plc.apply(page, "9103000", {}) + assert result.changed is False, result.changes + assert _row_state(page, 3) == (None, "150.00") + assert page.evaluate("window.feeWrites") == 0 + + +# ============================================================================= +# apply() — the UB Claim form +# ============================================================================= + +@case +def apply_checks_an_unchecked_priced_line_on_the_ub_form(page): + page.goto(UB_FIXTURE.as_uri()) + _inject_row(page, "90791", "CPTROW_2", screen="ub", fee="150.00") + _inject_row(page, "G8510", "CPTROW_3", screen="ub", fee="0.00") + _inject_row(page, "99213", "CPTROW_4", screen="ub", checked=True, fee="210.00") + for row_id in ("CPTROW_2", "CPTROW_3", "CPTROW_4"): + _count_checkbox_clicks(page, row_id) + _watch_fee_writes(page) + + result = plc.apply(page, "9104000", {}) + assert result.changes == [{"row": 2, "code": "90791", "fee": "150.00"}], result.changes + + assert _row_state(page, 2) == (True, "150.00") + assert _row_state(page, 3) == (False, "0.00") + assert _row_state(page, 4) == (True, "210.00") + assert page.evaluate("window.clicksOn") == { + "CPTROW_2": 1, "CPTROW_3": 0, "CPTROW_4": 0} + assert page.evaluate("window.feeWrites") == 0, "the Billed Fee was written" + + +@case +def apply_leaves_ub_rows_without_the_assign_to_patient_checkbox_alone(page): + """ + ub_claim.html's own rows carry a plain checkbox with no + `updateBillToIns(` ng-click — not the live-verified "Assign To Patient" + box — so their billed state is unknown: one sitting unchecked at 74.00 + must not be acted on. + """ + page.goto(UB_FIXTURE.as_uri()) + page.evaluate("() => { document.getElementById('claimCPTBillToInsChk0')" + ".checked = false; }") + + result = plc.apply(page, "9104000", {}) + assert result.changed is False, result.changes + assert page.locator("#claimCPTBillToInsChk0").is_checked() is False + assert page.locator("#uBClaimBillFee0").input_value() == "74.00" + + +# ============================================================================= +# apply() — no change, and a tab that cannot be confirmed +# ============================================================================= + +@case +def apply_logs_why_when_there_is_nothing_to_check(page): + page.goto(FIXTURE.as_uri()) + _neutralise_fixture_g8510(page) + out = io.StringIO() + with contextlib.redirect_stdout(out): + result = plc.apply(page, "9103000", {}) + assert result == ConditionResult.none("priced_line_check"), result + _assert_priced_line_check_ran_and_found_nothing(out.getvalue(), "9103000") + + # Only on that path — not when a line was actually checked. + page.goto(FIXTURE.as_uri()) + out = io.StringIO() + with contextlib.redirect_stdout(out): + result = plc.apply(page, "9103000", {}) + assert result.changed is True, result.changes + assert "nothing to check" not in out.getvalue(), out.getvalue() + + +@case +def apply_leaves_the_claim_as_read_when_the_cpt_tab_cannot_be_confirmed(page): + page.goto(FIXTURE.as_uri()) + + def tab_that_raises(page_): + raise RuntimeError("No CPT/HCPCS tab matched (simulated)") + + real = claim_form.open_cpt_hcpcs_tab + claim_form.open_cpt_hcpcs_tab = tab_that_raises + out = io.StringIO() + try: + with contextlib.redirect_stdout(out): + result = plc.apply(page, "9103000", {}) + finally: + claim_form.open_cpt_hcpcs_tab = real + assert result.changed is False, result.changes + assert _row_state(page, 2) == (False, "65.00"), "nothing may be touched" + assert "leaving the claim as read" in out.getvalue(), out.getvalue() + # Declined, not none(): never checked is not clean (review item D2). + assert result.review == CPT_TAB_NOT_CONFIRMED, result + + +# ============================================================================= +# describe() +# ============================================================================= + +@case +def describe_names_each_checked_line_and_its_fee(page): + page.goto(FIXTURE.as_uri()) + _neutralise_fixture_g8510(page) + _inject_row(page, "99213", "CPTROW_3", fee="150.00") + result = plc.apply(page, "9103000", {}) + text = plc.describe(result) + lines = text.splitlines() + assert lines[0] == "Unchecked CPT line(s) with a billed fee checked:", text + assert lines[1:] == [" Row 4 (99213): unchecked with billed fee 150.00 -> checked"], text + + +@case +def describe_lists_every_line_in_grid_order(): + result = ConditionResult(condition_name="priced_line_check", changed=True, + changes=[{"row": 0, "code": "99213", "fee": "150.00"}, + {"row": 5, "code": "J7298", "fee": "1,200.00"}]) + assert plc.describe(result).splitlines()[1:] == [ + " Row 1 (99213): unchecked with billed fee 150.00 -> checked", + " Row 6 (J7298): unchecked with billed fee 1,200.00 -> checked", + ], plc.describe(result) + + +# ============================================================================= +# The registry +# ============================================================================= + +@case +def registered_last_with_zero_fee_uncheck_first(): + """ + zero_fee_uncheck FIRST, this LAST, every specific condition between + them in its own order: an exclusion condition has always zeroed its + line before this looks at the grid. + """ + assert conditions.CONDITIONS[0] is zero_fee_uncheck, conditions.CONDITIONS + assert conditions.CONDITIONS[-1] is plc, conditions.CONDITIONS + assert conditions.CONDITIONS == [ + zero_fee_uncheck, + telehealth, medicare_telehealth, depression_dx_pointer, + j2930_j2001_update, dummy_codes, dental_dummy_codes, + dental_payer_check, medicaid_uncheck_cpts, + medicare_billing_screening, medicare_vaccine_a6, + medicare_wellness_history, medicaid_pregnancy_th, + bh_h_code_modifier, vaccine_admin_codes, injection_admin_code, + medicare_pps_code, + plc, + ], conditions.CONDITIONS + assert conditions.BY_NAME["priced_line_check"] is plc + assert plc.NAME == "priced_line_check" + + +# ============================================================================= +# run_all() against the REAL registry — the positive cases +# ============================================================================= + +@case +def run_all_checks_an_unrelated_priced_line_and_reports_it(page): + """ + 99213 unchecked at 150.00 on the claim as read, no other condition's + business: priced_line_check alone fires, and the line ends checked with + its fee untouched. `icd` is left out of the data on purpose: the + fixture carries Z13.31, which would fire depression_dx_pointer on its + own account. + """ + page.goto(FIXTURE.as_uri()) + _neutralise_fixture_g8510(page) + _set_row(page, "CPTROW_0", checked=False, fee="150.00") + _count_checkbox_clicks(page, "CPTROW_0") + data = {"cpt": claim_form.extract(page)["cpt"]} + + results = run_all(page, "9103000", data) + assert [r.condition_name for r in results] == ["priced_line_check"], results + assert results[0].changes == [{"row": 0, "code": "99213", "fee": "150.00"}], \ + results[0].changes + assert _row_state(page, 0) == (True, "150.00") + assert page.evaluate("window.clicksOn['CPTROW_0']") == 1 + + +@case +def run_all_fires_both_halves_of_the_common_rule_on_one_claim(page): + # A checked 0.00 line and an unchecked priced one: each half takes its + # own line, and neither touches the other's. + page.goto(FIXTURE.as_uri()) + _neutralise_fixture_g8510(page) + _inject_row(page, "85018", "CPTROW_3", checked=True, fee="0.00") + _inject_row(page, "90791", "CPTROW_4", checked=False, fee="150.00") + _count_checkbox_clicks(page, "CPTROW_3") + _count_checkbox_clicks(page, "CPTROW_4") + data = {"cpt": claim_form.extract(page)["cpt"]} + + results = run_all(page, "9103000", data) + assert [r.condition_name for r in results] == \ + ["zero_fee_uncheck", "priced_line_check"], results + assert results[0].changes == [{"row": 3, "code": "85018", "fee": "0.00"}] + assert results[1].changes == [{"row": 4, "code": "90791", "fee": "150.00"}] + assert (_row_state(page, 3), _row_state(page, 4)) == \ + ((False, "0.00"), (True, "150.00")) + assert page.evaluate("window.clicksOn") == {"CPTROW_3": 1, "CPTROW_4": 1} + + +@case +def run_all_leaves_claim_9131000_alone(page): + """ + Live claim 9131000 (Logs\\run_20260929_161613.log), already correct: + the first G8510 checked at 65.00 and tagged Z13.31 alone, a later G8510 + unchecked at 0.00. Nothing may change — not even with this rule in the + registry: the 0.00 copy is not priced, and nobody clicks it. + """ + page.goto(FIXTURE.as_uri()) + page.evaluate("""() => { + const set = (rowId, field, value) => { + document.querySelector( + '#' + rowId + ' [data-fieldname="' + field + '"]').value = value; + }; + set('CPTROW_0', 'ClaimCPTICD2', '3'); + set('CPTROW_0', 'ClaimCPTICD3', ''); + set('CPTROW_2', 'ClaimCPTICD1', '2'); + }""") + _neutralise_fixture_g8510(page) + _inject_full_g8510_row(page, "CPTROW_3", checked=False, fee="0.00", icd1="2") + _count_checkbox_clicks(page, "CPTROW_3") + + results = run_all(page, "9131000", claim_form.extract(page)) + assert results == [], results + assert _row_state(page, 3) == (False, "0.00") + assert page.evaluate("window.clicksOn['CPTROW_3']") == 0 + + +# ============================================================================= +# run_all() against the REAL registry — NO TOGGLE on an excluded line +# ============================================================================= + +@case +def run_all_does_not_toggle_an_unchecked_priced_j2001(page): + """ + J2001 unchecked but still at 45.00 on the claim as read. + priced_line_check's detect() fires on it; j2930_j2001_update zeroes + its fee (no click — it is already unchecked); priced_line_check, + running last, finds it at 0.00 and checks nothing. + """ + page.goto(FIXTURE.as_uri()) + _neutralise_fixture_g8510(page) + _inject_row(page, "J2001", "CPTROW_3", checked=False, fee="45.00") + _count_checkbox_clicks(page, "CPTROW_3") + data = {"cpt": claim_form.extract(page)["cpt"]} + assert plc.detect("9103000", data) is True + assert j2930_j2001_update.detect("9103000", data) is True + + results, log = _run_all_logged(page, "9103000", data) + assert [r.condition_name for r in results] == ["j2930_j2001_update"], results + assert results[0].changes == [{"row": 3, "code": "J2001", + "fee": {"before": "45.00", "after": "0.00"}}], \ + results[0].changes + assert _checks_reported_on(results, 3) == [], results + assert _row_state(page, 3) == (False, "0.00") + assert page.evaluate("window.clicksOn['CPTROW_3']") == 0, \ + "J2001's checkbox was clicked — updateBillToIns() would fire" + _assert_priced_line_check_ran_and_found_nothing(log, "9103000") + + +@case +def run_all_does_not_toggle_an_unchecked_priced_dcons(page): + page.goto(FIXTURE.as_uri()) + _neutralise_fixture_g8510(page) + _inject_row(page, "DCONS", "CPTROW_3", checked=False, fee="45.00") + _count_checkbox_clicks(page, "CPTROW_3") + data = {"cpt": claim_form.extract(page)["cpt"]} + assert plc.detect("9121000", data) is True + assert dummy_codes.detect("9121000", data) is True + + results, log = _run_all_logged(page, "9121000", data) + assert [r.condition_name for r in results] == ["dummy_codes"], results + assert results[0].changes == [{"row": 3, "code": "DCONS", + "fee": {"before": "45.00", "after": "0.00"}}], \ + results[0].changes + assert _checks_reported_on(results, 3) == [], results + assert _row_state(page, 3) == (False, "0.00") + assert page.evaluate("window.clicksOn['CPTROW_3']") == 0, \ + "DCONS's checkbox was clicked — updateBillToIns() would fire" + _assert_priced_line_check_ran_and_found_nothing(log, "9121000") + + +_MEDICARE_DATA = { + "progress_notes": {"text": "Session Setting (PoS): TeleHealth"}, + "insurance": [{"Name": "Humana Medicare"}], +} + + +@case +def run_all_does_not_toggle_a_medicare_telehealth_other_line(page): + """ + Document #22's "Other Primary CPT codes need to be unchecked and change + the fee amount as Zero", on the fixture's own G8510 — already unchecked + but still at 65.00, exactly an excluded-but-priced line. G2025 is added + through the fixture's Fee Schedule picker, every other line is zeroed, + and priced_line_check leaves G8510 excluded. Data shaped the way + test_conditions_medicare_telehealth.py's `_MEDICARE_DATA` is, plus the + CPT grid as read. + """ + page.goto(FIXTURE.as_uri()) + _count_checkbox_clicks(page, "CPTROW_2") + data = dict(_MEDICARE_DATA, cpt=claim_form.extract(page)["cpt"]) + assert plc.detect("9103000", data) is True + assert medicare_telehealth.detect("9103000", data) is True + + results, log = _run_all_logged(page, "9103000", data) + assert [r.condition_name for r in results] == ["medicare_telehealth"], results + g8510 = [c for c in results[0].changes if c.get("row") == 2] + assert g8510 == [{"row": 2, "code": "G8510", + "fee": {"before": "65.00", "after": "0.00"}, + # Unit E (2026-10-05): every change also carries the + # audio/video evidence describe() shows. + "signal": "telehealth", "mode": "video", + "mode_phrase": ""}], results[0].changes + assert _checks_reported_on(results, 2) == [], results + assert _row_state(page, 2) == (False, "0.00") + assert page.evaluate("window.clicksOn['CPTROW_2']") == 0, \ + "G8510's checkbox was clicked — updateBillToIns() would fire" + _assert_priced_line_check_ran_and_found_nothing(log, "9103000") + # G2025, the one line #22 keeps billed, is still billed. + last = page.locator(sel.SEL_CPT_ROWS).last + assert last.locator(sel.SEL_CPT_CODE).first.input_value() == "G2025" + assert last.locator(sel.SEL_CPT_ASSIGN_TO_PATIENT).first.is_checked() is True + + +@case +def pinned_needs_ruling_g2025_unconfirmed_other_line_gets_checked(page): + """ + PINNED, NEEDS A RULING (review finding, 2026-09-30) — the one normal, + non-raising path running last does NOT protect. When G2025 cannot be + confirmed (here: ub_claim.html, where the Fee Schedule picker flow has + no evidence), medicare_telehealth deliberately leaves every other CPT + row as read "for review". An "other" line sitting unchecked but priced + is then checked by this rule, last — billing a line document #22 says + must be excluded, against medicare_telehealth's own log line. Leaving + it for review instead is beyond rulings A/B/C. + """ + page.goto(UB_FIXTURE.as_uri()) + _inject_row(page, "99214", "CPTROW_7", screen="ub", checked=False, + fee="65.00") + data = dict(_MEDICARE_DATA, cpt=claim_form.extract(page)["cpt"]) + results, log = _run_all_logged(page, "9120000", data) + names = [r.condition_name for r in results] + # The fixture's H0023 is billed to Medicare, not a BH payer: #12 + # unchecks and zeroes it (operator, 2026-10-12). + assert names == ["medicare_telehealth", "bh_h_code_modifier", + "priced_line_check"], results + assert results[1].changes == [{"row": 0, "code": "H0023", + "fee": {"before": "74.00", + "after": "0.00"}}], \ + results[1].changes + assert "Could not confirm G2025" in log, log + assert [c for c in results[0].changes if "fee" in c] == [], results[0].changes + assert [(c["code"], c["fee"]) for c in results[2].changes] == \ + [("99214", "65.00")], ("today: the other line is checked", + results[2].changes) + + +@case +def run_all_does_not_toggle_an_unchecked_priced_duplicate_g8510(page): + """ + depression_dx_pointer's duplicate step: the first G8510 checked at + 65.00 is the one kept, and a later G8510 sits unchecked but still at + 75.00. The duplicate step zeroes its fee (no click — already + unchecked); priced_line_check, last, finds it at 0.00. + """ + page.goto(FIXTURE.as_uri()) + _neutralise_fixture_g8510(page) + _inject_full_g8510_row(page, "CPTROW_3", checked=False, fee="75.00") + _count_checkbox_clicks(page, "CPTROW_3") + data = claim_form.extract(page) + assert plc.detect("9103000", data) is True + assert depression_dx_pointer.detect("9103000", data) is True + + results, log = _run_all_logged(page, "9103000", data) + assert [r.condition_name for r in results] == ["depression_dx_pointer"], results + duplicate = [c for c in results[0].changes if "before" not in c] + assert duplicate == [{"row": 3, "code": "G8510", + "fee": {"before": "75.00", "after": "0.00"}}], \ + results[0].changes + assert _checks_reported_on(results, 3) == [], results + assert _row_state(page, 2) == (True, "65.00"), "the kept G8510 stays billed" + assert _row_state(page, 3) == (False, "0.00") + assert page.evaluate("window.clicksOn['CPTROW_3']") == 0, \ + "the duplicate's checkbox was clicked — updateBillToIns() would fire" + _assert_priced_line_check_ran_and_found_nothing(log, "9103000") + + +@case +def run_all_checks_the_unrelated_line_but_never_the_excluded_one(page): + # Both on one claim: 99213 unchecked at 150.00 (checked) and J2001 + # unchecked at 45.00 (excluded) — the rule tells them apart only by + # running after j2930_j2001_update has zeroed J2001. + page.goto(FIXTURE.as_uri()) + _neutralise_fixture_g8510(page) + _set_row(page, "CPTROW_0", checked=False, fee="150.00") + _inject_row(page, "J2001", "CPTROW_3", checked=False, fee="45.00") + _count_checkbox_clicks(page, "CPTROW_0") + _count_checkbox_clicks(page, "CPTROW_3") + data = {"cpt": claim_form.extract(page)["cpt"]} + + results = run_all(page, "9103000", data) + assert [r.condition_name for r in results] == \ + ["j2930_j2001_update", "priced_line_check"], results + assert results[1].changes == [{"row": 0, "code": "99213", "fee": "150.00"}], \ + results[1].changes + assert _checks_reported_on(results, 3) == [], results + assert (_row_state(page, 0), _row_state(page, 3)) == \ + ((True, "150.00"), (False, "0.00")) + assert page.evaluate("window.clicksOn") == {"CPTROW_0": 1, "CPTROW_3": 0} + + +@case +def run_all_with_this_rule_first_would_toggle_j2001(page): + """ + The contrast that is WHY this is registered last: the same claim as + `run_all_does_not_toggle_an_unchecked_priced_j2001`, with this rule + moved ahead of j2930_j2001_update. It checks J2001, and J2001's own + condition unchecks it straight after — the same end state, reached by + two clicks on a line that must stay out and two contradictory changes + in the review box. + """ + page.goto(FIXTURE.as_uri()) + _neutralise_fixture_g8510(page) + _inject_row(page, "J2001", "CPTROW_3", checked=False, fee="45.00") + _count_checkbox_clicks(page, "CPTROW_3") + data = {"cpt": claim_form.extract(page)["cpt"]} + + results = run_all(page, "9103000", data, + conditions=[plc, j2930_j2001_update]) + assert [r.condition_name for r in results] == \ + ["priced_line_check", "j2930_j2001_update"], results + assert results[0].changes == [{"row": 3, "code": "J2001", "fee": "45.00"}] + assert results[1].changes == [{"row": 3, "code": "J2001", "unchecked": True, + "fee": {"before": "45.00", "after": "0.00"}}] + assert _row_state(page, 3) == (False, "0.00") + assert page.evaluate("window.clicksOn['CPTROW_3']") == 2, "the toggle" + + +# ============================================================================= +# run_all() — a KNOWN GAP where this rule meets vaccine_admin_codes' own +# pre-read decision, PINNED pending an operator ruling. Not the settled end +# state; see priced_line_check.py's module docstring. +# ============================================================================= + +_TDAP = "TETANUS DIPHTHERIA TOXOIDS AND ACELLULAR PERTUSSIS VACC" +_ADMIN_1ST = "IMMUNIZATION ADMIN SINGLE OR COMBINATION" + + +@case +def run_all_pins_the_vaccine_gap_for_an_unchecked_priced_admin_line(page): + """ + PINNED KNOWN GAP, pending an operator ruling — the mirror image of the + checked-zero-fee admin line zero_fee_uncheck used to open (fixed + 2026-09-30). vaccine_admin_codes decides on the claim as read, where + the 90471 is UNCHECKED — so "no admin billed", and it adds a new 90471. + This rule then runs last and checks the old 90471, still at 30.00: two + admin lines billed for one vaccine. None of the 39 snapshots has this + shape (none has an unchecked priced line at all). Fixing it means + vaccine_admin_codes counting an unchecked priced admin line as billed — + another condition's behaviour, not ruled on. + """ + page.goto(VACCINE_FIXTURE.as_uri()) + page.evaluate("rows => window.seedCptRows(rows)", [ + {"code": "99396", "title": "PREVENTIVE VISIT EST AGE 40-64", + "fee": "250.00"}, + {"code": "90734", "title": _TDAP, "fee": "60.00", "icd1": "2"}, + {"code": "90471", "title": _ADMIN_1ST, "fee": "30.00", + "checked": False}, + ]) + results = run_all(page, "9113000", claim_form.extract(page)) + assert [r.condition_name for r in results] == \ + ["vaccine_admin_codes", "priced_line_check"], results + assert results[1].changes == [{"row": 2, "code": "90471", "fee": "30.00"}] + grid = [(r["code"], r["billed"], r["billed_fee"]) + for r in claim_form.extract(page)["cpt"]] + assert grid == [("99396", True, "250.00"), ("90734", True, "60.00"), + ("90471", True, "30.00"), ("90471", True, "30.00")], \ + ("today: two 90471 lines billed", grid) + + +def _run_cases() -> int: + for fixture in (FIXTURE, UB_FIXTURE, VACCINE_FIXTURE): + if not fixture.exists(): + print(f" FAIL fixture missing: {fixture}") + return 1 + try: + from playwright.sync_api import sync_playwright + except ImportError: + print(" SKIP playwright is not installed") + return 0 + + failures = 0 + with sync_playwright() as pw: + browser = pw.chromium.launch(headless=True) + page = browser.new_page() + + for fn in CASES: + name = fn.__name__.replace("_", " ") + try: + if len(inspect.signature(fn).parameters) == 0: + fn() + else: + fn(page) + print(f" ok {name}") + except AssertionError as exc: + print(f" FAIL {name}: {exc}") + failures += 1 + except Exception as exc: + print(f" ERROR {name}: {type(exc).__name__}: {exc}") + failures += 1 + browser.close() + + print(f"\n{len(CASES) - failures}/{len(CASES)} passed") + return 1 if failures else 0 + + +def main() -> int: + from ecw_bot import settings as _cfg + # These cases exercise eCW's own click and Add flow, as a LIVE-WRITE run + # does. VERIFY clicks no box and adds no row (eCW saves both at once, + # 2026-10-16): that is tests/test_verify_no_live_writes.py. + orig = _cfg.SAVE_CORRECTIONS + _cfg.SAVE_CORRECTIONS = True + try: + return _run_cases() + finally: + _cfg.SAVE_CORRECTIONS = orig + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tests/test_conditions_runner.py b/tests/test_conditions_runner.py new file mode 100644 index 0000000..f4edb7d --- /dev/null +++ b/tests/test_conditions_runner.py @@ -0,0 +1,458 @@ +r""" +The Condition Framework's runner — exception isolation, not condition logic. + + python tests\test_conditions_runner.py + +See docs\superpowers\specs\2026-09-16-condition-framework-design.md. This +file tests `conditions.base.run_all` in isolation, against small fake +condition modules built for each case — never the real telehealth logic +(that stays in test_conditions_telehealth.py and test_telehealth_fix.py). +The one thing this file exists to prove: **one condition's bug can never +block or corrupt another's result on the same claim.** + +No Playwright needed for most cases — `apply()` on a fake condition never +touches `page`, so `page` is passed through untouched (`None` is fine). +""" + +import io +import sys +import pathlib +import types +import contextlib + +ROOT = pathlib.Path(__file__).resolve().parent.parent +sys.path.insert(0, str(ROOT / "src")) + +from ecw_bot.conditions.base import ConditionResult, run_all # noqa: E402 +import ecw_bot.conditions as conditions_pkg # noqa: E402 + +CASES = [] + + +def case(fn): + CASES.append(fn) + return fn + + +def _fake_condition(name, *, detects=True, changed=True, raises_in=None): + """ + Builds a minimal condition module (matching the real detect/apply/ + describe contract) as a throwaway `types.ModuleType`, so these tests + never need real files on disk for scenarios that only exist to prove + the runner's exception handling. + """ + mod = types.ModuleType(name) + mod.NAME = name + + def detect(claim_id, data): + if raises_in == "detect": + raise RuntimeError(f"{name} detect() blew up") + return detects + + def apply(page, claim_id, data): + if raises_in == "apply": + raise RuntimeError(f"{name} apply() blew up") + return ConditionResult( + condition_name=name, + changed=changed, + changes=[{"row": 0, "code": "X", "before": {}, "after": {}}] + if changed else [], + ) + + def describe(result): + return f"{name}: {len(result.changes)} change(s)" + + mod.detect, mod.apply, mod.describe = detect, apply, describe + return mod + + +@case +def a_condition_that_does_not_detect_is_never_applied(): + calls = [] + + def detect(claim_id, data): + return False + + def apply(page, claim_id, data): + calls.append("apply") + return ConditionResult.none("never") + + mod = types.ModuleType("never") + mod.NAME = "never" + mod.detect, mod.apply, mod.describe = detect, apply, lambda r: "" + + results = run_all(None, "C1", {}, conditions=[mod]) + assert results == [], results + assert calls == [], "apply() must not run when detect() returned False" + + +@case +def results_is_empty_when_every_condition_detects_false(): + a = _fake_condition("a", detects=False) + b = _fake_condition("b", detects=False) + results = run_all(None, "C1", {}, conditions=[a, b]) + assert results == [], results + + +@case +def a_raising_condition_is_skipped_and_does_not_block_the_next_one(): + broken = _fake_condition("broken", raises_in="apply") + healthy = _fake_condition("healthy", changed=True) + results = run_all(None, "C1", {}, conditions=[broken, healthy]) + assert len(results) == 1, results + assert results[0].condition_name == "healthy", results + + +@case +def a_raising_detect_is_also_skipped_without_blocking_others(): + broken = _fake_condition("broken_detect", raises_in="detect") + healthy = _fake_condition("healthy2", changed=True) + results = run_all(None, "C1", {}, conditions=[broken, healthy]) + assert len(results) == 1, results + assert results[0].condition_name == "healthy2", results + + +@case +def a_condition_that_detects_but_changes_nothing_contributes_no_result(): + mod = _fake_condition("no_op", detects=True, changed=False) + results = run_all(None, "C1", {}, conditions=[mod]) + assert results == [], results + + +@case +def the_package_exposes_run_all_the_way_export_medcor_ready_calls_it(): + """ + Regression test for a real bug (run_20260916_162654.log, claim + 9130000): `conditions/__init__.py` never re-exported `run_all` from + `base.py`, so `conditions.run_all(...)` — exactly how + export_medcor_ready.py calls it (`from .. import conditions` then + `conditions.run_all(...)`) — raised AttributeError on every single + claim. Every other test here imported `run_all` straight from + `conditions.base`, so none of them caught it. This one imports the + package itself, the same way the real caller does, so it cannot pass + by accident the way the others did. + """ + assert hasattr(conditions_pkg, "run_all"), ( + "conditions.run_all is missing — export_medcor_ready.py's " + "conditions.run_all(...) call would raise AttributeError on " + "every claim") + assert conditions_pkg.run_all is run_all + + +@case +def multiple_fired_conditions_all_appear_in_order(): + a = _fake_condition("a", changed=True) + b = _fake_condition("b", changed=True) + results = run_all(None, "C1", {}, conditions=[a, b]) + assert [r.condition_name for r in results] == ["a", "b"], results + + +@case +def before_apply_receives_only_fired_conditions_and_runs_before_any_apply(): + """ + The before/after message-box pair the operator sees per claim needs a + checkpoint between "everything has detect()ed" and "the first apply() + touches the DOM" — this proves `before_apply` lands exactly there: + called once, with only the conditions that actually detected True + (never the one that didn't), and before either fired condition's + apply() has run. + """ + calls = [] + + def tracking_apply(name): + def apply(page, claim_id, data): + calls.append(f"apply:{name}") + return ConditionResult(condition_name=name, changed=True, + changes=[{"row": 0}]) + return apply + + a = _fake_condition("a", detects=True) + a.apply = tracking_apply("a") + b = _fake_condition("b", detects=False) + b.apply = tracking_apply("b") + c = _fake_condition("c", detects=True) + c.apply = tracking_apply("c") + + def before_apply(fired): + calls.append("before_apply:" + ",".join(m.NAME for m in fired)) + + results = run_all(None, "C1", {}, conditions=[a, b, c], + before_apply=before_apply) + + assert calls == ["before_apply:a,c", "apply:a", "apply:c"], calls + assert [r.condition_name for r in results] == ["a", "c"], results + + +@case +def before_apply_is_not_called_when_nothing_fires(): + calls = [] + a = _fake_condition("a", detects=False) + run_all(None, "C1", {}, conditions=[a], + before_apply=lambda fired: calls.append(fired)) + assert calls == [], calls + + +# ============================================================================= +# report= — what really happened, not only what changed (review items D2/P2) +# ============================================================================= +# Before 2026-10-05 run_all returned the changed results and nothing else, so +# a condition that raised, or matched and declined to act, left no trace and +# the Charge Summary called the claim "Clean / Pass". + +def _quiet(fn, *args, **kwargs): + with contextlib.redirect_stdout(io.StringIO()): + return fn(*args, **kwargs) + + +def _hooked(name, *, detects, needs_review=None, summary_comment=None, + result=None): + """A fake condition with the optional hooks, and a fixed apply() result.""" + mod = _fake_condition(name, detects=detects) + if result is not None: + mod.apply = lambda page, claim_id, data: result + if needs_review is not None: + mod.needs_review = needs_review + if summary_comment is not None: + mod.summary_comment = summary_comment + return mod + + +@case +def report_records_a_detect_that_raised_as_failed(): + report = {} + broken = _fake_condition("broken_detect", raises_in="detect") + healthy = _fake_condition("healthy") + results = _quiet(run_all, None, "C1", {}, conditions=[broken, healthy], + report=report) + assert [r.condition_name for r in results] == ["healthy"], results + assert report["failed"] == [{"name": "broken_detect", "stage": "detect", + "error": "broken_detect detect() blew up"}], \ + report + assert report["fired"] == ["healthy"], report + assert report["changed"] == ["healthy"], report + + +@case +def report_records_an_apply_that_raised_as_failed_but_still_fired(): + report = {} + broken = _fake_condition("broken", raises_in="apply") + healthy = _fake_condition("healthy") + results = _quiet(run_all, None, "C1", {}, conditions=[broken, healthy], + report=report) + assert [r.condition_name for r in results] == ["healthy"], results + assert report["fired"] == ["broken", "healthy"], report + assert report["changed"] == ["healthy"], report + assert report["failed"] == [{"name": "broken", "stage": "apply", + "error": "broken apply() blew up"}], report + + +@case +def report_keeps_only_the_first_line_of_a_long_error(): + report = {} + mod = _fake_condition("long") + + def apply(page, claim_id, data): + raise RuntimeError("Timeout 30000ms exceeded.\nCall log:\n - waiting") + mod.apply = apply + _quiet(run_all, None, "C1", {}, conditions=[mod], report=report) + assert report["failed"][0]["error"] == "Timeout 30000ms exceeded.", report + + +@case +def a_declined_result_is_review_not_changed(): + report = {} + mod = _hooked("decliner", detects=True, + result=ConditionResult.declined("decliner", "tab gone")) + results = run_all(None, "C1", {}, conditions=[mod], report=report) + assert results == [], results + assert report["fired"] == ["decliner"] and report["changed"] == [], report + assert report["review"] == [{"name": "decliner", "reason": "tab gone"}], \ + report + assert report["failed"] == [], report + + +@case +def declined_is_not_changed_and_none_is_unchanged(): + d = ConditionResult.declined("x", "why") + assert (d.changed, d.changes, d.review) == (False, [], "why"), d + n = ConditionResult.none("x") + assert (n.changed, n.changes, n.review) == (False, [], ""), n + # The new field defaults, so every pre-existing construction still works. + assert ConditionResult("x", True, [1]).review == "" + + +@case +def a_changed_result_with_a_review_reason_is_both_changed_and_review(): + report = {} + res = ConditionResult(condition_name="half", changed=True, + changes=[{"row": 0}], review="check by hand") + mod = _hooked("half", detects=True, result=res) + results = run_all(None, "C1", {}, conditions=[mod], report=report) + assert results == [res], results + assert report["changed"] == ["half"], report + assert report["review"] == [{"name": "half", + "reason": "check by hand"}], report + + +@case +def needs_review_hook_is_asked_only_when_detect_said_no(): + asked = [] + + def hook(claim_id, data): + asked.append(claim_id) + return "payer not covered" + + report = {} + quiet = _hooked("quiet", detects=False, needs_review=hook) + loud = _hooked("loud", detects=True, needs_review=hook) + run_all(None, "C1", {}, conditions=[quiet, loud], report=report) + assert asked == ["C1"], "asked once — for the one that did not fire" + assert report["review"] == [{"name": "quiet", + "reason": "payer not covered"}], report + + +@case +def needs_review_returning_nothing_adds_no_entry(): + report = {} + mod = _hooked("fine", detects=False, needs_review=lambda c, d: None) + run_all(None, "C1", {}, conditions=[mod], report=report) + assert report["review"] == [] and report["failed"] == [], report + + +@case +def a_raising_needs_review_hook_is_failed_and_blocks_nothing_else(): + def hook(claim_id, data): + raise ValueError("bad mapping") + + report = {} + broken = _hooked("hooked", detects=False, needs_review=hook) + healthy = _fake_condition("healthy") + results = _quiet(run_all, None, "C1", {}, conditions=[broken, healthy], + report=report) + assert [r.condition_name for r in results] == ["healthy"], results + assert report["failed"] == [{"name": "hooked", "stage": "needs_review", + "error": "bad mapping"}], report + + +@case +def summary_comment_hook_words_the_comment_of_a_changed_result(): + report = {} + mod = _hooked("worded", detects=True, + summary_comment=lambda r: f"{len(r.changes)} line(s) fixed") + plain = _fake_condition("plain") + run_all(None, "C1", {}, conditions=[mod, plain], report=report) + assert report["comments"] == {"worded": "1 line(s) fixed"}, report + + +@case +def summary_comment_is_not_asked_for_an_unchanged_result(): + asked = [] + mod = _hooked("same", detects=True, + result=ConditionResult.none("same"), + summary_comment=lambda r: asked.append(r) or "x") + report = {} + run_all(None, "C1", {}, conditions=[mod], report=report) + assert asked == [] and report["comments"] == {}, report + + +@case +def a_raising_summary_comment_falls_back_without_tainting_the_claim(): + def hook(result): + raise KeyError("missing") + + report = {} + mod = _hooked("worded", detects=True, summary_comment=hook) + results = _quiet(run_all, None, "C1", {}, conditions=[mod], report=report) + assert [r.condition_name for r in results] == ["worded"], results + assert report["comments"] == {} and report["failed"] == [], report + assert report["review"] == [], report + + +@case +def return_value_is_unchanged_without_a_report(): + """Old callers (no report=) see exactly the list they always did.""" + a = _fake_condition("a") + b = _hooked("b", detects=True, + result=ConditionResult.declined("b", "why")) + c = _fake_condition("c", raises_in="apply") + with_report = _quiet(run_all, None, "C1", {}, conditions=[a, b, c], + report={}) + without = _quiet(run_all, None, "C1", {}, conditions=[a, b, c]) + assert [r.condition_name for r in without] == ["a"], without + assert without == with_report, (without, with_report) + + +@case +def a_report_passed_twice_is_reset_not_appended_to(): + report = {} + broken = _fake_condition("broken", raises_in="apply") + _quiet(run_all, None, "C1", {}, conditions=[broken], report=report) + _quiet(run_all, None, "C2", {}, conditions=[broken], report=report) + assert len(report["failed"]) == 1, report + + +# ============================================================================= +# Every real condition: a CPT/HCPCS tab that will not open is declined +# ============================================================================= + +@case +def every_registered_condition_declines_when_the_cpt_tab_cannot_open(): + """ + Every registered `apply()` re-clicks CPT/HCPCS first and used to return + `none()` when that failed — "nothing to do", reported Clean, on a claim + that was never checked (review item D2). The branch runs before any + page access, so `page=None` is enough. + """ + from ecw_bot.conditions import CONDITIONS + from ecw_bot.conditions.base import CPT_TAB_NOT_CONFIRMED + from ecw_bot.pages import claim_form + + def tab_that_raises(page): + raise RuntimeError("No CPT/HCPCS tab matched (simulated)") + + real = claim_form.open_cpt_hcpcs_tab + claim_form.open_cpt_hcpcs_tab = tab_that_raises + try: + for cond in CONDITIONS: + result = _quiet(cond.apply, None, "C1", {}) + assert result == ConditionResult.declined( + cond.NAME, CPT_TAB_NOT_CONFIRMED), (cond.NAME, result) + finally: + claim_form.open_cpt_hcpcs_tab = real + # The whole registry: the common rule's two halves bracket it, and no + # condition is listed twice. + names = [c.NAME for c in CONDITIONS] + assert len(set(names)) == len(names), names + assert names[0] == "zero_fee_uncheck", names + assert names[-1] == "priced_line_check", names + + +def main() -> int: + failures = 0 + # run_all screenshots a condition that raised (base.shot), and shot() + # creates the operator's dated Screenshots\ folder before it fails on a + # page of None. A no-op here keeps the suite out of that folder (final + # review, 2026-10-05), as test_save_corrections_gate.py already does. + from ecw_bot.conditions import base + orig_shot = base.shot + base.shot = lambda page, name: None + try: + for fn in CASES: + name = fn.__name__.replace("_", " ") + try: + fn() + print(f" ok {name}") + except AssertionError as exc: + print(f" FAIL {name}: {exc}") + failures += 1 + except Exception as exc: + print(f" ERROR {name}: {type(exc).__name__}: {exc}") + failures += 1 + finally: + base.shot = orig_shot + print(f"\n{len(CASES) - failures}/{len(CASES)} passed") + return 1 if failures else 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tests/test_conditions_telehealth.py b/tests/test_conditions_telehealth.py new file mode 100644 index 0000000..60e49de --- /dev/null +++ b/tests/test_conditions_telehealth.py @@ -0,0 +1,424 @@ +r""" +conditions/telehealth.py — the thin detect/apply/describe wrapper around +Condition 1's existing logic. + + python tests\test_conditions_telehealth.py + +The actual DOM mechanics (`note_mentions_telehealth`, `set_cpt_pos_modifier`) +are already covered exhaustively in test_telehealth_fix.py and do not move — +this file only proves the wrapper calls them correctly and shapes the result +the Condition Framework's runner expects. Reuses ub_claim.html, same as +test_telehealth_fix.py, for the one case that needs a real form. +""" + +import sys +import pathlib +import inspect + +ROOT = pathlib.Path(__file__).resolve().parent.parent +sys.path.insert(0, str(ROOT / "src")) + +FIXTURE = pathlib.Path(__file__).resolve().parent / "fixtures" / "ub_claim.html" + +from ecw_bot.conditions import telehealth # noqa: E402 +from ecw_bot.conditions.base import ConditionResult # noqa: E402 +from ecw_bot.selectors import claim_form as sel # noqa: E402 + +CASES = [] + + +def case(fn): + CASES.append(fn) + return fn + + +@case +def detect_true_when_the_note_mentions_telehealth(): + data = {"progress_notes": {"text": "Session Setting (PoS): TeleHealth"}} + assert telehealth.detect("C1", data) is True + + +@case +def detect_true_for_telephone_and_standalone_th_too(): + assert telehealth.detect( + "C1", {"progress_notes": {"text": "Mode of Treatment: Telephone"}} + ) is True + assert telehealth.detect( + "C1", {"progress_notes": {"text": "Visit type: TH"}} + ) is True + + +@case +def detect_false_with_no_progress_notes_key_at_all(): + """A claim dict from extract() before Prog. Notes was opened — must not + raise on the missing key, just report nothing to detect.""" + assert telehealth.detect("C1", {}) is False + + +@case +def detect_false_for_an_ordinary_note(): + data = {"progress_notes": {"text": "Regular in-office visit, no video."}} + assert telehealth.detect("C1", data) is False + + +# --- modifier selection (condition #21 of the Charge Condition Document) -- +# +# `_choose_modifier` returns `(modifier, decline_reason)` since review item +# D5 (2026-10-05), so apply() can tell "write this", "not covered" +# (Medicare) and "declined" apart. + +@case +def audio_only_is_modifier_93_for_commercial_payers(): + assert telehealth._choose_modifier("audio", "Aetna") == ("93", "") + + +@case +def video_is_modifier_95_for_commercial_payers(): + assert telehealth._choose_modifier("video", "Aetna") == ("95", "") + + +@case +def medicaid_is_always_gt_even_for_an_audio_only_visit(): + """ + The client's own expected results (claims 9123000, 9124000, 9122000 — + Medicaid-group telephone visits) keep or add GT, never 93: the + document's "93 for Audio only" applies to Commercial, not Medicaid. + """ + assert telehealth._choose_modifier("audio", "BH COLORADO ACCESS") \ + == ("GT", "") + assert telehealth._choose_modifier("audio", "Medicaid") == ("GT", "") + + +@case +def every_rae_payer_gets_gt_like_medicaid_audio_or_video(): + """ + The nine RAE payers are grouped "RAE payer", not "Medicaid", in + payer_groups.json — but they are Medicaid plans, and their telehealth + rule is the Medicaid one: POS 2 + GT (confirmed 2026-09-24). + """ + for payer in ("BH COLORADO ACCESS", "BH ROCKY MOUNTAIN HEALTH", + "BH ROCKY MTN HEALTH 2022", "BH ROCK MTN UB", + "Carelon Behavioral Health, Inc", + "CCH CO COMMUNITY HEALTH ALLIANCE", "HEALTH COLORADO INC", + "Northeast Colorado Health Department", + "Northeast Health Partners"): + for mode in ("audio", "video"): + assert telehealth._choose_modifier(mode, payer) == ("GT", ""), \ + (payer, mode) + + +@case +def medicare_is_not_covered_in_every_mode(): + """ + The actual bug this test guards: before this was fixed, _choose_modifier + checked mode == "audio" BEFORE the payer group, so an audio-only + Medicare claim got 93 from THIS condition too — while + medicare_telehealth.py also fired on the same row, double-writing it. + Payer group must be checked first, in every mode — and "not covered" + carries no decline reason: medicare_telehealth handles the claim, so + it is not a review. + """ + for mode in ("audio", "video"): + assert telehealth._choose_modifier(mode, "Humana Medicare") \ + == telehealth.NOT_COVERED == (None, "") + + +@case +def a_medicare_advantage_plan_gets_the_commercial_modifier(): + """Ruling 2026-10-21: the dashboard's Commercial Ins — 93 / 95 (#21), + not G2025 (#22).""" + for plan in ("AARP Medicare Advantage", "Aetna Medicare ADV"): + assert telehealth._choose_modifier("audio", plan) == ("93", "") + assert telehealth._choose_modifier("video", plan) == ("95", "") + + +@case +def payers_with_no_rule_are_declined_with_a_reason_in_every_mode(): + """ + Review item D5: the OLD expectation here was `_choose_modifier("audio", + "") == "93"` — an unmapped or blank payer on an audio visit got 93 + written, because the audio branch ran before the payer lookup. An + unclassified Medicare plan then got rule #21 where #22 applies, and an + unmapped Medicaid MCO got 93 instead of GT. Now every payer with no + telehealth modifier rule is declined, audio or video, with the payer + and its group named. + """ + for payer, group in (("Some Unmapped Payer", "not in payer_groups.json"), + ("OUTREACH PROGRAM", "Program"), + ("DentaQuest", "Dentaquest")): + for mode in ("audio", "video"): + modifier, reason = telehealth._choose_modifier(mode, payer) + assert modifier is None, (payer, mode, modifier) + assert reason == ( + f"telehealth visit, but payer {payer!r} ({group}) has no " + f"telehealth modifier rule — set POS/modifier by hand"), reason + # A blank payer is a read that found no name, not an unmapped payer: + # its reason says so instead of pointing at payer_groups.json (final + # review, 2026-10-05; this case used to expect "payer '' (not in + # payer_groups.json)"). + for mode in ("audio", "video"): + for blank in ("", " "): + modifier, reason = telehealth._choose_modifier(mode, blank) + assert modifier is None, (blank, mode, modifier) + assert reason == ("telehealth visit, but no primary payer name " + "was read — set POS/modifier by hand"), reason + + +@case +def an_unloadable_mapping_is_declined_not_guessed(): + original = telehealth.payer_groups.load_mapping + + def broken(): + raise ValueError("bad JSON") + telehealth.payer_groups.load_mapping = broken + try: + assert telehealth._choose_modifier("video", "Aetna") \ + == (None, "payer_groups.json could not be loaded") + finally: + telehealth.payer_groups.load_mapping = original + + +# --- apply(), against the real fixture ------------------------------------ + +_MEDICAID_DATA = { + "progress_notes": {"text": "Session Setting (PoS): TeleHealth"}, + "insurance": [{"Name": "BH COLORADO ACCESS"}], +} + + +@case +def apply_sets_pos_2_and_the_medicaid_modifier_on_the_first_row_only(page): + page.goto(FIXTURE.as_uri()) + result = telehealth.apply(page, "9120000", _MEDICAID_DATA) + assert isinstance(result, ConditionResult) + assert result.condition_name == "telehealth" + assert result.changed is True + assert len(result.changes) == 1, result.changes + assert result.changes[0]["code"] == "H0023", result.changes + assert result.changes[0]["after"]["pos"] == "2", result.changes + assert result.changes[0]["after"]["mod1"] == "GT", result.changes + + # Row 1 (90791) must be untouched — this condition is first-row-only. + row1_mod1 = page.locator(sel.SEL_CPT_ROWS).nth(1).locator( + sel.SEL_CPT_MOD1).first + assert row1_mod1.input_value() != "GT", \ + "telehealth condition must not touch the second CPT row" + + +@case +def apply_keeps_gt_for_an_audio_only_visit_on_a_medicaid_payer(page): + page.goto(FIXTURE.as_uri()) + data = { + "progress_notes": {"text": "Mode of Treatment: Telephone"}, + "insurance": [{"Name": "BH COLORADO ACCESS"}], + } + result = telehealth.apply(page, "9120000", data) + assert result.changed is True + assert len(result.changes) == 1, result.changes + assert result.changes[0]["after"]["mod1"] == "GT", result.changes + + +@case +def apply_uses_93_for_an_audio_only_visit_on_a_commercial_payer(page): + page.goto(FIXTURE.as_uri()) + data = { + "progress_notes": {"text": "Mode of Treatment: Telephone"}, + "insurance": [{"Name": "Aetna"}], + } + result = telehealth.apply(page, "9120000", data) + assert result.changed is True + assert result.changes[0]["after"]["mod1"] == "93", result.changes + + +@case +def apply_declines_every_payer_with_no_telehealth_rule(page): + """ + Review item D5: unmapped, blank, Program and Dentaquest payers are + declined (a Review row with the reason), and nothing is written — the + old behaviour was a quiet none() on video and 93 written on audio. + """ + for payer in ("Some Unmapped Payer", "", "OUTREACH PROGRAM", + "DentaQuest"): + for note in ("Session Setting (PoS): TeleHealth", + "Mode of Treatment: Telephone"): + page.goto(FIXTURE.as_uri()) + data = {"progress_notes": {"text": note}, + "insurance": [{"Name": payer}]} + result = telehealth.apply(page, "9120000", data) + assert result.changed is False and result.changes == [], result + if payer: + assert f"payer {payer!r}" in result.review, result.review + assert "has no telehealth modifier rule" in result.review + else: + assert "no primary payer name was read" in result.review, \ + result.review + row0 = page.locator(sel.SEL_CPT_ROWS).nth(0) + assert row0.locator(sel.SEL_CPT_POS).first.input_value() == "11", \ + "a declined payer must leave the claim as read" + + +@case +def apply_uses_95_for_a_video_visit_on_a_commercial_payer(page): + page.goto(FIXTURE.as_uri()) + data = { + "progress_notes": {"text": "TELEHEALTH: real-time audio and video"}, + "insurance": [{"Name": "Aetna"}], + } + result = telehealth.apply(page, "9120000", data) + assert result.changed is True and result.review == "", result + assert result.changes[0]["after"]["mod1"] == "95", result.changes + + +@case +def apply_returns_none_not_review_for_a_medicare_payer(page): + """medicare_telehealth fires on the same claim and handles it — a + review here would put a correctly handled claim in front of a human.""" + page.goto(FIXTURE.as_uri()) + data = {"progress_notes": {"text": "Mode of Treatment: Telephone"}, + "insurance": [{"Name": "Humana Medicare"}]} + result = telehealth.apply(page, "9120000", data) + assert result == ConditionResult.none("telehealth"), result + + +@case +def apply_returns_none_for_an_already_correct_claim(page): + """ + Review item D3: a claim whose primary row already holds POS 2 (eCW + writes "02") and the right modifier is Pass, not "Corrections pending" + — none(), no review. + """ + page.goto(FIXTURE.as_uri()) + row0 = page.locator(sel.SEL_CPT_ROWS).nth(0) + row0.locator(sel.SEL_CPT_POS).first.fill("02") # M1 is already GT + result = telehealth.apply(page, "9120000", _MEDICAID_DATA) + assert result == ConditionResult.none("telehealth"), result + assert row0.locator(sel.SEL_CPT_POS).first.input_value() == "02" + + +@case +def apply_sends_a_write_that_did_not_take_to_review(page): + """A POS that reads back wrong after the fill (eCW reformatting it, + a field that ignores input) is kept in the result but marked review.""" + page.goto(FIXTURE.as_uri()) + page.evaluate("""() => { + const el = document.getElementById('uBClaimPOS0'); + el.addEventListener('input', () => { el.value = '11'; }); + }""") + result = telehealth.apply(page, "9120000", _MEDICAID_DATA) + assert result.changed is True, result + assert result.changes[0]["ok"] is False, result.changes + assert result.review == ("POS/modifier did not take on row 1 — set it " + "by hand"), result.review + assert "did NOT take" in telehealth.describe(result) + + +@case +def describe_and_summary_comment_say_what_actually_changed(page): + page.goto(FIXTURE.as_uri()) + data = {"progress_notes": {"text": "TELEHEALTH: audio only visit"}, + "insurance": [{"Name": "Aetna"}]} + result = telehealth.apply(page, "9120000", data) + text = telehealth.describe(result) + assert 'mode audio (matched "audio only")' in text, text + assert "93" in text and "H0023" in text, text + assert telehealth.summary_comment(result) == \ + "Telehealth (POS 02, modifier 93 on H0023)" + + page.goto(FIXTURE.as_uri()) + result = telehealth.apply(page, "9120000", _MEDICAID_DATA) + assert "mode video (default — no audio-only evidence)" in \ + telehealth.describe(result) + # The fixture's row already has M1 GT, so only POS was fixed — and only + # POS is named (final review, 2026-10-05; this used to expect + # "POS 02, modifier GT on H0023", reading the correct M1 as a fix). + assert telehealth.summary_comment(result) == \ + "Telehealth (POS 02 on H0023)" + + +@case +def describe_mentions_the_changed_row(page): + page.goto(FIXTURE.as_uri()) + result = telehealth.apply(page, "9120000", _MEDICAID_DATA) + text = telehealth.describe(result) + assert "H0023" in text, text + assert "POS" in text and "GT" in text, text + # _MEDICAID_DATA's note text is "Session Setting (PoS): TeleHealth" — + # the "telehealth" signal, not "telephone" or a TH tag — so the + # operator can see WHY this fired, not just that it did. + assert "telehealth" in text, text + + +@case +def a_live_grid_that_reads_empty_after_a_full_first_read_is_declined(page): + """ + Final review, 2026-10-05: the claim's first read had CPT rows, but the + live grid apply() re-read had none (not re-rendered after the tab + re-click). The fix was never applied — declined (Review), not none() + (Pass). With no CPT rows in the first read either it stays none(). + """ + from ecw_bot.conditions.base import CPT_GRID_NOT_READ + page.goto(FIXTURE.as_uri()) + page.evaluate("""() => document.querySelectorAll('tr[id^="CPTROW_"]') + .forEach(r => r.remove())""") + result = telehealth.apply(page, "9120000", + dict(_MEDICAID_DATA, cpt=[{"code": "H0023"}])) + assert result == ConditionResult.declined("telehealth", + CPT_GRID_NOT_READ), result + page.goto(FIXTURE.as_uri()) + page.evaluate("""() => document.querySelectorAll('tr[id^="CPTROW_"]') + .forEach(r => r.remove())""") + result = telehealth.apply(page, "9120000", _MEDICAID_DATA) + assert result == ConditionResult.none("telehealth"), result + + +@case +def summary_comment_names_only_the_field_that_was_fixed(): + def row(pos, mod): + return {"row": 0, "code": "99213", "changed": True, "ok": True, + "before": {"pos": pos, "mod1": mod}, + "after": {"pos": "2", "mod1": "GT"}, "modifier": "GT"} + sc = telehealth.summary_comment + mk = lambda c: ConditionResult("telehealth", True, [c]) + assert sc(mk(row("11", "GT"))) == "Telehealth (POS 02 on 99213)" + assert sc(mk(row("02", ""))) == "Telehealth (modifier GT on 99213)" + assert sc(mk(row("11", "95"))) == \ + "Telehealth (POS 02, modifier GT on 99213)" + + +def main() -> int: + if not FIXTURE.exists(): + print(f" FAIL fixture missing: {FIXTURE}") + return 1 + try: + from playwright.sync_api import sync_playwright + except ImportError: + print(" SKIP playwright is not installed") + return 0 + + failures = 0 + with sync_playwright() as pw: + browser = pw.chromium.launch(headless=True) + page = browser.new_page() + + for fn in CASES: + name = fn.__name__.replace("_", " ") + try: + if len(inspect.signature(fn).parameters) == 0: + fn() + else: + fn(page) + print(f" ok {name}") + except AssertionError as exc: + print(f" FAIL {name}: {exc}") + failures += 1 + except Exception as exc: + print(f" ERROR {name}: {type(exc).__name__}: {exc}") + failures += 1 + browser.close() + + print(f"\n{len(CASES) - failures}/{len(CASES)} passed") + return 1 if failures else 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tests/test_conditions_vaccine_admin_codes.py b/tests/test_conditions_vaccine_admin_codes.py new file mode 100644 index 0000000..87a230d --- /dev/null +++ b/tests/test_conditions_vaccine_admin_codes.py @@ -0,0 +1,1366 @@ +r""" +conditions/vaccine_admin_codes.py — Charge Condition Document row 18: +vaccine codes billed without an immunization-admin code get the admin +code(s) added — 90460/90461 for age 18 and below, 90471/90472 above 18 — +with the "each additional" code's Units = vaccine count - 1. + + python tests\test_conditions_vaccine_admin_codes.py + +Pure-data cases first (detection, payer gate, age), then page cases +against fixtures/vaccine_claim.html, whose Fee Schedule picker is the same +simulation hcfa_claim.html uses for G2025 — `add_cpt_row()` is exercised for +real (click Add, search, pick the exact match, OK), never stubbed. + +The three "no change" data cases at the end mirror live claims the client +had already fixed by hand (9131000, 9132000, 9133000): the condition must +leave every one of them alone. + +Every checked vaccine or admin row a page case seeds carries its own fee. +The fixture's `seedCptRows` prices a row with no `fee` at 0.00, and since +the operator's ruling of 2026-09-30 a CHECKED line billed at zero is not +billed at all — zero_fee_uncheck unchecks it first — so it no longer counts +as a vaccine, or as "admin already billed". A case that meant a billed line +says so with a price; the cases about a zero-fee line say "0.00". +""" + +import contextlib +import io +import sys +import pathlib +import inspect + +ROOT = pathlib.Path(__file__).resolve().parent.parent +sys.path.insert(0, str(ROOT / "src")) + +FIXTURE = pathlib.Path(__file__).resolve().parent / "fixtures" / "vaccine_claim.html" +UB_FIXTURE = pathlib.Path(__file__).resolve().parent / "fixtures" / "ub_claim.html" +HCFA_FIXTURE = pathlib.Path(__file__).resolve().parent / "fixtures" / "hcfa_claim.html" + +from ecw_bot import conditions # noqa: E402 +from ecw_bot.conditions import vaccine_admin_codes as vac # noqa: E402 +from ecw_bot.conditions.base import ConditionResult, run_all # noqa: E402 +from ecw_bot.pages import claim_form # noqa: E402 +from ecw_bot.selectors import claim_form as sel # noqa: E402 + + +CASES = [] + + +def case(fn): + CASES.append(fn) + return fn + + +# ============================================================================= +# Data helpers +# ============================================================================= + +def _row(code, desc="", billed=True, sdos="07/07/2026", units="1"): + return {"code": code, "description": desc, "billed": billed, + "sdos": sdos, "units": units} + + +def _data(cpt, payer="Medicaid", dob="01/02/1976", age_sex=" 50Y, M", + claim_date="07/07/2026"): + return { + "cpt": cpt, + "insurance": [{"Name": payer}] if payer is not None else [], + "patient": {"dob": dob, "age_sex": age_sex}, + "header": {"claim_date": claim_date}, + } + + +def _age(data): + return vac.patient_age(data, vac.checked_vaccine_rows(data.get("cpt")))[0] + + +_TDAP = "TETANUS DIPHTHERIA TOXOIDS AND ACELLULAR PERTUSSIS VACC" +_ADMIN_1ST = "IMMUNIZATION ADMIN SINGLE OR COMBINATION" +_ADMIN_ADD = "IMMUNIZATION ADMIN, EACH ADD" + + +# ============================================================================= +# Vaccine / admin row recognition +# ============================================================================= + +_CLIENT_VACCINES = ("90633", "90648", "90651", "90656", "90657", "90660", + "90680", "90687", "90698", "90707", "90710", "90713", + "90716", "90732", "90734", "90744", "90746", "91321", + "91322") + + +@case +def every_code_on_the_client_vaccine_list_is_a_vaccine(): + """Operator, 2026-10-13: the 19 codes of the client's list, by code.""" + assert vac.VACCINE_CODES == frozenset(_CLIENT_VACCINES) + for code in _CLIENT_VACCINES: + assert vac.is_vaccine_row(_row(code)), code + + +@case +def a_listed_code_is_a_vaccine_whatever_its_hover_text(): + assert vac.is_vaccine_row(_row("90732", "PCV20")) + assert vac.is_vaccine_row(_row("90746", "")) + + +@case +def a_code_off_the_list_is_not_a_vaccine_whatever_its_hover_text(): + # The earlier rule took "VACC" in the hover text or 90476-90759; both + # are gone (operator, 2026-10-13) — the list is the whole rule. + for code, hover in (("90715", _TDAP), ("90677", "PCV20"), + ("90750", "Shingrix"), ("90686", "FLU VACC"), + ("90476", ""), ("90759", ""), + ("Q2037", "FLU VACC FLUVIRIN 3 YRS & >"), + ("99213", "Office Visit, Est Pt., Level 3")): + assert not vac.is_vaccine_row(_row(code, hover)), code + + +@case +def only_the_four_admin_codes_are_admin_rows(): + for code in ("90460", "90461", "90471", "90472"): + assert vac.is_admin_row(_row(code)), code + assert not vac.is_vaccine_row(_row(code)), code + # Other administration codes, and "ADMIN" in the hover text, are not + # this condition's admin codes (operator, 2026-10-13). + for code, hover in (("90473", ""), ("90474", ""), ("90480", ""), + ("G0008", ""), ("G0009", ""), ("G0010", ""), + ("0002A", "IMMUNIZATION ADMIN COVID 2ND")): + assert not vac.is_admin_row(_row(code, hover)), code + + +@case +def a_covid_vaccine_with_90480_billed_still_gets_its_admin_code(): + """90480 is not one of the four admin codes, so it is not "admin + already billed": 91321 on an adult gets 90471 added.""" + cpt = [_row("99396"), _row("91321"), _row("90480")] + assert [r["code"] for r in vac.checked_vaccine_rows(cpt)] == ["91321"] + assert vac.checked_admin_present(cpt) is False + assert vac.detect("C1", _data(cpt)) is True + + +@case +def flu_plus_covid_count_as_two_vaccines(): + data = _data([_row("90656"), _row("91322"), _row("90480")]) + assert vac._plan("C1", data, data["cpt"])["vaccines"] == ["90656", "91322"] + + +@case +def vaccine_count_is_rows_not_units(): + # Ruling 4: one vaccine row at Units 3 is ONE vaccine -> 90471 x1 only. + data = _data([_row("99396"), _row("90734", _TDAP, units="3")]) + assert vac._plan("C1", data, data["cpt"])["vaccines"] == ["90734"] + data = _data([_row("90734", _TDAP, units="2"), + _row("90732", "PCV20", units="1")]) + assert len(vac._plan("C1", data, data["cpt"])["vaccines"]) == 2 + + +@case +def unchecked_vaccine_is_not_counted(): + cpt = [_row("99396"), _row("90734", _TDAP, billed=False)] + assert vac.checked_vaccine_rows(cpt) == [] + assert vac.detect("C1", _data(cpt)) is False + + +@case +def unknown_checkbox_state_counts_as_checked(): + # billed=None: the row has no "Assign To Patient" box at all; an absent + # key is a snapshot from before extract() read it. Neither may silently + # drop a vaccine. + none_row = _row("90734", _TDAP, billed=None) + missing = {"code": "90732", "description": "PCV20"} + assert vac.checked_vaccine_rows([none_row, missing]) == [none_row, missing] + + +# ============================================================================= +# A checked line billed at zero — zero_fee_uncheck unchecks it first +# (operator ruling, 2026-09-30) +# ============================================================================= + +def _fee(row, fee): + """`row` with a Billed Fee as extract() reads it.""" + return dict(row, billed_fee=fee) + + +@case +def a_checked_zero_fee_admin_line_is_not_admin_already_billed(): + # zero_fee_uncheck, registered first, unchecks it before this + # condition's apply() runs — counting it would leave the vaccine billed + # with no admin code (document #18's own case). + for fee in ("0.00", "", "0", "$0.00"): + cpt = [_fee(_row("90734", _TDAP), "60.00"), + _fee(_row("90471", _ADMIN_1ST), fee)] + assert vac.checked_admin_present(cpt) is False, fee + assert vac.detect("C1", _data(cpt)) is True, fee + plan = vac._plan("C1", _data(cpt), cpt) + assert (plan["first"], plan["vaccines"]) == ("90471", ["90734"]), (fee, plan) + + +@case +def a_checked_priced_admin_line_is_still_admin_already_billed(): + for fee in ("30.00", "$30.00", "N/A"): # N/A: does not parse, not zero + cpt = [_fee(_row("90734", _TDAP), "60.00"), + _fee(_row("90471", _ADMIN_1ST), fee)] + assert vac.checked_admin_present(cpt) is True, fee + assert vac.detect("C1", _data(cpt)) is False, fee + + +@case +def a_zero_fee_admin_line_with_an_unknown_checkbox_still_counts(): + # billed None keeps today's behaviour: zero_fee_uncheck never touches a + # row with no "Assign To Patient" checkbox, so it stays "billed". + cpt = [_fee(_row("90734", _TDAP), "60.00"), + _fee(_row("90471", _ADMIN_1ST, billed=None), "0.00")] + assert vac.checked_admin_present(cpt) is True + assert vac.detect("C1", _data(cpt)) is False + + +@case +def an_admin_line_whose_fee_was_never_read_still_counts(): + # No billed_fee key at all (a snapshot from before extract() read fees, + # or a hand-built row): the fee is unknown, not zero — today's behaviour. + cpt = [_row("90734", _TDAP), _row("90471", _ADMIN_1ST)] + assert "billed_fee" not in cpt[1] + assert vac.checked_admin_present(cpt) is True + assert vac.detect("C1", _data(cpt)) is False + + +@case +def a_checked_zero_fee_vaccine_is_not_counted(): + # The same ruling for a vaccine row: zero_fee_uncheck unchecks it, so it + # is not billed — one vaccine left, so 90471 alone, no 90472. + tdap = _fee(_row("90734", _TDAP), "0.00") + pcv = _fee(_row("90732", "PCV20"), "290.00") + assert vac.checked_vaccine_rows([tdap, pcv]) == [pcv] + plan = vac._plan("C1", _data([tdap, pcv]), [tdap, pcv]) + assert plan["vaccines"] == ["90732"], plan + assert plan["first"] == "90471", plan + + +@case +def a_claim_whose_only_vaccine_is_checked_at_zero_fires_nothing(): + cpt = [_fee(_row("99396"), "250.00"), _fee(_row("90734", _TDAP), "0.00")] + assert vac.checked_vaccine_rows(cpt) == [] + assert vac.detect("C1", _data(cpt)) is False + + +@case +def a_zero_fee_vaccine_with_an_unknown_checkbox_still_counts(): + none_row = _fee(_row("90734", _TDAP, billed=None), "0.00") + assert vac.checked_vaccine_rows([none_row]) == [none_row] + assert vac.detect("C1", _data([none_row])) is True + + +@case +def a_checked_admin_line_with_no_fee_input_still_counts(): + # billed_fee None: extract() found NO Billed Fee input on the row + # (review finding, 2026-09-30). zero_fee_uncheck's apply() skips such a + # row (`uncheck_zero_fee_cpt_rows`: no fee input -> left alone), so it is + # still billed when this condition's apply() runs — it must count. + cpt = [_fee(_row("90734", _TDAP), "60.00"), + _fee(_row("90471", _ADMIN_1ST), None)] + assert vac.unchecked_by_zero_fee_rule(cpt[1]) is False + assert vac.checked_admin_present(cpt) is True + assert vac.detect("C1", _data(cpt)) is False + + +@case +def a_checked_vaccine_with_no_fee_input_still_counts(): + tdap = _fee(_row("90734", _TDAP), None) + pcv = _fee(_row("90732", "PCV20"), "290.00") + assert vac.checked_vaccine_rows([tdap, pcv]) == [tdap, pcv] + plan = vac._plan("C1", _data([tdap, pcv]), [tdap, pcv]) + assert plan["vaccines"] == ["90734", "90732"], plan + + +# ============================================================================= +# detect() +# ============================================================================= + +@case +def detect_true_for_checked_vaccines_without_admin(): + cpt = [_row("99396"), _row("90732", "PCV20"), _row("90734", _TDAP)] + assert vac.detect("C1", _data(cpt)) is True + + +@case +def detect_false_when_a_checked_admin_code_is_present(): + cpt = [_row("90734", _TDAP), _row("90471", _ADMIN_1ST)] + assert vac.detect("C1", _data(cpt)) is False + + +@case +def an_admin_by_hover_text_does_not_count_as_admin_billed(): + # By code only since 2026-10-13: 0002A is not one of the four. + cpt = [_row("90734", _TDAP), _row("0002A", "IMMUNIZATION ADMIN COVID 2ND")] + assert vac.detect("C1", _data(cpt)) is True + + +@case +def detect_still_true_when_the_only_admin_code_is_unchecked(): + # Live claim 9132000's unchecked 90460 at 0.00 is not "present". + cpt = [_row("90656", "FLU VACC IIV3 NO PRSV 0.5ML IM"), + _row("90460", "IMMUNIZATION ADMIN", billed=False)] + assert vac.detect("C1", _data(cpt)) is True + + +@case +def detect_false_with_no_vaccine_at_all(): + assert vac.detect("C1", _data([_row("99213")])) is False + assert vac.detect("C1", {}) is False + assert vac.detect("C1", None) is False + + +@case +def detect_false_for_medicare(): + cpt = [_row("90734", _TDAP)] + assert vac.detect("C1", _data(cpt, payer="Humana Medicare")) is False + + +@case +def detect_false_for_an_unmapped_payer_or_no_payer(): + cpt = [_row("90734", _TDAP)] + assert vac.detect("C1", _data(cpt, payer="Some New Carrier Inc")) is False + assert vac.detect("C1", _data(cpt, payer=None)) is False + + +@case +def detect_true_for_every_known_non_medicare_group(): + # The PAYER GATE only, on data shaped like a UB-form read. Commercial + # ("United Health Care") claims open on the professional/HCFA screen — + # end to end in hcfa_screen_vaccine_claim_fires_on_the_payer_from_the_ + # insurances_grid below — and Dental claims never reach run_all at all + # (is_dental_claim_form). + cpt = [_row("90734", _TDAP)] + for payer in ("Medicaid", "BH COLORADO ACCESS", "United Health Care", + "DentaQuest", "OUTREACH PROGRAM"): + assert vac.detect("C1", _data(cpt, payer=payer)) is True, payer + + +@case +def detect_false_when_no_age_can_be_determined(): + cpt = [_row("90734", _TDAP)] + assert vac.detect("C1", _data(cpt, dob="", age_sex="")) is False + + +# ============================================================================= +# needs_review() — "did not fire" that is not "clean" (review item D2) +# ============================================================================= + +@case +def needs_review_names_an_unmapped_payer(): + cpt = [_row("90734", _TDAP)] + why = vac.needs_review("C1", _data(cpt, payer="Some New Carrier Inc")) + assert why and "Some New Carrier Inc" in why and "90734" in why, why + assert "add the admin code by hand" in why, why + + +@case +def needs_review_names_a_claim_with_no_payer_name_read(): + why = vac.needs_review("C1", _data([_row("90734", _TDAP)], payer=None)) + assert why and "no primary payer name was read" in why, why + + +@case +def needs_review_names_a_claim_with_no_usable_age(): + why = vac.needs_review("C1", _data([_row("90734", _TDAP)], dob="", + age_sex="")) + assert why and "no usable DOB or age" in why, why + + +@case +def needs_review_is_silent_for_medicare(): + """Document #19 is Medicare's own rule: not covered, not unfinished.""" + cpt = [_row("90734", _TDAP)] + why = vac.needs_review("C1", _data(cpt, payer="Humana Medicare")) + assert why is None, why + + +@case +def needs_review_is_silent_when_an_admin_code_is_already_billed(): + # Even with a payer it could not classify: nothing is missing. + cpt = [_row("90734", _TDAP), _row("90471", _ADMIN_1ST)] + why = vac.needs_review("C1", _data(cpt, payer="Some New Carrier Inc")) + assert why is None, why + + +@case +def needs_review_is_silent_with_no_vaccine_or_when_detect_fires(): + assert vac.needs_review("C1", _data([_row("99213", "OFFICE VISIT")], + payer="Some New Carrier Inc")) is None + assert vac.needs_review("C1", {}) is None + assert vac.needs_review("C1", _data([_row("90734", _TDAP)])) is None + + +@case +def needs_review_does_not_log_the_decision_a_second_time(): + data = _data([_row("90734", _TDAP)], payer="Some New Carrier Inc") + out = io.StringIO() + with contextlib.redirect_stdout(out): + vac.detect("C1", data) + vac.needs_review("C1", data) + logged = out.getvalue() + assert logged.count("leaving the claim as read") == 1, logged + + +@case +def run_all_reports_the_unmapped_payer_as_review(): + report = {} + data = _data([_row("90734", _TDAP)], payer="Some New Carrier Inc") + with contextlib.redirect_stdout(io.StringIO()): + results = run_all(None, "C1", data, conditions=[vac], report=report) + assert results == [], results + assert report["fired"] == [] and report["failed"] == [], report + assert [r["name"] for r in report["review"]] == [vac.NAME], report + + +# ============================================================================= +# Age +# ============================================================================= + +@case +def age_is_whole_years_on_the_first_vaccine_rows_date_of_service(): + cpt = [_row("99396", sdos="01/01/2020"), + _row("90734", _TDAP, sdos="07/07/2026")] + assert _age(_data(cpt, dob="01/02/1976")) == 50 + + +@case +def eighteenth_birthday_on_the_dos_is_still_18_pediatric(): + cpt = [_row("90734", _TDAP, sdos="03/15/2026")] + assert _age(_data(cpt, dob="03/15/2008")) == 18 + assert vac.admin_codes_for_age(18) == ("90460", "90461") + + +@case +def nineteenth_birthday_on_the_dos_is_19_adult(): + cpt = [_row("90734", _TDAP, sdos="03/15/2026")] + assert _age(_data(cpt, dob="03/15/2007")) == 19 + assert vac.admin_codes_for_age(19) == ("90471", "90472") + + +@case +def day_before_the_nineteenth_birthday_is_still_18(): + cpt = [_row("90734", _TDAP, sdos="03/14/2026")] + assert _age(_data(cpt, dob="03/15/2007")) == 18 + + +@case +def age_falls_back_to_the_claim_date_when_the_row_has_no_sdos(): + cpt = [_row("90734", _TDAP, sdos="")] + assert _age(_data(cpt, dob="03/15/2007", claim_date="03/15/2026")) == 19 + + +@case +def age_falls_back_to_age_sex_when_dob_is_unusable(): + cpt = [_row("90734", _TDAP)] + assert _age(_data(cpt, dob="", age_sex=" 50Y, M")) == 50 + assert _age(_data(cpt, dob="not a date", age_sex="7Y, F")) == 7 + # a DOB after the date of service is not a usable DOB either + assert _age(_data(cpt, dob="01/01/2030", age_sex=" 50Y, M")) == 50 + + +@case +def months_or_days_only_age_sex_is_zero_years(): + cpt = [_row("90734", _TDAP)] + assert _age(_data(cpt, dob="", age_sex=" 12M 12D, F")) == 0 + assert _age(_data(cpt, dob="", age_sex="3D, M")) == 0 + assert _age(_data(cpt, dob="", age_sex="")) is None + + +# ============================================================================= +# Live samples the client already fixed by hand — must stay untouched +# ============================================================================= + +@case +def live_9131000_adult_with_admin_already_billed_no_change(): + # The live vaccines (90677, 90750, 90715) are off the client's list + # since 2026-10-13; listed codes stand in so the admin rule is tested. + cpt = [_row("99396"), _row("90732", "PCV20"), _row("90746", "Shingrix"), + _row("90734", _TDAP), + _row("90471", _ADMIN_1ST, units="1"), + _row("90472", _ADMIN_ADD, units="2")] + assert vac.detect("9131000", _data(cpt, age_sex=" 50Y, F")) is False + + +@case +def live_9132000_adult_with_checked_90471_and_unchecked_90460_no_change(): + # 90673 is off the client's vaccine list (2026-10-13): no vaccine at + # all now, as well as an admin code already billed. + cpt = [_row("99397"), _row("90673", "FLU VACC RIV3 NO PRESERV"), + _row("90471", _ADMIN_1ST), + _row("90460", "IMMUNIZATION ADMIN THROUGH 18 YRS", billed=False)] + assert vac.detect("9132000", + _data(cpt, dob="02/10/1968", age_sex=" 58Y, F")) is False + + +@case +def live_9133000_infant_with_pediatric_admin_already_billed_no_change(): + cpt = [_row("99392"), _row("90732", "PCV20"), + _row("90716", "VARICELLA VIRUS VACCINE LIVE FOR SUBCUTANEOUS USE"), + _row("90707", "MMR VIRUS VACCINE LIVE SUBCUTANEOUS"), + _row("90633", "HEPA VACCINE PED/ADOL-2 DOSE IM"), + _row("90460", "IMMUNIZATION ADMIN THROUGH 18 YRS"), + _row("90461", "IMMUNIZATION ADMIN EACH ADD", units="3")] + assert vac.detect("9133000", + _data(cpt, dob="07/07/2025", age_sex=" 12M, M")) is False + + +# ============================================================================= +# Registry +# ============================================================================= + +@case +def registered_last_of_the_specific_conditions(): + """ + After every other specific condition, so it re-reads a grid they have + already changed. After it come only injection_admin_code (#20, which + adds a line the same way, 2026-10-13), medicare_pps_code (#25, which + adds one too, 2026-10-23) and priced_line_check — the common rule's + reverse half, registered LAST on 2026-09-30 so it only ever sees lines + the exclusion conditions have already zeroed. + """ + from ecw_bot.conditions import (injection_admin_code, medicare_pps_code, + priced_line_check) + assert conditions.CONDITIONS[-4] is vac, conditions.CONDITIONS + assert conditions.CONDITIONS[-3] is injection_admin_code, conditions.CONDITIONS + assert conditions.CONDITIONS[-2] is medicare_pps_code, conditions.CONDITIONS + assert conditions.CONDITIONS[-1] is priced_line_check, conditions.CONDITIONS + assert conditions.BY_NAME["vaccine_admin_codes"] is vac + assert vac.NAME == "vaccine_admin_codes" + + +# ============================================================================= +# Page helpers +# ============================================================================= + +def _fresh(page): + page.goto(FIXTURE.as_uri()) + + +def _seed(page, rows, dob=None, age_sex=None): + page.evaluate("rows => window.seedCptRows(rows)", rows) + if dob is not None or age_sex is not None: + page.evaluate("([d, a]) => window.setPatient(d, a)", + [dob or "", age_sex or ""]) + + +def _live(page): + return claim_form.extract(page) + + +def _rows_for(page, code): + return [r for r in _live(page)["cpt"] if r["code"] == code] + + +_ADULT_ONE = [ + {"code": "99396", "title": "PREVENTIVE VISIT EST AGE 40-64", "fee": "250.00"}, + {"code": "90734", "title": _TDAP, "fee": "60.00", "icd1": "2"}, +] + +_CHILD_FOUR = [ + {"code": "99392", "title": "PREV VISIT EST AGE 1-4", "fee": "180.00"}, + {"code": "90732", "title": "PCV20", "fee": "290.00", "icd1": "2"}, + {"code": "90716", "title": "VARICELLA VIRUS VACCINE LIVE FOR SUBCUTANEOUS USE", + "fee": "180.00", "icd1": "2"}, + {"code": "90707", "title": "MMR VIRUS VACCINE LIVE SUBCUTANEOUS", + "fee": "95.00", "icd1": "2"}, + {"code": "90633", "title": "HEPA VACCINE PED/ADOL-2 DOSE IM", + "fee": "70.00", "icd1": "2"}, +] + + +# ============================================================================= +# Extractor: the additive `billed` field +# ============================================================================= + +@case +def extract_reports_billed_from_the_assign_to_patient_checkbox(page): + _fresh(page) + _seed(page, [{"code": "90734", "title": _TDAP}, + {"code": "90460", "title": "IMMUNIZATION ADMIN", "checked": False}, + {"code": "90732", "title": "PCV20", "noCheckbox": True}]) + cpt = _live(page)["cpt"] + assert [r["billed"] for r in cpt] == [True, False, None], cpt + assert cpt[0]["description"] == _TDAP, cpt[0] + + +@case +def extract_billed_is_none_on_a_ub_form_without_the_checkbox(page): + # ub_claim.html's rows carry no updateBillToIns( checkbox — unknown, + # which the condition treats as checked, never as "not billed". + page.goto(UB_FIXTURE.as_uri()) + cpt = _live(page)["cpt"] + assert cpt and all(r["billed"] is None for r in cpt), cpt + assert cpt[0]["code"] == "H0023", "existing fields must be unchanged" + + +@case +def extract_reports_billed_fee_none_for_a_row_with_no_fee_input(page): + # None ("never read"), not "" — "" is a blank fee that WAS read, which + # is_zero_fee() calls zero. Told apart, zero_fee_uncheck.detect() and + # this condition's pre-read agree with uncheck_zero_fee_cpt_rows(), + # which leaves a row with no fee input alone (review finding 2026-09-30). + _fresh(page) + _seed(page, [{"code": "90734", "title": _TDAP, "fee": "60.00"}, + {"code": "90471", "title": _ADMIN_1ST, "noFee": True}]) + cpt = _live(page)["cpt"] + assert [r["billed_fee"] for r in cpt] == ["60.00", None], cpt + + +@case +def run_all_counts_a_checked_admin_line_with_no_fee_input_as_billed(page): + """ + The claim-level shape of the finding: a checked 90471 with no Billed + Fee input. zero_fee_uncheck never unchecks it (no fee input -> left + alone), so it IS the admin code billed — no second 90471 is added. + Before the fix extract() read its fee as "" (zero): vaccine_admin_codes + fired and added a 90471 on top, two admin lines billed. + """ + _fresh(page) + _seed(page, _ADULT_ONE + [{"code": "90471", "title": _ADMIN_1ST, + "noFee": True}]) + data = _live(page) + assert vac.detect("9113000", data) is False + results = run_all(page, "9113000", data) + assert [r.condition_name for r in results] == [], results + assert [r["billed"] for r in _rows_for(page, "90471")] == [True] + + +@case +def run_all_counts_a_checked_vaccine_with_no_fee_input(page): + # The mirror: a checked vaccine with no fee input stays billed, so it is + # counted — two vaccines, 90471 AND 90472, not 90471 alone. + _fresh(page) + _seed(page, _ADULT_ONE + [{"code": "90716", "title": _CHILD_FOUR[2]["title"], + "noFee": True, "icd1": "2"}]) + results = run_all(page, "9113000", _live(page)) + assert [r.condition_name for r in results] == ["vaccine_admin_codes"], results + added = [c["code"] for c in results[0].changes if c.get("added")] + assert added == ["90471", "90472"], results[0].changes + + +# ============================================================================= +# PINNED, NEEDS AN OPERATOR RULING — an admin code the Fee Schedule prices +# at 0.00. Today's outcome, not the settled one (review finding 2026-09-30). +# ============================================================================= + +@case +def pinned_needs_ruling_an_admin_code_added_at_zero_adds_again_next_run(page): + """ + PINNED, NEEDS A RULING. Run 1: the picker prices the added 90471 at + 0.00 — it lands checked at 0.00 and stays checked (zero_fee_uncheck ran + FIRST, before it existed; priced_line_check only ever checks), and + describe() reports it with no warning. Run 2 on the same claim, still + in the queue: that 90471 is a checked zero-fee admin line, which ruling + B says does not count, so zero_fee_uncheck unchecks it and + vaccine_admin_codes adds ANOTHER 90471 at 0.00 — one more per run. + Whether a line added at 0.00 should be flagged, failed, or left is + beyond rulings A/B/C. + """ + _fresh(page) + page.evaluate("([c, f]) => window.setFeeScheduleFee(c, f)", ["90471", "0.00"]) + _seed(page, _ADULT_ONE) + + def grid(): + return [(r["code"], r["billed"], r["billed_fee"]) + for r in _live(page)["cpt"]] + + results = run_all(page, "9113000", _live(page)) + assert [r.condition_name for r in results] == ["vaccine_admin_codes"], results + assert "CHECK" not in vac.describe(results[0]).upper(), vac.describe(results[0]) + assert grid()[-1] == ("90471", True, "0.00"), grid() + + results = run_all(page, "9113000", _live(page)) + assert [r.condition_name for r in results] == ["zero_fee_uncheck", "vaccine_admin_codes"], results + assert grid()[2:] == [("90471", False, "0.00"), ("90471", True, "0.00")], ("today: a second 90471 added", grid()) + + +@case +def default_fixture_is_what_detect_fires_on(page): + _fresh(page) + data = _live(page) + assert data["insurance"][0]["Name"] == "Medicaid", data["insurance"] + assert data["patient"]["dob"] == "01/02/1976", data["patient"] + assert vac.detect("9113000", data) is True + + +# ============================================================================= +# apply() +# ============================================================================= + +@case +def apply_adult_one_vaccine_adds_90471_x1_only(page): + _fresh(page) + _seed(page, _ADULT_ONE) + data = _live(page) + + result = vac.apply(page, "9113000", data) + assert isinstance(result, ConditionResult) + assert result.condition_name == "vaccine_admin_codes" + assert result.changed is True, result + assert [c["code"] for c in result.changes] == ["90471"], result.changes + assert result.changes[0]["added"] is True + assert result.changes[0]["units"] == "1", result.changes + + added = _rows_for(page, "90471") + assert len(added) == 1 and added[0]["units"] == "1", added + assert added[0]["billed_fee"] == "30.00", added + assert _rows_for(page, "90472") == [], "one vaccine needs no 90472" + assert _rows_for(page, "90460") == [], "an adult never gets 90460" + + +@case +def apply_adult_three_vaccines_adds_90471_x1_and_90472_x1(page): + """ + The operator's simplified rule (2026-09-29): more than one vaccine -> + the two admin codes, each x1 — NOT 90472 x (vaccines - 1). + """ + _fresh(page) # the default grid: 90732, 90746, 90734 on a 50-year-old + data = _live(page) + + result = vac.apply(page, "9113000", data) + assert result.changed is True + by_code = {c["code"]: c for c in result.changes} + assert set(by_code) == {"90471", "90472"}, result.changes + assert by_code["90471"]["units"] == "1", by_code + assert by_code["90472"]["units"] == "1", by_code + assert by_code["90472"]["billed_fee"] == "20.00", by_code + # Both landed: nothing for a human (control for unit D review finding 3). + assert result.review == "", result.review + + row = _rows_for(page, "90472") + assert len(row) == 1 and row[0]["units"] == "1", row + assert row[0]["billed_fee"] == "20.00", row + + +@case +def apply_adult_two_vaccines_leaves_both_lines_as_ecw_priced_them(page): + # Both codes want Units 1 and the picker already gave 1 — so neither + # line's Units is touched; each is left exactly as eCW priced it. + _fresh(page) + _seed(page, _ADULT_ONE + [{"code": "90732", "title": "PCV20", "icd1": "2", + "fee": "290.00"}]) + result = vac.apply(page, "9113000", _live(page)) + by_code = {c["code"]: c for c in result.changes} + assert set(by_code) == {"90471", "90472"}, result.changes + assert by_code["90472"]["units"] == "1", by_code + assert by_code["90472"]["billed_fee"] == "20.00", by_code + for i in (3, 4): + units = page.locator(sel.SEL_CPT_ROWS).nth(i).locator(sel.SEL_CPT_UNITS).first + assert units.input_value() == "1" + assert units.get_attribute("data-blurred") is None, \ + "a line eCW already gave 1 unit must be left as priced" + + +@case +def apply_child_four_vaccines_adds_90460_x1_and_90461_x1(page): + _fresh(page) + _seed(page, _CHILD_FOUR, dob="07/01/2025", age_sex=" 12M 6D, F") + data = _live(page) + + result = vac.apply(page, "9133000", data) + assert result.changed is True + by_code = {c["code"]: c for c in result.changes} + assert set(by_code) == {"90460", "90461"}, result.changes + assert by_code["90460"]["units"] == "1", by_code + assert by_code["90461"]["units"] == "1", by_code + assert by_code["90461"]["billed_fee"] == "15.00", by_code + assert _rows_for(page, "90471") == [] and _rows_for(page, "90472") == [] + + +@case +def apply_does_not_hand_fill_pos_or_icd_pointers(page): + # eCW fills POS / dates / ICD pointers on the new line itself (the + # fixture's picker fakes that by copying the first line). Whatever it + # put there must survive apply() untouched. + _fresh(page) + _seed(page, [{"code": "99396", "title": "PREVENTIVE VISIT", "pos": "22", + "icd1": "1", "icd2": "2"}, + {"code": "90734", "title": _TDAP, "pos": "22", "icd1": "2", + "fee": "60.00"}]) + vac.apply(page, "9113000", _live(page)) + added = _rows_for(page, "90471")[0] + assert added["pos"] == "22" and added["icd1"] == "1" \ + and added["icd2"] == "2", added + + +@case +def apply_still_adds_when_only_an_unchecked_admin_is_present(page): + _fresh(page) + _seed(page, [{"code": "99397", "title": "PREVENTIVE VISIT EST 65+"}, + {"code": "90656", "title": "FLU VACC IIV3 NO PRSV 0.5ML IM", + "fee": "65.00"}, + {"code": "90460", "title": "IMMUNIZATION ADMIN", "checked": False, + "fee": "0.00"}]) + result = vac.apply(page, "9132000", _live(page)) + assert result.changed is True + assert [c["code"] for c in result.changes] == ["90471"], result.changes + assert len(_rows_for(page, "90460")) == 1, "the unchecked 90460 is left alone" + + +@case +def apply_child_with_an_unchecked_90460_adds_a_new_90460_row(page): + _fresh(page) + _seed(page, [{"code": "99392", "title": "PREV VISIT EST AGE 1-4"}, + {"code": "90732", "title": "PCV20", "fee": "290.00"}, + {"code": "90460", "title": "IMMUNIZATION ADMIN", "checked": False, + "fee": "0.00"}], + dob="07/01/2020", age_sex=" 6Y, M") + result = vac.apply(page, "C1", _live(page)) + assert result.changed is True + rows = _rows_for(page, "90460") + assert [r["billed"] for r in rows] == [False, True], rows + + +@case +def apply_add_that_leaves_no_new_row_counts_as_a_failure(page): + # add_cpt_row() verifies only that SOME 90460 row exists afterward — + # true here from the unchecked one alone. The new-row count is what + # tells a real add from a picker that silently did nothing. + _fresh(page) + _seed(page, [{"code": "90732", "title": "PCV20", "fee": "290.00"}, + {"code": "90460", "title": "IMMUNIZATION ADMIN", "checked": False}], + dob="07/01/2020", age_sex=" 6Y, M") + page.evaluate("() => { window.feeScheduleOkAddsNothing = true; }") + result = vac.apply(page, "C1", _live(page)) + assert result.changed is False, result + assert result.changes == [] + assert len(_rows_for(page, "90460")) == 1 + + +@case +def apply_first_code_add_failure_adds_nothing(page): + _fresh(page) + page.evaluate("() => window.removeFromFeeSchedule('90471')") + result = vac.apply(page, "9113000", _live(page)) + assert result.changed is False, result + assert result.changes == [] + # Declined, not none(): the claim still lacks its admin code (D2). + assert result == ConditionResult.declined( + vac.NAME, "90471 could not be added — add it by hand"), result + assert _rows_for(page, "90471") == [] + assert _rows_for(page, "90472") == [], \ + "90472 must not be attempted once 90471 could not be added" + assert not page.locator(sel.SEL_FEE_SCHEDULE_CPT_SEARCH).first.is_visible(), \ + "the picker must be closed, not left blocking the claim form" + + +@case +def apply_additional_code_failure_keeps_the_first_and_reports_it(page): + _fresh(page) + page.evaluate("() => window.removeFromFeeSchedule('90472')") + result = vac.apply(page, "9113000", _live(page)) + assert result.changed is True + by_code = {c["code"]: c for c in result.changes} + assert by_code["90471"]["added"] is True, by_code + assert by_code["90472"]["added"] is False, by_code + # The first code landed, the second did not: the claim still lacks an + # admin code, so it carries a review reason (review of unit D, + # 2026-10-05, finding 3 — this asserted review == "" before, which made + # the summary a Fail row reading "vaccine admin code added"). The + # removed fee-schedule entry means the picker added nothing. + assert result.review == "90472 could not be added — add it by hand", \ + result.review + assert len(_rows_for(page, "90471")) == 1 + text = vac.describe(result) + assert "90472" in text and "FAILED" in text, text + + +@case +def apply_decides_from_the_live_grid_not_the_pre_read_data(page): + # The pre-read data says "vaccines, no admin" — but by the time apply() + # runs, an admin code is on the live grid (e.g. added by hand or by an + # earlier condition). Nothing may be added on top of it. + _fresh(page) + data = _live(page) + _seed(page, [{"code": "90734", "title": _TDAP, "fee": "60.00"}, + {"code": "90471", "title": _ADMIN_1ST, "fee": "30.00"}]) + result = vac.apply(page, "9113000", data) + assert result.changed is False, result + assert len(_rows_for(page, "90471")) == 1 + + +@case +def apply_live_plan_skips_a_checked_zero_fee_admin_line_the_same_as_detect(page): + """ + detect() and apply()'s live-grid plan share one rule (2026-09-30): the + claim as read has its only 90471 checked at 0.00, detect() fires on + it, and apply() — called straight here, zero_fee_uncheck never run — + reads the live grid the same way and adds 90471, rather than calling + the 0.00 line "admin already billed". In a real run zero_fee_uncheck + has unchecked that line first (test_conditions_zero_fee_uncheck.py). + """ + _fresh(page) + _seed(page, _ADULT_ONE + [{"code": "90471", "title": _ADMIN_1ST, + "fee": "0.00"}]) + data = _live(page) + assert vac.detect("9113000", data) is True + result = vac.apply(page, "9113000", data) + assert result.changed is True, result + assert [c["code"] for c in result.changes if c["added"]] == ["90471"], \ + result.changes + assert [(r["billed"], r["billed_fee"]) for r in _rows_for(page, "90471")] \ + == [(True, "0.00"), (True, "30.00")] + + +@case +def apply_live_plan_does_not_count_a_checked_zero_fee_vaccine(page): + # Two vaccine lines, one of them checked at 0.00: one billed vaccine, + # so 90471 alone — detect()'s plan and apply()'s agree. + _fresh(page) + _seed(page, _ADULT_ONE + [{"code": "90732", "title": "PCV20", + "fee": "0.00", "icd1": "2"}]) + data = _live(page) + assert vac._plan("9113000", data, data["cpt"])["vaccines"] == ["90734"] + result = vac.apply(page, "9113000", data) + assert [c["code"] for c in result.changes] == ["90471"], result.changes + assert _rows_for(page, "90472") == [], "one billed vaccine needs no 90472" + + +@case +def apply_leaves_a_medicare_claim_alone(page): + _fresh(page) + data = _live(page) + data["insurance"] = [{"Name": "Humana Medicare"}] + result = vac.apply(page, "9113000", data) + assert result.changed is False + assert _rows_for(page, "90471") == [] + + +@case +def describe_names_vaccines_age_codes_and_units(page): + _fresh(page) + result = vac.apply(page, "9113000", _live(page)) + text = vac.describe(result) + for bit in ("90732", "90746", "90734", "90471", "90472"): + assert bit in text, (bit, text) + # The age line itself — a bare "50" is already inside "90746". + assert "Patient age used: 50 (" in text and "above 18" in text, text + assert "Added 90471 x1" in text and "Added 90472 x1" in text, text + + +@case +def describe_names_the_pediatric_band_for_a_child(page): + _fresh(page) + _seed(page, _CHILD_FOUR, dob="07/01/2025", age_sex=" 12M 6D, F") + text = vac.describe(vac.apply(page, "9133000", _live(page))) + assert "Patient age used: 1 (" in text and "18 and below" in text, text + assert "Added 90460 x1" in text and "Added 90461 x1" in text, text + + +# --- vaccine count: rows, not units (ruling 4) -------------------------------- + +@case +def apply_counts_vaccine_rows_not_their_units(page): + _fresh(page) + _seed(page, [{"code": "99396", "title": "PREVENTIVE VISIT EST AGE 40-64"}, + {"code": "90734", "title": _TDAP, "units": "2", "fee": "120.00"}, + {"code": "90732", "title": "PCV20", "units": "1", + "fee": "290.00"}]) + result = vac.apply(page, "9113000", _live(page)) + by_code = {c["code"]: c for c in result.changes} + assert set(by_code) == {"90471", "90472"}, result.changes + assert by_code["90472"]["units"] == "1", \ + "2 vaccine ROWS -> 90472 x1, whatever units the vaccines carry" + + +# --- the new line is the one Units is written to ------------------------------ + +_CHILD_THREE_WITH_OLD_90461 = [ + {"code": "99392", "title": "PREV VISIT EST AGE 1-4"}, + {"code": "90732", "title": "PCV20", "fee": "290.00"}, + {"code": "90716", "title": "VARICELLA VIRUS VACCINE LIVE FOR SUBCUTANEOUS USE", + "fee": "180.00"}, + {"code": "90707", "title": "MMR VIRUS VACCINE LIVE SUBCUTANEOUS", "fee": "95.00"}, + {"code": "90461", "title": "IMMUNIZATION ADMIN EACH ADD", "checked": False, + "fee": "0.00"}, +] + + +def _assert_new_and_old_90461(rows, new_first): + assert len(rows) == 2, rows + new, old = (rows[0], rows[1]) if new_first else (rows[1], rows[0]) + assert new["billed"] is True and new["units"] == "1" \ + and new["billed_fee"] == "15.00", ("the NEW line gets Units 1", rows) + assert old["billed"] is False and old["units"] == "1" \ + and old["billed_fee"] == "0.00", ("the old unchecked line is untouched", rows) + + +@case +def apply_sets_units_on_the_new_90461_not_an_old_unchecked_one(page): + # The 9132000 shape, pediatric: a leftover UNCHECKED 90461 at 0.00 sits + # above where the new one lands. + # The picker hands back Units 0, so the Units write actually runs — + # it must land on the NEW line, not the old one. + _fresh(page) + _seed(page, _CHILD_THREE_WITH_OLD_90461, dob="07/01/2020", age_sex=" 6Y, M") + page.evaluate("() => { window.feeScheduleNewLineUnits = '0'; }") + result = vac.apply(page, "C1", _live(page)) + by_code = {c["code"]: c for c in result.changes} + assert by_code["90461"]["units"] == "1", by_code + assert by_code["90461"]["error"] is None, by_code + _assert_new_and_old_90461(_rows_for(page, "90461"), new_first=False) + + +@case +def apply_sets_units_on_the_new_line_even_when_it_lands_above_an_old_one(page): + # A grid that inserts the new 90461 ABOVE the old one: "the last row + # with this code" would be the old unchecked line — the diff finds the + # real new one. + _fresh(page) + _seed(page, _CHILD_THREE_WITH_OLD_90461, dob="07/01/2020", age_sex=" 6Y, M") + page.evaluate("() => { window.feeScheduleInsertBeforeCode = '90461'; " + "window.feeScheduleNewLineUnits = '0'; }") + result = vac.apply(page, "C1", _live(page)) + by_code = {c["code"]: c for c in result.changes} + assert by_code["90461"]["units"] == "1", by_code + assert by_code["90461"]["error"] is None, by_code + _assert_new_and_old_90461(_rows_for(page, "90461"), new_first=True) + + +@case +def apply_reports_an_extra_line_the_picker_added_alongside(page): + # The second picker open keeps the first pick, so OK adds 90471 again + # with 90472: the 90472 add itself is fine, the stray line is reported. + _fresh(page) + _seed(page, _ADULT_ONE + [{"code": "90732", "title": "PCV20", "icd1": "2", + "fee": "290.00"}]) + page.evaluate("() => { window.feeScheduleOkAlsoAdds = '90471'; }") + result = vac.apply(page, "9113000", _live(page)) + by_code = {c["code"]: c for c in result.changes} + assert by_code["90472"]["added"] is True, by_code + unexpected = " ".join(by_code["90472"]["unexpected"]) + assert "90471" in unexpected, by_code + text = vac.describe(result) + assert "CHECK BY HAND" in text and "90471" in text, text + + +# --- a failed first add that still changed the grid --------------------------- + +@case +def apply_first_add_that_rechecks_an_old_line_is_reported_not_hidden(page): + # OK re-checks and re-prices the claim's existing UNCHECKED 90460 + # instead of appending one: nothing was "added", but the grid is not + # what it was — a result of "nothing changed" would let another + # condition's Save write it unreviewed. + _fresh(page) + _seed(page, [{"code": "99392", "title": "PREV VISIT EST AGE 1-4"}, + {"code": "90732", "title": "PCV20", "fee": "290.00"}, + {"code": "90716", "title": "VARICELLA VIRUS VACCINE LIVE", + "fee": "180.00"}, + {"code": "90460", "title": "IMMUNIZATION ADMIN", "checked": False, + "fee": "0.00"}], + dob="07/01/2020", age_sex=" 6Y, M") + page.evaluate("() => { window.feeScheduleOkRechecksExisting = true; }") + result = vac.apply(page, "C1", _live(page)) + assert result.changed is True, result + assert [c["code"] for c in result.changes] == ["90460"], result.changes + change = result.changes[0] + assert change["added"] is False and change["grid_changed"] is True, change + # And flagged: a failed add that moved the grid is never "clean" (D2). + assert result.review == ("90460 could not be added cleanly — check the " + "CPT grid by hand"), result.review + assert _rows_for(page, "90461") == [], "90461 must not be attempted" + text = vac.describe(result) + assert "FAILED to add 90460" in text and "CHECK BY HAND" in text, text + # both states of the line, so the operator sees exactly what moved + assert "90460 x1 (fee 0.00, unchecked)" in text, text + assert "90460 x1 (fee 25.00, checked)" in text, text + + +# --- nothing raised mid-add may lose a line already on the form --------------- + +@contextlib.contextmanager +def _patched(module, name, replacement): + real = getattr(module, name) + setattr(module, name, replacement) + try: + yield real + finally: + setattr(module, name, real) + + +@case +def apply_second_add_raising_keeps_the_first_and_reports_both(page): + _fresh(page) + real_add = claim_form.add_cpt_row + + def add_that_times_out_on_90472(page_, code): + if code != "90472": + return real_add(page_, code) + # The picker is open when the timeout hits, as it would be live. + page_.locator(sel.SEL_CPT_ADD_BTN).first.click() + raise TimeoutError("Locator.click: Timeout 10000ms exceeded (simulated)") + + with _patched(claim_form, "add_cpt_row", add_that_times_out_on_90472): + results = run_all(page, "9113000", _live(page)) + assert [r.condition_name for r in results] == ["vaccine_admin_codes"], \ + "a raise inside the second add must not drop the whole result" + by_code = {c["code"]: c for c in results[0].changes} + assert by_code["90471"]["added"] is True, by_code + assert by_code["90472"]["added"] is False, by_code + assert "raised" in by_code["90472"]["error"], by_code + # Unit D review, finding 3: the missing 90472 makes it a Review row. + assert results[0].review.startswith("90472 could not be added"), \ + results[0].review + assert len(_rows_for(page, "90471")) == 1 + assert not page.locator(sel.SEL_FEE_SCHEDULE_CPT_SEARCH).first.is_visible(), \ + "the picker the failed add left open must be closed" + text = vac.describe(results[0]) + assert "Added 90471 x1" in text and "FAILED to add 90472" in text, text + + +@case +def apply_units_write_raising_still_reports_the_line_it_added(page): + _fresh(page) + page.evaluate("() => { window.feeScheduleNewLineUnits = '0'; }") + + def units_that_time_out(*args, **kwargs): + raise TimeoutError("Locator.fill: Timeout 10000ms exceeded (simulated)") + + with _patched(claim_form, "set_cpt_units", units_that_time_out): + results = run_all(page, "9113000", _live(page)) + assert [r.condition_name for r in results] == ["vaccine_admin_codes"], results + by_code = {c["code"]: c for c in results[0].changes} + assert by_code["90472"]["added"] is True, by_code + assert by_code["90472"]["units"] == "0", \ + "the report shows what the line really reads — still 0 units" + assert "raised" in by_code["90472"]["error"], by_code + text = vac.describe(results[0]) + assert "Added 90472 x0" in text and "CHECK" in text, text + + +# --- the Fee Schedule picker is never left open -------------------------------- + +@case +def apply_ok_that_keeps_the_picker_open_closes_it_and_adds_nothing(page): + # Slow (~15s): add_cpt_row() waits out its own 10s for the picker to + # hide before deciding OK did nothing. + _fresh(page) + _seed(page, _ADULT_ONE) + page.evaluate("() => { window.feeScheduleOkKeepsOpen = true; }") + result = vac.apply(page, "9113000", _live(page)) + assert result.changed is False, result + assert _rows_for(page, "90471") == [] + assert not page.locator(sel.SEL_FEE_SCHEDULE_CPT_SEARCH).first.is_visible(), \ + "a picker left up would block the claim form's own Cancel" + + +@case +def add_cpt_row_closes_the_picker_when_a_step_raises(page): + # Slow (~10s): a disabled OK makes Playwright's click time out, with + # the picker open — it must be closed before the error goes on up. + _fresh(page) + page.evaluate("() => { document.getElementById('claimFeeSchOkBtn')" + ".disabled = true; }") + try: + claim_form.add_cpt_row(page, "90471") + except Exception: + pass + else: + raise AssertionError("the OK click timeout must still be raised") + assert not page.locator(sel.SEL_FEE_SCHEDULE_CPT_SEARCH).first.is_visible() + + +# --- the fee read back is the re-priced one ----------------------------------- + +@case +def apply_waits_for_an_async_units_reprice(page): + _fresh(page) + page.evaluate("() => { window.unitsRepriceDelayMs = 600; " + "window.feeScheduleNewLineUnits = '0'; }") + result = vac.apply(page, "9113000", _live(page)) + by_code = {c["code"]: c for c in result.changes} + assert by_code["90472"]["billed_fee"] == "20.00", \ + ("the 0.00 fee read straight after the blur is stale", by_code) + assert by_code["90472"]["error"] is None, by_code + + +@case +def apply_flags_a_fee_that_never_reprices(page): + _fresh(page) + page.evaluate("() => { window.unitsNoReprice = true; " + "window.feeScheduleNewLineUnits = '0'; }") + result = vac.apply(page, "9113000", _live(page)) + by_code = {c["code"]: c for c in result.changes} + assert by_code["90472"]["units"] == "1", by_code + assert "re-priced" in (by_code["90472"]["error"] or ""), by_code + assert "CHECK" in vac.describe(result) + + +# --- the professional/HCFA screen --------------------------------------------- + +def _hcfa_with_one_vaccine(page): + page.goto(HCFA_FIXTURE.as_uri()) + page.evaluate("""(title) => { + const row = document.getElementById('CPTROW_1'); + row.querySelector('[data-fieldname="claimCPTCode"]').value = '90734'; + row.querySelector('[data-fieldname="claimCPTCode"]').closest('td') + .setAttribute('title', title); + // The live screen's DOB bind (MM/DD/YYYY, read on 9109000 and + // 9119000) is not part of the fixture; a made-up adult DOB. + const dob = document.createElement('span'); + dob.setAttribute('ng-bind', 'PatientInfo.dob'); + dob.textContent = '01/15/1980'; + document.body.appendChild(dob); + }""", _TDAP) + + +@case +def hcfa_screen_vaccine_claim_fires_on_the_payer_from_the_insurances_grid(page): + # hcfa_claim.html is a United Health Care (Commercial) claim — covered + # by document #18. This used to be pinned as a gap: extract() read no + # insurance on this screen, so the payer gate always said no. + _hcfa_with_one_vaccine(page) + data = _live(page) + assert [r["code"] for r in vac.checked_vaccine_rows(data["cpt"])] == ["90734"] + assert data["insurance"][0]["Name"] == "United Health Care", data["insurance"] + assert vac.detect("9103000", data) is True + + +@case +def hcfa_screen_with_no_payer_row_still_blames_the_read(page): + _hcfa_with_one_vaccine(page) + page.evaluate("() => document.querySelector(" + "'#billingClaimTbl8 tbody').innerHTML = ''") + data = _live(page) + assert data["insurance"] == [], data["insurance"] + out = io.StringIO() + with contextlib.redirect_stdout(out): + assert vac.detect("9103000", data) is False + assert "no primary payer name was read" in out.getvalue(), \ + ("the log must blame the read, not payer_groups.json", out.getvalue()) + + +@case +def run_all_fires_only_this_condition_on_the_fixture(page): + _fresh(page) + data = _live(page) + results = run_all(page, "9113000", data) + assert [r.condition_name for r in results] == ["vaccine_admin_codes"], results + + +# ============================================================================= +# What the Charge Summary says (final review, 2026-10-05) +# ============================================================================= + +def _change(code, added): + return {"code": code, "added": added, "units": 1, "wanted_units": 1, + "billed_fee": "10.00", "error": "" if added else "not found", + "grid_changed": None if added else True} + + +@case +def summary_comment_says_which_admin_code_was_added_and_which_was_not(): + """ + The fixed "( vaccine admin code added )" read "added" next to the + review reason saying the code could NOT be added. + """ + sc = vac.summary_comment + mk = lambda *c: ConditionResult("vaccine_admin_codes", True, list(c)) + assert sc(mk(_change("90471", True), _change("90472", True))) == \ + "CPT Code Missing (vaccine admin code 90471 added; 90472 added)" + assert sc(mk(_change("90471", False))) == \ + "CPT Code Missing (vaccine admin code 90471 NOT added — add by hand)" + assert sc(mk(_change("90471", True), _change("90472", False))) == ( + "CPT Code Missing (vaccine admin code 90471 added; 90472 NOT added " + "— add by hand)") + + +@case +def the_charge_summary_row_no_longer_says_added_for_a_failed_add(): + """End to end through charge_summary.claim_outcome, both cases.""" + from ecw_bot.core import charge_summary as cs + for changes, review in ( + ([_change("90471", False)], + "90471 could not be added cleanly — check the CPT grid by hand"), + ([_change("90471", True), _change("90472", False)], + "90472 could not be added cleanly — check the CPT grid by hand")): + result = ConditionResult("vaccine_admin_codes", True, changes, + review=review) + data = {"cpt": [{"code": "90686"}], "icd": [{"code": "Z23"}], + "progress_notes": {"opened": "frame", "sections": {}}, + "_conditions": { + "fired": ["vaccine_admin_codes"], "saved": False, + "failed": [], + "review": [{"name": "vaccine_admin_codes", + "reason": review}], + "comments": {"vaccine_admin_codes": + vac.summary_comment(result)}}} + o = cs.claim_outcome(True, data) + assert o["Category"] == "Review", o + assert "vaccine admin code added" not in o["Comments"], o + assert "NOT added — add by hand" in o["Comments"], o + + +@case +def a_live_grid_that_reads_empty_after_a_full_first_read_is_declined(): + """ + Final review, 2026-10-05: detect() found billed vaccines in the first + read, but the live re-read came back empty — declined, not "Nothing to + add" (none(), which the summary reads as Pass). + """ + from ecw_bot.conditions.base import CPT_GRID_NOT_READ + orig = (claim_form.open_cpt_hcpcs_tab, claim_form.extract) + claim_form.open_cpt_hcpcs_tab = lambda page: True + claim_form.extract = lambda page: {"cpt": []} + try: + with contextlib.redirect_stdout(io.StringIO()): + result = vac.apply(None, "9131000", + {"cpt": [_row("90686", "Flu vaccine")]}) + finally: + claim_form.open_cpt_hcpcs_tab, claim_form.extract = orig + assert result == ConditionResult.declined("vaccine_admin_codes", + CPT_GRID_NOT_READ), result + + +def _run_cases() -> int: + if not FIXTURE.exists(): + print(f" FAIL fixture missing: {FIXTURE}") + return 1 + try: + from playwright.sync_api import sync_playwright + except ImportError: + print(" SKIP playwright is not installed") + return 0 + + failures = 0 + with sync_playwright() as pw: + browser = pw.chromium.launch(headless=True) + page = browser.new_page() + + for fn in CASES: + name = fn.__name__.replace("_", " ") + try: + if len(inspect.signature(fn).parameters) == 0: + fn() + else: + fn(page) + print(f" ok {name}") + except AssertionError as exc: + print(f" FAIL {name}: {exc}") + failures += 1 + except Exception as exc: + print(f" ERROR {name}: {type(exc).__name__}: {exc}") + failures += 1 + browser.close() + + print(f"\n{len(CASES) - failures}/{len(CASES)} passed") + return 1 if failures else 0 + + +def main() -> int: + from ecw_bot import settings as _cfg + # These cases exercise eCW's own click and Add flow, as a LIVE-WRITE run + # does. VERIFY clicks no box and adds no row (eCW saves both at once, + # 2026-10-16): that is tests/test_verify_no_live_writes.py. + orig = _cfg.SAVE_CORRECTIONS + _cfg.SAVE_CORRECTIONS = True + try: + return _run_cases() + finally: + _cfg.SAVE_CORRECTIONS = orig + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tests/test_conditions_zero_fee_uncheck.py b/tests/test_conditions_zero_fee_uncheck.py new file mode 100644 index 0000000..c4f28b0 --- /dev/null +++ b/tests/test_conditions_zero_fee_uncheck.py @@ -0,0 +1,831 @@ +r""" +conditions/zero_fee_uncheck.py — the operator's common rule for every +claim (2026-09-30): a CPT line that is checked but billed at zero gets +unchecked. Only that direction here — an unchecked line is never touched by +this condition; the reverse half (unchecked with a billed fee -> checked) +is conditions/priced_line_check.py's, registered last, and tested in +test_conditions_priced_line_check.py. + + python tests\test_conditions_zero_fee_uncheck.py + +Pure-data cases first (the shared zero-fee predicate, then detect()), then +page cases against BOTH claim screens: hcfa_claim.html, whose own rows carry +the live-verified "Assign To Patient" checkbox (`ng-click="updateBillToIns( +$index)"`), and ub_claim.html, whose own rows carry a plain checkbox with no +ng-click at all — so on the UB form a UB-style row is injected with the +checkbox vaccine_claim.html's rows use (`id="claimCPTBillToInsChk..." +ng-click="updateBillToIns(n)"`), and the fixture's own rows stand in for a +row whose checkbox state is unknown. + +Rows are injected with page.evaluate, the same way +test_conditions_depression_dx_pointer.py's `_inject_duplicate_target_row` +adds its row, rather than growing either shared fixture and its row-count +assumptions (`CPT/HCPCS (3)`, `len(cpt) == 3`, ...). + +The live evidence this rule was built against: of 39 saved claim snapshots, +exactly one checked zero-fee line — 85018 (Hemoglobin) at 0.00 — which is +the code the injected rows below use. + +The last two cases are where this rule meets another condition's own rule +— depression's choice of which G8510 to keep, and vaccine_admin_codes' +checked-admin test on vaccine_claim.html. Both used to be PINNED as known +gaps; the operator ruled on both on 2026-09-30, and they now assert the +ruled outcome: the priced G8510 is the one kept, and a checked zero-fee +admin line no longer counts as "admin already billed". +""" + +import io +import sys +import pathlib +import inspect +import contextlib + +ROOT = pathlib.Path(__file__).resolve().parent.parent +sys.path.insert(0, str(ROOT / "src")) + +FIXTURE = pathlib.Path(__file__).resolve().parent / "fixtures" / "hcfa_claim.html" +UB_FIXTURE = pathlib.Path(__file__).resolve().parent / "fixtures" / "ub_claim.html" +VACCINE_FIXTURE = pathlib.Path(__file__).resolve().parent / "fixtures" / "vaccine_claim.html" + +from ecw_bot import conditions # noqa: E402 +from ecw_bot.conditions import zero_fee_uncheck as zfu # noqa: E402 +from ecw_bot.conditions import (bh_h_code_modifier, # noqa: E402 + dental_dummy_codes, + dental_payer_check, + depression_dx_pointer, dummy_codes, + injection_admin_code, + medicare_pps_code, + j2930_j2001_update, medicaid_uncheck_cpts, + medicare_billing_screening, + medicare_telehealth, + medicare_vaccine_a6, medicare_wellness_history, + medicaid_pregnancy_th, + priced_line_check, telehealth, + vaccine_admin_codes) +from ecw_bot.conditions.base import ( # noqa: E402 + CPT_TAB_NOT_CONFIRMED, ConditionResult, run_all) +from ecw_bot.pages import claim_form # noqa: E402 +from ecw_bot.selectors import claim_form as sel # noqa: E402 + + +CASES = [] + + +def case(fn): + CASES.append(fn) + return fn + + +_HEMOGLOBIN = "85018" + + +def _cpt(code, billed=True, fee="0.00"): + """One `extract()["cpt"]` row, cut down to what detect() reads.""" + return {"code": code, "billed": billed, "billed_fee": fee} + + +def _inject_row(page, code, row_id, *, screen="hcfa", checked=True, + fee="0.00", has_checkbox=True, has_fee=True): + """ + Adds one real CPT row (a `CPTROW_` id) above the grid's trailing "*" + entry row. `screen` picks the row's own markup: + + * "hcfa" — hcfa_claim.html's shape: `data-fieldname` fields. + * "ub" — the UB form's `uBClaim*` id prefixes, with the + "Assign To Patient" checkbox vaccine_claim.html's rows carry + (ub_claim.html's own rows have none that SEL_CPT_ASSIGN_TO_PATIENT + matches). + + `has_checkbox` / `has_fee` drop the checkbox or the Billed Fee input + entirely — the rows whose state is unknown, which must be left alone. + """ + page.evaluate( + """(a) => { + const n = a.rowId.replace('CPTROW_', ''); + const tbody = document.querySelector(a.screen === 'ub' + ? '#claimCPTCodesTable9999999999999 tbody' + : '#claimCPTCodesTable8888888888888 tbody'); + const entryRow = tbody.querySelector('tr:last-child'); + const esc = s => String(s).replace(/&/g, '&').replace(/"/g, '"'); + const box = !a.hasCheckbox ? '' : (a.screen === 'ub' + ? ''; + const codeInput = a.screen === 'ub' + ? '' + : ''; + const feeInput = !a.hasFee ? '' : (a.screen === 'ub' + ? '' + : ''); + const tr = document.createElement('tr'); + tr.id = a.rowId; + tr.setAttribute('ng-repeat', 'cpt in CPTCodes'); + tr.innerHTML = '' + box + '' + codeInput + '' + + '' + feeInput + ''; + tbody.insertBefore(tr, entryRow); + }""", {"code": code, "rowId": row_id, "screen": screen, + "checked": checked, "fee": fee, + "hasCheckbox": has_checkbox, "hasFee": has_fee}) + + +def _set_row(page, row_id, *, checked=None, fee=None): + """Sets one existing row's checkbox and/or Billed Fee in place.""" + page.evaluate( + """(a) => { + const tr = document.getElementById(a.rowId); + if (a.checked !== null) + tr.querySelector('[ng-click*="updateBillToIns("]').checked = a.checked; + if (a.fee !== null) + tr.querySelector('[id^="uBClaimBillFee"], [data-fieldname="ClaimCPTBilledFee"]') + .value = a.fee; + }""", {"rowId": row_id, "checked": checked, "fee": fee}) + + +def _count_checkbox_clicks(page, row_id): + """ + Counts clicks on one row's "Assign To Patient" checkbox, read back as + `window.clicksOn[row_id]` — the same helper + test_conditions_depression_dx_pointer.py uses. On the live screen that + click is what fires eCW's own `updateBillToIns()`, so a row this rule + must leave alone should never receive one at all. + """ + page.evaluate( + """(rowId) => { + window.clicksOn = window.clicksOn || {}; + window.clicksOn[rowId] = 0; + document.querySelector('#' + rowId + ' [ng-click*="updateBillToIns("]') + .addEventListener('click', () => { window.clicksOn[rowId] += 1; }); + }""", row_id) + + +def _watch_fee_writes(page): + """ + Counts `input`/`change` events on every Billed Fee input on the grid as + `window.feeWrites` — Playwright's fill() fires both, so a zero here + proves the fee was never written, not merely left with the same value. + Call after every row has been injected. + """ + page.evaluate( + """(s) => { + window.feeWrites = 0; + document.querySelectorAll('tr[id^="CPTROW_"]').forEach(tr => { + tr.querySelectorAll(s).forEach(el => { + el.addEventListener('input', () => { window.feeWrites += 1; }); + el.addEventListener('change', () => { window.feeWrites += 1; }); + }); + }); + }""", sel.SEL_CPT_BILLED_FEE) + + +def _row_state(page, index): + """(checked or None, fee or None) for the index-th real CPT row.""" + row = page.locator(sel.SEL_CPT_ROWS).nth(index) + box = row.locator(sel.SEL_CPT_ASSIGN_TO_PATIENT).first + fee = row.locator(sel.SEL_CPT_BILLED_FEE).first + return (box.is_checked() if box.count() else None, + fee.input_value() if fee.count() else None) + + +# ============================================================================= +# The shared zero-fee predicate +# ============================================================================= + +@case +def is_zero_fee_reads_blank_and_every_zero_spelling_as_zero(): + for value in ("", " ", None, "0", "0.00", "0.0", ".00", " 0.00 ", + "$0.00", "0,000.00"): + assert claim_form.is_zero_fee(value) is True, repr(value) + + +@case +def is_zero_fee_reads_a_priced_or_non_numeric_fee_as_not_zero(): + for value in ("65.00", "0.01", "$1,200.00", "1,200.00", "N/A", "abc", + "--", "0.00 USD"): + assert claim_form.is_zero_fee(value) is False, repr(value) + + +# ============================================================================= +# detect() +# ============================================================================= + +@case +def detect_true_for_a_checked_line_billed_at_0_00(): + # The one live case: 85018 (Hemoglobin), checked, at 0.00. + data = {"cpt": [_cpt("99213", fee="210.00"), _cpt(_HEMOGLOBIN, fee="0.00")]} + assert zfu.detect("C1", data) is True + + +@case +def detect_true_for_a_checked_line_with_a_blank_fee(): + assert zfu.detect("C1", {"cpt": [_cpt(_HEMOGLOBIN, fee="")]}) is True + + +@case +def detect_true_for_a_checked_line_billed_at_plain_0(): + assert zfu.detect("C1", {"cpt": [_cpt(_HEMOGLOBIN, fee="0")]}) is True + + +@case +def detect_false_for_an_unchecked_line_at_0_00(): + # Already excluded — this condition never touches an unchecked line; the + # reverse half (unchecked AND priced -> checked) is priced_line_check's, + # and 0.00 is not priced anyway. + data = {"cpt": [_cpt("99213", fee="210.00"), + _cpt("G8510", billed=False, fee="0.00")]} + assert zfu.detect("C1", data) is False + + +@case +def detect_false_when_the_checkbox_state_is_unknown(): + # billed None: extract() found no "Assign To Patient" checkbox on the row. + assert zfu.detect("C1", {"cpt": [_cpt(_HEMOGLOBIN, billed=None, fee="0.00")]}) is False + assert zfu.detect("C1", {"cpt": [{"code": _HEMOGLOBIN, "billed_fee": "0.00"}]}) is False + + +@case +def detect_false_for_a_checked_line_whose_fee_was_never_read(): + # billed_fee None: extract() found no Billed Fee input on the row. + # apply() (uncheck_zero_fee_cpt_rows) leaves such a row alone, so + # detect() must not fire on it either — unknown is not zero (review + # finding, 2026-09-30). + assert zfu.detect("C1", {"cpt": [_cpt(_HEMOGLOBIN, fee=None)]}) is False + + +@case +def detect_false_for_a_checked_priced_line(): + data = {"cpt": [_cpt("99213", fee="210.00"), _cpt("J7298", fee="1200.00")]} + assert zfu.detect("C1", data) is False + + +@case +def detect_false_for_a_non_numeric_fee(): + assert zfu.detect("C1", {"cpt": [_cpt(_HEMOGLOBIN, fee="N/A")]}) is False + + +@case +def detect_false_with_no_cpt_lines(): + assert zfu.detect("C1", {"cpt": []}) is False + assert zfu.detect("C1", {}) is False + assert zfu.detect("C1", None) is False + + +# ============================================================================= +# apply() — the professional/HCFA screen +# ============================================================================= + +@case +def apply_unchecks_a_checked_zero_fee_line_on_the_hcfa_screen(page): + page.goto(FIXTURE.as_uri()) + _inject_row(page, _HEMOGLOBIN, "CPTROW_3", checked=True, fee="0.00") + _count_checkbox_clicks(page, "CPTROW_3") + _watch_fee_writes(page) + + result = zfu.apply(page, "9103000", {}) + assert isinstance(result, ConditionResult) + assert result.condition_name == "zero_fee_uncheck" + assert result.changed is True + assert result.changes == [{"row": 3, "code": _HEMOGLOBIN, "fee": "0.00"}], result.changes + + assert _row_state(page, 3) == (False, "0.00") + assert page.evaluate("window.clicksOn['CPTROW_3']") == 1 + # Priced lines keep their checkbox and fee; G8510 (unchecked, 65.00) too. + assert _row_state(page, 0) == (True, "210.00") + assert _row_state(page, 1) == (True, "1200.00") + assert _row_state(page, 2) == (False, "65.00") + assert page.evaluate("window.feeWrites") == 0, "the Billed Fee was written" + + # A second pass has nothing left to uncheck. + assert zfu.apply(page, "9103000", {}).changed is False + + +@case +def apply_leaves_priced_and_unchecked_lines_alone(page): + page.goto(FIXTURE.as_uri()) + _inject_row(page, "G8510", "CPTROW_3", checked=False, fee="0.00") + _count_checkbox_clicks(page, "CPTROW_3") + _count_checkbox_clicks(page, "CPTROW_0") + _watch_fee_writes(page) + + result = zfu.apply(page, "9131000", {}) + assert result.changed is False, result.changes + assert result.changes == [], result.changes + + assert _row_state(page, 0) == (True, "210.00") + assert _row_state(page, 1) == (True, "1200.00") + assert _row_state(page, 2) == (False, "65.00") + assert _row_state(page, 3) == (False, "0.00") + assert page.evaluate("window.clicksOn['CPTROW_3']") == 0, \ + "an unchecked line must never be clicked — updateBillToIns() would fire" + assert page.evaluate("window.clicksOn['CPTROW_0']") == 0 + assert page.evaluate("window.feeWrites") == 0 + + +@case +def apply_unchecks_every_zero_spelling_and_never_writes_the_fee(page): + """ + Blank, "0", ".00" and "0.00" are all zero — each line is unchecked, and + its fee is reported and left exactly as read: never normalised to 0.00, + never filled, never blurred. + """ + page.goto(FIXTURE.as_uri()) + _inject_row(page, "85018", "CPTROW_3", fee="") + _inject_row(page, "85025", "CPTROW_4", fee="0") + _inject_row(page, "36415", "CPTROW_5", fee=".00") + _inject_row(page, "81002", "CPTROW_6", fee="0.00") + _watch_fee_writes(page) + + result = zfu.apply(page, "9103000", {}) + assert result.changes == [ + {"row": 3, "code": "85018", "fee": ""}, + {"row": 4, "code": "85025", "fee": "0"}, + {"row": 5, "code": "36415", "fee": ".00"}, + {"row": 6, "code": "81002", "fee": "0.00"}, + ], result.changes + assert [_row_state(page, i) for i in (3, 4, 5, 6)] == [ + (False, ""), (False, "0"), (False, ".00"), (False, "0.00")] + assert page.evaluate("window.feeWrites") == 0, "the Billed Fee was written" + + +@case +def apply_leaves_a_non_numeric_fee_alone(page): + page.goto(FIXTURE.as_uri()) + _inject_row(page, _HEMOGLOBIN, "CPTROW_3", checked=True, fee="N/A") + _count_checkbox_clicks(page, "CPTROW_3") + + result = zfu.apply(page, "9103000", {}) + assert result.changed is False, result.changes + assert _row_state(page, 3) == (True, "N/A") + assert page.evaluate("window.clicksOn['CPTROW_3']") == 0 + + +@case +def apply_leaves_a_checked_row_with_no_fee_input_alone(page): + # No Billed Fee input at all: the fee is unknown, not zero. + page.goto(FIXTURE.as_uri()) + _inject_row(page, _HEMOGLOBIN, "CPTROW_3", checked=True, has_fee=False) + _count_checkbox_clicks(page, "CPTROW_3") + + result = zfu.apply(page, "9103000", {}) + assert result.changed is False, result.changes + assert _row_state(page, 3) == (True, None) + assert page.evaluate("window.clicksOn['CPTROW_3']") == 0 + + +@case +def apply_leaves_a_zero_fee_row_with_no_checkbox_alone(page): + # No "Assign To Patient" checkbox: whether it is billed is unknown. + page.goto(FIXTURE.as_uri()) + _inject_row(page, _HEMOGLOBIN, "CPTROW_3", fee="0.00", has_checkbox=False) + _watch_fee_writes(page) + + result = zfu.apply(page, "9103000", {}) + assert result.changed is False, result.changes + assert _row_state(page, 3) == (None, "0.00") + assert page.evaluate("window.feeWrites") == 0 + + +# ============================================================================= +# apply() — the UB Claim form +# ============================================================================= + +@case +def apply_unchecks_a_checked_zero_fee_line_on_the_ub_form(page): + page.goto(UB_FIXTURE.as_uri()) + _inject_row(page, _HEMOGLOBIN, "CPTROW_2", screen="ub", checked=True, fee="0.00") + _inject_row(page, "90791", "CPTROW_3", screen="ub", checked=True, fee="150.00") + _inject_row(page, "G8510", "CPTROW_4", screen="ub", checked=False, fee="0.00") + _count_checkbox_clicks(page, "CPTROW_2") + _count_checkbox_clicks(page, "CPTROW_3") + _count_checkbox_clicks(page, "CPTROW_4") + _watch_fee_writes(page) + + result = zfu.apply(page, "9104000", {}) + assert result.changes == [{"row": 2, "code": _HEMOGLOBIN, "fee": "0.00"}], result.changes + + assert _row_state(page, 2) == (False, "0.00") + assert _row_state(page, 3) == (True, "150.00") + assert _row_state(page, 4) == (False, "0.00") + assert page.evaluate("window.clicksOn['CPTROW_2']") == 1 + assert page.evaluate("window.clicksOn['CPTROW_3']") == 0 + assert page.evaluate("window.clicksOn['CPTROW_4']") == 0 + assert page.evaluate("window.feeWrites") == 0, "the Billed Fee was written" + + +@case +def apply_leaves_ub_rows_without_the_assign_to_patient_checkbox_alone(page): + """ + ub_claim.html's own rows carry a plain checkbox with no + `updateBillToIns(` ng-click — not the live-verified "Assign To Patient" + box — so their billed state is unknown, and a 0.00 fee on one of them + must not be acted on. + """ + page.goto(UB_FIXTURE.as_uri()) + page.evaluate("() => { document.getElementById('uBClaimBillFee0').value = '0.00'; }") + _watch_fee_writes(page) + + result = zfu.apply(page, "9104000", {}) + assert result.changed is False, result.changes + assert page.locator("#claimCPTBillToInsChk0").is_checked() is True + assert page.locator("#uBClaimBillFee0").input_value() == "0.00" + assert page.evaluate("window.feeWrites") == 0 + + +# ============================================================================= +# apply() — no change, and a tab that cannot be confirmed +# ============================================================================= + +@case +def apply_logs_why_when_there_is_nothing_to_uncheck(page): + page.goto(FIXTURE.as_uri()) + out = io.StringIO() + with contextlib.redirect_stdout(out): + result = zfu.apply(page, "9103000", {}) + assert result == ConditionResult.none("zero_fee_uncheck"), result + logged = out.getvalue() + assert "[COND zero_fee_uncheck 9103000]" in logged, logged + assert "nothing to uncheck" in logged, logged + + # Only on that path — not when a line was actually unchecked. + page.goto(FIXTURE.as_uri()) + _inject_row(page, _HEMOGLOBIN, "CPTROW_3", checked=True, fee="0.00") + out = io.StringIO() + with contextlib.redirect_stdout(out): + result = zfu.apply(page, "9103000", {}) + assert result.changed is True, result.changes + assert "nothing to uncheck" not in out.getvalue(), out.getvalue() + + +@case +def apply_leaves_the_claim_as_read_when_the_cpt_tab_cannot_be_confirmed(page): + page.goto(FIXTURE.as_uri()) + _inject_row(page, _HEMOGLOBIN, "CPTROW_3", checked=True, fee="0.00") + + def tab_that_raises(page_): + raise RuntimeError("No CPT/HCPCS tab matched (simulated)") + + real = claim_form.open_cpt_hcpcs_tab + claim_form.open_cpt_hcpcs_tab = tab_that_raises + out = io.StringIO() + try: + with contextlib.redirect_stdout(out): + result = zfu.apply(page, "9103000", {}) + finally: + claim_form.open_cpt_hcpcs_tab = real + assert result.changed is False, result.changes + assert _row_state(page, 3) == (True, "0.00"), "nothing may be touched" + assert "leaving the claim as read" in out.getvalue(), out.getvalue() + # Declined, not none(): never checked is not clean (review item D2). + assert result.review == CPT_TAB_NOT_CONFIRMED, result + + +# ============================================================================= +# describe() +# ============================================================================= + +@case +def describe_names_each_unchecked_line_and_its_fee(page): + page.goto(FIXTURE.as_uri()) + _inject_row(page, _HEMOGLOBIN, "CPTROW_3", checked=True, fee="0.00") + result = zfu.apply(page, "9103000", {}) + text = zfu.describe(result) + lines = text.splitlines() + assert lines[0] == "Checked CPT line(s) with a zero billed fee unchecked:", text + assert lines[1:] == [" Row 4 (85018): checked with billed fee 0.00 -> unchecked"], text + + +@case +def describe_says_blank_rather_than_printing_nothing(): + result = ConditionResult(condition_name="zero_fee_uncheck", changed=True, + changes=[{"row": 0, "code": _HEMOGLOBIN, "fee": ""}]) + text = zfu.describe(result) + assert " Row 1 (85018): checked with billed fee (blank) -> unchecked" in text, text + + +# ============================================================================= +# The registry +# ============================================================================= + +@case +def registered_first_in_conditions(): + """ + First, so it runs on the untouched claim and every specific condition + runs after it on a consistent grid and has the last word — and the + specific conditions keep their own order behind it. The one entry + after them is this rule's reverse half, priced_line_check, registered + LAST (2026-09-30) so it only ever sees a line an exclusion condition + has already zeroed — see test_conditions_priced_line_check.py. + """ + assert conditions.CONDITIONS[0] is zfu, conditions.CONDITIONS + assert conditions.CONDITIONS[1:] == [ + telehealth, medicare_telehealth, depression_dx_pointer, + j2930_j2001_update, dummy_codes, dental_dummy_codes, + dental_payer_check, medicaid_uncheck_cpts, + medicare_billing_screening, medicare_vaccine_a6, + medicare_wellness_history, medicaid_pregnancy_th, + bh_h_code_modifier, vaccine_admin_codes, injection_admin_code, + medicare_pps_code, + priced_line_check, + ], conditions.CONDITIONS + assert conditions.BY_NAME["zero_fee_uncheck"] is zfu + assert zfu.NAME == "zero_fee_uncheck" + + +# ============================================================================= +# run_all() against the REAL registry +# ============================================================================= + +@case +def run_all_fires_zero_fee_uncheck_alone_on_a_plain_zero_fee_line(page): + """ + Against the REAL registry, not fakes — proves this condition is wired + into conditions.CONDITIONS, and that a plain zero-fee line sets off + nothing else. `icd` is left out of the data on purpose: the fixture + carries Z13.31, which would fire depression_dx_pointer on its own + account (the next case covers the two together). And the fixture's own + G8510 is checked first: as served it sits unchecked at 65.00, which + priced_line_check (the reverse half, 2026-09-30) would check on its + own account too. + """ + page.goto(FIXTURE.as_uri()) + _set_row(page, "CPTROW_2", checked=True) + _inject_row(page, _HEMOGLOBIN, "CPTROW_3", checked=True, fee="0.00") + data = {"cpt": claim_form.extract(page)["cpt"]} + results = run_all(page, "9103000", data) + assert [r.condition_name for r in results] == ["zero_fee_uncheck"], results + assert results[0].changes == [{"row": 3, "code": _HEMOGLOBIN, "fee": "0.00"}] + + +@case +def run_all_unchecks_a_zero_fee_g8510_and_depression_does_not_re_check_it(page): + """ + The toggle this rule would otherwise start — the same check/uncheck + shape fixed for claim 9131000: the first G8510 is checked at 0.00 and + the claim carries Z13.31, so both zero_fee_uncheck and + depression_dx_pointer fire. zero_fee_uncheck runs first and unchecks + it; depression's own "re-include the first G8510" step must then leave + it unchecked, because a zero-fee line is never billed. Before, it + checked it straight back: two contradictory changes reported for one + row, and updateBillToIns() fired twice on a line that must stay out. + """ + page.goto(FIXTURE.as_uri()) + _set_row(page, "CPTROW_2", checked=True, fee="0.00") + _count_checkbox_clicks(page, "CPTROW_2") + data = claim_form.extract(page) + assert zfu.detect("9103000", data) is True + assert depression_dx_pointer.detect("9103000", data) is True + + results = run_all(page, "9103000", data) + names = [r.condition_name for r in results] + assert names == ["zero_fee_uncheck", "depression_dx_pointer"], names + assert results[0].changes == [{"row": 2, "code": "G8510", "fee": "0.00"}], results[0].changes + + depression_checks = [c for c in results[1].changes + if "before" not in c and "unchecked" not in c and "fee" not in c] + assert depression_checks == [], ("depression re-checked the zero-fee G8510", + results[1].changes) + # Its pointer is still retagged — only the check is withheld. + retagged = {c["row"] for c in results[1].changes if "before" in c} + assert retagged == {0, 2}, results[1].changes + + assert _row_state(page, 2) == (False, "0.00") + assert page.evaluate("window.clicksOn['CPTROW_2']") == 1, \ + "the G8510 checkbox was clicked more than once — a check/uncheck toggle" + + +# ============================================================================= +# run_all() — where the rule meets another condition's own rule. Both were +# PINNED as known gaps until the operator ruled on them (2026-09-30); each +# now asserts the ruled outcome. See zero_fee_uncheck.py's module docstring. +# ============================================================================= + +def _inject_full_g8510_row(page, row_id, *, checked, fee): + """ + A second G8510 on hcfa_claim.html, after the fixture's own (CPTROW_2), + carrying all four ICD pointer fields — depression_dx_pointer's retag + step reads every one, the same as + test_conditions_depression_dx_pointer.py's `_inject_duplicate_target_row`. + """ + page.evaluate( + """(a) => { + const tbody = document.querySelector( + '#claimCPTCodesTable8888888888888 tbody'); + const entryRow = tbody.querySelector('tr:last-child'); + const n = a.rowId.replace('CPTROW_', ''); + const tr = document.createElement('tr'); + tr.id = a.rowId; + tr.innerHTML = + '' + + '' + + '' + + '' + + '' + + '' + + ''; + tbody.insertBefore(tr, entryRow); + }""", {"rowId": row_id, "checked": checked, "fee": fee}) + + +@case +def run_all_keeps_the_priced_g8510_when_the_first_copy_is_zero_fee(page): + """ + Operator ruling (2026-09-30), flipped from a PINNED gap. The first + G8510 is checked at 0.00 and a later G8510 checked at 65.00 — a shape + none of the 39 snapshots shows. zero_fee_uncheck unchecks the 0.00 + copy. depression then keeps the first G8510 whose fee is NOT zero — + the 65.00 one — so its re-include step has nothing to check (it is + already checked) and its duplicate step has nothing to do on the 0.00 + copy (already unchecked, already 0.00). The priced G8510 stays billed + with its fee; before the ruling it was zeroed, and no G8510 was left + billed at all. + + Without zero_fee_uncheck in the registry, depression reaches the same + end state on its own: the 0.00 copy is now the duplicate, and its + duplicate step unchecks it. + """ + def claim(): + page.goto(FIXTURE.as_uri()) + _set_row(page, "CPTROW_2", checked=True, fee="0.00") + _inject_full_g8510_row(page, "CPTROW_3", checked=True, fee="65.00") + _count_checkbox_clicks(page, "CPTROW_2") + _count_checkbox_clicks(page, "CPTROW_3") + return claim_form.extract(page) + + results = run_all(page, "9103000", claim()) + names = [r.condition_name for r in results] + assert names == ["zero_fee_uncheck", "depression_dx_pointer"], names + assert results[0].changes == [{"row": 2, "code": "G8510", "fee": "0.00"}], \ + results[0].changes + not_retags = [c for c in results[1].changes if "before" not in c] + assert not_retags == [], ("depression checked, unchecked or zeroed a " + "G8510", results[1].changes) + assert {c["row"] for c in results[1].changes} == {0, 2, 3}, results[1].changes + assert (_row_state(page, 2), _row_state(page, 3)) == \ + ((False, "0.00"), (True, "65.00")), "the priced G8510 stays billed" + assert page.evaluate("window.clicksOn") == {"CPTROW_2": 1, "CPTROW_3": 0} + + # Without the rule: depression's own duplicate step unchecks the 0.00 + # copy, the priced one untouched. + results = run_all(page, "9103000", claim(), + conditions=[c for c in conditions.CONDITIONS + if c is not zfu]) + assert [r.condition_name for r in results] == ["depression_dx_pointer"], results + assert [c for c in results[0].changes if "before" not in c] == \ + [{"row": 2, "code": "G8510", "unchecked": True}], results[0].changes + assert (_row_state(page, 2), _row_state(page, 3)) == \ + ((False, "0.00"), (True, "65.00")) + assert page.evaluate("window.clicksOn") == {"CPTROW_2": 1, "CPTROW_3": 0} + + +_TDAP = "TETANUS DIPHTHERIA TOXOIDS AND ACELLULAR PERTUSSIS VACC" +_ADMIN_1ST = "IMMUNIZATION ADMIN SINGLE OR COMBINATION" + + +@case +def run_all_adds_the_admin_code_when_the_only_admin_line_is_checked_at_zero(page): + """ + Operator ruling (2026-09-30), flipped from a PINNED known gap. `run_all` + runs every detect() on the claim as read, where the 90471 is CHECKED at + 0.00. It used to count as "admin already billed", so vaccine_admin_codes + never fired; zero_fee_uncheck then unchecked that 90471 and the claim + left with the vaccine billed and no admin code — document #18's own + case. Now a checked zero-fee admin line does not count (zero_fee_uncheck, + first, will uncheck it before vaccine_admin_codes' apply() runs): both + fire, the 0.00 line ends unchecked, and a new 90471 is added. + + The same claim with the 90471 ALREADY unchecked (live claim 9132000's + shape) reaches the same grid, with vaccine_admin_codes alone. + """ + def claim(admin_checked): + page.goto(VACCINE_FIXTURE.as_uri()) + page.evaluate("rows => window.seedCptRows(rows)", [ + {"code": "99396", "title": "PREVENTIVE VISIT EST AGE 40-64", + "fee": "250.00"}, + {"code": "90734", "title": _TDAP, "fee": "60.00", "icd1": "2"}, + {"code": "90471", "title": _ADMIN_1ST, "fee": "0.00", + "checked": admin_checked}, + ]) + return claim_form.extract(page) + + def grid(): + return [(r["code"], r["billed"], r["billed_fee"]) + for r in claim_form.extract(page)["cpt"]] + + fixed = [("99396", True, "250.00"), ("90734", True, "60.00"), + ("90471", False, "0.00"), ("90471", True, "30.00")] + + data = claim(admin_checked=True) + assert vaccine_admin_codes.detect("9113000", data) is True + results = run_all(page, "9113000", data) + assert [r.condition_name for r in results] == \ + ["zero_fee_uncheck", "vaccine_admin_codes"], results + assert results[0].changes == [{"row": 2, "code": "90471", "fee": "0.00"}], \ + results[0].changes + added = [c for c in results[1].changes if c.get("added")] + assert [c["code"] for c in added] == ["90471"], results[1].changes + assert grid() == fixed, grid() + + results = run_all(page, "9113000", claim(admin_checked=False)) + assert [r.condition_name for r in results] == ["vaccine_admin_codes"], results + assert grid() == fixed, grid() + + +@case +def run_all_does_not_fire_on_a_checked_row_with_no_fee_input(page): + # The page-level shape: extract() reports the row's fee as None, not + # "" — so detect() and apply() agree and the log never claims a + # zero-fee line that apply() then cannot find. + page.goto(FIXTURE.as_uri()) + _inject_row(page, _HEMOGLOBIN, "CPTROW_3", checked=True, has_fee=False) + data = claim_form.extract(page) + row = [r for r in data["cpt"] if r["code"] == _HEMOGLOBIN] + assert [(r["billed"], r["billed_fee"]) for r in row] == [(True, None)], row + assert zfu.detect("9103000", data) is False + + +# ============================================================================= +# PINNED, NEEDS AN OPERATOR RULING — medicare_telehealth on a claim whose +# G2025 is checked at 0.00. Today's outcome, not the settled one (review +# finding, 2026-09-30). +# ============================================================================= + +_MEDICARE_DATA = { + "progress_notes": {"text": "Session Setting (PoS): TeleHealth"}, + "insurance": [{"Name": "Humana Medicare"}], +} + + +@case +def pinned_needs_ruling_a_g2025_checked_at_zero_leaves_no_line_billed(page): + """ + PINNED, NEEDS A RULING. The claim a G2025 added at 0.00 leaves for the + next run (the Fee Schedule priced it at nothing). zero_fee_uncheck, + first, unchecks the 0.00 G2025 — the common rule. medicare_telehealth + then asks `cpt_code_present("G2025")`, which ignores the checkbox, + counts G2025 as confirmed, and unchecks and zeroes every OTHER line: + the claim ends with NO billed line, which that step's own gate says it + must never do. Requiring a checked, non-zero G2025 there changes + document #22's behaviour — beyond rulings A/B/C. + """ + page.goto(FIXTURE.as_uri()) + _inject_row(page, "G2025", "CPTROW_3", checked=True, fee="0.00") + data = dict(_MEDICARE_DATA, cpt=claim_form.extract(page)["cpt"]) + results = run_all(page, "9103000", data) + assert [r.condition_name for r in results] == ["zero_fee_uncheck", "medicare_telehealth"], results + assert results[0].changes == [{"row": 3, "code": "G2025", "fee": "0.00"}] + billed = [r["code"] for r in claim_form.extract(page)["cpt"] if r["billed"]] + assert billed == [], ("today: no line left billed", billed) + + +def _run_cases() -> int: + for fixture in (FIXTURE, UB_FIXTURE, VACCINE_FIXTURE): + if not fixture.exists(): + print(f" FAIL fixture missing: {fixture}") + return 1 + try: + from playwright.sync_api import sync_playwright + except ImportError: + print(" SKIP playwright is not installed") + return 0 + + failures = 0 + with sync_playwright() as pw: + browser = pw.chromium.launch(headless=True) + page = browser.new_page() + + for fn in CASES: + name = fn.__name__.replace("_", " ") + try: + if len(inspect.signature(fn).parameters) == 0: + fn() + else: + fn(page) + print(f" ok {name}") + except AssertionError as exc: + print(f" FAIL {name}: {exc}") + failures += 1 + except Exception as exc: + print(f" ERROR {name}: {type(exc).__name__}: {exc}") + failures += 1 + browser.close() + + print(f"\n{len(CASES) - failures}/{len(CASES)} passed") + return 1 if failures else 0 + + +def main() -> int: + from ecw_bot import settings as _cfg + # These cases exercise eCW's own click and Add flow, as a LIVE-WRITE run + # does. VERIFY clicks no box and adds no row (eCW saves both at once, + # 2026-10-16): that is tests/test_verify_no_live_writes.py. + orig = _cfg.SAVE_CORRECTIONS + _cfg.SAVE_CORRECTIONS = True + try: + return _run_cases() + finally: + _cfg.SAVE_CORRECTIONS = orig + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tests/test_dead_end.py b/tests/test_dead_end.py new file mode 100644 index 0000000..f681b75 --- /dev/null +++ b/tests/test_dead_end.py @@ -0,0 +1,105 @@ +""" +2FA dead-end detection. + + python tests\\test_dead_end.py + +The `#dispMessage` strings below are copied from the real page eCW served on +2026-09-07, not invented. That matters: all four states — the healthy one and +the three dead ends — are written into the **same element**, so the only thing +separating "still waiting for mail" from "this page is finished" is the text. +Get the matching wrong in either direction and the cost is real: a missed dead +end burns twenty minutes and an approval link, while a false positive throws +away a login that was about to succeed. + +No browser is started. A stub with one method is all `reason()` touches, which +is the point of keeping it that small. +""" + +import sys +import pathlib + +sys.path.insert(0, str(pathlib.Path(__file__).resolve().parent.parent / "src")) + +from ecw_bot.auth import dead_end # noqa: E402 +from ecw_bot.auth.dead_end import LoginTimedOut # noqa: E402 + + +class FakePage: + """Just enough Page for `reason()`: locator().first.inner_text().""" + + def __init__(self, text, raises=False): + self._text = text + self._raises = raises + + def locator(self, _selector): + return self + + @property + def first(self): + return self + + def inner_text(self, timeout=None): + if self._raises: + raise RuntimeError("locator not found") + return self._text + + +# (label, #dispMessage text, should it be treated as a dead end) +CASES = [ + ("healthy - notification sent", + "eClinicalWorks sent a notification to your email. " + "Please confirm it was you.", False), + ("healthy - 6-digit code prompt", + "Enter the 6 digit code sent to your email", False), + ("timed out", + "Your log in request has timed out. Please try again", True), + ("answered not-me", + "You confirmed that you weren't trying to Log in. " + "If this was a mistake, please try again.", True), + ("resends exhausted", + "A new notification has been sent to your email, No attempt left.", True), + # Case must not matter — eCW has shipped "Log in" and "log in" in the same + # sentence, and this is matched on a fragment. + ("timed out, lowercased", + "your log in request has timed out. please try again", True), + # Not on the 2FA screen at all: the element is missing and that is normal. + ("element missing", None, False), +] + + +def main() -> int: + failures = 0 + for label, text, want_dead in CASES: + page = FakePage(text or "", raises=text is None) + why = dead_end.reason(page) + got_dead = bool(why) + + raised = False + try: + dead_end.assert_alive(page, "a test") + except LoginTimedOut: + raised = True + except Exception as exc: # pragma: no cover + print(f" FAIL {label}: assert_alive raised {type(exc).__name__}") + failures += 1 + continue + + # reason() and assert_alive() must never disagree — one is the other's + # only input. + ok = (got_dead == want_dead) and (raised == want_dead) + failures += not ok + verdict = "dead end" if got_dead else "alive " + wanted = "" if ok else \ + (" (wanted dead end)" if want_dead else " (wanted alive)") + # ASCII only. A test that crashes on its own output is worse than no + # test, and a cp1252 console cannot encode an arrow — see runlog.start(). + detail = f" -> {why}" if why else "" + print(f" {'ok ' if ok else 'FAIL'} {label:28s} " + f"{verdict}{wanted}{detail}") + + print(f"\n{len(CASES) - failures}/{len(CASES)} passed") + return 1 if failures else 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tests/test_dental_claim_skip.py b/tests/test_dental_claim_skip.py new file mode 100644 index 0000000..6da9b89 --- /dev/null +++ b/tests/test_dental_claim_skip.py @@ -0,0 +1,104 @@ +r""" +The Dental Claim screen (mapped from live claim 9125000 — DentaQuest) is a +THIRD claim-form shape, distinct from both the UB form and the +professional/HCFA "Claim" screen — see dental_claim.html's own comment for +exactly what differs. + + python tests\test_dental_claim_skip.py + +Real bug this guards: before is_dental_claim_form()/SEL_CANCEL_DENTAL +existed, `open_claim()` waited the full NAV_TIMEOUT (5 minutes) for a +UB/HCFA marker that never appears on this screen — the same failure shape +already fixed once for HCFA (claim 9103000) — and `close_claim()` burned +through every UB/HCFA attempt before giving up with the modal still open, +ready to collide with the next claim in the loop. No condition in this +codebase understands the Dental screen's shape (its CPT rows carry no +CPTROW_ id at all), so the fix is to detect it immediately and skip +straight to closing, never attempting an extract(). +""" + +import sys +import pathlib +import inspect + +ROOT = pathlib.Path(__file__).resolve().parent.parent +sys.path.insert(0, str(ROOT / "src")) + +FIXTURE = pathlib.Path(__file__).resolve().parent / "fixtures" / "dental_claim.html" + +from ecw_bot.pages import claim_form # noqa: E402 +from ecw_bot.selectors import claim_form as sel # noqa: E402 + +CASES = [] + + +def case(fn): + CASES.append(fn) + return fn + + +@case +def is_dental_claim_form_is_true_for_the_dental_screen(page): + assert claim_form.is_dental_claim_form(page) is True + + +@case +def is_ub_claim_form_is_false_for_the_dental_screen(page): + assert claim_form.is_ub_claim_form(page) is False + + +@case +def any_claim_form_open_matches_the_dental_screen(page): + """ + The exact selector open_claim() waits on. Before SEL_CANCEL_DENTAL was + added, this would not match the Dental screen at all, and open_claim() + would burn the full 5-minute NAV_TIMEOUT waiting for it. + """ + assert page.locator(sel.SEL_ANY_CLAIM_FORM_OPEN).first.is_visible() + + +@case +def close_claim_uses_the_dental_screens_own_cancel_first(page): + assert page.locator("#cancelDentalClaimButton").first.is_visible() + assert claim_form.close_claim(page) is True + assert not page.locator("#cancelDentalClaimButton").first.is_visible() + + +def main() -> int: + if not FIXTURE.exists(): + print(f" FAIL fixture missing: {FIXTURE}") + return 1 + try: + from playwright.sync_api import sync_playwright + except ImportError: + print(" SKIP playwright is not installed") + return 0 + + failures = 0 + with sync_playwright() as pw: + browser = pw.chromium.launch(headless=True) + page = browser.new_page() + + for fn in CASES: + name = fn.__name__.replace("_", " ") + try: + page.goto(FIXTURE.as_uri()) + if len(inspect.signature(fn).parameters) == 0: + fn() + else: + fn(page) + print(f" ok {name}") + except AssertionError as exc: + print(f" FAIL {name}: {exc}") + failures += 1 + except Exception as exc: + print(f" ERROR {name}: {type(exc).__name__}: {exc}") + failures += 1 + browser.close() + + print(f"\n{len(CASES) - failures}/{len(CASES)} passed") + return 1 if failures else 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tests/test_dental_conditions.py b/tests/test_dental_conditions.py new file mode 100644 index 0000000..29d81e1 --- /dev/null +++ b/tests/test_dental_conditions.py @@ -0,0 +1,253 @@ +r""" +Dental claims run every applicable condition (operator, 2026-10-07 and +2026-10-12): every "All payers" condition plus the claim's own payer's — +the same rule as UB/HCFA. Spec: +docs/superpowers/specs/2026-10-07-dental-claims-run-all-conditions-design.md + + python tests\test_dental_conditions.py + +Uses tests/fixtures/dental_claim.html: D0120 (row 0, checked, 45.00) and +DCONS (row 1, checked, 30.00), ICD 1 = Z01.20, payer DentaQuest. +""" + +import sys +import pathlib +import inspect + +ROOT = pathlib.Path(__file__).resolve().parent.parent +sys.path.insert(0, str(ROOT / "src")) + +FIXTURE = pathlib.Path(__file__).resolve().parent / "fixtures" / "dental_claim.html" + +from ecw_bot.pages import claim_form # noqa: E402 +from ecw_bot.selectors import claim_form as sel # noqa: E402 +from ecw_bot.conditions import (dummy_codes, telehealth, # noqa: E402 + medicare_telehealth, vaccine_admin_codes, + bh_h_code_modifier, j2930_j2001_update) +from ecw_bot.conditions.base import run_all # noqa: E402 + +CASES = [] + + +def case(fn): + CASES.append(fn) + return fn + + +def _add_row(page, code, fee="40.00", checked=True, icd1="1"): + """One more real service line, cloned from row 0.""" + page.evaluate( + """(a) => { + const rows = document.querySelectorAll( + 'tr[ng-repeat^="CPT in CPTCodes"]'); + const tr = rows[0].cloneNode(true); + tr.querySelector('[data-fieldname="web_cptcode"]').value = a.code; + tr.querySelector('[data-fieldname="web_billedFee"]').value = a.fee; + tr.querySelector('[data-fieldname="web_icd1"]').value = a.icd1; + const box = tr.querySelector('[ng-click*="updateBillToIns("]'); + box.checked = a.checked; + rows[rows.length - 1].after(tr); + }""", {"code": code, "fee": fee, "checked": checked, "icd1": icd1}) + + +def _row(page, index): + tr = page.locator(sel.SEL_CPT_ROWS).nth(index) + return (tr.locator(sel.SEL_CPT_CODE).first.input_value(), + tr.locator(sel.SEL_CPT_ASSIGN_TO_PATIENT).first.is_checked(), + tr.locator(sel.SEL_CPT_BILLED_FEE).first.input_value()) + + +# ── reading the Dental screen ──────────────────────────────────────────────── + +@case +def extract_reads_the_dental_grid_in_the_ub_row_shape(page): + page.goto(FIXTURE.as_uri()) + data = claim_form.extract(page) + assert data["screen"] == "dental", data.get("screen") + assert data["claim_no"] == "5000000", data["claim_no"] + cpt = data["cpt"] + assert [r["code"] for r in cpt] == ["D0120", "DCONS"], cpt + row = cpt[1] + assert row["billed"] is True and row["billed_fee"] == "30.00", row + assert row["icd1"] == "1" and row["units"] == "1", row + assert row["sdos"] == "09/01/2026", row + assert row["mod1"] == "" and row["pos"] == "", "no such columns" + assert data["insurance"][0]["Name"] == "DentaQuest", data["insurance"] + assert data["icd"] == [{"#": "1", "Code": "Z01.20", + "Name": "Encounter for dental exam"}], data["icd"] + assert data["notes"] == "Routine visit.", data["notes"] + + +@case +def extract_on_ub_and_hcfa_says_which_screen(page): + page.goto(FIXTURE.with_name("ub_claim.html").as_uri()) + assert claim_form.extract(page)["screen"] == "ub" + page.goto(FIXTURE.with_name("hcfa_claim.html").as_uri()) + assert claim_form.extract(page)["screen"] == "hcfa" + + +@case +def a_hidden_dental_form_is_never_read(page): + """eCW leaves closed forms in the DOM; only the one on screen counts.""" + page.goto(FIXTURE.as_uri()) + page.evaluate("""() => document.getElementById('dentalClaim7777777777777') + .style.display = 'none'""") + data = claim_form.extract(page) + assert data["screen"] != "dental" and data["cpt"] == [], data + + +# ── the row helpers work on the Dental grid ────────────────────────────────── + +@case +def dcons_is_unchecked_and_zeroed_on_the_dental_screen(page): + page.goto(FIXTURE.as_uri()) + data = claim_form.extract(page) + data["insurance"] = [{"Name": "DentaQuest"}] + assert dummy_codes.detect("C1", data) is True + result = dummy_codes.apply(page, "C1", data) + assert result.changed is True, result + assert result.changes == [{"row": 1, "code": "DCONS", "unchecked": True, + "fee": {"before": "30.00", + "after": "0.00"}}], result.changes + assert _row(page, 1) == ("DCONS", False, "0.00") + assert _row(page, 0) == ("D0120", True, "45.00"), "D0120 untouched" + + +@case +def j2001_is_unchecked_and_zeroed_on_the_dental_screen(page): + page.goto(FIXTURE.as_uri()) + _add_row(page, "J2001", fee="12.00") + data = claim_form.extract(page) + result = j2930_j2001_update.apply(page, "C1", data) + assert result.changed is True, result + assert _row(page, 2) == ("J2001", False, "0.00") + + +# ── run_all on a Dental claim ──────────────────────────────────────────────── + +def _dental_data(page, payer="DentaQuest", note="Routine dental visit."): + data = claim_form.extract(page) + data["insurance"] = [{"Name": payer}] + data["progress_notes"] = {"opened": "tab", "text": note} + return data + + +@case +def run_all_on_a_dental_claim_runs_the_all_payer_conditions(page): + page.goto(FIXTURE.as_uri()) + _add_row(page, "J2001", fee="12.00") + _add_row(page, "D1110", fee="0.00") + report = {} + results = run_all(page, "C1", _dental_data(page), report=report) + names = [r.condition_name for r in results] + for name in ("dummy_codes", "j2930_j2001_update", "zero_fee_uncheck"): + assert name in names, (name, names, report) + assert report["failed"] == [] and report["review"] == [], report + assert _row(page, 1)[1:] == (False, "0.00") + assert _row(page, 2)[1:] == (False, "0.00") + assert _row(page, 3)[1] is False, "zero-fee line unchecked" + assert _row(page, 0) == ("D0120", True, "45.00") + + +@case +def a_fix_the_dental_screen_cannot_make_goes_to_review(page): + """ + Operator, 2026-10-12: the Dental grid has no POS and no modifier + columns, so telehealth's POS 2 + modifier cannot be written — the + claim is Review with the reason, never a silent skip. + """ + page.goto(FIXTURE.as_uri()) + data = _dental_data(page, note="Session Setting (PoS): TeleHealth. " + "Audio and video telehealth visit.") + report = {} + run_all(page, "C1", data, report=report) + reasons = {r["name"]: r["reason"] for r in report["review"]} + assert "telehealth" in reasons, report + assert "Dental" in reasons["telehealth"], reasons + assert "telehealth" not in report["changed"], report + + +@case +def depression_pointer_is_retagged_on_the_dental_icd_fields(page): + """#6 on web_icd1-4: Z13.31 only on G8510, taken off D0120.""" + page.goto(FIXTURE.as_uri()) + page.evaluate("""() => { + const body = document.querySelector('#addTable tbody'); + const tr = body.rows[0].cloneNode(true); + tr.cells[0].textContent = '2'; + tr.cells[1].querySelector('input').value = 'Z13.31'; + tr.cells[2].textContent = 'Encounter for screening for depression'; + body.rows[0].after(tr); + document.querySelector('[data-fieldname="web_icd2"]').value = '2'; + }""") + _add_row(page, "G8510", fee="0.00", icd1="1") + report = {} + run_all(page, "C1", _dental_data(page), report=report) + assert "depression_dx_pointer" in report["changed"], report + icd = lambda i, n: page.locator(sel.SEL_CPT_ROWS).nth(i).locator( + sel.SEL_CPT_ICD[n]).first.input_value() + assert [icd(0, n) for n in range(4)] == ["1", "", "", ""], \ + "Z13.31 (pointer 2) taken off D0120" + assert icd(2, 0) == "2", "G8510 carries Z13.31" + + +@case +def every_condition_that_needs_pos_modifiers_or_new_lines_declares_it(): + for module in (telehealth, medicare_telehealth, vaccine_admin_codes): + reason = getattr(module, "DENTAL_UNSUPPORTED", "") + assert reason and "Dental" in reason, module.NAME + + +@case +def bh_h_code_modifier_path_is_review_on_dental_but_non_bh_unchecks(page): + page.goto(FIXTURE.as_uri()) + _add_row(page, "H0023", fee="50.00") + report = {} + run_all(page, "C1", _dental_data(page, payer="APM2 Medicaid"), + report=report) + assert "bh_h_code_modifier" in report["changed"], report + assert _row(page, 2)[1:] == (False, "0.00") + + # A BH payer's H code with a modifier: the Dental grid has no modifier + # column, so there is nothing to read and nothing fires. + page.goto(FIXTURE.as_uri()) + _add_row(page, "H0023", fee="50.00") + report = {} + run_all(page, "C1", _dental_data(page, payer="BH COLORADO ACCESS"), + report=report) + assert "bh_h_code_modifier" not in report["fired"], report + assert _row(page, 2)[1:] == (True, "50.00") + + +def main() -> int: + if not FIXTURE.exists(): + print(f" FAIL fixture missing: {FIXTURE}") + return 1 + from playwright.sync_api import sync_playwright + + failures = 0 + with sync_playwright() as pw: + browser = pw.chromium.launch(headless=True) + page = browser.new_page() + for fn in CASES: + name = fn.__name__.replace("_", " ") + try: + if len(inspect.signature(fn).parameters) == 0: + fn() + else: + fn(page) + print(f" ok {name}") + except AssertionError as exc: + print(f" FAIL {name}: {exc}") + failures += 1 + except Exception as exc: + print(f" ERROR {name}: {type(exc).__name__}: {exc}") + failures += 1 + browser.close() + + print(f"\n{len(CASES) - failures}/{len(CASES)} passed") + return 1 if failures else 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tests/test_dental_high_risk.py b/tests/test_dental_high_risk.py new file mode 100644 index 0000000..088f9e1 --- /dev/null +++ b/tests/test_dental_high_risk.py @@ -0,0 +1,246 @@ +r""" +Dental claims: "HIGH RISK" in Billing Notes (document #15), and the +claim's one per-claim box (SHOW_EACH_CLAIM) that reports it with the +sliding fee — since review item D9, not a box of _dental_checks' own. + + python tests\test_dental_high_risk.py +""" + +import sys +import pathlib +import inspect + +ROOT = pathlib.Path(__file__).resolve().parent.parent +sys.path.insert(0, str(ROOT / "src")) + +from ecw_bot.pages import dental_notes # noqa: E402 +from ecw_bot.flows import export_medcor_ready as flow # noqa: E402 + +CASES = [] + +# Shaped like a live claim's Billing Notes, fictional text: 79+ characters +# from HIGH RISK to the end. +NOTES_HIGH = ("notesEligibility :2026-09-04 @ 12:04:10 MDT : Eligible : " + "Eligible; HIGH RISK for caries - caries risk assessment done, " + "fluoride varnish applied, recall 3 months") +NOTES_PLAIN = "notesEligibility :2026-09-04 @ 12:04:10 MDT : Eligible : Eligible" + +DENTAL = """ +
+ +
+""" + + +def case(fn): + CASES.append(fn) + return fn + + +def _dental(page, notes): + page.set_content(DENTAL) + # Angular sets .value, not the text node — so does the test. + page.evaluate("t => document.querySelector('#dentalClaimNotes').value = t", + notes) + + +# ── keyword (no browser) ───────────────────────────────────────────────────── + +@case +def high_risk_matches_any_case_and_spacing(): + for t in (NOTES_HIGH, "high risk", "High-Risk pt", "HIGHRISK", "high risk"): + assert dental_notes.mentions_high_risk(t) is True, t + tail = " " + NOTES_PLAIN + "; " + NOTES_PLAIN # well over 79 + for t in ("high risk", "High-Risk pt", "HIGHRISK", "high risk"): + assert dental_notes.is_high_risk(t + tail) is True, t + + +@case +def plain_notes_are_not_high_risk(): + for t in (NOTES_PLAIN, "", None, "risk assessment high", + NOTES_PLAIN * 3): + assert dental_notes.is_high_risk(t) is False, t + + +# ── the length rule (operator, 2026-10-12; from HIGH RISK since 2026-10-21) ─ + +def _notes_of(length): + """Notes whose part from HIGH RISK to the end is exactly `length` + characters, after a first line that must not count.""" + head = "HIGH RISK for caries" + return "Pt called for appt; eligible\n" + head + "x" * (length - len(head)) + + +@case +def high_risk_needs_79_characters_or_more(): + assert dental_notes.HIGH_RISK_MIN_CHARS == 79 + assert dental_notes.is_high_risk(_notes_of(79)) is True + assert dental_notes.is_high_risk(_notes_of(120)) is True + assert dental_notes.is_high_risk(_notes_of(78)) is False + + +@case +def trailing_blanks_do_not_count_toward_79(): + assert dental_notes.is_high_risk(_notes_of(78) + " \n ") is False + assert dental_notes.is_high_risk(_notes_of(79) + " ") is True + + +@case +def text_before_high_risk_does_not_count(): + """Operator 2026-10-21: count from HIGH RISK, not the whole notes.""" + before = NOTES_PLAIN * 3 + "\n" # 190+ characters before + assert dental_notes.is_high_risk(before + "HIGH RISK for caries") is False + assert dental_notes.high_risk_length(before + "HIGH RISK for caries") == 20 + + +@case +def later_lines_after_high_risk_count(): + """HIGH RISK on line 2: line 2 and every line after it count.""" + notes = ("Pt called for appt\nHIGH RISK - caries risk assessment done\n" + "fluoride varnish applied, recall in 3 months") + assert dental_notes.high_risk_length(notes) == len(notes) - 19 + assert dental_notes.is_high_risk(notes) is True + assert dental_notes.high_risk_length("no marker here " * 10) == 0 + + +@case +def short_notes_saying_high_risk_are_not_high_risk_and_say_why(page): + import io + import contextlib + _dental(page, _notes_of(50)) + with contextlib.redirect_stdout(io.StringIO()) as out: + r = dental_notes.check(page, "5000000") + assert r["high_risk"] is False and r["match"] == "", r + assert r["length"] == 50, r + assert "50 characters (79 needed)" in out.getvalue(), out.getvalue() + + +@case +def message_says_yes_no_or_unknown(): + assert "YES" in dental_notes.message({"high_risk": True, "match": "HIGH RISK"}) + assert "NO" in dental_notes.message({"high_risk": False, "match": ""}) + assert "UNKNOWN" in dental_notes.message({"high_risk": None, "match": ""}) + + +# ── the Dental screen (browser) ────────────────────────────────────────────── + +@case +def check_reads_billing_notes_and_flags_high_risk(page): + _dental(page, NOTES_HIGH) + r = dental_notes.check(page, "9115000") + assert r["high_risk"] is True, r + assert "HIGH RISK" in r["match"], r + + +@case +def check_says_not_high_risk_for_plain_notes(page): + _dental(page, NOTES_PLAIN) + assert dental_notes.check(page, "5000000")["high_risk"] is False + + +@case +def missing_notes_box_is_unknown_not_a_crash(page): + page.set_content("
") + assert dental_notes.check(page, "X")["high_risk"] is None + + +# ── the claim's one box (review items D9/P6, 2026-10-05) ──────────────────── +# _dental_checks used to show a box of its own, ungated, and the per-claim +# box after it printed a page of "?". Now _dental_checks shows nothing and +# the per-claim box (_claim_box, gated by SHOW_EACH_CLAIM) carries the +# Dental results. Updated: these three cases used to assert the box came +# from _dental_checks itself. + +HEAD = "Claim 1/1\n\nClaim No : 9115000" + + +def _checks(page, claim_id): + """_dental_checks with info_box forbidden: it must not show a box.""" + def _no_box(*a, **k): + raise AssertionError("_dental_checks must not show a box (D9/P6)") + old_box, old_sf = flow.msgbox.info_box, flow.cfg.CHECK_SLIDING_FEE + old_hr = flow.cfg.CHECK_HIGH_RISK + flow.msgbox.info_box = _no_box + flow.cfg.CHECK_SLIDING_FEE = False # no Patient Info on this page + flow.cfg.CHECK_HIGH_RISK = True + try: + return flow._dental_checks(page, claim_id) + finally: + flow.msgbox.info_box, flow.cfg.CHECK_SLIDING_FEE = old_box, old_sf + flow.cfg.CHECK_HIGH_RISK = old_hr + + +@case +def dental_checks_shows_no_box_and_the_claim_box_carries_high_risk(page): + _dental(page, NOTES_HIGH) + data = _checks(page, "9115000") + assert data["high_risk"]["high_risk"] is True, data + title, body = flow._claim_box(HEAD, "9115000", data, + flow.charge_summary.claim_outcome(True, data)) + assert "9115000" in title and "HIGH RISK" in title, title + assert "YES" in body and "Read-only" in body, body + assert body.startswith(HEAD), body + + +@case +def dental_claim_box_title_has_no_high_risk_tag_when_not(page): + _dental(page, NOTES_PLAIN) + data = _checks(page, "5000000") + title, body = flow._claim_box(HEAD, "5000000", data, None) + assert "HIGH RISK" not in title, title + assert "not a high risk claim" in body, body + assert flow.claim_form.NOT_LOADED_NOTE not in body, body + + +@case +def dental_claim_box_leads_with_not_loaded_when_the_form_never_loaded(page): + """ + wait_claim_loaded() gave up: "not a high risk claim" was read off Billing + Notes that may simply not have arrived yet, so the Dental part of the + box says so first rather than showing it as a confident result (review + of unit C). The caller puts the reason on the data as `_not_evaluated`. + """ + _dental(page, NOTES_PLAIN) + why = "Dental form did not finish loading in 20s (0 service lines)" + data = _checks(page, "5000000") + data["_not_evaluated"] = why + _, body = flow._claim_box(HEAD, "5000000", data, None) + detail = body.split(flow.RULE + "\n", 1)[1].splitlines() + assert detail[0] == flow.claim_form.NOT_LOADED_NOTE, body + assert detail[1] == f"({why})", body + assert "not a high risk claim" in body, body + + +def main() -> int: + try: + from playwright.sync_api import sync_playwright + except ImportError: + print(" SKIP playwright is not installed") + return 0 + + failures = 0 + with sync_playwright() as pw: + browser = pw.chromium.launch(headless=True) + page = browser.new_page() + for fn in CASES: + name = fn.__name__.replace("_", " ") + try: + if len(inspect.signature(fn).parameters) == 0: + fn() + else: + fn(page) + print(f" ok {name}") + except AssertionError as exc: + print(f" FAIL {name}: {exc}") + failures += 1 + except Exception as exc: + print(f" ERROR {name}: {type(exc).__name__}: {exc}") + failures += 1 + browser.close() + + print(f"\n{len(CASES) - failures}/{len(CASES)} passed") + return 1 if failures else 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tests/test_fallback_selectors.py b/tests/test_fallback_selectors.py new file mode 100644 index 0000000..f18ff38 --- /dev/null +++ b/tests/test_fallback_selectors.py @@ -0,0 +1,165 @@ +r""" +core/fallback.py and the selector chains (2026-10-21): a renamed id falls +back to an alternative taken from the live DOM, and the log says so. + + python tests\test_fallback_selectors.py +""" + +import io +import sys +import inspect +import pathlib +import contextlib + +ROOT = pathlib.Path(__file__).resolve().parent.parent +sys.path.insert(0, str(ROOT / "src")) + +from ecw_bot.core import fallback # noqa: E402 +from ecw_bot.selectors.fallbacks import CHAINS # noqa: E402 +from ecw_bot.selectors import claim_form as form_sel # noqa: E402 +from ecw_bot.selectors import claim_lookup as lookup_sel # noqa: E402 +from ecw_bot.selectors import login as login_sel # noqa: E402 + +CASES = [] + + +def case(fn): + CASES.append(fn) + return fn + + +def _quiet(fn, *a): + out = io.StringIO() + with contextlib.redirect_stdout(out): + result = fn(*a) + return result, out.getvalue() + + +# ── the chains themselves ──────────────────────────────────────────────────── + +@case +def each_selector_is_its_chain_with_the_primary_first(): + pairs = [(login_sel.SEL_PASSWORD, login_sel.PASSWORD_CHAIN, "#passwordField"), + (login_sel.SEL_LOGIN_BTN, login_sel.LOGIN_BTN_CHAIN, "#Login"), + (lookup_sel.SEL_CLAIM_NO, lookup_sel.CLAIM_NO_CHAIN, + "#claimLookupIpt10"), + (lookup_sel.SEL_LOOKUP_BTN, lookup_sel.LOOKUP_BTN_CHAIN, + "#btnclaimlookup"), + (form_sel.SEL_CANCEL, form_sel.CANCEL_CHAIN, "#UBClaimBtn17"), + (form_sel.SEL_CANCEL_DENTAL, form_sel.CANCEL_DENTAL_CHAIN, + "#cancelDentalClaimButton"), + (form_sel.SEL_PROGRESS_NOTES, form_sel.PROGRESS_NOTES_CHAIN, + 'button[ng-click="viewProgNote()"]')] + for joined, chain, primary in pairs: + assert chain[0] == primary and len(chain) >= 2, chain + assert joined == ", ".join(chain), joined + + +@case +def every_alternative_is_plain_css(page): + # Used in querySelectorAll / :has() too: Playwright-only syntax breaks + # there. querySelectorAll throws on anything the browser cannot parse. + page.set_content("
") + for name, (_, chain) in CHAINS.items(): + for selector in chain: + assert ":has-text" not in selector and "text=" not in selector, \ + (name, selector) + page.evaluate("s => document.querySelectorAll(s)", selector) + + +@case +def cancel_alternatives_never_match_ok_or_the_x(page): + page.set_content(""" +
+ + + +
+ """) + ub = page.locator(form_sel.SEL_CANCEL) + assert ub.count() == 1 and ub.get_attribute("id") == "NewCancelId" + dental = page.locator(form_sel.SEL_CANCEL_DENTAL) + assert dental.count() == 1 \ + and dental.get_attribute("id") == "newDentalCancel" + + +# ── report() ───────────────────────────────────────────────────────────────── + +@case +def the_primary_present_is_not_reported(page): + fallback.reset() + page.set_content('' + '') + names, log = _quiet(fallback.report, page, + ["Claim No box", "Lookup button"], CHAINS) + assert names == [] and log == "", log + + +@case +def a_renamed_primary_is_reported_once_with_what_matched(page): + fallback.reset() + page.set_content('' + "") + names, log = _quiet(fallback.report, page, + ["Claim No box", "Lookup button"], CHAINS) + assert names == ["Claim No box", "Lookup button"], names + assert "#claimLookupIpt10 not found" in log \ + and 'matched input[ng-model="_InvId"]' in log \ + and "selectors/claim_lookup.py" in log, log + names, log = _quiet(fallback.report, page, ["Claim No box"], CHAINS) + assert names == [] and log == "", "once a run" + + +@case +def the_bot_still_acts_through_the_alternative(page): + page.set_content('' + '') + page.fill(login_sel.SEL_PASSWORD, "x") + page.click(login_sel.SEL_LOGIN_BTN) + assert page.input_value("#pw2") == "x" + assert page.evaluate("window.clicked") == 1 + + +@case +def nothing_shown_is_not_a_fallback(page): + fallback.reset() + page.set_content("

other page

") + names, log = _quiet(fallback.report, page, list(CHAINS), CHAINS) + assert names == [] and log == "" + + +def main() -> int: + from playwright.sync_api import sync_playwright + + failures = 0 + with sync_playwright() as pw: + browser = pw.chromium.launch(headless=True) + page = browser.new_page() + for fn in CASES: + name = fn.__name__.replace("_", " ") + try: + if len(inspect.signature(fn).parameters) == 0: + fn() + else: + fn(page) + print(f" ok {name}") + except AssertionError as exc: + print(f" FAIL {name}: {exc}") + failures += 1 + except Exception as exc: + print(f" ERROR {name}: {type(exc).__name__}: {exc}") + failures += 1 + browser.close() + + print(f"\n{len(CASES) - failures}/{len(CASES)} passed") + return 1 if failures else 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tests/test_guard.py b/tests/test_guard.py new file mode 100644 index 0000000..96e07ce --- /dev/null +++ b/tests/test_guard.py @@ -0,0 +1,113 @@ +""" +Attempt-guard tests. + + python tests\\test_guard.py + +Plain asserts, no pytest — same reasoning as the hand-rolled `.env` parser: +these machines are locked down and every added dependency is one more thing to +get approved. `python tests\\test_guard.py` needs nothing that is not already +installed. + +These are the only tests that can exist for the guard, and they matter more +than usual: the alternative is a real run, and a real run costs one of a +handful of hourly login attempts plus a mailed 2FA link. Verifying the cooldown +arithmetic against a live portal would be absurd. + +Both clocks are faked from **one** source. That is the point of the harness, +not an accident: an earlier version of `wait_if_cooling` 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 of a second early. A single fake clock catches that. +""" + +import io +import sys +import json +import pathlib +import tempfile +import contextlib +from datetime import datetime, timezone, timedelta + +sys.path.insert(0, str(pathlib.Path(__file__).resolve().parent.parent / "src")) + +from ecw_bot import settings as cfg # noqa: E402 +from ecw_bot.core import guard # noqa: E402 + +BASE = datetime.now(timezone.utc) +_ago = lambda s: (BASE - timedelta(seconds=s)).isoformat() # noqa: E731 + + +class Clock: + """One fake clock driving both `_now()` and `time.sleep()`.""" + + def __init__(self): + self.off = 0.0 + self.slept = 0.0 + + def now(self): + return BASE + timedelta(seconds=self.off) + + def sleep(self, seconds): + self.off += seconds + self.slept += seconds + + +def run(state: dict): + """Apply `state`, run wait_if_cooling() then check(). → (slept, verdict).""" + tmp = pathlib.Path(tempfile.mkdtemp()) / "attempts.json" + tmp.write_text(json.dumps(state), encoding="utf-8") + + clock = Clock() + saved = (guard.STATE_PATH, guard._now, guard.time) + guard.STATE_PATH = tmp + guard._now = clock.now + guard.time = type("_t", (), {"sleep": staticmethod(clock.sleep)}) + try: + with contextlib.redirect_stdout(io.StringIO()): + guard.wait_if_cooling() + verdict = guard.check() + finally: + guard.STATE_PATH, guard._now, guard.time = saved + return clock.slept, verdict + + +CASES = [ + # name, state, slept, clear + ("first ever run", {}, 0, True), + ("cooldown long expired", {"last_attempt": _ago(4000)}, 0, True), + # The one that used to exit 3 with three seconds to go. + ("3s left of the cooldown", {"last_attempt": _ago(297)}, 4, True), + ("280s left of the cooldown", {"last_attempt": _ago(20)}, 281, True), + # Neither of these may be slept through — they mean stop, not "not yet". + ("burst limit reached", {"last_attempt": _ago(4000), + "recent": [_ago(60 * m) for m in (1, 2, 3, 4)]}, + 0, False), + ("restriction lockout", {"last_attempt": _ago(10), + "locked_until": + (BASE + timedelta(minutes=4)).isoformat(), + "restricted_at": BASE.isoformat()}, 0, False), +] + + +def main() -> int: + assert cfg.LOGIN_COOLDOWN_SECONDS == 300, ( + f"these expectations assume a 300s cooldown, .env says " + f"{cfg.LOGIN_COOLDOWN_SECONDS}") + + failures = 0 + for name, state, want_slept, want_clear in CASES: + slept, verdict = run(state) + clear = not verdict + ok = (round(slept) == want_slept) and (clear == want_clear) + failures += not ok + print(f" {'ok ' if ok else 'FAIL'} {name:26s} " + f"slept {slept:6.0f}s (want {want_slept:4d}) " + f"{'clear' if clear else 'refused'}" + f"{'' if clear == want_clear else f' (want {want_clear})'}") + + print(f"\n{len(CASES) - failures}/{len(CASES)} passed") + return 1 if failures else 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tests/test_hcfa_claim.py b/tests/test_hcfa_claim.py new file mode 100644 index 0000000..b993bbe --- /dev/null +++ b/tests/test_hcfa_claim.py @@ -0,0 +1,542 @@ +r""" +The professional/HCFA "Claim" screen (Commercial payers) has no separate +CPT/HCPCS tab and a different set of per-field ids from the UB Claim form — +see hcfa_claim.html's own comment for exactly what differs and why. + + python tests\test_hcfa_claim.py + +Real live bug this guards (claim 9103000, Logs\run_20260917_162939.log): +`open_cpt_hcpcs_tab()` waited 30s for a tab that does not exist on this +screen, raised, and aborted the whole claim read before Progress Notes was +ever reached — so it never opened for that claim at all. Fixed by making +`open_cpt_hcpcs_tab()` a no-op here, and by giving `extract()` and +`set_cpt_pos_modifier()` a `data-fieldname` fallback for this screen's +differently-shaped CPT row fields. + +Since then: ICD and the payer (#billingClaimTbl8) are read here too, and +close_claim() no longer Escapes this form's own window. The rest of +extract() (header, revenue, payments) is still UB-only. +""" + +import sys +import pathlib +import inspect + +ROOT = pathlib.Path(__file__).resolve().parent.parent +sys.path.insert(0, str(ROOT / "src")) + +FIXTURE = pathlib.Path(__file__).resolve().parent / "fixtures" / "hcfa_claim.html" + +from ecw_bot.pages import claim_form # noqa: E402 +from ecw_bot.selectors import claim_form as sel # noqa: E402 + + +CASES = [] + + +def case(fn): + CASES.append(fn) + return fn + + +@case +def is_ub_claim_form_is_false_for_the_hcfa_screen(page): + assert claim_form.is_ub_claim_form(page) is False + + +@case +def open_cpt_hcpcs_tab_is_a_no_op_here_and_does_not_raise(page): + """The real bug: this used to wait 30s for a tab that will never appear, + then raise and abort the whole claim read. It must now return promptly + without touching the page at all.""" + assert claim_form.open_cpt_hcpcs_tab(page) is False + # Still on the same screen, untouched — no exception propagated. + assert claim_form.is_ub_claim_form(page) is False + + +@case +def extract_reads_all_three_real_cpt_rows_via_data_fieldname(page): + data = claim_form.extract(page) + cpt = data.get("cpt") or [] + assert len(cpt) == 3, cpt + + row0, row1, row2 = cpt + assert row0["code"] == "99213", row0 + assert row0["pos"] == "11" and row0["mod1"] == "", row0 + assert row0["icd1"] == "1" and row0["icd2"] == "2" and row0["icd3"] == "3", row0 + + assert row1["code"] == "J7298", row1 + assert row1["pos"] == "11" and row1["mod1"] == "", row1 + + assert row2["code"] == "G8510", row2 + assert row2["icd1"] == "1", row2 + + +@case +def extract_reads_the_icd_table_via_id_prefix_on_the_hcfa_screen(page): + data = claim_form.extract(page) + icd = data.get("icd") or [] + assert len(icd) == 2, icd + assert icd[0]["#"] == "1" and icd[0]["Code"] == "Z30.430", icd[0] + assert icd[1]["#"] == "2" and icd[1]["Code"] == "Z13.31", icd[1] + + +@case +def extract_reads_the_payer_from_the_insurances_and_payment_grid(page): + """ + Every live HCFA snapshot had `insurance: []` (9109000, 9119000 — + extract() only knew the UB form's #UBClaimTbl3), so telehealth never + chose 95 and vaccine_admin_codes never fired on a Commercial claim. + #billingClaimTbl8 carries the UB table's columns; #billingClaimTbl5's + Name cell also holds an edit input, which the value-first cell reader + would return instead of the payer. + """ + page.goto(FIXTURE.as_uri()) + ins = claim_form.extract(page).get("insurance") or [] + assert len(ins) == 1, ins + assert ins[0]["Name"] == "United Health Care", ins[0] + assert ins[0]["Ins Id"] == "1660", ins[0] + + +@case +def set_cpt_pos_modifier_writes_via_data_fieldname_and_blurs_mod1(page): + changes = claim_form.set_cpt_pos_modifier(page, pos="2", modifier="95") + assert len(changes) == 3, changes + + row0, row1, row2 = changes + assert row0["code"] == "99213" and row0["after"]["pos"] == "2" \ + and row0["after"]["mod1"] == "95", row0 + assert row1["code"] == "J7298" and row1["after"]["pos"] == "2" \ + and row1["after"]["mod1"] == "95", row1 + assert row2["code"] == "G8510" and row2["after"]["pos"] == "2" \ + and row2["after"]["mod1"] == "95", row2 + + mod1_field = page.locator(sel.SEL_CPT_ROWS).nth(0).locator( + sel.SEL_CPT_MOD1).first + assert mod1_field.get_attribute("data-blurred") == "1", \ + "M1 was filled but never blurred - updateCPTMod() would not fire" + + +@case +def retag_cpt_icd_pointer_moves_the_depression_pointer_onto_g8510_only(page): + """ + The real bug (claim 9103000): 99213 wrongly carried the depression + pointer ("2") in ICD2 alongside its own diagnosis in ICD1 (and, in this + fixture, a third pointer "3" in ICD3 to prove the shift-left behaviour). + G8510 wrongly carried "1" (Z30.430) instead of the depression pointer. + J7298 carries neither and must be left untouched entirely. + """ + page.goto(FIXTURE.as_uri()) + changes = claim_form.retag_cpt_icd_pointer( + page, target_pointer="2", target_codes={"G8510", "G8431"}) + + by_code = {c["code"]: c for c in changes} + assert set(by_code) == {"99213", "G8510"}, changes + + row0 = by_code["99213"] + assert row0["before"]["icd"] == ["1", "2", "3", ""], row0 + assert row0["after"]["icd"] == ["1", "3", "", ""], row0 + + row2 = by_code["G8510"] + assert row2["before"]["icd"] == ["1", "", "", ""], row2 + assert row2["after"]["icd"] == ["2", "", "", ""], row2 + + # Read back from the live DOM, not just the returned dict. + row0_fields = page.locator(sel.SEL_CPT_ROWS).nth(0) + assert [row0_fields.locator(s).first.input_value() for s in sel.SEL_CPT_ICD] \ + == ["1", "3", "", ""] + row2_fields = page.locator(sel.SEL_CPT_ROWS).nth(2) + assert [row2_fields.locator(s).first.input_value() for s in sel.SEL_CPT_ICD] \ + == ["2", "", "", ""] + + # J7298 (row 1) never carried the pointer - untouched, not even read-written. + row1_fields = page.locator(sel.SEL_CPT_ROWS).nth(1) + assert row1_fields.locator(sel.SEL_CPT_ICD1).first.input_value() == "1" + + +@case +def retag_cpt_icd_pointer_is_a_no_op_on_an_already_correct_claim(page): + page.goto(FIXTURE.as_uri()) + claim_form.retag_cpt_icd_pointer( + page, target_pointer="2", target_codes={"G8510", "G8431"}) + # Second pass over the now-corrected claim: nothing left to change. + changes = claim_form.retag_cpt_icd_pointer( + page, target_pointer="2", target_codes={"G8510", "G8431"}) + assert changes == [], changes + + +@case +def ensure_cpt_rows_checked_checks_the_unchecked_g8510_row(page): + """ + The real bug report (claim 9103000): G8510's own checkbox sat + unchecked - excluded from the claim - and needed checking once its + ICD pointer was corrected. 99213/J7298 are already checked and are not + target codes anyway; neither should be touched. + """ + page.goto(FIXTURE.as_uri()) + changes = claim_form.ensure_cpt_rows_checked( + page, {"G8510", "G8431"}) + assert changes == [{"row": 2, "code": "G8510"}], changes + + checkbox = page.locator(sel.SEL_CPT_ROWS).nth(2).locator( + sel.SEL_CPT_ASSIGN_TO_PATIENT).first + assert checkbox.is_checked() is True + + +@case +def ensure_cpt_rows_checked_is_a_no_op_once_already_checked(page): + page.goto(FIXTURE.as_uri()) + claim_form.ensure_cpt_rows_checked(page, {"G8510", "G8431"}) + changes = claim_form.ensure_cpt_rows_checked(page, {"G8510", "G8431"}) + assert changes == [], changes + + +def _inject_g8510_duplicate_row(page, checked=False, fee="0.00"): + """ + A second G8510 row, after the fixture's own (CPTROW_2) — injected the + same way `_inject_j2001_row` below adds its row, rather than growing + the shared fixture and its row-count assumptions. + """ + page.evaluate( + """(args) => { + const tbody = document.querySelector( + '#claimCPTCodesTable8888888888888 tbody'); + const addRowTr = tbody.querySelector('tr:last-child'); + const tr = document.createElement('tr'); + tr.id = 'CPTROW_3'; + tr.innerHTML = + '' + + '' + + ''; + tbody.insertBefore(tr, addRowTr); + }""", {"checked": checked, "fee": fee}) + + +@case +def ensure_cpt_rows_checked_checks_only_the_first_row_for_a_code(page): + """ + Live claim 9131000: a second G8510 row, already excluded, got checked + here and then unchecked again by zero_and_uncheck_duplicate_cpt_rows() + — a no-op on the claim, reported as two contradictory changes. Only the + first real row for a code is this function's to check. Both start + unchecked: the first is checked, the second is left alone, and a second + pass (the first now checked) must not fall through to the duplicate. + """ + page.goto(FIXTURE.as_uri()) + _inject_g8510_duplicate_row(page, checked=False) + + changes = claim_form.ensure_cpt_rows_checked(page, {"G8510", "G8431"}) + assert changes == [{"row": 2, "code": "G8510"}], changes + + rows = page.locator(sel.SEL_CPT_ROWS) + assert rows.nth(2).locator(sel.SEL_CPT_ASSIGN_TO_PATIENT).first.is_checked() is True + assert rows.nth(3).locator(sel.SEL_CPT_ASSIGN_TO_PATIENT).first.is_checked() is False + + changes = claim_form.ensure_cpt_rows_checked(page, {"G8510", "G8431"}) + assert changes == [], changes + assert rows.nth(3).locator(sel.SEL_CPT_ASSIGN_TO_PATIENT).first.is_checked() is False + + +@case +def ensure_cpt_rows_checked_checks_the_priced_copy_not_a_zero_fee_first_g8510(page): + """ + The operator's common rule (zero_fee_uncheck.py, 2026-09-30): a line + billed at zero is never billed. zero_fee_uncheck runs first and + unchecks a checked 0.00 G8510; checking it again here would be the + same check/uncheck toggle as claim 9131000, between two conditions + instead of two steps of one. Blank is zero by the same shared rule. + + Flipped by the operator's ruling of the same day: the row kept for a + code is the first whose fee is NOT zero, so the priced G8510 further + down is the one re-included here — `kept_cpt_row_per_code()`, the same + helper `zero_and_uncheck_duplicate_cpt_rows()` uses. It used to be left + unchecked too ("first" by position alone), leaving no G8510 billed. + """ + for fee in ("0.00", ""): + page.goto(FIXTURE.as_uri()) + page.evaluate("(fee) => { document.getElementById('billingClaimIpt17ngR2')" + ".value = fee; }", fee) + _inject_g8510_duplicate_row(page, checked=False, fee="65.00") + + changes = claim_form.ensure_cpt_rows_checked(page, {"G8510", "G8431"}) + assert changes == [{"row": 3, "code": "G8510"}], (fee, changes) + + rows = page.locator(sel.SEL_CPT_ROWS) + assert rows.nth(2).locator(sel.SEL_CPT_ASSIGN_TO_PATIENT).first.is_checked() is False, fee + assert rows.nth(3).locator(sel.SEL_CPT_ASSIGN_TO_PATIENT).first.is_checked() is True, fee + assert rows.nth(2).locator(sel.SEL_CPT_BILLED_FEE).first.input_value() == fee, \ + "the fee must never be written" + assert rows.nth(3).locator(sel.SEL_CPT_BILLED_FEE).first.input_value() == "65.00" + + +@case +def set_cpt_pos_modifier_only_first_stops_after_row_0(page): + page.goto(FIXTURE.as_uri()) + changes = claim_form.set_cpt_pos_modifier( + page, pos="2", modifier="93", only_first=True) + assert len(changes) == 1, changes + assert changes[0]["code"] == "99213", changes + + row1_mod1 = page.locator(sel.SEL_CPT_ROWS).nth(1).locator( + sel.SEL_CPT_MOD1).first + assert row1_mod1.input_value() != "93", \ + "only_first=True must not touch the second row on the HCFA screen" + + +@case +def zero_and_uncheck_other_cpt_rows_unchecks_the_checked_rows_here(page): + """ + The checkbox half of document condition #22, only provable on this + screen: ub_claim.html's checkboxes carry no ng-click at all, but this + fixture's do (`ng-click="updateBillToIns($index)"`, verified live on + claim 9103000). 99213 and J7298 start checked with real fees; G8510 + starts unchecked on purpose and is the kept code here, so it must + come back with no change entry at all. + """ + page.goto(FIXTURE.as_uri()) + changes = claim_form.zero_and_uncheck_other_cpt_rows( + page, keep_codes={"G8510"}) + by_code = {c["code"]: c for c in changes} + assert set(by_code) == {"99213", "J7298"}, changes + assert by_code["99213"]["unchecked"] is True + assert by_code["99213"]["fee"] == {"before": "210.00", "after": "0.00"} + assert by_code["J7298"]["unchecked"] is True + assert by_code["J7298"]["fee"] == {"before": "1200.00", "after": "0.00"} + assert "G8510" not in by_code, changes + + row0_checkbox = page.locator(sel.SEL_CPT_ROWS).nth(0).locator( + sel.SEL_CPT_ASSIGN_TO_PATIENT).first + assert row0_checkbox.is_checked() is False + row2_fee = page.locator(sel.SEL_CPT_ROWS).nth(2).locator( + sel.SEL_CPT_BILLED_FEE).first + assert row2_fee.input_value() == "65.00", \ + "the kept code's fee must be left alone" + + +def _inject_j2001_row(page, checked=True, fee="45.00"): + """ + hcfa_claim.html has no J2001 row of its own — injected the same way + the medicare_telehealth tests inject a G2025 row, mirroring the real + markup this fixture already uses for its own checked/fee rows. + """ + page.evaluate( + """(args) => { + const tbody = document.querySelector( + '#claimCPTCodesTable8888888888888 tbody'); + const addRowTr = tbody.querySelector('tr:last-child'); + const tr = document.createElement('tr'); + tr.id = 'CPTROW_3'; + tr.innerHTML = + '' + + '' + + ''; + tbody.insertBefore(tr, addRowTr); + }""", {"checked": checked, "fee": fee}) + + +@case +def zero_and_uncheck_cpt_rows_excludes_only_the_target_codes(page): + """ + Document condition #5's J2001 half: "this code can be unchecked in + the claim" plus zeroing its fee, the same uncheck+zero shape as + condition #22's cleanup step but inverted — only the target codes are + touched, every other row (99213, J7298, G8510) is left alone. + """ + page.goto(FIXTURE.as_uri()) + _inject_j2001_row(page, checked=True, fee="45.00") + + changes = claim_form.zero_and_uncheck_cpt_rows(page, {"J2001"}) + by_code = {c["code"]: c for c in changes} + assert set(by_code) == {"J2001"}, changes + assert by_code["J2001"]["unchecked"] is True + assert by_code["J2001"]["fee"] == {"before": "45.00", "after": "0.00"} + + j2001_row = page.locator(sel.SEL_CPT_ROWS).nth(3) + assert j2001_row.locator(sel.SEL_CPT_ASSIGN_TO_PATIENT).first.is_checked() is False + assert j2001_row.locator(sel.SEL_CPT_BILLED_FEE).first.input_value() == "0.00" + + row0_fee = page.locator(sel.SEL_CPT_ROWS).nth(0).locator( + sel.SEL_CPT_BILLED_FEE).first + assert row0_fee.input_value() == "210.00", \ + "an untargeted row's fee must be left alone" + row0_checkbox = page.locator(sel.SEL_CPT_ROWS).nth(0).locator( + sel.SEL_CPT_ASSIGN_TO_PATIENT).first + assert row0_checkbox.is_checked() is True, \ + "an untargeted row's checkbox must be left alone" + + # A second pass has nothing left to touch. + assert claim_form.zero_and_uncheck_cpt_rows(page, {"J2001"}) == [] + + +@case +def add_cpt_row_uses_the_real_add_button_and_fee_schedule_picker(page): + """ + The real flow (claim 9109000's own live evidence): click "Add", search + the picker for the code, select the one exact match, click the + picker's own OK — never typing straight into the grid's trailing "*" + row, which is a different field entirely. + """ + page.goto(FIXTURE.as_uri()) + assert claim_form.cpt_code_present(page, "G2025") is False + assert claim_form.add_cpt_row(page, "G2025") is True + assert claim_form.cpt_code_present(page, "G2025") is True + + assert page.locator(sel.SEL_CPT_ROWS).count() == 4 + new_row = page.locator(sel.SEL_CPT_ROWS).last + assert new_row.locator(sel.SEL_CPT_CODE).first.input_value() == "G2025" + # The picker itself must be closed afterward, not left open over the claim. + assert page.locator("#claimFeeScheduleModal").is_visible() is False + + +@case +def add_cpt_row_searches_by_the_real_search_field_not_a_substring_of_everything(page): + """ + The catalog also carries G2026 (a decoy), which starts with the same + "G20" prefix — proves the search actually narrows to the exact code + asked for, not just whatever the picker happened to show first. + """ + page.goto(FIXTURE.as_uri()) + assert claim_form.add_cpt_row(page, "G2026") is True + new_row = page.locator(sel.SEL_CPT_ROWS).last + assert new_row.locator(sel.SEL_CPT_CODE).first.input_value() == "G2026" + + +@case +def add_cpt_row_cancels_the_picker_and_returns_false_on_no_match(page): + """ + A code the seeded catalog has no match for at all. Must close the + picker with its own Cancel rather than leave it open — an open Fee + Schedule modal would block every click on the claim form behind it, + the exact class of bug dismiss_stray_modal() exists to recover from. + """ + page.goto(FIXTURE.as_uri()) + assert claim_form.add_cpt_row(page, "NOPE99") is False + assert page.locator("#claimFeeScheduleModal").is_visible() is False + + +@case +def open_progress_notes_works_the_same_as_the_ub_form(page): + page.goto(FIXTURE.as_uri()) + note = claim_form.open_progress_notes(page) + assert note.get("opened") not in (None, "absent", "disabled", "nothing"), note + + +@case +def close_claim_uses_the_real_cancel_not_escape_no_wasted_fallback(page): + """ + Live, on every HCFA claim the bot closed itself (9109000, + Logs\\run_20260929_160143.log): dismiss_stray_modal() took this form's + own uib window (.claimModalBackdrop) for a stray panel and pressed + Escape, which closed the form without closeClaimScreen() — then Cancel, + X and the Dental Cancel each timed out on buttons already gone. ~45s + and a false "Could not close the claim form", per claim. + """ + import time + page.goto(FIXTURE.as_uri()) + assert page.locator("#billingClaimBtn36").first.is_visible() + started = time.monotonic() + assert claim_form.close_claim(page) is True + elapsed = time.monotonic() - started + assert page.evaluate("window.closeClaimScreenRan") is True, \ + "the form was closed by Escape, not by its own Cancel" + assert elapsed < 5, f"took {elapsed:.1f}s — a fallback was tried" + assert not page.locator("#billingClaimBtn36").first.is_visible() + + +@case +def close_claim_still_dismisses_a_real_stray_panel_over_the_form(page): + """ + The claim's own window is spared, not the stray check as a whole: a + .claimModalBackdrop panel with no claim form inside it, sitting on top, + still gets its Escape — and the form under it is then closed by Cancel. + """ + page.goto(FIXTURE.as_uri()) + page.evaluate("""() => { + const p = document.createElement('div'); + p.className = 'modal fade ng-isolate-scope claimModalBackdrop in'; + p.id = 'strayPanel'; + p.style.cssText = 'position:fixed;top:0;left:0;width:100%;' + + 'height:100%;z-index:2000;display:block;'; + document.body.appendChild(p); + // Escape goes to the topmost window only: the stray panel, first. + document.addEventListener('keydown', e => { + if (e.key === 'Escape' && p.isConnected) { + p.remove(); + e.stopImmediatePropagation(); + } + }, true); + }""") + assert claim_form.close_claim(page) is True + assert page.locator("#strayPanel").count() == 0 + assert page.evaluate("window.closeClaimScreenRan") is True + + +@case +def save_claim_clicks_the_hcfa_ok_and_the_modal_closes(page): + page.goto(FIXTURE.as_uri()) + assert page.locator("#claimScreenOkBtn8888888888888").first.is_visible() + assert claim_form.save_claim(page) is True + assert not page.locator("#claimScreenOkBtn8888888888888").first.is_visible() + + +def _run() -> int: + if not FIXTURE.exists(): + print(f" FAIL fixture missing: {FIXTURE}") + return 1 + try: + from playwright.sync_api import sync_playwright + except ImportError: + print(" SKIP playwright is not installed") + return 0 + + failures = 0 + with sync_playwright() as pw: + browser = pw.chromium.launch(headless=True) + page = browser.new_page() + page.goto(FIXTURE.as_uri()) + + for fn in CASES: + name = fn.__name__.replace("_", " ") + try: + if len(inspect.signature(fn).parameters) == 0: + fn() + else: + fn(page) + print(f" ok {name}") + except AssertionError as exc: + print(f" FAIL {name}: {exc}") + failures += 1 + except Exception as exc: + print(f" ERROR {name}: {type(exc).__name__}: {exc}") + failures += 1 + browser.close() + + print(f"\n{len(CASES) - failures}/{len(CASES)} passed") + return 1 if failures else 0 + + +def main() -> int: + """ + shot() writes into the operator's dated Screenshots\ folder; a test + run must not, so the cases run with it pointed at a temp folder. + """ + import tempfile + from ecw_bot import settings as _cfg + orig = (_cfg.SCREENSHOTS, _cfg.SAVE_CORRECTIONS) + with tempfile.TemporaryDirectory() as tmp: + _cfg.SCREENSHOTS = pathlib.Path(tmp) + # These cases exercise eCW's own click and Add flow, as a LIVE-WRITE + # run does. VERIFY clicks no box and adds no row (eCW saves both at + # once, 2026-10-16): that is tests/test_verify_no_live_writes.py. + _cfg.SAVE_CORRECTIONS = True + try: + return _run() + finally: + _cfg.SCREENSHOTS, _cfg.SAVE_CORRECTIONS = orig + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tests/test_import_payer_groups.py b/tests/test_import_payer_groups.py new file mode 100644 index 0000000..77fad1d --- /dev/null +++ b/tests/test_import_payer_groups.py @@ -0,0 +1,76 @@ +r""" +tools/import_payer_groups.py — turning the Claim Status Dashboard's +Payer Name / Payer Group Name pairs into payer_groups.json. + + python tests\test_import_payer_groups.py + +Only `build()` is exercised: the merge rules. No network file is read. +""" + +import sys +import pathlib +import collections + +ROOT = pathlib.Path(__file__).resolve().parent.parent +sys.path.insert(0, str(ROOT / "tools")) + +import import_payer_groups as tool # noqa: E402 + + +def _pairs(**groups_by_payer): + return {p.replace("_", " "): collections.Counter(g) + for p, g in groups_by_payer.items()} + + +def case_dashboard_group_wins_and_names_are_kept_exactly(): + mapping, conflicts, kept = tool.build( + _pairs(Medicaid={"Medicaid": 5}, Anthem={"Commercial Ins": 3}), + {"Anthem": "Commercial"}) + assert mapping == {"Anthem": "Commercial Ins", "Medicaid": "Medicaid"} + assert conflicts == {} and kept == {} + + +def case_a_conflict_keeps_the_group_on_most_rows_and_is_reported(): + mapping, conflicts, _ = tool.build( + _pairs(Humana={"Medicare": 9, "Commercial Ins": 2}), {}) + assert mapping["Humana"] == "Medicare" + assert conflicts == {"Humana": {"Medicare": 9, "Commercial Ins": 2}} + + +def case_payers_missing_from_the_dashboard_are_kept_and_renamed(): + mapping, _, kept = tool.build( + _pairs(Medicaid={"Medicaid": 1}), + {"BH ROCK MTN UB": "RAE payer", "Odd Payer": "Something Else"}) + assert kept == {"BH ROCK MTN UB": "RAE (Behavioral Health)", + "Odd Payer": "Something Else"} + assert mapping["BH ROCK MTN UB"] == "RAE (Behavioral Health)" + + +def case_output_is_sorted_case_insensitively(): + mapping, _, _ = tool.build( + _pairs(b={"X": 1}, A={"X": 1}, c={"X": 1}), {}) + assert list(mapping) == ["A", "b", "c"] + + +CASES = [v for k, v in sorted(globals().items()) if k.startswith("case_")] + + +def main() -> int: + failures = 0 + for fn in CASES: + name = fn.__name__[5:].replace("_", " ") + try: + fn() + print(f" ok {name}") + except AssertionError as exc: + print(f" FAIL {name}: {exc}") + failures += 1 + except Exception as exc: + print(f" ERROR {name}: {type(exc).__name__}: {exc}") + failures += 1 + print(f"\n{len(CASES) - failures}/{len(CASES)} passed") + return 1 if failures else 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tests/test_inert_claim_windows.py b/tests/test_inert_claim_windows.py new file mode 100644 index 0000000..ae7bc66 --- /dev/null +++ b/tests/test_inert_claim_windows.py @@ -0,0 +1,225 @@ +r""" +Claim windows that take no clicks (live run 2026-10-13, +Logs\run_20261013_163322.log). After an HCFA claim closed while the bot's +review box was in front of Chrome, its window stayed in the page at opacity +0.222 with `pointer-events: none` — Chrome had paused the fade-out — and the +next claim spent ~45 s clicking Cancel on it four times. A Dental window +eCW opened by itself after the Lookup was briefly the same. + + * Chrome is launched so it keeps running behind a message box. + * Such a window is never clicked: it gets up to `_SETTLE_MS` to finish — + gone → nothing to do; clickable → cancelled; still not → refused, as + before, so it is never read as the next claim's form. + * `click_topmost` / `count_shown` do not count what no click can reach. + + python tests\test_inert_claim_windows.py +""" + +import io +import sys +import pathlib +import inspect +import contextlib + +ROOT = pathlib.Path(__file__).resolve().parent.parent +sys.path.insert(0, str(ROOT / "src")) + +FIXTURES = pathlib.Path(__file__).resolve().parent / "fixtures" +HCFA = FIXTURES / "hcfa_claim.html" + +from ecw_bot import settings as cfg # noqa: E402 +from ecw_bot.core import browser, page_utils # noqa: E402 +from ecw_bot.pages import claim_form # noqa: E402 + +CASES = [] + + +def case(fn): + CASES.append(fn) + return fn + + +def _quiet(fn, *a, **k): + with contextlib.redirect_stdout(io.StringIO()) as out: + result = fn(*a, **k) + return result, out.getvalue() + + +def _hcfa_window(page, inert=True, then=None, after_ms=500): + """ + The HCFA fixture's claim window, its Cancel counting clicks. `inert`: + shown but `pointer-events: none` at opacity 0.222, as the live ghost + was. `then`: "gone" removes the window after `after_ms`, "live" makes + it clickable again. + """ + page.goto(HCFA.as_uri()) + page.evaluate("""([inert, then, ms]) => { + const win = document.querySelector('.claimModalBackdrop'); + const old = win.querySelector('[ng-click="closeClaimScreen()"]'); + const c = old.cloneNode(true); // drop the fixture's handler + old.replaceWith(c); + window.cancels = 0; + c.addEventListener('click', () => { window.cancels++; win.remove(); }); + if (inert) Object.assign(win.style, + {pointerEvents: 'none', opacity: '0.222'}); + if (then === 'gone') setTimeout(() => win.remove(), ms); + if (then === 'live') setTimeout(() => Object.assign(win.style, + {pointerEvents: '', opacity: ''}), ms); + }""", [inert, then, after_ms]) + + +def _with_short_settle(fn): + old = claim_form._SETTLE_MS + claim_form._SETTLE_MS = 2_000 + try: + return fn() + finally: + claim_form._SETTLE_MS = old + + +# ── Chrome keeps running behind a box ─────────────────────────────────────── + +@case +def chrome_is_launched_to_keep_running_behind_a_message_box(): + seen = {} + + class Chromium: + def launch(self, **kw): + seen.update(kw) + raise RuntimeError("stop here") + + class PW: + chromium = Chromium() + + saved = cfg.BROWSER_CDP_URL + cfg.BROWSER_CDP_URL = "" # launch, not attach (.env may set it) + try: + _quiet(browser.launch, PW()) + except RuntimeError: + pass + finally: + cfg.BROWSER_CDP_URL = saved + for flag in ("--disable-backgrounding-occluded-windows", + "--disable-renderer-backgrounding", + "--disable-background-timer-throttling"): + assert flag in seen["args"], seen.get("args") + + +# ── a window that takes no clicks ──────────────────────────────────────────── + +@case +def an_inert_window_that_never_settles_is_refused_without_a_click(page): + _hcfa_window(page, inert=True) + + def run(): + try: + _quiet(claim_form._close_leftover_form, page, "9112000") + except RuntimeError as exc: + return str(exc) + return None + why = _with_short_settle(run) + assert why and "takes no clicks" in why, why + assert page.evaluate("window.cancels") == 0, "an inert window was clicked" + + +@case +def an_inert_window_that_finishes_closing_needs_nothing(page): + _hcfa_window(page, inert=True, then="gone") + closed, out = _with_short_settle( + lambda: _quiet(claim_form._close_leftover_form, page, "9112000")) + assert closed is False, out + assert "finished on its own" in out, out + assert page.evaluate("window.cancels") == 0 + + +@case +def a_window_that_becomes_clickable_is_cancelled(page): + _hcfa_window(page, inert=True, then="live") + closed, out = _with_short_settle( + lambda: _quiet(claim_form._close_leftover_form, page, "9112000")) + assert closed is True, out + assert page.evaluate("window.cancels") == 1, out + assert "already open" in out, out + + +@case +def a_live_leftover_is_cancelled_at_once(page): + _hcfa_window(page, inert=False) + closed, out = _quiet(claim_form._close_leftover_form, page, "9112000") + assert closed is True, out + assert page.evaluate("window.cancels") == 1, out + + +@case +def clear_screen_reports_an_inert_window_and_clicks_nothing(page): + """No click on it — Escape, the last resort (operator, 2026-10-14), is + a key press, and is said in the log.""" + _hcfa_window(page, inert=True) + done, out = _with_short_settle( + lambda: _quiet(claim_form.clear_screen, page, "9112000", + page.context.pages)) + assert "takes no clicks" in out, out + assert "LAST RESORT" in out and "pressing Escape" in out, out + assert page.evaluate("window.cancels") == 0 + + +# ── click_topmost ──────────────────────────────────────────────────────────── + +@case +def click_topmost_and_count_shown_skip_what_no_click_can_reach(page): + page.set_content(""" + + + + """) + assert page_utils.count_shown(page, ".b") == 1 + assert page_utils.click_topmost(page, ".b", timeout=2_000) is True + assert page.evaluate("window.hits") == ["b1"] + page.set_content('') + assert page_utils.click_topmost(page, ".b", timeout=1_000) is False + + +def _run_cases() -> int: + from playwright.sync_api import sync_playwright + + failures = 0 + with sync_playwright() as pw: + b = pw.chromium.launch(headless=True) + page = b.new_page(viewport={"width": 1600, "height": 1000}) + for fn in CASES: + name = fn.__name__.replace("_", " ") + try: + if len(inspect.signature(fn).parameters) == 0: + fn() + else: + fn(page) + print(f" ok {name}") + except AssertionError as exc: + print(f" FAIL {name}: {exc}") + failures += 1 + except Exception as exc: + print(f" ERROR {name}: {type(exc).__name__}: {exc}") + failures += 1 + b.close() + + print(f"\n{len(CASES) - failures}/{len(CASES)} passed") + return 1 if failures else 0 + + +def main() -> int: + """shot() writes into the operator's Screenshots\\ folder; not here.""" + import tempfile + from ecw_bot import settings as _cfg + orig = _cfg.SCREENSHOTS + with tempfile.TemporaryDirectory() as tmp: + _cfg.SCREENSHOTS = pathlib.Path(tmp) + try: + return _run_cases() + finally: + _cfg.SCREENSHOTS = orig + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tests/test_input_archive.py b/tests/test_input_archive.py new file mode 100644 index 0000000..7962f73 --- /dev/null +++ b/tests/test_input_archive.py @@ -0,0 +1,201 @@ +r""" +Input\ holds exactly one report, and it is always the current one. + + python tests\test_input_archive.py + +The problem this covers, in the words it was reported in: run the bot a second +time, and the agent takes the **old** file's claims instead of the new one. + +Three days of testing left six reports in `Input\`, named +`Claim Report_09-07-2026.xlsx`, `... (2).xlsx`, `... (3).xlsx` and so on. Once +more than one is sitting there, "which report is current?" stops being obvious +— and the wrong answer is silent: the run looks clean and processes claims from +a stale queue. + +Two things make the answer unambiguous, and both are tested here: + + * the export moves every earlier report into `Input\Archive\` before saving + the new one, so there is only ever one to choose from; + * `latest_report()` picks by **modification time**, never by name. Sorting + names is a trap: `Claim Report_09-09-2026 (2).xlsx` sorts *before* + `Claim Report_09-09-2026.xlsx`, because a space (0x20) is lower than a dot + (0x2e) — so the alphabetically first file can be the older one. + +Runs against a temporary Input\ ; the real one is never touched. +""" + +import sys +import time +import pathlib +import tempfile + +ROOT = pathlib.Path(__file__).resolve().parent.parent +sys.path.insert(0, str(ROOT / "src")) + +from ecw_bot import settings as cfg, claim_file # noqa: E402 +from ecw_bot.pages import claim_report # noqa: E402 + +CHECKS = [] + + +def check(fn): + CHECKS.append(fn) + return fn + + +def sandbox(): + """A fresh temporary Input\\ , pointed at by settings.""" + root = pathlib.Path(tempfile.mkdtemp()) + cfg.INPUTS = root / "Input" + cfg.INPUTS_ARCHIVE = cfg.INPUTS / "Archive" + cfg.INPUTS.mkdir(parents=True) + return cfg.INPUTS + + +def report(where, name, minutes_ago=0, ids=("9115000",)): + """Write a minimal xlsx that claim_file can actually read.""" + import zipfile + ns = 'xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main"' + rows = ['' + 'CLAIMS#'] + for i, cid in enumerate(ids, start=2): + rows.append(f'' + f'{cid}') + sheet = (f'' + f'{"".join(rows)}') + path = where / name + with zipfile.ZipFile(path, "w") as z: + z.writestr("xl/worksheets/sheet1.xml", sheet) + if minutes_ago: + old = time.time() - minutes_ago * 60 + import os + os.utime(path, (old, old)) + return path + + +@check +def archive_leaves_only_the_new_report(page=None): + inp = sandbox() + report(inp, "Claim Report_09-07-2026.xlsx", minutes_ago=2880) + report(inp, "Claim Report_09-07-2026 (2).xlsx", minutes_ago=2900) + report(inp, "Claim Report_09-08-2026.xlsx", minutes_ago=1440) + report(inp, "rescued_20260908_163651.xlsx", minutes_ago=1400) + + moved = claim_report._archive_old() + assert len(moved) == 4, [m.name for m in moved] + + left = [f.name for f in inp.iterdir() if f.is_file()] + assert left == [], f"Input should be empty of reports, got {left}" + archived = sorted(f.name for f in cfg.INPUTS_ARCHIVE.iterdir()) + assert len(archived) == 4, archived + # Each archived name carries the file's own mtime, so nothing collides and + # the order is readable. + assert all("__20" in n for n in archived), archived + + +@check +def the_new_report_is_the_one_that_gets_read(page=None): + """The reported symptom, reproduced and then not happening.""" + inp = sandbox() + report(inp, "Claim Report_09-08-2026.xlsx", + minutes_ago=1440, ids=("1111111", "2222222")) + + # Second run: archive, then the new file lands. + claim_report._archive_old() + report(inp, "Claim Report_09-09-2026.xlsx", ids=("9115000", "9114000")) + + info = claim_file.claim_ids() # no path - it has to find it + assert info["file"].name == "Claim Report_09-09-2026.xlsx", info["file"] + assert info["ids"] == ["9115000", "9114000"], info["ids"] + assert "1111111" not in info["ids"], "read the OLD report's claims" + + +@check +def newest_by_mtime_not_by_name(page=None): + """ + The name-sorting trap, with the two files that expose it. + + `Claim Report_09-09-2026 (2).xlsx` sorts FIRST alphabetically - a space is + 0x20, a dot is 0x2e - while being the OLDER file. Anything picking + `sorted(...)[0]` reads stale claims and looks perfectly healthy doing it. + """ + inp = sandbox() + old = report(inp, "Claim Report_09-09-2026 (2).xlsx", + minutes_ago=120, ids=("1111111",)) + new = report(inp, "Claim Report_09-09-2026.xlsx", ids=("9115000",)) + + names = sorted(f.name for f in inp.iterdir()) + assert names[0] == old.name, ( + f"this test assumes '(2)' sorts first; it did not: {names}") + + picked = claim_file.latest_report() + assert picked.name == new.name, ( + f"picked {picked.name}, the alphabetically-first but OLDER file") + + +@check +def more_than_one_report_is_reported_not_guessed(page=None): + import io + import contextlib + inp = sandbox() + report(inp, "Claim Report_09-08-2026.xlsx", minutes_ago=1440) + report(inp, "Claim Report_09-09-2026.xlsx") + + buf = io.StringIO() + with contextlib.redirect_stdout(buf): + picked = claim_file.latest_report() + out = buf.getvalue() + assert picked.name == "Claim Report_09-09-2026.xlsx", picked + # A guess that goes unmentioned is how a stale read stays invisible. + assert "WARNING" in out and "should be one" in out, out + + +@check +def empty_input_says_where_the_old_ones_went(page=None): + inp = sandbox() + (inp / "Archive").mkdir() + try: + claim_file.latest_report() + except FileNotFoundError as exc: + assert "Archive" in str(exc), exc + return + raise AssertionError("expected FileNotFoundError naming Archive") + + +@check +def archiving_can_be_turned_off(page=None): + inp = sandbox() + report(inp, "Claim Report_09-08-2026.xlsx", minutes_ago=1440) + was = cfg.ARCHIVE_OLD_INPUTS + cfg.ARCHIVE_OLD_INPUTS = False + try: + assert claim_report._archive_old() == [] + assert (inp / "Claim Report_09-08-2026.xlsx").exists() + finally: + cfg.ARCHIVE_OLD_INPUTS = was + + +def main() -> int: + real_in, real_arch = cfg.INPUTS, cfg.INPUTS_ARCHIVE + failures = 0 + try: + for fn in CHECKS: + name = fn.__name__.replace("_", " ") + try: + fn() + print(f" ok {name}") + except AssertionError as exc: + print(f" FAIL {name}: {exc}") + failures += 1 + except Exception as exc: + print(f" ERROR {name}: {type(exc).__name__}: {exc}") + failures += 1 + finally: + cfg.INPUTS, cfg.INPUTS_ARCHIVE = real_in, real_arch + + print(f"\n{len(CHECKS) - failures}/{len(CHECKS)} passed") + return 1 if failures else 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tests/test_mode_banner.py b/tests/test_mode_banner.py new file mode 100644 index 0000000..d249f72 --- /dev/null +++ b/tests/test_mode_banner.py @@ -0,0 +1,341 @@ +r""" +export_medcor_ready - the [MODE] banner and the LIVE-WRITE start box. + + python tests\test_mode_banner.py + +Review item P1 (2026-10-01): nothing at the start of a run said whether it +could write to live eCW. The only way to tell VERIFY from LIVE-WRITE was to +open .env, and the .env reader itself could get it wrong silently. So: + + * `mode_banner()` prints, after the [CONFIG] lines, which mode this is, the + claim limit, and whether the ids come from a test sheet. + * With SAVE_CORRECTIONS=1, `main()` asks for an OK BEFORE login, with + Cancel as the default button. Anything but an explicit OK stops the run + with exit 2 - nothing spent: no login attempt, no 2FA mail. + +`main()` is driven with everything past the config check stubbed: the run +log, the browser and the login all raise if reached, so these cases prove +where `main()` stops, never touching eCW, the Logs\ folder or the real .env. +""" + +import io +import sys +import pathlib +import tempfile +import contextlib + +ROOT = pathlib.Path(__file__).resolve().parent.parent +sys.path.insert(0, str(ROOT / "src")) + +from ecw_bot import settings as cfg # noqa: E402 +from ecw_bot.core import msgbox # noqa: E402 +from ecw_bot.flows import export_medcor_ready as flow # noqa: E402 + +CASES = [] + + +def case(fn): + CASES.append(fn) + return fn + + +_CFG_KEYS = ("SAVE_CORRECTIONS", "APPLY_CORRECTIONS", "CLAIM_PROCESS_LIMIT", + "TEST_CLAIM_IDS_FILE", "CLAIM_STATUS_LABEL", "ECW_USERNAME", + "PROXY_IP", "PROXY_PORT", "HEADLESS", "validate", + "resolved_otp_mode", "WRITE_CHARGE_SUMMARY", "BROWSER_CDP_URL") + + +def _with_cfg(fn, **values): + """Run fn() with cfg attributes set, restoring every one after.""" + saved = {k: getattr(cfg, k) for k in _CFG_KEYS} + saved_bad = list(cfg._BAD_VALUES) + try: + for k, v in values.items(): + assert k in _CFG_KEYS, k + setattr(cfg, k, v) + return fn() + finally: + for k, v in saved.items(): + setattr(cfg, k, v) + cfg._BAD_VALUES[:] = saved_bad + + +_VERIFY = dict(SAVE_CORRECTIONS=False, APPLY_CORRECTIONS=True, + CLAIM_PROCESS_LIMIT=1, TEST_CLAIM_IDS_FILE="", + CLAIM_STATUS_LABEL="Medcor Ready") + + +# ============================================================================= +# mode_banner() +# ============================================================================= + +@case +def verify_mode_banner(): + lines = _with_cfg(flow.mode_banner, **_VERIFY) + assert lines == [ + "[MODE] VERIFY — corrections are filled in on screen for review " + "and NEVER saved (SAVE_CORRECTIONS=0)", + "[MODE] CLAIM_PROCESS_LIMIT=1 (0 = all)", + ], lines + + +@case +def live_write_mode_banner(): + lines = _with_cfg(flow.mode_banner, + **{**_VERIFY, "SAVE_CORRECTIONS": True}) + assert lines[0] == ("[MODE] LIVE-WRITE — corrections are SAVED to eCW " + "after the per-claim OK; box ticks and added CPT " + "rows save at once, OK or not " + "(SAVE_CORRECTIONS=1)"), lines + assert not any("VERIFY" in l for l in lines), lines + + +@case +def apply_off_says_conditions_are_not_run(): + lines = _with_cfg(flow.mode_banner, + **{**_VERIFY, "APPLY_CORRECTIONS": False}) + assert "[MODE] conditions are NOT run (APPLY_CORRECTIONS=0)" in lines, \ + lines + + +@case +def apply_on_says_nothing_about_conditions(): + lines = _with_cfg(flow.mode_banner, **_VERIFY) + assert not any("APPLY_CORRECTIONS" in l for l in lines), lines + + +@case +def claim_limit_zero_is_shown_as_all(): + lines = _with_cfg(flow.mode_banner, + **{**_VERIFY, "CLAIM_PROCESS_LIMIT": 0}) + assert "[MODE] CLAIM_PROCESS_LIMIT=0 (0 = all)" in lines, lines + + +@case +def claim_ids_from_a_test_sheet_are_named_by_file_name(): + sheet = str(pathlib.Path("C:/somewhere/Standard Claims.xlsx")) + lines = _with_cfg(flow.mode_banner, + **{**_VERIFY, "TEST_CLAIM_IDS_FILE": sheet}) + assert lines[-1] == ("[MODE] TEST MODE — claim ids come from Standard " + "Claims.xlsx, not the live Medcor Ready queue"), lines + + +@case +def no_test_mode_line_without_a_test_file(): + lines = _with_cfg(flow.mode_banner, **_VERIFY) + assert not any("TEST MODE" in l for l in lines), lines + + +# ============================================================================= +# main() - where it stops +# ============================================================================= + +class _ReachedLogin(Exception): + """The browser/login was reached.""" + + +class _BoxShown(Exception): + """A box that must not appear in this mode appeared.""" + + +def _reached_login(*a, **k): + raise _ReachedLogin() + + +def _run_main(save, box_answer=None, validate=None): + """ + main() with the config check passed (unless `validate` is given), the + browser and login stubbed to raise _ReachedLogin, and the run log not + started. `box_answer`: what the LIVE-WRITE box answers, or None to make + any box an error. Returns (exit code or _ReachedLogin, printed text, + the boxes shown as (title, body, kwargs)). + """ + boxes = [] + + def _box(title, msg, **kw): + boxes.append((title, msg, kw)) + if box_answer is None: + raise _BoxShown(title) + return box_answer + + orig = (flow.runlog.start, flow.sync_playwright, flow._login_with_retry, + msgbox.ok_cancel, flow.run_lock.acquire, flow.run_lock.finish) + # In a temp folder: main() writes the Run's events beside the log. + unused_log = pathlib.Path(tempfile.mkdtemp()) / "unused.log" + flow.runlog.start = lambda: unused_log + flow.sync_playwright = _reached_login + flow._login_with_retry = _reached_login + msgbox.ok_cancel = _box + flow.run_lock.acquire = lambda log_path, source=None: {} + flow.run_lock.finish = lambda code: None + out = io.StringIO() + + def _go(): + with contextlib.redirect_stdout(out): + try: + return flow.main() + except _ReachedLogin as exc: + return exc + + values = {**_VERIFY, "SAVE_CORRECTIONS": save, + "ECW_USERNAME": "test-user", "PROXY_IP": "", "PROXY_PORT": "", + # Never the operator's Chrome: with BROWSER_CDP_URL from .env + # main() would open a real one, here with no proxy — and a + # Portal run then attached to it (2026-10-22). + "BROWSER_CDP_URL": "", + "HEADLESS": False, "resolved_otp_mode": lambda: "manual", + "validate": validate or (lambda: [])} + try: + result = _with_cfg(_go, **values) + finally: + (flow.runlog.start, flow.sync_playwright, flow._login_with_retry, + msgbox.ok_cancel, flow.run_lock.acquire, flow.run_lock.finish) = orig + return result, out.getvalue(), boxes + + +@case +def live_write_not_confirmed_stops_before_login_with_exit_2(): + result, out, boxes = _run_main(save=True, box_answer=False) + assert result == 2, (result, out) + assert ("[MODE] Operator did not confirm LIVE-WRITE mode — stopping " + "before login") in out, out + assert len(boxes) == 1, boxes + title, body, kw = boxes[0] + assert title == "eCW Bot — LIVE-WRITE mode", title + # Enter must answer Cancel here too: OK starts a run that writes. + assert kw.get("default_cancel") is True, kw + assert "live eCW" in body, body + # The banner is printed first, so the log says why the box appeared. + assert out.index("[MODE] LIVE-WRITE") < out.index("did not confirm"), out + + +@case +def live_write_confirmed_goes_on_to_login(): + result, out, boxes = _run_main(save=True, box_answer=True) + assert isinstance(result, _ReachedLogin), (result, out) + assert len(boxes) == 1, boxes + assert "did not confirm" not in out, out + + +@case +def verify_mode_asks_nothing_and_goes_on_to_login(): + result, out, boxes = _run_main(save=False, box_answer=None) + assert isinstance(result, _ReachedLogin), (result, out) + assert boxes == [], boxes + assert "[MODE] VERIFY" in out, out + + +@case +def mode_banner_follows_the_config_lines(): + _, out, _ = _run_main(save=False) + assert out.index("[CONFIG] log=") < out.index("[MODE] VERIFY"), out + + +@case +def unreadable_setting_exits_2_before_banner_and_login(): + """ + P1's strict parsing, end to end: a bad yes/no value recorded by the + settings parser is a validate() problem, so main() stops at the config + check - before the banner, the box and the login. + """ + def _validate(): + cfg._BAD_VALUES.append(("SAVE_CORRECTIONS", "O", + "a valid yes/no value")) + return orig_validate() + + orig_validate = cfg.validate + result, out, boxes = _run_main(save=True, box_answer=None, + validate=_validate) + assert result == 2, (result, out) + assert "SAVE_CORRECTIONS='O' is not a valid yes/no value" in out, out + assert "[MODE]" not in out, out + assert boxes == [], boxes + + +# ── tools/check_claims_from_excel.py (final review, 2026-10-05) ────────────── +# A second entry point that logs in and reaches the flow's real +# _process_claims (so it can save). It only printed a WARNING line and went +# straight to login with SAVE_CORRECTIONS=1; it now goes through the same +# start_mode_gate() as main(). + +def _tool(): + import importlib.util + path = ROOT / "tools" / "check_claims_from_excel.py" + spec = importlib.util.spec_from_file_location("_check_tool", path) + mod = importlib.util.module_from_spec(spec) + spec.loader.exec_module(mod) + return mod + + +def _run_tool(save, box_answer=None): + tool = _tool() + boxes = [] + + def _box(title, msg, **kw): + boxes.append((title, msg, kw)) + if box_answer is None: + raise _BoxShown(title) + return box_answer + + orig = (tool.runlog.start, msgbox.ok_cancel) + tool.runlog.start = lambda: pathlib.Path("unused.log") + tool.sync_playwright = _reached_login + tool.login = _reached_login + tool.load_claims_from_excel = lambda path: [("9103000", "")] + msgbox.ok_cancel = _box + out = io.StringIO() + + def _go(): + with contextlib.redirect_stdout(out): + try: + return tool.main(pathlib.Path("unused.xlsx")) + except _ReachedLogin as exc: + return exc + + values = {**_VERIFY, "SAVE_CORRECTIONS": save, + "ECW_USERNAME": "test-user", "WRITE_CHARGE_SUMMARY": True, + "validate": lambda: []} + try: + result = _with_cfg(_go, **values) + finally: + tool.runlog.start, msgbox.ok_cancel = orig + return result, out.getvalue(), boxes + + +@case +def the_check_tool_stops_before_login_when_live_write_is_not_confirmed(): + result, out, boxes = _run_tool(save=True, box_answer=False) + assert result == 2, (result, out) + assert "[MODE] LIVE-WRITE" in out, out + assert "did not confirm LIVE-WRITE" in out, out + assert len(boxes) == 1 and boxes[0][2].get("default_cancel") is True, \ + boxes + + +@case +def the_check_tool_in_verify_mode_asks_nothing_and_goes_on_to_login(): + result, out, boxes = _run_tool(save=False) + assert isinstance(result, _ReachedLogin), (result, out) + assert boxes == [], boxes + assert "[MODE] VERIFY" in out, out + + +def main() -> int: + failures = 0 + for fn in CASES: + name = fn.__name__.replace("_", " ") + try: + fn() + print(f" ok {name}") + except AssertionError as exc: + print(f" FAIL {name}: {exc}") + failures += 1 + except Exception as exc: + print(f" ERROR {name}: {type(exc).__name__}: {exc}") + failures += 1 + print(f"\n{len(CASES) - failures}/{len(CASES)} passed") + return 1 if failures else 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tests/test_msgbox.py b/tests/test_msgbox.py new file mode 100644 index 0000000..fe4732e --- /dev/null +++ b/tests/test_msgbox.py @@ -0,0 +1,256 @@ +r""" +core/msgbox - which button Enter answers. + + python tests\test_msgbox.py + +Review item P1 (2026-10-01): the per-claim save box's default button was +OK, so pressing Enter - out of habit, or to dismiss what looked like one more +info box - would save the claim to live eCW. `default_cancel=True` adds +MB_DEFBUTTON2 so Enter/Space answers Cancel; every other caller (captcha, +security image) must keep the box it always had. + +No real message box is shown: `_raw_call`, the one function that reaches +user32, is replaced by a recorder that answers with a chosen return code. +""" + +import sys +import pathlib + +ROOT = pathlib.Path(__file__).resolve().parent.parent +sys.path.insert(0, str(ROOT / "src")) + +from ecw_bot.core import msgbox # noqa: E402 + +CASES = [] + + +def case(fn): + CASES.append(fn) + return fn + + +_DEFBUTTON2 = 0x00000100 +_IDOK, _IDCANCEL = 1, 2 + + +def _capture(fn, rc=_IDCANCEL): + """Call fn() with _raw_call recording its flags. Returns (result, flags).""" + seen = [] + orig = msgbox._raw_call + msgbox._raw_call = lambda text, caption, flags, timeout_s: ( + seen.append(flags) or rc) + try: + result = fn() + finally: + msgbox._raw_call = orig + assert len(seen) == 1, seen + return result, seen[0] + + +@case +def defbutton2_constant_is_the_win32_value(): + # Pinned: a typo here would silently leave OK as the default button. + assert msgbox._MB_DEFBUTTON2 == _DEFBUTTON2, hex(msgbox._MB_DEFBUTTON2) + + +@case +def confirm_default_keeps_ok_as_the_default_button(): + _, flags = _capture(lambda: msgbox.confirm("t", "m", 5)) + assert not flags & _DEFBUTTON2, hex(flags) + assert flags & msgbox._MB_OKCANCEL, hex(flags) + + +@case +def confirm_default_cancel_makes_cancel_the_default_button(): + _, flags = _capture( + lambda: msgbox.confirm("t", "m", 5, default_cancel=True)) + assert flags & _DEFBUTTON2, hex(flags) + # Still the same OK/Cancel warning box, on top of the browser. + assert flags & msgbox._MB_OKCANCEL, hex(flags) + assert flags & msgbox._MB_ICONWARN, hex(flags) + assert flags & msgbox._MB_TOPMOST, hex(flags) + + +@case +def ok_cancel_passes_default_cancel_through(): + _, flags = _capture(lambda: msgbox.ok_cancel("t", "m", default_cancel=True)) + assert flags & _DEFBUTTON2, hex(flags) + + +@case +def ok_cancel_default_is_unchanged_for_existing_callers(): + """captcha.py and login.py call ok_cancel(title, msg) - no Enter change.""" + _, flags = _capture(lambda: msgbox.ok_cancel("t", "m")) + assert not flags & _DEFBUTTON2, hex(flags) + + +@case +def ok_cancel_default_cancel_still_true_only_on_ok(): + """The default button changes; what each answer means does not.""" + ok, _ = _capture(lambda: msgbox.ok_cancel("t", "m", default_cancel=True), + rc=_IDOK) + assert ok is True, ok + cancel, _ = _capture( + lambda: msgbox.ok_cancel("t", "m", default_cancel=True), rc=_IDCANCEL) + assert cancel is False, cancel + timed_out, _ = _capture( + lambda: msgbox.ok_cancel("t", "m", default_cancel=True), + rc=msgbox._IDTIMEOUT) + assert timed_out is False, timed_out + + +@case +def info_box_never_gets_defbutton2(): + _, flags = _capture(lambda: msgbox.info("t", "m", 5), rc=_IDOK) + assert not flags & _DEFBUTTON2, hex(flags) + + +# ── [TEST] title prefix (review item D9, 2026-10-05) ───────────────────────── +# A TEST_CLAIM_IDS_FILE run drives live eCW too, so every box it shows says +# so in its title. Applied in the raw info() / confirm() (and so in the +# info_box / ok_cancel wrappers built on them): the 2FA manual box, "Run the +# Lookup?" and the export report box call the raw ones directly, and the 2FA +# box is reached at login in a test run (review fix round 1, 2026-10-05). + +def _captions(fn, test_file, rc=_IDOK): + seen = [] + orig_raw, orig_file = msgbox._raw_call, msgbox.cfg.TEST_CLAIM_IDS_FILE + msgbox._raw_call = lambda text, caption, flags, timeout_s: ( + seen.append(caption) or rc) + msgbox.cfg.TEST_CLAIM_IDS_FILE = test_file + try: + fn() + finally: + msgbox._raw_call = orig_raw + msgbox.cfg.TEST_CLAIM_IDS_FILE = orig_file + return seen + + +@case +def info_box_title_says_test_in_test_mode(): + seen = _captions(lambda: msgbox.info_box("eCW Bot — Claim 1", "m"), + "C:/x/Standard Claims.xlsx") + assert seen == ["[TEST] eCW Bot — Claim 1"], seen + + +@case +def ok_cancel_title_says_test_in_test_mode(): + seen = _captions(lambda: msgbox.ok_cancel("eCW Bot — Save?", "m", + default_cancel=True), + "C:/x/Standard Claims.xlsx", rc=_IDCANCEL) + assert seen == ["[TEST] eCW Bot — Save?"], seen + + +@case +def live_run_titles_are_unchanged(): + seen = _captions(lambda: (msgbox.info_box("A", "m"), + msgbox.ok_cancel("B", "m")), "") + assert seen == ["A", "B"], seen + + +@case +def raw_confirm_title_says_test_in_test_mode(): + """auth/mailbox.py's 2FA manual box calls msgbox.confirm directly.""" + seen = _captions(lambda: msgbox.confirm("eCW Bot — 2FA (Salud)", "m", 5), + "C:/x/Standard Claims.xlsx", rc=_IDCANCEL) + assert seen == ["[TEST] eCW Bot — 2FA (Salud)"], seen + + +@case +def raw_info_title_says_test_in_test_mode(): + """pages/claim_report.py's export report box calls msgbox.info directly.""" + seen = _captions(lambda: msgbox.info("eCW Bot — EXCEL report", "m", 5), + "C:/x/Standard Claims.xlsx") + assert seen == ["[TEST] eCW Bot — EXCEL report"], seen + + +@case +def raw_boxes_are_unchanged_on_a_live_run(): + seen = _captions(lambda: (msgbox.info("A", "m", 5), + msgbox.confirm("B", "m", 5)), "") + assert seen == ["A", "B"], seen + + +@case +def test_prefix_is_never_doubled(): + # The wrappers apply it and so do the raw functions under them. + seen = _captions(lambda: (msgbox.info_box("[TEST] A", "m"), + msgbox.info_box("B", "m"), + msgbox.ok_cancel("C", "m")), "x.xlsx") + assert seen == ["[TEST] A", "[TEST] B", "[TEST] C"], seen + + +@case +def quiet_boxes_send_an_info_box_to_the_log_not_the_screen(): + import io + import contextlib + shown = [] + # TEST_CLAIM_IDS_FILE cleared: the operator's .env sets it, and then + # titled() puts "[TEST] " in front of the title. + orig = (msgbox._raw_call, msgbox.cfg.QUIET_BOXES, + msgbox.cfg.TEST_CLAIM_IDS_FILE) + msgbox._raw_call = lambda *a: shown.append(a) or 1 + msgbox.cfg.QUIET_BOXES = True + msgbox.cfg.TEST_CLAIM_IDS_FILE = "" + out = io.StringIO() + try: + with contextlib.redirect_stdout(out): + msgbox.info_box("eCW Bot — Claim 1", "Summary : Pass\nmore") + rc = msgbox.info("eCW Bot — CSV report", "Saved.", 5) + finally: + (msgbox._raw_call, msgbox.cfg.QUIET_BOXES, + msgbox.cfg.TEST_CLAIM_IDS_FILE) = orig + assert shown == [], shown + assert rc == msgbox.OK + text = out.getvalue() + assert "[BOX] eCW Bot — Claim 1: Summary : Pass" in text, text + assert "more" not in text, text + + +@case +def quiet_boxes_still_show_a_box_that_needs_a_person(): + shown = [] + orig = (msgbox._raw_call, msgbox.cfg.QUIET_BOXES) + msgbox._raw_call = lambda *a: shown.append(a) or 1 + msgbox.cfg.QUIET_BOXES = True + try: + msgbox.info_box("eCW Bot — Security Image", "Click Continue", + needs_person=True) + msgbox.confirm("eCW Bot — CAPTCHA", "Type it", 5) + finally: + msgbox._raw_call, msgbox.cfg.QUIET_BOXES = orig + assert len(shown) == 2, shown + + +@case +def without_quiet_boxes_an_info_box_is_shown(): + shown = [] + orig = (msgbox._raw_call, msgbox.cfg.QUIET_BOXES) + msgbox._raw_call = lambda *a: shown.append(a) or 1 + msgbox.cfg.QUIET_BOXES = False + try: + msgbox.info_box("eCW Bot — Claim 1", "Summary : Pass") + finally: + msgbox._raw_call, msgbox.cfg.QUIET_BOXES = orig + assert len(shown) == 1, shown + + +def main() -> int: + failures = 0 + for fn in CASES: + name = fn.__name__.replace("_", " ") + try: + fn() + print(f" ok {name}") + except AssertionError as exc: + print(f" FAIL {name}: {exc}") + failures += 1 + except Exception as exc: + print(f" ERROR {name}: {type(exc).__name__}: {exc}") + failures += 1 + print(f"\n{len(CASES) - failures}/{len(CASES)} passed") + return 1 if failures else 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tests/test_open_claim_recovery.py b/tests/test_open_claim_recovery.py new file mode 100644 index 0000000..e663f02 --- /dev/null +++ b/tests/test_open_claim_recovery.py @@ -0,0 +1,657 @@ +r""" +open_claim() recovering from eCW's own stray "Account Inquiry" panel. + + python tests\test_open_claim_recovery.py + +Seen live (claim 9128000, Logs\run_20260915_195515.log): eCW opens its own +modal-style panel over the results grid — +`id="claimModal" class="modal fade bluetheme billing-width +right-panel-mode in"`, with a `claimModalBackdrop`-classed backdrop div that +covers the full page — and it intercepts the click meant for the claim +number underneath. Playwright's own retry trace named this element; nothing +in this codebase opens it on purpose. `open_claim()` retried the blocked +click for the full 30s timeout and gave up before ever reaching the claim +form, so Condition 1 (or any future condition) never got a chance to run. + +This is a navigation problem, not a telehealth-detection problem — it +blocks every claim, not just telehealth ones — which is why it is tested +here rather than in test_telehealth_fix.py. + +Local headless chromium only, exactly like the other page tests: no proxy, +no login, no eCW. Everything is built with `page.set_content()` rather than +a fixture file, the same pattern test_telehealth_fix.py already uses for its +HCFA-fallback cases — this scenario is about a page state (a stray backdrop +sitting over a claim link) that has nothing to do with the UB form fixture. +""" + +import sys +import pathlib +import inspect +import tempfile + +ROOT = pathlib.Path(__file__).resolve().parent.parent +sys.path.insert(0, str(ROOT / "src")) + +from ecw_bot.pages import claim_form # noqa: E402 +from ecw_bot.selectors import claim_form as sel # noqa: E402 +from ecw_bot.core import page_utils # noqa: E402 +from ecw_bot import settings as cfg # noqa: E402 + +CASES = [] + + +def case(fn): + CASES.append(fn) + return fn + + +# A backdrop shaped like eCW's real one: covers the whole viewport, so a real +# (non-forced) Playwright click on anything underneath is genuinely blocked — +# this is what makes the test prove the fix rather than assume it. +_BACKDROP = ( + '' +) + +_RESULTS_GRID = ( + '9128000' + '' + '' +) + + +@case +def dismiss_stray_modal_returns_false_when_nothing_is_there(page): + page.set_content(_RESULTS_GRID) + assert claim_form.dismiss_stray_modal(page) is False + + +@case +def dismiss_stray_modal_removes_a_visible_backdrop(page): + page.set_content(_BACKDROP + _RESULTS_GRID) + assert page.locator(sel.SEL_STRAY_MODAL_BACKDROP).first.is_visible() + assert claim_form.dismiss_stray_modal(page) is True + assert page.locator(sel.SEL_STRAY_MODAL_BACKDROP).count() == 0 + + +@case +def open_claim_succeeds_normally_with_no_stray_modal(page): + """Regression check: the new dismiss step must not change the happy path.""" + page.set_content(_RESULTS_GRID) + assert claim_form.open_claim(page, "9128000") is True + assert page.locator("#UBClaimDiv").is_visible() + + +_DENTAL_GRID = ( + '9125000' + '' + '' +) + + +def _open_and_capture(page, claim_id): + import io, contextlib + buf = io.StringIO() + with contextlib.redirect_stdout(buf): + assert claim_form.open_claim(page, claim_id) is True + return buf.getvalue() + + +@case +def open_claim_logs_a_dental_claim_as_dental_not_hcfa(page): + """Live run 2026-09-30: every Dental claim logged "(HCFA/professional + screen)" because the only check was "not UB".""" + page.set_content(_DENTAL_GRID) + out = _open_and_capture(page, "9125000") + assert "Claim form open (Dental screen)" in out, out + assert "HCFA" not in out, out + + +@case +def open_claim_dismisses_a_stray_modal_that_would_otherwise_block_the_click(page): + """ + The exact failure from run_20260915_195515.log, reproduced: a backdrop + already covers the claim link before open_claim() is ever called. Without + the proactive dismiss, this click blocks for the full timeout and raises + — this proves it doesn't, not just that the function returns something. + """ + page.set_content(_BACKDROP + _RESULTS_GRID) + assert claim_form.open_claim(page, "9128000") is True + assert page.locator("#UBClaimDiv").is_visible() + assert page.locator(sel.SEL_STRAY_MODAL_BACKDROP).count() == 0 + + +# ── close_claim() recovering from the same stray backdrop ──────────────────── +# The exact shape of the claim 9127000 failure (Logs\run_20260917_172622.log): +# Cancel, X, then Cancel again all timed out trying to close the form. Nothing +# in that log names what blocked the clicks, but it is the same failure mode +# open_claim() already had — a stray overlay sitting on top of the button — +# and dismiss_stray_modal() was never wired into close_claim()'s retry loop. +_UB_FORM_OPEN = ( + '
' + '
Claim
' + ' ' + ' ' + '
' + '' +) + + +@case +def close_claim_succeeds_normally_with_no_stray_modal(page): + """Regression check: the new dismiss step must not change the happy path.""" + page.set_content(_UB_FORM_OPEN) + assert claim_form.close_claim(page) is True + assert page.locator("#UBClaimDiv").first.is_visible() is False + + +@case +def close_claim_dismisses_a_stray_modal_that_would_otherwise_block_cancel(page): + """ + Without the proactive dismiss, this click blocks for the full attempt + chain (Cancel, X, HCFA Cancel — none of them present or clickable under + the backdrop) and close_claim() gives up and returns False. This proves + it doesn't, not just that the function returns something. + """ + page.set_content(_BACKDROP + _UB_FORM_OPEN) + assert claim_form.close_claim(page) is True + assert page.locator("#UBClaimDiv").first.is_visible() is False + assert page.locator(sel.SEL_STRAY_MODAL_BACKDROP).count() == 0 + + +# ── close_claim() when the operator already closed the form by hand ───────── +# Seen live on claim 9123000 (Logs\run_20260923_165056.log): the operator +# clicked eCW's own Cancel while the review box was up, and close_claim() +# then burned ~45s on four click attempts at buttons that no longer existed, +# ending in "Could not close the claim form" for a form that was closed. +_UB_FORM_ALREADY_CLOSED = ( + '' +) + + +@case +def close_claim_returns_at_once_when_the_form_was_already_closed_by_hand(page): + import time + page.set_content(_UB_FORM_ALREADY_CLOSED) + started = time.monotonic() + assert claim_form.close_claim(page) is True + elapsed = time.monotonic() - started + assert elapsed < 3, f"took {elapsed:.1f}s — still trying to click Cancel" + + +@case +def close_claim_still_closes_an_hcfa_form_behind_a_hidden_leftover_ub_form(page): + """ + The already-closed check must look at every screen's marker, not only + the first match in the page: a hidden UB form left in the DOM ahead of + an open HCFA form must not make it look like nothing is open. + """ + page.set_content( + _UB_FORM_ALREADY_CLOSED + + '
') + assert claim_form.close_claim(page) is True + assert page.locator("#hcfa").is_visible() is False, \ + "the open HCFA form was skipped instead of closed" + + +# A permanent overlay with NO known class (unlike .claimModalBackdrop) — +# dismiss_stray_modal() cannot recognize or remove this one, so every click +# attempt on claimLink genuinely fails, for the whole test. The form opens +# underneath it regardless, exactly like the "Follow up" panel seen live on +# claim 9129000 (Logs\run_20260916_162654.log) sitting beside an open, fully +# populated UB form. +# +# It opens a second after load, while the first click is still being +# blocked — not before open_claim() is called: a form already on screen +# before the click is a LEFTOVER from an earlier claim, which open_claim() +# now Cancels first (final review, 2026-10-05; see the leftover cases below). +_ALREADY_OPEN_WITH_UNKNOWN_OVERLAY = ( + '9128000' + '' + '
' + '
' + '' +) + + +@case +def open_claim_treats_the_form_as_open_even_when_the_click_keeps_failing(page): + """ + The exact shape of the claim 9129000 failure: a stray overlay this + codebase has no selector for at all blocks every click attempt, but the + claim form is already open and populated underneath it regardless. + open_claim() must trust the form's own presence over a reported click + failure, rather than raising when the claim actually did open. + """ + page.set_content(_ALREADY_OPEN_WITH_UNKNOWN_OVERLAY) + assert claim_form.open_claim(page, "9128000") is True + + +# Same unknown overlay, but the form is NOT open yet when the click gives up: +# it finishes opening about a second later (6s after load; the first click +# now gives up at 5s). Review item D11, 2026-10-01: the fallback used to be +# `visible(timeout=1_000)`, which Playwright 1.62 answers instantly — so a +# form still opening was missed, and attempt 2's Escape closed it (on HCFA +# without closeClaimScreen()). Escapes are counted so the test can tell. +_OPENS_LATE_UNDER_UNKNOWN_OVERLAY = ( + '9128000' + '' + '
' + '
' + '' +) + + +@case +def open_claim_fallback_waits_for_a_form_still_opening(page): + """ + The fallback is `appears(..., 5_000)`, a real wait, and the first click + gives up at 5s, not 30s. With the old 30s click this takes 30s+; with the + old instant check it Escapes and spends another 15s on attempt 2 — both + fail the timing and Escape assertions below. + """ + import time + page.set_content(_OPENS_LATE_UNDER_UNKNOWN_OVERLAY) + started = time.monotonic() + out = _open_and_capture(page, "9128000") + took = time.monotonic() - started + assert took < 12, f"took {took:.1f}s" + assert page.evaluate("window.escapes") == 0, \ + "attempt 2 ran — the fallback did not wait for the form" + # The click's own first line and its elapsed seconds, for the root-cause + # study of why eCW's click reports failure on a form that opened. + assert "Click reported failure, but the claim form is already open" \ + in out, out + assert "(click: " in out and "failed after 5." in out \ + and "s later)" in out, out + + +class _ClickRaisesBlank: + """ + The real page, except the claim link's click raises an exception with + an EMPTY message. `str(exc).splitlines()[0]` is an IndexError on that — + raised inside the except block, out of open_claim(), losing a claim + whose form was open. Playwright's own errors always carry a message; + this proves the log line does not depend on it. + """ + + def __init__(self, page): + self._page = page + + def __getattr__(self, name): + return getattr(self._page, name) + + def get_by_text(self, *a, **k): + class _Link: + @property + def first(self): + return self + + def click(self, **kw): + raise Exception() + return _Link() + + +@case +def open_claim_survives_a_click_error_with_no_message(page): + page.set_content(_ALREADY_OPEN_WITH_UNKNOWN_OVERLAY) + out = _open_and_capture(_ClickRaisesBlank(page), "9128000") + assert "already open" in out and "(click: Exception;" in out, out + + +# ── A form left open by an earlier claim ───────────────────────────────────── +# Final review, 2026-10-05: when the previous claim's close_claim() gave up, +# its form was still on screen at the next open_claim(), and the "click +# reported failure, but the claim form is already open" fallback accepted it +# as the new claim's. On an HCFA screen with no claim-number field nothing +# checks identity after that, so the conditions ran — and were logged — on +# the wrong claim. open_claim() now Cancels a leftover form before its click +# and refuses to go on over one that will not close. + +# An earlier claim's UB form still up (claim 9112000); its Cancel works. The +# link opens this claim's form and fills in its number. +_LEFTOVER_UB_THAT_CLOSES = ( + '9128000' + '
' + '
9112000
' + '
Claim
' + ' ' + '
' + '' +) + + +@case +def open_claim_cancels_a_form_left_open_by_an_earlier_claim_first(page): + page.set_content(_LEFTOVER_UB_THAT_CLOSES) + out = _open_and_capture(page, "9128000") + assert "already open — left from an earlier claim" in out, out + assert page.evaluate("window.cancels") == 1, \ + "the leftover form was not Cancelled before the click" + assert page.evaluate("window.linkClicks") == 1 + assert page.locator("#claimNoText").inner_text() == "9128000" + + +# An earlier claim's HCFA screen (no claim-number field) still up, and the +# claim link is blocked by an overlay nothing can dismiss — the exact shape +# where the fallback used to take the stale form for this claim's. +_LEFTOVER_HCFA_UNDER_OVERLAY = ( + '9128000' + '
' + '
' + '
' + '' +) + + +@case +def open_claim_refuses_to_go_on_over_a_leftover_form_that_will_not_close(page): + """ + close_claim() gives up (stood in for: its four real attempts take the + best part of two minutes). Without the guard the blocked click's fallback + sees the HCFA screen and returns True — the stale form, read as 9128000. + """ + page.set_content(_LEFTOVER_HCFA_UNDER_OVERLAY) + orig = claim_form.close_claim + claim_form.close_claim = lambda p: False + try: + try: + _open_and_capture(page, "9128000") + except RuntimeError as exc: + assert "still open and would not close" in str(exc), exc + else: + raise AssertionError("open_claim() accepted the leftover form") + finally: + claim_form.close_claim = orig + + +@case +def open_claim_does_not_trust_a_cancel_that_left_an_hcfa_form_up(page): + """ + close_claim()'s own "hidden" check is on the UB form's div, which the + HCFA screen does not have, so it answers True while this Cancel (one + that does nothing) leaves the screen up. open_claim() checks every + screen itself before it clicks. + """ + page.set_content(_LEFTOVER_HCFA_UNDER_OVERLAY.replace( + 'z-index:2000;', 'z-index:-1;')) + try: + _open_and_capture(page, "9128000") + except RuntimeError as exc: + assert "still open and would not close" in str(exc), exc + else: + raise AssertionError("open_claim() accepted the leftover form") + assert page.evaluate("window.cancels") >= 1 + + +# A hidden leftover backdrop FIRST in the DOM, the live panel second; Escape +# starts the live one's close, which takes 1s (an animation). Final review, +# 2026-10-05: the check counts any VISIBLE match, but the wait was on +# `.first` — the hidden copy — so it returned at once, with the panel still +# up over the grid. +_HIDDEN_THEN_LIVE_BACKDROP = ( + '' + '' + '' +) + + +@case +def dismiss_stray_modal_waits_for_the_visible_backdrop_not_a_hidden_copy(page): + page.set_content(_HIDDEN_THEN_LIVE_BACKDROP) + assert claim_form.dismiss_stray_modal(page) is True + assert page.locator(".claimModalBackdrop").filter( + visible=True).count() == 0, \ + "returned while the live panel was still up" + + +# ── The stranded scroll lock ────────────────────────────────────────────────── +# Captured live on the Claim Lookup screen: `` with no +# `.modal-backdrop` in the document and every `.modal` still `fade` without +# `in`. Bootstrap's own stylesheet hangs `overflow: hidden` off that class, so +# the page cannot scroll and there is no dialog on screen to explain it. +# +# The stylesheet is real here rather than mocked: without `body.modal-open +# {overflow:hidden}` actually loaded, the assertions below would pass against a +# body that was never locked in the first place and prove nothing. +_LOCK_CSS = ( + "" +) + + +def _doc(body_class, inner): + return (f"{_LOCK_CSS}" + f'{inner}' + '
tall enough to need scrolling
' + "") + + +_CLOSED_DIALOG = '' +_OPEN_DIALOG = '' + + +def _overflow(page): + return page.evaluate("getComputedStyle(document.body).overflow") + + +@case +def clear_stale_modal_lock_is_false_when_body_was_never_locked(page): + page.set_content(_doc("", _CLOSED_DIALOG)) + assert claim_form.clear_stale_modal_lock(page) is False + + +@case +def clear_stale_modal_lock_unlocks_a_body_with_no_modal_showing(page): + """The exact captured state: modal-open, no backdrop, nothing rendered.""" + page.set_content(_doc("modal-open", _CLOSED_DIALOG)) + assert _overflow(page) == "hidden", "fixture did not actually lock the body" + + assert claim_form.clear_stale_modal_lock(page) is True + + assert _overflow(page) != "hidden", "body is still scroll-locked" + assert page.evaluate( + "document.body.classList.contains('modal-open')") is False + + +@case +def clear_stale_modal_lock_leaves_a_genuinely_open_modal_alone(page): + """ + The guard that makes this safe to call unconditionally. A modal that is + really on screen owns the lock legitimately — stripping it would let the + page scroll around behind a live dialog, which is worse than the bug. + """ + page.set_content(_doc("modal-open", _OPEN_DIALOG)) + assert claim_form.clear_stale_modal_lock(page) is False + assert _overflow(page) == "hidden", "a live dialog's lock was stripped" + assert page.evaluate( + "document.body.classList.contains('modal-open')") is True + + +@case +def clear_stale_modal_lock_also_removes_orphaned_backdrops(page): + page.set_content(_doc( + "modal-open", + _CLOSED_DIALOG + '' + '')) + assert claim_form.clear_stale_modal_lock(page) is True + assert page.locator(".modal-backdrop").count() == 0 + + +@case +def dismiss_stray_modal_clears_a_lock_left_behind_with_no_backdrop(page): + """ + Why the lock check cannot be gated on a backdrop being present: there is no + `.claimModalBackdrop` here at all, so the original backdrop-only path bails + out at the first line and reports "nothing to do" over a body that cannot + scroll. + """ + page.set_content(_doc("modal-open", _CLOSED_DIALOG)) + assert claim_form.dismiss_stray_modal(page) is True + assert _overflow(page) != "hidden", "body is still scroll-locked" + + +@case +def dismiss_stray_modal_handles_a_backdrop_and_a_lock_together(page): + """ + Escape closing the panel does not, on its own, put the class back — so the + lock check runs after the Escape rather than instead of it. + """ + # _RESULTS_GRID carries the keydown handler that makes Escape actually + # remove the backdrop, the same way the real panel responds to it. + page.set_content(_doc("modal-open", + _BACKDROP + _RESULTS_GRID + _CLOSED_DIALOG)) + assert claim_form.dismiss_stray_modal(page) is True + assert page.locator(sel.SEL_STRAY_MODAL_BACKDROP).count() == 0, "Escape should have removed the stray backdrop" + assert _overflow(page) != "hidden", "backdrop went but the scroll lock stayed behind" + + +@case +def reset_scroll_names_the_inner_container_that_actually_scrolled(page): + """ + `window.scrollTo(0, 0)` is a no-op when the document is not the scroller — + the whole reason reset_scroll sweeps for the real container and reports it. + """ + page.set_content( + '
' + '
rows
') + page.evaluate("document.getElementById('grid').scrollTop = 400") + + moved = page_utils.reset_scroll(page) + + assert any("#grid" in m for m in moved), moved + assert page.evaluate("document.getElementById('grid').scrollTop") == 0 + + +def main() -> int: + try: + from playwright.sync_api import sync_playwright + except ImportError: + print(" SKIP playwright is not installed") + return 0 + + failures = 0 + # The leftover-form cases take a screenshot on their raise path: into a + # temp folder, never the operators' real dated Screenshots folder. + orig_shots = cfg.SCREENSHOTS + with tempfile.TemporaryDirectory() as tmp, sync_playwright() as pw: + cfg.SCREENSHOTS = pathlib.Path(tmp) + browser = pw.chromium.launch(headless=True) + page = browser.new_page() + + for fn in CASES: + name = fn.__name__.replace("_", " ") + try: + if len(inspect.signature(fn).parameters) == 0: + fn() + else: + fn(page) + print(f" ok {name}") + except AssertionError as exc: + print(f" FAIL {name}: {exc}") + failures += 1 + except Exception as exc: + print(f" ERROR {name}: {type(exc).__name__}: {exc}") + failures += 1 + browser.close() + cfg.SCREENSHOTS = orig_shots + + print(f"\n{len(CASES) - failures}/{len(CASES)} passed") + return 1 if failures else 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tests/test_page_utils.py b/tests/test_page_utils.py new file mode 100644 index 0000000..0dc3aff --- /dev/null +++ b/tests/test_page_utils.py @@ -0,0 +1,176 @@ +r""" +`visible()` and `appears()` — the instant check and the real wait. + + python tests\test_page_utils.py + +Review item P8 (2026-10-01): Playwright 1.62's `is_visible(timeout=...)` +ignores its timeout, so `visible(page, sel, timeout=10_000)` was always an +instant check, and every caller that meant "give it ten seconds" got none. +And `.first` answered for whichever match came first in the DOM — a hidden +leftover copy made the live element read as absent. + +Local headless chromium, `page.set_content()` only. No eCW. +""" + +import sys +import time +import pathlib +import inspect + +ROOT = pathlib.Path(__file__).resolve().parent.parent +sys.path.insert(0, str(ROOT / "src")) + +from ecw_bot.core import page_utils # noqa: E402 + +CASES = [] + + +def case(fn): + CASES.append(fn) + return fn + + +# Two matches for one selector: a hidden leftover first, the live one second — +# the shape eCW leaves behind when it renders an id twice. +_HIDDEN_THEN_SHOWN = ( + '' + '' +) + + +@case +def visible_is_true_when_the_second_of_two_matches_is_the_visible_one(page): + page.set_content(_HIDDEN_THEN_SHOWN) + # The old `.first.is_visible()` answered for the hidden copy. + assert page.locator(".b").first.is_visible() is False, \ + "fixture no longer puts the hidden copy first" + assert page_utils.visible(page, ".b") is True + + +@case +def visible_is_false_when_every_match_is_hidden(page): + page.set_content('' + '') + assert page_utils.visible(page, ".x") is False + + +@case +def visible_is_instant_and_ignores_its_timeout(page): + """The documented contract: `timeout` is accepted and ignored.""" + page.set_content("
") + started = time.monotonic() + assert page_utils.visible(page, "#never", timeout=5_000) is False + assert time.monotonic() - started < 1.0, "visible() waited" + + +@case +def visible_never_raises_on_a_bad_selector(page): + page.set_content("
") + assert page_utils.visible(page, "[[[not a selector") is False + + +@case +def appears_waits_for_an_element_shown_after_a_delay(page): + page.set_content( + '' + '') + # The instant check misses it — the bug P8 is about. + assert page_utils.visible(page, "#late", timeout=5_000) is False + started = time.monotonic() + assert page_utils.appears(page, "#late", timeout=5_000) is True + took = time.monotonic() - started + assert 0.5 < took < 4.0, f"took {took:.1f}s" + + +@case +def appears_returns_false_at_its_timeout(page): + page.set_content('') + started = time.monotonic() + assert page_utils.appears(page, "#gone", timeout=600) is False + took = time.monotonic() - started + assert 0.5 < took < 3.0, f"took {took:.1f}s" + + +@case +def appears_skips_a_hidden_leftover_ahead_of_the_live_element(page): + """`.first` alone would wait on the hidden copy and time out.""" + page.set_content(_HIDDEN_THEN_SHOWN) + started = time.monotonic() + assert page_utils.appears(page, ".b", timeout=2_000) is True + assert time.monotonic() - started < 1.0 + + +# ── The callers that mean "wait" (final review, 2026-10-05) ───────────────── +# Each of these was `visible(timeout=...)` before P8 and answered instantly. +# A test per caller that fails if it is turned back into visible(). + +_LATE_SUBMENU = ( + '' + '' + '' +) + + +@case +def a_report_submenu_slower_than_the_hover_pause_is_not_clicked(page): + """ + `_open_report_submenu` hovers, pauses 600 ms, then gives the submenu + 1.5s to appear before falling back to a click — the click its docstring + warns can close the parent dropdown instead. Here the submenu takes + 1s: with an instant visible() the 1.5s was never waited and the click + fired. + """ + import io + import contextlib + from ecw_bot.pages import claim_report + page.set_content(_LATE_SUBMENU) + out = io.StringIO() + with contextlib.redirect_stdout(out): + claim_report._open_report_submenu(page) + assert "Report submenu opened (hover)" in out.getvalue(), out.getvalue() + assert page.evaluate("window.clicked") is False, "fell back to the click" + + +def main() -> int: + try: + from playwright.sync_api import sync_playwright + except ImportError: + print(" SKIP playwright is not installed") + return 0 + + failures = 0 + with sync_playwright() as pw: + browser = pw.chromium.launch(headless=True) + page = browser.new_page() + for fn in CASES: + name = fn.__name__.replace("_", " ") + try: + if len(inspect.signature(fn).parameters) == 0: + fn() + else: + fn(page) + print(f" ok {name}") + except AssertionError as exc: + print(f" FAIL {name}: {exc}") + failures += 1 + except Exception as exc: + print(f" ERROR {name}: {type(exc).__name__}: {exc}") + failures += 1 + browser.close() + + print(f"\n{len(CASES) - failures}/{len(CASES)} passed") + return 1 if failures else 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tests/test_password_entry.py b/tests/test_password_entry.py new file mode 100644 index 0000000..30803fd --- /dev/null +++ b/tests/test_password_entry.py @@ -0,0 +1,88 @@ +r""" +auth/password_entry.py — the eCW password is TYPED key by key, never set +with fill() (2026-10-20: a filled password left Log In doing nothing). + + python tests\test_password_entry.py +""" + +import re +import sys +import inspect +import pathlib + +ROOT = pathlib.Path(__file__).resolve().parent.parent +sys.path.insert(0, str(ROOT / "src")) + +from ecw_bot.auth import password_entry # noqa: E402 + +LOGIN_PAGE = """ + + + +""" + +FAKE = "Fake#Pass 123!" # a test value, not a credential + +CASES = [] + + +def case(fn): + CASES.append(fn) + return fn + + +@case +def it_types_one_key_event_per_character(page): + page.set_content(LOGIN_PAGE) + password_entry.type_password(page, FAKE) + assert page.input_value("#passwordField") == FAKE + # At least one keydown per character (Shift may add its own). + assert page.evaluate("window.keys") >= len(FAKE) + + +@case +def whatever_was_in_the_field_is_replaced(page): + page.set_content(LOGIN_PAGE) + password_entry.type_password(page, "abc") + password_entry.type_password(page, "xyz") + assert page.input_value("#passwordField") == "xyz" + + +@case +def login_and_captcha_never_fill_the_password_field(page): + for path in ("src/ecw_bot/auth/login.py", "src/ecw_bot/auth/captcha.py"): + text = (ROOT / path).read_text(encoding="utf-8") + assert not re.search(r"\.fill\(\s*sel\.SEL_PASSWORD", text), path + assert "type_password(" in text, path + + +def main() -> int: + from playwright.sync_api import sync_playwright + + failures = 0 + with sync_playwright() as pw: + browser = pw.chromium.launch(headless=True) + page = browser.new_page() + for fn in CASES: + name = fn.__name__.replace("_", " ") + try: + fn(page) + print(f" ok {name}") + except AssertionError as exc: + print(f" FAIL {name}: {exc}") + failures += 1 + except Exception as exc: + print(f" ERROR {name}: {type(exc).__name__}: {exc}") + failures += 1 + browser.close() + + print(f"\n{len(CASES) - failures}/{len(CASES)} passed") + return 1 if failures else 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tests/test_password_expiry_prompt.py b/tests/test_password_expiry_prompt.py new file mode 100644 index 0000000..dd53ed0 --- /dev/null +++ b/tests/test_password_expiry_prompt.py @@ -0,0 +1,198 @@ +r""" +eCW's post-login "This password expires in N days" box — answered with +Cancel, never OK, so the login reaches the home screen. + + python tests\test_password_expiry_prompt.py + +Seen live 2026-10-05: both runs that day got through the 2FA approval and +then stopped on this box (Screenshots\10-05-2026\*_login_unconfirmed.png), +reporting "Login not confirmed" after two minutes. The box's real DOM was +not captured, so the fixtures below are made-up markup with eCW's wording; +the code finds the box by its text, which is what these pin. + +Local headless chromium, `page.set_content()` only. No eCW, no login. +""" + +import io +import sys +import pathlib +import tempfile +import contextlib + +ROOT = pathlib.Path(__file__).resolve().parent.parent +sys.path.insert(0, str(ROOT / "src")) + +from ecw_bot import settings as cfg # noqa: E402 +from ecw_bot.auth import login # noqa: E402 +from ecw_bot.selectors import login as sel # noqa: E402 + +CASES = [] + + +def case(fn): + CASES.append(fn) + return fn + + +# The box over a page whose home link only appears once the box is gone — +# the shape seen live. OK sets a flag (it must never be clicked); the page +# also has an unrelated Cancel button outside the box. +_PROMPT = """ + + + + +""" + +_INPUT_BUTTONS = """ + + +
This password expires in 3 days. Do you want to + change the password now?
+
+ +""" + +_EXPIRED = """ +
Your password has expired. Please set a new password.
+ +""" + +_NO_CANCEL = """ +
This password expires in 5 days.
+ + +""" + + +def _quiet(fn, *args): + out = io.StringIO() + with contextlib.redirect_stdout(out): + result = fn(*args) + return result, out.getvalue() + + +@case +def a_late_prompt_is_cancelled_and_the_home_screen_is_reached(page): + page.set_content(_PROMPT % 700) + ok, log = _quiet(login.wait_for_home, page, 8_000) + assert ok is True, log + assert page.evaluate("window.okClicked") is False, "OK was clicked" + assert page.evaluate("window.otherCancel") is False, \ + "clicked a Cancel outside the box" + assert "expires in 14 day(s)" in log, log + assert "Answered the password-expiry box with Cancel" in log, log + + +@case +def input_buttons_work_too(page): + page.set_content(_INPUT_BUTTONS) + ok, log = _quiet(login.wait_for_home, page, 5_000) + assert ok is True, log + assert page.evaluate("window.okClicked") is False + + +@case +def no_prompt_means_nothing_is_clicked(page): + page.set_content('Home' + '' + '') + result, _ = _quiet(login.dismiss_password_expiry_prompt, page) + assert result is None + ok, _ = _quiet(login.wait_for_home, page, 2_000) + assert ok is True and page.evaluate("window.c") is False + + +@case +def a_hidden_prompt_is_ignored(page): + page.set_content(_PROMPT % 60_000) # never shown within the test + result, _ = _quiet(login.dismiss_password_expiry_prompt, page) + assert result is None + + +@case +def an_expired_password_stops_the_login_with_the_reason(page): + page.set_content(_EXPIRED) + try: + _quiet(login.dismiss_password_expiry_prompt, page) + except RuntimeError as exc: + assert "password has expired" in str(exc), exc + assert "never sets a password" in str(exc), exc + else: + raise AssertionError("an expired password did not raise") + + +@case +def a_prompt_without_cancel_is_reported_and_ok_is_never_clicked(page): + page.set_content(_NO_CANCEL) + ok, log = _quiet(login.wait_for_home, page, 1_500) + assert ok is False + assert page.evaluate("window.okClicked") is False, "OK was clicked" + assert log.count("no Cancel button") == 3, log # capped, not every poll + + +@case +def the_patterns_match_the_live_wording(): + import re + live = ("This password expires in 14 days. Do you want to change the " + "password now?") + m = re.search(sel.TXT_PASSWORD_EXPIRES, live, re.I) + assert m and m.group(1) == "14" + assert not re.search(sel.TXT_PASSWORD_EXPIRED, live, re.I) + + +def main() -> int: + try: + from playwright.sync_api import sync_playwright + except ImportError: + print(" SKIP playwright is not installed") + return 0 + + import inspect + failures = 0 + with tempfile.TemporaryDirectory() as tmp: + cfg.SCREENSHOTS = pathlib.Path(tmp) # never the real folder + with sync_playwright() as pw: + browser = pw.chromium.launch(headless=True) + page = browser.new_page() + for fn in CASES: + name = fn.__name__.replace("_", " ") + try: + if inspect.signature(fn).parameters: + fn(page) + else: + fn() + print(f" ok {name}") + except AssertionError as exc: + print(f" FAIL {name}: {exc}") + failures += 1 + except Exception as exc: + print(f" ERROR {name}: {type(exc).__name__}: {exc}") + failures += 1 + browser.close() + + print(f"\n{len(CASES) - failures}/{len(CASES)} passed") + return 1 if failures else 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tests/test_payer_groups.py b/tests/test_payer_groups.py new file mode 100644 index 0000000..0c31c5a --- /dev/null +++ b/tests/test_payer_groups.py @@ -0,0 +1,154 @@ +""" +Payer -> Group Name lookup. + + python tests\\test_payer_groups.py + +Exact-string match only, 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` all appear in one real export), so +"close enough" matching would have to guess which of several real, distinct +strings a new one is closest to. Billing data does not get a guess — an +unrecognized string must come back None and get a human's attention. +""" + +import sys +import json +import pathlib +import tempfile + +sys.path.insert(0, str(pathlib.Path(__file__).resolve().parent.parent / "src")) + +from ecw_bot.core import payer_groups # noqa: E402 + +TMP = pathlib.Path(tempfile.mkdtemp()) + +MAPPING = { + "Medicaid": "Medicaid", + "DentaQuest": "Dentaquest", + "Anthem Blue Cross Blue Shield": "Commercial", +} + + +def case_exact_match_returns_group(): + assert payer_groups.classify("Medicaid", MAPPING) == "Medicaid" + assert payer_groups.classify("DentaQuest", MAPPING) == "Dentaquest" + + +def case_unmapped_payer_returns_none(): + assert payer_groups.classify("Some New Payer LLC", MAPPING) is None + + +def case_case_sensitive_no_fuzzy_matching(): + """Different case is a different string, on purpose — see module docstring.""" + assert payer_groups.classify("medicaid", MAPPING) is None + assert payer_groups.classify("MEDICAID", MAPPING) is None + + +def case_blank_or_none_payer_is_unmapped(): + assert payer_groups.classify("", MAPPING) is None + assert payer_groups.classify(None, MAPPING) is None + + +def case_load_mapping_reads_json_file(): + path = TMP / "mapping.json" + path.write_text(json.dumps({"Medicaid": "Medicaid"}), encoding="utf-8") + loaded = payer_groups.load_mapping(path) + assert loaded == {"Medicaid": "Medicaid"}, loaded + + +def case_load_mapping_missing_file_raises(): + try: + payer_groups.load_mapping(TMP / "does_not_exist.json") + except FileNotFoundError as exc: + assert "does_not_exist.json" in str(exc), exc + return + raise AssertionError("expected FileNotFoundError") + + +def case_seed_config_loads_and_covers_medicaid_and_dentaquest(): + """ + The real config file this ships with, not a fixture. Since 2026-10-06 it + is the team's Claim Status Dashboard pairs (tools/import_payer_groups.py), + so its group names are the dashboard's own. + """ + mapping = payer_groups.load_mapping() + assert mapping.get("Medicaid") == "Medicaid", mapping.get("Medicaid") + assert mapping.get("DentaQuest") == "Dental - CHP+/DentaQuest", mapping.get("DentaQuest") + assert mapping.get("Child Health Plan Plus") == "CHP+" + + +RAE_PAYERS = [ + "BH COLORADO ACCESS", + "BH ROCKY MOUNTAIN HEALTH", + "BH ROCKY MTN HEALTH 2022", + "BH ROCK MTN UB", + "Carelon Behavioral Health, Inc", + "CCH CO COMMUNITY HEALTH ALLIANCE", + "HEALTH COLORADO INC", + "Northeast Colorado Health Department", + "Northeast Health Partners", +] + + +def case_seed_config_groups_the_nine_rae_payers_as_rae_payer(): + """ + The client's own RAE list (document condition #11) — all Medicaid + plans, grouped "RAE (Behavioral Health)" on the dashboard (the five the + dashboard spells differently are kept by the import), and "RAE payer" + for the conditions' rules. Plain "Medicaid" stays "Medicaid". + """ + mapping = payer_groups.load_mapping() + for payer in RAE_PAYERS: + assert payer_groups.classify(payer, mapping) == "RAE (Behavioral Health)", (payer, mapping.get(payer)) + assert payer_groups.rule_group(payer, mapping) == "RAE payer", payer + assert payer_groups.classify("Medicaid", mapping) == "Medicaid" + assert payer_groups.rule_group("Medicaid", mapping) == "Medicaid" + + +DASH = {"Anthem": "Commercial Ins", "Rocky Mountain Health Prime": + "Regional Accountable Entity-RAE (Reporting Group)", + "Outreach": "Self Pay", "Kip": "Payer Not In Group", + "CHP": "CHP+", "Old": "Commercial", + "AARP Medicare Advantage": "Commercial Ins"} + + +def case_rule_group_translates_dashboard_names_for_the_conditions(): + assert payer_groups.rule_group("Anthem", DASH) == "Commercial" + assert payer_groups.rule_group("Rocky Mountain Health Prime", DASH) == "RAE payer" + assert payer_groups.rule_group("Outreach", DASH) == "Program" + assert payer_groups.rule_group("Kip", DASH) is None + assert payer_groups.rule_group("CHP", DASH) == "CHP+" # passes through + assert payer_groups.rule_group("Old", DASH) == "Commercial" + assert payer_groups.rule_group("Unknown", DASH) is None + + +def case_medicare_advantage_follows_the_dashboard(): + """Operator ruling 2026-10-21: the dashboard's Commercial Ins, for every + condition — no override keeps it on Medicare any more.""" + assert payer_groups.classify("AARP Medicare Advantage", DASH) == "Commercial Ins" + assert payer_groups.rule_group("AARP Medicare Advantage", DASH) == "Commercial" + assert payer_groups.RULE_OVERRIDES == {} + + +CASES = [v for k, v in sorted(globals().items()) if k.startswith("case_")] + + +def main() -> int: + failures = 0 + for fn in CASES: + name = fn.__name__[5:].replace("_", " ") + try: + fn() + print(f" ok {name}") + except AssertionError as exc: + print(f" FAIL {name}: {exc}") + failures += 1 + except Exception as exc: + print(f" ERROR {name}: {type(exc).__name__}: {exc}") + failures += 1 + print(f"\n{len(CASES) - failures}/{len(CASES)} passed") + return 1 if failures else 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tests/test_portal_signin.py b/tests/test_portal_signin.py new file mode 100644 index 0000000..09bcde9 --- /dev/null +++ b/tests/test_portal_signin.py @@ -0,0 +1,315 @@ +r""" +The Portal's Sign-in, roles and Admin pages — `web/app.py` with +`web/users.py` (spec 2026-10-22 Stage 2, EEWA-32), against the in-memory +user store and the fake repository / runner (no SQL Server). + + python tests\test_portal_signin.py + + * Nothing but /signin without a Sign-in: a page goes to /signin (and + back after), /api answers 401. + * The Sign-in form needs this page's token and Origin; the cookie is + HttpOnly + SameSite=Strict; `next` never leaves the site. + * Wrong password / lock / disabled read as such; 30 minutes idle signs + out (and is recorded). + * A user an Admin made must change the password before anything else. + * Roles: a Viewer sees no Start / Stop and is refused them; only an + Admin reaches Portal Users and the Sign-in history. + * Started By / Stopped By reach the runner as the signed-in username. + * Admin changes take effect on the user's open session at once. +""" + +import sys +import pathlib +import tempfile +from datetime import timedelta + +ROOT = pathlib.Path(__file__).resolve().parent.parent +sys.path.insert(0, str(ROOT)) +sys.path.insert(0, str(ROOT / "src")) +sys.path.insert(0, str(ROOT / "tests")) + +from fastapi.testclient import TestClient # noqa: E402 + +from web import app as web_app # noqa: E402 +from web import users as portal_users # noqa: E402 +from test_portal_users import FakeUserStore, Clock # noqa: E402 +from test_web_app import FakeRepo, FakeRunner # noqa: E402 + +CASES = [] +BASE = "http://127.0.0.1:8000" +ORIGIN = {"Origin": BASE} +PW = "first-password-1" + + +def case(fn): + CASES.append(fn) + return fn + + +def _portal(runner=None, clock=None): + """(app, store, runner) with users asha (Admin), olu (Operator) and + vee (Viewer), all able to sign in straight away.""" + store = FakeUserStore() + acc = portal_users.Accounts(store) + for name, role in (("asha", "Admin"), ("olu", "Operator"), + ("vee", "Viewer")): + acc.create_user(name, name.title(), role, PW) + runner = runner or FakeRunner() + sessions = portal_users.Sessions(now=clock) if clock else None + app = web_app.create_app(FakeRepo(), runner=runner, users_store=store, + sessions=sessions, logs_dir=tempfile.mkdtemp()) + return app, store, runner + + +def _sign_in(app, name, password=PW, next_url="/days"): + client = TestClient(app, base_url=BASE) + r = client.post("/signin", headers=ORIGIN, data={ + "username": name, "password": password, + "token": app.state.run_token, "next": next_url}, + follow_redirects=False) + return client, r + + +def _form(app, **data): + return {"token": app.state.run_token, **data} + + +@case +def nothing_opens_without_a_sign_in(): + app, _, _ = _portal() + c = TestClient(app, base_url=BASE) + r = c.get("/day/2026-10-16?category=Fail", follow_redirects=False) + assert r.status_code == 303, r.status_code + assert r.headers["location"] == ("/signin?next=%2Fday%2F2026-10-16%3F" + "category%3DFail"), r.headers + for path in ("/api/runs", "/api/results", "/api/bot/status"): + assert c.get(path).status_code == 401, path + assert c.post("/api/bot/start", headers={**ORIGIN, "X-Run-Token": + app.state.run_token}).status_code == 401 + assert c.get("/signin").status_code == 200 + + +@case +def a_sign_in_goes_back_and_sets_a_strict_httponly_cookie(): + app, store, _ = _portal() + c, r = _sign_in(app, "ASHA", next_url="/day/2026-10-16") + assert r.status_code == 303 and r.headers["location"] == "/day/2026-10-16" + cookie = r.headers["set-cookie"].lower() + assert "httponly" in cookie and "samesite=strict" in cookie, cookie + assert c.get("/days").status_code == 200 + assert ("asha", "sign_in", "") in store.events + + +@case +def next_never_leaves_the_site(): + app, _, _ = _portal() + for bad in ("//evil.example/x", "https://evil.example", "/\\evil", + "javascript:alert(1)"): + _, r = _sign_in(app, "asha", next_url=bad) + assert r.headers["location"] == "/", (bad, r.headers["location"]) + + +@case +def the_sign_in_form_needs_the_token_and_origin(): + app, _, _ = _portal() + c = TestClient(app, base_url=BASE) + data = {"username": "asha", "password": PW} + assert c.post("/signin", headers=ORIGIN, data=data).status_code == 403 + assert c.post("/signin", headers={"Origin": "http://evil.example"}, + data=_form(app, **data)).status_code == 403 + + +@case +def a_wrong_password_says_so_and_five_lock(): + app, store, _ = _portal() + _, r = _sign_in(app, "asha", "wrong-password-x") + assert r.status_code == 401 and "Wrong username or password" in r.text + _, r = _sign_in(app, "nobody", "wrong-password-x") + assert "Wrong username or password" in r.text, "same words, unknown user" + for _ in range(4): + _, r = _sign_in(app, "asha", "wrong-password-x") + assert "locked for 15 minutes" in r.text, r.text + _, r = _sign_in(app, "asha") + assert r.status_code == 401 and "locked" in r.text + + +@case +def sign_out_ends_the_session(): + app, store, _ = _portal() + c, _ = _sign_in(app, "olu") + assert c.post("/signout", headers=ORIGIN, + data={"token": "wrong"}).status_code == 403 + r = c.post("/signout", headers=ORIGIN, data=_form(app), + follow_redirects=False) + assert r.status_code == 303 and r.headers["location"] == "/signin" + c.cookies.clear() + assert c.get("/days", follow_redirects=False).status_code == 303 + assert ("olu", "sign_out", "") in store.events + + +@case +def thirty_idle_minutes_sign_out_and_are_recorded(): + clock = Clock() + app, store, _ = _portal(clock=clock) + c, _ = _sign_in(app, "vee") + clock.t += timedelta(minutes=29) + assert c.get("/days").status_code == 200 + clock.t += timedelta(minutes=31) + assert c.get("/days", follow_redirects=False).status_code == 303 + assert ("vee", "timeout", "") in store.events + + +@case +def an_admin_made_user_must_change_the_password_first(): + app, store, _ = _portal() + portal_users.Accounts(store).create_user("newbie", "New", "Viewer", + "temp-password-1", by="asha") + c, r = _sign_in(app, "newbie", "temp-password-1") + assert r.headers["location"] == "/account/password" + assert c.get("/days", follow_redirects=False).headers["location"] \ + == "/account/password" + assert c.get("/api/runs").status_code == 403 + r = c.post("/account/password", headers=ORIGIN, data=_form( + app, current="temp-password-1", new="my-own-password", + confirm="my-own-password"), follow_redirects=False) + assert r.status_code == 303, r.text + assert c.get("/days").status_code == 200 + + +@case +def a_password_change_with_a_mismatch_is_refused(): + app, _, _ = _portal() + c, _ = _sign_in(app, "vee") + r = c.post("/account/password", headers=ORIGIN, data=_form( + app, current=PW, new="new-password-1", confirm="new-password-2")) + assert r.status_code == 400 and "not the same" in r.text + + +@case +def a_viewer_sees_no_start_and_is_refused_it(): + app, _, runner = _portal() + c, _ = _sign_in(app, "vee") + page = c.get("/").text + assert ' + + +""" + + +def case(fn): + CASES.append(fn) + return fn + + +@contextlib.contextmanager +def _sandbox(**settings): + """Shots in a temp folder; settings set for the case, then put back.""" + keys = ["SCREENSHOTS", "PREFLIGHT", "PREFLIGHT_STOP", + "SAVE_DOM_ON_FAILURE", "SAVE_DOM_LIMIT"] + old = {k: getattr(cfg, k) for k in keys} + old_saved = page_utils._dom_saved + with tempfile.TemporaryDirectory() as tmp: + cfg.SCREENSHOTS = pathlib.Path(tmp) + cfg.PREFLIGHT, cfg.PREFLIGHT_STOP = True, True + cfg.SAVE_DOM_ON_FAILURE, cfg.SAVE_DOM_LIMIT = True, 30 + for k, v in settings.items(): + setattr(cfg, k, v) + page_utils._dom_saved = 0 + preflight.reset() + try: + with contextlib.redirect_stdout(io.StringIO()): + yield pathlib.Path(tmp) + finally: + for k, v in old.items(): + setattr(cfg, k, v) + page_utils._dom_saved = old_saved + preflight.reset() + + +def _files(folder, suffix): + return sorted(p.name for p in folder.glob(f"*{suffix}")) + + +# ── DOM capture in shot() ───────────────────────────────────────────────────── + +@case +def a_failure_shot_saves_the_dom_beside_the_png(page): + page.set_content("
fake page
" + "") + page.wait_for_timeout(200) + with _sandbox() as tmp: + page_utils.shot(page, "claim_form_no_modal_C1") + pngs, htmls = _files(tmp, ".png"), _files(tmp, ".html") + assert len(pngs) == 1 and len(htmls) == 1, (pngs, htmls) + assert htmls[0].endswith("_claim_form_no_modal_C1.html") + assert htmls[0][:6] == pngs[0][:6], "same HHMMSS" + text = (tmp / htmls[0]).read_text(encoding="utf-8") + assert "fake page" in text and "frame 1" in text \ + and "frame text" in text, text[:300] + + +@case +def an_ordinary_shot_saves_no_dom(page): + page.set_content("
ok
") + with _sandbox() as tmp: + for tag in ("logged_in", "landing", "claims_status", + "progress_note", "claims_balance_cleared"): + page_utils.shot(page, tag) + assert _files(tmp, ".html") == [] + assert len(_files(tmp, ".png")) >= 1 + + +@case +def dom_capture_can_be_off_and_is_capped(page): + page.set_content("
x
") + with _sandbox(SAVE_DOM_ON_FAILURE=False) as tmp: + page_utils.shot(page, "claims_status_failed") + assert _files(tmp, ".html") == [] + with _sandbox(SAVE_DOM_LIMIT=2) as tmp: + for n in range(4): + page_utils.shot(page, f"x{n}_failed") + assert page_utils._dom_saved == 2 + + +@case +def failure_tags_are_told_from_ordinary_ones(): + for tag in ("claims_status_failed", "login_unconfirmed", + "search_not_ready_1", "claim_notfound_1", + "claim_form_wrong_claim_1", "claim_form_cpt_grid_missing", + "claims_report_nodownload", "2fa_timed_out", + "preflight_failed_search_page"): + assert page_utils.is_failure_tag(tag), tag + for tag in ("logged_in", "landing", "captcha", "approval_page", + "claims_results", "nav_claims", "progress_note", + "claims_balance_cleared", "claims_datetype_open"): + assert not page_utils.is_failure_tag(tag), tag + + +# ── the search page ────────────────────────────────────────────────────────── + +@case +def the_search_page_passes_with_every_selector(page): + page.set_content(SEARCH_PAGE) + with _sandbox() as tmp: + preflight.check_search(page) + assert _files(tmp, ".png") == [] + + +@case +def a_missing_search_selector_stops_the_run_named_and_saved(page): + page.set_content(SEARCH_PAGE.replace('id="btnclaimlookup"', 'id="x"')) + with _sandbox() as tmp: + try: + preflight.check_search(page) + except preflight.PreflightFailed as exc: + assert "Lookup button" in str(exc), exc + else: + raise AssertionError("no PreflightFailed") + assert any("preflight_failed_search_page" in n + for n in _files(tmp, ".html")), _files(tmp, ".html") + + +@case +def preflight_stop_off_only_warns_and_preflight_off_does_nothing(page): + page.set_content("

not the search page

") + with _sandbox(PREFLIGHT_STOP=False) as tmp: + preflight.check_search(page) + assert _files(tmp, ".png"), "still shot" + with _sandbox(PREFLIGHT=False) as tmp: + preflight.check_search(page) + assert _files(tmp, ".png") == [] + + +@case +def no_page_at_all_is_skipped_never_raised(): + with _sandbox(): + preflight.check_search(None) + preflight.check_form(None) + + +# ── the claim form ─────────────────────────────────────────────────────────── + +def _open(page, name): + page.goto((FIXTURES / name).as_uri()) + with contextlib.redirect_stdout(io.StringIO()): + claim_form.open_cpt_hcpcs_tab(page) + + +@case +def each_fixture_form_passes(page): + for name in ("ub_claim.html", "hcfa_claim.html", "dental_claim.html"): + _open(page, name) + with _sandbox() as tmp: + preflight.check_form(page) + assert _files(tmp, ".png") == [], name + + +@case +def a_missing_row_box_names_it(page): + _open(page, "ub_claim.html") + page.evaluate("document.querySelectorAll('[id^=\"uBClaimMod1\"]')" + ".forEach(e => e.remove())") + with _sandbox(): + try: + preflight.check_form(page) + except preflight.PreflightFailed as exc: + assert "ub claim form" in str(exc) and "M1 box" in str(exc), exc + else: + raise AssertionError("no PreflightFailed") + + +@case +def each_screen_is_checked_once_a_run(page): + _open(page, "hcfa_claim.html") + with _sandbox(): + preflight.check_form(page) + page.evaluate("document.querySelectorAll('[data-fieldname=" + "\"ClaimCPTPOS\"]').forEach(e => e.remove())") + preflight.check_form(page) # not checked again: no raise + preflight.reset() + try: + preflight.check_form(page) + except preflight.PreflightFailed as exc: + assert "POS box" in str(exc), exc + else: + raise AssertionError("a new run checks again") + + +@case +def a_form_with_no_rows_waits_then_fails_on_the_third(page): + _open(page, "hcfa_claim.html") + page.evaluate("document.querySelectorAll('tr[id^=\"CPTROW_\"]')" + ".forEach(e => e.remove())") + with _sandbox(): + preflight.check_form(page) + preflight.check_form(page) + try: + preflight.check_form(page) + except preflight.PreflightFailed as exc: + assert "CPT rows" in str(exc), exc + else: + raise AssertionError("three claims with no rows should fail") + + +# ── the flow ───────────────────────────────────────────────────────────────── + +@case +def the_flow_lets_preflight_failed_through_and_still_closes_the_form(): + closed = [] + + def boom(page): + raise preflight.PreflightFailed("eCW ub claim form: not found — M1") + + stubs = {"open_claim": lambda page, cid: True, + "wait_claim_loaded": lambda page, cid: None, + "close_duplicate_forms": lambda page, cid: None, + "is_dental_claim_form": lambda page: False, + "open_cpt_hcpcs_tab": lambda page: None, + "close_claim": lambda page: closed.append(1) or True} + orig = {k: getattr(claim_form, k) for k in stubs} + orig_check, orig_open = preflight.check_form, cfg.OPEN_CLAIM_FORM + for k, v in stubs.items(): + setattr(claim_form, k, v) + preflight.check_form = boom + cfg.OPEN_CLAIM_FORM = True + try: + with contextlib.redirect_stdout(io.StringIO()): + try: + flow._read_claim_form(None, "C1") + except preflight.PreflightFailed: + pass + else: + raise AssertionError("PreflightFailed was swallowed") + assert closed == [1], "the form is still closed" + finally: + for k, v in orig.items(): + setattr(claim_form, k, v) + preflight.check_form, cfg.OPEN_CLAIM_FORM = orig_check, orig_open + + +@case +def the_web_page_explains_exit_7(): + sys.path.insert(0, str(ROOT)) + from web import run_status + assert "selector" in run_status.exit_text(7) + + +def main() -> int: + from playwright.sync_api import sync_playwright + + failures = 0 + with sync_playwright() as pw: + browser = pw.chromium.launch(headless=True) + page = browser.new_page() + for fn in CASES: + name = fn.__name__.replace("_", " ") + try: + if len(inspect.signature(fn).parameters) == 0: + fn() + else: + fn(page) + print(f" ok {name}") + except AssertionError as exc: + print(f" FAIL {name}: {exc}") + failures += 1 + except Exception as exc: + print(f" ERROR {name}: {type(exc).__name__}: {exc}") + failures += 1 + browser.close() + + print(f"\n{len(CASES) - failures}/{len(CASES)} passed") + return 1 if failures else 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tests/test_progress_note.py b/tests/test_progress_note.py new file mode 100644 index 0000000..083fada --- /dev/null +++ b/tests/test_progress_note.py @@ -0,0 +1,198 @@ +r""" +Progress Note extraction, against a real browser. + + python tests\test_progress_note.py + +Local headless chromium against `tests\fixtures\progress_note_host.html`. Never +touches eCW: no proxy, no login, no 2FA mail, no attempt against the guard. + +The fixture puts the note in an **iframe** with Close on the parent page, +because that is how it actually arrives — the note is a whole printable +document with its own ``. That split is the trap being tested: + + * reading the modal's `innerText` finds nothing, since iframe content is not + part of it — which an earlier version of `open_progress_notes` did; + * looking for Close inside the note finds nothing either, since it lives on + the parent. + +The rest is about eCW's own labels. Every section carries +`prisma-section="..."`, so the note comes back as a dict keyed by those rather +than as prose — but `"Footer"` repeats four times, and a naive dict build drops +the signature block. That is covered. +""" + +import sys +import pathlib + +ROOT = pathlib.Path(__file__).resolve().parent.parent +sys.path.insert(0, str(ROOT / "src")) + +HOST = pathlib.Path(__file__).resolve().parent / "fixtures" / "progress_note_host.html" + +from ecw_bot.pages import progress_note # noqa: E402 +from ecw_bot.selectors import claim_form as sel # noqa: E402 + +CHECKS = [] + + +def check(fn): + CHECKS.append(fn) + return fn + + +@check +def found_in_the_iframe_not_the_modal(page, note, where): + """The note is a separate document; the frame walk is what finds it.""" + assert where == "frame", where + # And the trap: the hosting modal's own innerText holds none of the note. + modal_text = page.evaluate( + """() => { + const m = document.querySelector('.modal'); + return m ? (m.innerText || '') : ''; + }""") + assert "R69" not in modal_text, ( + "the modal's innerText contains the note, so this fixture no longer " + "reproduces the iframe case it exists for") + + +@check +def patient_header_and_dos(page, note, where): + hdr = note["patient_header"] + assert "SAMPLE, Pat Q" in hdr, hdr + assert "900123" in hdr, hdr + assert note["dos"] == "06/29/2026", note["dos"] + + +@check +def sections_are_keyed_by_ecw_labels(page, note, where): + got = note["sections"] + for want in ("Header", "Past Medical History", "Surgical History", + "Family History", "Social History", + "Hospitalization/Major Diagnostic Procedure", + "Reason for Appointment", "History of Present Illness", + "Assessments", "Labs", "Procedure Codes", + "Care Plan Details", "Footer", "Active Problem List"): + assert want in got, f"{want!r} missing; got {sorted(got)}" + assert "Generalized Anxiety Disorder" in got["Past Medical History"], \ + got["Past Medical History"] + assert "BH Outreach" in got["Reason for Appointment"], \ + got["Reason for Appointment"] + # Present but empty on this note, exactly as eCW served it. + assert got["Active Problem List"] == "", repr(got["Active Problem List"]) + + +@check +def repeated_footer_sections_are_joined_not_overwritten(page, note, where): + """ + "Footer" appears four times. Overwriting on each would keep only the last — + the facility block — and silently lose the signature and the sign-off. + """ + footer = note["sections"]["Footer"] + assert "Electronically signed by Provider" in footer, footer + assert "Sign off status" in footer, footer + assert "Brighton Womens Clinic" in footer, footer + + +@check +def assessments_are_parsed_with_their_codes(page, note, where): + a = note["assessments"] + assert len(a) == 2, a + assert a[0]["n"] == "1" and a[0]["code"] == "R69", a[0] + assert a[0]["primary"] is True, a[0] + assert "Illness, unspecified" in a[0]["text"], a[0] + assert a[1]["code"] == "F41.1", a[1] + assert a[1]["primary"] is False, a[1] + + +@check +def procedure_codes_carry_their_modifiers(page, note, where): + """ + The pair charge entry actually needs: the note's CPT and modifier are the + same H0023 / GT the UB Claim form shows. + """ + pr = note["procedure_codes"] + assert len(pr) == 2, pr + assert pr[0]["code"] == "H0023", pr[0] + assert pr[0]["modifiers"] == ["GT"], pr[0] + assert "Behavioral Health Outreach Service" in pr[0]["description"], pr[0] + # The description must not swallow the "Modifiers: ..." tail. + assert "Modifiers" not in pr[0]["description"], pr[0] + assert pr[1]["code"] == "90791" and pr[1]["modifiers"] == [], pr[1] + + +@check +def signoff_is_extracted(page, note, where): + assert "Morgan Lee Taylor" in note["signed_by"], note["signed_by"] + assert "07/13/2026" in note["signed_at"], note["signed_at"] + assert note["signoff_status"] == "Completed", note["signoff_status"] + + +@check +def chart_locked_needs_textcontent(page, note, where): + """ + `#chartLockedId` is `display:none`. innerText returns "" for it, so a + reader using innerText alone reports the chart as unlocked — the opposite + of the truth, on a field that decides whether a claim should be touched. + """ + assert note["chart_locked"] is True, note["chart_locked"] + + +@check +def summary_names_the_facts_that_matter(page, note, where): + text = progress_note.summary(note) + for want in ("06/29/2026", "R69", "H0023", "GT", "Completed", + "Morgan Lee Taylor"): + assert want in text, f"{want!r} missing from summary:\n{text}" + + +@check +def close_clicks_the_parent_not_the_note(page, note, where): + """Close lives on the host page; the ng-click typo is eCW's own.""" + assert "closeProgressNoteViwer" in sel.SEL_PN_CLOSE, sel.SEL_PN_CLOSE + assert progress_note.close(page) is True + assert page.locator(".modal").first.is_visible() is False + + +def main() -> int: + if not HOST.exists(): + print(f" FAIL fixture missing: {HOST}") + return 1 + try: + from playwright.sync_api import sync_playwright + except ImportError: + print(" SKIP playwright is not installed") + return 0 + + failures = 0 + with sync_playwright() as pw: + browser = pw.chromium.launch(headless=True) # local only, no proxy + page = browser.new_page() + page.goto(HOST.as_uri()) + page.wait_for_timeout(500) # let the iframe load + + reader, where = progress_note.find(page) + if reader is None: + print(" FAIL the note document was not found at all") + browser.close() + return 1 + note = progress_note.extract(reader) + + for fn in CHECKS: + name = fn.__name__.replace("_", " ") + try: + fn(page, note, where) + print(f" ok {name}") + except AssertionError as exc: + print(f" FAIL {name}: {exc}") + failures += 1 + except Exception as exc: + print(f" ERROR {name}: {type(exc).__name__}: {exc}") + failures += 1 + browser.close() + + print(f"\n{len(CHECKS) - failures}/{len(CHECKS)} passed") + return 1 if failures else 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tests/test_progress_note_wait.py b/tests/test_progress_note_wait.py new file mode 100644 index 0000000..6ca9c18 --- /dev/null +++ b/tests/test_progress_note_wait.py @@ -0,0 +1,311 @@ +r""" +Progress Notes: polled, not slept on (review item D8). + + python tests\test_progress_note_wait.py + +The old `open_progress_notes` clicked, slept a fixed 3 s and probed once. The +logs show 3 misses in ~115 note reads, and in every one the viewer had opened +on a document that had not loaded yet — so the claim was reported Clean with +telehealth never checked. Now the note is polled every 250 ms for up to +`PROGRESS_NOTE_WAIT_MS`, accepted once its `[prisma-section]` count holds +still, with one retry of the click if nothing opened at all. + +Local headless chromium against `tests\fixtures\progress_note_delayed.html` +(the note arrives late, never, only on a second click, or never settles). +`PROGRESS_NOTE_WAIT_MS` is shrunk per case so the suite stays quick. No eCW; +screenshots go to a temp folder. +""" + +import io +import sys +import time +import types +import pathlib +import inspect +import tempfile +import contextlib + +ROOT = pathlib.Path(__file__).resolve().parent.parent +sys.path.insert(0, str(ROOT / "src")) + +HOST = pathlib.Path(__file__).resolve().parent / "fixtures" / \ + "progress_note_delayed.html" + +from ecw_bot import settings as cfg # noqa: E402 +from ecw_bot.pages import claim_form, progress_note # noqa: E402 +from ecw_bot.selectors import claim_form as sel # noqa: E402 + +CASES = [] + + +def case(fn): + CASES.append(fn) + return fn + + +def _open(page, query: str, wait_ms: int): + """open_progress_notes against the fixture: (note, seconds, log).""" + page.goto(HOST.as_uri() + "?" + query) + orig = cfg.PROGRESS_NOTE_WAIT_MS + cfg.PROGRESS_NOTE_WAIT_MS = wait_ms + buf = io.StringIO() + started = time.monotonic() + try: + with contextlib.redirect_stdout(buf): + note = claim_form.open_progress_notes(page) + finally: + cfg.PROGRESS_NOTE_WAIT_MS = orig + return note, time.monotonic() - started, buf.getvalue() + + +@case +def a_note_that_loads_after_the_old_3s_sleep_is_read(page): + """3.5 s: the old sleep-3-then-probe-once returned "nothing" here.""" + note, took, log = _open(page, "mode=slow&delay=3500", 10_000) + assert note.get("opened") == "frame", (note, log) + codes = [p["code"] for p in note.get("procedure_codes") or []] + assert "H0023" in codes, codes + assert took >= 3.5, f"read after {took:.1f}s — before the note existed" + assert "Note loaded in the frame" in log, log + assert "partial" not in note, "a note that settled was marked partial" + + +@case +def a_note_that_loads_quickly_is_accepted_in_about_a_second(page): + """The normal claim no longer pays a fixed 3 s.""" + note, took, _ = _open(page, "mode=slow&delay=200", 10_000) + assert note.get("opened") == "frame", note + assert took < 2.5, f"took {took:.1f}s on a note that loaded in 0.2s" + + +@case +def a_note_that_never_appears_is_nothing_within_the_configured_wait(page): + note, took, log = _open(page, "mode=never", 2_000) + assert note == {"opened": "nothing"}, note + assert 1.8 <= took < 5.0, f"took {took:.1f}s for a 2s wait" + # A viewer WAS open (slow-document case), so no second click was made. + assert page.evaluate("window.progNoteClicks") == 1, log + # And the viewer was closed on the way out. + assert page.locator(sel.SEL_PN_CLOSE).count() == 0, "viewer left open" + + +@case +def a_click_that_did_not_take_is_retried_once(page): + note, _, log = _open(page, "mode=reclick", 3_000) + assert note.get("opened") == "frame", (note, log) + assert page.evaluate("window.progNoteClicks") == 2 + assert "clicking Prog. Notes once more" in log, log + + +@case +def a_note_that_never_settles_is_read_anyway_and_logged_as_partial(page): + note, took, log = _open(page, "mode=growing", 2_000) + assert note.get("opened") == "frame", (note, log) + assert note.get("sections"), note + assert "may be partial" in log, log + assert took < 5.0, f"took {took:.1f}s" + # On the note, not only in the log, so the summary can act on it. + assert note.get("partial") is True, note + assert note.get("partial_sections", 0) >= 1, note + + +@case +def a_note_header_with_no_sections_is_read_but_marked_partial(page): + """ + The note's header arrives and not one section under it: read at the + deadline, it has no Procedure Codes, so telehealth is never seen — the + D8 false Clean. The log line alone could not stop that; the mark on the + note is what the summary can turn into a Review row. + """ + note, _, log = _open(page, "mode=empty", 1_500) + assert note.get("opened") == "frame", (note, log) + assert note.get("partial") is True, note + assert note.get("partial_sections") == 0, note + assert not note.get("procedure_codes"), note + + +@case +def section_count_never_raises(): + class Broken: + def evaluate(self, *a, **k): + raise RuntimeError("frame detached") + assert progress_note.section_count(Broken()) == 0 + + +@case +def polling_find_does_not_repeat_the_60s_tab_load_wait(): + """ + find() waits on each new tab's domcontentloaded; at its 60s default, a + poll every 250 ms could sit a minute per poll. The poll passes 1s. + """ + waits = [] + tab = types.SimpleNamespace( + wait_for_load_state=lambda state, timeout: waits.append(timeout), + evaluate=lambda *a: False) + fake = types.SimpleNamespace( + context=types.SimpleNamespace(pages=["claim page", tab]), + frames=[], main_frame=None) + assert progress_note.find(fake, tabs_before=1, load_timeout=1_000) == \ + (None, "nothing") + assert waits == [1_000], waits + + calls = [] + orig_find, orig_wait = progress_note.find, cfg.PROGRESS_NOTE_WAIT_MS + progress_note.find = lambda page, **kw: (calls.append(kw) + or (None, "nothing")) + cfg.PROGRESS_NOTE_WAIT_MS = 0 + fake_page = types.SimpleNamespace( + context=types.SimpleNamespace(pages=[]), + wait_for_timeout=lambda ms: None) + try: + with contextlib.redirect_stdout(io.StringIO()): + claim_form._wait_for_note(fake_page, 0) + finally: + progress_note.find, cfg.PROGRESS_NOTE_WAIT_MS = orig_find, orig_wait + assert calls and all(kw.get("load_timeout") == 1_000 for kw in calls), \ + calls + + +@case +def a_note_whose_document_stalls_mid_stream_is_not_taken_early(page): + """ + Final review, 2026-10-05: the note is a whole HTML document, so a slow + server delivers it in pieces. Here two sections are parsed, then the + stream stalls for 1.5 s with readyState still "loading" — the same + count on every poll. A count-only check took the note at ~0.25 s, + without its Procedure Codes, unmarked: the D8 false Clean again. + """ + note, took, log = _open(page, "mode=stalled&delay=1500", 6_000) + assert note.get("opened") == "frame", (note, log) + codes = [p["code"] for p in note.get("procedure_codes") or []] + assert codes == ["H0023"], (codes, log) + assert "partial" not in note, note + assert took >= 1.5, f"read after {took:.1f}s — mid-stream" + + +@case +def a_stall_that_never_ends_is_read_at_the_deadline_as_partial(page): + """Still "loading" at the deadline: read anyway, and marked partial.""" + note, _, log = _open(page, "mode=stalled&delay=60000", 1_500) + assert note.get("opened") == "frame", (note, log) + assert note.get("partial") is True, note + assert note.get("partial_sections") == 2, note + + +@case +def an_earlier_claims_note_left_in_a_reused_viewer_is_not_read(page): + """ + Final review, 2026-10-05: Close may only hide the viewer, and a reused + iframe still shows the previous claim's note until the new one commits. + Without the pre-click mark, A1111 (the earlier claim) was read as this + claim's note on the first two polls. + """ + note, took, log = _open(page, "mode=reused&delay=1500", 6_000) + assert note.get("opened") == "frame", (note, log) + codes = [p["code"] for p in note.get("procedure_codes") or []] + assert codes == ["H0023"], (codes, log) + assert took >= 1.5, f"read after {took:.1f}s — before this claim's note" + assert "earlier note document(s) still on the page" in log, log + + +@case +def a_note_in_a_slow_new_tab_gets_no_second_click_and_no_close_wait(page): + """ + The tab opens at once but its note arrives past the halfway point. + A new tab is the note on its way, so Prog. Notes is NOT clicked again + (that would open a second tab). And with no in-page viewer, closing + is an instant check, not a 5 s wait for a Close button that is not + there (final review, 2026-10-05). + """ + tabs = len(page.context.pages) + note, took, log = _open(page, "mode=tab&delay=2000", 3_000) + assert note.get("opened") == "tab", (note, log) + codes = [p["code"] for p in note.get("procedure_codes") or []] + assert "H0023" in codes, codes + assert page.evaluate("window.progNoteClicks") == 1, log + assert "clicking Prog. Notes once more" not in log, log + assert took < 4.5, f"took {took:.1f}s — the 5s close wait is back" + assert len(page.context.pages) == tabs, "the note's tab was left open" + + +def _settings_default(key: str): + """ + `key`'s code default: settings.py run afresh with no .env beside it (the + frozen-exe branch points ROOT at an empty temp folder — the same probe + tests/test_settings.py uses) and the key unset in the environment. + """ + import os + import importlib.util + saved = os.environ.pop(key, None) + had_frozen = hasattr(sys, "frozen") + saved_frozen, saved_exe = getattr(sys, "frozen", None), sys.executable + with tempfile.TemporaryDirectory() as tmp: + try: + sys.frozen = True + sys.executable = str(pathlib.Path(tmp) / "bot.exe") + spec = importlib.util.spec_from_file_location( + "ecw_bot._settings_probe", cfg.__file__) + probe = importlib.util.module_from_spec(spec) + with contextlib.redirect_stdout(io.StringIO()): + spec.loader.exec_module(probe) + assert probe.ROOT != cfg.ROOT, "the probe used the real .env" + return getattr(probe, key) + finally: + sys.executable = saved_exe + if had_frozen: + sys.frozen = saved_frozen + else: + del sys.frozen + if saved is not None: + os.environ[key] = saved + + +@case +def the_wait_is_a_setting_with_a_20s_default(): + # The code default, from a probe with no .env — not settings.py's source + # text (a reformat broke that) and not the operator's .env. + assert _settings_default("PROGRESS_NOTE_WAIT_MS") == 20_000 + assert isinstance(cfg.PROGRESS_NOTE_WAIT_MS, int) + + +def main() -> int: + if not HOST.exists(): + print(f" FAIL fixture missing: {HOST}") + return 1 + try: + from playwright.sync_api import sync_playwright + except ImportError: + print(" SKIP playwright is not installed") + return 0 + + failures = 0 + orig_shots = cfg.SCREENSHOTS + with tempfile.TemporaryDirectory() as tmp, sync_playwright() as pw: + cfg.SCREENSHOTS = pathlib.Path(tmp) + browser = pw.chromium.launch(headless=True) + page = browser.new_page() + try: + for fn in CASES: + name = fn.__name__.replace("_", " ") + try: + if len(inspect.signature(fn).parameters) == 0: + fn() + else: + fn(page) + print(f" ok {name}") + except AssertionError as exc: + print(f" FAIL {name}: {exc}") + failures += 1 + except Exception as exc: + print(f" ERROR {name}: {type(exc).__name__}: {exc}") + failures += 1 + finally: + cfg.SCREENSHOTS = orig_shots + browser.close() + + print(f"\n{len(CASES) - failures}/{len(CASES)} passed") + return 1 if failures else 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tests/test_results_db.py b/tests/test_results_db.py new file mode 100644 index 0000000..0cd1c31 --- /dev/null +++ b/tests/test_results_db.py @@ -0,0 +1,473 @@ +r""" +core/results_db.py — Charge Summary rows to SQL Server — and the flow's +`_save_results_db` hook. + + python tests\test_results_db.py + +No real database: a fake connection records what would be sent. +""" + +import io +import sys +import json +import pathlib +import contextlib +from datetime import date, datetime +from decimal import Decimal + +ROOT = pathlib.Path(__file__).resolve().parent.parent +sys.path.insert(0, str(ROOT / "src")) + +from ecw_bot import settings as cfg # noqa: E402 +from ecw_bot.core import charge_summary as cs # noqa: E402 +from ecw_bot.core import results_db as rdb # noqa: E402 +from ecw_bot.flows import export_medcor_ready as flow # noqa: E402 + +CASES = [] + + +def case(fn): + CASES.append(fn) + return fn + + +class FakeCursor: + def __init__(self, log): + self.log = log + + def executemany(self, sql, params): + self.log.append(("executemany", sql, [list(p) for p in params])) + + +class FakeConn: + def __init__(self): + self.log = [] + + def cursor(self): + return FakeCursor(self.log) + + def commit(self): + self.log.append(("commit",)) + + def close(self): + self.log.append(("close",)) + + +def _row(**over): + row = {h: "" for h in cs.HEADERS} + row.update({"S.No": 1, "CLAIMS#": "9117000", "SERVICE DATE": "7/31/2026", + "PAYER": "Medicaid", "Group Name": "Medicaid", + "CHARGES": "$1,365.00", "Balance": "365", + "Recevied date": "10/08/2026", "Comments": "ICD pointer issue", + "Current Status": "Corrections pending (not saved)", + "Category": "Fail"}) + row.update(over) + return row + + +def _sheet(*rows): + return [list(cs.HEADERS)] + [[r[h] for h in cs.HEADERS] for r in rows] + + +RUN_AT = datetime(2026, 10, 8, 15, 10, 0) + + +# ── column map ─────────────────────────────────────────────────────────────── + +@case +def the_table_carries_the_projects_cv_prefix(): + """Operator, 2026-10-12: every table of this project starts with CV_.""" + assert rdb.TABLE == "dbo.CV_ChargeSummary", rdb.TABLE + sql = (ROOT / "tools" / "sql" / "create_tables.sql").read_text( + encoding="utf-8") + assert "CREATE TABLE dbo.CV_ChargeSummary" in sql + assert "IX_CV_ChargeSummary_run_at" in sql + assert "IX_CV_ChargeSummary_claim_no" in sql + + +@case +def every_charge_summary_header_has_a_db_column(): + assert [h for h, _ in rdb.COLUMNS] == cs.HEADERS + assert len(rdb.COLUMNS) == 24 + cols = [c for _, c in rdb.COLUMNS] + assert len(set(cols)) == 24, cols + assert rdb.DB_COLUMNS == ["run_at", "run_mode"] + cols + ["details_json"] + assert dict(rdb.COLUMNS)["Assigned To "] == "assigned_to" + assert dict(rdb.COLUMNS)["Recevied date"] == "received_date" + + +# ── conversion ─────────────────────────────────────────────────────────────── + +@case +def money_strips_dollar_and_commas(): + assert rdb.to_money("$1,234.50") == Decimal("1234.50") + assert rdb.to_money(" 365 ") == Decimal("365.00") + assert rdb.to_money(365) == Decimal("365.00") + assert rdb.to_money(12.5) == Decimal("12.50") + assert rdb.to_money("(12.00)") == Decimal("-12.00") + assert rdb.to_money("-5") == Decimal("-5.00") + + +@case +def money_that_is_blank_or_junk_is_null(): + for v in ("", None, "abc", "NaN", "inf", "1e20", True): + assert rdb.to_money(v) is None, v + + +@case +def dates_accept_us_iso_and_date_objects(): + assert rdb.to_date("7/31/2026") == date(2026, 7, 31) + assert rdb.to_date("07/31/2026") == date(2026, 7, 31) + assert rdb.to_date("2026-07-31") == date(2026, 7, 31) + assert rdb.to_date(datetime(2026, 7, 31, 9, 0)) == date(2026, 7, 31) + assert rdb.to_date(date(2026, 7, 31)) == date(2026, 7, 31) + for v in ("", None, "13/45/2026", "soon"): + assert rdb.to_date(v) is None, v + + +@case +def to_record_types_every_column(): + rec = rdb.to_record(_row(), run_at=RUN_AT, run_mode="VERIFY", + details={"fired": ["depression_dx_pointer"]}) + assert set(rec) == set(rdb.DB_COLUMNS), set(rec) ^ set(rdb.DB_COLUMNS) + assert rec["run_at"] == RUN_AT and rec["run_mode"] == "VERIFY" + assert rec["s_no"] == 1 + assert rec["claim_no"] == "9117000" + assert rec["service_date"] == date(2026, 7, 31) + assert rec["received_date"] == date(2026, 10, 8) + assert rec["charges"] == Decimal("1365.00") + assert rec["balance"] == Decimal("365.00") + assert rec["pvdr"] is None, "blank text is NULL" + assert rec["category"] == "Fail" + assert json.loads(rec["details_json"]) == {"fired": ["depression_dx_pointer"]} + + +@case +def to_record_without_details_stores_null(): + rec = rdb.to_record(_row(), run_at=RUN_AT, run_mode="TEST", details=None) + assert rec["details_json"] is None + + +# ── details ────────────────────────────────────────────────────────────────── + +@case +def details_for_copies_conditions_dental_and_errors(): + data = { + "_conditions": {"fired": ["dummy_codes"], "saved": False, + "failed": [], "review": [{"name": "x", "reason": "y"}], + "comments": {}, "changes": {"dummy_codes": [{"row": 3}]}}, + "high_risk": {"high_risk": True, "match": "HIGH RISK"}, + "sliding_fee": {"verdict": "active", "reason": "DOS inside", + "from_date": "01/01/2026"}, + "_not_evaluated": "form did not load", + "cpt": [{"code": "99213"}], "patient": {"name": "never copied"}, + } + d = rdb.details_for(data) + assert d["fired"] == ["dummy_codes"] + assert d["changes"] == {"dummy_codes": [{"row": 3}]} + assert d["review"] == [{"name": "x", "reason": "y"}] + # Verdicts only (review, 2026-10-12): not the note text, not the dates. + assert d["dental"] == {"high_risk": True, + "sliding_fee": {"verdict": "active", + "reason": "DOS inside"}} + assert d["not_evaluated"] == "form did not load" + assert "patient" not in d and "cpt" not in d + assert rdb.details_for({}) == {} and rdb.details_for(None) == {} + + +# ── insert ─────────────────────────────────────────────────────────────────── + +@case +def insert_rows_sends_one_parameterized_statement_and_commits(): + conn = FakeConn() + recs = [rdb.to_record(_row(), run_at=RUN_AT, run_mode="VERIFY", details=None), + rdb.to_record(_row(**{"CLAIMS#": "9118000", "S.No": 2}), + run_at=RUN_AT, run_mode="VERIFY", details=None)] + assert rdb.insert_rows(conn, recs) == 2 + (kind, sql, params), commit = conn.log + assert kind == "executemany" and commit == ("commit",) + assert sql.startswith("INSERT INTO dbo.CV_ChargeSummary (run_at, run_mode, s_no,") + assert sql.count("?") == len(rdb.DB_COLUMNS) + assert "9117000" not in sql, "values never go into the SQL text" + assert params[1][rdb.DB_COLUMNS.index("claim_no")] == "9118000" + assert len(params[0]) == len(rdb.DB_COLUMNS) + + +@case +def insert_rows_with_nothing_sends_nothing(): + conn = FakeConn() + assert rdb.insert_rows(conn, []) == 0 + assert conn.log == [] + + +@case +def save_summary_skips_rows_the_run_did_not_fill_in(): + conn = FakeConn() + seen = {} + + def connect(conn_str, timeout): + seen["conn_str"], seen["timeout"] = conn_str, timeout + return conn + + sheet = _sheet(_row(), _row(**{"CLAIMS#": "999", "Category": "", + "Comments": "", "Current Status": ""})) + with contextlib.redirect_stdout(io.StringIO()) as out: + n = rdb.save_summary(sheet, {"9117000": {"fired": ["a"]}}, + run_at=RUN_AT, run_mode="VERIFY", + conn_str="DSN=x", connect=connect) + assert n == 1, n + assert seen == {"conn_str": "DSN=x", "timeout": 15}, seen + params = conn.log[0][2] + assert [p[rdb.DB_COLUMNS.index("claim_no")] for p in params] == ["9117000"] + assert json.loads(params[0][rdb.DB_COLUMNS.index("details_json")]) == \ + {"fired": ["a"]} + assert conn.log[-1] == ("close",) + assert "[RESULTS-DB] 1 row(s) saved" in out.getvalue(), out.getvalue() + + +@case +def save_summary_never_raises_on_a_db_error(): + def connect(conn_str, timeout): + raise RuntimeError("Login failed for user\nmore detail") + + with contextlib.redirect_stdout(io.StringIO()) as out: + n = rdb.save_summary(_sheet(_row()), {}, run_at=RUN_AT, + run_mode="VERIFY", conn_str="DSN=x", + connect=connect) + assert n is None + text = out.getvalue() + assert "[RESULTS-DB] Could not save" in text and "RuntimeError" in text, \ + text + assert "Login failed" not in text, "the driver's message is never logged" + + +@case +def save_summary_with_no_filled_rows_does_not_connect(): + def connect(conn_str, timeout): + raise AssertionError("must not connect") + + with contextlib.redirect_stdout(io.StringIO()): + n = rdb.save_summary(_sheet(_row(Category="")), {}, run_at=RUN_AT, + run_mode="VERIFY", conn_str="DSN=x", + connect=connect) + assert n == 0 + + +# ── hardening (review, 2026-10-12) ─────────────────────────────────────────── + +@case +def money_that_rounds_past_the_column_is_null(): + assert rdb.to_money("9999999999.995") is None + assert rdb.to_money("9999999999.99") == Decimal("9999999999.99") + + +@case +def dates_with_a_time_or_as_an_excel_serial_still_convert(): + assert rdb.to_date("07/31/2026 10:30:00") == date(2026, 7, 31) + assert rdb.to_date("46234") == date(2026, 7, 31) + assert rdb.to_date(46234) == date(2026, 7, 31) + assert rdb.to_date("46234.0") == date(2026, 7, 31) + for v in ("123", "9117000", "99999", "07/31/2026x"): + assert rdb.to_date(v) is None, v + + +@case +def text_longer_than_its_column_is_cut_to_fit(): + rec = rdb.to_record(_row(PVDR="P" * 80, PATIENT="N" * 300, + Comments="C" * 5000), + run_at=RUN_AT, run_mode="TEST LIVE-WRITE", + details=None) + assert rec["pvdr"] == "P" * 50 + assert rec["patient"] == "N" * 200 + assert rec["comments"] == "C" * 5000, "NVARCHAR(MAX) is not cut" + assert rec["run_mode"] == "TEST LIVE-WRITE" + + +@case +def details_never_carry_patient_text_from_the_changes(): + data = {"_conditions": { + "fired": ["vaccine_admin_codes"], "changed": ["vaccine_admin_codes"], + "detected": ["vaccine_admin_codes", "zero_fee_uncheck"], + "saved": False, "save_unconfirmed": True, + "changes": {"vaccine_admin_codes": [ + {"row": 4, "code": "90460", "age": 7, + "age_source": "DOB 01/02/2019"}]}}} + d = rdb.details_for(data) + change = d["changes"]["vaccine_admin_codes"][0] + assert change == {"row": 4, "code": "90460", "age": 7}, change + assert "01/02/2019" not in json.dumps(d) + assert d["changed"] == ["vaccine_admin_codes"] + assert d["detected"] == ["vaccine_admin_codes", "zero_fee_uncheck"] + assert d["save_unconfirmed"] is True + + +class _DbError(Exception): + pass + + +@case +def a_db_error_is_logged_by_sqlstate_without_the_value(): + """Msg 2628 quotes the value that did not fit — a patient name.""" + class Conn(FakeConn): + def cursor(self): + raise _DbError("22001", "[Microsoft][ODBC Driver 18] String or " + "binary data would be truncated in column " + "'patient', truncated value: 'Smith, John'") + + with contextlib.redirect_stdout(io.StringIO()) as out: + n = rdb.save_summary(_sheet(_row()), {}, run_at=RUN_AT, + run_mode="VERIFY", conn_str="DSN=x", + connect=lambda cs_, timeout: Conn()) + assert n is None + text = out.getvalue() + assert "SQLSTATE 22001" in text and "too long" in text, text + assert "Smith" not in text, text + + +@case +def save_summary_sets_a_query_timeout(): + conn = FakeConn() + with contextlib.redirect_stdout(io.StringIO()): + rdb.save_summary(_sheet(_row()), {}, run_at=RUN_AT, + run_mode="VERIFY", conn_str="DSN=x", + connect=lambda cs_, timeout: conn) + assert getattr(conn, "timeout", None) == 30 + + +# ── settings ───────────────────────────────────────────────────────────────── + +@case +def settings_default_off_with_the_windows_login_connection(): + """ + The code's defaults, read from settings.py itself: the loaded values + follow this machine's .env, which may well turn RESULTS_DB on. + """ + assert isinstance(cfg.RESULTS_DB, bool) + src = (ROOT / "src" / "ecw_bot" / "settings.py").read_text( + encoding="utf-8") + assert '_env_bool("RESULTS_DB", False)' in src + start = src.index('"RESULTS_DB_CONN",') + default = src[start:src.index(")", start)] + for part in ("ODBC Driver 18 for SQL Server", "MECARE331\\\\MECARE331", + "DATABASE=Charge_Validation", "Trusted_Connection=yes"): + assert part in default, (part, default) + assert "PWD" not in default.upper() and "UID" not in default.upper() + + +# ── flow hook ──────────────────────────────────────────────────────────────── + +def _with_cfg(**values): + saved = {k: getattr(cfg, k) for k in values} + for k, v in values.items(): + setattr(cfg, k, v) + return saved + + +@case +def run_mode_names_test_live_write_and_verify(): + for test_file, save, want in (("C:/t.xlsx", True, "TEST LIVE-WRITE"), + ("C:/t.xlsx", False, "TEST VERIFY"), + ("", True, "LIVE-WRITE"), + ("", False, "VERIFY")): + saved = _with_cfg(TEST_CLAIM_IDS_FILE=test_file, SAVE_CORRECTIONS=save) + try: + assert flow._run_mode() == want, (test_file, save) + finally: + _with_cfg(**saved) + + +def _hooked(results_db_on, build=None): + calls = [] + orig = (cs.build, rdb.save_summary) + saved = _with_cfg(RESULTS_DB=results_db_on, TEST_CLAIM_IDS_FILE="", + SAVE_CORRECTIONS=False) + try: + cs.build = build or (lambda *a: calls.append(("build", a)) or ["SHEET"]) + rdb.save_summary = lambda *a, **k: calls.append(("save", a, k)) or 1 + with contextlib.redirect_stdout(io.StringIO()) as out: + flow._save_results_db({"file": pathlib.Path("r.xlsx")}, + {"1": {"Category": "Pass"}}, {"1": {"x": 1}}) + finally: + cs.build, rdb.save_summary = orig + _with_cfg(**saved) + return calls, out.getvalue() + + +@case +def flow_hook_does_nothing_when_results_db_is_off(): + calls, _ = _hooked(False) + assert calls == [], calls + + +@case +def flow_hook_builds_the_sheet_and_saves_it(): + calls, _ = _hooked(True) + assert calls[0] == ("build", (pathlib.Path("r.xlsx"), + {"1": {"Category": "Pass"}}, + cfg.SUMMARY_ASSIGNED_TO)), calls + kind, args, kwargs = calls[1] + assert kind == "save" and args == (["SHEET"], {"1": {"x": 1}}), calls + assert kwargs["run_mode"] == "VERIFY" + assert kwargs["conn_str"] == cfg.RESULTS_DB_CONN + assert isinstance(kwargs["run_at"], datetime) + assert kwargs["run_at"].microsecond == 0 + + +@case +def flow_hook_keeps_one_run_at_for_every_claim_of_a_run(): + """Saved claim by claim, the rows still group as one run on the page.""" + seen = [] + ticks = iter([datetime(2026, 10, 12, 9, 0, 0), + datetime(2026, 10, 12, 9, 5, 0)]) + + class _Clock(datetime): + @classmethod + def now(cls, tz=None): + return next(ticks) + + orig = (cs.build, rdb.save_summary, flow.datetime) + saved = _with_cfg(RESULTS_DB=True, TEST_CLAIM_IDS_FILE="", + SAVE_CORRECTIONS=False) + info = {"file": pathlib.Path("r.xlsx")} + try: + flow.datetime = _Clock + cs.build = lambda *a: ["SHEET"] + rdb.save_summary = lambda *a, **k: seen.append(k["run_at"]) or 1 + with contextlib.redirect_stdout(io.StringIO()): + flow._save_results_db(info, {"1": {"Category": "Pass"}}, {}) + flow._save_results_db(info, {"2": {"Category": "Fail"}}, {}) + finally: + cs.build, rdb.save_summary, flow.datetime = orig + _with_cfg(**saved) + assert len(seen) == 2 and seen[0] == seen[1], seen + + +@case +def flow_hook_survives_a_build_error(): + def boom(*a): + raise OSError("report locked") + + calls, out = _hooked(True, build=boom) + assert calls == [], calls + assert "[RESULTS-DB]" in out and "report locked" in out, out + + +def main() -> int: + failures = 0 + for fn in CASES: + name = fn.__name__.replace("_", " ") + try: + fn() + print(f" ok {name}") + except AssertionError as exc: + print(f" FAIL {name}: {exc}") + failures += 1 + except Exception as exc: + print(f" ERROR {name}: {type(exc).__name__}: {exc}") + failures += 1 + print(f"\n{len(CASES) - failures}/{len(CASES)} passed") + return 1 if failures else 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tests/test_run_alerts.py b/tests/test_run_alerts.py new file mode 100644 index 0000000..cabde42 --- /dev/null +++ b/tests/test_run_alerts.py @@ -0,0 +1,278 @@ +r""" +Stage 3 (EEWA-33, spec 2026-10-22): the Dashboard's banner for a Run that +ended badly, and Started By in `dbo.CV_ChargeSummary`. + + python tests\test_run_alerts.py + + * **Banner**: the latest Run ended in a Run Error, or without a record + (killed) → a red banner naming when, who, the step and a link to the + Run. Done, Stopped and Running → no banner. + * **started_by** is written only once its column exists — a table not yet + updated must never cost a Run its rows — and the Portal shows it in a + claim's side panel; a Portal reading a table without it says how to fix + that. + +Claim numbers here are made up. +""" + +import io +import os +import sys +import json +import pathlib +import tempfile +import contextlib + +ROOT = pathlib.Path(__file__).resolve().parent.parent +sys.path.insert(0, str(ROOT)) +sys.path.insert(0, str(ROOT / "src")) +sys.path.insert(0, str(ROOT / "tests")) + +from ecw_bot import settings as cfg # noqa: E402 +from ecw_bot.core import results_db as rdb # noqa: E402 +from ecw_bot.flows import export_medcor_ready as flow # noqa: E402 +from web import runs as run_view # noqa: E402 +from web.repo import SqlResultsRepo # noqa: E402 +from test_web_app import (FakeRepo, FakeRunner, signed_in, _row, # noqa: E402 + _quiet, DAY) +from test_results_db import _row as _sheet_row, _sheet # noqa: E402 + +CASES = [] + + +class DbError(Exception): + """A pyodbc error: SQLSTATE first, as the driver gives it.""" + + +def case(fn): + CASES.append(fn) + return fn + + +def ev(t, kind, **f): + return {"t": f"2026-10-22T{t}.000", "kind": kind, **f} + + +START = ev("14:00:00", "run_start", source="portal", started_by="olu", + mode="VERIFY", claim_limit=2, test_sheet="", pid=1, log="x") + + +def _logs(*runs): + d = pathlib.Path(tempfile.mkdtemp()) + for run_id, events in runs: + (d / f"events_{run_id}.jsonl").write_text( + "".join(json.dumps(e) + "\n" for e in events), encoding="utf-8") + return d + + +ERRORED = [START, ev("14:00:05", "queue", claims=["9001", "9002"]), + ev("14:00:06", "claim_start", n=1, total=2, claim="9001"), + ev("14:01:00", "run_error", step="claims", exit_code=5, + message="Target closed", traceback=""), + ev("14:01:01", "run_end", exit_code=5)] +DONE = [{**START, "t": "2026-10-22T15:00:00.000"}, + {"t": "2026-10-22T15:01:00.000", "kind": "run_end", "exit_code": 0}] + + +# ── Banner ──────────────────────────────────────────────────────────────────── + +def _dashboard(logs, runner=None): + client, _ = signed_in(FakeRepo(), runner or FakeRunner(), logs_dir=logs) + return client.get("/").text + + +@case +def a_run_error_last_shows_the_banner_with_who_what_and_a_link(): + page = _dashboard(_logs(("20261022_140000", ERRORED))) + assert 'id="last-run-alert"' in page, page[:500] + assert "ended in a Run Error" in page and "started by olu" in page + assert "claims (exit 5): Target closed" in page + assert "1 claim(s) not reached" in page + assert 'href="/runs/20261022_140000"' in page + + +@case +def a_finished_run_after_the_error_clears_the_banner(): + page = _dashboard(_logs(("20261022_140000", ERRORED), + ("20261022_150000", DONE))) + assert 'id="last-run-alert"' not in page + + +@case +def a_killed_run_shows_the_banner_a_stopped_or_running_one_does_not(): + killed = ERRORED[:3] + page = _dashboard(_logs(("20261022_140000", killed))) + assert "ended without a record" in page + stopped = killed + [ev("14:02:00", "stopped", stopped_by="asha")] + assert 'id="last-run-alert"' not in _dashboard( + _logs(("20261022_140000", stopped))) + runner = FakeRunner(running=True) + st = runner._st + runner._st = lambda: {**st(), + "log_path": r"D:\x\Logs\run_20261022_140000.log"} + assert 'id="last-run-alert"' not in _dashboard( + _logs(("20261022_140000", killed)), runner) + + +@case +def no_runs_no_banner_and_the_latest_is_read_alone(): + assert 'id="last-run-alert"' not in _dashboard(_logs()) + d = _logs(("20261022_140000", ERRORED), ("20261022_150000", DONE)) + assert run_view.latest_run(d)["run_id"] == "20261022_150000" + assert run_view.latest_run(_logs()) is None + + +# ── started_by in the results table ─────────────────────────────────────────── + +class ColCursor: + def __init__(self, log, has): + self.log, self.has = log, has + + def execute(self, sql, params): + self.log.append(("execute", sql, list(params))) + if self.has == "raise": + raise RuntimeError("no permission") + return self + + def fetchone(self): + return (100,) if self.has else (None,) + + def executemany(self, sql, params): + self.log.append(("executemany", sql, list(params))) + + +class ColConn: + def __init__(self, has): + self.log, self.has = [], has + + def cursor(self): + return ColCursor(self.log, self.has) + + def commit(self): + self.log.append(("commit",)) + + def close(self): + self.log.append(("close",)) + + +def _records(): + return [rdb.to_record(dict(zip(_sheet(_sheet_row())[0], + _sheet(_sheet_row())[1])), + run_at=None, run_mode="VERIFY", details=None)] + + +def _insert(conn): + return next(e for e in conn.log if e[0] == "executemany") + + +@case +def started_by_is_written_when_the_column_exists(): + conn = ColConn(has=True) + assert rdb.insert_rows(conn, _records(), started_by="olu") == 1 + _, sql, params = _insert(conn) + assert sql.split("(")[1].split(")")[0].endswith("details_json, " + "started_by"), sql + assert params[0][-1] == "olu" and len(params[0]) == len(rdb.DB_COLUMNS) + 1 + check = conn.log[0] + assert check[1] == "SELECT COL_LENGTH(?, ?)", check + assert check[2] == [rdb.TABLE, "started_by"], "parameterized" + + +@case +def a_table_without_the_column_still_gets_its_rows(): + for has in (False, "raise"): + conn = ColConn(has=has) + assert rdb.insert_rows(conn, _records(), started_by="olu") == 1 + _, sql, params = _insert(conn) + assert "started_by" not in sql and len(params[0]) == \ + len(rdb.DB_COLUMNS), has + + +@case +def no_started_by_no_check(): + conn = ColConn(has=True) + rdb.insert_rows(conn, _records()) + assert [e[0] for e in conn.log] == ["executemany", "commit"], conn.log + + +@case +def the_flow_saves_started_by_with_the_rows(): + seen = {} + saved = (rdb.save_summary, cfg.RESULTS_DB, flow.charge_summary.build) + os.environ["ECW_STARTED_BY"] = "olu" + rdb.save_summary = lambda *a, **k: seen.update(k) + flow.charge_summary.build = lambda *a: [["CLAIMS#"], ["9001"]] + cfg.RESULTS_DB = True + try: + info = {"ids": ["9001"], "file": None} + with contextlib.redirect_stdout(io.StringIO()): + flow._save_results_db(info, {"9001": {"Category": "Pass"}}, + {"9001": {}}) + finally: + rdb.save_summary, cfg.RESULTS_DB, flow.charge_summary.build = saved + del os.environ["ECW_STARTED_BY"] + assert seen.get("started_by") == "olu", seen + + +@case +def the_portal_reads_and_shows_started_by(): + class Cur: + def __init__(self, log): + self.log, self.description = log, [("claim_no",)] + + def execute(self, sql, params): + self.log.append(sql) + return self + + def fetchall(self): + return [] + + class Conn: + def __init__(self): + self.log = [] + + def cursor(self): + return Cur(self.log) + + def close(self): + pass + + conn = Conn() + SqlResultsRepo("DSN=x", connect=lambda cs, timeout: conn).day_results(DAY) + assert "started_by" in conn.log[0], conn.log[0] + row = _row("9000001", "Pass", 1) + row["started_by"] = "olu" + client, _ = signed_in(FakeRepo(rows=[row]), FakeRunner()) + page = client.get(f"/day/{DAY.isoformat()}").text + assert '["Started By", "olu"]' in page, "a field of the side panel" + + +@case +def a_table_without_the_column_says_run_the_script_again(): + client, _ = signed_in(FakeRepo(fail=DbError("42S22", "Invalid column " + "name 'started_by'")), + FakeRunner()) + r = _quiet(client.get, "/days") + assert r.status_code == 503 and "needs an update" in r.text, r.text + assert "create_tables.sql" in r.text + + +def main() -> int: + failures = 0 + for fn in CASES: + name = fn.__name__.replace("_", " ") + try: + fn() + print(f" ok {name}") + except AssertionError as exc: + print(f" FAIL {name}: {exc}") + failures += 1 + except Exception as exc: + print(f" ERROR {name}: {type(exc).__name__}: {exc}") + failures += 1 + print(f"\n{len(CASES) - failures}/{len(CASES)} passed") + return 1 if failures else 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tests/test_run_events.py b/tests/test_run_events.py new file mode 100644 index 0000000..840f9f8 --- /dev/null +++ b/tests/test_run_events.py @@ -0,0 +1,193 @@ +""" +Run events — `core/run_events.py` (spec 2026-10-22 Stage 1, EEWA-31). + + python tests\\test_run_events.py + +Every Run writes its events, each with its time, to +`Logs\\events_.jsonl` beside `run_.log`. What has to hold: + + * **The file sits beside the log, named after it**, so the Portal finds a + Run's events from the log path `bot_run.json` already holds. + * **One JSON object per line, flushed at once** — a killed Run still + leaves every event it wrote. + * **An event never stops a Run**: no file, a bad value, a closed handle — + the event is dropped and the Run goes on. + * **eCW Login Attempts are numbered** and every step carries its attempt. + * **A Run Error carries the traceback** of the exception that caused it. + * **No secret reaches the file**: the run_start fields are a fixed list. +""" + +import os +import sys +import json +import pathlib +import tempfile + +ROOT = pathlib.Path(__file__).resolve().parent.parent +sys.path.insert(0, str(ROOT / "src")) + +from ecw_bot.core import run_events # noqa: E402 + +CASES = [] + + +def case(fn): + CASES.append(fn) + return fn + + +def _fresh(): + run_events._reset() + d = pathlib.Path(tempfile.mkdtemp()) + log = d / "run_20261022_141530.log" + log.write_text("") + return log + + +def _read(log): + path = run_events.path_for(log) + return [json.loads(l) for l in path.read_text(encoding="utf-8").splitlines()] + + +@case +def the_events_file_is_named_after_the_log(): + log = pathlib.Path(r"D:\x\Logs\run_20261022_141530.log") + assert run_events.path_for(log).name == "events_20261022_141530.jsonl" + assert run_events.path_for(log).parent == log.parent + assert run_events.run_id_of(log) == "20261022_141530" + + +@case +def start_writes_run_start_with_source_and_who(): + log = _fresh() + os.environ["ECW_STARTED_BY"] = "asha" + os.environ["ECW_RUN_SOURCE"] = "web" + try: + run_events.start(log, mode="VERIFY", claim_limit=20, + test_sheet=r"D:\Ecw Process\Input\sheet.xlsx") + finally: + del os.environ["ECW_STARTED_BY"], os.environ["ECW_RUN_SOURCE"] + ev = _read(log) + assert len(ev) == 1 and ev[0]["kind"] == "run_start", ev + e = ev[0] + assert e["started_by"] == "asha" and e["source"] == "portal", e + assert e["mode"] == "VERIFY" and e["claim_limit"] == 20, e + assert e["test_sheet"] == "sheet.xlsx", e # file name only + assert e["pid"] == os.getpid() and "t" in e, e + assert len(e["t"]) == 23, e["t"] # ms: 2026-10-22T14:15:30.123 + + +@case +def a_terminal_run_is_started_by_the_windows_user(): + log = _fresh() + os.environ.pop("ECW_STARTED_BY", None) + os.environ.pop("ECW_RUN_SOURCE", None) + run_events.start(log, mode="VERIFY") + e = _read(log)[0] + assert e["source"] == "terminal", e + assert e["started_by"] and e["started_by"] == run_events._windows_user(), e + + +@case +def emit_appends_one_line_per_event(): + log = _fresh() + run_events.start(log, mode="VERIFY") + run_events.emit("queue", claims=["1", "2"]) + run_events.warning("not_found", "not in eCW", claim="2") + ev = _read(log) + assert [e["kind"] for e in ev] == ["run_start", "queue", "warning"], ev + assert ev[2]["code"] == "not_found" and ev[2]["claim"] == "2", ev[2] + + +@case +def login_attempts_are_numbered_and_steps_carry_the_attempt(): + log = _fresh() + run_events.start(log, mode="VERIFY") + run_events.login_attempt_start() + run_events.login_step("page_open") + run_events.login_attempt_end("twofa_timeout", "no mail") + run_events.login_attempt_start() + run_events.login_step("home") + run_events.login_attempt_end("ok") + ev = [e for e in _read(log) if e["kind"] != "run_start"] + assert [(e["kind"], e["attempt"]) for e in ev] == [ + ("login_attempt_start", 1), ("login_step", 1), + ("login_attempt_end", 1), ("login_attempt_start", 2), + ("login_step", 2), ("login_attempt_end", 2)], ev + assert ev[2]["result"] == "twofa_timeout" and ev[2]["message"] == "no mail" + assert ev[1]["step"] == "page_open" + + +@case +def a_run_error_carries_the_traceback(): + log = _fresh() + run_events.start(log, mode="VERIFY") + try: + raise ValueError("boom") + except ValueError as exc: + run_events.run_error("claims", str(exc), 5, exc=exc) + e = _read(log)[-1] + assert e["kind"] == "run_error" and e["exit_code"] == 5, e + assert e["step"] == "claims" and e["message"] == "boom", e + assert "ValueError: boom" in e["traceback"], e["traceback"] + + +@case +def a_run_error_without_an_exception_has_no_traceback(): + log = _fresh() + run_events.start(log, mode="VERIFY") + run_events.run_error("config", "ECW_URL is not set", 2) + assert _read(log)[-1]["traceback"] == "" + + +@case +def before_start_and_after_a_failure_nothing_raises(): + run_events._reset() + run_events.emit("queue", claims=[]) # not started: dropped + run_events.warning("x", "y") + log = _fresh() + run_events.start(log, mode="VERIFY") + run_events.emit("odd", value=object()) # not JSON: str() it + assert _read(log)[-1]["value"].startswith(" int: + failures = 0 + for fn in CASES: + name = fn.__name__.replace("_", " ") + try: + fn() + print(f" ok {name}") + except AssertionError as exc: + print(f" FAIL {name}: {exc}") + failures += 1 + except Exception as exc: + print(f" ERROR {name}: {type(exc).__name__}: {exc}") + failures += 1 + run_events._reset() + print(f"\n{len(CASES) - failures}/{len(CASES)} passed") + return 1 if failures else 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tests/test_run_events_flow.py b/tests/test_run_events_flow.py new file mode 100644 index 0000000..d0e8cd7 --- /dev/null +++ b/tests/test_run_events_flow.py @@ -0,0 +1,280 @@ +""" +Where the flow writes its Run events — `flows/export_medcor_ready.py` +(spec 2026-10-22 Stage 1, EEWA-31). No browser, no eCW: the login, guard, +run lock and disk are stand-ins. + + python tests\\test_run_events_flow.py + + * **Every claim** gets a `claim_result`; a search that raised, a form not + read and a form that never loaded are Claim Errors; not found is a + Warning. + * **Each eCW Login Attempt** has a start and an end with its result — + OK, 2FA timeout (and the retry is a second attempt), restricted, + captcha failed, failed. + * **The disk check**: under DISK_STOP_MB stops before login (Run Error, + step disk); under DISK_WARN_MB is a Warning; enough space, nothing. + * **main()** always ends the file with `run_end` and its exit code, and a + raise that escapes `_main` is a Run Error with its traceback. + +Claim numbers here are made up. +""" + +import sys +import json +import pathlib +import tempfile +from collections import namedtuple + +ROOT = pathlib.Path(__file__).resolve().parent.parent +sys.path.insert(0, str(ROOT / "src")) + +from ecw_bot import settings as cfg # noqa: E402 +from ecw_bot.core import run_events # noqa: E402 +from ecw_bot.flows import export_medcor_ready as flow # noqa: E402 +from ecw_bot.auth.dead_end import LoginTimedOut # noqa: E402 +from ecw_bot.auth.restricted import Restricted # noqa: E402 + +CASES = [] + + +def case(fn): + CASES.append(fn) + return fn + + +def _started(): + run_events._reset() + d = pathlib.Path(tempfile.mkdtemp()) + log = d / "run_20261022_141530.log" + run_events.start(log, mode="VERIFY") + return log + + +def _events(log, kind=None): + lines = run_events.path_for(log).read_text(encoding="utf-8").splitlines() + ev = [json.loads(l) for l in lines] + return [e for e in ev if kind is None or e["kind"] == kind] + + +class _Patch: + """Set attributes for one case and put them back.""" + def __init__(self, *triples): + self.triples = triples + + def __enter__(self): + self.saved = [(o, n, getattr(o, n)) for o, n, _ in self.triples] + for o, n, v in self.triples: + setattr(o, n, v) + + def __exit__(self, *exc): + for o, n, v in self.saved: + setattr(o, n, v) + + +# ── Claims ──────────────────────────────────────────────────────────────────── + +@case +def a_read_claim_has_only_its_result(): + log = _started() + flow._claim_events(1, "9001", {"found": True}, {"cpt": []}, + {"Category": "Pass"}) + assert [e["kind"] for e in _events(log)][1:] == ["claim_result"] + assert _events(log, "claim_result")[0]["category"] == "Pass" + + +@case +def a_search_that_raised_is_a_claim_error_not_a_not_found(): + log = _started() + flow._claim_events(1, "9001", {"found": False, "search_error": "timeout"}, + {"_search_error": "timeout"}, {"Category": "Review"}) + errs = _events(log, "claim_error") + assert [(e["claim"], e["step"]) for e in errs] == [("9001", "search")] + assert not _events(log, "warning") + + +@case +def a_claim_not_found_is_a_warning(): + log = _started() + flow._claim_events(2, "9002", {"found": False}, {}, {}) + assert _events(log, "claim_result")[0]["category"] == "Not found" + w = _events(log, "warning") + assert [(e["code"], e["claim"]) for e in w] == [("not_found", "9002")] + + +@case +def a_form_not_read_and_a_form_not_loaded_are_claim_errors(): + log = _started() + flow._claim_events(1, "9001", {"found": True}, {"_error": "boom"}, + {"Category": "Review"}) + flow._claim_events(2, "9002", {"found": True}, + {"_not_evaluated": "rows never came"}, + {"Category": "Review"}) + errs = _events(log, "claim_error") + assert [(e["claim"], e["step"]) for e in errs] == [ + ("9001", "form"), ("9002", "load")], errs + + +# ── Disk ────────────────────────────────────────────────────────────────────── + +Usage = namedtuple("Usage", "total used free") + + +def _disk(free_mb): + return lambda drive: Usage(0, 0, free_mb * 1024 * 1024) + + +@case +def a_nearly_full_disk_stops_before_login(): + log = _started() + with _Patch((flow.shutil, "disk_usage", _disk(100))): + assert flow._disk_check() is False + e = _events(log, "run_error")[0] + assert e["step"] == "disk" and e["exit_code"] == 2, e + assert _events(log, "disk")[0]["free_mb"] == 100 + + +@case +def a_low_disk_is_a_warning_and_goes_on(): + log = _started() + with _Patch((flow.shutil, "disk_usage", _disk(1000))): + assert flow._disk_check() is True + assert [e["code"] for e in _events(log, "warning")] == ["disk_low"] + assert not _events(log, "run_error") + + +@case +def enough_disk_is_no_warning(): + log = _started() + with _Patch((flow.shutil, "disk_usage", _disk(50_000))): + assert flow._disk_check() is True + assert not _events(log, "warning") and not _events(log, "run_error") + + +# ── eCW Login Attempts ──────────────────────────────────────────────────────── + +def _login_patches(login): + g = flow.guard + return _Patch((g, "wait_if_cooling", lambda: None), + (g, "check", lambda: None), + (g, "record_attempt", lambda: None), + (flow, "login", login), + (cfg, "LOGIN_RETRY_ON_TIMEOUT", 1)) + + +def _attempt_ends(log): + return [(e["attempt"], e["result"]) + for e in _events(log, "login_attempt_end")] + + +@case +def a_timed_out_attempt_then_an_ok_one(): + log = _started() + calls = [] + + def login(pw, name): + calls.append(1) + if len(calls) == 1: + raise LoginTimedOut("no mail") + return ("b", "c", "p") + + with _login_patches(login): + assert flow._login_with_retry(None) == ("b", "c", "p") + assert _attempt_ends(log) == [(1, "twofa_timeout"), (2, "ok")] + assert [e["attempt"] for e in _events(log, "login_attempt_start")] == [1, 2] + + +@case +def restricted_captcha_and_other_failures_end_their_attempt(): + for exc, result in ((Restricted("blocked"), "restricted"), + (RuntimeError("CAPTCHA not solved"), "captcha_failed"), + (RuntimeError("Login failed"), "failed")): + log = _started() + + def login(pw, name, exc=exc): + raise exc + + with _login_patches(login): + try: + flow._login_with_retry(None) + raise AssertionError("did not raise") + except type(exc): + pass + assert _attempt_ends(log) == [(1, result)], (result, _attempt_ends(log)) + + +# ── main() ──────────────────────────────────────────────────────────────────── + +def _main_patches(log, inner): + return _Patch((flow.runlog, "start", lambda: log), + (flow.run_lock, "acquire", lambda p: {}), + (flow.run_lock, "finish", lambda c: None), + (flow, "_main", inner)) + + +@case +def main_ends_the_file_with_the_exit_code(): + run_events._reset() + log = pathlib.Path(tempfile.mkdtemp()) / "run_20261022_141530.log" + with _main_patches(log, lambda p: 3): + assert flow.main() == 3 + ev = _events(log) + assert ev[0]["kind"] == "run_start" and ev[-1]["kind"] == "run_end", ev + assert ev[-1]["exit_code"] == 3 + + +@case +def a_raise_escaping_main_is_a_run_error_with_its_traceback(): + run_events._reset() + log = pathlib.Path(tempfile.mkdtemp()) / "run_20261022_141530.log" + + def boom(p): + raise KeyError("lost") + + with _main_patches(log, boom): + try: + flow.main() + raise AssertionError("did not raise") + except KeyError: + pass + err = _events(log, "run_error")[0] + assert err["step"] == "unexpected" and err["exit_code"] == 1, err + assert "KeyError" in err["traceback"], err + assert _events(log)[-1] == {**_events(log)[-1], "kind": "run_end", + "exit_code": 1} + + +@case +def a_busy_run_lock_is_a_run_error_and_an_end(): + run_events._reset() + log = pathlib.Path(tempfile.mkdtemp()) / "run_20261022_141530.log" + + def busy(p): + raise flow.run_lock.Busy("A bot run is already going") + + with _Patch((flow.runlog, "start", lambda: log), + (flow.run_lock, "acquire", busy)): + assert flow.main() == 2 + kinds = [e["kind"] for e in _events(log)] + assert kinds == ["run_start", "run_error", "run_end"], kinds + + +def main() -> int: + failures = 0 + for fn in CASES: + name = fn.__name__.replace("_", " ") + try: + fn() + print(f" ok {name}") + except AssertionError as exc: + print(f" FAIL {name}: {exc}") + failures += 1 + except Exception as exc: + print(f" ERROR {name}: {type(exc).__name__}: {exc}") + failures += 1 + run_events._reset() + print(f"\n{len(CASES) - failures}/{len(CASES)} passed") + return 1 if failures else 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tests/test_run_lock.py b/tests/test_run_lock.py new file mode 100644 index 0000000..88968ff --- /dev/null +++ b/tests/test_run_lock.py @@ -0,0 +1,350 @@ +r""" +core/run_lock.py — the record of the latest bot run, Logs\bot_run.json +(spec 2026-10-14-start-bot-from-web-page-design.md). + + python tests\test_run_lock.py + +Every case runs with cfg.LOGS pointed at a temp folder. +""" + +import os +import sys +import json +import pathlib +import tempfile +import subprocess +from datetime import datetime, timedelta + +ROOT = pathlib.Path(__file__).resolve().parent.parent +sys.path.insert(0, str(ROOT / "src")) + +from ecw_bot import settings as cfg # noqa: E402 +from ecw_bot.core import run_lock # noqa: E402 + +CASES = [] + + +def case(fn): + CASES.append(fn) + return fn + + +def _dead_pid() -> int: + p = subprocess.Popen([sys.executable, "-c", "pass"]) + p.wait() + return p.pid + + +@case +def pid_alive_is_true_for_this_python_process(): + assert run_lock.pid_alive(os.getpid()) is True + + +@case +def pid_alive_is_false_for_junk_and_ended_processes(): + for junk in (None, "", "x", 0, -5): + assert run_lock.pid_alive(junk) is False, junk + assert run_lock.pid_alive(_dead_pid()) is False + + +@case +def pid_alive_is_false_for_a_live_process_that_is_not_python(): + """A stale record's pid reused by another program must not read as a + live bot run.""" + p = subprocess.Popen(["ping", "-n", "6", "127.0.0.1"], + stdout=subprocess.DEVNULL) + try: + assert run_lock.pid_alive(p.pid) is False + finally: + p.kill() + p.wait() + + +@case +def process_created_reads_the_creation_time(): + made = run_lock.process_created(os.getpid()) + assert made is not None and made <= datetime.now(), made + assert datetime.now() - made < timedelta(days=1), made + assert run_lock.process_created(_dead_pid()) is None + assert run_lock.process_created(None) is None + + +@case +def process_created_is_none_for_an_ended_process_still_held_open(): + """The Popen object still holds its handle after wait(): the ended + process can be opened and has times, but it is not running.""" + p = subprocess.Popen([sys.executable, "-c", "pass"]) + p.wait() + assert run_lock.process_created(p.pid) is None + del p + + +@case +def an_offset_aware_start_time_keeps_the_pid_verdict(): + """A record whose started_at carries a UTC offset must not crash + is_live() (aware vs naive) — in the page or in the bot's acquire().""" + rec = {"pid": os.getpid(), "started_at": "2026-10-14T10:00:00+05:30", + "ended_at": None} + assert run_lock.is_live(rec) is True + run_lock.path().parent.mkdir(parents=True, exist_ok=True) + run_lock.path().write_text(json.dumps(rec), encoding="utf-8") + try: + run_lock.acquire("b.log") + except run_lock.Busy: + pass + else: + raise AssertionError("acquire went over a live record") + + +@case +def a_reused_pid_does_not_read_as_live(): + """The record's pid is this (live python) process, but the record was + started an hour before this process existed: the pid was reused.""" + old = run_lock.process_created(os.getpid()) - timedelta(hours=1) + run_lock.path().parent.mkdir(parents=True, exist_ok=True) + run_lock.path().write_text(json.dumps( + {"pid": os.getpid(), + "started_at": old.isoformat(timespec="seconds"), + "log_path": "old.log", "ended_at": None}), encoding="utf-8") + assert run_lock.is_live(run_lock.read()) is False + rec = run_lock.acquire("new.log") + assert rec["log_path"] == "new.log", rec + + +@case +def an_unreadable_start_time_keeps_the_pid_verdict(): + rec = {"pid": os.getpid(), "started_at": "garbage", "ended_at": None} + assert run_lock.is_live(rec) is True + assert run_lock.is_live({"pid": os.getpid(), "ended_at": None}) is True + + +@case +def acquire_writes_this_process_record(): + rec = run_lock.acquire(pathlib.Path("Logs/run_x.log"), source="web") + assert rec["pid"] == os.getpid() and rec["source"] == "web", rec + assert rec["ended_at"] is None and rec["exit_code"] is None, rec + on_disk = json.loads(run_lock.path().read_text(encoding="utf-8")) + assert on_disk == rec, on_disk + + +@case +def acquire_source_defaults_to_the_env_then_terminal(): + os.environ.pop("ECW_RUN_SOURCE", None) + assert run_lock.acquire("a.log")["source"] == "terminal" + run_lock.finish(0) + os.environ["ECW_RUN_SOURCE"] = "web" + try: + assert run_lock.acquire("a.log")["source"] == "web" + finally: + os.environ.pop("ECW_RUN_SOURCE", None) + + +@case +def a_live_record_refuses_a_second_run(): + run_lock.acquire("a.log") + try: + run_lock.acquire("b.log") + except run_lock.Busy as exc: + assert "already going" in str(exc), exc + else: + raise AssertionError("second acquire was allowed") + + +@case +def a_stale_record_of_a_dead_pid_is_replaced(): + run_lock.path().parent.mkdir(parents=True, exist_ok=True) + run_lock.path().write_text(json.dumps( + {"pid": _dead_pid(), "started_at": "2026-10-14T09:00:00", + "log_path": "old.log", "ended_at": None}), encoding="utf-8") + rec = run_lock.acquire("new.log") + assert rec["log_path"] == "new.log", rec + + +@case +def an_ended_record_does_not_block(): + run_lock.acquire("a.log") + run_lock.finish(0) + assert run_lock.acquire("b.log")["log_path"] == "b.log" + + +@case +def finish_records_the_exit_code_and_end_time(): + run_lock.acquire("a.log") + run_lock.finish(5) + rec = run_lock.read() + assert rec["exit_code"] == 5 and rec["ended_at"], rec + assert run_lock.is_live(rec) is False + + +@case +def finish_leaves_another_process_record_alone(): + run_lock.path().parent.mkdir(parents=True, exist_ok=True) + other = {"pid": os.getpid() + 100000, "ended_at": None, + "exit_code": None} + run_lock.path().write_text(json.dumps(other), encoding="utf-8") + run_lock.finish(0) + assert run_lock.read() == other + + +@case +def finish_never_raises_without_a_record(): + run_lock.finish(0) # no file at all + run_lock.path().parent.mkdir(parents=True, exist_ok=True) + run_lock.path().write_text("{not json", encoding="utf-8") + run_lock.finish(0) + assert run_lock.read() is None + + +@case +def mark_stopped_ends_the_named_run(): + rec = run_lock.acquire("a.log") + run_lock.mark_stopped(rec["pid"]) + rec = run_lock.read() + assert rec["stopped_by_operator"] is True and rec["ended_at"], rec + assert rec["exit_code"] is None, rec + + +# ── the flow: main() and the [RESULT] line ─────────────────────────────────── + +import io # noqa: E402 +import contextlib # noqa: E402 + +from ecw_bot.flows import export_medcor_ready as flow # noqa: E402 + + +def _main_with(inner): + """flow.main() with runlog stubbed and `_main` replaced by `inner`.""" + orig = (flow.runlog.start, flow._main) + # In a temp folder: main() writes the Run's events beside the log, and + # a test must not leave one in the real Logs\. + test_log = pathlib.Path(tempfile.mkdtemp()) / "run_test.log" + flow.runlog.start = lambda: test_log + flow._main = inner + out = io.StringIO() + try: + with contextlib.redirect_stdout(out): + return flow.main(), out.getvalue() + finally: + flow.runlog.start, flow._main = orig + + +@case +def main_records_the_run_and_its_exit_code(): + seen = {} + + def inner(log_path): + seen["live"] = run_lock.is_live(run_lock.read()) + seen["log"] = str(log_path) + return 5 + code, _ = _main_with(inner) + assert code == 5 + assert seen["live"] is True and seen["log"].endswith("run_test.log"), seen + rec = run_lock.read() + assert rec["exit_code"] == 5 and rec["ended_at"], rec + + +@case +def main_finishes_the_record_when_the_run_raises(): + def inner(log_path): + raise RuntimeError("boom") + try: + _main_with(inner) + except RuntimeError: + pass + rec = run_lock.read() + assert rec["ended_at"] and rec["exit_code"] == 1, rec + + +@case +def main_refuses_a_second_run_before_login(): + run_lock.acquire("other.log") # this process: live python + called = [] + code, out = _main_with(lambda log_path: called.append(1) or 0) + assert code == 2 and called == [], (code, out) + assert "[RUN] A bot run is already going" in out, out + + +@case +def main_stops_cleanly_when_the_record_cannot_be_written(): + """acquire() failing for a reason other than Busy (disk full, a locked + file): a [RUN] line and exit 2, not a traceback.""" + def broken(log_path, source=None): + raise PermissionError(13, "Access is denied\nsecond line") + orig = flow.run_lock.acquire + flow.run_lock.acquire = broken + called = [] + try: + code, out = _main_with(lambda log_path: called.append(1) or 0) + finally: + flow.run_lock.acquire = orig + assert code == 2 and called == [], (code, out) + assert ("[RUN] Could not write the run record ([Errno 13] Access is " + "denied) — not starting") in out, out + + +@case +def each_claim_prints_its_result_line(): + orig = (cfg.SHOW_EACH_CLAIM, cfg.SHOW_CLAIM_IDS, cfg.CLAIM_PROCESS_LIMIT, + flow.claim_results.search_claim, flow._read_claim_form, + flow.claim_filters.clear_claim_no, flow._write_charge_summary, + flow._save_results_db, flow.claim_form.clear_screen) + cfg.SHOW_EACH_CLAIM = False + cfg.SHOW_CLAIM_IDS = False + cfg.CLAIM_PROCESS_LIMIT = 0 + + def search(page, cid): + return {"id": cid, "count": 1, "found": cid != "3", "seconds": 1.0} + flow.claim_results.search_claim = search + flow._read_claim_form = lambda page, cid, head: { + "claim_no": cid, + "cpt": [{"code": "99213", "checked": True, "fee": "100.00"}], + "icd": [{"code": "Z00.00"}], + "progress_notes": {"opened": "modal", "text": "Office visit."}, + "_conditions": {"fired": [], "saved": False, "failed": [], + "review": [], "comments": {}}} + flow.claim_filters.clear_claim_no = lambda page: None + flow._write_charge_summary = lambda info, o: None + flow._save_results_db = lambda info, o, d: None + flow.claim_form.clear_screen = lambda page, cid, keep=(): [] + out = io.StringIO() + try: + with contextlib.redirect_stdout(out): + flow._process_claims(None, {"ids": ["1", "3"], "file": None}) + finally: + (cfg.SHOW_EACH_CLAIM, cfg.SHOW_CLAIM_IDS, cfg.CLAIM_PROCESS_LIMIT, + flow.claim_results.search_claim, flow._read_claim_form, + flow.claim_filters.clear_claim_no, flow._write_charge_summary, + flow._save_results_db, flow.claim_form.clear_screen) = orig + text = out.getvalue() + assert "[RESULT] claim 1/2 1: Pass" in text, text + assert "[RESULT] claim 2/2 3: Not found" in text, text + # The whole queue up front (2026-10-15): the page's Claim status panel + # lists the claims still waiting, which no other line names. + assert "[CLAIMS] Queue: 1, 3" in text, text + assert text.index("[CLAIMS] Queue:") < text.index("--- claim 1/2"), text + + +def main() -> int: + failures = 0 + orig = cfg.LOGS + for fn in CASES: + name = fn.__name__.replace("_", " ") + with tempfile.TemporaryDirectory() as tmp: + cfg.LOGS = pathlib.Path(tmp) / "Logs" + try: + fn() + print(f" ok {name}") + except AssertionError as exc: + print(f" FAIL {name}: {exc}") + failures += 1 + except Exception as exc: + print(f" ERROR {name}: {type(exc).__name__}: {exc}") + failures += 1 + finally: + cfg.LOGS = orig + print(f"\n{len(CASES) - failures}/{len(CASES)} passed") + return 1 if failures else 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tests/test_run_status.py b/tests/test_run_status.py new file mode 100644 index 0000000..0a84775 --- /dev/null +++ b/tests/test_run_status.py @@ -0,0 +1,342 @@ +r""" +web/run_status.py — the bot's run log read into the page's live status. + + python tests\test_run_status.py +""" + +import sys +import pathlib +import tempfile + +ROOT = pathlib.Path(__file__).resolve().parent.parent +sys.path.insert(0, str(ROOT)) +sys.path.insert(0, str(ROOT / "src")) + +from web import run_status as rs # noqa: E402 + +CASES = [] + + +def case(fn): + CASES.append(fn) + return fn + + +def _status(**over): + """The runner's status (web/bot_runner.py): no run yet, unless `over` + says otherwise.""" + st = {"running": False, "starting": False, "log_path": None, + "out_path": None, "exit_code": None, "stopped_by_operator": False, + "started_at": None, "ended_at": None} + st.update(over) + return st + + +LOG = """\ +====================================================================== +2026-10-14 10:00:00 — run start +[CONFIG] user=x otp=imap +[LOGIN] Opening the login page +[OTP] Waiting for the approval mail +[OK] Logged in in 95s +[CLAIMS] --- claim 1/3: 9000001 --- +[RESULT] claim 1/3 9000001: Pass +[CLAIMS] --- claim 2/3: 9000002 --- +[CLAIMS] --- claim 2/3 done, moving to next --- +[RESULT] claim 2/3 9000002: Fail +[CLAIMS] --- claim 3/3: 9000003 --- +""" + + +@case +def phase_follows_the_last_tagged_line(): + assert rs.parse("[LOGIN] x")["phase"] == "Logging in" + assert rs.parse("[LOGIN] x\n[2FA] y")["phase"] == \ + "Waiting for the 2FA approval mail" + assert rs.parse("[OTP] y\n[OK] Logged in in 3s")["phase"] == "Logged in" + assert rs.parse(LOG)["phase"] == "Checking claims" + assert rs.parse("")["phase"] == "Starting" + + +@case +def progress_and_current_claim_come_from_the_claim_header(): + p = rs.parse(LOG) + assert p["progress"] == [3, 3] and p["current"] == "9000003", p + + +@case +def a_done_line_is_not_a_claim_header(): + p = rs.parse("[CLAIMS] --- claim 2/3 done, moving to next ---") + assert p["progress"] is None, p + + +@case +def counts_come_from_result_lines(): + c = rs.parse(LOG + "[RESULT] claim 3/3 9000003: Review\n")["counts"] + assert c == {"Pass": 1, "Fail": 1, "Review": 1, "Not found": 0}, c + + +@case +def tail_is_the_last_lines(): + text = "\n".join(f"line {i}" for i in range(100)) + tail = rs.parse(text)["tail"] + assert len(tail) == rs.TAIL_LINES and tail[-1] == "line 99", tail + + +@case +def guard_line_is_kept(): + p = rs.parse("[GUARD] Refused: next attempt allowed at 10:40") + assert p["guard"] == "[GUARD] Refused: next attempt allowed at 10:40" + + +@case +def exit_codes_read_as_plain_words(): + assert rs.exit_text(0) == "Finished" + assert "setting" in rs.exit_text(2) + assert "guard" in rs.exit_text(3) + assert "claims step failed" in rs.exit_text(5) + assert "2FA" in rs.exit_text(6) + assert "restricted" in rs.exit_text(4) and "do not retry" in rs.exit_text(4) + assert rs.exit_text(1) == "The run failed (see the log)" + assert rs.exit_text(9) == "Ended with code 9" + assert rs.exit_text(None, stopped=True) == "Stopped by operator" + assert "no exit code" in rs.exit_text(None) + + +@case +def read_tail_reads_the_end_of_a_file_and_missing_is_empty(): + with tempfile.TemporaryDirectory() as tmp: + p = pathlib.Path(tmp) / "run.log" + p.write_text("a\n" * 1000 + "LAST\n", encoding="utf-8") + assert rs.read_tail(p, max_bytes=100).endswith("LAST\n") + assert rs.read_tail(pathlib.Path(tmp) / "none.log") == "" + assert rs.read_tail(None) == "" + + +@case +def summarize_running_uses_the_phase_as_the_message(): + with tempfile.TemporaryDirectory() as tmp: + p = pathlib.Path(tmp) / "run.log" + p.write_text(LOG, encoding="utf-8") + s = rs.summarize(_status(running=True, log_path=str(p), + started_at="2026-10-14T10:00:00", pid=123, + source="web")) + assert s["message"] == "Checking claims", s + assert s["progress"] == [3, 3], s + for k in ("log_path", "out_path", "pid"): + assert k not in s, (k, s) + assert set(s) == {"running", "starting", "started_at", "ended_at", + "exit_code", "stopped_by_operator", "source", "phase", + "progress", "current", "counts", "tail", "message", + "elapsed_s", "claims", "results_run_at", + "started_by"}, set(s) + + +@case +def summarize_ended_says_how_and_how_long(): + with tempfile.TemporaryDirectory() as tmp: + p = pathlib.Path(tmp) / "run.log" + p.write_text("[GUARD] Refused: next attempt allowed at 10:40\n", + encoding="utf-8") + s = rs.summarize(_status(log_path=str(p), exit_code=3, + started_at="2026-10-14T10:00:00", + ended_at="2026-10-14T10:04:12")) + assert s["message"].startswith("Login guard refused"), s + assert "next attempt allowed at 10:40" in s["message"], s + assert "[GUARD]" not in s["message"], s + assert s["elapsed_s"] == 252, s + + +QUEUE_LOG = """\ +[CLAIMS] Queue: 9000001, 9000002, 9000003, 9000004 +[CLAIMS] --- claim 1/4: 9000001 --- +[RESULT] claim 1/4 9000001: Pass +[CLAIMS] --- claim 2/4: 9000002 --- +[RESULT] claim 2/4 9000002: Fail +[CLAIMS] --- claim 3/4: 9000003 --- +""" + + +@case +def claims_lists_every_queued_claim_with_its_status(): + """The Claim status panel (operator, 2026-10-15): every claim of the + run — done with its result, the one being checked, the ones waiting.""" + claims = rs.parse(QUEUE_LOG)["claims"] + assert claims == [ + {"n": 1, "id": "9000001", "status": "Pass", "done": True}, + {"n": 2, "id": "9000002", "status": "Fail", "done": True}, + {"n": 3, "id": "9000003", "status": "Checking", "done": False}, + {"n": 4, "id": "9000004", "status": "Waiting", "done": False}, + ], claims + + +@case +def claims_without_a_queue_line_come_from_the_claim_lines(): + """An older log, written before the Queue line existed.""" + claims = rs.parse(LOG)["claims"] + assert [c["id"] for c in claims] == ["9000001", "9000002", "9000003"] + assert [c["status"] for c in claims] == ["Pass", "Fail", "Checking"] + + +@case +def a_claim_left_half_way_is_not_finished_once_the_run_ended(): + def summary(running): + with tempfile.TemporaryDirectory() as tmp: + p = pathlib.Path(tmp) / "run.log" + p.write_text(QUEUE_LOG, encoding="utf-8") + return rs.summarize(_status( + running=running, log_path=str(p), + stopped_by_operator=not running, + started_at="2026-10-15T10:00:00", + ended_at=None if running else "2026-10-15T10:05:00")) + live = summary(True)["claims"] + assert live[2]["status"] == "Checking" and live[3]["status"] == "Waiting" + ended = summary(False)["claims"] + assert ended[2]["status"] == "Not finished", ended + assert ended[3]["status"] == "Not checked", ended + assert [c["done"] for c in ended] == [True, True, False, False], ended + + +@case +def a_not_found_claim_is_done_with_no_result_category(): + """Not found ends the claim (it counts as done) — the panel shows it + done, but it has no results row to open.""" + claims = rs.parse("[CLAIMS] Queue: 9000001, 9000002\n" + "[CLAIMS] --- claim 1/2: 9000001 ---\n" + "[RESULT] claim 1/2 9000001: Not found\n")["claims"] + assert claims[0] == {"n": 1, "id": "9000001", "status": "Not found", + "done": True}, claims + assert claims[1]["done"] is False, claims + + +@case +def the_results_run_at_line_names_the_run_the_claims_are_saved_in(): + """Review 2026-10-19: the Claim status panel opened a claim in the run + the table showed, not the one it was saved in. The bot names that run + right after the Queue line (only with RESULTS_DB=1).""" + text = ("[CLAIMS] Queue: 9000001, 9000002\n" + "[CLAIMS] Results run_at: 2026-10-16T19:08:37\n" + "[CLAIMS] --- claim 1/2: 9000001 ---\n") + assert rs.parse(text)["results_run_at"] == "2026-10-16T19:08:37" + with tempfile.TemporaryDirectory() as tmp: + p = pathlib.Path(tmp) / "run.log" + p.write_text(text, encoding="utf-8") + s = rs.summarize(_status(running=True, log_path=str(p))) + assert s["results_run_at"] == "2026-10-16T19:08:37", s + + +@case +def no_results_run_at_line_is_none(): + """RESULTS_DB off (or an older log): no line, no saved run.""" + assert rs.parse(QUEUE_LOG)["results_run_at"] is None + assert rs.parse("")["results_run_at"] is None + assert rs.summarize(_status())["results_run_at"] is None + + +@case +def a_later_run_in_the_same_log_drops_the_earlier_runs_time(): + """A new Queue line starts a new run: a run_at from before it is not + this run's.""" + text = ("[CLAIMS] Queue: 9000001\n" + "[CLAIMS] Results run_at: 2026-10-16T19:08:37\n" + "[CLAIMS] Queue: 9000002\n") + assert rs.parse(text)["results_run_at"] is None + + +@case +def a_results_run_at_that_is_not_a_time_is_ignored(): + """It goes into the page's link: only the bot's own ISO form counts.""" + for bad in ("[CLAIMS] Results run_at: 2026-10-16T19:08:37&x=1", + "[CLAIMS] Results run_at: soon", + "[CLAIMS] Results run_at: "): + assert rs.parse(bad)["results_run_at"] is None, bad + + +@case +def a_run_that_ended_with_no_end_time_has_no_duration(): + """Review 2026-10-14: a run killed outside the page (or crashed) never + wrote ended_at; measured to now(), its duration grew on every poll — + "Ended ... in 87m 3s". No end time, no duration. A running run still + counts up to now.""" + ended = rs.summarize(_status(started_at="2026-10-14T10:00:00")) + assert ended["elapsed_s"] is None, ended + running = rs.summarize(_status(running=True, + started_at="2026-10-14T10:00:00")) + assert isinstance(running["elapsed_s"], int), running + + +@case +def summarize_starting_reads_the_child_output_file(): + with tempfile.TemporaryDirectory() as tmp: + out = pathlib.Path(tmp) / "web_run.out" + out.write_text("Traceback: boom\n", encoding="utf-8") + s = rs.summarize(_status(running=True, starting=True, + out_path=str(out))) + assert s["message"] == "Starting" and s["tail"] == ["Traceback: boom"], s + + +@case +def a_done_line_with_extra_words_is_not_a_claim_header(): + p = rs.parse("[CLAIMS] --- claim 2/3 done (shown in its review box), " + "moving to next ---") + assert p["progress"] is None and p["current"] is None, p + + +@case +def a_crlf_log_parses_the_same(): + a, b = rs.parse(LOG), rs.parse(LOG.replace("\n", "\r\n")) + for k in ("phase", "progress", "current", "counts", "guard"): + assert a[k] == b[k], (k, a[k], b[k]) + assert b["tail"] == a["tail"], b["tail"] + + +@case +def summarize_counts_the_whole_log_not_just_its_tail(): + with tempfile.TemporaryDirectory() as tmp: + p = pathlib.Path(tmp) / "run.log" + body = ("[RESULT] claim 1/9 111: Pass\n" + "[RESULT] claim 2/9 222: Fail\n" + + "[CLAIMS] noise line to push the results out of the tail\n" + * 10_000 + + "[CLAIMS] --- claim 9/9: 999 ---\n") + p.write_text(body, encoding="utf-8") + assert p.stat().st_size > 256_000 + s = rs.summarize(_status(running=True, log_path=str(p))) + assert s["counts"]["Pass"] == 1 and s["counts"]["Fail"] == 1, s["counts"] + assert s["progress"] == [9, 9] and len(s["tail"]) == rs.TAIL_LINES, s + + +@case +def the_tail_hides_the_otp_code_and_the_username(): + text = ("[CONFIG] user=jsmith otp=imap proxy=-:- headless=False\n" + "[OTP] Code 482913 in 'Your code 482913' (sent 10:01:02)\n" + "[OTP] Approval link in 'Sign-in' (sent 10:01:02)\n") + p = rs.parse(text) + assert p["tail"] == [ + "[CONFIG] user=(hidden) otp=imap proxy=-:- headless=False", + "[OTP] Code (hidden)", + "[OTP] Approval link in 'Sign-in' (sent 10:01:02)"], p["tail"] + joined = "\n".join(p["tail"]) + assert "482913" not in joined and "jsmith" not in joined, joined + assert p["phase"] == "Waiting for the 2FA approval mail", p + + +def main() -> int: + failures = 0 + for fn in CASES: + name = fn.__name__.replace("_", " ") + try: + fn() + print(f" ok {name}") + except AssertionError as exc: + print(f" FAIL {name}: {exc}") + failures += 1 + except Exception as exc: + print(f" ERROR {name}: {type(exc).__name__}: {exc}") + failures += 1 + print(f"\n{len(CASES) - failures}/{len(CASES)} passed") + return 1 if failures else 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tests/test_runs_page.py b/tests/test_runs_page.py new file mode 100644 index 0000000..4d2baa5 --- /dev/null +++ b/tests/test_runs_page.py @@ -0,0 +1,168 @@ +r""" +The Portal's Runs pages — `/runs`, `/runs/`, `/runs//shot/` +(spec 2026-10-22 Stage 1, EEWA-31), against events files in a temp folder +and the fake repository / runner of test_web_app.py. + + python tests\test_runs_page.py + +Claim numbers here are made up. +""" + +import sys +import json +import pathlib +import tempfile + +ROOT = pathlib.Path(__file__).resolve().parent.parent +sys.path.insert(0, str(ROOT)) +sys.path.insert(0, str(ROOT / "src")) +sys.path.insert(0, str(ROOT / "tests")) + +from test_web_app import FakeRepo, FakeRunner, signed_in # noqa: E402 + +CASES = [] +RUN = "20261022_140000" + + +def case(fn): + CASES.append(fn) + return fn + + +def ev(t, kind, **f): + return {"t": f"2026-10-22T{t}.000", "kind": kind, **f} + + +def _setup(events, runner=None, run_id=RUN): + d = pathlib.Path(tempfile.mkdtemp()) + (d / f"events_{run_id}.jsonl").write_text( + "".join(json.dumps(e) + "\n" for e in events), encoding="utf-8") + shots = d / "Screenshots" + shots.mkdir() + client, _ = signed_in(FakeRepo(), runner or FakeRunner(), logs_dir=d, + screenshots_root=shots) + return client, d, shots + + +START = ev("14:00:00", "run_start", source="portal", started_by="asha", + mode="VERIFY", claim_limit=2, test_sheet="", pid=1, log="x") +ERROR_RUN = [ + START, + ev("14:00:01", "login_attempt_start", attempt=1), + ev("14:00:10", "login_step", attempt=1, step="twofa_requested"), + ev("14:00:52", "login_step", attempt=1, step="mail_received", + mail_kind="link"), + ev("14:01:00", "login_attempt_end", attempt=1, result="ok", message=""), + ev("14:01:10", "queue", claims=["9001", "9002"]), + ev("14:01:11", "claim_start", n=1, total=2, claim="9001"), + ev("14:01:20", "claim_error", claim="9001", step="search", + message="timeout"), + ev("14:01:21", "claim_result", n=1, claim="9001", category="Review"), + ev("14:01:22", "run_error", step="claims", exit_code=5, + message="Target closed", traceback="Traceback (most recent)\nX"), + ev("14:01:23", "run_end", exit_code=5), +] + + +@case +def the_runs_list_shows_a_run_with_its_result_and_counts(): + client, _, _ = _setup(ERROR_RUN) + page = client.get("/runs").text + assert f'href="/runs/{RUN}"' in page, page + assert "asha" in page and "Run Error" in page and "exit 5" in page + assert "1 attempt · 59s" in page, "login count + time" + + +@case +def the_runs_list_filters_and_rejects_a_bad_filter(): + client, _, _ = _setup(ERROR_RUN) + assert RUN not in client.get("/runs?status=Done").text + assert RUN in client.get("/runs?status=Run+Error").text + assert RUN not in client.get("/runs?day_from=2026-10-23").text + assert client.get("/runs?day_from=yesterday").status_code == 400 + assert client.get("/runs?status=Bogus").status_code == 400 + + +@case +def a_runs_page_has_login_errors_not_reached_and_timeline(): + client, _, _ = _setup(ERROR_RUN) + page = client.get(f"/runs/{RUN}").text + assert "eCW Login Attempts" in page and "42s" in page, "2FA wait" + assert "Run Error" in page and "Traceback (most recent)" in page + assert "Claim Errors (1)" in page + assert "Not Reached (1)" in page and "9002" in page + assert "Timeline" in page and "Run ended, exit 5" in page + assert 'http-equiv="refresh"' not in page, "an ended run does not poll" + + +@case +def event_text_reaches_the_page_escaped(): + client, _, _ = _setup(ERROR_RUN) + page = client.get(f"/runs/{RUN}").text + assert "timeout" not in page + assert "<b>timeout</b>" in page + + +@case +def a_running_run_refreshes_every_five_seconds(): + runner = FakeRunner(running=True) + st = runner._st + runner._st = lambda: {**st(), "log_path": rf"D:\x\Logs\run_{RUN}.log"} + client, _, _ = _setup(ERROR_RUN[:7], runner=runner) + page = client.get(f"/runs/{RUN}").text + assert "Running" in page, page + assert '' in page + + +@case +def an_unknown_or_bad_run_is_404(): + client, _, _ = _setup(ERROR_RUN) + assert client.get("/runs/20261022_140001").status_code == 404 + assert client.get("/runs/..%5C..%5Cx").status_code in (404, 400) + + +@case +def a_screenshot_is_served_and_nothing_else(): + d = pathlib.Path(tempfile.mkdtemp()) + client, d, shots = _setup([START]) + png = shots / "140100_failed.png" + png.write_bytes(b"\x89PNG") + (d / f"events_{RUN}.jsonl").write_text( + json.dumps(START) + "\n" + json.dumps( + ev("14:01:00", "screenshot", path=str(png), tag="failed")) + "\n", + encoding="utf-8") + r = client.get(f"/runs/{RUN}/shot/0") + assert r.status_code == 200 and r.content == b"\x89PNG", r.status_code + assert client.get(f"/runs/{RUN}/shot/1").status_code == 404 + page = client.get(f"/runs/{RUN}").text + assert f'/runs/{RUN}/shot/0' in page + + +@case +def every_page_links_the_runs_page_and_carries_no_inline_style(): + client, _, _ = _setup(ERROR_RUN) + for path in ("/", "/runs", f"/runs/{RUN}"): + page = client.get(path).text + assert 'href="/runs"' in page, path + assert " style=" not in page, path + + +def main() -> int: + failures = 0 + for fn in CASES: + name = fn.__name__.replace("_", " ") + try: + fn() + print(f" ok {name}") + except AssertionError as exc: + print(f" FAIL {name}: {exc}") + failures += 1 + except Exception as exc: + print(f" ERROR {name}: {type(exc).__name__}: {exc}") + failures += 1 + print(f"\n{len(CASES) - failures}/{len(CASES)} passed") + return 1 if failures else 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tests/test_runs_view.py b/tests/test_runs_view.py new file mode 100644 index 0000000..74d7c06 --- /dev/null +++ b/tests/test_runs_view.py @@ -0,0 +1,243 @@ +""" +The Runs page's reading of `Logs\\events_.jsonl` — `web/runs.py` +(spec 2026-10-22 Stage 1, EEWA-31). + + python tests\\test_runs_view.py + +What has to hold: + + * **A Run's result**: Done (exit 0), Run Error (any other exit, with the + step and message of its Run Error), Stopped (the Portal's `stopped` + event), Running (no end yet and its process is the live one), Ended + without a record (no end, not live — killed). + * **Each eCW Login Attempt**: start, end, duration, result, every step's + time, and the 2FA wait = mail received − 2FA requested. + * **Counts**: Pass / Fail / Review / Not found, Claim Errors, Warnings. + * **Not Reached**: queued claims never started — only once the Run is + over and did not finish. + * **A half-written last line** (a killed Run) is skipped, not fatal. + * **Only real Run ids and only files inside Screenshots\\** are served. + * Old runs with no events file are not listed. +""" + +import sys +import json +import pathlib +import tempfile +from datetime import datetime + +ROOT = pathlib.Path(__file__).resolve().parent.parent +sys.path.insert(0, str(ROOT)) +sys.path.insert(0, str(ROOT / "src")) + +from web import runs # noqa: E402 + +CASES = [] +NOW = datetime(2026, 10, 22, 15, 0, 0) + + +def case(fn): + CASES.append(fn) + return fn + + +def ev(t, kind, **f): + return {"t": f"2026-10-22T{t}.000", "kind": kind, **f} + + +def _write(d, run_id, events, tail=""): + p = pathlib.Path(d) / f"events_{run_id}.jsonl" + p.write_text("".join(json.dumps(e) + "\n" for e in events) + tail, + encoding="utf-8") + return p + + +START = ev("14:00:00", "run_start", source="portal", started_by="asha", + mode="VERIFY", claim_limit=3, test_sheet="s.xlsx", pid=1, log="x") + +LOGIN = [ + ev("14:00:01", "login_attempt_start", attempt=1), + ev("14:00:05", "login_step", attempt=1, step="page_open"), + ev("14:00:09", "login_step", attempt=1, step="password"), + ev("14:00:10", "login_step", attempt=1, step="twofa_requested"), + ev("14:09:10", "login_attempt_end", attempt=1, result="twofa_timeout", + message="no mail"), + ev("14:09:20", "login_attempt_start", attempt=2), + ev("14:09:25", "login_step", attempt=2, step="page_open"), + ev("14:09:30", "login_step", attempt=2, step="twofa_requested"), + ev("14:10:12", "login_step", attempt=2, step="mail_received", + mail_kind="link"), + ev("14:10:15", "login_step", attempt=2, step="approval_clicked"), + ev("14:10:20", "login_step", attempt=2, step="home"), + ev("14:10:20", "login_attempt_end", attempt=2, result="ok", message=""), +] + +CLAIMS = [ + ev("14:10:30", "queue", claims=["A1", "A2", "A3"]), + ev("14:10:31", "claim_start", n=1, total=3, claim="A1"), + ev("14:10:50", "claim_result", n=1, claim="A1", category="Pass"), + ev("14:10:51", "claim_start", n=2, total=3, claim="A2"), + ev("14:11:00", "claim_error", claim="A2", step="form", message="bad"), + ev("14:11:01", "claim_result", n=2, claim="A2", category="Review"), + ev("14:11:02", "warning", code="fallback", message="Lookup alt"), +] + + +@case +def a_finished_run_is_done_with_its_counts_and_login_attempts(): + d = tempfile.mkdtemp() + _write(d, "20261022_140000", [START, *LOGIN, *CLAIMS, + ev("14:11:03", "claim_start", n=3, total=3, claim="A3"), + ev("14:11:20", "claim_result", n=3, claim="A3", category="Fail"), + ev("14:11:30", "run_end", exit_code=0)]) + r = runs.load_run(d, "20261022_140000", live_run_id=None, now=NOW) + assert r["status"] == "Done" and r["exit_code"] == 0, r["status"] + assert r["started_by"] == "asha" and r["source"] == "portal", r + assert r["duration_s"] == 690, r["duration_s"] + assert (r["pass"], r["fail"], r["review"], r["not_found"]) == (1, 1, 1, 0) + assert len(r["claim_errors"]) == 1 and len(r["warnings"]) == 1, r + assert r["not_reached"] == [], r["not_reached"] + a1, a2 = r["login_attempts"] + assert a1["result"] == "twofa_timeout" and a1["duration_s"] == 549, a1 + assert a1["twofa_wait_s"] is None, a1 # no mail ever came + assert a2["result"] == "ok" and a2["duration_s"] == 60, a2 + assert a2["twofa_wait_s"] == 42, a2 + assert a2["steps"]["home"].endswith("14:10:20.000"), a2["steps"] + assert r["login_total_s"] == 609, r["login_total_s"] + + +@case +def a_run_error_names_its_step_and_lists_the_claims_not_reached(): + d = tempfile.mkdtemp() + _write(d, "20261022_140000", [START, *LOGIN, *CLAIMS, + ev("14:11:05", "run_error", step="claims", exit_code=5, + message="Target closed", traceback="Traceback ...\nError"), + ev("14:11:06", "run_end", exit_code=5)]) + r = runs.load_run(d, "20261022_140000", live_run_id=None, now=NOW) + assert r["status"] == "Run Error" and r["exit_code"] == 5, r["status"] + assert r["error_step"] == "claims" and r["error_message"] == "Target closed" + assert r["run_errors"][0]["traceback"].startswith("Traceback"), r + assert r["not_reached"] == ["A3"], r["not_reached"] + + +@case +def a_running_run_has_no_not_reached_and_counts_up_to_now(): + d = tempfile.mkdtemp() + _write(d, "20261022_140000", [START, *LOGIN, *CLAIMS]) + r = runs.load_run(d, "20261022_140000", live_run_id="20261022_140000", + now=NOW) + assert r["status"] == "Running", r["status"] + assert r["duration_s"] == 3600, r["duration_s"] + assert r["not_reached"] == [], r["not_reached"] + + +@case +def a_killed_run_and_a_stopped_run(): + d = tempfile.mkdtemp() + _write(d, "20261022_140000", [START, *LOGIN, *CLAIMS]) + r = runs.load_run(d, "20261022_140000", live_run_id=None, now=NOW) + assert r["status"] == "Ended without a record", r["status"] + assert r["not_reached"] == ["A3"], r["not_reached"] + _write(d, "20261022_140000", [START, *LOGIN, *CLAIMS, + ev("14:12:00", "stopped", stopped_by="asha")]) + r = runs.load_run(d, "20261022_140000", live_run_id=None, now=NOW) + assert r["status"] == "Stopped" and r["stopped_by"] == "asha", r + assert r["duration_s"] == 720, r["duration_s"] + + +@case +def a_half_written_last_line_is_skipped(): + d = tempfile.mkdtemp() + _write(d, "20261022_140000", [START], tail='{"t": "2026-10-22T14:0') + r = runs.load_run(d, "20261022_140000", live_run_id=None, now=NOW) + assert r["status"] == "Ended without a record", r + + +@case +def the_list_is_newest_first_and_filters_by_date_and_result(): + d = tempfile.mkdtemp() + _write(d, "20261021_090000", [ + {**START, "t": "2026-10-21T09:00:00.000"}, + {"t": "2026-10-21T09:05:00.000", "kind": "run_error", + "step": "twofa_timeout", "exit_code": 6, "message": "x", + "traceback": ""}, + {"t": "2026-10-21T09:05:00.000", "kind": "run_end", "exit_code": 6}]) + _write(d, "20261022_140000", [START, ev("14:01:00", "run_end", + exit_code=0)]) + (pathlib.Path(d) / "run_20261020_080000.log").write_text("old") + (pathlib.Path(d) / "events_bad.jsonl").write_text("{}") + rows = runs.list_runs(d, live_run_id=None, now=NOW) + assert [r["run_id"] for r in rows] == ["20261022_140000", + "20261021_090000"], rows + rows = runs.list_runs(d, live_run_id=None, now=NOW, + day_from="2026-10-22") + assert [r["run_id"] for r in rows] == ["20261022_140000"], rows + rows = runs.list_runs(d, live_run_id=None, now=NOW, + day_to="2026-10-21", status="Run Error") + assert [r["run_id"] for r in rows] == ["20261021_090000"], rows + + +@case +def the_timeline_has_a_line_for_every_event_in_order(): + d = tempfile.mkdtemp() + _write(d, "20261022_140000", [START, *LOGIN[:3]]) + r = runs.load_run(d, "20261022_140000", live_run_id=None, now=NOW) + assert [e["kind"] for e in r["timeline"]] == [ + "run_start", "login_attempt_start", "login_step", "login_step"], r + assert all(e["text"] for e in r["timeline"]), r["timeline"] + + +@case +def a_bad_run_id_or_missing_run_is_none(): + d = tempfile.mkdtemp() + assert runs.load_run(d, "..\\..\\x", live_run_id=None, now=NOW) is None + assert runs.load_run(d, "20261022_140001", live_run_id=None, + now=NOW) is None + assert not runs.valid_run_id("20261022_14000a") + + +@case +def a_screenshot_is_served_only_from_inside_the_screenshots_folder(): + d = pathlib.Path(tempfile.mkdtemp()) + shots = d / "Screenshots" / "10-22-2026" + shots.mkdir(parents=True) + png = shots / "140500_failed.png" + png.write_bytes(b"png") + outside = d / "secret.png" + outside.write_bytes(b"no") + _write(d, "20261022_140000", [START, + ev("14:05:00", "screenshot", path=str(png), tag="failed"), + ev("14:05:01", "screenshot", path=str(outside), tag="failed"), + ev("14:05:02", "screenshot", path=str(shots / "gone.png"), + tag="failed")]) + root = d / "Screenshots" + assert runs.screenshot_path(d, "20261022_140000", 0, root) == png + assert runs.screenshot_path(d, "20261022_140000", 1, root) is None + assert runs.screenshot_path(d, "20261022_140000", 2, root) is None + assert runs.screenshot_path(d, "20261022_140000", 9, root) is None + page = shots / "140500_failed.html" # eCW's own scripts + page.write_text("") + _write(d, "20261022_140001", [START, + ev("14:05:00", "screenshot", path=str(page), tag="failed")]) + assert runs.screenshot_path(d, "20261022_140001", 0, root) is None + + +def main() -> int: + failures = 0 + for fn in CASES: + name = fn.__name__.replace("_", " ") + try: + fn() + print(f" ok {name}") + except AssertionError as exc: + print(f" FAIL {name}: {exc}") + failures += 1 + except Exception as exc: + print(f" ERROR {name}: {type(exc).__name__}: {exc}") + failures += 1 + print(f"\n{len(CASES) - failures}/{len(CASES)} passed") + return 1 if failures else 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tests/test_save_corrections_gate.py b/tests/test_save_corrections_gate.py new file mode 100644 index 0000000..30bcc0f --- /dev/null +++ b/tests/test_save_corrections_gate.py @@ -0,0 +1,783 @@ +r""" +export_medcor_ready - the SAVE_CORRECTIONS gate and the review box under it. + + python tests\test_save_corrections_gate.py + +Real incident this guards against: claim 9103000 got saved for real during +a live test run (Logs\run_20260917_172622.log: "[COND 9103000] Saved: +depression_dx_pointer") because someone answered the confirmation box while +conditions were still being verified against live claims, not because the +code did anything wrong. SAVE_CORRECTIONS=0 makes that impossible while a +condition is still under test: the confirmation box must never even +appear, and save_claim() must never be called, regardless of what a human +would have answered. + +No Playwright page needed - SAVE_CORRECTIONS=0 must return before the +function ever touches one. +""" + +import io +import sys +import pathlib +import types +import contextlib + +ROOT = pathlib.Path(__file__).resolve().parent.parent +sys.path.insert(0, str(ROOT / "src")) + +from ecw_bot import settings as cfg # noqa: E402 +from ecw_bot.core import msgbox # noqa: E402 +from ecw_bot.pages import claim_form # noqa: E402 +from ecw_bot.conditions.base import ConditionResult # noqa: E402 +from ecw_bot.flows import export_medcor_ready as flow # noqa: E402 + +CASES = [] + + +def case(fn): + CASES.append(fn) + return fn + + +_RESULT = [ConditionResult( + condition_name="depression_dx_pointer", changed=True, + changes=[{"row": 0, "code": "G8510", + "before": {"icd": ["1", "", "", ""]}, + "after": {"icd": ["2", "", "", ""]}}])] + + +class _Called(Exception): + """Raised by a stub to prove it was reached, without needing a mock lib.""" + + +def _forbidden(*a, **k): + raise _Called("must not be called while SAVE_CORRECTIONS is off") + + +@case +def save_corrections_off_never_shows_the_box_or_saves(): + orig_save, orig_flag = cfg.SAVE_CORRECTIONS, cfg.SAVE_CORRECTIONS + orig_ok_cancel = msgbox.ok_cancel + orig_save_claim = claim_form.save_claim + cfg.SAVE_CORRECTIONS = False + msgbox.ok_cancel = _forbidden + claim_form.save_claim = _forbidden + try: + saved = flow._confirm_and_save(None, "9103000", _RESULT) + assert saved is False, saved + finally: + cfg.SAVE_CORRECTIONS = orig_flag + msgbox.ok_cancel = orig_ok_cancel + claim_form.save_claim = orig_save_claim + + +@case +def save_corrections_on_and_operator_approves_still_saves(): + """ + The pre-existing path, unchanged: proves the gate only ever REMOVES the + save, never adds a save that was not already going to happen. + """ + orig_flag = cfg.SAVE_CORRECTIONS + orig_ok_cancel = msgbox.ok_cancel + orig_save_claim = claim_form.save_claim + cfg.SAVE_CORRECTIONS = True + msgbox.ok_cancel = lambda *a, **k: True + claim_form.save_claim = lambda page: True + try: + saved = flow._confirm_and_save(None, "9103000", _RESULT) + assert saved is True, saved + finally: + cfg.SAVE_CORRECTIONS = orig_flag + msgbox.ok_cancel = orig_ok_cancel + claim_form.save_claim = orig_save_claim + + +@case +def save_box_makes_cancel_the_default_button(): + """ + Review item P1: OK on this box writes the claim to live eCW, so Enter + must answer Cancel. Proven at the call site; test_msgbox.py proves that + default_cancel=True really adds MB_DEFBUTTON2. + """ + calls = [] + orig_flag = cfg.SAVE_CORRECTIONS + orig_ok_cancel = msgbox.ok_cancel + orig_save_claim = claim_form.save_claim + cfg.SAVE_CORRECTIONS = True + msgbox.ok_cancel = lambda *a, **k: calls.append(k) or False + claim_form.save_claim = _forbidden + try: + flow._confirm_and_save(None, "9103000", _RESULT) + finally: + cfg.SAVE_CORRECTIONS = orig_flag + msgbox.ok_cancel = orig_ok_cancel + claim_form.save_claim = orig_save_claim + assert len(calls) == 1, calls + assert calls[0].get("default_cancel") is True, calls + + +@case +def save_corrections_on_and_operator_declines_does_not_save(): + orig_flag = cfg.SAVE_CORRECTIONS + orig_ok_cancel = msgbox.ok_cancel + orig_save_claim = claim_form.save_claim + cfg.SAVE_CORRECTIONS = True + msgbox.ok_cancel = lambda *a, **k: False + claim_form.save_claim = _forbidden + try: + saved = flow._confirm_and_save(None, "9103000", _RESULT) + assert saved is False, saved + finally: + cfg.SAVE_CORRECTIONS = orig_flag + msgbox.ok_cancel = orig_ok_cancel + claim_form.save_claim = orig_save_claim + + +@case +def no_results_never_saves_regardless_of_the_flag(): + saved = flow._confirm_and_save(None, "9103000", []) + assert saved is False, saved + + +# ============================================================================= +# _review_before_close - the pause the gate was missing +# ============================================================================= +# SAVE_CORRECTIONS=0 filled the form in and clicked Cancel in the same breath, +# so "applied for review" was only ever reviewable in the log and the +# screenshot. These cases pin the pause to exactly the path that lacked one, +# and - the point of the whole gate - prove it still cannot write. + +_SHOWN = [] + + +def _record_info_box(title, msg): + _SHOWN.append((title, msg)) + + +def _with(save_corrections, review_before_close, results, info_box): + """Run _review_before_close under a given config, restoring it after.""" + orig = (cfg.SAVE_CORRECTIONS, cfg.REVIEW_BEFORE_CLOSE, + msgbox.info_box, claim_form.save_claim) + _SHOWN.clear() + cfg.SAVE_CORRECTIONS = save_corrections + cfg.REVIEW_BEFORE_CLOSE = review_before_close + msgbox.info_box = info_box + # Nothing on this path may write, whatever the operator does with the box. + claim_form.save_claim = _forbidden + try: + return flow._review_before_close("9103000", results) + finally: + (cfg.SAVE_CORRECTIONS, cfg.REVIEW_BEFORE_CLOSE, + msgbox.info_box, claim_form.save_claim) = orig + + +@case +def review_box_appears_when_the_save_gate_is_off(): + """The gap: gate off, a condition fired, so the form must be held open.""" + shown = _with(False, True, _RESULT, _record_info_box) + assert shown is True, shown + assert len(_SHOWN) == 1, _SHOWN + title, msg = _SHOWN[0] + assert "9103000" in title, title + # The operator has to be told what to look for, not just that something + # happened - so the condition's own describe() output has to be in there. + assert "G8510" in msg, msg + + +@case +def review_box_does_not_appear_when_the_save_gate_is_on(): + """ + With the gate on, _confirm_and_save's own box is already the pause before + closing. A second one would re-ask a decision that was just made. + """ + shown = _with(True, True, _RESULT, _forbidden) + assert shown is False, shown + + +@case +def review_box_does_not_appear_when_no_condition_fired(): + shown = _with(False, True, [], _forbidden) + assert shown is False, shown + + +@case +def review_box_can_be_switched_off_for_unattended_runs(): + shown = _with(False, False, _RESULT, _forbidden) + assert shown is False, shown + + +@case +def review_box_is_shown_before_the_form_is_closed(): + """ + The ordering is the whole feature: a box after close_claim() would be + describing a form that is no longer on screen. Recorded rather than + assumed, because nothing else in _read_claim_form's `finally` enforces it. + + `conditions.run_all` is stubbed wholesale here, so it never actually + calls the `before_apply` callback `_read_claim_form` passes it — that + wiring (and the BEFORE box's own content) is proven separately below, + against the real `run_all`. This case only needs the stub to accept + the new keyword without blowing up. + """ + calls = [] + orig = (cfg.SAVE_CORRECTIONS, cfg.REVIEW_BEFORE_CLOSE, cfg.APPLY_CORRECTIONS, + cfg.OPEN_CLAIM_FORM, cfg.OPEN_PROGRESS_NOTES, + msgbox.info_box, claim_form.open_claim, claim_form.open_cpt_hcpcs_tab, + claim_form.wait_claim_loaded, + claim_form.extract, claim_form.save, claim_form.close_claim, + claim_form.save_claim, flow.conditions.run_all) + cfg.SAVE_CORRECTIONS = False + cfg.REVIEW_BEFORE_CLOSE = True + cfg.APPLY_CORRECTIONS = True + cfg.OPEN_CLAIM_FORM = True + cfg.OPEN_PROGRESS_NOTES = False + msgbox.info_box = lambda t, m: calls.append("review_box") + claim_form.open_claim = lambda page, cid: True + # Stubbed too (review item D7): on page=None the real one would + # poll its full 20s before giving up. + claim_form.wait_claim_loaded = lambda page, cid: None + claim_form.open_cpt_hcpcs_tab = lambda page: None + claim_form.extract = lambda page: {"cpt": [], "icd": [], "revenue": []} + claim_form.save = lambda data: pathlib.Path("unused.json") + claim_form.close_claim = lambda page: calls.append("close_claim") + claim_form.save_claim = _forbidden + flow.conditions.run_all = (lambda page, cid, data, before_apply=None, + report=None: _RESULT) + try: + flow._read_claim_form(None, "9103000") + finally: + (cfg.SAVE_CORRECTIONS, cfg.REVIEW_BEFORE_CLOSE, cfg.APPLY_CORRECTIONS, + cfg.OPEN_CLAIM_FORM, cfg.OPEN_PROGRESS_NOTES, + msgbox.info_box, claim_form.open_claim, claim_form.open_cpt_hcpcs_tab, + claim_form.wait_claim_loaded, + claim_form.extract, claim_form.save, claim_form.close_claim, + claim_form.save_claim, flow.conditions.run_all) = orig + + assert calls == ["review_box", "close_claim"], calls + + +# ============================================================================= +# _log_before_apply - the BEFORE half, now a log line only (review item D9) +# ============================================================================= +# _review_before_close shows what changed, once apply() has already written +# to the DOM. In front of it there used to be a second box, "N condition(s) +# matched - about to apply". Since D9 (2026-10-05) that is a log line only: +# a corrected claim gets ONE box, and these cases pin that the old box is +# gone whatever REVIEW_BEFORE_CLOSE says, while its log line stays. +# (Updated: these two cases used to assert the box appeared with REVIEW on.) + +_FIRED = [types.SimpleNamespace(NAME="depression_dx_pointer"), + types.SimpleNamespace(NAME="j2930_j2001_update")] + + +def _log_before_apply_with(review_before_close): + orig = (cfg.REVIEW_BEFORE_CLOSE, msgbox.info_box) + cfg.REVIEW_BEFORE_CLOSE = review_before_close + msgbox.info_box = _forbidden + out = io.StringIO() + try: + with contextlib.redirect_stdout(out): + flow._log_before_apply("9103000", _FIRED) + finally: + cfg.REVIEW_BEFORE_CLOSE, msgbox.info_box = orig + return out.getvalue() + + +@case +def before_apply_logs_the_matched_conditions_and_shows_no_box(): + out = _log_before_apply_with(True) + assert ("[COND 9103000] 2 condition(s) matched — about to apply: " + "depression_dx_pointer, j2930_j2001_update") in out, out + + +@case +def before_apply_still_logs_with_review_before_close_off(): + out = _log_before_apply_with(False) + assert "2 condition(s) matched — about to apply" in out, out + + +@case +def read_claim_form_wires_before_apply_through_to_run_all(): + """ + Proves the actual wiring in `_read_claim_form` — not a stubbed + `run_all`, but the real one, fed fake condition modules so this stays + independent of whatever the real registry currently contains. If + `_read_claim_form` stopped passing `before_apply=...`, or passed it + with the wrong `claim_id`, the BEFORE log line would never be written + on a real run and this would catch it. + + Updated for review item D9: the BEFORE half used to be a box, first in + `calls`; it is now only the log line, so the one box is the review box. + """ + calls = [] + orig = (cfg.SAVE_CORRECTIONS, cfg.REVIEW_BEFORE_CLOSE, cfg.APPLY_CORRECTIONS, + cfg.OPEN_CLAIM_FORM, cfg.OPEN_PROGRESS_NOTES, + msgbox.info_box, claim_form.open_claim, claim_form.open_cpt_hcpcs_tab, + claim_form.wait_claim_loaded, + claim_form.extract, claim_form.save, claim_form.close_claim, + claim_form.save_claim, flow.conditions.CONDITIONS) + + fake = types.ModuleType("fake_cond") + fake.NAME = "fake_cond" + fake.detect = lambda claim_id, data: True + fake.apply = lambda page, claim_id, data: ( + calls.append("apply:fake_cond") or _RESULT[0]) + + cfg.SAVE_CORRECTIONS = False + cfg.REVIEW_BEFORE_CLOSE = True + cfg.APPLY_CORRECTIONS = True + cfg.OPEN_CLAIM_FORM = True + cfg.OPEN_PROGRESS_NOTES = False + msgbox.info_box = lambda t, m: calls.append(f"box:{t}") + claim_form.open_claim = lambda page, cid: True + # Stubbed too (review item D7): on page=None the real one would + # poll its full 20s before giving up. + claim_form.wait_claim_loaded = lambda page, cid: None + claim_form.open_cpt_hcpcs_tab = lambda page: None + claim_form.extract = lambda page: {"cpt": [], "icd": [], "revenue": []} + claim_form.save = lambda data: pathlib.Path("unused.json") + claim_form.close_claim = lambda page: calls.append("close_claim") + claim_form.save_claim = _forbidden + flow.conditions.CONDITIONS = [fake] + out = io.StringIO() + try: + with contextlib.redirect_stdout(out): + flow._read_claim_form(None, "9103000") + finally: + (cfg.SAVE_CORRECTIONS, cfg.REVIEW_BEFORE_CLOSE, cfg.APPLY_CORRECTIONS, + cfg.OPEN_CLAIM_FORM, cfg.OPEN_PROGRESS_NOTES, + msgbox.info_box, claim_form.open_claim, claim_form.open_cpt_hcpcs_tab, + claim_form.wait_claim_loaded, + claim_form.extract, claim_form.save, claim_form.close_claim, + claim_form.save_claim, flow.conditions.CONDITIONS) = orig + + # BEFORE log line (no box), the fix lands, the ONE box, then close. + assert ("[COND 9103000] 1 condition(s) matched — about to apply: " + "fake_cond") in out.getvalue(), out.getvalue() + assert calls == ["apply:fake_cond", + "box:eCW Bot — Claim 9103000: review before close", + "close_claim"], calls + + +@case +def read_claim_form_names_the_snapshot_by_the_searched_claim_id(): + """ + The HCFA screen has no UB claim-number field, so extract() returns no + claim_no there and every HCFA claim used to overwrite one shared + Output\\claims\\unknown.json — losing 9109000's progress note. + """ + saved = [] + orig = (cfg.APPLY_CORRECTIONS, cfg.OPEN_CLAIM_FORM, cfg.OPEN_PROGRESS_NOTES, + claim_form.open_claim, claim_form.is_dental_claim_form, + claim_form.wait_claim_loaded, + claim_form.open_cpt_hcpcs_tab, claim_form.extract, claim_form.save, + claim_form.close_claim) + cfg.APPLY_CORRECTIONS = False + cfg.OPEN_CLAIM_FORM = True + cfg.OPEN_PROGRESS_NOTES = False + claim_form.open_claim = lambda page, cid: True + # Stubbed too (review item D7): on page=None the real one would + # poll its full 20s before giving up. + claim_form.wait_claim_loaded = lambda page, cid: None + claim_form.is_dental_claim_form = lambda page: False + claim_form.open_cpt_hcpcs_tab = lambda page: None + claim_form.extract = lambda page: {"cpt": [], "icd": [], "revenue": []} + claim_form.save = lambda data: (saved.append(data.get("claim_no")) + or pathlib.Path("unused.json")) + claim_form.close_claim = lambda page: None + try: + flow._read_claim_form(None, "9109000") + finally: + (cfg.APPLY_CORRECTIONS, cfg.OPEN_CLAIM_FORM, cfg.OPEN_PROGRESS_NOTES, + claim_form.open_claim, claim_form.is_dental_claim_form, + claim_form.wait_claim_loaded, + claim_form.open_cpt_hcpcs_tab, claim_form.extract, claim_form.save, + claim_form.close_claim) = orig + assert saved == ["9109000"], saved + + +# ============================================================================= +# A tainted claim is never offered for save (review item P2, unit D) +# ============================================================================= +# A condition that raised half-way through apply() leaves its edits on the +# form with no result to describe them, and Save writes the whole form. So a +# run_all report with any `failed` or `review` entry keeps the box away even +# with SAVE_CORRECTIONS=1 — the box is never shown, save_claim never called. + +def _gate_on(report): + orig = (cfg.SAVE_CORRECTIONS, msgbox.ok_cancel, claim_form.save_claim) + cfg.SAVE_CORRECTIONS = True + msgbox.ok_cancel = _forbidden + claim_form.save_claim = _forbidden + out = io.StringIO() + try: + with contextlib.redirect_stdout(out): + saved = flow._confirm_and_save(None, "9103000", _RESULT, report) + finally: + cfg.SAVE_CORRECTIONS, msgbox.ok_cancel, claim_form.save_claim = orig + return saved, out.getvalue() + + +@case +def a_failed_condition_blocks_the_save_box_even_with_save_on(): + saved, out = _gate_on({"failed": [{"name": "telehealth", "stage": "apply", + "error": "Timeout 30000ms"}], + "review": []}) + assert saved is False, saved + assert ("[COND 9103000] Not offering Save: telehealth failed (apply): " + "Timeout 30000ms — closing with Cancel") in out, out + + +@case +def a_review_entry_blocks_the_save_box_even_with_save_on(): + saved, out = _gate_on({"failed": [], "review": [ + {"name": "vaccine_admin_codes", + "reason": "90471 could not be added cleanly — check the CPT grid " + "by hand"}]}) + assert saved is False, saved + assert "Not offering Save: vaccine_admin_codes: 90471 could not be " \ + "added cleanly" in out, out + + +@case +def a_clean_report_still_reaches_the_save_box(): + """The control: an empty report changes nothing about the old path.""" + orig = (cfg.SAVE_CORRECTIONS, msgbox.ok_cancel, claim_form.save_claim) + cfg.SAVE_CORRECTIONS = True + asked = [] + msgbox.ok_cancel = lambda *a, **k: asked.append(1) or False + claim_form.save_claim = _forbidden + try: + with contextlib.redirect_stdout(io.StringIO()): + flow._confirm_and_save(None, "9103000", _RESULT, + {"failed": [], "review": [], + "comments": {}}) + finally: + cfg.SAVE_CORRECTIONS, msgbox.ok_cancel, claim_form.save_claim = orig + assert asked == [1], asked + + +def _read_with(conds, save_corrections=True, open_claim=None, note=None, + ask=None, closed=None, any_open=None, save_claim=None): + """ + The real _read_claim_form and the real run_all, fed fake condition + modules; every page step stubbed and save_claim / the save box + forbidden. Returns (data, printed text). + + `note`: the progress note open_progress_notes returns (OPEN_PROGRESS_NOTES + on); None = notes off. `ask`: a stand-in for the save box (default: + forbidden). `closed`: a list close_claim appends to. `any_open`: a + stand-in for claim_form.any_claim_form_open. `save_claim`: a stand-in + for claim_form.save_claim (default: forbidden). + """ + orig = (cfg.SAVE_CORRECTIONS, cfg.REVIEW_BEFORE_CLOSE, cfg.APPLY_CORRECTIONS, + cfg.OPEN_CLAIM_FORM, cfg.OPEN_PROGRESS_NOTES, + msgbox.info_box, msgbox.ok_cancel, claim_form.open_claim, + claim_form.is_dental_claim_form, claim_form.open_cpt_hcpcs_tab, + claim_form.wait_claim_loaded, + claim_form.extract, claim_form.save, claim_form.close_claim, + claim_form.save_claim, flow.conditions.CONDITIONS, + claim_form.open_progress_notes, claim_form.any_claim_form_open) + cfg.SAVE_CORRECTIONS = save_corrections + cfg.REVIEW_BEFORE_CLOSE = False + cfg.APPLY_CORRECTIONS = True + cfg.OPEN_CLAIM_FORM = True + cfg.OPEN_PROGRESS_NOTES = note is not None + msgbox.info_box = lambda t, m: None + msgbox.ok_cancel = ask or _forbidden + claim_form.open_claim = open_claim or (lambda page, cid: True) + claim_form.is_dental_claim_form = lambda page: False + claim_form.wait_claim_loaded = lambda page, cid: None + claim_form.open_cpt_hcpcs_tab = lambda page: None + claim_form.extract = lambda page: {"cpt": [{"code": "99213"}], + "icd": [{"#": "1", "code": "R69"}], + "revenue": []} + claim_form.save = lambda data: pathlib.Path("unused.json") + claim_form.close_claim = ((lambda page: closed.append(True)) + if closed is not None else (lambda page: None)) + claim_form.save_claim = save_claim or _forbidden + claim_form.open_progress_notes = lambda page: dict(note or {}) + if any_open is not None: + claim_form.any_claim_form_open = any_open + flow.conditions.CONDITIONS = conds + out = io.StringIO() + try: + with contextlib.redirect_stdout(out): + data = flow._read_claim_form(None, "9103000") + finally: + (cfg.SAVE_CORRECTIONS, cfg.REVIEW_BEFORE_CLOSE, cfg.APPLY_CORRECTIONS, + cfg.OPEN_CLAIM_FORM, cfg.OPEN_PROGRESS_NOTES, + msgbox.info_box, msgbox.ok_cancel, claim_form.open_claim, + claim_form.is_dental_claim_form, claim_form.open_cpt_hcpcs_tab, + claim_form.wait_claim_loaded, + claim_form.extract, claim_form.save, claim_form.close_claim, + claim_form.save_claim, flow.conditions.CONDITIONS, + claim_form.open_progress_notes, claim_form.any_claim_form_open) = orig + return data, out.getvalue() + + +def _fake(name, apply): + mod = types.ModuleType(name) + mod.NAME = name + mod.detect = lambda claim_id, data: True + mod.apply = apply + mod.describe = lambda result: f"{name} changed" + return mod + + +@case +def read_claim_form_never_offers_save_after_a_condition_raised_in_apply(): + """ + End to end, SAVE_CORRECTIONS=1: one condition changes something, the + next raises mid-apply(). The box would list only the first while the + form also carries whatever the second did — so it is never shown. + """ + def boom(page, claim_id, data): + raise RuntimeError("fill failed\ncall log ...") + + good = _fake("depression_dx_pointer", lambda p, c, d: _RESULT[0]) + bad = _fake("telehealth", boom) + orig_shot = flow.conditions.base.shot + flow.conditions.base.shot = lambda page, name: None + try: + data, out = _read_with([good, bad]) + finally: + flow.conditions.base.shot = orig_shot + cond = data["_conditions"] + assert cond["fired"] == ["depression_dx_pointer"], cond + assert cond["saved"] is False, cond + assert cond["failed"] == [{"name": "telehealth", "stage": "apply", + "error": "fill failed"}], cond + assert cond["review"] == [] and cond["comments"] == {}, cond + assert "Not offering Save" in out, out + + +@case +def read_claim_form_stores_a_declined_conditions_review_reason(): + from ecw_bot.conditions.base import CPT_TAB_NOT_CONFIRMED + good = _fake("depression_dx_pointer", lambda p, c, d: _RESULT[0]) + declines = _fake("dummy_codes", lambda p, c, d: ConditionResult.declined( + "dummy_codes", CPT_TAB_NOT_CONFIRMED)) + data, out = _read_with([good, declines]) + assert data["_conditions"]["review"] == [ + {"name": "dummy_codes", "reason": CPT_TAB_NOT_CONFIRMED}], data + assert "Not offering Save" in out, out + + +# ============================================================================= +# Claim-level review reasons block the save box too (final review, 2026-10-05) +# ============================================================================= +# The gate used to read only run_all's report. A claim the Charge Summary +# itself sends to Review — a partial note, an unread note, no CPT rows — +# could still be offered for Save: a partial note can hide "Audio Only" and +# turn 93 into 95, and one OK writes that to live eCW. + +_PARTIAL_NOTE = {"opened": "frame", "sections": {"TELEHEALTH": "x"}, + "partial": True, "partial_sections": 1} +_READ_NOTE = {"opened": "frame", "sections": {"Procedure Codes": "x"}} + + +@case +def a_partial_note_blocks_the_save_box_even_with_save_on(): + good = _fake("depression_dx_pointer", lambda p, c, d: _RESULT[0]) + # ok_cancel and save_claim are both forbidden in _read_with. + data, out = _read_with([good], note=_PARTIAL_NOTE) + assert data["_conditions"]["fired"] == ["depression_dx_pointer"], data + assert data["_conditions"]["saved"] is False, data + assert ("[COND 9103000] Not offering Save: Progress note may be partial " + "(1 section(s))") in out, out + + +@case +def an_unread_note_blocks_the_save_box_even_with_save_on(): + good = _fake("depression_dx_pointer", lambda p, c, d: _RESULT[0]) + data, out = _read_with([good], note={"opened": "nothing"}) + assert "Not offering Save: Progress note not read (nothing)" in out, out + + +@case +def a_fully_read_claim_still_reaches_the_save_box(): + """The control: a claim with no review reason is offered as before.""" + asked = [] + good = _fake("depression_dx_pointer", lambda p, c, d: _RESULT[0]) + data, out = _read_with([good], note=_READ_NOTE, + ask=lambda *a, **k: asked.append(1) or False) + assert asked == [1], out + assert "Not offering Save" not in out, out + + +@case +def the_conditions_account_survives_a_raise_after_run_all(): + """ + Final review, 2026-10-05: `_conditions` is stored right after run_all, + so a raise inside _confirm_and_save (here a describe() that meets an + unexpected change shape) no longer loses what the conditions changed. + """ + good = _fake("depression_dx_pointer", lambda p, c, d: _RESULT[0]) + + def bad_describe(result): + raise KeyError("after") + + orig_by_name = flow.conditions.BY_NAME + flow.conditions.BY_NAME = dict( + orig_by_name, + depression_dx_pointer=types.SimpleNamespace(describe=bad_describe)) + try: + data, out = _read_with([good], save_corrections=False) + finally: + flow.conditions.BY_NAME = orig_by_name + assert data["_error"] == "'after'", data + assert data["_conditions"]["fired"] == ["depression_dx_pointer"], data + from ecw_bot.core import charge_summary as cs + o = cs.claim_outcome(True, data) + assert o["Category"] == "Review", o + assert "Bot failed after the conditions ran ('after')" in o["Comments"], o + assert "could not read" not in o["Comments"], o + + +@case +def an_approved_save_eCW_did_not_confirm_is_review_not_not_saved(): + """ + Final review, 2026-10-05: save_claim() answers False when OK WAS + clicked and the form just did not hide in time — eCW may have written + the claim. The row used to read "Corrections pending (not saved)", a + guess stated as fact; it is now Review, saying the save is unconfirmed. + """ + good = _fake("depression_dx_pointer", lambda p, c, d: _RESULT[0]) + closed = [] + data, out = _read_with([good], note=_READ_NOTE, + ask=lambda *a, **k: True, + save_claim=lambda page: False, closed=closed) + cond = data["_conditions"] + assert cond["saved"] is False and cond.get("save_unconfirmed") is True, cond + assert closed == [True], "the form was not Cancelled after the failed save" + from ecw_bot.core import charge_summary as cs + o = cs.claim_outcome(True, data) + assert o["Category"] == "Review", o + assert o["Current Status"] == "Needs manual review", o + assert cs.SAVE_UNCONFIRMED in o["Comments"], o + assert "not saved" not in o["Current Status"], o + + +# ============================================================================= +# A form left on screen by a raise is still Cancelled (final review) +# ============================================================================= + +def _raises_with_form_open(page, cid): + raise RuntimeError("Opened the form for claim '5000001' after clicking " + "'9103000'") + + +@case +def a_form_open_when_open_claim_raises_is_closed_with_cancel(): + """ + open_claim's UB wrong-claim check raises AFTER the form opened. That + form used to be left open (the flow's `opened` was never set), and the + next claim's click fallback would accept it as its own form. + """ + closed = [] + data, _ = _read_with([], open_claim=_raises_with_form_open, + closed=closed, any_open=lambda page: True) + assert data["_error"].startswith("Opened the form for claim"), data + assert closed == [True], "the form open on screen was not Cancelled" + + +@case +def nothing_is_cancelled_when_open_claim_raises_with_no_form_open(): + closed = [] + _read_with([], open_claim=_cannot_open, closed=closed, + any_open=lambda page: False) + assert closed == [], closed + + +# ============================================================================= +# A form that could not be read is not "not found" (review item D2, unit D) +# ============================================================================= + +def _cannot_open(page, cid): + raise RuntimeError("Timeout 30000ms exceeded.\nCall log:\n - waiting") + + +@case +def read_claim_form_returns_the_error_instead_of_an_empty_dict(): + data, out = _read_with([], open_claim=_cannot_open) + assert data == {"claim_no": "9103000", + "_error": "Timeout 30000ms exceeded."}, data + assert "[FORM 9103000] Could not read the form" in out, out + + +@case +def process_claims_counts_an_unreadable_form_as_not_read_and_reviews_it(): + """ + `_process_claims` with search and every page step stubbed: the form + raises on open. It must count as found-but-not-read, the SHOW_EACH_CLAIM + box must not run claim_form.summary() on the error dict, and the + charge summary row must be Review, not blank. + """ + from ecw_bot.core import charge_summary as cs + + written, boxes = [], [] + orig = (cfg.SHOW_EACH_CLAIM, cfg.SHOW_CLAIM_IDS, cfg.CLAIM_PROCESS_LIMIT, + cfg.OPEN_CLAIM_FORM, msgbox.info_box, claim_form.open_claim, + claim_form.close_claim, claim_form.summary, + flow.claim_results.search_claim, flow.claim_filters.clear_claim_no, + flow._write_charge_summary, cfg.RESULTS_DB) + cfg.SHOW_EACH_CLAIM = True + cfg.SHOW_CLAIM_IDS = False + cfg.CLAIM_PROCESS_LIMIT = 0 + cfg.OPEN_CLAIM_FORM = True + msgbox.info_box = lambda t, m: boxes.append((t, m)) + claim_form.open_claim = _cannot_open + claim_form.close_claim = lambda page: None + claim_form.summary = _forbidden + flow.claim_results.search_claim = lambda page, cid: { + "id": cid, "count": 1, "found": True, "seconds": 1.0} + flow.claim_filters.clear_claim_no = lambda page: None + flow._write_charge_summary = lambda info, outcomes: written.append(outcomes) + # Never the real results database, whatever this machine's .env says. + cfg.RESULTS_DB = False + out = io.StringIO() + try: + with contextlib.redirect_stdout(out): + flow._process_claims(None, {"ids": ["9103000"], "file": None}) + finally: + (cfg.SHOW_EACH_CLAIM, cfg.SHOW_CLAIM_IDS, cfg.CLAIM_PROCESS_LIMIT, + cfg.OPEN_CLAIM_FORM, msgbox.info_box, claim_form.open_claim, + claim_form.close_claim, claim_form.summary, + flow.claim_results.search_claim, flow.claim_filters.clear_claim_no, + flow._write_charge_summary, cfg.RESULTS_DB) = orig + + text = out.getvalue() + assert ("1 found, 0 not found, 0 form(s) read, 1 could not be read" + in text), text + assert len(boxes) == 1 and \ + "Could not read this claim: Timeout 30000ms exceeded." in boxes[0][1], \ + boxes + outcome = written[0]["9103000"] + assert outcome == { + "Comments": "Bot could not read the claim (Timeout 30000ms " + "exceeded.)", + "Current Status": "Needs manual review", + "Category": "Review"}, outcome + assert outcome != cs.claim_outcome(True, {}), "must not be blank" + + +def main() -> int: + failures = 0 + for fn in CASES: + name = fn.__name__.replace("_", " ") + try: + fn() + print(f" ok {name}") + except AssertionError as exc: + print(f" FAIL {name}: {exc}") + failures += 1 + except Exception as exc: + print(f" ERROR {name}: {type(exc).__name__}: {exc}") + failures += 1 + print(f"\n{len(CASES) - failures}/{len(CASES)} passed") + return 1 if failures else 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tests/test_settings.py b/tests/test_settings.py new file mode 100644 index 0000000..e74bfc0 --- /dev/null +++ b/tests/test_settings.py @@ -0,0 +1,510 @@ +""" +Settings and import-graph tests. + + python tests\\test_settings.py + +Three things it checks, each of which has actually broken: + + * **`ROOT` resolves to the project root.** `settings.py` moved two levels + deeper during the restructure, so the `.parent` count had to change. Get it + wrong and `.env` silently does not load — every credential reads empty and + the failure surfaces as "ECW_URL is not set", pointing at the wrong file. + * **No selector reaches for `cfg.`** Selectors moved out of settings into + `selectors\\`, so a leftover `cfg.SEL_…` is an AttributeError that only + fires on the line that uses it — possibly minutes into a run, after a login + has been spent. + * **The import graph is acyclic**, layer by layer. The layering is the whole + point of the package; a cycle would make it decorative. + +And, since 2026-10-05 (review item P1), how `.env` values are read — because +the reader once turned saving to live eCW ON without a word: + + * **Inline comments are stripped.** `SAVE_CORRECTIONS=0 # note` used to + read as "0 # note", which is not a yes/no word, so the setting fell back + to its default — which was ON. + * **An unreadable yes/no or number is a config problem**, not a silent + fallback, so the run stops before login. + * **Writing is off unless asked for**: SAVE_CORRECTIONS defaults to False, + APPLY_CORRECTIONS (which only fills the form in) to True. + +These never read the real `.env`: the loader is fed a temp file, the parsers +read test-only keys, and the defaults are checked on a copy of settings.py +loaded with no `.env` beside it. +""" + +import os +import sys +import pathlib +import tempfile +import importlib.util + +ROOT = pathlib.Path(__file__).resolve().parent.parent +sys.path.insert(0, str(ROOT / "src")) + +from ecw_bot import settings as cfg # noqa: E402 + +# Layer → what it is allowed to import from. A module may always import its own +# layer and anything below it. +ALLOWED = { + "settings": {"selectors"}, + "selectors": set(), + "core": {"settings"}, + # Reads the exported report off disk. Sits beside settings rather than in + # pages\ because it touches no DOM, and above core\ because it knows what a + # claim report is — which core deliberately does not. + "claim_file": {"settings", "core"}, + "auth": {"settings", "selectors", "core"}, + "pages": {"settings", "selectors", "core"}, + # Detect/apply against a claim, via what pages\ already exposes — never + # its own DOM knowledge, and never reaches into flows\ (which is what + # calls it). + "conditions": {"settings", "selectors", "core", "pages"}, + "flows": {"settings", "selectors", "core", "claim_file", + "auth", "pages", "conditions"}, +} + + +def check_root() -> list: + bad = [] + if cfg.ROOT != ROOT: + bad.append(f"settings.ROOT is {cfg.ROOT}, expected {ROOT}") + if not cfg.ENV_PATH.exists(): + bad.append(f"settings.ENV_PATH does not exist: {cfg.ENV_PATH}") + if not cfg.ECW_URL: + bad.append("ECW_URL is empty — .env was not picked up from ROOT") + for name in ("LOGS", "INPUTS", "SCREENSHOTS", "DOWNLOADS_TMP"): + p = getattr(cfg, name) + if ROOT not in p.parents and p != ROOT: + bad.append(f"settings.{name} ({p}) is outside the project root") + return bad + + +def check_no_selectors_in_settings() -> list: + leaked = [n for n in dir(cfg) if n.startswith(("SEL_", "TXT_"))] + return [f"selectors still exported by settings: {leaked}"] if leaked else [] + + +def check_no_alias_shadowing() -> list: + """ + No function may bind a name that is also a module-level import alias. + + This exists because of a real bug, and because the check that was supposed + to catch it did not. `claim_filters.clear_balance` inherited + `for sel in (cfg.SEL_BAL_OP1, ...)` from the pre-package code, where `sel` + was just a loop variable. After the split, `sel` is also the selectors + module — and binding it anywhere in a function makes it local for the + **whole** function, so a `sel.SEL_BAL_OP1` earlier in the same function + raises UnboundLocalError: + + [CLAIMS] Failed: cannot access local variable 'sel' + where it is not associated with a value + + An AST pass for *undefined* names cannot see this — the name is bound, just + later than it is read. Only shadowing itself is detectable, so that is what + is checked. It is also simply a bad idea: reading `sel` in one function + should not mean something different from reading it in the next. + """ + import ast + bad = [] + pkg = ROOT / "src" / "ecw_bot" + for path in sorted(pkg.rglob("*.py")): + tree = ast.parse(path.read_text(encoding="utf-8")) + aliases = set() + for node in tree.body: + if isinstance(node, (ast.Import, ast.ImportFrom)): + for a in node.names: + aliases.add((a.asname or a.name).split(".")[0]) + if not aliases: + continue + for fn in [n for n in ast.walk(tree) + if isinstance(n, (ast.FunctionDef, ast.AsyncFunctionDef))]: + bound = {a.arg for a in fn.args.args} | \ + {a.arg for a in fn.args.kwonlyargs} + for n in ast.walk(fn): + if isinstance(n, ast.Name) and isinstance(n.ctx, ast.Store): + bound.add(n.id) + elif isinstance(n, ast.comprehension): + for t in ast.walk(n.target): + if isinstance(t, ast.Name): + bound.add(t.id) + elif isinstance(n, ast.ExceptHandler) and n.name: + bound.add(n.name) + for name in sorted(bound & aliases): + bad.append(f"{path.relative_to(pkg)}: {fn.name}() binds " + f"{name!r}, which is an imported module alias") + return bad + + +def check_layers() -> list: + import re + bad = [] + pkg = ROOT / "src" / "ecw_bot" + for path in sorted(pkg.rglob("*.py")): + rel = path.relative_to(pkg) + layer = rel.parts[0] if len(rel.parts) > 1 else rel.stem + if layer not in ALLOWED: + continue + text = path.read_text(encoding="utf-8") + # Relative imports only — that is the convention inside the package. + for m in re.finditer(r"^from \.\.?(\w+)", text, re.M): + target = m.group(1) + if target == layer or target in ALLOWED[layer]: + continue + if target in ALLOWED: + bad.append(f"{rel} ({layer}) imports {target}, which is above it") + return bad + + +# ── .env reading (P1) ───────────────────────────────────────────────────────── +# Every key below is test-only and unset in a real run, so the loader's +# "real environment wins" setdefault always takes the file's value — and none +# of this can collide with, or leak, a real setting. +_PREFIX = "ECWBOT_TEST_" + + +def _clear_test_keys() -> None: + for k in [k for k in os.environ if k.startswith(_PREFIX)]: + del os.environ[k] + + +def _load_lines(lines: list) -> None: + """Run the real loader over a temp .env holding `lines`.""" + _clear_test_keys() + with tempfile.TemporaryDirectory() as tmp: + path = pathlib.Path(tmp) / ".env" + path.write_text("\n".join(lines) + "\n", encoding="utf-8") + cfg._load_env_file(path) + + +def _expect(cases: dict) -> list: + """cases: {suffix: expected value}. Problems for every mismatch.""" + bad = [] + for suffix, want in cases.items(): + got = os.environ.get(_PREFIX + suffix) + if got != want: + bad.append(f"{_PREFIX}{suffix}: got {got!r}, expected {want!r}") + return bad + + +def check_env_inline_comments_unquoted() -> list: + _load_lines([ + # The two lines review item P1 found. + f"{_PREFIX}SAVE=0 # verification only", + f"{_PREFIX}LIMIT=5 # demo", + # A tab is whitespace too. + f"{_PREFIX}TAB=1\t# tab before the hash", + # '#' with no whitespace before it is part of the value: the landing + # URL's fragment and a mailbox password both look like this. + f"{_PREFIX}URL=https://host.example/index.jsp#/schedule", + f"{_PREFIX}PASS=abc#123", + # ...and the cut is at the FIRST ` #`, keeping a '#' inside the value. + f"{_PREFIX}BOTH=abc#123 # the comment", + # Blank value with only a comment after it is blank, not "# ...". + f"{_PREFIX}EMPTY= # nothing yet", + # Plain values, spaces inside, are untouched. + f"{_PREFIX}PLAIN=Claim Dt(s)", + ]) + try: + return _expect({ + "SAVE": "0", "LIMIT": "5", "TAB": "1", + "URL": "https://host.example/index.jsp#/schedule", + "PASS": "abc#123", "BOTH": "abc#123", "EMPTY": "", + "PLAIN": "Claim Dt(s)", + }) + finally: + _clear_test_keys() + + +def check_env_inline_comments_quoted() -> list: + _load_lines([ + # Quoting is how a value keeps a ` #`. + f'{_PREFIX}HASH="a # b"', + f"{_PREFIX}SINGLE='a # b'", + # Anything after the closing quote is dropped. + f'{_PREFIX}TRAIL="abc" # note', + f"{_PREFIX}STRAIL='abc' # note", + # A quote inside the comment must not drag the comment in. + f'{_PREFIX}QCOMMENT="abc" # it is "quoted"', + # Fully quoted, nothing after: exactly as before, inner quote kept. + f'{_PREFIX}FULL="abc"', + f'{_PREFIX}INNER="pa"ss"', + # ...including an inner `"#`: like an unquoted value, a '#' only + # starts a comment after whitespace, so a password quoted end to end + # that contains `"#` is not cut at it (review of unit A, fix round 1). + f'{_PREFIX}INNERHASH="a"#b"', + f'{_PREFIX}INNERHASH2="x"# y"', + # Same-kind inner quote, then whitespace and '#': indistinguishable + # from a trailing comment, so it IS one (documented in settings.py); + # the other kind of quotes is how such a value is written. + f'{_PREFIX}SAMEQ="a" #b"', + f"""{_PREFIX}OTHERQ='a" #b'""", + # Not quoted end to end and no whitespace before the '#': the first + # closing quote still ends the value. + f'{_PREFIX}NOSPACE="abc"#note', + f'{_PREFIX}BLANKQ=""', + # No closing quote: not a quoted value; the quote stays, as before. + f'{_PREFIX}OPEN="abc', + ]) + try: + return _expect({ + "HASH": "a # b", "SINGLE": "a # b", "TRAIL": "abc", + "STRAIL": "abc", "QCOMMENT": "abc", "FULL": "abc", + "INNER": 'pa"ss', "INNERHASH": 'a"#b', "INNERHASH2": 'x"# y', + "SAMEQ": "a", "OTHERQ": 'a" #b', + "NOSPACE": "abc", "BLANKQ": "", "OPEN": '"abc', + }) + finally: + _clear_test_keys() + + +def check_commented_save_line_reads_as_off() -> list: + """The P1 incident end to end: loader, then the yes/no parser.""" + saved_bad = list(cfg._BAD_VALUES) + _load_lines([f"{_PREFIX}SAVE=0 # verification only"]) + try: + bad = [] + # Default True on purpose — the old SAVE_CORRECTIONS default — so a + # comment that leaked into the value would show up as True here. + if cfg._env_bool(_PREFIX + "SAVE", True) is not False: + bad.append("SAVE=0 # comment did not read as False") + if cfg._BAD_VALUES != saved_bad: + bad.append(f"a clean commented line was recorded as bad: " + f"{cfg._BAD_VALUES[len(saved_bad):]}") + return bad + finally: + cfg._BAD_VALUES[:] = saved_bad + _clear_test_keys() + + +def check_bad_values_are_reported_by_validate() -> list: + saved_bad = list(cfg._BAD_VALUES) + _clear_test_keys() + os.environ[_PREFIX + "FLAG"] = "O" # the letter O, not zero + os.environ[_PREFIX + "COUNT"] = "5x" + os.environ[_PREFIX + "NEG"] = "-1" + os.environ[_PREFIX + "BLANK"] = "" + os.environ[_PREFIX + "PASSWORD_FLAG"] = "hunter2" + os.environ[_PREFIX + "OK"] = "Yes" + try: + bad = [] + # Unreadable: the default comes back, but the value is recorded. + if cfg._env_bool(_PREFIX + "FLAG", True) is not True: + bad.append("bad bool did not return the default") + if cfg._env_int(_PREFIX + "COUNT", 1) != 1: + bad.append("bad int did not return the default") + cfg._env_int(_PREFIX + "NEG", 1) + cfg._env_bool(_PREFIX + "PASSWORD_FLAG", False) + # Blank and unset mean "use the default" — never a problem. + cfg._env_bool(_PREFIX + "BLANK", False) + cfg._env_int(_PREFIX + "BLANK", 7) + cfg._env_bool(_PREFIX + "UNSET", False) + cfg._env_int(_PREFIX + "UNSET", 7) + # Recognised words are case-insensitive, as before. + if cfg._env_bool(_PREFIX + "OK", False) is not True: + bad.append("'Yes' did not read as True") + + problems = cfg.validate() + for want in (f"{_PREFIX}FLAG='O' is not a valid yes/no value", + f"{_PREFIX}COUNT='5x' is not a whole number", + f"{_PREFIX}NEG='-1' is not a whole number"): + if not any(want in p for p in problems): + bad.append(f"validate() did not report: {want}") + secret = [p for p in problems if _PREFIX + "PASSWORD_FLAG" in p] + if len(secret) != 1: + bad.append(f"PASSWORD key reported {len(secret)} time(s), want 1") + if any("hunter2" in p for p in problems): + bad.append("validate() printed the value of a PASSWORD key") + for quiet in ("BLANK", "UNSET", "OK"): + if any(f"{_PREFIX}{quiet}" in p for p in problems): + bad.append(f"{_PREFIX}{quiet} was reported but is fine") + # Recorded once, however often the setting is read. + cfg._env_bool(_PREFIX + "FLAG", True) + if sum(1 for k, _, _ in cfg._BAD_VALUES if k == _PREFIX + "FLAG") != 1: + bad.append("a bad value read twice was recorded twice") + return bad + finally: + cfg._BAD_VALUES[:] = saved_bad + _clear_test_keys() + + +def check_quoted_value_with_trailing_text_is_not_guessed() -> list: + """ + Final review, 2026-10-05: `SAVE_CORRECTIONS="1"0` read as 1 (LIVE-WRITE) + with nothing recorded, while the same text unquoted (`10`) stopped the + run. A yes/no or number key with text after its closing quote is now a + bad value; a text key keeps reading as the quoted part, as before. + """ + saved_bad = list(cfg._BAD_VALUES) + saved_q = dict(cfg._QUOTE_TRAILING) + _load_lines([ + f'{_PREFIX}QSAVE="1"0', + f"{_PREFIX}QINT='5'x", + f'{_PREFIX}QTEXT="abc"xyz', + f'{_PREFIX}QCLEAN="1" # a real comment', + ]) + try: + bad = [] + if cfg._env_bool(_PREFIX + "QSAVE", False) is not False: + bad.append('"1"0 did not fall back to the default') + if cfg._env_int(_PREFIX + "QINT", 1) != 1: + bad.append("'5'x did not fall back to the default") + if cfg._env(_PREFIX + "QTEXT") != "abc": + bad.append("a text key no longer reads as its quoted part") + if cfg._env_bool(_PREFIX + "QCLEAN", False) is not True: + bad.append('"1" # comment did not read as True') + problems = cfg.validate() + for want in (f"""{_PREFIX}QSAVE='"1"0' is not a valid yes/no value""", + f"""{_PREFIX}QINT="'5'x" is not a whole number"""): + if not any(want in p for p in problems): + bad.append(f"validate() did not report: {want}") + if any(f"{_PREFIX}QCLEAN" in p for p in problems): + bad.append("a quoted value with a real comment was reported") + return bad + finally: + cfg._BAD_VALUES[:] = saved_bad + cfg._QUOTE_TRAILING.clear() + cfg._QUOTE_TRAILING.update(saved_q) + _clear_test_keys() + + +def _settings_without_env(overrides: dict): + """ + A fresh copy of settings.py, run with NO .env beside it and the given + environment overrides (None = unset). So what comes back is the code + defaults, not whatever the real .env on this machine says. + + The copy is pointed at an empty temp folder through settings.py's own + frozen-exe branch (ROOT = the executable's folder), so its loader finds + no .env. Nothing is registered in sys.modules; the real `cfg` is untouched. + """ + saved_env = {k: os.environ.get(k) for k in overrides} + had_frozen = hasattr(sys, "frozen") + saved_frozen = getattr(sys, "frozen", None) + saved_exe = sys.executable + with tempfile.TemporaryDirectory() as tmp: + try: + for k, v in overrides.items(): + if v is None: + os.environ.pop(k, None) + else: + os.environ[k] = v + sys.frozen = True + sys.executable = str(pathlib.Path(tmp) / "bot.exe") + spec = importlib.util.spec_from_file_location( + "ecw_bot._settings_probe", cfg.__file__) + probe = importlib.util.module_from_spec(spec) + spec.loader.exec_module(probe) + return probe + finally: + sys.executable = saved_exe + if had_frozen: + sys.frozen = saved_frozen + else: + del sys.frozen + for k, v in saved_env.items(): + if v is None: + os.environ.pop(k, None) + else: + os.environ[k] = v + + +def check_write_defaults() -> list: + bad = [] + for label, value in (("unset", None), ("blank", "")): + probe = _settings_without_env({"SAVE_CORRECTIONS": value, + "APPLY_CORRECTIONS": value}) + # Proves the probe really ran away from the project's .env. + if probe.ROOT == cfg.ROOT: + bad.append(f"{label}: the probe used the real project root") + # A missing or blank line must never enable writes. + if probe.SAVE_CORRECTIONS is not False: + bad.append(f"{label}: SAVE_CORRECTIONS defaults to " + f"{probe.SAVE_CORRECTIONS!r}, expected False") + # Applying only fills the form in (closed with Cancel while saving is + # off); defaulting it off would make a run that omits it check nothing. + if probe.APPLY_CORRECTIONS is not True: + bad.append(f"{label}: APPLY_CORRECTIONS defaults to " + f"{probe.APPLY_CORRECTIONS!r}, expected True") + if any(k in ("SAVE_CORRECTIONS", "APPLY_CORRECTIONS") + for k, _, _ in getattr(probe, "_BAD_VALUES", [])): + bad.append(f"{label}: a {label} value was recorded as bad") + return bad + + +def check_env_file_keys_are_recorded() -> list: + """The keys the loader took from .env — not those the real environment + already had — are recorded, so a child process can be started without + them and read .env afresh (web/bot_runner.py, review 2026-10-14).""" + _clear_test_keys() + os.environ[_PREFIX + "REAL"] = "from-the-real-environment" + try: + with tempfile.TemporaryDirectory() as tmp: + path = pathlib.Path(tmp) / ".env" + path.write_text(f"{_PREFIX}REAL=file\n{_PREFIX}FILE=file\n" + f"Ecwbot_Test_Mixed=file\n", encoding="utf-8") + cfg._load_env_file(path) + keys = getattr(cfg, "ENV_FILE_KEYS", set()) + bad = [] + if _PREFIX + "FILE" not in keys: + bad.append("a key loaded from .env is not in ENV_FILE_KEYS") + if _PREFIX + "REAL" in keys: + bad.append("a key the real environment had is in ENV_FILE_KEYS") + # Recorded as os.environ lists it — on Windows upper-cased — or the + # child-env filter, which walks os.environ, misses it. + listed = [k for k in os.environ if k.upper() == _PREFIX + "MIXED"] + if not listed or listed[0] not in keys: + bad.append("a mixed-case .env key is not recorded the way " + "os.environ lists it") + return bad + finally: + keys = getattr(cfg, "ENV_FILE_KEYS", set()) + for k in (_PREFIX + "FILE", _PREFIX + "REAL", _PREFIX + "MIXED", + "Ecwbot_Test_Mixed"): + keys.discard(k) + _clear_test_keys() + + +def _run(check) -> list: + """A check that raises is a failure of that check, not of the file.""" + try: + return check() + except Exception as exc: + return [f"raised {type(exc).__name__}: {exc}"] + + +def main() -> int: + groups = [ + ("ROOT and .env resolution", _run(check_root)), + ("no selectors in settings", _run(check_no_selectors_in_settings)), + ("no shadowed import alias", _run(check_no_alias_shadowing)), + ("import graph is layered", _run(check_layers)), + (".env: unquoted inline comment stripped", + _run(check_env_inline_comments_unquoted)), + (".env: quoted value and trailing comment", + _run(check_env_inline_comments_quoted)), + ("SAVE=0 with a comment reads as off", + _run(check_commented_save_line_reads_as_off)), + ("bad yes/no and number reported by validate", + _run(check_bad_values_are_reported_by_validate)), + ("quoted yes/no with trailing text is not guessed", + _run(check_quoted_value_with_trailing_text_is_not_guessed)), + ("SAVE off / APPLY on by default", _run(check_write_defaults)), + (".env keys recorded for a child process", + _run(check_env_file_keys_are_recorded)), + ] + failures = 0 + for name, problems in groups: + if problems: + failures += 1 + print(f" FAIL {name}") + for p in problems: + print(f" {p}") + else: + print(f" ok {name}") + + print(f"\n{len(groups) - failures}/{len(groups)} passed") + return 1 if failures else 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tests/test_sliding_fee.py b/tests/test_sliding_fee.py new file mode 100644 index 0000000..6a33359 --- /dev/null +++ b/tests/test_sliding_fee.py @@ -0,0 +1,311 @@ +r""" +Patient Info -> Sliding Fee Schedule: the read-only "is the sliding fee +still valid on the claim's DOS" check (pages/sliding_fee.py). + + python tests\test_sliding_fee.py + +Browser cases run against tests/fixtures/sliding_fee.html; the verdict +cases need no browser. +""" + +import sys +import pathlib +import inspect +import tempfile + +ROOT = pathlib.Path(__file__).resolve().parent.parent +sys.path.insert(0, str(ROOT / "src")) + +FIXTURE = pathlib.Path(__file__).resolve().parent / "fixtures" / "sliding_fee.html" + +from ecw_bot.pages import sliding_fee # noqa: E402 +from ecw_bot.selectors import claim_form as sel # noqa: E402 + +CASES = [] + +ASSIGNED = {"from_date": "03/10/2026", "to_date": "03/10/2027"} + + +def case(fn): + CASES.append(fn) + return fn + + +# ── verdict (no browser) ───────────────────────────────────────────────────── + +@case +def dos_inside_the_range_is_active(): + assert sliding_fee.evaluate(ASSIGNED, "08/31/2026")["verdict"] == "active" + + +@case +def both_range_ends_count_as_inside(): + assert sliding_fee.evaluate(ASSIGNED, "03/10/2026")["verdict"] == "active" + assert sliding_fee.evaluate(ASSIGNED, "03/10/2027")["verdict"] == "active" + + +@case +def dos_after_the_to_date_is_expired(): + r = sliding_fee.evaluate(ASSIGNED, "03/11/2027") + assert r["verdict"] == "expired", r + + +@case +def dos_before_the_from_date_is_not_started(): + r = sliding_fee.evaluate(ASSIGNED, "03/09/2026") + assert r["verdict"] == "not_started", r + + +@case +def only_the_dates_decide_other_fields_are_ignored(): + """Operator, 2026-09-30: only the Date from/to against the DOS is + checked — eCW's Status, poverty level, type are not.""" + r = sliding_fee.evaluate(dict(ASSIGNED, status="Expired"), "08/31/2026") + assert r["verdict"] == "active", r + + +@case +def disabled_button_or_no_dates_is_none(): + assert sliding_fee.evaluate({"disabled": True}, "08/31/2026")["verdict"] == "none" + blank = dict(ASSIGNED, from_date="", to_date="__/__/____") + assert sliding_fee.evaluate(blank, "08/31/2026")["verdict"] == "none" + + +@case +def read_error_or_missing_dos_is_unknown(): + assert sliding_fee.evaluate({"error": "x"}, "08/31/2026")["verdict"] == "unknown" + assert sliding_fee.evaluate(None, "08/31/2026")["verdict"] == "unknown" + assert sliding_fee.evaluate(ASSIGNED, "")["verdict"] == "unknown" + + +@case +def claim_dos_is_the_first_readable_sdos(): + data = {"cpt": [{"sdos": "__/__/____"}, {"sdos": "08/31/2026"}, + {"sdos": "09/01/2026"}]} + assert sliding_fee.claim_dos(data) == "08/31/2026" + assert sliding_fee.claim_dos({}) == "" + + +# ── the screen (fixture) ───────────────────────────────────────────────────── + +def _reset(page, **knobs): + page.goto(FIXTURE.as_uri()) + page.evaluate("k => Object.assign(window.fixture, k)", knobs) + + +def _all_closed_nothing_saved(page): + assert not page.locator(sel.SEL_PT_INFO_MODAL).is_visible(), "ptInfo open" + assert not page.locator(sel.SEL_SLIDING_FEE_MODAL).is_visible(), "modal open" + assert page.evaluate("window.saved") is False, "a Save was clicked" + + +@case +def reads_the_assigned_dates_once_angular_fills_them(page): + _reset(page, values=ASSIGNED) + info = sliding_fee.read(page) + assert info["from_date"] == "03/10/2026", info + assert info["to_date"] == "03/10/2027", info + assert set(info) == {"from_date", "to_date"}, info + _all_closed_nothing_saved(page) + + +@case +def check_gives_the_verdict_for_the_claim_dos(page): + _reset(page, values=ASSIGNED) + r = sliding_fee.check(page, "T1", {"cpt": [{"sdos": "04/01/2027"}]}) + assert r["verdict"] == "expired", r + _all_closed_nothing_saved(page) + + +@case +def disabled_button_is_reported_and_still_closes(page): + _reset(page, disabled=True, values=ASSIGNED) + info = sliding_fee.read(page) + assert info == {"disabled": True}, info + _all_closed_nothing_saved(page) + + +@case +def unsaved_prompt_is_answered_dont_save(page): + _reset(page, values=ASSIGNED, prompt=True) + sliding_fee.read(page) + assert not page.locator("#savePrompt").is_visible() + _all_closed_nothing_saved(page) + + +@case +def a_hidden_leftover_info_button_first_in_the_dom_is_not_clicked(page): + """ + appears() waits on the VISIBLE Info button; the click that follows must + use that one too. With a hidden copy first in the DOM, an unfiltered + `.first` clicked the hidden copy and sat out its 15s click timeout, then + reported "error" for a claim whose Info button was on screen. + """ + import time + _reset(page, values=ASSIGNED) + page.evaluate("""() => { + const live = document.querySelector('#claimPatientInfo1790766147287'); + const copy = live.cloneNode(true); + copy.style.display = 'none'; + document.body.insertBefore(copy, document.body.firstChild); + }""") + started = time.monotonic() + info = sliding_fee.read(page) + assert info.get("from_date") == "03/10/2026", info + assert time.monotonic() - started < 10, "clicked the hidden copy first" + _all_closed_nothing_saved(page) + + +@case +def a_hidden_leftover_sliding_fee_button_first_in_the_dom_is_not_clicked(page): + """ + Final review, 2026-10-05: appears() waits on the VISIBLE Sliding Fee + Schedule button, and the click must take that one too. With a hidden + copy first in the DOM, an unfiltered `.first` clicked the hidden copy + and sat out its 15s click timeout, then reported "error". + """ + import time + _reset(page, values=ASSIGNED) + page.evaluate("""() => { + const live = document.querySelector('#btnSlidingFeeschedule'); + const copy = live.cloneNode(true); + copy.style.display = 'none'; + copy.removeAttribute('onclick'); + copy.removeAttribute('data-ng-click'); + document.body.insertBefore(copy, document.body.firstChild); + }""") + started = time.monotonic() + info = sliding_fee.read(page) + assert info.get("from_date") == "03/10/2026", info + assert time.monotonic() - started < 10, "clicked the hidden copy first" + _all_closed_nothing_saved(page) + + +@case +def hidden_leftover_close_and_cancel_buttons_are_not_clicked(page): + """ + _close_patient_info() checks with visible(), which answers True when ANY + copy is on screen (P8) — so its clicks must take the visible copy too. A + hidden leftover Cancel FIRST in Patient Information (what `.first` + took) and a hidden leftover Close LAST in the Sliding Fee modal (what + `.last` took) each sat out a 10s click timeout, and Patient Information + stayed open over the claim form (review of unit C, 2026-10-05). + """ + import time + _reset(page, values=ASSIGNED) + page.evaluate("""() => { + const hide = e => { e.style.display = 'none'; e.onclick = null; + e.removeAttribute('id'); return e; }; + const cancel = document.querySelector('#patient-demographicsBtn57'); + cancel.parentNode.insertBefore(hide(cancel.cloneNode(true)), + cancel.parentNode.firstChild); + const close = document.querySelector('#slidingfeescheduleBtn8'); + close.parentNode.appendChild(hide(close.cloneNode(true))); + }""") + started = time.monotonic() + info = sliding_fee.read(page) + assert info.get("from_date") == "03/10/2026", info + took = time.monotonic() - started + assert took < 10, f"took {took:.1f}s — clicked a hidden copy" + _all_closed_nothing_saved(page) + + +@case +def no_info_button_is_an_error_not_a_crash(page): + _reset(page, values=ASSIGNED) + page.evaluate("document.querySelector('#claimPatientInfo1790766147287').remove()") + info = sliding_fee.read(page) + assert "error" in info, info + assert sliding_fee.evaluate(info, "08/31/2026")["verdict"] == "unknown" + + +@case +def message_box_text_carries_the_verdict_dos_and_dates_only(): + text = sliding_fee.message(sliding_fee.evaluate(ASSIGNED, "04/01/2027")) + for want in ("EXPIRED", "04/01/2027", "03/10/2026", "03/10/2027"): + assert want in text, (want, text) + for gone in ("Status", "Poverty", "Scale type", "Fee schedule"): + assert gone not in text, (gone, text) + assert "UNKNOWN" in sliding_fee.message(sliding_fee.evaluate(None, "")) + + +DENTAL_ROWS = """ +
+ + +
+""" + + +@case +def dental_dos_reads_the_first_service_line_then_the_claim_date(page): + page.set_content(DENTAL_ROWS) + # Angular sets .value, not the attribute — so does the test. + page.evaluate("""() => { + document.querySelector('#DentalClaimCPTSDOS0').value = '__/__/____'; + document.querySelector('#DentalClaimCPTSDOS1').value = '08/31/2026'; + document.querySelector('#servicedate').value = '09/01/2026'; }""") + assert sliding_fee.dental_dos(page) == "08/31/2026" + page.evaluate("document.querySelector('#DentalClaimCPTSDOS1').value = ''") + assert sliding_fee.dental_dos(page) == "09/01/2026" + page.evaluate("document.querySelector('#servicedate').value = ''") + assert sliding_fee.dental_dos(page) == "" + + +@case +def check_on_a_dental_claim_uses_the_dental_dos(page): + _reset(page, values=ASSIGNED) + page.evaluate("h => document.body.insertAdjacentHTML('beforeend', h)", + DENTAL_ROWS) + page.evaluate("document.querySelector('#DentalClaimCPTSDOS0').value = '08/31/2026'") + r = sliding_fee.check(page, "D1", {}) + assert r["dos"] == "08/31/2026" and r["verdict"] == "active", r + _all_closed_nothing_saved(page) + + +def main() -> int: + if not FIXTURE.exists(): + print(f" FAIL fixture missing: {FIXTURE}") + return 1 + try: + from playwright.sync_api import sync_playwright + except ImportError: + print(" SKIP playwright is not installed") + return 0 + + failures = 0 + # read() screenshots every success (shot(page, "sliding_fee")): into a + # temp folder, never the operator's dated Screenshots\ folder (final + # review, 2026-10-05 — the suite was mixing fixture PNGs into it). + from ecw_bot import settings as cfg + orig_shots = cfg.SCREENSHOTS + with tempfile.TemporaryDirectory() as tmp, sync_playwright() as pw: + cfg.SCREENSHOTS = pathlib.Path(tmp) + browser = pw.chromium.launch(headless=True) + page = browser.new_page() + page.goto(FIXTURE.as_uri()) + + for fn in CASES: + name = fn.__name__.replace("_", " ") + try: + if len(inspect.signature(fn).parameters) == 0: + fn() + else: + fn(page) + print(f" ok {name}") + except AssertionError as exc: + print(f" FAIL {name}: {exc}") + failures += 1 + except Exception as exc: + print(f" ERROR {name}: {type(exc).__name__}: {exc}") + failures += 1 + browser.close() + cfg.SCREENSHOTS = orig_shots + + print(f"\n{len(CASES) - failures}/{len(CASES)} passed") + return 1 if failures else 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tests/test_spreadsheet_write.py b/tests/test_spreadsheet_write.py new file mode 100644 index 0000000..b2cf152 --- /dev/null +++ b/tests/test_spreadsheet_write.py @@ -0,0 +1,339 @@ +""" +write_xlsx(): the write side of core/spreadsheet.py. + + python tests\\test_spreadsheet_write.py + +Round-trips through the existing `read_rows()` reader rather than asserting +on raw XML — what matters is that a file this writes is a file this project's +own reader (and real Excel) can open, not the exact bytes. + +Also write_xlsx_keep_old(), the one writer both dated outputs go through +(review item D10): a fresh path, an existing file kept aside, and an existing +file that is OPEN — held here by a plain `open(path, "rb")`, which on Windows +refuses the rename with WinError 32 exactly as Excel does — whose run must +land under a `__HHMMSS` name instead of being lost. Every case works in its +own temp folder. +""" + +import io +import re +import sys +import pathlib +import tempfile +import contextlib +from datetime import datetime + +sys.path.insert(0, str(pathlib.Path(__file__).resolve().parent.parent / "src")) + +from ecw_bot.core import spreadsheet # noqa: E402 + +TMP = pathlib.Path(tempfile.mkdtemp()) + + +def case_write_then_read_round_trips(): + rows = [ + ["CLAIMS#", "PAYER", "Group Name"], + ["9107000", "Medicaid", "Medicaid"], + ["9106000", "DentaQuest", "Dentaquest"], + ] + path = TMP / "roundtrip.xlsx" + spreadsheet.write_xlsx(path, rows) + back = spreadsheet.read_rows(path) + assert back == rows, back + + +def case_special_characters_are_escaped(): + rows = [["PATIENT", "NOTE"], + ["Smith & Sons", "A < B > C"], + ["O'Brien", '"quoted"']] + path = TMP / "escaped.xlsx" + spreadsheet.write_xlsx(path, rows) + back = spreadsheet.read_rows(path) + assert back == rows, back + + +def case_wide_row_uses_correct_column_letters(): + """27 columns exercises the single-letter -> AA rollover.""" + header = [f"col{i}" for i in range(27)] + data = [str(i) for i in range(27)] + path = TMP / "wide.xlsx" + spreadsheet.write_xlsx(path, [header, data]) + back = spreadsheet.read_rows(path) + assert back[1][26] == "26", back[1] # column AA (index 26) + assert back[1] == data, back[1] + + +def case_control_characters_are_stripped_not_written(): + rows = [["A"], ["value\x00with\x01control\x1fchars"]] + path = TMP / "control_chars.xlsx" + spreadsheet.write_xlsx(path, rows) + back = spreadsheet.read_rows(path) + assert back == [["A"], ["valuewithcontrolchars"]], back + + +def case_creates_parent_directory(): + path = TMP / "nested" / "new_dir" / "file.xlsx" + assert not path.parent.exists() + spreadsheet.write_xlsx(path, [["A"], ["1"]]) + assert path.exists() + + +def case_non_string_values_are_stringified(): + rows = [["N"], [5], [3.5]] + path = TMP / "numbers.xlsx" + spreadsheet.write_xlsx(path, rows) + back = spreadsheet.read_rows(path) + assert back == [["N"], ["5"], ["3.5"]], back + + +# ── write_xlsx_keep_old ────────────────────────────────────────────────────── + +OLD = [["CLAIMS#", "Group Name"], ["9107000", "hand-filled by the operator"]] +NEW = [["CLAIMS#", "Group Name"], ["9107000", "Medicaid"], ["9106000", ""]] + + +def _folder_with_old_file(name="Medcor Ready_10-05-2026.xlsx"): + """A fresh temp folder holding a same-day file with OLD rows in it.""" + folder = pathlib.Path(tempfile.mkdtemp(dir=TMP)) + out = folder / name + spreadsheet.write_xlsx(out, OLD) + return folder, out + + +def _keep_old(out, rows=NEW): + """Run the helper, returning (path it returned, what it printed).""" + printed = io.StringIO() + with contextlib.redirect_stdout(printed): + got = spreadsheet.write_xlsx_keep_old(out, rows, "[TAG]") + return got, printed.getvalue() + + +@contextlib.contextmanager +def _held_open(path): + """ + Hold `path` open the way Excel does for the duration. A Python handle + blocks the RENAME (WinError 32) but not a write over it — so the cases + below also prove the old file was never written over. The rename is + probed first: a platform that does not lock an open file cannot + exercise this path, and should fail loudly rather than pass vacuously. + """ + with open(path, "rb") as handle: + probe = path.with_name("probe-rename.xlsx") + try: + path.rename(probe) + except OSError: + pass + else: + probe.rename(path) + raise AssertionError("an open file is not locked against rename " + "here — cannot exercise the locked path") + yield handle + + +class _FixedClock: + """Stands in for spreadsheet.datetime: now() is always 12:34:56.""" + @staticmethod + def now(): + return datetime(2026, 10, 5, 12, 34, 56) + + +def case_keep_old_fresh_path_is_written_as_is(): + folder = pathlib.Path(tempfile.mkdtemp(dir=TMP)) + out = folder / "Charge Summary_10-05-2026.xlsx" + got, printed = _keep_old(out) + assert got == out, got + assert spreadsheet.read_rows(out) == NEW + assert [p.name for p in folder.iterdir()] == [out.name], \ + list(folder.iterdir()) + assert printed == "", printed + + +def case_keep_old_existing_file_is_kept_aside_and_new_rows_take_its_name(): + folder, out = _folder_with_old_file() + old_bytes = out.read_bytes() + got, printed = _keep_old(out) + assert got == out, got + assert spreadsheet.read_rows(out) == NEW + kept = list(folder.glob("Medcor Ready_10-05-2026__*.xlsx")) + assert len(kept) == 1, kept + assert re.fullmatch(r"Medcor Ready_10-05-2026__\d{6}\.xlsx", + kept[0].name), kept[0].name + assert kept[0].read_bytes() == old_bytes, "the kept file was changed" + assert printed.strip() == (f"[TAG] {out.name} already existed — kept as " + f"{kept[0].name}"), printed + + +def case_keep_old_open_file_is_left_alone_and_new_rows_get_a_free_name(): + folder, out = _folder_with_old_file() + old_bytes = out.read_bytes() + with _held_open(out): + got, printed = _keep_old(out) # must not raise + assert out.exists(), "the open file was moved" + assert got != out, got + assert got.parent == folder, got + assert re.fullmatch(r"Medcor Ready_10-05-2026__\d{6}\.xlsx", + got.name), got.name + assert spreadsheet.read_rows(got) == NEW, "this run's rows were lost" + assert out.read_bytes() == old_bytes, "the open file was changed" + assert sorted(p.name for p in folder.iterdir()) == \ + sorted([out.name, got.name]), list(folder.iterdir()) + assert printed.startswith(f"[TAG] {out.name} is open or locked ("), \ + printed + assert "WinError 32" in printed, printed + assert printed.rstrip().endswith(f"— saved this run as {got.name} " + f"instead"), printed + + +def case_keep_old_names_skip_ones_already_taken_in_the_same_second(): + folder, out = _folder_with_old_file() + taken = folder / "Medcor Ready_10-05-2026__123456.xlsx" + taken.write_bytes(b"an earlier run, same second") + orig = spreadsheet.datetime + spreadsheet.datetime = _FixedClock + try: + got, printed = _keep_old(out) + assert got == out, got + assert "kept as Medcor Ready_10-05-2026__123456_2.xlsx" in printed, \ + printed + with _held_open(out): + got, printed = _keep_old(out) + assert got.name == "Medcor Ready_10-05-2026__123456_3.xlsx", got + finally: + spreadsheet.datetime = orig + assert taken.read_bytes() == b"an earlier run, same second" + + +def case_keep_old_failed_write_after_the_rename_puts_the_old_file_back(): + """ + The rename succeeds but writing the new file in its place fails (disk + full, a sync client grabbing the name): the old file goes back under its + own name — the failed write's half-file removed first — and the rows go + to a free `__HHMMSS` name instead. + """ + folder, out = _folder_with_old_file() + old_bytes = out.read_bytes() + real_write = spreadsheet.write_xlsx + + def _fails_on_the_usual_name(path, rows): + if pathlib.Path(path) == out: + pathlib.Path(path).write_bytes(b"PK half a zip") + raise OSError(28, "No space left on device") + real_write(path, rows) + + spreadsheet.write_xlsx = _fails_on_the_usual_name + try: + got, printed = _keep_old(out) + finally: + spreadsheet.write_xlsx = real_write + assert out.read_bytes() == old_bytes, "the old file was not put back" + assert got != out and spreadsheet.read_rows(got) == NEW, got + assert sorted(p.name for p in folder.iterdir()) == \ + sorted([out.name, got.name]), list(folder.iterdir()) + assert "No space left on device" in printed, printed + + +def case_keep_old_failed_move_back_says_the_usual_name_may_be_incomplete(): + """ + Fix round 1 (2026-10-05): the write to the usual name fails AND its + half-file cannot be removed (a virus scanner holding it — here a real + open handle, so the unlink is refused with WinError 32). The old file + cannot go back, so the log must say the usual name may be incomplete + and where the operator's earlier sheet really is. + """ + folder, out = _folder_with_old_file() + old_bytes = out.read_bytes() + real_write = spreadsheet.write_xlsx + handles = [] + + def _fails_and_stays_held(path, rows): + if pathlib.Path(path) == out: + pathlib.Path(path).write_bytes(b"PK half a zip") + handles.append(open(path, "rb")) # the scanner's grip + raise OSError(28, "No space left on device") + real_write(path, rows) + + spreadsheet.write_xlsx = _fails_and_stays_held + try: + got, printed = _keep_old(out) + finally: + spreadsheet.write_xlsx = real_write + for h in handles: + h.close() + backups = [p for p in folder.iterdir() + if p not in (out, got)] + assert len(backups) == 1, list(folder.iterdir()) + backup = backups[0] + assert backup.read_bytes() == old_bytes, "the earlier sheet changed" + assert got != out and spreadsheet.read_rows(got) == NEW, got + want = (f"[TAG] {out.name} may be an incomplete file — your earlier " + f"sheet is {backup.name} (could not move it back: ") + assert want in printed, printed + assert "already existed — kept as" not in printed, printed + + +def case_keep_old_a_bug_while_writing_puts_the_old_file_back_and_raises(): + folder, out = _folder_with_old_file() + old_bytes = out.read_bytes() + real_write = spreadsheet.write_xlsx + + def _bug(path, rows): + raise ValueError("not a file-system problem") + + spreadsheet.write_xlsx = _bug + try: + _keep_old(out) + except ValueError: + pass + else: + raise AssertionError("a non-OSError must propagate, not be retried") + finally: + spreadsheet.write_xlsx = real_write + assert out.read_bytes() == old_bytes + assert [p.name for p in folder.iterdir()] == [out.name], \ + list(folder.iterdir()) + + +def case_keep_old_fallback_write_failing_too_propagates(): + """Nowhere left to put the rows: the caller's own except reports it.""" + folder, out = _folder_with_old_file() + old_bytes = out.read_bytes() + real_write = spreadsheet.write_xlsx + + def _disk_full(path, rows): + raise OSError(28, "No space left on device") + + spreadsheet.write_xlsx = _disk_full + try: + with _held_open(out): + _keep_old(out) + except OSError as exc: + assert "No space left" in str(exc), exc + else: + raise AssertionError("the fallback's own failure must propagate") + finally: + spreadsheet.write_xlsx = real_write + assert out.read_bytes() == old_bytes + + +CASES = [v for k, v in sorted(globals().items()) if k.startswith("case_")] + + +def main() -> int: + failures = 0 + for fn in CASES: + name = fn.__name__[5:].replace("_", " ") + try: + fn() + print(f" ok {name}") + except AssertionError as exc: + print(f" FAIL {name}: {exc}") + failures += 1 + except Exception as exc: + print(f" ERROR {name}: {type(exc).__name__}: {exc}") + failures += 1 + print(f"\n{len(CASES) - failures}/{len(CASES)} passed") + return 1 if failures else 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tests/test_stacked_dialogs.py b/tests/test_stacked_dialogs.py new file mode 100644 index 0000000..bc7c365 --- /dev/null +++ b/tests/test_stacked_dialogs.py @@ -0,0 +1,258 @@ +r""" +Stacked dialogs (live run 2026-10-12, Logs\run_20261012_160331.log): one +dialog left on top of another blocked every click after it, and five of +seven claims went to Review. + + * Claim 1: Prog. Notes was clicked a second time while the first viewer + was still loading its document — two viewers. Close was clicked on the + LOWER one (`.first`), which the upper, blank one covered: 15 s timeout, + the viewer stayed, and the CPT tab, the form's Cancel and the next two + claims' searches were all intercepted by its iframe. + * Claim 6: eCW opened the same HCFA claim twice (two claim windows 648 ms + apart). The bot worked on the lower one; its Prog. Notes click was + intercepted by the upper one, and the next claim's search too. + + python tests\test_stacked_dialogs.py +""" + +import io +import sys +import pathlib +import inspect +import contextlib + +ROOT = pathlib.Path(__file__).resolve().parent.parent +sys.path.insert(0, str(ROOT / "src")) + +FIXTURES = pathlib.Path(__file__).resolve().parent / "fixtures" +HCFA = FIXTURES / "hcfa_claim.html" + +from ecw_bot import settings as cfg # noqa: E402 +from ecw_bot.core import page_utils # noqa: E402 +from ecw_bot.pages import claim_form, progress_note # noqa: E402 +from ecw_bot.selectors import claim_form as sel # noqa: E402 + +CASES = [] + + +def case(fn): + CASES.append(fn) + return fn + + +def _quiet(fn, *a, **k): + with contextlib.redirect_stdout(io.StringIO()) as out: + result = fn(*a, **k) + return result, out.getvalue() + + +# ── the helper ─────────────────────────────────────────────────────────────── + +@case +def click_topmost_clicks_the_button_on_top_not_the_first_in_the_dom(page): + page.set_content(""" +
+
+
+
+ + """) + assert page_utils.click_topmost(page, ".b", timeout=3_000) is True + assert page.evaluate("window.hits") == ["b2"] + + +@case +def click_topmost_says_false_when_nothing_matches(page): + page.set_content("
nothing here
") + assert page_utils.click_topmost(page, ".b", timeout=1_000) is False + + +# ── claim 1: two progress note viewers ─────────────────────────────────────── + +@case +def close_shuts_every_stacked_viewer_top_first(page): + page.goto(HCFA.as_uri()) + btn = "#claimProgressNoteBtn8888888888888" + page.click(btn) + page.click(btn) + page.evaluate("""() => { + window.shut = []; + document.querySelectorAll('[ng-click="closeProgressNoteViwer()"]') + .forEach((b, i) => b.addEventListener('click', + () => window.shut.push(i))); + }""") + assert page.locator(sel.SEL_PN_CLOSE).filter(visible=True).count() == 2 + ok, out = _quiet(progress_note.close, page, wait_ms=2_000) + assert ok is True, out + assert page.evaluate("window.shut") == [1, 0], "top (second) first" + assert page.locator(sel.SEL_PN_CLOSE).filter(visible=True).count() == 0 + assert "2 progress note viewers" in out, out + + +@case +def one_viewer_with_two_close_buttons_is_one_viewer(page): + """Live 2026-10-12: the viewer draws an X and a Close — one viewer.""" + page.set_content(""" + + + """) + ok, out = _quiet(progress_note.close, page, wait_ms=1_000) + assert ok is True, out + assert "viewers were open" not in out, out + assert page.locator(sel.SEL_PN_CLOSE).count() == 0 + + +@case +def no_second_click_while_a_viewer_is_still_loading_its_note(page): + """The re-click must not fire while eCW's viewer frame is open.""" + page.set_content(""" + + + + """) + old = cfg.PROGRESS_NOTE_WAIT_MS + cfg.PROGRESS_NOTE_WAIT_MS = 2_000 + try: + (reader, where, _), out = _quiet( + claim_form._wait_for_note, page, len(page.context.pages)) + finally: + cfg.PROGRESS_NOTE_WAIT_MS = old + assert reader is None and where == "nothing" + assert page.evaluate("window.clicks") == 0, out + assert "once more" not in out, out + + +@case +def the_second_click_still_fires_when_nothing_at_all_opened(page): + """The control: the click that did not take is still retried.""" + page.set_content(""" + + + """) + old = cfg.PROGRESS_NOTE_WAIT_MS + cfg.PROGRESS_NOTE_WAIT_MS = 2_000 + try: + _, out = _quiet(claim_form._wait_for_note, page, + len(page.context.pages)) + finally: + cfg.PROGRESS_NOTE_WAIT_MS = old + assert page.evaluate("window.clicks") == 1, out + + +# ── claim 6: the same HCFA claim opened twice ──────────────────────────────── + +def _duplicate_hcfa_window(page): + """A second copy of the claim window on top, as eCW opened it.""" + page.evaluate("""() => { + const w = document.querySelector('.claimModalBackdrop'); + const copy = w.cloneNode(true); + // Over the original, as angular-ui draws it: same place, higher. + const r = w.getBoundingClientRect(); + Object.assign(copy.style, {position: 'absolute', zIndex: '1060', + left: (r.left + scrollX) + 'px', top: (r.top + scrollY) + 'px', + width: r.width + 'px', background: '#fff'}); + copy.dataset.copy = '1'; + document.body.appendChild(copy); + window.cancelled = []; + for (const win of document.querySelectorAll('.claimModalBackdrop')) { + const c = win.querySelector('[ng-click="closeClaimScreen()"]'); + const clone = c.cloneNode(true); // drop the fixture's handler + c.replaceWith(clone); + clone.addEventListener('click', () => { + window.cancelled.push(win.dataset.copy ? 'copy' : 'original'); + win.remove(); + }); + } + }""") + + +@case +def a_claim_opened_twice_loses_its_top_copy_by_cancel(page): + page.goto(HCFA.as_uri()) + _duplicate_hcfa_window(page) + n, out = _quiet(claim_form.close_duplicate_forms, page, "9103000") + assert n == 1, out + assert page.evaluate("window.cancelled") == ["copy"] + assert page.locator(".claimModalBackdrop").count() == 1 + assert "opened the claim twice" in out, out + + +@case +def one_claim_window_is_left_alone(page): + page.goto(HCFA.as_uri()) + n, out = _quiet(claim_form.close_duplicate_forms, page, "9103000") + assert n == 0, out + assert page.locator(".claimModalBackdrop").count() == 1 + + +@case +def close_claim_cancels_the_window_on_top(page): + """With two windows up, Cancel goes to the one that can be clicked.""" + page.goto(HCFA.as_uri()) + _duplicate_hcfa_window(page) + page.evaluate("""() => { // the original's Cancel is covered + window.cancelled = window.cancelled || []; + }""") + ok, out = _quiet(claim_form.close_claim, page) + assert page.evaluate("window.cancelled")[0] == "copy", out + + +@case +def a_viewer_left_from_the_last_claim_is_closed_before_the_next_search(page): + """ + Defence in depth: claims 2 and 3 were lost to claim 1's viewer. Before + a claim is searched, any viewer still up is closed. + """ + page.goto(HCFA.as_uri()) + page.click("#claimProgressNoteBtn8888888888888") + assert page.locator(sel.SEL_PN_CLOSE).filter(visible=True).count() == 1 + done, out = _quiet(claim_form.clear_screen, page, "9103000", + page.context.pages) + assert page.locator(sel.SEL_PN_CLOSE).filter(visible=True).count() == 0 + assert "a progress note viewer" in done, out + assert "Left over from the last claim" in out, out + + +def main() -> int: + from playwright.sync_api import sync_playwright + + failures = 0 + with sync_playwright() as pw: + browser = pw.chromium.launch(headless=True) + page = browser.new_page(viewport={"width": 1600, "height": 1000}) + for fn in CASES: + name = fn.__name__.replace("_", " ") + try: + if len(inspect.signature(fn).parameters) == 0: + fn() + else: + fn(page) + print(f" ok {name}") + except AssertionError as exc: + print(f" FAIL {name}: {exc}") + failures += 1 + except Exception as exc: + print(f" ERROR {name}: {type(exc).__name__}: {exc}") + failures += 1 + browser.close() + + print(f"\n{len(CASES) - failures}/{len(CASES)} passed") + return 1 if failures else 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tests/test_summary_per_claim.py b/tests/test_summary_per_claim.py new file mode 100644 index 0000000..a704e5f --- /dev/null +++ b/tests/test_summary_per_claim.py @@ -0,0 +1,140 @@ +r""" +The Charge Summary grows claim by claim (operator, 2026-10-12): each claim's +row goes into the Excel summary and the results database as soon as that +claim is done, not once at the end of the run. + + python tests\test_summary_per_claim.py + +Drives the real `_process_claims` with the search, the form read and both +writers stubbed — no browser, no file, no database. +""" + +import io +import sys +import pathlib +import contextlib + +ROOT = pathlib.Path(__file__).resolve().parent.parent +sys.path.insert(0, str(ROOT / "src")) + +from ecw_bot import settings as cfg # noqa: E402 +from ecw_bot.core import charge_summary as cs # noqa: E402 +from ecw_bot.flows import export_medcor_ready as flow # noqa: E402 + +CASES = [] + + +def case(fn): + CASES.append(fn) + return fn + + +def _ub(fired=()): + return {"cpt": [{"code": "99213", "checked": True, "fee": "100.00"}], + "icd": [{"code": "Z00.00"}], + "progress_notes": {"opened": "modal", "text": "Office visit."}, + "_conditions": {"fired": list(fired), "saved": False, + "failed": [], "review": [], "comments": {}}} + + +def _run(ids, found=None, forms=None): + """ + `_process_claims` over `ids`. `found` = ids the search finds (default + all), `forms` = claim id -> what the form read returns. Returns the + event list: ("search", id), ("excel", sorted outcome ids), + ("db", outcomes, details). + """ + found = set(ids if found is None else found) + forms = forms or {} + events = [] + + def search(page, cid): + events.append(("search", cid)) + return {"id": cid, "count": 1 if cid in found else 0, + "found": cid in found, "seconds": 1.0} + + orig = (cfg.SHOW_EACH_CLAIM, cfg.SHOW_CLAIM_IDS, cfg.CLAIM_PROCESS_LIMIT, + flow.claim_results.search_claim, flow._read_claim_form, + flow.claim_filters.clear_claim_no, flow._write_charge_summary, + flow._save_results_db) + cfg.SHOW_EACH_CLAIM = False + cfg.SHOW_CLAIM_IDS = False + cfg.CLAIM_PROCESS_LIMIT = 0 + flow.claim_results.search_claim = search + flow._read_claim_form = lambda page, cid, head: dict( + forms.get(cid) or _ub(), claim_no=cid) + flow.claim_filters.clear_claim_no = lambda page: None + flow._write_charge_summary = lambda info, outcomes: events.append( + ("excel", sorted(outcomes))) + flow._save_results_db = lambda info, outcomes, details: events.append( + ("db", dict(outcomes), dict(details))) + try: + with contextlib.redirect_stdout(io.StringIO()): + flow._process_claims(None, {"ids": list(ids), "file": None}) + finally: + (cfg.SHOW_EACH_CLAIM, cfg.SHOW_CLAIM_IDS, cfg.CLAIM_PROCESS_LIMIT, + flow.claim_results.search_claim, flow._read_claim_form, + flow.claim_filters.clear_claim_no, flow._write_charge_summary, + flow._save_results_db) = orig + return events + + +@case +def each_claim_is_written_to_the_excel_summary_before_the_next_starts(): + events = _run(["111", "222"]) + order = [e[:2] if e[0] != "db" else ("db",) for e in events] + assert order == [("search", "111"), ("excel", ["111"]), ("db",), + ("search", "222"), ("excel", ["111", "222"]), ("db",)], \ + order + + +@case +def each_claim_saves_only_its_own_row_to_the_database(): + events = _run(["111", "222"], + forms={"222": _ub(["depression_dx_pointer"])}) + db = [e for e in events if e[0] == "db"] + assert [sorted(e[1]) for e in db] == [["111"], ["222"]], db + assert db[0][1]["111"]["Comments"] == cs.CLEAN, db[0] + assert db[1][1]["222"]["Category"] == "Fail", db[1] + assert "ICD pointer issue" in db[1][1]["222"]["Comments"], db[1] + assert sorted(db[1][2]) == ["222"], db[1] + # The details are what the conditions reported, never the claim's own + # fields (review, 2026-10-12). + detail = db[1][2]["222"] + assert detail["fired"] == ["depression_dx_pointer"], detail + assert "cpt" not in detail and "progress_notes" not in detail, detail + + +@case +def a_claim_not_found_updates_the_excel_but_saves_nothing_to_the_database(): + events = _run(["111", "222"], found=["222"]) + assert ("excel", ["111"]) in events, events + db = [e for e in events if e[0] == "db"] + assert [sorted(e[1]) for e in db] == [["222"]], db + + +@case +def nothing_is_written_again_after_the_last_claim(): + events = _run(["111"]) + assert [e[0] for e in events] == ["search", "excel", "db"], events + + +def main() -> int: + failures = 0 + for fn in CASES: + name = fn.__name__.replace("_", " ") + try: + fn() + print(f" ok {name}") + except AssertionError as exc: + print(f" FAIL {name}: {exc}") + failures += 1 + except Exception as exc: + print(f" ERROR {name}: {type(exc).__name__}: {exc}") + failures += 1 + print(f"\n{len(CASES) - failures}/{len(CASES)} passed") + return 1 if failures else 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tests/test_telehealth_fix.py b/tests/test_telehealth_fix.py new file mode 100644 index 0000000..524ea75 --- /dev/null +++ b/tests/test_telehealth_fix.py @@ -0,0 +1,644 @@ +r""" +Condition 1: a telehealth visit needs POS=11 and M1=GT on every CPT/HCPCS row. + + python 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 (see `progress_note.html`'s History of Present Illness +section, `TELEHEALTH`), and detection never touches the DOM at all, so +the negative case is just a plain string. + +Local headless chromium only, exactly like test_claim_form.py: no proxy, no +login, no eCW. This is the bot's first WRITE path, so what matters most here +is proven *before* it ever runs against a real claim: + + * detection matches "telehealth", "telephone", and a standalone "TH" token + (case-insensitive for the first two, word-bounded and case-sensitive for + "TH" — see claim_form.note_mentions_telehealth's docstring for why) and + nothing looser than that — a payer/claim-specific "TeleHealth" or + "TELEHEALTH VISIT" must still match, but "MONTH" or "WITH" must not; + * the write only ever touches real CPT/HCPCS rows — the blank padding row + and the trailing "*" entry row (neither carries a CPTROW_ id, exactly the + trap `claim_form.extract()`'s own reader already guards against) must + never be touched; + * every field is read back after the fill, so a write that silently didn't + take is a **visible** assertion failure here, not a silent no-op on a + real claim; + * Cancel and OK are both wired in this fixture (they are not in + ub_claim.html's original form — see the added '" + rows = [_row("9000009", "Fail", 1, comments=evil, payer=evil)] + html = _client(FakeRepo(rows=rows))[0].get("/day/2026-10-16").text + assert " diff --git a/web/templates/base.html b/web/templates/base.html new file mode 100644 index 0000000..5d1a8b8 --- /dev/null +++ b/web/templates/base.html @@ -0,0 +1,192 @@ + + + + + + +{% block title %}Charge Validation{% endblock %} · eCW +{% block head %}{% endblock %} + + + + +
+ eCW Charge Validation + {{ mode }} + {% if user %}{% endif %} +
+ +
+{% block content %}{% endblock %} +
+
Local page (127.0.0.1) — contains patient data; do not + share screenshots outside the team.
+{% block scripts %}{% endblock %} + + diff --git a/web/templates/claim.html b/web/templates/claim.html new file mode 100644 index 0000000..cdc9f06 --- /dev/null +++ b/web/templates/claim.html @@ -0,0 +1,54 @@ +{% extends "base.html" %} +{% block title %}Find a claim{% endblock %} +{% block style %} + table.list col.date { width: 150px; } + table.list col.claim { width: 120px; } + table.list col.time { width: 100px; } + table.list col.mode { width: 96px; } + table.list col.result { width: 96px; } + /* A phone: the key columns only — the rest is in the side panel. */ + @media (max-width: 760px) { + table.list th:nth-child(4), table.list td:nth-child(4), table.list col:nth-child(4), table.list th:nth-child(6), table.list td:nth-child(6), table.list col:nth-child(6) { display: none; } + } +{% endblock %} +{% block content %} +

Find a claim

+

Every time the bot checked a claim, newest first.

+ +{% if rows %} +
+ + + + + {% for row in rows %} + + + + + + + + + {% endfor %} + +
DateClaim #TimeModeResultComments
{{ row.run_at.date() | longdate }}{{ row.claim_no or "" }}{{ row.run_at | clock }}{{ row.run_mode or "" }}{% if row.category %}{{ row.category }}{% endif %}{{ row.comments or "" }}
+
+{% elif claim_no %} +
No saved check of a claim matching “{{ claim_no }}”.
+{% endif %} +{% include "_claim_panel.html" %} +{% endblock %} +{% block scripts %} + +{% endblock %} diff --git a/web/templates/dashboard.html b/web/templates/dashboard.html new file mode 100644 index 0000000..bf091fe --- /dev/null +++ b/web/templates/dashboard.html @@ -0,0 +1,273 @@ +{% extends "base.html" %} +{% block title %}Dashboard{% endblock %} +{% block style %} + .botrun .row { display: flex; gap: 12px; align-items: center; + flex-wrap: wrap; } + .botrun .row + .row { margin-top: 10px; } + .botrun h2 { margin: 0; } + #br-message { color: var(--muted); } + .botrun button.stop { background: var(--fail); border-color: var(--fail); } + .botrun progress { width: 260px; height: 10px; accent-color: var(--accent); } + .botrun pre { background: var(--head); padding: 10px; border-radius: 6px; + max-height: 260px; overflow: auto; font-size: 12px; + white-space: pre-wrap; } + .botrun summary { cursor: pointer; color: var(--accent); margin-top: 10px; } + .chip { padding: 2px 9px; border-radius: 10px; font-size: 12px; + font-weight: 600; } + /* `.row { display: flex }` beats the browser's [hidden] rule. */ + .botrun [hidden] { display: none; } + .actions { display: grid; grid-template-columns: 1fr 1fr; gap: 18px; + align-items: stretch; } + .actions .card { margin-bottom: 0; } + a.big { display: flex; align-items: center; justify-content: center; + gap: 10px; font-size: 18px; padding: 22px; border-radius: 10px; + height: 100%; } + .today-h { display: flex; justify-content: space-between; align-items: baseline; + margin: 4px 0 10px; } + .today-h h2 { font-size: 15px; margin: 0; } + #live-summary { color: var(--muted); margin: 0 0 10px; } + #live-list { list-style: none; margin: 0; padding: 0; } + #live-list li { display: flex; gap: 10px; align-items: center; + padding: 7px 8px; border-bottom: 1px solid var(--line); } + #live-list li.opens { cursor: pointer; } + #live-list li.opens:hover { background: var(--hilite); } + #live-list .n { color: var(--muted); width: 34px; } + #live-list .id { font-weight: 600; width: 100px; } + #live-list .st { margin-left: auto; } + #live-list li.st-checking { background: var(--hilite); } + #live-list li.st-checking .st { color: var(--accent); font-weight: 600; } + #live-list li.st-idle .st { color: var(--muted); } + @media (max-width: 760px) { .actions { grid-template-columns: 1fr; } } + /* The last Run ended badly (EEWA-33). */ + .alert { background: var(--fail-bg); color: var(--fail); + border: 1px solid #f5c2bd; border-left: 5px solid var(--fail); + border-radius: 10px; padding: 12px 16px; margin-bottom: 18px; } + .alert a { color: var(--fail); font-weight: 600; text-decoration: underline; } +{% endblock %} +{% block content %} +{% if alert %} + +{% endif %} +
+
+

Bot run

+ … + + + + +
+

VERIFY mode — nothing is saved to eCW. + CAPTCHA and 2FA boxes still come up on this PC when the login needs a + person.

+ + +
+ +
+

Today · {{ today.day | longdate }}

+ {% if today.claims %}Open today's claims →{% endif %} +
+ + +
+ Claim status → + +
+{% if not has_runs %} +

No results saved yet. Run the bot with RESULTS_DB=1 + in .env; each day's claims appear under Claim status.

+{% endif %} + + +{% endblock %} +{% block scripts %} + +{% endblock %} diff --git a/web/templates/day.html b/web/templates/day.html new file mode 100644 index 0000000..7a17327 --- /dev/null +++ b/web/templates/day.html @@ -0,0 +1,86 @@ +{% extends "base.html" %} +{% block title %}{{ day | longdate }}{% endblock %} +{% block style %} + table.list col.n { width: 52px; } + table.list col.claim { width: 120px; } + table.list col.time { width: 100px; } + table.list col.payer { width: 22%; } + table.list col.result { width: 96px; } + .bar { display: flex; justify-content: space-between; align-items: end; + gap: 12px; flex-wrap: wrap; margin-bottom: 14px; } + /* A phone: the key columns only — the rest is in the side panel. */ + @media (max-width: 760px) { + table.list th:nth-child(4), table.list td:nth-child(4), table.list col:nth-child(4), table.list th:nth-child(6), table.list td:nth-child(6), table.list col:nth-child(6) { display: none; } + table.list col.n { width: 36px; } + table.list col.claim { width: 96px; } + table.list col.time { width: 84px; } + } +{% endblock %} +{% block content %} +
+
+

{{ day | longdate }}

+

{{ counts.claims }} claims checked · {{ counts.runs }} + run{{ "" if counts.runs == 1 else "s" }} — every run of the day together

+
+ +
+ +{% set q = ("&claim_no=" ~ claim_no) if claim_no else "" %} + + +{% if rows %} +
+ + + + + {% for row in rows %} + + + + + + + + + {% endfor %} + +
#Claim #TimePayerResultComments
{{ loop.index }}{{ row.claim_no or "" }}{{ row.run_at | clock }}{{ row.payer or "" }}{% if row.category %}{{ row.category }}{% endif %}{{ row.comments or "" }}
+
+{% elif counts.claims %} +
No claims match this filter. Show all
+{% else %} +
No claims were checked on this day. Back to Claim status
+{% endif %} +{% include "_claim_panel.html" %} +{% endblock %} +{% block scripts %} + +{% endblock %} diff --git a/web/templates/days.html b/web/templates/days.html new file mode 100644 index 0000000..d89bdbf --- /dev/null +++ b/web/templates/days.html @@ -0,0 +1,57 @@ +{% extends "base.html" %} +{% block title %}Claim status{% endblock %} +{% block style %} + .days { display: grid; grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); + gap: 14px; } + a.daycard { display: block; background: var(--panel); color: var(--text); + border: 1px solid var(--line); border-left: 5px solid var(--accent); + border-radius: 10px; padding: 14px 16px; box-shadow: var(--shadow); } + a.daycard:hover { text-decoration: none; border-color: var(--accent); + background: var(--hilite); } + .daycard .d { font-size: 16px; font-weight: 700; } + .daycard .m { color: var(--muted); font-size: 12px; margin: 2px 0 8px; } + .daycard .c { display: flex; gap: 12px; font-weight: 600; font-size: 13px; } + .daycard .c .Pass { color: var(--pass); } + .daycard .c .Fail { color: var(--fail); } + .daycard .c .Review { color: var(--review); } + .stack { display: flex; height: 7px; border-radius: 4px; overflow: hidden; + background: var(--head); margin-top: 10px; } + .stack i { display: block; } + .stack i.Pass { background: var(--pass); } + .stack i.Fail { background: var(--fail); } + .stack i.Review { background: var(--review-bar); } +{% endblock %} +{% block content %} +

Claim status

+

Every day the bot checked claims, newest first — all runs of a + day together. Pick a day to see its claims.

+{% if days %} + +{% else %} +
No results saved yet. Run the bot with RESULTS_DB=1 + in .env; each day's claims appear here.
+{% endif %} +{% endblock %} +{% block scripts %} + +{% endblock %} diff --git a/web/templates/password.html b/web/templates/password.html new file mode 100644 index 0000000..20f1112 --- /dev/null +++ b/web/templates/password.html @@ -0,0 +1,23 @@ +{% extends "base.html" %} +{% block title %}Change password{% endblock %} +{% block style %} + .pw { max-width: 420px; } + .pw label { display: block; margin: 0 0 12px; font-weight: 600; } + .pw input { display: block; width: 100%; margin-top: 4px; } +{% endblock %} +{% block content %} +
+

Change password

+ {% if user.must_change %}

Choose your own password before + going on (an Admin set the one you signed in with).

+ {% else %}

At least 10 characters, not your username.

{% endif %} + {% if error %}{% endif %} +
+ + + + + +
+
+{% endblock %} diff --git a/web/templates/run.html b/web/templates/run.html new file mode 100644 index 0000000..2c84400 --- /dev/null +++ b/web/templates/run.html @@ -0,0 +1,126 @@ +{% extends "base.html" %} +{% block title %}Run {{ run.run_id }}{% endblock %} +{% block head %}{% if run.status == "Running" %}{% endif %}{% endblock %} +{% block style %} + .st { display: inline-block; padding: 2px 9px; border-radius: 10px; + font-weight: 600; font-size: 12px; background: var(--head); } + .st.Done { background: var(--pass-bg); color: var(--pass); } + .st.Run-Error, .st.Ended-without-a-record { background: var(--fail-bg); + color: var(--fail); } + .st.Stopped { background: var(--review-bg); color: var(--review); } + .st.Running { background: var(--hilite); color: var(--accent); } + dl.facts { display: grid; grid-template-columns: 150px 1fr; gap: 6px 12px; + margin: 0; } + dl.facts dt { color: var(--muted); } + dl.facts dd { margin: 0; } + .card.error { border-left: 5px solid var(--fail); } + .card.warn { border-left: 5px solid var(--review-bar); } + table.plain { width: 100%; border-collapse: collapse; font-size: 13px; } + table.plain th, table.plain td { text-align: left; padding: 6px 8px; + border-bottom: 1px solid var(--line); + vertical-align: top; } + table.plain th { color: #3b4757; font-size: 12px; text-transform: uppercase; + letter-spacing: .4px; background: var(--head); } + td.t { white-space: nowrap; color: var(--muted); width: 90px; } + .ok { color: var(--pass); font-weight: 600; } + .no { color: var(--fail); font-weight: 600; } + pre.tb { background: var(--head); padding: 10px; border-radius: 6px; + white-space: pre-wrap; font-size: 12px; margin: 6px 0 0; } + ul.claims { columns: 4; margin: 0; padding-left: 18px; } + tr.k-run_error td, tr.k-claim_error td { color: var(--fail); } + tr.k-warning td { color: var(--review); } +{% endblock %} +{% block content %} +

Run {{ run.started | usdate }} {{ run.status }}

+

{% if run.status == "Running" %}Still running — this page refreshes every 5 seconds.{% else %}Ran {{ run.duration_s | dur }}.{% endif %}

+ +
+
+
Started By
{{ run.started_by }}
+
Source
{{ run.source | capitalize }}
+
Mode
{{ run.mode }}{% if run.test_sheet %} · claims from {{ run.test_sheet }}{% endif %}
+
Started
{{ run.started | usdate }}
+
Ended
{{ run.ended | usdate }}
+
Result
{{ run.status }}{% if run.exit_code is not none %} (exit {{ run.exit_code }}){% endif %}{% if run.error_step %} — {{ run.error_step }}: {{ run.error_message }}{% endif %}
+ {% if run.status == "Stopped" %}
Stopped By
{{ run.stopped_by or "—" }}
{% endif %} +
Claims
{{ run.claims_done }} of {{ run.claims_total }} checked · + {{ run.pass }} Pass · {{ run.fail }} Fail · {{ run.review }} Review{% if run.not_found %} · {{ run.not_found }} not found{% endif %}
+
+
+ +{% if run.run_errors %} +
+

Run Error

+ {% for e in run.run_errors %} +

{{ e.t[11:19] }} · {{ e.step }} · exit {{ e.exit_code }} — {{ e.message }}

+ {% if e.traceback %}
Traceback
{{ e.traceback }}
{% endif %} + {% endfor %} +
+{% endif %} + +
+

eCW Login Attempts{% if run.login_attempts %} · {{ run.login_total_s | dur }} in all{% endif %}

+ {% if run.login_attempts %} + + + + {% for a in run.login_attempts %} + + + + + + + + + + {% endfor %} + +
#StartEndTook2FA mail waitResultSteps
{{ a.attempt }}{{ a.start | usdate }}{{ a.end | usdate }}{{ a.duration_s | dur }}{{ a.twofa_wait_s | dur if a.twofa_wait_s is not none else "—" }}{{ a.result_label }}{% if a.message %} — {{ a.message }}{% endif %}{% for label, time in a.step_rows %}{{ time }} {{ label }}{% if not loop.last %}
{% endif %}{% endfor %}
+ {% else %}

No eCW login in this run.

{% endif %} +
+ +{% if run.claim_errors %} +
+

Claim Errors ({{ run.claim_errors | length }})

+

The bot could not check these claims; the Charge Summary shows them as Review.

+ + + {% for e in run.claim_errors %}{% endfor %} +
TimeClaimStepMessage
{{ e.t[11:19] }}{{ e.claim }}{{ e.step }}{{ e.message }}
+
+{% endif %} + +{% if run.not_reached %} +
+

Not Reached ({{ run.not_reached | length }})

+

Queued, but the run ended before it got to them.

+
    {% for c in run.not_reached %}
  • {{ c }}
  • {% endfor %}
+
+{% endif %} + +{% if run.warnings %} +
+

Warnings ({{ run.warnings | length }})

+ + + {% for e in run.warnings %}{% endfor %} +
TimeKindClaimMessage
{{ e.t[11:19] }}{{ e.code }}{{ e.claim or "" }}{{ e.message }}
+
+{% endif %} + +{% if run.screenshots %} +
+

Failure screenshots

+
    {% for e in run.screenshots %}
  • {{ e.t[11:19] }} {{ e.tag }}
  • {% endfor %}
+

Each one's page HTML, when saved, is beside it in Screenshots\ (open it there, not here).

+
+{% endif %} + +
+

Timeline

+ + {% for e in run.timeline %}{% endfor %} +
{{ e.time }}{{ e.text }}
+
+{% endblock %} diff --git a/web/templates/runs.html b/web/templates/runs.html new file mode 100644 index 0000000..1a162cb --- /dev/null +++ b/web/templates/runs.html @@ -0,0 +1,83 @@ +{% extends "base.html" %} +{% block title %}Runs{% endblock %} +{% block style %} + .st { display: inline-block; padding: 2px 9px; border-radius: 10px; + font-weight: 600; font-size: 12px; background: var(--head); } + .st.Done { background: var(--pass-bg); color: var(--pass); } + .st.Run-Error, .st.Ended-without-a-record { background: var(--fail-bg); + color: var(--fail); } + .st.Stopped { background: var(--review-bg); color: var(--review); } + .st.Running { background: var(--hilite); color: var(--accent); } + td .Pass { color: var(--pass); font-weight: 600; } + td .Fail { color: var(--fail); font-weight: 600; } + td .Review { color: var(--review); font-weight: 600; } + td.bad { color: var(--fail); font-weight: 600; } + td.warn { color: var(--review); font-weight: 600; } + table.list tbody tr a { color: inherit; display: block; } + /* Fixed layout takes the header's widths: the time and the login column + must not be cut at 1180 px. */ + #runs th.w-start { width: 160px; } #runs th.w-who { width: 100px; } + #runs th.w-src { width: 85px; } #runs th.w-mode { width: 80px; } + #runs th.w-dur { width: 85px; } #runs th.w-res { width: 135px; } + #runs th.w-login { width: 150px; } #runs th.w-cat { width: 125px; } + #runs th.w-err { width: 95px; } #runs th.w-warn { width: 80px; } + table.list tbody tr a:hover { text-decoration: none; } +{% endblock %} +{% block content %} +

Runs

+

Every bot run since run tracking started (2026-10-22), newest + first: who started it, how long the eCW login took, and what went wrong. + Pick a run for its login steps, errors and timeline.

+ +{% if runs %} + + + + + + + + + + {% for r in runs %} + + + + + + + + + + + + + {% endfor %} + +
StartedStarted BySourceModeDurationResultPass / Fail / ReviewClaim ErrorsWarnings
{{ r.started | usdate }}{{ r.started_by }}{{ r.source | capitalize }}{{ r.mode }}{{ r.duration_s | dur }}{{ r.status }}{% if r.status == "Run Error" %} exit {{ r.exit_code }}{% endif %}{{ r.login_attempts | length }} attempt{{ "" if r.login_attempts | length == 1 else "s" }}{% if r.login_attempts %} · {{ r.login_total_s | dur }}{% endif %}{{ r.pass }} / {{ r.fail }} / {{ r.review }}{{ r.claim_errors | length }}{{ r.warnings | length }}
+{% else %} +
No runs{% if day_from or day_to or status %} match{% else %} tracked yet{% endif %}. + Runs from before 2026-10-22 are not tracked; every run from now on is.
+{% endif %} +{% endblock %} +{% block scripts %} + +{% endblock %} diff --git a/web/templates/signin.html b/web/templates/signin.html new file mode 100644 index 0000000..d642261 --- /dev/null +++ b/web/templates/signin.html @@ -0,0 +1,25 @@ +{% extends "base.html" %} +{% block title %}Sign in{% endblock %} +{% block style %} + .signin { max-width: 380px; margin: 40px auto; } + .signin label { display: block; margin: 0 0 12px; font-weight: 600; } + .signin input { display: block; width: 100%; margin-top: 4px; } + .signin button { width: 100%; margin-top: 6px; } +{% endblock %} +{% block content %} + +{% endblock %} diff --git a/web/templates/signins.html b/web/templates/signins.html new file mode 100644 index 0000000..3d9c038 --- /dev/null +++ b/web/templates/signins.html @@ -0,0 +1,23 @@ +{% extends "base.html" %} +{% block title %}Sign-in history{% endblock %} +{% block style %} + table.plain { width: 100%; border-collapse: collapse; font-size: 13px; } + table.plain th, table.plain td { text-align: left; padding: 7px 8px; + border-bottom: 1px solid var(--line); } + table.plain th { color: #3b4757; font-size: 12px; text-transform: uppercase; + letter-spacing: .4px; background: var(--head); } + tr.wrong_password td, tr.locked td, tr.disabled_attempt td { color: var(--fail); } +{% endblock %} +{% block content %} +

Sign-in history

+

The latest 300: Sign-ins, Sign-outs, wrong passwords, locks, + timeouts and every change an Admin made, newest first.

+{% if events %} +
+ + + {% for e in events %}{% endfor %} +
WhenUserWhatBy
{{ e.at | usdate }}{{ e.username }}{{ e.event | replace('_', ' ') }}{{ e.by_user or "" }}
+
+{% else %}
Nothing yet.
{% endif %} +{% endblock %} diff --git a/web/templates/users.html b/web/templates/users.html new file mode 100644 index 0000000..4a46e8e --- /dev/null +++ b/web/templates/users.html @@ -0,0 +1,89 @@ +{% extends "base.html" %} +{% block title %}Portal Users{% endblock %} +{% block style %} + form.add { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)) auto; + gap: 10px; align-items: end; } + form.add label { font-weight: 600; font-size: 13px; } + form.add input, form.add select { display: block; width: 100%; margin-top: 4px; } + table.plain { width: 100%; border-collapse: collapse; font-size: 13px; } + table.plain th, table.plain td { text-align: left; padding: 8px; + border-bottom: 1px solid var(--line); + vertical-align: middle; } + table.plain th { color: #3b4757; font-size: 12px; text-transform: uppercase; + letter-spacing: .4px; background: var(--head); } + td form { display: inline-flex; gap: 6px; margin: 0 8px 0 0; } + td form input { width: 150px; padding: 4px 8px; } + td form select, td form button { padding: 4px 8px; } + .tag { display: inline-block; padding: 1px 8px; border-radius: 10px; + font-size: 12px; font-weight: 600; background: var(--head); } + .tag.off { background: var(--fail-bg); color: var(--fail); } + .tag.lock, .tag.change { background: var(--review-bg); color: var(--review); } + .tag.on { background: var(--pass-bg); color: var(--pass); } + @media (max-width: 760px) { form.add { grid-template-columns: 1fr; } } +{% endblock %} +{% block content %} +

Portal Users

+

Admin: everything, and this page. Operator: see everything, + Start / Stop a Run. Viewer: see only. Users are disabled, never deleted.

+{% if error %}{% endif %} +{% if message %}
{{ message }}
{% endif %} + +
+

Add a user

+
+ + + + + + +
+

They change the first password at their first Sign-in.

+
+ +
+ + + + {% for u in users %} + + + + + + + + {% endfor %} + +
UserRoleStatusAddedChange
{{ u.username }}
{{ u.display_name }}
{{ u.role }} + {% if u.disabled %}Disabled + {% else %}Active{% endif %} + {% if u.must_change %}Must change password{% endif %} + {% if u.locked_until %}Locked until {{ u.locked_until | usdate }}{% endif %} + {{ u.created_at | usdate }}
by {{ u.created_by }}
+
+ + + +
+
+ + + +
+ {% if u.disabled %} +
+ + +
+ {% elif u.username != user.username %} +
+ + +
+ {% endif %} +
+
+{% endblock %} diff --git a/web/users.py b/web/users.py new file mode 100644 index 0000000..a748404 --- /dev/null +++ b/web/users.py @@ -0,0 +1,328 @@ +r""" +Portal Users, Sign-in and sessions (spec docs/superpowers/specs/2026-10-22- +run-tracking-and-portal-sign-in-design.md, Stage 2, EEWA-32; terms in +GLOSSARY.md). + + * **Passwords** are stored only as PBKDF2-SHA256 hashes with their own + salt and iteration count (stdlib, no new package on this locked-down + PC); never stored, logged or shown in plain. At least 10 characters, + not the username. + * **Roles**: Admin (everything, and managing Portal Users), Operator (see + everything, Start / Stop a Run), Viewer (see only). + * **Lockout**: 5 wrong passwords in a row lock the user for 15 minutes. + An unknown username is told the same as a wrong password, and costs the + same time (a hash is still computed), so the page does not reveal which + usernames exist. + * **Sessions** live in the Portal's memory: a server restart signs + everyone out. 30 minutes idle ends one. Disabling a user, resetting + their password or changing their role ends theirs at once. + * Users are disabled, never deleted; their history stays. + * Every Sign-in, Sign-out, wrong password, lock, timeout and Admin change + is a row in `CV_PortalSignIn`. + +`Accounts` is the logic, against any store with the `SqlUserStore` +methods (tests use an in-memory one). +""" + +import re +import hmac +import secrets +import hashlib +from datetime import datetime, timedelta + +ROLES = ("Admin", "Operator", "Viewer") +CAN_RUN = ("Admin", "Operator") +MIN_PASSWORD = 10 +ITERATIONS = 600_000 +LOCK_AFTER = 5 +LOCK_MINUTES = 15 +IDLE_MINUTES = 30 +USERNAME = re.compile(r"^[A-Za-z0-9._-]{2,50}$") + +USER_TABLE = "dbo.CV_PortalUser" +SIGNIN_TABLE = "dbo.CV_PortalSignIn" +_USER_COLUMNS = ("username", "display_name", "role", "pw_hash", "pw_salt", + "pw_iterations", "must_change", "disabled", "failed_count", + "locked_until", "created_at", "created_by", "changed_at") + + +class UserError(ValueError): + """A request an Admin or user can fix: the message says how.""" + + +# ── Passwords ───────────────────────────────────────────────────────────────── + +def hash_password(password: str, salt: str = None, + iterations: int = None) -> tuple: + """(hash hex, salt hex, iterations).""" + salt = salt or secrets.token_hex(16) + iterations = iterations or ITERATIONS + digest = hashlib.pbkdf2_hmac("sha256", password.encode("utf-8"), + bytes.fromhex(salt), iterations) + return digest.hex(), salt, iterations + + +def verify_password(password: str, pw_hash: str, salt: str, + iterations: int) -> bool: + try: + got, _, _ = hash_password(password, salt, int(iterations)) + except (ValueError, TypeError): + return False + return hmac.compare_digest(got, pw_hash or "") + + +def password_problem(password: str, username: str = ""): + """Why `password` is not allowed, or None.""" + if len(password or "") < MIN_PASSWORD: + return f"The password needs at least {MIN_PASSWORD} characters" + if username and password.strip().lower() == username.lower(): + return "The password cannot be the username" + return None + + +def clean_username(username: str) -> str: + return (username or "").strip().lower() + + +# ── The store (SQL Server) ──────────────────────────────────────────────────── + +class SqlUserStore: + """`CV_PortalUser` and `CV_PortalSignIn` in Charge_Validation. Every + statement parameterized; one short connection per call.""" + + def __init__(self, conn_str: str, connect=None): + self.conn_str = conn_str + self._connect = connect + + def _run(self, sql: str, params=(), fetch=False): + connect = self._connect + if connect is None: + import pyodbc + connect = pyodbc.connect + conn = connect(self.conn_str, timeout=10) + try: + cur = conn.cursor().execute(sql, list(params)) + if fetch: + cols = [d[0] for d in cur.description] + return [dict(zip(cols, row)) for row in cur.fetchall()] + conn.commit() + return None + finally: + conn.close() + + def get(self, username): + rows = self._run(f"SELECT {', '.join(_USER_COLUMNS)} FROM " + f"{USER_TABLE} WHERE username = ?", + [username], fetch=True) + return _bools(rows[0]) if rows else None + + def list(self) -> list: + return [_bools(r) for r in self._run( + f"SELECT {', '.join(_USER_COLUMNS)} FROM {USER_TABLE} " + f"ORDER BY username", fetch=True)] + + def create(self, user: dict) -> None: + cols = [c for c in _USER_COLUMNS if c in user] + self._run(f"INSERT INTO {USER_TABLE} ({', '.join(cols)}) VALUES " + f"({', '.join('?' for _ in cols)})", + [user[c] for c in cols]) + + def update(self, username, **fields) -> None: + cols = [c for c in fields if c in _USER_COLUMNS and c != "username"] + if not cols: + return + self._run(f"UPDATE {USER_TABLE} SET " + f"{', '.join(c + ' = ?' for c in cols)} WHERE username = ?", + [fields[c] for c in cols] + [username]) + + def record(self, username, event, by="") -> None: + self._run(f"INSERT INTO {SIGNIN_TABLE} (at, username, event, by_user)" + f" VALUES (?, ?, ?, ?)", + [datetime.now().replace(microsecond=0), username[:50], + event, by or None]) + + def history(self, limit: int = 300) -> list: + return self._run(f"SELECT TOP {int(limit)} at, username, event, " + f"by_user FROM {SIGNIN_TABLE} ORDER BY id DESC", + fetch=True) + + +def _bools(row: dict) -> dict: + for k in ("must_change", "disabled"): + row[k] = bool(row.get(k)) + return row + + +# ── Sessions ────────────────────────────────────────────────────────────────── + +class Sessions: + """Signed-in users by cookie token, in memory.""" + + def __init__(self, now=datetime.now): + self._now = now + self._by_token = {} + + def new(self, user: dict) -> str: + token = secrets.token_urlsafe(32) + self._by_token[token] = {"user": _public(user), "last": self._now()} + return token + + def get(self, token): + """(user dict, None) for a live session; (None, username) for one + that just timed out; (None, None) otherwise.""" + s = self._by_token.get(token or "") + if not s: + return None, None + if self._now() - s["last"] > timedelta(minutes=IDLE_MINUTES): + del self._by_token[token] + return None, s["user"]["username"] + s["last"] = self._now() + return s["user"], None + + def drop(self, token) -> None: + self._by_token.pop(token or "", None) + + def drop_user(self, username) -> None: + for t in [t for t, s in self._by_token.items() + if s["user"]["username"] == username]: + del self._by_token[t] + + def refresh_user(self, user: dict) -> None: + for s in self._by_token.values(): + if s["user"]["username"] == user["username"]: + s["user"] = _public(user) + + +def _public(user: dict) -> dict: + """What a session keeps: never the hash.""" + return {"username": user["username"], + "display_name": user.get("display_name") or user["username"], + "role": user["role"], "must_change": bool(user.get("must_change"))} + + +# ── The rules ───────────────────────────────────────────────────────────────── + +class Accounts: + def __init__(self, store, now=datetime.now): + self.store = store + self._now = now + # Spent on an unknown username so it takes as long as a known one. + self._dummy = hash_password(secrets.token_hex(8)) + + def sign_in(self, username: str, password: str): + """(user, "ok") or (None, "bad" | "locked" | "disabled").""" + name = clean_username(username) + user = self.store.get(name) if USERNAME.match(name) else None + if user is None: + verify_password(password or "", *self._dummy) + if name: + self.store.record(name, "wrong_password") + return None, "bad" + now = self._now() + if user.get("locked_until") and user["locked_until"] > now: + self.store.record(name, "locked") + return None, "locked" + if not verify_password(password or "", user["pw_hash"], + user["pw_salt"], user["pw_iterations"]): + failed = int(user.get("failed_count") or 0) + 1 + if failed >= LOCK_AFTER: + self.store.update(name, failed_count=0, locked_until=now + + timedelta(minutes=LOCK_MINUTES)) + self.store.record(name, "locked") + return None, "locked" + self.store.update(name, failed_count=failed) + self.store.record(name, "wrong_password") + return None, "bad" + if user.get("disabled"): + self.store.record(name, "disabled_attempt") + return None, "disabled" + self.store.update(name, failed_count=0, locked_until=None) + self.store.record(name, "sign_in") + return user, "ok" + + def create_user(self, username, display_name, role, password, by=""): + name = clean_username(username) + if not USERNAME.match(name): + raise UserError("Username: 2-50 letters, digits, dot, dash or " + "underscore") + if role not in ROLES: + raise UserError(f"Role must be one of {', '.join(ROLES)}") + problem = password_problem(password, name) + if problem: + raise UserError(problem) + if self.store.get(name): + raise UserError(f"{name} already exists") + pw_hash, salt, iterations = hash_password(password) + now = self._now().replace(microsecond=0) + self.store.create({ + "username": name, + "display_name": (display_name or "").strip()[:100] or name, + "role": role, "pw_hash": pw_hash, "pw_salt": salt, + "pw_iterations": iterations, + # Made by an Admin: the user picks their own at first Sign-in. + "must_change": bool(by), "disabled": False, "failed_count": 0, + "locked_until": None, "created_at": now, + "created_by": by or "add_portal_user", "changed_at": now}) + self.store.record(name, "user_created", by) + return self.store.get(name) + + def change_password(self, username, current, new): + user = self.store.get(username) + if not user or not verify_password(current or "", user["pw_hash"], + user["pw_salt"], + user["pw_iterations"]): + raise UserError("The current password is not right") + if current == new: + raise UserError("The new password must be different") + self._set_password(user, new, must_change=False) + self.store.record(username, "password_changed") + return self.store.get(username) + + def reset_password(self, username, new, by): + user = self._existing(username) + self._set_password(user, new, must_change=True) + self.store.record(user["username"], "password_reset", by) + + def set_role(self, username, role, by): + user = self._existing(username) + if role not in ROLES: + raise UserError(f"Role must be one of {', '.join(ROLES)}") + if user["role"] == "Admin" and role != "Admin": + self._keep_an_admin(user["username"]) + self.store.update(user["username"], role=role, + changed_at=self._now().replace(microsecond=0)) + self.store.record(user["username"], f"role_{role.lower()}", by) + + def set_disabled(self, username, disabled: bool, by): + user = self._existing(username) + if disabled and user["username"] == by: + raise UserError("You cannot disable yourself") + if disabled and user["role"] == "Admin": + self._keep_an_admin(user["username"]) + self.store.update(user["username"], disabled=disabled, + failed_count=0, locked_until=None, + changed_at=self._now().replace(microsecond=0)) + self.store.record(user["username"], + "user_disabled" if disabled else "user_enabled", by) + + def _set_password(self, user, new, must_change): + problem = password_problem(new, user["username"]) + if problem: + raise UserError(problem) + pw_hash, salt, iterations = hash_password(new) + self.store.update(user["username"], pw_hash=pw_hash, pw_salt=salt, + pw_iterations=iterations, must_change=must_change, + failed_count=0, locked_until=None, + changed_at=self._now().replace(microsecond=0)) + + def _existing(self, username): + user = self.store.get(clean_username(username)) + if not user: + raise UserError(f"No user {username}") + return user + + def _keep_an_admin(self, leaving): + others = [u for u in self.store.list() if u["role"] == "Admin" + and not u["disabled"] and u["username"] != leaving] + if not others: + raise UserError("The Portal needs at least one active Admin")