From ba15a574ab20eb2410190f7b5f68364aa57fcaf6 Mon Sep 17 00:00:00 2001 From: ManikandanC Date: Fri, 23 Oct 2026 18:38:17 +0530 Subject: [PATCH] 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 --- .env.example | 302 ++ .gitignore | 51 + CHANGELOG.md | 340 ++ CLAUDE.md | 13 + Context.md | 1100 ++++++ GLOSSARY.md | 96 + README.md | 759 ++++ docs/2026-10-01-demo-production-review.md | 324 ++ docs/Sarva_RCM_API_Assessment.docx | Bin 0 -> 11082 bytes docs/adr/0001-run-events-in-files.md | 25 + docs/agents/domain.md | 42 + docs/agents/issue-tracker.md | 37 + .../2026-09-15-payer-group-categorization.md | 910 +++++ .../2026-10-08-results-db-and-web-page.md | 108 + .../2026-10-14-start-bot-from-web-page.md | 1767 ++++++++++ .../2026-10-19-results-portal-redesign.md | 67 + ...09-15-payer-group-categorization-design.md | 182 + .../2026-09-16-condition-framework-design.md | 269 ++ ...dental-claims-run-all-conditions-design.md | 137 + ...26-10-08-results-db-and-web-page-design.md | 164 + ...26-10-14-start-bot-from-web-page-design.md | 176 + ...26-10-19-results-portal-redesign-design.md | 96 + ...-run-tracking-and-portal-sign-in-design.md | 161 + requirements.txt | 8 + run.py | 23 + run_web.bat | 12 + src/ecw_bot/__init__.py | 21 + src/ecw_bot/__main__.py | 16 + src/ecw_bot/auth/__init__.py | 1 + src/ecw_bot/auth/captcha.py | 102 + src/ecw_bot/auth/dead_end.py | 81 + src/ecw_bot/auth/login.py | 332 ++ src/ecw_bot/auth/mailbox.py | 375 ++ src/ecw_bot/auth/password_entry.py | 24 + src/ecw_bot/auth/restricted.py | 51 + src/ecw_bot/auth/twofactor.py | 241 ++ src/ecw_bot/claim_file.py | 221 ++ src/ecw_bot/conditions/__init__.py | 74 + src/ecw_bot/conditions/base.py | 272 ++ src/ecw_bot/conditions/bh_h_code_modifier.py | 200 ++ src/ecw_bot/conditions/dental_dummy_codes.py | 122 + src/ecw_bot/conditions/dental_payer_check.py | 172 + .../conditions/depression_dx_pointer.py | 149 + src/ecw_bot/conditions/dummy_codes.py | 63 + .../conditions/injection_admin_code.py | 277 ++ src/ecw_bot/conditions/j2930_j2001_update.py | 74 + src/ecw_bot/conditions/lines.py | 60 + .../conditions/medicaid_pregnancy_th.py | 180 + .../conditions/medicaid_uncheck_cpts.py | 82 + .../conditions/medicare_billing_screening.py | 148 + src/ecw_bot/conditions/medicare_pps_code.py | 279 ++ src/ecw_bot/conditions/medicare_telehealth.py | 329 ++ src/ecw_bot/conditions/medicare_vaccine_a6.py | 151 + .../conditions/medicare_wellness_history.py | 158 + src/ecw_bot/conditions/priced_line_check.py | 157 + src/ecw_bot/conditions/telehealth.py | 265 ++ src/ecw_bot/conditions/vaccine_admin_codes.py | 814 +++++ src/ecw_bot/conditions/zero_fee_uncheck.py | 161 + src/ecw_bot/core/__init__.py | 1 + src/ecw_bot/core/browser.py | 351 ++ src/ecw_bot/core/charge_summary.py | 462 +++ src/ecw_bot/core/fallback.py | 67 + src/ecw_bot/core/guard.py | 201 ++ src/ecw_bot/core/msgbox.py | 175 + src/ecw_bot/core/page_utils.py | 330 ++ src/ecw_bot/core/payer_groups.py | 81 + src/ecw_bot/core/results_db.py | 318 ++ src/ecw_bot/core/run_events.py | 146 + src/ecw_bot/core/run_lock.py | 229 ++ src/ecw_bot/core/runlog.py | 76 + src/ecw_bot/core/spreadsheet.py | 396 +++ src/ecw_bot/core/text.py | 23 + src/ecw_bot/core/watchdog.py | 148 + src/ecw_bot/flows/__init__.py | 4 + src/ecw_bot/flows/export_medcor_ready.py | 1603 +++++++++ src/ecw_bot/pages/__init__.py | 4 + src/ecw_bot/pages/account_inquiry.py | 176 + src/ecw_bot/pages/claim_filters.py | 520 +++ src/ecw_bot/pages/claim_form.py | 3045 +++++++++++++++++ src/ecw_bot/pages/claim_report.py | 377 ++ src/ecw_bot/pages/claim_results.py | 385 +++ src/ecw_bot/pages/dental_notes.py | 104 + src/ecw_bot/pages/leftnav.py | 143 + src/ecw_bot/pages/preflight.py | 179 + src/ecw_bot/pages/progress_note.py | 386 +++ src/ecw_bot/pages/sliding_fee.py | 249 ++ src/ecw_bot/payer_groups.json | 274 ++ src/ecw_bot/selectors/__init__.py | 10 + src/ecw_bot/selectors/account_inquiry.py | 42 + src/ecw_bot/selectors/claim_form.py | 394 +++ src/ecw_bot/selectors/claim_lookup.py | 127 + src/ecw_bot/selectors/fallbacks.py | 19 + src/ecw_bot/selectors/login.py | 113 + src/ecw_bot/selectors/shell.py | 35 + src/ecw_bot/settings.py | 760 ++++ start_ecw_chrome.bat | 7 + tests/fixtures/account_inquiry.html | 106 + tests/fixtures/dental_claim.html | 208 ++ tests/fixtures/hcfa_claim.html | 499 +++ tests/fixtures/progress_note.html | 331 ++ tests/fixtures/progress_note_delayed.html | 162 + tests/fixtures/progress_note_host.html | 50 + tests/fixtures/sliding_fee.html | 93 + tests/fixtures/ub_claim.html | 458 +++ tests/fixtures/vaccine_claim.html | 506 +++ tests/test_auto_dialog.py | 175 + tests/test_bot_runner.py | 426 +++ tests/test_categorize_by_payer.py | 208 ++ tests/test_cdp_browser.py | 401 +++ tests/test_charge_summary.py | 865 +++++ tests/test_claim_boxes.py | 599 ++++ tests/test_claim_file.py | 189 + tests/test_claim_form.py | 251 ++ tests/test_claim_loaded.py | 690 ++++ tests/test_clean_between_claims.py | 410 +++ tests/test_conditions_bh_h_code_modifier.py | 459 +++ tests/test_conditions_dental_dummy_codes.py | 259 ++ tests/test_conditions_dental_payer_check.py | 269 ++ .../test_conditions_depression_dx_pointer.py | 628 ++++ tests/test_conditions_dummy_codes.py | 241 ++ tests/test_conditions_injection_admin_code.py | 306 ++ tests/test_conditions_j2930_j2001_update.py | 251 ++ .../test_conditions_medicaid_pregnancy_th.py | 256 ++ .../test_conditions_medicaid_uncheck_cpts.py | 214 ++ ...t_conditions_medicare_billing_screening.py | 343 ++ tests/test_conditions_medicare_pps_code.py | 380 ++ tests/test_conditions_medicare_telehealth.py | 601 ++++ tests/test_conditions_medicare_vaccine_a6.py | 330 ++ ...st_conditions_medicare_wellness_history.py | 314 ++ tests/test_conditions_priced_line_check.py | 972 ++++++ tests/test_conditions_runner.py | 458 +++ tests/test_conditions_telehealth.py | 424 +++ tests/test_conditions_vaccine_admin_codes.py | 1366 ++++++++ tests/test_conditions_zero_fee_uncheck.py | 831 +++++ tests/test_dead_end.py | 105 + tests/test_dental_claim_skip.py | 104 + tests/test_dental_conditions.py | 253 ++ tests/test_dental_high_risk.py | 246 ++ tests/test_fallback_selectors.py | 165 + tests/test_guard.py | 113 + tests/test_hcfa_claim.py | 542 +++ tests/test_import_payer_groups.py | 76 + tests/test_inert_claim_windows.py | 225 ++ tests/test_input_archive.py | 201 ++ tests/test_mode_banner.py | 341 ++ tests/test_msgbox.py | 256 ++ tests/test_open_claim_recovery.py | 657 ++++ tests/test_page_utils.py | 176 + tests/test_password_entry.py | 88 + tests/test_password_expiry_prompt.py | 198 ++ tests/test_payer_groups.py | 154 + tests/test_portal_signin.py | 315 ++ tests/test_portal_users.py | 263 ++ tests/test_preflight.py | 304 ++ tests/test_progress_note.py | 198 ++ tests/test_progress_note_wait.py | 311 ++ tests/test_results_db.py | 473 +++ tests/test_run_alerts.py | 278 ++ tests/test_run_events.py | 193 ++ tests/test_run_events_flow.py | 280 ++ tests/test_run_lock.py | 350 ++ tests/test_run_status.py | 342 ++ tests/test_runs_page.py | 168 + tests/test_runs_view.py | 243 ++ tests/test_save_corrections_gate.py | 783 +++++ tests/test_settings.py | 510 +++ tests/test_sliding_fee.py | 311 ++ tests/test_spreadsheet_write.py | 339 ++ tests/test_stacked_dialogs.py | 258 ++ tests/test_summary_per_claim.py | 140 + tests/test_telehealth_fix.py | 644 ++++ tests/test_test_claim_ids_file.py | 354 ++ tests/test_verify_no_live_writes.py | 189 + tests/test_watchdog.py | 233 ++ tests/test_watchdog_flow.py | 265 ++ tests/test_web_app.py | 789 +++++ tools/add_portal_user.py | 57 + tools/check_claims_from_excel.py | 228 ++ tools/check_guard.py | 114 + tools/check_imap.py | 169 + tools/check_net.py | 153 + tools/import_payer_groups.py | 140 + tools/sql/create_tables.sql | 98 + tools/sql/rename_to_cv_prefix.sql | 28 + tools/sql/view_portal_tables.sql | 32 + tools/start_ecw_chrome.py | 69 + web/__init__.py | 1 + web/app.py | 689 ++++ web/bot_runner.py | 209 ++ web/repo.py | 121 + web/run_status.py | 187 + web/runs.py | 292 ++ web/templates/_claim_panel.html | 83 + web/templates/base.html | 192 ++ web/templates/claim.html | 54 + web/templates/dashboard.html | 273 ++ web/templates/day.html | 86 + web/templates/days.html | 57 + web/templates/password.html | 23 + web/templates/run.html | 126 + web/templates/runs.html | 83 + web/templates/signin.html | 25 + web/templates/signins.html | 23 + web/templates/users.html | 89 + web/users.py | 328 ++ 205 files changed, 56772 insertions(+) create mode 100644 .env.example create mode 100644 .gitignore create mode 100644 CHANGELOG.md create mode 100644 CLAUDE.md create mode 100644 Context.md create mode 100644 GLOSSARY.md create mode 100644 README.md create mode 100644 docs/2026-10-01-demo-production-review.md create mode 100644 docs/Sarva_RCM_API_Assessment.docx create mode 100644 docs/adr/0001-run-events-in-files.md create mode 100644 docs/agents/domain.md create mode 100644 docs/agents/issue-tracker.md create mode 100644 docs/superpowers/plans/2026-09-15-payer-group-categorization.md create mode 100644 docs/superpowers/plans/2026-10-08-results-db-and-web-page.md create mode 100644 docs/superpowers/plans/2026-10-14-start-bot-from-web-page.md create mode 100644 docs/superpowers/plans/2026-10-19-results-portal-redesign.md create mode 100644 docs/superpowers/specs/2026-09-15-payer-group-categorization-design.md create mode 100644 docs/superpowers/specs/2026-09-16-condition-framework-design.md create mode 100644 docs/superpowers/specs/2026-10-07-dental-claims-run-all-conditions-design.md create mode 100644 docs/superpowers/specs/2026-10-08-results-db-and-web-page-design.md create mode 100644 docs/superpowers/specs/2026-10-14-start-bot-from-web-page-design.md create mode 100644 docs/superpowers/specs/2026-10-19-results-portal-redesign-design.md create mode 100644 docs/superpowers/specs/2026-10-22-run-tracking-and-portal-sign-in-design.md create mode 100644 requirements.txt create mode 100644 run.py create mode 100644 run_web.bat create mode 100644 src/ecw_bot/__init__.py create mode 100644 src/ecw_bot/__main__.py create mode 100644 src/ecw_bot/auth/__init__.py create mode 100644 src/ecw_bot/auth/captcha.py create mode 100644 src/ecw_bot/auth/dead_end.py create mode 100644 src/ecw_bot/auth/login.py create mode 100644 src/ecw_bot/auth/mailbox.py create mode 100644 src/ecw_bot/auth/password_entry.py create mode 100644 src/ecw_bot/auth/restricted.py create mode 100644 src/ecw_bot/auth/twofactor.py create mode 100644 src/ecw_bot/claim_file.py create mode 100644 src/ecw_bot/conditions/__init__.py create mode 100644 src/ecw_bot/conditions/base.py create mode 100644 src/ecw_bot/conditions/bh_h_code_modifier.py create mode 100644 src/ecw_bot/conditions/dental_dummy_codes.py create mode 100644 src/ecw_bot/conditions/dental_payer_check.py create mode 100644 src/ecw_bot/conditions/depression_dx_pointer.py create mode 100644 src/ecw_bot/conditions/dummy_codes.py create mode 100644 src/ecw_bot/conditions/injection_admin_code.py create mode 100644 src/ecw_bot/conditions/j2930_j2001_update.py create mode 100644 src/ecw_bot/conditions/lines.py create mode 100644 src/ecw_bot/conditions/medicaid_pregnancy_th.py create mode 100644 src/ecw_bot/conditions/medicaid_uncheck_cpts.py create mode 100644 src/ecw_bot/conditions/medicare_billing_screening.py create mode 100644 src/ecw_bot/conditions/medicare_pps_code.py create mode 100644 src/ecw_bot/conditions/medicare_telehealth.py create mode 100644 src/ecw_bot/conditions/medicare_vaccine_a6.py create mode 100644 src/ecw_bot/conditions/medicare_wellness_history.py create mode 100644 src/ecw_bot/conditions/priced_line_check.py create mode 100644 src/ecw_bot/conditions/telehealth.py create mode 100644 src/ecw_bot/conditions/vaccine_admin_codes.py create mode 100644 src/ecw_bot/conditions/zero_fee_uncheck.py create mode 100644 src/ecw_bot/core/__init__.py create mode 100644 src/ecw_bot/core/browser.py create mode 100644 src/ecw_bot/core/charge_summary.py create mode 100644 src/ecw_bot/core/fallback.py create mode 100644 src/ecw_bot/core/guard.py create mode 100644 src/ecw_bot/core/msgbox.py create mode 100644 src/ecw_bot/core/page_utils.py create mode 100644 src/ecw_bot/core/payer_groups.py create mode 100644 src/ecw_bot/core/results_db.py create mode 100644 src/ecw_bot/core/run_events.py create mode 100644 src/ecw_bot/core/run_lock.py create mode 100644 src/ecw_bot/core/runlog.py create mode 100644 src/ecw_bot/core/spreadsheet.py create mode 100644 src/ecw_bot/core/text.py create mode 100644 src/ecw_bot/core/watchdog.py create mode 100644 src/ecw_bot/flows/__init__.py create mode 100644 src/ecw_bot/flows/export_medcor_ready.py create mode 100644 src/ecw_bot/pages/__init__.py create mode 100644 src/ecw_bot/pages/account_inquiry.py create mode 100644 src/ecw_bot/pages/claim_filters.py create mode 100644 src/ecw_bot/pages/claim_form.py create mode 100644 src/ecw_bot/pages/claim_report.py create mode 100644 src/ecw_bot/pages/claim_results.py create mode 100644 src/ecw_bot/pages/dental_notes.py create mode 100644 src/ecw_bot/pages/leftnav.py create mode 100644 src/ecw_bot/pages/preflight.py create mode 100644 src/ecw_bot/pages/progress_note.py create mode 100644 src/ecw_bot/pages/sliding_fee.py create mode 100644 src/ecw_bot/payer_groups.json create mode 100644 src/ecw_bot/selectors/__init__.py create mode 100644 src/ecw_bot/selectors/account_inquiry.py create mode 100644 src/ecw_bot/selectors/claim_form.py create mode 100644 src/ecw_bot/selectors/claim_lookup.py create mode 100644 src/ecw_bot/selectors/fallbacks.py create mode 100644 src/ecw_bot/selectors/login.py create mode 100644 src/ecw_bot/selectors/shell.py create mode 100644 src/ecw_bot/settings.py create mode 100644 start_ecw_chrome.bat create mode 100644 tests/fixtures/account_inquiry.html create mode 100644 tests/fixtures/dental_claim.html create mode 100644 tests/fixtures/hcfa_claim.html create mode 100644 tests/fixtures/progress_note.html create mode 100644 tests/fixtures/progress_note_delayed.html create mode 100644 tests/fixtures/progress_note_host.html create mode 100644 tests/fixtures/sliding_fee.html create mode 100644 tests/fixtures/ub_claim.html create mode 100644 tests/fixtures/vaccine_claim.html create mode 100644 tests/test_auto_dialog.py create mode 100644 tests/test_bot_runner.py create mode 100644 tests/test_categorize_by_payer.py create mode 100644 tests/test_cdp_browser.py create mode 100644 tests/test_charge_summary.py create mode 100644 tests/test_claim_boxes.py create mode 100644 tests/test_claim_file.py create mode 100644 tests/test_claim_form.py create mode 100644 tests/test_claim_loaded.py create mode 100644 tests/test_clean_between_claims.py create mode 100644 tests/test_conditions_bh_h_code_modifier.py create mode 100644 tests/test_conditions_dental_dummy_codes.py create mode 100644 tests/test_conditions_dental_payer_check.py create mode 100644 tests/test_conditions_depression_dx_pointer.py create mode 100644 tests/test_conditions_dummy_codes.py create mode 100644 tests/test_conditions_injection_admin_code.py create mode 100644 tests/test_conditions_j2930_j2001_update.py create mode 100644 tests/test_conditions_medicaid_pregnancy_th.py create mode 100644 tests/test_conditions_medicaid_uncheck_cpts.py create mode 100644 tests/test_conditions_medicare_billing_screening.py create mode 100644 tests/test_conditions_medicare_pps_code.py create mode 100644 tests/test_conditions_medicare_telehealth.py create mode 100644 tests/test_conditions_medicare_vaccine_a6.py create mode 100644 tests/test_conditions_medicare_wellness_history.py create mode 100644 tests/test_conditions_priced_line_check.py create mode 100644 tests/test_conditions_runner.py create mode 100644 tests/test_conditions_telehealth.py create mode 100644 tests/test_conditions_vaccine_admin_codes.py create mode 100644 tests/test_conditions_zero_fee_uncheck.py create mode 100644 tests/test_dead_end.py create mode 100644 tests/test_dental_claim_skip.py create mode 100644 tests/test_dental_conditions.py create mode 100644 tests/test_dental_high_risk.py create mode 100644 tests/test_fallback_selectors.py create mode 100644 tests/test_guard.py create mode 100644 tests/test_hcfa_claim.py create mode 100644 tests/test_import_payer_groups.py create mode 100644 tests/test_inert_claim_windows.py create mode 100644 tests/test_input_archive.py create mode 100644 tests/test_mode_banner.py create mode 100644 tests/test_msgbox.py create mode 100644 tests/test_open_claim_recovery.py create mode 100644 tests/test_page_utils.py create mode 100644 tests/test_password_entry.py create mode 100644 tests/test_password_expiry_prompt.py create mode 100644 tests/test_payer_groups.py create mode 100644 tests/test_portal_signin.py create mode 100644 tests/test_portal_users.py create mode 100644 tests/test_preflight.py create mode 100644 tests/test_progress_note.py create mode 100644 tests/test_progress_note_wait.py create mode 100644 tests/test_results_db.py create mode 100644 tests/test_run_alerts.py create mode 100644 tests/test_run_events.py create mode 100644 tests/test_run_events_flow.py create mode 100644 tests/test_run_lock.py create mode 100644 tests/test_run_status.py create mode 100644 tests/test_runs_page.py create mode 100644 tests/test_runs_view.py create mode 100644 tests/test_save_corrections_gate.py create mode 100644 tests/test_settings.py create mode 100644 tests/test_sliding_fee.py create mode 100644 tests/test_spreadsheet_write.py create mode 100644 tests/test_stacked_dialogs.py create mode 100644 tests/test_summary_per_claim.py create mode 100644 tests/test_telehealth_fix.py create mode 100644 tests/test_test_claim_ids_file.py create mode 100644 tests/test_verify_no_live_writes.py create mode 100644 tests/test_watchdog.py create mode 100644 tests/test_watchdog_flow.py create mode 100644 tests/test_web_app.py create mode 100644 tools/add_portal_user.py create mode 100644 tools/check_claims_from_excel.py create mode 100644 tools/check_guard.py create mode 100644 tools/check_imap.py create mode 100644 tools/check_net.py create mode 100644 tools/import_payer_groups.py create mode 100644 tools/sql/create_tables.sql create mode 100644 tools/sql/rename_to_cv_prefix.sql create mode 100644 tools/sql/view_portal_tables.sql create mode 100644 tools/start_ecw_chrome.py create mode 100644 web/__init__.py create mode 100644 web/app.py create mode 100644 web/bot_runner.py create mode 100644 web/repo.py create mode 100644 web/run_status.py create mode 100644 web/runs.py create mode 100644 web/templates/_claim_panel.html create mode 100644 web/templates/base.html create mode 100644 web/templates/claim.html create mode 100644 web/templates/dashboard.html create mode 100644 web/templates/day.html create mode 100644 web/templates/days.html create mode 100644 web/templates/password.html create mode 100644 web/templates/run.html create mode 100644 web/templates/runs.html create mode 100644 web/templates/signin.html create mode 100644 web/templates/signins.html create mode 100644 web/templates/users.html create mode 100644 web/users.py 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")