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:
1 parent
a8af44e673
commit
fafa5285eb
21 files changed
+1509
-63
No files matched your search
+75
-19
@@ -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
|
||||||
|
|||||||
+45
-15
@@ -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,21 +123,18 @@ 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})",
|
||||||
"The 'VERIFY YOUR SECURITY IMAGE' page came up.\n"
|
"The 'VERIFY YOUR SECURITY IMAGE' page came up.\n"
|
||||||
"Click Continue/OK in the browser.\n\n"
|
"Click Continue/OK in the browser.\n\n"
|
||||||
"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"],
|
||||||
|
|||||||
@@ -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]
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
@@ -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:
|
||||||
"Current Status": ("Ready to Submit" if saved
|
out = {"Comments": "; ".join(comments),
|
||||||
else "Corrections pending (not saved)"),
|
"Current Status": ("Ready to Submit" if saved
|
||||||
"Category": "Fail"}
|
else "Corrections pending (not saved)"),
|
||||||
|
"Category": "Fail"}
|
||||||
|
# Not a column: build_rows compares it with the report's PAYER.
|
||||||
|
if _ecw_payer(data):
|
||||||
|
out["_ecw_payer"] = _ecw_payer(data)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
def _money(text):
|
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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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())
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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())
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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())
|
||||||
@@ -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."""
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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(
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in new issue
Block a user