Portal Delete run (Super Admin), lab codes #3/#4, Z23 pointer #45, T1017 #46, payer note, login security-image wait

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
ManikandanandClaude Opus 5.5 committed 2026-10-29 12:30:38 +05:30
1 parent a8af44e673
commit fafa5285eb
21 files changed
+1496 -50

No files matched your search

+75 -19
View File
@@ -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) `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 — login 98 s, **50/50, 30 Pass / 15 Fail / 5 Review**, the same as that
file on 26/10. No bot error in either. 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`):** **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 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: Admin from the Portal, only the PC tool makes one), **Admin** (everything:
Start / Stop, Sign-ins page; adds / changes Viewers only), **Viewer** Start / Stop, Sign-ins page; adds / changes Viewers only), **Viewer**
(Dashboard, Claim status, Runs; changes nothing, no Users or Sign-ins). (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 <date time> · 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 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 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. 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. this machine, with ODBC Driver 18 for SQL Server.
- Tests run offline against fake HTML pages in `tests/fixtures/` and fake DB - 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` 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 starts a local Chromium on a debug port). As of 2026-10-28: 75 test
files, **1239 tests** (full run 2026-10-27 after `a89f689`: 1239/1239) (earlier flaky, each 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, 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). 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 | | 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) | | 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) | | 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) | | 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 | `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) | | 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 <payer> - check by hand". Not on Dental claims |
| 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 | | 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 | `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) | | 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 | `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 | | 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 | `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") | | 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 | `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) | | 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 | `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` | | 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 | `priced_line_check` | common rule | **LAST.** Unchecked line with Billed Fee > 0 → check | | 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 **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" 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)". "Corrections pending (not saved)".
- **Pass**: "Clean claims (…)", Ready to Submit. - **Pass**: "Clean claims (…)", Ready to Submit.
- A claim the run never reached or did not find stays blank in S–X. - 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 - 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 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 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`. 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` waits for the closed Chrome and opens a new one — §10), `1da9e7c`
(2026-10-27, Portal sign-in with the Ecare (2026-10-27, Portal sign-in with the Ecare
office login — §2), `cc3a01a` (2026-10-27, Portal container on India time — 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-8 | Medicare Telehealth | Done |
| EEWA-10 | j2930_j2001_update | 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-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-13 | sliding_fee | Done |
| EEWA-14 | dental_high_risk (incl. the 79-character rule) | 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) | | 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-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-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-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-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-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-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-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 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 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 Dokploy 2026-10-27, `cc3a01a`, Ecare network only).** URL
`http://10.200.0.15:8085` (Dokploy project Ecw_ChargeValidation → `http://10.200.0.15:8085` (Dokploy project Ecw_ChargeValidation →
production → app **Portal**; port 8085 → 8000). The operator signed in 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 - **Source = Drop (zip upload), not Git:** Dokploy cannot clone from
Gitea (`server certificate verification failed`, Traefik's self-signed Gitea (`server certificate verification failed`, Traefik's self-signed
cert); Gitea has no internal http port and no SSH; the operator cannot 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 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 it; set a static port later). SQL login **`cv_portal`** (made in SSMS
2026-10-27: db_datareader + INSERT/UPDATE `CV_PortalUser`, INSERT 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 (`RESULTS_DB_CONN`), never here or in chat. The Portal works only while
that PC is on. that PC is on.
- **Fixes found live:** `69c51d8` the image needs `libgssapi-krb5-2` by - **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, `addClaimFSCPTCode('save')`) saves too. Typed edits (POS, modifiers,
fees) are not kept. Earlier runs changed boxes on 9 claims fees) are not kept. Earlier runs changed boxes on 9 claims
(2026-09-17..10-15, mostly G8510 checked by the old depression (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 re-include step); the list is in the logs (no claim ids here). Putting
operator restores them by hand from the audit log. 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 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 (no click event) and `add_cpt_row` adds nothing; vaccine admin (every
code it would add) / 96372 / G2025 become Review "add by hand"; #22 code it would add) / 96372 / G2025 become Review "add by hand"; #22
+37 -7
View File
@@ -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`. approval link rather than an authenticator code — see `twofactor.py`.
""" """
import time
from datetime import datetime, timezone from datetime import datetime, timezone
from playwright.sync_api import Page, TimeoutError as PWTimeout 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 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: def _assert_login_page(page: Page) -> None:
""" """
Confirm the real login form came back before typing any credentials. Confirm the real login form came back before typing any credentials.
@@ -90,9 +123,9 @@ def _do_login(page: Page, context, client_name: str):
except Exception: except Exception:
pass pass
# Some logins interpose a security-image confirmation page. # Some logins interpose a security-image confirmation page — sometimes
try: # seconds after Next (2026-10-28 17:59: missed by a one-look check).
if page.locator("text=VERIFY YOUR SECURITY IMAGE").first.is_visible(timeout=3_000): if wait_after_username(page, cfg.LOGIN_TIMEOUT) == "image":
print("[LOGIN] Security image page") print("[LOGIN] Security image page")
msgbox.info_box( msgbox.info_box(
f"eCW Bot — Security Image ({client_name})", f"eCW Bot — Security Image ({client_name})",
@@ -101,10 +134,7 @@ def _do_login(page: Page, context, client_name: str):
"Click OK once the password page appears.", "Click OK once the password page appears.",
needs_person=True, needs_person=True,
) )
except Exception: wait_after_username(page, cfg.LOGIN_TIMEOUT, image=False)
pass
page.wait_for_selector(sel.SEL_PASSWORD, timeout=cfg.LOGIN_TIMEOUT)
# A renamed password field or Log In button still works through its # A renamed password field or Log In button still works through its
# fallback (selectors/login.py); this says so in the log. # fallback (selectors/login.py); this says so in the log.
fallback.report(page, ["Password field", "Log In button"], fallback.report(page, ["Password field", "Log In button"],
+8 -2
View File
@@ -44,6 +44,7 @@ from . import depression_dx_pointer
from . import dummy_codes from . import dummy_codes
from . import injection_admin_code from . import injection_admin_code
from . import j2930_j2001_update from . import j2930_j2001_update
from . import lab_codes
from . import medicaid_pregnancy_th from . import medicaid_pregnancy_th
from . import medicaid_uncheck_cpts from . import medicaid_uncheck_cpts
from . import medicare_billing_screening from . import medicare_billing_screening
@@ -52,18 +53,23 @@ from . import medicare_telehealth
from . import medicare_vaccine_a6 from . import medicare_vaccine_a6
from . import medicare_wellness_history from . import medicare_wellness_history
from . import priced_line_check from . import priced_line_check
from . import t1017_non_billable
from . import telehealth from . import telehealth
from . import vaccine_admin_codes from . import vaccine_admin_codes
from . import vaccine_z23_pointer
from . import zero_fee_uncheck from . import zero_fee_uncheck
from .base import run_all from .base import run_all
CONDITIONS = [zero_fee_uncheck, CONDITIONS = [zero_fee_uncheck,
telehealth, medicare_telehealth, depression_dx_pointer, telehealth, medicare_telehealth, depression_dx_pointer,
j2930_j2001_update, dummy_codes, dental_dummy_codes, 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, medicare_vaccine_a6, medicare_wellness_history,
medicaid_pregnancy_th, 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, medicare_pps_code,
priced_line_check] priced_line_check]
+126
View File
@@ -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)
@@ -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 <payer> - 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)
@@ -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)
+37 -4
View File
@@ -91,6 +91,10 @@ COMMENTS = {
"CHP+ Dental - Claim Status to Patient"), "CHP+ Dental - Claim Status to Patient"),
"medicaid_uncheck_cpts": ( "medicaid_uncheck_cpts": (
"CPT not billable to Medicaid (15851/15852/73521 - unchecked, fee 0)"), "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": ( "medicare_billing_screening": (
"Screening codes not billed to Medicare (G8510/G8431)"), "Screening codes not billed to Medicare (G8510/G8431)"),
# Fallback only: the condition words its own comment (which codes). # Fallback only: the condition words its own comment (which codes).
@@ -104,6 +108,8 @@ COMMENTS = {
"medicaid_pregnancy_th": ( "medicaid_pregnancy_th": (
"TH modifier added (pregnancy visit on Medicaid)"), "TH modifier added (pregnancy visit on Medicaid)"),
"vaccine_admin_codes": "CPT Code Missing ( vaccine admin code added )", "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). # Fallback only: the condition words its own comment (96372 added or not).
"injection_admin_code": "CPT Code Missing (injection admin code 96372)", "injection_admin_code": "CPT Code Missing (injection admin code 96372)",
# Fallback only: the condition words its own comment by the path it # Fallback only: the condition words its own comment by the path it
@@ -256,6 +262,24 @@ def review_reasons(data: dict) -> list:
return reasons 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: def claim_outcome(found: bool, data: dict) -> dict:
""" """
Columns V-X for one claim, from what `_read_claim_form` returned. 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" # A save that happened is never hidden behind "Needs manual review"
# (final review, 2026-10-05): it leads the Comments. # (final review, 2026-10-05): it leads the Comments.
lead = [SAVED_NOTE] if saved else [] lead = [SAVED_NOTE] if saved else []
return {"Comments": "; ".join(lead + comments + reasons), out = {"Comments": "; ".join(lead + comments + reasons),
"Current Status": "Needs manual review", "Category": "Review"} "Current Status": "Needs manual review", "Category": "Review"}
if not failed: elif not failed:
return {"Comments": "; ".join([CLEAN] + comments), out = {"Comments": "; ".join([CLEAN] + comments),
"Current Status": "Ready to Submit", "Category": "Pass"} "Current Status": "Ready to Submit", "Category": "Pass"}
return {"Comments": "; ".join(comments), else:
out = {"Comments": "; ".join(comments),
"Current Status": ("Ready to Submit" if saved "Current Status": ("Ready to Submit" if saved
else "Corrections pending (not saved)"), else "Corrections pending (not saved)"),
"Category": "Fail"} "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): def _money(text):
@@ -371,6 +400,10 @@ def build_rows(report_rows: list, header_row: int, claim_col: int,
done = outcomes.get(claim) or {} done = outcomes.get(claim) or {}
if done: if done:
vals.update(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["Recevied date"] = run_date
vals["Completed On"] = run_date vals["Completed On"] = run_date
vals["Assigned To "] = assigned_to vals["Assigned To "] = assigned_to
+39
View File
@@ -2005,6 +2005,45 @@ def retag_cpt_icd_pointer(page: Page, *, target_pointer: str,
return changes 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: def kept_cpt_row_per_code(rows) -> dict:
""" """
Which row is KEPT for each code when the same code sits on more than Which row is KEPT for each code when the same code sits on more than
+20
View File
@@ -520,6 +520,26 @@ def tracking_columns_fill_only_for_claims_the_run_read():
assert unread[_col(h)] == "", (h, unread) 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 @case
def a_claim_id_only_test_sheet_still_builds(): def a_claim_id_only_test_sheet_still_builds():
rows = cs.build_rows([["Claim No"], ["9000002"], ["9000001"]], 0, 0, rows = cs.build_rows([["Claim No"], ["9000002"], ["9000001"]], 0, 0,
+231
View File
@@ -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())
+7 -4
View File
@@ -56,12 +56,13 @@ from ecw_bot.conditions import (bh_h_code_modifier, # noqa: E402
depression_dx_pointer, dummy_codes, depression_dx_pointer, dummy_codes,
injection_admin_code, injection_admin_code,
medicare_pps_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_billing_screening,
medicare_telehealth, medicare_telehealth,
medicare_vaccine_a6, medicare_wellness_history, medicare_vaccine_a6, medicare_wellness_history,
medicaid_pregnancy_th, medicaid_pregnancy_th,
telehealth, vaccine_admin_codes, telehealth, vaccine_admin_codes, vaccine_z23_pointer,
zero_fee_uncheck) zero_fee_uncheck)
from ecw_bot.conditions.base import ( # noqa: E402 from ecw_bot.conditions.base import ( # noqa: E402
CPT_TAB_NOT_CONFIRMED, ConditionResult, run_all) CPT_TAB_NOT_CONFIRMED, ConditionResult, run_all)
@@ -578,10 +579,12 @@ def registered_last_with_zero_fee_uncheck_first():
zero_fee_uncheck, zero_fee_uncheck,
telehealth, medicare_telehealth, depression_dx_pointer, telehealth, medicare_telehealth, depression_dx_pointer,
j2930_j2001_update, dummy_codes, dental_dummy_codes, 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_billing_screening, medicare_vaccine_a6,
medicare_wellness_history, medicaid_pregnancy_th, 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, medicare_pps_code,
plc, plc,
], conditions.CONDITIONS ], conditions.CONDITIONS
+192
View File
@@ -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())
@@ -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 =
'<td><input data-fieldname="claimCPTCode" value="' + args.code + '" readonly></td>'
+ '<td><input type="checkbox" ng-click="updateBillToIns(3)" checked></td>'
+ args.icd.map((v, i) => '<td><input data-fieldname="ClaimCPTICD'
+ (i + 1) + '" value="' + v + '"></td>').join('')
+ '<td><input data-fieldname="ClaimCPTBilledFee" value="45.00"></td>';
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())
+7 -4
View File
@@ -55,13 +55,14 @@ from ecw_bot.conditions import (bh_h_code_modifier, # noqa: E402
depression_dx_pointer, dummy_codes, depression_dx_pointer, dummy_codes,
injection_admin_code, injection_admin_code,
medicare_pps_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_billing_screening,
medicare_telehealth, medicare_telehealth,
medicare_vaccine_a6, medicare_wellness_history, medicare_vaccine_a6, medicare_wellness_history,
medicaid_pregnancy_th, medicaid_pregnancy_th,
priced_line_check, telehealth, priced_line_check, telehealth,
vaccine_admin_codes) vaccine_admin_codes, vaccine_z23_pointer)
from ecw_bot.conditions.base import ( # noqa: E402 from ecw_bot.conditions.base import ( # noqa: E402
CPT_TAB_NOT_CONFIRMED, ConditionResult, run_all) CPT_TAB_NOT_CONFIRMED, ConditionResult, run_all)
from ecw_bot.pages import claim_form # noqa: E402 from ecw_bot.pages import claim_form # noqa: E402
@@ -520,10 +521,12 @@ def registered_first_in_conditions():
assert conditions.CONDITIONS[1:] == [ assert conditions.CONDITIONS[1:] == [
telehealth, medicare_telehealth, depression_dx_pointer, telehealth, medicare_telehealth, depression_dx_pointer,
j2930_j2001_update, dummy_codes, dental_dummy_codes, 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_billing_screening, medicare_vaccine_a6,
medicare_wellness_history, medicaid_pregnancy_th, 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, medicare_pps_code,
priced_line_check, priced_line_check,
], conditions.CONDITIONS ], conditions.CONDITIONS
+109
View File
@@ -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 = """
<input id="doctorID" value="someone">
<input disabled type="password" id="passwordField">
<div id="img" style="display:none">VERIFY YOUR SECURITY IMAGE</div>
<script>%s</script>
"""
@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())
+83
View File
@@ -105,6 +105,16 @@ class FakeRepo:
self._maybe_fail() self._maybe_fail()
return [dict(r) for r in self._rows if claim_no in r["claim_no"]] 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" 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" 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 ───────────────────────────────────────────────────────────────── # ── repo SQL ─────────────────────────────────────────────────────────────────
class FakeCursor: class FakeCursor:
@@ -590,6 +657,7 @@ class FakeCursor:
def execute(self, sql, params): def execute(self, sql, params):
self.log.append((sql, list(params))) self.log.append((sql, list(params)))
self.rowcount = len(self.rows)
return self return self
def fetchall(self): def fetchall(self):
@@ -599,10 +667,14 @@ class FakeCursor:
class FakeConn: class FakeConn:
def __init__(self, rows=(), cols=()): def __init__(self, rows=(), cols=()):
self.log, self.rows, self.cols, self.closed = [], list(rows), cols, False self.log, self.rows, self.cols, self.closed = [], list(rows), cols, False
self.committed = False
def cursor(self): def cursor(self):
return FakeCursor(self.log, self.rows, self.cols) return FakeCursor(self.log, self.rows, self.cols)
def commit(self):
self.committed = True
def close(self): def close(self):
self.closed = True self.closed = True
@@ -611,6 +683,17 @@ def _repo(conn):
return SqlResultsRepo("DSN=x", connect=lambda cs, timeout: 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 @case
def repo_turns_a_text_run_at_or_day_into_a_datetime_or_date(): def repo_turns_a_text_run_at_or_day_into_a_datetime_or_date():
"""The legacy {SQL Server} driver returns DATETIME2 / DATE as text.""" """The legacy {SQL Server} driver returns DATETIME2 / DATE as text."""
+6
View File
@@ -110,3 +110,9 @@ GO
-- CREATE USER [MFILESERVER\EC10980] FOR LOGIN [MFILESERVER\EC10980]; -- CREATE USER [MFILESERVER\EC10980] FOR LOGIN [MFILESERVER\EC10980];
-- ALTER ROLE db_datareader ADD MEMBER [MFILESERVER\EC10980]; -- ALTER ROLE db_datareader ADD MEMBER [MFILESERVER\EC10980];
-- ALTER ROLE db_datawriter 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;
+45 -5
View File
@@ -56,7 +56,7 @@ from ecw_bot.core.run_events import run_id_of # noqa: E402
from .repo import SqlResultsRepo # noqa: E402 from .repo import SqlResultsRepo # noqa: E402
from . import run_status # noqa: E402 from . import run_status # noqa: E402
from . import runs as run_view # 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, EcareSignIn, IDLE_MINUTES, LOCK_AFTER, LOCK_MINUTES,
MANAGES, Sessions, SqlUserStore, UserError, is_ecare) MANAGES, Sessions, SqlUserStore, UserError, is_ecare)
from .bot_runner import (AlreadyRunning, BotRunner, # noqa: E402 from .bot_runner import (AlreadyRunning, BotRunner, # noqa: E402
@@ -109,6 +109,13 @@ _DB_PROBLEMS = {
"42000": ("No permission to read the results table", "42000": ("No permission to read the results table",
"The Windows login needs db_datareader on Charge_Validation."), "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 " _DB_DEFAULT = (DB_DOWN, "Check that SQL Server is running and that "
"RESULTS_DB_CONN in .env points at the " "RESULTS_DB_CONN in .env points at the "
"Charge_Validation database.") "Charge_Validation database.")
@@ -211,7 +218,9 @@ def _filters(run_at, category, claim_no):
return when, category, claim 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: try:
return fn(*args, **kwargs) return fn(*args, **kwargs)
except UserError: except UserError:
@@ -219,7 +228,8 @@ def _db(fn, *args, **kwargs):
except Exception as exc: except Exception as exc:
state = exc.args[0] if exc.args and isinstance(exc.args[0], str) \ state = exc.args[0] if exc.args and isinstance(exc.args[0], str) \
else "" 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)}") print(f"[WEB] {title}: {results_db.safe_error(exc)}")
raise DbUnavailable(title, hint) from 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 "can_run": bool(can_start and user
and user["role"] in CAN_RUN), and user["role"] in CAN_RUN),
"is_admin": bool(user and user["role"] in CAN_ADMIN), "is_admin": bool(user and user["role"] in CAN_ADMIN),
"can_delete": bool(user and user["role"] in CAN_DELETE),
**context}) **context})
@app.get("/", response_class=HTMLResponse) @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) @app.get("/day/{day}", response_class=HTMLResponse)
def day_page(request: Request, day: str, category: str = None, 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) when = _day(day)
_, cat, search = _filters(None, category, claim_no) _, cat, search = _filters(None, category, claim_no)
_, _, opened = _filters(None, None, claim) _, _, opened = _filters(None, None, claim)
rows = _db(repo.day_results, when) 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 shown = [_for_panel(r) for r in rows
if (not cat or r.get("category") == cat) if (not cat or r.get("category") == cat)
and (not search or search in str(r.get("claim_no") or ""))] 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]], [_long_date(when), None]],
day=when, counts=_counts(rows), rows=shown, day=when, counts=_counts(rows), rows=shown,
category=cat or "", claim_no=search or "", 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) @app.get("/claim", response_class=HTMLResponse)
def claim_page(request: Request, claim_no: str = None): def claim_page(request: Request, claim_no: str = None):
+22 -5
View File
@@ -1,7 +1,8 @@
""" """
Read-only access to the results table (`dbo.CV_ChargeSummary`, written by Access to the results table (`dbo.CV_ChargeSummary`, written by
src/ecw_bot/core/results_db.py). SELECT statements only, always src/ecw_bot/core/results_db.py): SELECTs, and one DELETE — a whole run, the
parameterized; one short connection per call. Super Admin's Delete run (2026-10-28). Always parameterized; one short
connection per call.
""" """
from datetime import date, datetime, timedelta from datetime import date, datetime, timedelta
@@ -61,12 +62,15 @@ class SqlResultsRepo:
self.conn_str = conn_str self.conn_str = conn_str
self._connect = connect self._connect = connect
def _query(self, sql: str, params=()) -> list: def _open(self):
connect = self._connect connect = self._connect
if connect is None: if connect is None:
import pyodbc import pyodbc
connect = pyodbc.connect 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: try:
cur = conn.cursor().execute(sql, list(params)) cur = conn.cursor().execute(sql, list(params))
cols = [d[0] for d in cur.description] cols = [d[0] for d in cur.description]
@@ -74,6 +78,19 @@ class SqlResultsRepo:
finally: finally:
conn.close() 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: def runs(self) -> list:
"""Every run, newest first, with its claim count per Category.""" """Every run, newest first, with its claim count per Category."""
return self._query( return self._query(
+22
View File
@@ -8,6 +8,13 @@
table.list col.result { width: 96px; } table.list col.result { width: 96px; }
.bar { display: flex; justify-content: space-between; align-items: end; .bar { display: flex; justify-content: space-between; align-items: end;
gap: 12px; flex-wrap: wrap; margin-bottom: 14px; } 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. */ /* A phone: the key columns only — the rest is in the side panel. */
@media (max-width: 760px) { @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 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 @@
</form> </form>
</div> </div>
{% if message %}<div class="msg good">{{ message }}</div>{% endif %}
{% if can_delete and runs %}
<div class="runs-of-day">
{% for run in runs %}
<form method="post" action="/admin/runs/delete"
onsubmit='return confirm({{ ("Delete the " ~ (run.run_at | clock) ~ " run — " ~ run.claims ~ (" claim" if run.claims == 1 else " claims") ~ "? They go from the Portal for everyone. This cannot be undone.") | tojson }})'>
<input type="hidden" name="token" value="{{ run_token }}">
<input type="hidden" name="run_at" value="{{ run.run_at.isoformat() }}">
<span>{{ run.run_at | clock }} · {{ run.claims }} claim{{ "" if run.claims == 1 else "s" }}{% if run.started_by %} · {{ run.started_by }}{% endif %}</span>
<button type="submit" class="danger">Delete run</button>
</form>
{% endfor %}
</div>
{% endif %}
{% set q = ("&claim_no=" ~ claim_no) if claim_no else "" %} {% set q = ("&claim_no=" ~ claim_no) if claim_no else "" %}
<div class="tiles"> <div class="tiles">
<a class="tile{{ ' on' if not category }}" href="/day/{{ day.isoformat() }}{{ ('?claim_no=' ~ claim_no) if claim_no }}"><span class="n">{{ counts.claims }}</span><span class="l">All</span></a> <a class="tile{{ ' on' if not category }}" href="/day/{{ day.isoformat() }}{{ ('?claim_no=' ~ claim_no) if claim_no }}"><span class="n">{{ counts.claims }}</span><span class="l">All</span></a>
+1
View File
@@ -46,6 +46,7 @@ from datetime import datetime, timedelta
ROLES = ("Super Admin", "Admin", "Viewer") ROLES = ("Super Admin", "Admin", "Viewer")
CAN_RUN = ("Super Admin", "Admin") CAN_RUN = ("Super Admin", "Admin")
CAN_ADMIN = ("Super Admin", "Admin") # Portal Users, Sign-in history 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. # The roles each role may add, give, reset, disable or enable.
MANAGES = {"Super Admin": ("Admin", "Viewer"), "Admin": ("Viewer",)} MANAGES = {"Super Admin": ("Admin", "Viewer"), "Admin": ("Viewer",)}
MIN_PASSWORD = 10 MIN_PASSWORD = 10