diff --git a/Context.md b/Context.md index c458562..a40e485 100644 --- a/Context.md +++ b/Context.md @@ -104,6 +104,26 @@ Fail / 0 Review** (the 50-claim sheet; same as 26/10 17:40). 17:30 with `Mecor Ready 2 - 10262026.xlsx` (all claims, QUIET_BOXES, ECW_KEEP_OPEN=0) — login 98 s, **50/50, 30 Pass / 15 Fail / 5 Review**, the same as that file on 26/10. No bot error in either. +**Client call (2026-10-27, MoM mailed to the team):** open conditions +discussed with Madan for clarification; the 50-claim results file is in +the shared production folder for Madan to review and give feedback; Madan +has a Portal login (daily runs, Pass / Fail claims, comments; changes +nothing). eCW data is the client's: we never update anything in eCW, not +even to undo the bot's own earlier box changes (operator, 2026-10-28). +**2026-10-28 runs:** 17:59 login failed (a late security image page, the +password box stayed disabled; fixed `d1aa6da`); 18:00 / 18:03 / 18:04 runs +started from VS Code's debugger (F5) — a Stop / restart there kills the run +with no log line (18:04 died at claim 29 of 50, 28 saved). **19:28 — first +run on the LIVE Medcor Ready queue** (operator: "move the download part +live"; `.env` `TEST_CLAIM_IDS_FILE` blanked, the old line kept as a +comment; `SHOW_EACH_CLAIM=0`, `QUIET_BOXES=1`, `MSGBOX_TIMEOUT_SECONDS=10`; +backup of the earlier `.env` in the session's temp folder): report +downloaded, 216 claims in the queue, the first 50 (`CLAIM_PROCESS_LIMIT`) +read: **24 Pass / 25 Fail / 1 Review**, exit 0, ~32 min; login 753 s (the +security image page waited for a person; 2FA mail 48 s). lab_codes, +vaccine_z23_pointer and t1017_non_billable fired on live claims. Still +VERIFY: nothing saved. Unattended runs still need the security image +Continue clicked by a person (offered: the bot clicks it — not decided). **Runs page (2026-10-22, Stage 1 of EEWA-31..33, `751a177`):** http://127.0.0.1:8000/runs (header link "Runs"). Every Run writes @@ -137,6 +157,15 @@ adds / changes Admins and Viewers — never deletes; nobody changes a Super Admin from the Portal, only the PC tool makes one), **Admin** (everything: Start / Stop, Sign-ins page; adds / changes Viewers only), **Viewer** (Dashboard, Claim status, Runs; changes nothing, no Users or Sign-ins). +**Delete run (`4cc27a5`, 2026-10-28, Super Admin only):** a day's page lists that +day's runs (time, claims, started by) with a Delete run button, shown to the +Super Admin only; `POST /admin/runs/delete` checks the role and the page +token (Admin / Viewer = 403). A confirm box first; it deletes that run's +`CV_ChargeSummary` rows (`WHERE run_at = ?`), never eCW, never the `Logs\` +files, and records "run · run deleted · by" in the Sign-in +history. A SQL login without DELETE gets "No permission to delete runs" +(SQLSTATE 42000) — the Dokploy Portal's `cv_portal` needs +`GRANT DELETE ON dbo.CV_ChargeSummary TO cv_portal` (§10). Operator is gone (folded into Admin). The live table was moved 2026-10-27 by the operator in SSMS (the bot's SQL login cannot ALTER the table): the one user is Super Admin; the CHECK allows only the three roles. @@ -213,8 +242,8 @@ Get-ChildItem tests\test_*.py | ForEach-Object { "$($_.Name): $((python $_.FullN this machine, with ODBC Driver 18 for SQL Server. - Tests run offline against fake HTML pages in `tests/fixtures/` and fake DB connections; they never touch eCW or SQL Server (`test_cdp_browser.py` - starts a local Chromium on a debug port). As of 2026-10-27: 71 test - files, **1239 tests** (full run 2026-10-27 after `a89f689`: 1239/1239) (earlier flaky, each + starts a local Chromium on a debug port). As of 2026-10-28: 75 test + files, **1286 tests** (full run 2026-10-28 with Z23 and T1017: 1286/1286) (earlier flaky, each failed once and passed on rerun: test_verify_no_live_writes, depression_dx_pointer, progress_note_wait — watch them) (about 5 minutes for all of them). @@ -315,15 +344,18 @@ Registry order (`conditions/__init__.py`) — order matters: | 7 | `dental_dummy_codes` | #48 | DPIMP or **DFIMP** (any case; DFIMP "FINAL IMPRESSION" is the code the live Dental grid carries, 2026-10-20). **Dental payer** (dashboard "Dental - Commercial" or "Dental - CHP+/DentaQuest") → uncheck + fee 0. **Any other payer**, unmapped or none read → nothing changed, claim **Review** with a Comments note naming the payer group ("DPIMP on a Medicaid payer claim — not changed ...") (operator, 2026-10-13). Separate from #8 | | 8 | `dental_payer_check` | #13 | A **billed Dental code** ("D" + 4 digits; DCONS / Dscprit / DPIMP / DFIMP are not; billed = `lines.ends_billed`) and **no insurance row** (primary, secondary ...) is DentaQuest, CHP+ DentaQuest or "Delta Dental …" (by name, case and spaces ignored; Aetna Dental PPO, Cigna Dental, MetLife are NOT allowed) → **Claim Status set to Patient** (`PAT`, `claim_form.set_claim_status`, `selectors.claim_form.SEL_CLAIM_STATUS` — never the `#status` claimRP field), no program check (operator, 2026-10-21, EEWA-27). Only where the run may write (`writes_allowed`: LIVE-WRITE, never the Dental screen) — otherwise Review "set it to Patient by hand". No payer read → Review, nothing changed. Comment names codes and payer(s) | | 9 | `medicaid_uncheck_cpts` | #43, #44 | **Medicaid** payer (rule_group "Medicaid" only — not RAE, CHP+, unmapped or blank; RAE ruled out by the operator 2026-10-14) → 15851, 15852, 73521 uncheck + fee 0; no other line touched (operator, 2026-10-13) | -| 10 | `medicare_billing_screening` | #14 | Medicare payer (rule_group; Medicare Advantage plans are Commercial since 2026-10-21) → G8510/G8431 uncheck + fee 0; no other line touched. No other billed line (screening only) → also Review "provider clarification needed" (operator, 2026-10-07) | -| 11 | `medicare_vaccine_a6` | #19 | **Medicare** payer (rule_group) with a billed **G0008 or G0009** (`lines.ends_billed`) → condition code **A6**: UB form ON SCREEN only — A6 already in FL 18-28 → nothing; else typed into the first empty box (`uBClaimCondtnCode1..11`) and read back from the box and its Angular model; all 11 full → Review "add A6 by hand"; typed but not taken → changed + Review. HCFA / Dental → Review "check A6 by hand" — **not to be mapped**: Medicare is billed on UB (#2), so such a claim is on the wrong form (operator, 2026-10-21). **#19 complete**; live test still needs a Medicare UB claim with G0008 / G0009. Only those two codes trigger it — not #18's vaccine list; the bot does not add G0008/G0009 (operator, 2026-10-16) | -| 12 | `medicare_wellness_history` | #27, #28, #29 | **Check only, never changes the claim** (operator, 2026-10-20). **Medicare** payer with a billed **G0402** (IPPE, once per lifetime), **G0438** (first AWV, once in 3 years) or **G0439** (subsequent AWV, once a year): reads the patient's earlier claims in **Account Inquiry** (claim form side menu → Patient Inquiry Detail → Show CPT Payments, every page; this claim left out; closed with the X, the tick undone). Billed inside the window, counted either way from this claim's service date (exactly 3 years / 12 months = outside) → **Review**, Comments "G0438 already billed on MM/DD/YYYY (claim N) — once in 3 years". Account Inquiry not read → Review "check … history by hand". `pages/account_inquiry.py`, `selectors/account_inquiry.py`. Since `ac25f83` (2026-10-23) it also leaves its verdict on the claim's data (`_wellness_history`: read or not, the repeats found) for #25 — still nothing changed in eCW | -| 13 | `medicaid_pregnancy_th` | #23 | **Medicaid** payer (rule_group "Medicaid" only, NOT RAE) with a **pregnancy-related ICD** (chapter O, Z33, Z34, Z36, Z3A — **Z32.00/.01/.02 pregnancy test never counts**) → **TH** on the first billed E&M line (99202-99499; none → first billed line), **in M1, overwriting what M1 held** (comment says "replacing 25"; never M2-M4); TH already in any box → nothing; no M1 box → Review "add TH by hand". A Medicaid telehealth pregnancy visit: **#21 writes TH instead of GT** (RAE keeps GT). Dental → Review. `9b87b4d`, not run live yet (2026-10-20, EEWA-30) | -| 14 | `bh_h_code_modifier` | #12 | H code (H + 4 digits). **BH payer** (name starts with "BH") with any modifier → M1–M4 cleared on that line only; telehealth note → not fired (GT stays); note not read → Review, nothing changed (operator, 2026-10-07). **Any other payer** → billed H code line unchecked + fee 0.00, telehealth or not, modifier or not; blank payer → not fired (operator, 2026-10-12). Comment worded from what was done | -| 15 | `vaccine_admin_codes` | #18 | Vaccine without admin code → add 90460(+90461) age ≤18, 90471(+90472) >18; units 1; all payers except Medicare. **Vaccine = the client's 19-code list only** (90633, 90648, 90651, 90656, 90657, 90660, 90680, 90687, 90698, 90707, 90710, 90713, 90716, 90732, 90734, 90744, 90746, 91321, 91322), by code; **admin = 90460/90461/90471/90472 only** (operator, 2026-10-13; no hover text, no 90476-90759 range). Unclassified payer / no payer / no age → Review; an admin code that could not be added → Review ("NOT added — add by hand") | -| 16 | `injection_admin_code` | #20 | Injection code from the client's **41-code list** (J0561 ... J8499, as given — IUD, inhalation and oral J-codes included) billed without **96372** → add 96372 ×1, Units 1, all payers except Medicare. Only 96372 counts as admin billed — also when it is unchecked with a fee (priced_line_check checks it at the end, so no second 96372; review 2026-10-14). J2001 / J2930 never count (#5 unchecks them). Unclassified / no payer → Review; not added → Review; Dental → Review (operator, 2026-10-13) | -| 17 | `medicare_pps_code` | #25 | **PPS Payer** (exact eCW names: Medicare, Medicare-Novitas, HCFA Medicare, HCFA Medicare Novitas, Humana Medicare, Devoted MCR UB, Cigna Healthspring, Cigna Health Spring/ Bravo — the two Cigna plans count although Commercial elsewhere) on a **UB** claim: billed (`lines.ends_billed`) **Qualifying Codes** → the **PPS Code** on the client's Medicare G code crosswalk (`CROSSWALK`): G0466 new office, G0466H new home, G0467 established office, G0467NH nursing home, G0467H established home, G0468 IPPE / AWV (G0402 / G0438 / G0439 win over an E&M). Right PPS Code billed → Pass. Missing → **added** via the Fee Schedule picker, Units 1 (LIVE-WRITE; Fail "CPT Code Missing (PPS code G0467 added (99213))"); VERIFY → Review "G0467 not added in VERIFY — add it by hand (99213)". **Review, nothing changed:** codes only on both the G0466 and G0467 lists ("new or established?"); conflicting visit codes; a different PPS Code billed (also beside the right one); mental health 90791 … 90845 ("G0469 or G0470?" until the client rules, EEWA-37; either one billed → Pass); a variant the picker cannot find; HCFA with a Qualifying Code; G0468 when #27–#29 found a repeat, could not read the history, or never checks it (the Cigna plans, Commercial there — operator, 2026-10-23). Dental → skipped (operator). `ac25f83` | -| 18 | `priced_line_check` | common rule | **LAST.** Unchecked line with Billed Fee > 0 → check | +| 10 | `lab_codes` | #3, #4 | **Lab code** = 5 digits starting with 8 (36416 is not one, never touched). By the **primary payer** (EEWA-38, operator 2026-10-28): **Medicare payers** (#25's exact eCW names, `medicare_pps_code.is_pps_payer`) → every lab line unchecked + fee 0; **Cigna Salud Employee** → any lab code; **every other payer**, blank included → only the 13 in-house codes (82948, 81025, 85018, 82270, 82274, 87806, 85610, 87880, 80305, 81001, 81003, 87210, 87636), any other lab line unchecked + fee 0. Code only, not the fee. Not on Dental claims. Comment: "Lab code not billed to Medicare (…)" / "Lab code not in the in-house list (…)" | +| 11 | `t1017_non_billable` | #46 | **T1017** by the primary payer (EEWA-40, operator 2026-10-28): a **Medicare payer** (#25's exact eCW names) or **Medicaid** (dashboard "Medicaid" group only, as #43/#44 — not RAE, not CHP+) → unchecked + fee 0, comment "T1017 not billable (Medicaid) - unchecked, fee 0"; **any other payer**, blank or unmapped included → nothing changed, **Review** "T1017 on - check by hand". Not on Dental claims | +| 12 | `medicare_billing_screening` | #14 | Medicare payer (rule_group; Medicare Advantage plans are Commercial since 2026-10-21) → G8510/G8431 uncheck + fee 0; no other line touched. No other billed line (screening only) → also Review "provider clarification needed" (operator, 2026-10-07) | +| 13 | `medicare_vaccine_a6` | #19 | **Medicare** payer (rule_group) with a billed **G0008 or G0009** (`lines.ends_billed`) → condition code **A6**: UB form ON SCREEN only — A6 already in FL 18-28 → nothing; else typed into the first empty box (`uBClaimCondtnCode1..11`) and read back from the box and its Angular model; all 11 full → Review "add A6 by hand"; typed but not taken → changed + Review. HCFA / Dental → Review "check A6 by hand" — **not to be mapped**: Medicare is billed on UB (#2), so such a claim is on the wrong form (operator, 2026-10-21). **#19 complete**; live test still needs a Medicare UB claim with G0008 / G0009. Only those two codes trigger it — not #18's vaccine list; the bot does not add G0008/G0009 (operator, 2026-10-16) | +| 14 | `medicare_wellness_history` | #27, #28, #29 | **Check only, never changes the claim** (operator, 2026-10-20). **Medicare** payer with a billed **G0402** (IPPE, once per lifetime), **G0438** (first AWV, once in 3 years) or **G0439** (subsequent AWV, once a year): reads the patient's earlier claims in **Account Inquiry** (claim form side menu → Patient Inquiry Detail → Show CPT Payments, every page; this claim left out; closed with the X, the tick undone). Billed inside the window, counted either way from this claim's service date (exactly 3 years / 12 months = outside) → **Review**, Comments "G0438 already billed on MM/DD/YYYY (claim N) — once in 3 years". Account Inquiry not read → Review "check … history by hand". `pages/account_inquiry.py`, `selectors/account_inquiry.py`. Since `ac25f83` (2026-10-23) it also leaves its verdict on the claim's data (`_wellness_history`: read or not, the repeats found) for #25 — still nothing changed in eCW | +| 15 | `medicaid_pregnancy_th` | #23 | **Medicaid** payer (rule_group "Medicaid" only, NOT RAE) with a **pregnancy-related ICD** (chapter O, Z33, Z34, Z36, Z3A — **Z32.00/.01/.02 pregnancy test never counts**) → **TH** on the first billed E&M line (99202-99499; none → first billed line), **in M1, overwriting what M1 held** (comment says "replacing 25"; never M2-M4); TH already in any box → nothing; no M1 box → Review "add TH by hand". A Medicaid telehealth pregnancy visit: **#21 writes TH instead of GT** (RAE keeps GT). Dental → Review. `9b87b4d`, not run live yet (2026-10-20, EEWA-30) | +| 16 | `bh_h_code_modifier` | #12 | H code (H + 4 digits). **BH payer** (name starts with "BH") with any modifier → M1–M4 cleared on that line only; telehealth note → not fired (GT stays); note not read → Review, nothing changed (operator, 2026-10-07). **Any other payer** → billed H code line unchecked + fee 0.00, telehealth or not, modifier or not; blank payer → not fired (operator, 2026-10-12). Comment worded from what was done | +| 17 | `vaccine_z23_pointer` | #45 | A line with one of the client's 19 vaccine codes (#18's list, not the admin codes) must carry **Z23**'s ICD pointer (its number in the ICD list) in ICD1–4 (EEWA-39, operator 2026-10-28). Missing → the first empty slot, the line's other pointers kept; already there → nothing; no other line touched. No Z23 in the ICD list → Review "Z23 not on the claim — add it by hand"; four slots full → Review "no free pointer slot". All payers, Medicare too; not Dental. Comment "Z23 pointer added to the vaccine line (…)" | +| 18 | `vaccine_admin_codes` | #18 | Vaccine without admin code → add 90460(+90461) age ≤18, 90471(+90472) >18; units 1; all payers except Medicare. **Vaccine = the client's 19-code list only** (90633, 90648, 90651, 90656, 90657, 90660, 90680, 90687, 90698, 90707, 90710, 90713, 90716, 90732, 90734, 90744, 90746, 91321, 91322), by code; **admin = 90460/90461/90471/90472 only** (operator, 2026-10-13; no hover text, no 90476-90759 range). Unclassified payer / no payer / no age → Review; an admin code that could not be added → Review ("NOT added — add by hand") | +| 19 | `injection_admin_code` | #20 | Injection code from the client's **41-code list** (J0561 ... J8499, as given — IUD, inhalation and oral J-codes included) billed without **96372** → add 96372 ×1, Units 1, all payers except Medicare. Only 96372 counts as admin billed — also when it is unchecked with a fee (priced_line_check checks it at the end, so no second 96372; review 2026-10-14). J2001 / J2930 never count (#5 unchecks them). Unclassified / no payer → Review; not added → Review; Dental → Review (operator, 2026-10-13) | +| 20 | `medicare_pps_code` | #25 | **PPS Payer** (exact eCW names: Medicare, Medicare-Novitas, HCFA Medicare, HCFA Medicare Novitas, Humana Medicare, Devoted MCR UB, Cigna Healthspring, Cigna Health Spring/ Bravo — the two Cigna plans count although Commercial elsewhere) on a **UB** claim: billed (`lines.ends_billed`) **Qualifying Codes** → the **PPS Code** on the client's Medicare G code crosswalk (`CROSSWALK`): G0466 new office, G0466H new home, G0467 established office, G0467NH nursing home, G0467H established home, G0468 IPPE / AWV (G0402 / G0438 / G0439 win over an E&M). Right PPS Code billed → Pass. Missing → **added** via the Fee Schedule picker, Units 1 (LIVE-WRITE; Fail "CPT Code Missing (PPS code G0467 added (99213))"); VERIFY → Review "G0467 not added in VERIFY — add it by hand (99213)". **Review, nothing changed:** codes only on both the G0466 and G0467 lists ("new or established?"); conflicting visit codes; a different PPS Code billed (also beside the right one); mental health 90791 … 90845 ("G0469 or G0470?" until the client rules, EEWA-37; either one billed → Pass); a variant the picker cannot find; HCFA with a Qualifying Code; G0468 when #27–#29 found a repeat, could not read the history, or never checks it (the Cigna plans, Commercial there — operator, 2026-10-23). Dental → skipped (operator). `ac25f83` | +| 21 | `priced_line_check` | common rule | **LAST.** Unchecked line with Billed Fee > 0 → check | **Every claim runs every condition above** — each one's `detect()` decides from the claim's codes and payer, so a claim gets every "All payers" @@ -427,6 +459,12 @@ noted in Comments (Pass); Billing Notes unreadable or sliding fee UNKNOWN → "Corrections pending (not saved)". - **Pass**: "Clean claims (…)", Ready to Submit. - A claim the run never reached or did not find stays blank in S–X. +- **Payer: sheet vs eCW (`97d4d74`, 2026-10-28, operator):** every condition goes by + eCW's payer (the claim form's first insurance row), never the report's + PAYER column. When the two differ (case and spacing aside, neither blank) + the Comments end with "Payer: sheet says X, eCW says Y - eCW used" — a + note, the Category is not changed. The PAYER / Group Name columns stay + the report's. - Same-day re-run keeps the old file as `…__HHMMSS.xlsx`. If the old file is open in Excel (rename refused), the old file is left alone and the NEW sheet is saved as `…__HHMMSS.xlsx` instead (D10, 2026-10-05); the `[SUMMARY]` log @@ -650,7 +688,12 @@ the Portal (restarted 2026-10-26, [portal] terminal tab). History: `git log --oneline`. -Last code commits: `a89f689` (2026-10-27, login retry after a 2FA timeout +Last code commits: `0c2fca8` (2026-10-28, vaccine_z23_pointer #45 / +t1017_non_billable #46 — §4), `d1aa6da` (2026-10-28, login waits for a late +security image page — §10), `97d4d74` (2026-10-28, Charge Summary notes a payer the +sheet and eCW disagree on — §5), `aaab1ff` (2026-10-28, lab_codes #3/#4, +EEWA-38 — §4), `4cc27a5` (2026-10-28, Portal Delete run, Super Admin only +— §2), `a89f689` (2026-10-27, login retry after a 2FA timeout waits for the closed Chrome and opens a new one — §10), `1da9e7c` (2026-10-27, Portal sign-in with the Ecare office login — §2), `cc3a01a` (2026-10-27, Portal container on India time — @@ -738,7 +781,7 @@ Status lives in Jira; the open work is in §6 and §10. | EEWA-8 | Medicare Telehealth | Done | | EEWA-10 | j2930_j2001_update | Done | | EEWA-11 | dummy_codes | Done (2026-10-12 comment corrects the old BHT3/BHT4 one: DCONS/Dscprit only) | -| EEWA-12 | vaccine_admin_codes (2026-10-13 comment: client's 19 vaccine codes, 4 admin codes) | In Progress | +| EEWA-12 | vaccine_admin_codes (2026-10-13 comment: client's 19 vaccine codes, 4 admin codes) | Done (closed 2026-10-28 by the operator) | | EEWA-13 | sliding_fee | Done | | EEWA-14 | dental_high_risk (incl. the 79-character rule) | Done | | EEWA-15 | bh_h_code_modifier (#12 H codes) | Done (closed 2026-10-20) | @@ -758,12 +801,15 @@ Status lives in Jira; the open work is in §6 and §10. | EEWA-30 | medicaid_pregnancy_th (#23: TH modifier on a pregnancy visit on Medicaid) | To Do | | EEWA-29 | medicare_wellness_history (#27/#28/#29: G0402 / G0438 / G0439 billed too often → Review) | To Do | | EEWA-31 | run_events + Runs page (Stage 1) | Done (closed 2026-10-26; built `751a177`, seen on every live run since 2026-10-22) | -| EEWA-32 | portal sign-in (Stage 2: Portal Users, Roles, Started By / Stopped By); blocked by EEWA-31 | In Progress (built 2026-10-22, `c230a8e`; live Sign-in and a Portal Start with Started By 2026-10-22; roles Super Admin / Admin / Viewer `f99dc17` 2026-10-27; Viewer not yet tried) | +| EEWA-32 | portal sign-in (Stage 2: Portal Users, Roles, Started By / Stopped By); blocked by EEWA-31 | Done (closed 2026-10-28 by the operator; built 2026-10-22, `c230a8e`; live Sign-in and a Portal Start with Started By 2026-10-22; roles Super Admin / Admin / Viewer `f99dc17` 2026-10-27; Viewer not yet tried) | | EEWA-33 | portal run alerts (Stage 3: Run Error banner, `started_by` column); blocked by EEWA-32 | Done (closed 2026-10-26; built `ecbbf3a`; banner seen after the 19:42 run, `started_by` on the Portal runs' rows) | | EEWA-34 | medicare_pps_code (#25) — spec | To Do (parent of the work below) | -| EEWA-35 | #25 PPS Code check — Review when missing | In Progress (built 2026-10-23, `ac25f83`; not run live) | +| EEWA-35 | #25 PPS Code check — Review when missing | Done (closed 2026-10-28 by the operator; built 2026-10-23, `ac25f83`; not run live) | | EEWA-36 | #25 add the PPS Code in LIVE-WRITE; blocked by EEWA-35 | In Progress (built 2026-10-23, `ac25f83`; not run live) | | EEWA-37 | #25 mental health G0469 / G0470 — after the client's ruling | To Do (waiting on the client) | +| EEWA-38 | lab_codes (#3, #4: Medicare payers no lab code, Cigna Salud any, others the 13 in-house) | In Progress (built 2026-10-28, `aaab1ff`; fired on live claims 2026-10-28 19:28) | +| EEWA-39 | vaccine_z23_pointer (#45: Z23 pointer on every vaccine line) | In Progress (built 2026-10-28, `0c2fca8`; fired on live claims 2026-10-28 19:28) | +| EEWA-40 | t1017_non_billable (#46: T1017 non-billable on Medicare / Medicaid, Review elsewhere) | In Progress (built 2026-10-28, `0c2fca8`; fired on a live claim 2026-10-28 19:28) | EEWA-15 to EEWA-18 were created on 2026-10-12 for work already built and moved to In Progress on 2026-10-13; EEWA-19, EEWA-20 and EEWA-21 were @@ -809,7 +855,14 @@ test claim ids — new posts must not (§7). Dokploy 2026-10-27, `cc3a01a`, Ecare network only).** URL `http://10.200.0.15:8085` (Dokploy project Ecw_ChargeValidation → production → app **Portal**; port 8085 → 8000). The operator signed in - there as Super Admin; Dashboard, Users, Sign-ins work. How it got there: + there as Super Admin; Dashboard, Users, Sign-ins work. **Domain + (2026-10-29): `https://ecw.app.ecareindia.com`** (DNS → 10.200.0.15, so + Ecare network / VPN only; Let's Encrypt cert to 2027-01-27). Its 502 + was the domain's Container Port 3000 (must be **8000**); then 400 + "Invalid host header" until `PORTAL_URLS=http://10.200.0.15:8085, + https://ecw.app.ecareindia.com` was saved AND the app reloaded (the + operator's Reload had not run — the container still said "Up 44 + hours"; check that on the Logs tab). How it got there: - **Source = Drop (zip upload), not Git:** Dokploy cannot clone from Gitea (`server certificate verification failed`, Traefik's self-signed cert); Gitea has no internal http port and no SSH; the operator cannot @@ -823,7 +876,9 @@ test claim ids — new posts must not (§7). 10.50.2.131), instance TCP port 58468 (dynamic — a SQL restart may move it; set a static port later). SQL login **`cv_portal`** (made in SSMS 2026-10-27: db_datareader + INSERT/UPDATE `CV_PortalUser`, INSERT - `CV_PortalSignIn`); its password only in Dokploy's env + `CV_PortalSignIn`; Delete run also needs `GRANT DELETE ON + dbo.CV_ChargeSummary TO cv_portal` — not run yet, and no zip with + Delete run deployed yet); its password only in Dokploy's env (`RESULTS_DB_CONN`), never here or in chat. The Portal works only while that PC is on. - **Fixes found live:** `69c51d8` the image needs `libgssapi-krb5-2` by @@ -942,8 +997,9 @@ test claim ids — new posts must not (§7). `addClaimFSCPTCode('save')`) saves too. Typed edits (POS, modifiers, fees) are not kept. Earlier runs changed boxes on 9 claims (2026-09-17..10-15, mostly G8510 checked by the old depression - re-include step); the list is in the logs (no claim ids here) and the - operator restores them by hand from the audit log. + re-include step); the list is in the logs (no claim ids here). Putting + them back is the client's call, not ours (operator, 2026-10-28): we do + not update anything in eCW, not even to undo the bot's own change. Fix (option C): in VERIFY `claim_form._set_bill_box` sets the box by JS (no click event) and `add_cpt_row` adds nothing; vaccine admin (every code it would add) / 96372 / G2025 become Review "add by hand"; #22 diff --git a/src/ecw_bot/auth/login.py b/src/ecw_bot/auth/login.py index 1329ad0..825f819 100644 --- a/src/ecw_bot/auth/login.py +++ b/src/ecw_bot/auth/login.py @@ -12,6 +12,7 @@ spreadsheet, one client instead of a loop, and the second factor is a mailed approval link rather than an authenticator code — see `twofactor.py`. """ +import time from datetime import datetime, timezone from playwright.sync_api import Page, TimeoutError as PWTimeout @@ -30,6 +31,38 @@ from .restricted import Restricted, assert_not_restricted from .twofactor import handle_2fa +SECURITY_IMAGE = "text=VERIFY YOUR SECURITY IMAGE" + + +def wait_after_username(page: Page, timeout_ms: int, image: bool = True) -> str: + """ + After the username's Next: "image" once eCW's security image page shows + (only while `image`), "password" once the password box is visible AND + enabled — it is on the page from the start, disabled until the username + step is done. Neither within `timeout_ms` → a screenshot (and the DOM) + and RuntimeError. Polls: `Locator.is_visible()` never waits, so a + single look missed a security image page that came late (2026-10-28). + """ + pw = page.locator(sel.SEL_PASSWORD).first + img = page.locator(SECURITY_IMAGE).first + end = time.monotonic() + timeout_ms / 1000 + while True: + try: + if image and img.is_visible(): + return "image" + if pw.is_visible() and pw.is_enabled(timeout=1_000): + return "password" + except Exception: + pass # navigating; look again + if time.monotonic() > end: + break + page.wait_for_timeout(250) + shot(page, "username_step_disabled") + raise RuntimeError( + f"Username step did not finish in {timeout_ms // 1000}s: the password " + f"box stayed disabled (no security image page seen either)") + + def _assert_login_page(page: Page) -> None: """ Confirm the real login form came back before typing any credentials. @@ -90,21 +123,18 @@ def _do_login(page: Page, context, client_name: str): 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) + # Some logins interpose a security-image confirmation page — sometimes + # seconds after Next (2026-10-28 17:59: missed by a one-look check). + if wait_after_username(page, cfg.LOGIN_TIMEOUT) == "image": + 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, + ) + wait_after_username(page, cfg.LOGIN_TIMEOUT, image=False) # 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"], diff --git a/src/ecw_bot/conditions/__init__.py b/src/ecw_bot/conditions/__init__.py index c72f588..71b53e5 100644 --- a/src/ecw_bot/conditions/__init__.py +++ b/src/ecw_bot/conditions/__init__.py @@ -44,6 +44,7 @@ from . import depression_dx_pointer from . import dummy_codes from . import injection_admin_code from . import j2930_j2001_update +from . import lab_codes from . import medicaid_pregnancy_th from . import medicaid_uncheck_cpts from . import medicare_billing_screening @@ -52,18 +53,23 @@ from . import medicare_telehealth from . import medicare_vaccine_a6 from . import medicare_wellness_history from . import priced_line_check +from . import t1017_non_billable from . import telehealth from . import vaccine_admin_codes +from . import vaccine_z23_pointer 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, + dental_payer_check, medicaid_uncheck_cpts, lab_codes, + t1017_non_billable, + medicare_billing_screening, medicare_vaccine_a6, medicare_wellness_history, medicaid_pregnancy_th, - bh_h_code_modifier, vaccine_admin_codes, injection_admin_code, + bh_h_code_modifier, vaccine_z23_pointer, vaccine_admin_codes, + injection_admin_code, medicare_pps_code, priced_line_check] diff --git a/src/ecw_bot/conditions/lab_codes.py b/src/ecw_bot/conditions/lab_codes.py new file mode 100644 index 0000000..fa35e40 --- /dev/null +++ b/src/ecw_bot/conditions/lab_codes.py @@ -0,0 +1,126 @@ +r""" +Condition (document #3, #4; EEWA-38, operator 2026-10-28): which lab codes +a payer takes. + +A **lab code** is a CPT of 5 digits starting with 8 (80000-89999) — the +operator's rule, "8 series". 36416 (the finger stick billed beside some of +them) is not one and is never touched. + +The claim's primary payer decides: + + * **Medicare payers** (#3: Medicare, Devoted MCR, Humana MCR, Novitas, + Cigna Health Springs) — no lab code at all. The same exact eCW names + as #25's PPS Payers (`medicare_pps_code.is_pps_payer`). + * **Cigna Salud Employee** (#4: "Cigna Salud", the Salud employees' + plan) — any lab code. + * **Every other payer**, a blank one included (#4: "ALL except Cigna + Salud") — only the 13 in-house codes, `IN_HOUSE`. + +A lab code the payer does not take is unchecked and its Billed Fee zeroed, +the treatment `j2930_j2001_update.py` gives its codes. The code only: the +fees in the document's list are not checked. Not on Dental claims. +""" + +import re + +from ..pages import claim_form +from . import lines +from .base import CPT_TAB_NOT_CONFIRMED, ConditionResult +from .medicare_pps_code import is_pps_payer + +NAME = "lab_codes" + +IN_HOUSE = frozenset(( + "82948", # Glucose blood stick + "81025", # HCG, urine + "85018", # Hemoglobin + "82270", # Stool + "82274", # Insure + "87806", # HIV + "85610", # INR + "87880", # Strep group A + "80305", # Drug screen + "81001", # Urine dip + "81003", # Urine dip w/o microscopy + "87210", # Wet prep + "87636", # COVID / Flu A/B +)) + +CIGNA_SALUD = "cigna salud employee" + +_LAB = re.compile(r"8\d{4}") + + +def is_lab_code(code) -> bool: + return bool(_LAB.fullmatch(str(code or "").strip())) + + +def _rule(data: dict) -> str: + """"medicare" (no lab code), "salud" (any) or "in_house" (the 13).""" + if is_pps_payer(data): + return "medicare" + if lines.primary_payer(data).lower() == CIGNA_SALUD: + return "salud" + return "in_house" + + +def _not_taken(code, rule: str) -> bool: + code = str(code or "").strip() + if not is_lab_code(code) or rule == "salud": + return False + return rule == "medicare" or code not in IN_HOUSE + + +def detect(claim_id: str, data: dict) -> bool: + if (data or {}).get("screen") == "dental": + return False + rule = _rule(data) + return any(_not_taken(row.get("code"), rule) + 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 `medicaid_uncheck_cpts.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) + + rule = _rule(data) + changes = claim_form.zero_and_uncheck_cpt_rows_matching( + page, lambda code: _not_taken(code, rule)) + if not changes: + return ConditionResult.none(NAME) + for c in changes: + c["rule"] = rule + return ConditionResult(condition_name=NAME, changed=True, changes=changes) + + +def _codes(result: ConditionResult) -> str: + return "/".join(dict.fromkeys(c["code"] for c in result.changes)) + + +def summary_comment(result: ConditionResult) -> str: + if any(c.get("rule") == "medicare" for c in result.changes): + return (f"Lab code not billed to Medicare ({_codes(result)} - " + f"unchecked, fee 0)") + return (f"Lab code not in the in-house list ({_codes(result)} - " + f"unchecked, fee 0)") + + +def describe(result: ConditionResult) -> str: + out = ["Lab codes the payer does not take — 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']}") + out.append(f" Row {c['row'] + 1} ({c['code']}): {', '.join(bits)}") + return "\n".join(out) diff --git a/src/ecw_bot/conditions/t1017_non_billable.py b/src/ecw_bot/conditions/t1017_non_billable.py new file mode 100644 index 0000000..fbdedd4 --- /dev/null +++ b/src/ecw_bot/conditions/t1017_non_billable.py @@ -0,0 +1,98 @@ +r""" +Condition (document #46; EEWA-40, operator 2026-10-28): T1017 is a +non-billable appointment on Medicare and Medicaid, and a human's call on +any other payer. + +Per the client's Charge Condition Document, row 46 (T1017, payer +Medicaid): + "If T1017 is received and it is not covered by Medicare, then please + change this to a non-billable appt" + +The operator's reading, by the claim's primary payer: + + * a **Medicare payer** (#25's exact eCW names, + `medicare_pps_code.is_pps_payer`) or **Medicaid** (the dashboard's + "Medicaid" group only, `medicaid_uncheck_cpts.is_medicaid_claim` — not + RAE, not CHP+) → the T1017 line is unchecked and its Billed Fee zeroed, + the treatment `j2930_j2001_update.py` gives its codes; + * **any other payer**, blank or unmapped included → nothing changed, + Review: "T1017 on - check by hand". + +Not on Dental claims. +""" + +from ..pages import claim_form +from . import lines +from .base import CPT_TAB_NOT_CONFIRMED, ConditionResult +from .medicaid_uncheck_cpts import is_medicaid_claim +from .medicare_pps_code import is_pps_payer + +NAME = "t1017_non_billable" + +T1017 = "T1017" + + +def _payer_rule(data: dict) -> str: + """"Medicare", "Medicaid" or "" (any other payer).""" + if is_pps_payer(data): + return "Medicare" + if is_medicaid_claim(data): + return "Medicaid" + return "" + + +def non_billable_for(data: dict) -> bool: + return bool(_payer_rule(data)) + + +def detect(claim_id: str, data: dict) -> bool: + if (data or {}).get("screen") == "dental": + return False + return any((row.get("code") or "").strip().upper() == T1017 + 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 `medicaid_uncheck_cpts.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) + + rule = _payer_rule(data) + if not rule: + payer = lines.primary_payer(data) or "no payer" + reason = f"{T1017} on {payer} - check by hand" + print(f"[COND {NAME} {claim_id}] {reason}") + return ConditionResult.declined(NAME, reason) + + changes = claim_form.zero_and_uncheck_cpt_rows(page, {T1017}) + if not changes: + return ConditionResult.none(NAME) + for c in changes: + c["rule"] = rule + return ConditionResult(condition_name=NAME, changed=True, changes=changes) + + +def summary_comment(result: ConditionResult) -> str: + rule = next((c["rule"] for c in result.changes if c.get("rule")), + "Medicare / Medicaid") + return f"{T1017} not billable ({rule}) - unchecked, fee 0" + + +def describe(result: ConditionResult) -> str: + out = [f"{T1017} made non-billable:"] + 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']}") + out.append(f" Row {c['row'] + 1} ({c['code']}): {', '.join(bits)}") + return "\n".join(out) diff --git a/src/ecw_bot/conditions/vaccine_z23_pointer.py b/src/ecw_bot/conditions/vaccine_z23_pointer.py new file mode 100644 index 0000000..193db38 --- /dev/null +++ b/src/ecw_bot/conditions/vaccine_z23_pointer.py @@ -0,0 +1,103 @@ +r""" +Condition (document #45; EEWA-39, operator 2026-10-28): every vaccine line +carries the Z23 diagnosis pointer. + +Per the client's Charge Condition Document, row 45 (Vaccines codes): + "Make sure you are assigning the proper diagnosis code (Z23) for ALL + VACCINES" + +The same mechanism as `depression_dx_pointer.py`: a CPT line points at a +diagnosis by its number in the ICD list (ICD1-4 hold "1", "2", ...). On a +claim with a vaccine code, Z23's number goes on every vaccine line that +lacks it, in the first empty slot (ICD1 → ICD4); the line's other +pointers stay (operator: "add in ICD1, ICD2"). Unlike #6, nothing is +removed and no other line is touched. + + * **Vaccine** = the client's 19 vaccine product codes of #18 + (`vaccine_admin_codes.VACCINE_CODES`), not the admin codes. + * No Z23 in the ICD list → Review "add it by hand" (the bot has no flow + for adding an ICD row). A line whose four slots are full → Review. + * All payers, Medicare included. Not on Dental claims. +""" + +from ..pages import claim_form +from .base import CPT_TAB_NOT_CONFIRMED, ConditionResult +from .vaccine_admin_codes import VACCINE_CODES + +NAME = "vaccine_z23_pointer" + +Z23 = "Z23" +NO_Z23 = "Z23 not on the claim — add it by hand" + + +def z23_pointer(data: dict): + """Z23's number in the ICD list ("2"), or None. Z23 with or without a + dot; Z23.0 is the same code written long, Z23.5 is not Z23.""" + for row in (data or {}).get("icd") or []: + code = (row.get("Code") or "").strip().upper().rstrip(".") + if code in (Z23, Z23 + ".0"): + return (row.get("#") or "").strip() or None + return None + + +def _vaccine_codes(data: dict) -> list: + return [(row.get("code") or "").strip() + for row in (data or {}).get("cpt") or [] + if (row.get("code") or "").strip() in VACCINE_CODES] + + +def detect(claim_id: str, data: dict) -> bool: + if (data or {}).get("screen") == "dental": + return False + return bool(_vaccine_codes(data)) + + +def apply(page, claim_id: str, data: dict) -> ConditionResult: + """ + Re-clicks CPT/HCPCS for the same reason `depression_dx_pointer.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) + pointer = z23_pointer(data) + if pointer is None: + print(f"[COND {NAME} {claim_id}] {NO_Z23} " + f"({'/'.join(dict.fromkeys(_vaccine_codes(data)))})") + return ConditionResult.declined(NAME, NO_Z23) + + changes, full = claim_form.add_cpt_icd_pointer( + page, pointer=pointer, target_codes=VACCINE_CODES) + review = (f"Z23 pointer: no free pointer slot on " + f"{'/'.join(dict.fromkeys(full))} — add it by hand" + if full else "") + if not changes: + if not review: + print(f"[COND {NAME} {claim_id}] Z23 already on every vaccine " + f"line — nothing to change") + return ConditionResult.none(NAME) + return ConditionResult.declined(NAME, review) + return ConditionResult(condition_name=NAME, changed=True, + changes=changes, review=review) + + +def _codes(result: ConditionResult) -> str: + return "/".join(dict.fromkeys(c["code"] for c in result.changes)) + + +def summary_comment(result: ConditionResult) -> str: + return f"Z23 pointer added to the vaccine line ({_codes(result)})" + + +def describe(result: ConditionResult) -> str: + out = ["Z23 ICD pointer on the vaccine lines:"] + for c in result.changes: + out.append(f" Row {c['row'] + 1} ({c['code']}): ICD " + f"{c['before']['icd']} -> {c['after']['icd']}") + if result.review: + out.append(f" {result.review}") + return "\n".join(out) diff --git a/src/ecw_bot/core/charge_summary.py b/src/ecw_bot/core/charge_summary.py index 3f1b19f..e91f3c5 100644 --- a/src/ecw_bot/core/charge_summary.py +++ b/src/ecw_bot/core/charge_summary.py @@ -91,6 +91,10 @@ COMMENTS = { "CHP+ Dental - Claim Status to Patient"), "medicaid_uncheck_cpts": ( "CPT not billable to Medicaid (15851/15852/73521 - unchecked, fee 0)"), + # Fallback only: the condition words its own comment (codes, payer rule). + "lab_codes": "Lab code the payer does not take - unchecked, fee 0", + # Fallback only: the condition words its own comment (which payer). + "t1017_non_billable": "T1017 not billable (Medicare / Medicaid) - unchecked, fee 0", "medicare_billing_screening": ( "Screening codes not billed to Medicare (G8510/G8431)"), # Fallback only: the condition words its own comment (which codes). @@ -104,6 +108,8 @@ COMMENTS = { "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 (which codes). + "vaccine_z23_pointer": "Z23 pointer added to the vaccine line", # 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 @@ -256,6 +262,24 @@ def review_reasons(data: dict) -> list: return reasons +def _ecw_payer(data: dict) -> str: + """The payer every condition goes by: the claim form's first insurance + row (`conditions.lines.primary_payer`, not imported — it brings the + page code with it).""" + rows = (data or {}).get("insurance") or [] + return str(rows[0].get("Name") or "").strip() if rows else "" + + +def payer_note(sheet: str, ecw: str) -> str: + """A note when the report's PAYER and eCW's differ (operator, + 2026-10-28): the rules went by eCW's, and the reader should know. Not + when either is blank, nor for case or spacing alone.""" + norm = lambda s: " ".join(str(s or "").split()).lower() + if not norm(sheet) or not norm(ecw) or norm(sheet) == norm(ecw): + return "" + return f"Payer: sheet says {sheet.strip()}, eCW says {ecw.strip()} - eCW used" + + def claim_outcome(found: bool, data: dict) -> dict: """ Columns V-X for one claim, from what `_read_claim_form` returned. @@ -318,15 +342,20 @@ def claim_outcome(found: bool, data: dict) -> dict: # 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"} + out = {"Comments": "; ".join(lead + comments + reasons), + "Current Status": "Needs manual review", "Category": "Review"} + elif not failed: + out = {"Comments": "; ".join([CLEAN] + comments), + "Current Status": "Ready to Submit", "Category": "Pass"} + else: + out = {"Comments": "; ".join(comments), + "Current Status": ("Ready to Submit" if saved + else "Corrections pending (not saved)"), + "Category": "Fail"} + # Not a column: build_rows compares it with the report's PAYER. + if _ecw_payer(data): + out["_ecw_payer"] = _ecw_payer(data) + return out def _money(text): @@ -371,6 +400,10 @@ def build_rows(report_rows: list, header_row: int, claim_col: int, done = outcomes.get(claim) or {} if done: vals.update(done) + note = payer_note(vals["PAYER"], done.get("_ecw_payer")) + if note: + vals["Comments"] = "; ".join( + c for c in (vals.get("Comments"), note) if c) vals["Recevied date"] = run_date vals["Completed On"] = run_date vals["Assigned To "] = assigned_to diff --git a/src/ecw_bot/pages/claim_form.py b/src/ecw_bot/pages/claim_form.py index 94a44bd..cbd70b5 100644 --- a/src/ecw_bot/pages/claim_form.py +++ b/src/ecw_bot/pages/claim_form.py @@ -2005,6 +2005,45 @@ def retag_cpt_icd_pointer(page: Page, *, target_pointer: str, return changes +def add_cpt_icd_pointer(page: Page, *, pointer: str, target_codes) -> tuple: + """ + Make every row whose code is in `target_codes` carry the ICD pointer + `pointer` among ICD1-4 — added in the first empty slot, the row's other + pointers kept (document #45: Z23 on every vaccine line). A row that + already carries it, and every other row, is left untouched; so is a row + whose four slots are full. + + Returns `(changes, full)`: `changes` in `retag_cpt_icd_pointer()`'s + shape, `full` the codes of the rows that had no free slot. + """ + changes, full = [], [] + wanted = {c.upper() for c in target_codes} + 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 code.upper() not in wanted: + continue + icd_inputs = [row.locator(s).first for s in sel.SEL_CPT_ICD] + before = [(inp.input_value(timeout=5_000) or "").strip() + for inp in icd_inputs] + if pointer in before: + continue + if "" not in before: + full.append(code) + continue + slot = before.index("") + icd_inputs[slot].fill(pointer, timeout=10_000) + after = list(before) + after[slot] = pointer + changes.append({"row": i, "code": code, + "before": {"icd": before}, "after": {"icd": after}}) + return changes, full + + def kept_cpt_row_per_code(rows) -> dict: """ Which row is KEPT for each code when the same code sits on more than diff --git a/tests/test_charge_summary.py b/tests/test_charge_summary.py index 3c34dad..2ac9d74 100644 --- a/tests/test_charge_summary.py +++ b/tests/test_charge_summary.py @@ -520,6 +520,26 @@ def tracking_columns_fill_only_for_claims_the_run_read(): assert unread[_col(h)] == "", (h, unread) +@case +def a_payer_the_sheet_and_ecw_disagree_on_is_noted_and_ecws_is_used(): + # Operator, 2026-10-28: the rules go by eCW's payer; the sheet's is + # compared, and a difference is a note — not a finding (Category kept). + ecw = _ub(insurance=[{"Name": "Medicare"}]) + r = _build({"1000001": cs.claim_outcome(True, ecw)})[1] + assert r[_col("Category")] == "Pass", r + assert r[_col("Comments")].endswith( + "; Payer: sheet says Medicaid, eCW says Medicare - eCW used"), r + assert r[_col("PAYER")] == "Medicaid", "the sheet's column stays" + + +@case +def no_payer_note_when_they_agree_or_ecw_shows_none(): + for ecw in ([{"Name": " medicaid "}], [], [{"Name": ""}]): + r = _build({"1000001": cs.claim_outcome(True, + _ub(insurance=ecw))})[1] + assert "Payer:" not in r[_col("Comments")], (ecw, r) + + @case def a_claim_id_only_test_sheet_still_builds(): rows = cs.build_rows([["Claim No"], ["9000002"], ["9000001"]], 0, 0, diff --git a/tests/test_conditions_lab_codes.py b/tests/test_conditions_lab_codes.py new file mode 100644 index 0000000..991f4d9 --- /dev/null +++ b/tests/test_conditions_lab_codes.py @@ -0,0 +1,231 @@ +r""" +conditions/lab_codes.py — document #3 and #4 (EEWA-38, operator +2026-10-28): a lab code (5 digits starting with 8) by the claim's primary +payer — + + * a Medicare payer (#25's exact eCW names): no lab code at all; + * Cigna Salud Employee: any lab code; + * every other payer, blank included: only the 13 in-house codes. + +A lab code the payer does not take is unchecked and its Billed Fee zeroed. +36416 and every non-8 code are never touched. Not on Dental claims. + + python tests\test_conditions_lab_codes.py + +Uses hcfa_claim.html like the Medicaid uncheck tests: `_inject_cpt_row()` +adds the lab lines. +""" + +import io +import sys +import pathlib +import inspect +import contextlib + +ROOT = pathlib.Path(__file__).resolve().parent.parent +sys.path.insert(0, str(ROOT / "src")) +sys.path.insert(0, str(ROOT / "tests")) + +FIXTURE = pathlib.Path(__file__).resolve().parent / "fixtures" / "hcfa_claim.html" + +from ecw_bot.conditions import lab_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.selectors import claim_form as sel # noqa: E402 +from test_conditions_medicaid_uncheck_cpts import _inject_cpt_row # noqa: E402 + +CASES = [] + + +def case(fn): + CASES.append(fn) + return fn + + +def _claim(payer, *codes, screen=None): + data = {"insurance": [{"Name": payer}] if payer is not None else [], + "cpt": [{"code": c, "checked": True, "fee": "45.00"} + for c in codes]} + if screen: + data["screen"] = screen + return data + + +# ── detect (no browser) ────────────────────────────────────────────────────── + +@case +def a_lab_code_is_five_digits_starting_with_8(): + for code in ("80053", "85025", " 87636 ", "89999", "80000"): + assert cond.is_lab_code(code), code + for code in ("36416", "99213", "8005", "800530", "G0466", "8005F", + "J2001", ""): + assert not cond.is_lab_code(code), code + + +@case +def medicare_payers_take_no_lab_code(): + for payer in ("Medicare", "medicare-novitas", "HCFA Medicare", + "Humana Medicare", "Devoted MCR UB", "Cigna Healthspring", + " Cigna Health Spring/ Bravo "): + assert cond.detect("C1", _claim(payer, "99213", "82948")), payer + assert cond.detect("C1", _claim(payer, "80053")), payer + + +@case +def cigna_salud_takes_any_lab_code(): + assert cond.detect("C1", _claim("Cigna Salud Employee", "80053", + "85025", "82948")) is False + assert cond.detect("C1", _claim(" cigna salud employee ", + "80053")) is False + + +@case +def other_payers_take_only_the_13_in_house_codes(): + in_house = ("82948", "81025", "85018", "82270", "82274", "87806", + "85610", "87880", "80305", "81001", "81003", "87210", + "87636") + assert len(set(in_house)) == 13 + for payer in ("Medicaid", "Cigna HealthCare Of Colorado", "AARP", "", + None): + assert cond.detect("C1", _claim(payer, "99213", *in_house)) is False, \ + payer + assert cond.detect("C1", _claim(payer, "82948", "80053")), payer + + +@case +def non_lab_codes_never_fire_it(): + assert cond.detect("C1", _claim("Medicare", "99213", "36416", + "G0467")) is False + assert cond.detect("C1", {}) is False + + +@case +def not_on_a_dental_claim(): + assert cond.detect("C1", _claim("Medicare", "80053", + screen="dental")) is False + + +# ── apply (browser) ────────────────────────────────────────────────────────── + +@case +def medicare_every_lab_line_unchecked_and_zeroed(page): + page.goto(FIXTURE.as_uri()) + _inject_cpt_row(page, "82948", row_id="CPTROW_3", fee="15.00") + _inject_cpt_row(page, "36416", row_id="CPTROW_4", fee="8.00") + result = cond.apply(page, "C1", _claim("Medicare", "82948", "36416")) + assert isinstance(result, ConditionResult) + assert result.condition_name == "lab_codes" + assert [c["code"] for c in result.changes] == ["82948"], result.changes + c = result.changes[0] + assert c["unchecked"] is True and c["fee"] == {"before": "15.00", + "after": "0.00"}, c + rows = page.locator(sel.SEL_CPT_ROWS) + assert rows.nth(3).locator(sel.SEL_CPT_BILLED_FEE).first.input_value() \ + == "0.00" + assert rows.nth(4).locator(sel.SEL_CPT_BILLED_FEE).first.input_value() \ + == "8.00", "36416 is not a lab code" + assert rows.nth(0).locator(sel.SEL_CPT_BILLED_FEE).first.input_value() \ + == "210.00", "99213 must be left alone" + + +@case +def other_payer_only_the_codes_outside_the_13(page): + page.goto(FIXTURE.as_uri()) + _inject_cpt_row(page, "87880", row_id="CPTROW_3", fee="30.00") + _inject_cpt_row(page, "80053", row_id="CPTROW_4", fee="55.00") + result = cond.apply(page, "C1", _claim("Cigna HealthCare Of Colorado", + "87880", "80053")) + assert [c["code"] for c in result.changes] == ["80053"], result.changes + rows = page.locator(sel.SEL_CPT_ROWS) + assert rows.nth(3).locator(sel.SEL_CPT_BILLED_FEE).first.input_value() \ + == "30.00", "87880 is in-house" + + +@case +def apply_is_a_no_op_once_already_excluded(page): + page.goto(FIXTURE.as_uri()) + _inject_cpt_row(page, "80053", checked=False, fee="0.00") + result = cond.apply(page, "C1", _claim("Medicare", "80053")) + assert result.changed is False and result.changes == [] + + +@case +def the_comment_names_the_codes_and_the_rule(page): + page.goto(FIXTURE.as_uri()) + _inject_cpt_row(page, "80053", fee="55.00") + text = cond.summary_comment(cond.apply(page, "C1", + _claim("Medicare", "80053"))) + assert "80053" in text and "Medicare" in text, text + page.goto(FIXTURE.as_uri()) + _inject_cpt_row(page, "80053", fee="55.00") + text = cond.summary_comment(cond.apply(page, "C1", + _claim("AARP", "80053"))) + assert "80053" in text and "in-house" in text, text + + +@case +def describe_names_the_code_and_the_fee_change(page): + page.goto(FIXTURE.as_uri()) + _inject_cpt_row(page, "80053", fee="55.00") + text = cond.describe(cond.apply(page, "C1", _claim("AARP", "80053"))) + assert "80053" in text and "55.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 "lab_codes" in names + assert names.index("lab_codes") < names.index("priced_line_check"), names + + +@case +def the_charge_summary_has_a_fallback_comment(): + assert "Lab code" in cs.COMMENTS["lab_codes"] + + +@case +def run_all_leaves_the_lab_line_unchecked_at_the_end(page): + # priced_line_check runs last: the zeroed lab line must stay unchecked. + page.goto(FIXTURE.as_uri()) + _inject_cpt_row(page, "80053", fee="55.00") + with contextlib.redirect_stdout(io.StringIO()): + results = run_all(page, "C1", _claim("Medicare", "80053")) + assert "lab_codes" in [r.condition_name for r in results], results + 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" + + +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 index 9987416..ef4c78d 100644 --- a/tests/test_conditions_priced_line_check.py +++ b/tests/test_conditions_priced_line_check.py @@ -56,12 +56,13 @@ from ecw_bot.conditions import (bh_h_code_modifier, # noqa: E402 depression_dx_pointer, dummy_codes, injection_admin_code, medicare_pps_code, - j2930_j2001_update, medicaid_uncheck_cpts, + j2930_j2001_update, lab_codes, medicaid_uncheck_cpts, + t1017_non_billable, medicare_billing_screening, medicare_telehealth, medicare_vaccine_a6, medicare_wellness_history, medicaid_pregnancy_th, - telehealth, vaccine_admin_codes, + telehealth, vaccine_admin_codes, vaccine_z23_pointer, zero_fee_uncheck) from ecw_bot.conditions.base import ( # noqa: E402 CPT_TAB_NOT_CONFIRMED, ConditionResult, run_all) @@ -578,10 +579,12 @@ def registered_last_with_zero_fee_uncheck_first(): zero_fee_uncheck, telehealth, medicare_telehealth, depression_dx_pointer, j2930_j2001_update, dummy_codes, dental_dummy_codes, - dental_payer_check, medicaid_uncheck_cpts, + dental_payer_check, medicaid_uncheck_cpts, lab_codes, + t1017_non_billable, medicare_billing_screening, medicare_vaccine_a6, medicare_wellness_history, medicaid_pregnancy_th, - bh_h_code_modifier, vaccine_admin_codes, injection_admin_code, + bh_h_code_modifier, vaccine_z23_pointer, vaccine_admin_codes, + injection_admin_code, medicare_pps_code, plc, ], conditions.CONDITIONS diff --git a/tests/test_conditions_t1017_non_billable.py b/tests/test_conditions_t1017_non_billable.py new file mode 100644 index 0000000..dfcbbe8 --- /dev/null +++ b/tests/test_conditions_t1017_non_billable.py @@ -0,0 +1,192 @@ +r""" +conditions/t1017_non_billable.py — document #46 (EEWA-40, operator +2026-10-28): T1017 by the claim's primary payer — + + * a Medicare payer (#25's exact eCW names) or Medicaid (the dashboard's + "Medicaid" group only, as #43/#44): unchecked and Billed Fee 0; + * any other payer (RAE, CHP+, blank, unmapped included): nothing + changed, Review. + + python tests\test_conditions_t1017_non_billable.py + +Uses hcfa_claim.html like the Medicaid uncheck tests. +""" + +import io +import sys +import pathlib +import inspect +import contextlib + +ROOT = pathlib.Path(__file__).resolve().parent.parent +sys.path.insert(0, str(ROOT / "src")) +sys.path.insert(0, str(ROOT / "tests")) + +FIXTURE = pathlib.Path(__file__).resolve().parent / "fixtures" / "hcfa_claim.html" + +from ecw_bot.conditions import t1017_non_billable 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 +from test_conditions_medicaid_uncheck_cpts import _inject_cpt_row # noqa: E402 + +CASES = [] + + +def case(fn): + CASES.append(fn) + return fn + + +def _claim(payer, *codes, screen=None): + data = {"insurance": [{"Name": payer}] if payer is not None else [], + "cpt": [{"code": c, "checked": True, "fee": "45.00"} + for c in codes]} + if screen: + data["screen"] = screen + return data + + +def _fee(page, n): + return page.locator(sel.SEL_CPT_ROWS).nth(n).locator( + sel.SEL_CPT_BILLED_FEE).first.input_value() + + +# ── detect (no browser) ────────────────────────────────────────────────────── + +@case +def detect_t1017_on_any_payer(): + for payer in ("Medicare", "Medicaid", "AARP", "", None): + assert cond.detect("C1", _claim(payer, "99213", "T1017")), payer + assert cond.detect("C1", _claim("Medicaid", " t1017 ")) + + +@case +def no_t1017_or_dental_never_fires_it(): + assert not cond.detect("C1", _claim("Medicaid", "99213", "T1016")) + assert not cond.detect("C1", {}) + assert not cond.detect("C1", _claim("Medicaid", "T1017", + screen="dental")) + + +@case +def which_payers_make_it_non_billable(): + for payer in ("Medicare", "HCFA Medicare", "Humana Medicare", + "Devoted MCR UB", "Medicaid", "HCFA Medicaid"): + assert cond.non_billable_for(_claim(payer, "T1017")), payer + for payer in ("BH COLORADO ACCESS", "Child Health Plan Plus", "AARP", + "NO SUCH PAYER XYZ", "", None): + assert not cond.non_billable_for(_claim(payer, "T1017")), payer + + +# ── apply (browser) ────────────────────────────────────────────────────────── + +@case +def medicaid_t1017_unchecked_and_zeroed(page): + page.goto(FIXTURE.as_uri()) + _inject_cpt_row(page, "T1017", fee="80.00") + result = cond.apply(page, "C1", _claim("Medicaid", "99213", "T1017")) + assert isinstance(result, ConditionResult) + assert result.changed and not result.review, result + assert [c["code"] for c in result.changes] == ["T1017"], result.changes + assert _fee(page, 3) == "0.00" + row = page.locator(sel.SEL_CPT_ROWS).nth(3) + assert row.locator(sel.SEL_CPT_ASSIGN_TO_PATIENT).first.is_checked() \ + is False + assert _fee(page, 0) == "210.00", "99213 must be left alone" + + +@case +def medicare_t1017_unchecked_and_zeroed(page): + page.goto(FIXTURE.as_uri()) + _inject_cpt_row(page, "T1017", fee="80.00") + result = cond.apply(page, "C1", _claim("Humana Medicare", "T1017")) + assert result.changed and _fee(page, 3) == "0.00", result + + +@case +def any_other_payer_is_review_and_nothing_changes(page): + for payer in ("Cigna HealthCare Of Colorado", "BH COLORADO ACCESS", ""): + page.goto(FIXTURE.as_uri()) + _inject_cpt_row(page, "T1017", fee="80.00") + result = cond.apply(page, "C1", _claim(payer, "T1017")) + assert result.changed is False, (payer, result) + assert "T1017" in result.review and "check by hand" in \ + result.review, (payer, result) + assert _fee(page, 3) == "80.00", payer + assert "Cigna HealthCare Of Colorado" in cond.apply( + page, "C1", _claim("Cigna HealthCare Of Colorado", "T1017")).review + + +@case +def the_comment_names_the_payer_group(page): + page.goto(FIXTURE.as_uri()) + _inject_cpt_row(page, "T1017", fee="80.00") + text = cond.summary_comment(cond.apply(page, "C1", + _claim("Medicaid", "T1017"))) + assert "T1017" in text and "Medicaid" in text, text + page.goto(FIXTURE.as_uri()) + _inject_cpt_row(page, "T1017", fee="80.00") + text = cond.describe(cond.apply(page, "C1", _claim("Medicare", "T1017"))) + assert "T1017" in text and "80.00" in text, text + + +# ── wiring ─────────────────────────────────────────────────────────────────── + +@case +def it_is_registered_before_priced_line_check(): + names = [m.NAME for m in CONDITIONS] + assert "t1017_non_billable" in names + assert names.index("t1017_non_billable") < names.index( + "priced_line_check"), names + + +@case +def the_charge_summary_has_a_fallback_comment(): + assert "T1017" in cs.COMMENTS["t1017_non_billable"] + + +@case +def run_all_leaves_t1017_unchecked_at_the_end(page): + page.goto(FIXTURE.as_uri()) + _inject_cpt_row(page, "T1017", fee="80.00") + with contextlib.redirect_stdout(io.StringIO()): + results = run_all(page, "C1", _claim("Medicaid", "T1017")) + assert "t1017_non_billable" in [r.condition_name for r in results], \ + results + row = page.locator(sel.SEL_CPT_ROWS).nth(3) + assert row.locator(sel.SEL_CPT_ASSIGN_TO_PATIENT).first.is_checked() \ + is False + assert _fee(page, 3) == "0.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_vaccine_z23_pointer.py b/tests/test_conditions_vaccine_z23_pointer.py new file mode 100644 index 0000000..e1aaeeb --- /dev/null +++ b/tests/test_conditions_vaccine_z23_pointer.py @@ -0,0 +1,228 @@ +r""" +conditions/vaccine_z23_pointer.py — document #45 (EEWA-39, operator +2026-10-28): "Make sure you are assigning the proper diagnosis code (Z23) +for ALL VACCINES". + +Every line with one of the client's 19 vaccine codes (#18's list; not the +admin codes) carries Z23's ICD pointer — its number in the ICD list — in +ICD1-4. Missing → the first empty slot, the line's other pointers kept. No +Z23 on the claim, or no free slot → Review. Other lines are never touched. +All payers; not Dental. + + python tests\test_conditions_vaccine_z23_pointer.py + +Uses hcfa_claim.html; `_inject_vaccine_row()` adds the vaccine lines. +""" + +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 vaccine_z23_pointer 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 + + +_ICD = [{"#": "1", "Code": "J06.9", "Name": "..."}, + {"#": "2", "Code": "Z23", "Name": "Encounter for immunization"}] + + +def _claim(*codes, icd=_ICD, screen=None): + data = {"insurance": [{"Name": "Medicaid"}], "icd": list(icd), + "cpt": [{"code": c, "checked": True, "fee": "45.00"} + for c in codes]} + if screen: + data["screen"] = screen + return data + + +def _inject_vaccine_row(page, code, row_id, icd=("1", "", "", "")): + 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 = + '' + + '' + + args.icd.map((v, i) => '').join('') + + ''; + tbody.insertBefore(tr, addRowTr); + }""", {"code": code, "rowId": row_id, "icd": list(icd)}) + + +def _icd_of(page, n): + row = page.locator(sel.SEL_CPT_ROWS).nth(n) + return [row.locator(s).first.input_value() for s in sel.SEL_CPT_ICD] + + +# ── detect (no browser) ────────────────────────────────────────────────────── + +@case +def detect_a_vaccine_code_on_any_payer(): + assert cond.detect("C1", _claim("99213", "90707")) + data = _claim("90656") + data["insurance"] = [{"Name": "Medicare"}] + assert cond.detect("C1", data), "Medicare too" + + +@case +def detect_even_without_z23_so_the_claim_goes_to_review(): + assert cond.detect("C1", _claim("90707", icd=_ICD[:1])) + + +@case +def admin_codes_alone_or_no_vaccine_never_fire_it(): + assert not cond.detect("C1", _claim("99213", "90471", "90460")) + assert not cond.detect("C1", _claim("99213")) + assert not cond.detect("C1", {}) + + +@case +def not_on_a_dental_claim(): + assert not cond.detect("C1", _claim("90707", screen="dental")) + + +@case +def z23_is_found_with_or_without_a_dot(): + for code in ("Z23", "z23", " Z23. ", "Z23.0"): + data = _claim("90707", icd=[{"#": "3", "Code": code}]) + assert cond.z23_pointer(data) == "3", code + assert cond.z23_pointer(_claim("90707", icd=[{"#": "1", + "Code": "Z23.5"}])) is None + + +# ── apply (browser) ────────────────────────────────────────────────────────── + +@case +def the_pointer_goes_in_the_first_empty_slot_of_each_vaccine_line(page): + page.goto(FIXTURE.as_uri()) + _inject_vaccine_row(page, "90707", "CPTROW_3", ("1", "", "", "")) + _inject_vaccine_row(page, "90656", "CPTROW_4", ("", "", "", "")) + first_before = _icd_of(page, 0) + result = cond.apply(page, "C1", _claim("99213", "90707", "90656")) + assert isinstance(result, ConditionResult) + assert result.changed and not result.review, result + assert [c["code"] for c in result.changes] == ["90707", "90656"] + assert _icd_of(page, 3) == ["1", "2", "", ""] + assert _icd_of(page, 4) == ["2", "", "", ""] + assert _icd_of(page, 0) == first_before, "99213 must be left alone" + + +@case +def a_line_that_already_carries_it_is_left_alone(page): + page.goto(FIXTURE.as_uri()) + _inject_vaccine_row(page, "90707", "CPTROW_3", ("1", "2", "", "")) + result = cond.apply(page, "C1", _claim("90707")) + assert result.changed is False and not result.review, result + assert _icd_of(page, 3) == ["1", "2", "", ""] + + +@case +def no_z23_on_the_claim_is_review_and_nothing_changes(page): + page.goto(FIXTURE.as_uri()) + _inject_vaccine_row(page, "90707", "CPTROW_3", ("1", "", "", "")) + result = cond.apply(page, "C1", _claim("90707", icd=_ICD[:1])) + assert result.changed is False + assert "Z23 not on the claim" in result.review, result + assert _icd_of(page, 3) == ["1", "", "", ""] + + +@case +def a_line_with_no_free_slot_is_review_the_others_still_fixed(page): + page.goto(FIXTURE.as_uri()) + _inject_vaccine_row(page, "90707", "CPTROW_3", ("1", "3", "4", "5")) + _inject_vaccine_row(page, "90656", "CPTROW_4", ("1", "", "", "")) + result = cond.apply(page, "C1", _claim("90707", "90656")) + assert [c["code"] for c in result.changes] == ["90656"], result + assert "no free pointer slot" in result.review and "90707" in \ + result.review, result + assert _icd_of(page, 3) == ["1", "3", "4", "5"] + + +@case +def the_comment_names_the_codes(page): + page.goto(FIXTURE.as_uri()) + _inject_vaccine_row(page, "90707", "CPTROW_3") + text = cond.summary_comment(cond.apply(page, "C1", _claim("90707"))) + assert "Z23" in text and "90707" in text, text + page.goto(FIXTURE.as_uri()) + _inject_vaccine_row(page, "90707", "CPTROW_3") + text = cond.describe(cond.apply(page, "C1", _claim("90707"))) + assert "90707" in text and "Z23" in text, text + + +# ── wiring ─────────────────────────────────────────────────────────────────── + +@case +def it_is_registered_before_priced_line_check(): + names = [m.NAME for m in CONDITIONS] + assert "vaccine_z23_pointer" in names + assert names.index("vaccine_z23_pointer") < names.index( + "priced_line_check"), names + + +@case +def the_charge_summary_has_a_fallback_comment(): + assert "Z23" in cs.COMMENTS["vaccine_z23_pointer"] + + +@case +def run_all_adds_the_pointer(page): + page.goto(FIXTURE.as_uri()) + _inject_vaccine_row(page, "90707", "CPTROW_3", ("1", "", "", "")) + with contextlib.redirect_stdout(io.StringIO()): + results = run_all(page, "C1", _claim("90707")) + assert "vaccine_z23_pointer" in [r.condition_name for r in results], \ + results + assert _icd_of(page, 3) == ["1", "2", "", ""] + + +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_zero_fee_uncheck.py b/tests/test_conditions_zero_fee_uncheck.py index c4f28b0..cabed23 100644 --- a/tests/test_conditions_zero_fee_uncheck.py +++ b/tests/test_conditions_zero_fee_uncheck.py @@ -55,13 +55,14 @@ from ecw_bot.conditions import (bh_h_code_modifier, # noqa: E402 depression_dx_pointer, dummy_codes, injection_admin_code, medicare_pps_code, - j2930_j2001_update, medicaid_uncheck_cpts, + j2930_j2001_update, lab_codes, medicaid_uncheck_cpts, + t1017_non_billable, medicare_billing_screening, medicare_telehealth, medicare_vaccine_a6, medicare_wellness_history, medicaid_pregnancy_th, priced_line_check, telehealth, - vaccine_admin_codes) + vaccine_admin_codes, vaccine_z23_pointer) from ecw_bot.conditions.base import ( # noqa: E402 CPT_TAB_NOT_CONFIRMED, ConditionResult, run_all) from ecw_bot.pages import claim_form # noqa: E402 @@ -520,10 +521,12 @@ def registered_first_in_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, + dental_payer_check, medicaid_uncheck_cpts, lab_codes, + t1017_non_billable, medicare_billing_screening, medicare_vaccine_a6, medicare_wellness_history, medicaid_pregnancy_th, - bh_h_code_modifier, vaccine_admin_codes, injection_admin_code, + bh_h_code_modifier, vaccine_z23_pointer, vaccine_admin_codes, + injection_admin_code, medicare_pps_code, priced_line_check, ], conditions.CONDITIONS diff --git a/tests/test_login_username_step.py b/tests/test_login_username_step.py new file mode 100644 index 0000000..69e6c07 --- /dev/null +++ b/tests/test_login_username_step.py @@ -0,0 +1,109 @@ +r""" +auth/login.py `wait_after_username()` — what comes after the username's +Next (live 2026-10-28 17:59: the security image page came late, the old +one-look check missed it, and the bot clicked a password box that stayed +disabled until it timed out). + +It waits, up to its timeout, for whichever comes first: eCW's "VERIFY YOUR +SECURITY IMAGE" page or a password box that is visible AND enabled. Neither +→ a screenshot (with the DOM) and an error that says so. + + python tests\test_login_username_step.py + +Local headless chromium, `page.set_content()` only. No eCW, no login. +""" + +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 # noqa: E402 +from ecw_bot.auth import login # noqa: E402 + +CASES = [] + + +def case(fn): + CASES.append(fn) + return fn + + +# The password box is on the page from the start, disabled — the shape +# seen live; eCW enables it once the username step is done. +_PAGE = """ + + + + +""" + + +@case +def a_late_security_image_page_is_seen(page): + page.set_content(_PAGE % "setTimeout(() => document.getElementById(" + "'img').style.display = '', 3000);") + assert login.wait_after_username(page, 10_000) == "image" + + +@case +def a_password_box_enabled_late_is_waited_for(page): + page.set_content(_PAGE % "setTimeout(() => document.getElementById(" + "'passwordField').disabled = false, 2500);") + t0 = time.monotonic() + assert login.wait_after_username(page, 10_000) == "password" + assert time.monotonic() - t0 >= 2, "returned before it was enabled" + + +@case +def after_the_image_only_the_password_box_counts(page): + page.set_content(_PAGE % "document.getElementById('img').style.display" + " = ''; setTimeout(() => document.getElementById(" + "'passwordField').disabled = false, 1500);") + assert login.wait_after_username(page, 10_000, image=False) == "password" + + +@case +def a_password_box_that_stays_disabled_is_an_error_with_a_screenshot(page): + page.set_content(_PAGE % "") + try: + login.wait_after_username(page, 2_000) + except RuntimeError as exc: + assert "password box stayed disabled" in str(exc), exc + else: + raise AssertionError("no error for a box that never enabled") + shots = list(pathlib.Path(cfg.SCREENSHOTS).glob("*_username_step_disabled.*")) + assert {p.suffix for p in shots} == {".png", ".html"}, shots + + +def main() -> int: + from playwright.sync_api import sync_playwright + + 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: + 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_web_app.py b/tests/test_web_app.py index 750ba7c..f2042ef 100644 --- a/tests/test_web_app.py +++ b/tests/test_web_app.py @@ -105,6 +105,16 @@ class FakeRepo: self._maybe_fail() return [dict(r) for r in self._rows if claim_no in r["claim_no"]] + delete_fails = None + + def delete_run(self, run_at): + self.calls.append(("delete_run", run_at)) + if self.delete_fails: + raise self.delete_fails + kept = [r for r in self._rows if r["run_at"] != run_at] + gone, self._rows = len(self._rows) - len(kept), kept + return gone + TESTER_PASSWORD = "tester-password-1" @@ -581,6 +591,63 @@ def the_error_page_names_what_went_wrong(): assert "Smith" not in r.text, "the driver's message is never shown" +# ── Delete a run (Super Admin only, operator 2026-10-28) ───────────────────── + +def _delete_run(client, app, run_at=RUN_PM, token=None): + return client.post("/admin/runs/delete", headers=_GOOD_ORIGIN, + data={"run_at": run_at.isoformat(), + "token": token or app.state.run_token}, + follow_redirects=False) + + +@case +def a_super_admin_deletes_one_run_and_the_delete_is_recorded(): + repo = FakeRepo() + client, app = signed_in(repo, FakeRunner(), role="Super Admin") + page = client.get(f"/day/{DAY.isoformat()}").text + assert page.count("Delete run") == 2, "one button per run of the day" + r = _delete_run(client, app) + assert r.status_code == 303, r.status_code + assert r.headers["location"].startswith(f"/day/{DAY.isoformat()}?"), \ + r.headers["location"] + assert [x["run_at"] for x in repo._rows] == [RUN_AM], \ + "only that run's rows go" + shown = client.get(r.headers["location"]).text + assert "2 claims deleted" in shown, shown + history = client.get("/admin/signins").text + assert "run deleted" in history and "10/16/2026 2:15 PM" in history, \ + history + + +@case +def an_admin_or_viewer_cannot_see_or_use_delete_run(): + for role in ("Admin", "Viewer"): + repo = FakeRepo() + client, app = signed_in(repo, FakeRunner(), role=role) + assert "Delete run" not in client.get(f"/day/{DAY.isoformat()}").text + assert _delete_run(client, app).status_code == 403, role + assert len(repo._rows) == 3, (role, "nothing deleted") + repo = FakeRepo() + client, app = signed_in(repo, FakeRunner(), role="Super Admin") + assert _delete_run(client, app, token="wrong").status_code == 403, \ + "a page on another site cannot send it" + assert len(repo._rows) == 3 + + +@case +def delete_run_without_the_sql_permission_says_so(): + class DbError(Exception): + pass + + repo = FakeRepo() + repo.delete_fails = DbError("42000", "The DELETE permission was denied") + client, app = signed_in(repo, FakeRunner(), role="Super Admin") + r = _quiet(_delete_run, client, app) + assert r.status_code == 503, r.status_code + assert "No permission to delete" in r.text, r.text + assert "GRANT DELETE" in r.text, r.text + + # ── repo SQL ───────────────────────────────────────────────────────────────── class FakeCursor: @@ -590,6 +657,7 @@ class FakeCursor: def execute(self, sql, params): self.log.append((sql, list(params))) + self.rowcount = len(self.rows) return self def fetchall(self): @@ -599,10 +667,14 @@ class FakeCursor: class FakeConn: def __init__(self, rows=(), cols=()): self.log, self.rows, self.cols, self.closed = [], list(rows), cols, False + self.committed = False def cursor(self): return FakeCursor(self.log, self.rows, self.cols) + def commit(self): + self.committed = True + def close(self): self.closed = True @@ -611,6 +683,17 @@ def _repo(conn): return SqlResultsRepo("DSN=x", connect=lambda cs, timeout: conn) +@case +def repo_deletes_exactly_one_run_and_commits(): + conn = FakeConn(rows=[None] * 20) # the driver's rowcount: 20 + assert _repo(conn).delete_run(RUN_PM) == 20 + (sql, params), = conn.log + assert re.fullmatch(r"DELETE FROM dbo\.CV_ChargeSummary WHERE run_at = \?", + sql), sql + assert params == [RUN_PM], params + assert conn.committed and conn.closed + + @case def repo_turns_a_text_run_at_or_day_into_a_datetime_or_date(): """The legacy {SQL Server} driver returns DATETIME2 / DATE as text.""" diff --git a/tools/sql/create_tables.sql b/tools/sql/create_tables.sql index 25407df..9638c3d 100644 --- a/tools/sql/create_tables.sql +++ b/tools/sql/create_tables.sql @@ -110,3 +110,9 @@ GO -- CREATE USER [MFILESERVER\EC10980] FOR LOGIN [MFILESERVER\EC10980]; -- ALTER ROLE db_datareader ADD MEMBER [MFILESERVER\EC10980]; -- ALTER ROLE db_datawriter ADD MEMBER [MFILESERVER\EC10980]; +-- +-- The Portal on Dokploy signs in as the SQL login cv_portal (password only +-- in Dokploy's env). Its Delete run button (Super Admin only, 2026-10-28) +-- needs one more grant — run once in SSMS: +-- +-- GRANT DELETE ON dbo.CV_ChargeSummary TO cv_portal; diff --git a/web/app.py b/web/app.py index 09ec35d..50da7b5 100644 --- a/web/app.py +++ b/web/app.py @@ -56,7 +56,7 @@ from ecw_bot.core.run_events import run_id_of # noqa: E402 from .repo import SqlResultsRepo # noqa: E402 from . import run_status # noqa: E402 from . import runs as run_view # noqa: E402 -from .users import (Accounts, CAN_ADMIN, CAN_RUN, # noqa: E402 +from .users import (Accounts, CAN_ADMIN, CAN_DELETE, CAN_RUN, # noqa: E402 EcareSignIn, IDLE_MINUTES, LOCK_AFTER, LOCK_MINUTES, MANAGES, Sessions, SqlUserStore, UserError, is_ecare) from .bot_runner import (AlreadyRunning, BotRunner, # noqa: E402 @@ -109,6 +109,13 @@ _DB_PROBLEMS = { "42000": ("No permission to read the results table", "The Windows login needs db_datareader on Charge_Validation."), } +# The Portal's SQL login reads; deleting a run needs one more grant. +_DELETE_PROBLEMS = { + "42000": ("No permission to delete runs", + "The Portal's SQL login needs GRANT DELETE ON " + "dbo.CV_ChargeSummary — see the end of " + "tools/sql/create_tables.sql."), +} _DB_DEFAULT = (DB_DOWN, "Check that SQL Server is running and that " "RESULTS_DB_CONN in .env points at the " "Charge_Validation database.") @@ -211,7 +218,9 @@ def _filters(run_at, category, claim_no): return when, category, claim -def _db(fn, *args, **kwargs): +def _db(fn, *args, problems=None, **kwargs): + """`fn(*args, **kwargs)`; a database error becomes DbUnavailable, named + by its SQLSTATE (`problems` adds or overrides names for one call).""" try: return fn(*args, **kwargs) except UserError: @@ -219,7 +228,8 @@ def _db(fn, *args, **kwargs): except Exception as exc: state = exc.args[0] if exc.args and isinstance(exc.args[0], str) \ else "" - title, hint = _DB_PROBLEMS.get(state, _DB_DEFAULT) + title, hint = {**_DB_PROBLEMS, **(problems or {})}.get(state, + _DB_DEFAULT) print(f"[WEB] {title}: {results_db.safe_error(exc)}") raise DbUnavailable(title, hint) from exc @@ -429,6 +439,7 @@ def create_app(repo, allowed_hosts=ALLOWED_HOSTS, runner=None, "can_run": bool(can_start and user and user["role"] in CAN_RUN), "is_admin": bool(user and user["role"] in CAN_ADMIN), + "can_delete": bool(user and user["role"] in CAN_DELETE), **context}) @app.get("/", response_class=HTMLResponse) @@ -464,11 +475,17 @@ def create_app(repo, allowed_hosts=ALLOWED_HOSTS, runner=None, @app.get("/day/{day}", response_class=HTMLResponse) def day_page(request: Request, day: str, category: str = None, - claim_no: str = None, claim: str = None): + claim_no: str = None, claim: str = None, msg: str = ""): when = _day(day) _, cat, search = _filters(None, category, claim_no) _, _, opened = _filters(None, None, claim) rows = _db(repo.day_results, when) + runs = {} + for r in rows: + run = runs.setdefault(r["run_at"], { + "run_at": r["run_at"], "run_mode": r.get("run_mode"), + "started_by": r.get("started_by"), "claims": 0}) + run["claims"] += 1 shown = [_for_panel(r) for r in rows if (not cat or r.get("category") == cat) and (not search or search in str(r.get("claim_no") or ""))] @@ -477,7 +494,30 @@ def create_app(repo, allowed_hosts=ALLOWED_HOSTS, runner=None, [_long_date(when), None]], day=when, counts=_counts(rows), rows=shown, category=cat or "", claim_no=search or "", - opened=opened or "") + opened=opened or "", runs=list(runs.values()), + message=msg[:200]) + + @app.post("/admin/runs/delete", response_class=HTMLResponse) + def run_delete(request: Request, run_at: str = Form(""), + token: str = Form("")): + """One run's rows out of the results table — the Super Admin's + only (operator, 2026-10-28). Our own data, never eCW's; the run's + Logs files stay. Recorded in the Sign-in history.""" + _check_token(request, token) + me = _need(request, CAN_DELETE)["username"] + try: + when = datetime.fromisoformat(run_at) + except ValueError: + raise BadFilter(f"Not a run time: {run_at}") + gone = _db(repo.delete_run, when, problems=_DELETE_PROBLEMS) + if gone: + _db(store.record, f"run {when:%m/%d/%Y} {_clock(when)}", + "run_deleted", by=me) + claims = f"{gone} claim{'' if gone == 1 else 's'}" + return RedirectResponse( + f"/day/{when.date().isoformat()}?" + urlencode( + {"msg": f"The {_clock(when)} run is deleted: {claims} " + f"deleted"}), status_code=303) @app.get("/claim", response_class=HTMLResponse) def claim_page(request: Request, claim_no: str = None): diff --git a/web/repo.py b/web/repo.py index 6233d97..7d5619e 100644 --- a/web/repo.py +++ b/web/repo.py @@ -1,7 +1,8 @@ """ -Read-only access to the results table (`dbo.CV_ChargeSummary`, written by -src/ecw_bot/core/results_db.py). SELECT statements only, always -parameterized; one short connection per call. +Access to the results table (`dbo.CV_ChargeSummary`, written by +src/ecw_bot/core/results_db.py): SELECTs, and one DELETE — a whole run, the +Super Admin's Delete run (2026-10-28). Always parameterized; one short +connection per call. """ from datetime import date, datetime, timedelta @@ -61,12 +62,15 @@ class SqlResultsRepo: self.conn_str = conn_str self._connect = connect - def _query(self, sql: str, params=()) -> list: + def _open(self): connect = self._connect if connect is None: import pyodbc connect = pyodbc.connect - conn = connect(self.conn_str, timeout=10) + return connect(self.conn_str, timeout=10) + + def _query(self, sql: str, params=()) -> list: + conn = self._open() try: cur = conn.cursor().execute(sql, list(params)) cols = [d[0] for d in cur.description] @@ -74,6 +78,19 @@ class SqlResultsRepo: finally: conn.close() + def delete_run(self, run_at) -> int: + """Every row of the run that started at `run_at` (every row of a + run shares it); how many went.""" + conn = self._open() + try: + cur = conn.cursor().execute( + f"DELETE FROM {results_db.TABLE} WHERE run_at = ?", [run_at]) + gone = cur.rowcount + conn.commit() + return gone + finally: + conn.close() + def runs(self) -> list: """Every run, newest first, with its claim count per Category.""" return self._query( diff --git a/web/templates/day.html b/web/templates/day.html index 7a17327..14b5dd8 100644 --- a/web/templates/day.html +++ b/web/templates/day.html @@ -8,6 +8,13 @@ table.list col.result { width: 96px; } .bar { display: flex; justify-content: space-between; align-items: end; gap: 12px; flex-wrap: wrap; margin-bottom: 14px; } + /* Super Admin only: one line per run of the day, with its Delete. */ + .runs-of-day { display: flex; gap: 8px; flex-wrap: wrap; margin-bottom: 14px; } + .runs-of-day form { display: flex; align-items: center; gap: 10px; + border: 1px solid var(--line); border-radius: 8px; + padding: 6px 6px 6px 12px; } + .runs-of-day button.danger { background: var(--fail-bg); color: var(--fail); + border: 1px solid var(--fail); } /* 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; } @@ -32,6 +39,21 @@ +{% if message %}
{{ message }}
{% endif %} +{% if can_delete and runs %} +
+ {% for run in runs %} +
+ + + {{ run.run_at | clock }} · {{ run.claims }} claim{{ "" if run.claims == 1 else "s" }}{% if run.started_by %} · {{ run.started_by }}{% endif %} + +
+ {% endfor %} +
+{% endif %} + {% set q = ("&claim_no=" ~ claim_no) if claim_no else "" %}
{{ counts.claims }}All diff --git a/web/users.py b/web/users.py index 8c32700..017215a 100644 --- a/web/users.py +++ b/web/users.py @@ -46,6 +46,7 @@ from datetime import datetime, timedelta ROLES = ("Super Admin", "Admin", "Viewer") CAN_RUN = ("Super Admin", "Admin") CAN_ADMIN = ("Super Admin", "Admin") # Portal Users, Sign-in history +CAN_DELETE = ("Super Admin",) # Delete run (operator, 2026-10-28) # The roles each role may add, give, reset, disable or enable. MANAGES = {"Super Admin": ("Admin", "Viewer"), "Admin": ("Viewer",)} MIN_PASSWORD = 10