coverage calculator and UI refinements; add demo MVP gap analysis and quality review docs
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
3b0de375d9
commit
cc2a5f4b47
10 changed files with 325 additions and 118 deletions
|
|
@ -103,7 +103,7 @@ Uploaded CSV files are:
|
||||||
| Upload batch metadata | Duration of subscription + 90 days |
|
| Upload batch metadata | Duration of subscription + 90 days |
|
||||||
| Scored patient records (normalized_records) | Duration of subscription + 90 days |
|
| Scored patient records (normalized_records) | Duration of subscription + 90 days |
|
||||||
| Confirmed visit dates | Duration of subscription + 90 days |
|
| Confirmed visit dates | Duration of subscription + 90 days |
|
||||||
| Audit events | 7 years (HIPAA compliance requirement, per 45 CFR §164.530(j)) |
|
| Audit events | 6 years minimum (HIPAA compliance requirement, per 45 CFR §164.530(j)) |
|
||||||
| Source file metadata (hash only) | Duration of subscription + 90 days |
|
| Source file metadata (hash only) | Duration of subscription + 90 days |
|
||||||
|
|
||||||
Upon written request, all customer data except audit events (which are retained for legal compliance) will be deleted within 10 business days. STTIL will confirm deletion in writing.
|
Upon written request, all customer data except audit events (which are retained for legal compliance) will be deleted within 10 business days. STTIL will confirm deletion in writing.
|
||||||
|
|
|
||||||
46
docs/reviews/2026-06-11-demo-mvp-gap-analysis.md
Normal file
46
docs/reviews/2026-06-11-demo-mvp-gap-analysis.md
Normal file
|
|
@ -0,0 +1,46 @@
|
||||||
|
# Demo MVP Spec Gap Analysis — 2026-06-11
|
||||||
|
|
||||||
|
Spec: docs/superpowers/specs/2026-06-07-signal-demo-mvp-design.md. Read-only analysis by background agent.
|
||||||
|
|
||||||
|
## DONE
|
||||||
|
- Visit date priority chain — _resolve_visit_date() coverage_calculator.py:167-183 (CSV > Supabase confirmed > shipment−30d estimate)
|
||||||
|
- 6-month validation — _compute_visit_flag() + /api/confirm-visit main.py:511-523
|
||||||
|
- 90/60/45 tier system + urgency sort — _compute_priority() coverage_calculator.py:203-224
|
||||||
|
- 45-day prescriber outreach flag — FLAG_ACTIONS RENEWAL_CRITICAL
|
||||||
|
- Confirm Visit workflow end to end — modal, persistence, recompute, live row update, persists across imports
|
||||||
|
- Full 5-item payer-dependent doc state machine — doc_state_machine.py matches spec exactly
|
||||||
|
- Cascade on expanded row + 5-dot status bar — WorklistTable.jsx:260-293, DocStatusBar.jsx
|
||||||
|
- Transfer detection via Transfer_From column — normalizer.py:75-78, TRANSFER_PENDING short-circuit
|
||||||
|
- CSV test suite — 50 files (PA+NJ), parameterized generators, market_data.json
|
||||||
|
- Legal drafts — pitch/legal/ (LOI, NDA, BAA)
|
||||||
|
|
||||||
|
## PARTIAL
|
||||||
|
- Status labels deviated from spec — RESOLVED 2026-06-11: locked to At Risk / Action Needed / On Track / Clear to Ship everywhere (see quality review doc)
|
||||||
|
- Supply Lapsed alert banner — sorts to top, no banner component
|
||||||
|
- Estimated label wording — spec's "Estimated — verify with prescriber office" not in UI
|
||||||
|
- Cascade format — all-gaps list vs spec's single chain (deliberate redesign, commit ff657f4)
|
||||||
|
- Per-item recommended actions in expanded checklist — live in cascade lines only
|
||||||
|
- Cycle reset via re-import — recompute works, but NO diff vs previous import; CSV visit dates not persisted to confirmed_visits
|
||||||
|
- Transfer clearing — "Verify Docs" button has no onClick handler
|
||||||
|
- MFA on admin accounts NOT VERIFIED; attorney review of LOI/NDA pending
|
||||||
|
|
||||||
|
## NOT BUILT
|
||||||
|
- Patient-disappeared-from-import flag (needs import diff: persistence.py previous batch load, main.py compare + new flag, frontend badge)
|
||||||
|
- Transfer detection via history gap (same import-history dependency)
|
||||||
|
- Duplicate patient_id detection in one file (normalizer.py has none; collapses silently)
|
||||||
|
- Watcher Agent (nothing exists; demo needs it only as talking point)
|
||||||
|
- 6 pilot questions in /pilot-prep skill (zero present)
|
||||||
|
|
||||||
|
## Empty-state / status legend
|
||||||
|
Spec does NOT cover either — no overlap, safe to build separately. EmptyState.jsx exists (no CSV guidance); zero "legend" hits in signal-ui/src. Maps directly to the last PARTIAL pilot-readiness item.
|
||||||
|
|
||||||
|
## Recommended build order
|
||||||
|
1. Status legend + empty-state guidance (clears pilot readiness to 13/13)
|
||||||
|
2. Pilot-prep skill questions (5-minute edit)
|
||||||
|
3. Import diff package: previous-batch lookup, disappeared flag, persist CSV visit dates ("Signal gets smarter every import")
|
||||||
|
4. Wire Verify Docs transfer clearing
|
||||||
|
5. Duplicate patient_id flagging
|
||||||
|
6. Watcher Agent (trail)
|
||||||
|
7. Parallel ops: MFA verification, attorney review
|
||||||
|
|
||||||
|
Beyond-spec already built: column mapping review UI (CSVImport.jsx) — spec Sec 12 excluded it; no action.
|
||||||
57
docs/reviews/2026-06-11-quality-review.md
Normal file
57
docs/reviews/2026-06-11-quality-review.md
Normal file
|
|
@ -0,0 +1,57 @@
|
||||||
|
# Signal Quality Review — 2026-06-11
|
||||||
|
|
||||||
|
Read-only review of signal-ui and python-backend by background agent. No fixes applied yet.
|
||||||
|
Execution plan: surgical pass = 2 CRITICAL + 8 HIGH first, then label unification (see decision note below).
|
||||||
|
|
||||||
|
**Label decision (Kisa, 2026-06-11):** All user-facing status labels are exactly four, consistent everywhere without fail: **At Risk, Action Needed, On Track, Clear to Ship.** Mapping: SUPPLY_LAPSED/VISIT_REQUIRED/RENEWAL_CRITICAL → At Risk; RENEWAL_ELEVATED/RENEWAL_SOON/TRANSFER_PENDING/NO_RECENT_SHIPMENT → Action Needed; RESUPPLY_READY → Clear to Ship; ACTIVE → On Track. Define once in backend; Badge.jsx, exports, and coverage.js consume it. Supersedes FLAG_LABELS in api/main.py:117-127 and coverage.js:20-29. This resolves findings 15 and 16.
|
||||||
|
|
||||||
|
## CRITICAL
|
||||||
|
|
||||||
|
1. **Confirm Visit sends fabricated shipment date (today) and hardcoded quantity 1.** ConfirmVisitModal.jsx:16-23 derives shipment = today; :58 hardcodes quantity 1. Backend recompute (main.py:538-546) then yields wrong Days Left/coverage end/flag; visit validation (main.py:514-523) anchors wrong. Fix: add last_shipment_date and quantity to RecordOut and pass through.
|
||||||
|
2. **Privacy page claims an executed BAA that does not exist.** Privacy.jsx:104-110; also :97 says 7-year audit retention vs audit_logger.py 6 years. Fix: rewrite HIPAA section to actual de-identified posture; align retention number.
|
||||||
|
|
||||||
|
## HIGH
|
||||||
|
|
||||||
|
3. **NO_RECENT_SHIPMENT unreachable** — coverage_calculator.py:269-288 returns SUPPLY_LAPSED before the 12-month check at :312-328; same in coverage.js:127-154. Fix: check days_since_shipment >= 365 before supply-lapsed early return.
|
||||||
|
4. **Tricare → commercial.** normalizer.py:202-208 normalizes first (no Tricare entries, default commercial), making coverage_calculator._normalize_payer TFL logic dead code; raw payer display string also destroyed. Fix: normalize in one place, keep raw string for display, add Tricare to normalizer.
|
||||||
|
5. **Frontend export drops order_number, hcpcs, recommended_next_step_code.** api.js:62-89 omits them; export rows get blanks + NO_ACTION_NEEDED (main.py:170-172 defaults). Fix: map all three in apiRecordToLocal.
|
||||||
|
6. **Fallback priority math inverted.** coverage.js:93-102: RENEWAL_CRITICAL 500 < RENEWAL_ELEVATED 700; SUPPLY_LAPSED 1000 vs backend 2000; uses daysUntilEnd vs backend days_until_visit_due. Fix: copy backend _compute_priority exactly.
|
||||||
|
7. **Confirmed visits never reflected in doc state; confirming regresses checklist.** main.py:262 passes confirmed_visit_date=None; /api/confirm-visit rebuild (main.py:538-545) loses csv_* doc fields, frontend merge (WorklistTable.jsx:71-77) overwrites row with regressed state. Fix: pass confirmed date into compute_doc_state; echo existing doc fields through confirm payload.
|
||||||
|
8. **_recommended_next_step_code precedence bug.** main.py:245 guard is constantly True (DocStateOut has no cascade attr); diagnosis "Missing" never checked → READY_TO_BILL with open diagnosis gap. Fix: drop broken guard, add diagnosis check.
|
||||||
|
9. **"refill" in user-facing text.** main.py:136 "30-day refill window" → "resupply window". (Internal refill_window_days config keys non-urgent.)
|
||||||
|
10. **Frontend FLAG enum missing TRANSFER_PENDING; payer config drift.** coverage.js:9-18 (8 vs 9 flags), no csv_visit_date support; getPayerConfig gives medicaid/commercial visitRenewalDays null vs payer_rules.json 180; visit due = shipment+180 vs backend (shipment−30)+180. Fix: mirror backend flag set, payer table, 30-day offset.
|
||||||
|
|
||||||
|
## MEDIUM
|
||||||
|
|
||||||
|
11. Backend failures silently swallowed; weak local fallback runs (api.js:21-34, App.jsx:61-74); real CSVs can show "Loaded 0 patients" with no error. Surface backend errors; fall back only on network failure.
|
||||||
|
12. Audit entries never reach WORM audit_log table — audit_logger.py:147-148 needs db_conn, no caller passes one. Add Supabase REST sink in log_event.
|
||||||
|
13. Audit calls hardcode "demo_user"/"0.0.0.0" (main.py:359, 381, 467, 549). Thread claims + request IP through.
|
||||||
|
14. Org resolution reads only "o" claim (main.py:371, 526); org_id-shaped JWTs silently land in shared gaboro-pilot org. Read o.id with org_id fallback; fail closed otherwise.
|
||||||
|
15. Three conflicting status vocabularies — RESOLVED by label decision above.
|
||||||
|
16. DocStatusBar.jsx:6-24 maps dead status strings; real values fall through to amber. Align keys with doc_state_machine.py values.
|
||||||
|
17. Duplicate patient_ids collapse in dict lookups (main.py:378, persistence.py:114). Key by (patient_id, shipment_date, component) or index-align.
|
||||||
|
18. Mapping review Override dropdown is a no-op (CSVImport.jsx:117-123); example_value (:52) never sent by backend. Wire it or remove until wired; checklist counts it PASS — correct the checklist if not wiring now.
|
||||||
|
19. Blank/unrecognized payer silently becomes commercial (normalizer.py:208). Surface warning in skipped_reasons/mapping_summary.
|
||||||
|
20. VITE_SIGNAL_API_KEY path would ship API key in public bundle if ever set (api.js:8). Remove frontend API-key path; Clerk token covers production.
|
||||||
|
21. No explicit PHI-column guard in normalizer; bare "patient"/"id" aliases broad (normalizer.py:20-26). Add rejection list (name/dob/ssn/phone/address) that fails upload with clear message; drop bare "patient" alias. Checklist claims "PHI blocked at normalizer" — currently implicit only.
|
||||||
|
22. Client/backend disagree on 6-month window: setMonth(-6) (ConfirmVisitModal.jsx:39-41) vs 183 days (main.py:514). Use 183 days both sides.
|
||||||
|
|
||||||
|
## LOW
|
||||||
|
|
||||||
|
23. Stale flag "OK" in _no_action_flags — persistence.py:153.
|
||||||
|
24. Unused/redundant imports — main.py:25, 499, 513.
|
||||||
|
25. catch(e){throw e} — api.js:116-118; misleading warning text — coverage_calculator.py:376.
|
||||||
|
26. escalateCount unused — App.jsx:34.
|
||||||
|
27. Two parallel toast systems — Toast.jsx + WorklistTable.jsx:45-53, 330-350.
|
||||||
|
28. expandedRow localStorage key auto-expands wrong row after re-import — WorklistTable.jsx:41, 139, 199.
|
||||||
|
29. parseCSV naive comma split breaks quoted fields (fallback only) — coverage.js:169-182.
|
||||||
|
30. EmptyState hardcodes light-theme colors — EmptyState.jsx:8, 22.
|
||||||
|
31. /health/db leaks raw exception text — main.py:339.
|
||||||
|
32. "now()" string literal to timestamptz — persistence.py:193.
|
||||||
|
33. SUPPLY_LAPSED priority adds abs(days_until_visit) even when visit far future — coverage_calculator.py:209-211.
|
||||||
|
34. Sidebar hardcodes "Billing Staff" role + dead Settings link — Sidebar.jsx:126-130, 143-145.
|
||||||
|
|
||||||
|
## Clean
|
||||||
|
|
||||||
|
- Auth: /api/upload, /api/export, /api/confirm-visit all behind require_auth; frontend passes Clerk Bearer tokens. Only /health endpoints open (see 31).
|
||||||
|
- PHI in audit log: all resource_ids hashed before storage.
|
||||||
|
|
@ -133,7 +133,7 @@ FLAG_ACTIONS = {
|
||||||
"RENEWAL_CRITICAL": "Contact prescriber's office and patient directly — ask: 'Was there a visit in the past 6 months to evaluate CGM dosing needs?' Appointment must be on calendar within 45 days",
|
"RENEWAL_CRITICAL": "Contact prescriber's office and patient directly — ask: 'Was there a visit in the past 6 months to evaluate CGM dosing needs?' Appointment must be on calendar within 45 days",
|
||||||
"RENEWAL_ELEVATED": "Follow up with prescriber's office — ask: 'Was there a visit in the past 6 months to evaluate CGM dosing needs?' Confirm appointment is scheduled",
|
"RENEWAL_ELEVATED": "Follow up with prescriber's office — ask: 'Was there a visit in the past 6 months to evaluate CGM dosing needs?' Confirm appointment is scheduled",
|
||||||
"RENEWAL_SOON": "Contact prescriber's office — ask: 'Was there a visit in the past 6 months to evaluate this patient's CGM dosing needs?' Schedule evaluation now if not yet completed",
|
"RENEWAL_SOON": "Contact prescriber's office — ask: 'Was there a visit in the past 6 months to evaluate this patient's CGM dosing needs?' Schedule evaluation now if not yet completed",
|
||||||
"RESUPPLY_READY": "Initiate resupply order — patient is within the 30-day refill window",
|
"RESUPPLY_READY": "Initiate resupply order — patient is within the 30-day resupply window",
|
||||||
"ACTIVE": "No action needed — documentation current, visit more than 90 days out",
|
"ACTIVE": "No action needed — documentation current, visit more than 90 days out",
|
||||||
"NO_RECENT_SHIPMENT": "Verify status with prescriber office — confirm patient is still active",
|
"NO_RECENT_SHIPMENT": "Verify status with prescriber office — confirm patient is still active",
|
||||||
}
|
}
|
||||||
|
|
@ -170,6 +170,16 @@ class RecordOut(BaseModel):
|
||||||
order_number: Optional[str] = None
|
order_number: Optional[str] = None
|
||||||
hcpcs: Optional[str] = None
|
hcpcs: Optional[str] = None
|
||||||
recommended_next_step_code: str = "NO_ACTION_NEEDED"
|
recommended_next_step_code: str = "NO_ACTION_NEEDED"
|
||||||
|
# Real values the frontend must echo back on /api/confirm-visit so the
|
||||||
|
# recompute uses actual data instead of fabricated defaults.
|
||||||
|
last_shipment_date: Optional[str] = None
|
||||||
|
quantity: int = 1
|
||||||
|
csv_visit_date: Optional[str] = None
|
||||||
|
csv_swo_status: Optional[str] = None
|
||||||
|
csv_pecos_verified: Optional[str] = None
|
||||||
|
csv_pa_status: Optional[str] = None
|
||||||
|
csv_diagnosis_on_file: Optional[str] = None
|
||||||
|
csv_transfer_from: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
class UploadResponse(BaseModel):
|
class UploadResponse(BaseModel):
|
||||||
|
|
@ -229,7 +239,9 @@ def _normalize_payer_type(payer: str) -> str:
|
||||||
return normalized
|
return normalized
|
||||||
|
|
||||||
|
|
||||||
def _recommended_next_step_code(flag_val: str, doc: Optional[DocStateOut]) -> str:
|
def _recommended_next_step_code(
|
||||||
|
flag_val: str, doc: Optional[DocStateOut], cascade: Optional[list] = None
|
||||||
|
) -> str:
|
||||||
if doc and doc.pa == "Denied":
|
if doc and doc.pa == "Denied":
|
||||||
return "PA_APPEAL"
|
return "PA_APPEAL"
|
||||||
if doc and doc.pa == "Required — Not Started":
|
if doc and doc.pa == "Required — Not Started":
|
||||||
|
|
@ -238,16 +250,18 @@ def _recommended_next_step_code(flag_val: str, doc: Optional[DocStateOut]) -> st
|
||||||
return "AWAITING_VISIT_CONFIRMATION"
|
return "AWAITING_VISIT_CONFIRMATION"
|
||||||
if doc and doc.swo in ("Pending", "Expired"):
|
if doc and doc.swo in ("Pending", "Expired"):
|
||||||
return "REQUEST_SWO_UPDATE"
|
return "REQUEST_SWO_UPDATE"
|
||||||
|
if doc and doc.diagnosis == "Missing":
|
||||||
|
return "OBTAIN_DIAGNOSIS"
|
||||||
if doc and doc.pecos == "Not Verified":
|
if doc and doc.pecos == "Not Verified":
|
||||||
return "VERIFY_PRESCRIBER_PECOS"
|
return "VERIFY_PRESCRIBER_PECOS"
|
||||||
if doc and doc.pa == "Pending":
|
if doc and doc.pa == "Pending":
|
||||||
return "PA_FOLLOW_UP"
|
return "PA_FOLLOW_UP"
|
||||||
if flag_val in ("RESUPPLY_READY", "ACTIVE") and (not doc or not doc.cascade if hasattr(doc, "cascade") else True):
|
if flag_val in ("RESUPPLY_READY", "ACTIVE") and not cascade:
|
||||||
return "READY_TO_BILL"
|
return "READY_TO_BILL"
|
||||||
return "NO_ACTION_NEEDED"
|
return "NO_ACTION_NEEDED"
|
||||||
|
|
||||||
|
|
||||||
def _to_record_out(r, record=None) -> RecordOut:
|
def _to_record_out(r, record=None, confirmed_visit_date=None) -> RecordOut:
|
||||||
flag_val = r.flag.value if hasattr(r.flag, "value") else str(r.flag)
|
flag_val = r.flag.value if hasattr(r.flag, "value") else str(r.flag)
|
||||||
|
|
||||||
# Compute doc state if we have the original record
|
# Compute doc state if we have the original record
|
||||||
|
|
@ -259,7 +273,7 @@ def _to_record_out(r, record=None) -> RecordOut:
|
||||||
payer_type=normalized_payer,
|
payer_type=normalized_payer,
|
||||||
csv_swo_status=record.csv_swo_status,
|
csv_swo_status=record.csv_swo_status,
|
||||||
csv_visit_date=record.csv_visit_date,
|
csv_visit_date=record.csv_visit_date,
|
||||||
confirmed_visit_date=None, # already factored into r.next_visit_due_date
|
confirmed_visit_date=confirmed_visit_date,
|
||||||
csv_pecos_verified=record.csv_pecos_verified,
|
csv_pecos_verified=record.csv_pecos_verified,
|
||||||
csv_pa_status=record.csv_pa_status,
|
csv_pa_status=record.csv_pa_status,
|
||||||
csv_diagnosis_on_file=record.csv_diagnosis_on_file,
|
csv_diagnosis_on_file=record.csv_diagnosis_on_file,
|
||||||
|
|
@ -292,7 +306,15 @@ def _to_record_out(r, record=None) -> RecordOut:
|
||||||
cascade=cascade,
|
cascade=cascade,
|
||||||
order_number=getattr(record, "order_number", None) if record else None,
|
order_number=getattr(record, "order_number", None) if record else None,
|
||||||
hcpcs=getattr(record, "hcpcs", None) if record else None,
|
hcpcs=getattr(record, "hcpcs", None) if record else None,
|
||||||
recommended_next_step_code=_recommended_next_step_code(flag_val, doc_state_out),
|
recommended_next_step_code=_recommended_next_step_code(flag_val, doc_state_out, cascade),
|
||||||
|
last_shipment_date=r.last_shipment_date.isoformat() if r.last_shipment_date else None,
|
||||||
|
quantity=getattr(record, "quantity", 1) if record else 1,
|
||||||
|
csv_visit_date=record.csv_visit_date.isoformat() if record and record.csv_visit_date else None,
|
||||||
|
csv_swo_status=getattr(record, "csv_swo_status", None) if record else None,
|
||||||
|
csv_pecos_verified=getattr(record, "csv_pecos_verified", None) if record else None,
|
||||||
|
csv_pa_status=getattr(record, "csv_pa_status", None) if record else None,
|
||||||
|
csv_diagnosis_on_file=getattr(record, "csv_diagnosis_on_file", None) if record else None,
|
||||||
|
csv_transfer_from=getattr(record, "csv_transfer_from", None) if record else None,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -376,7 +398,16 @@ async def upload_csv(
|
||||||
|
|
||||||
# Build a lookup from patient_id to original record for doc state computation
|
# Build a lookup from patient_id to original record for doc state computation
|
||||||
record_lookup = {r.patient_id: r for r in records}
|
record_lookup = {r.patient_id: r for r in records}
|
||||||
out = [_to_record_out(r, record=record_lookup.get(r.patient_id)) for r in results]
|
out = [
|
||||||
|
_to_record_out(
|
||||||
|
r,
|
||||||
|
record=record_lookup.get(r.patient_id),
|
||||||
|
confirmed_visit_date=confirmed_visits.get(
|
||||||
|
hashlib.sha256(r.patient_id.encode()).hexdigest()
|
||||||
|
),
|
||||||
|
)
|
||||||
|
for r in results
|
||||||
|
]
|
||||||
|
|
||||||
log_event(AuditAction.CSV_INGEST, file.filename or "unknown", "demo_user",
|
log_event(AuditAction.CSV_INGEST, file.filename or "unknown", "demo_user",
|
||||||
"success", "0.0.0.0", detail=f"{len(out)} records scored")
|
"success", "0.0.0.0", detail=f"{len(out)} records scored")
|
||||||
|
|
@ -485,6 +516,16 @@ class ConfirmVisitRequest(BaseModel):
|
||||||
device_type: str
|
device_type: str
|
||||||
quantity: int = 1
|
quantity: int = 1
|
||||||
component: str = "sensor"
|
component: str = "sensor"
|
||||||
|
# Doc status fields echoed from the original upload so the recompute
|
||||||
|
# preserves checklist state instead of regressing it to defaults
|
||||||
|
csv_visit_date: Optional[str] = None
|
||||||
|
csv_swo_status: Optional[str] = None
|
||||||
|
csv_pecos_verified: Optional[str] = None
|
||||||
|
csv_pa_status: Optional[str] = None
|
||||||
|
csv_diagnosis_on_file: Optional[str] = None
|
||||||
|
csv_transfer_from: Optional[str] = None
|
||||||
|
order_number: Optional[str] = None
|
||||||
|
hcpcs: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
@app.post("/api/confirm-visit")
|
@app.post("/api/confirm-visit")
|
||||||
|
|
@ -534,7 +575,14 @@ async def confirm_visit(
|
||||||
if not success:
|
if not success:
|
||||||
raise HTTPException(status_code=503, detail="Failed to save confirmed visit.")
|
raise HTTPException(status_code=503, detail="Failed to save confirmed visit.")
|
||||||
|
|
||||||
# Recompute coverage with confirmed visit date
|
# Recompute coverage with confirmed visit date, preserving the doc status
|
||||||
|
# fields echoed from the original upload
|
||||||
|
csv_visit = None
|
||||||
|
if body.csv_visit_date:
|
||||||
|
try:
|
||||||
|
csv_visit = date_type.fromisoformat(body.csv_visit_date)
|
||||||
|
except ValueError:
|
||||||
|
csv_visit = None
|
||||||
record = ShipmentRecord(
|
record = ShipmentRecord(
|
||||||
patient_id=body.patient_id,
|
patient_id=body.patient_id,
|
||||||
device_type=body.device_type,
|
device_type=body.device_type,
|
||||||
|
|
@ -542,6 +590,14 @@ async def confirm_visit(
|
||||||
quantity=body.quantity,
|
quantity=body.quantity,
|
||||||
payer=body.payer,
|
payer=body.payer,
|
||||||
component=body.component,
|
component=body.component,
|
||||||
|
csv_visit_date=csv_visit,
|
||||||
|
csv_swo_status=body.csv_swo_status,
|
||||||
|
csv_pecos_verified=body.csv_pecos_verified,
|
||||||
|
csv_pa_status=body.csv_pa_status,
|
||||||
|
csv_diagnosis_on_file=body.csv_diagnosis_on_file,
|
||||||
|
csv_transfer_from=body.csv_transfer_from,
|
||||||
|
order_number=body.order_number,
|
||||||
|
hcpcs=body.hcpcs,
|
||||||
)
|
)
|
||||||
result = calculate_coverage(record, confirmed_visit_date=confirmed)
|
result = calculate_coverage(record, confirmed_visit_date=confirmed)
|
||||||
|
|
||||||
|
|
@ -554,4 +610,4 @@ async def confirm_visit(
|
||||||
detail=f"Visit confirmed {confirmed.isoformat()}",
|
detail=f"Visit confirmed {confirmed.isoformat()}",
|
||||||
)
|
)
|
||||||
|
|
||||||
return _to_record_out(result, record=record)
|
return _to_record_out(result, record=record, confirmed_visit_date=confirmed)
|
||||||
|
|
|
||||||
|
|
@ -117,30 +117,6 @@ DEVICE_MAP: dict[str, str] = {
|
||||||
"op5": "omnipod_5",
|
"op5": "omnipod_5",
|
||||||
}
|
}
|
||||||
|
|
||||||
PAYER_MAP: dict[str, str] = {
|
|
||||||
"medicare part b": "medicare",
|
|
||||||
"medicare part a": "medicare",
|
|
||||||
"medicare advantage": "medicare_advantage", # distinct from medicare FFS
|
|
||||||
"medicare": "medicare",
|
|
||||||
"cms": "medicare",
|
|
||||||
"medicaid": "medicaid",
|
|
||||||
"mcd": "medicaid",
|
|
||||||
"molina": "medicaid",
|
|
||||||
"centene": "medicaid",
|
|
||||||
"wellcare": "medicaid",
|
|
||||||
"bcbs": "commercial",
|
|
||||||
"blue cross": "commercial",
|
|
||||||
"blue shield": "commercial",
|
|
||||||
"aetna": "commercial",
|
|
||||||
"cigna": "commercial",
|
|
||||||
"unitedhealthcare": "commercial",
|
|
||||||
"united health": "commercial",
|
|
||||||
"uhc": "commercial",
|
|
||||||
"humana": "commercial",
|
|
||||||
"anthem": "commercial",
|
|
||||||
"united": "commercial",
|
|
||||||
}
|
|
||||||
|
|
||||||
DATE_FORMATS = [
|
DATE_FORMATS = [
|
||||||
"%Y-%m-%d",
|
"%Y-%m-%d",
|
||||||
"%m/%d/%Y",
|
"%m/%d/%Y",
|
||||||
|
|
@ -199,15 +175,6 @@ def _normalize_device(value: str) -> Optional[str]:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
def _normalize_payer(value: str) -> str:
|
|
||||||
key = _normalize_key(value)
|
|
||||||
# Longest-match first (payer_map keys are already ordered longest first for medicare)
|
|
||||||
for alias, canonical in PAYER_MAP.items():
|
|
||||||
if alias in key:
|
|
||||||
return canonical
|
|
||||||
return "commercial"
|
|
||||||
|
|
||||||
|
|
||||||
def normalize_csv(text: str) -> tuple[list[ShipmentRecord], list[str], dict]:
|
def normalize_csv(text: str) -> tuple[list[ShipmentRecord], list[str], dict]:
|
||||||
"""
|
"""
|
||||||
Parse raw CSV text and return (records, skipped_reasons, mapping_summary).
|
Parse raw CSV text and return (records, skipped_reasons, mapping_summary).
|
||||||
|
|
@ -276,7 +243,9 @@ def normalize_csv(text: str) -> tuple[list[ShipmentRecord], list[str], dict]:
|
||||||
except (ValueError, TypeError):
|
except (ValueError, TypeError):
|
||||||
quantity = 1
|
quantity = 1
|
||||||
|
|
||||||
payer = _normalize_payer(mapped.get("payer", ""))
|
# Raw payer string passes through untouched. coverage_calculator._normalize_payer
|
||||||
|
# is the single normalization point; the raw value survives for display.
|
||||||
|
payer = mapped.get("payer", "").strip()
|
||||||
component = (mapped.get("component", "sensor") or "sensor").lower().strip()
|
component = (mapped.get("component", "sensor") or "sensor").lower().strip()
|
||||||
if component not in ("sensor", "transmitter", "pod"):
|
if component not in ("sensor", "transmitter", "pod"):
|
||||||
component = "sensor"
|
component = "sensor"
|
||||||
|
|
|
||||||
|
|
@ -266,6 +266,43 @@ def calculate_coverage(
|
||||||
coverage_end = record.shipment_date + timedelta(days=total_wear_days)
|
coverage_end = record.shipment_date + timedelta(days=total_wear_days)
|
||||||
days_until_end = (coverage_end - today).days
|
days_until_end = (coverage_end - today).days
|
||||||
|
|
||||||
|
# Catch-all FIRST: patient shows no active-care signals and last shipment was
|
||||||
|
# 12+ months ago. Must precede the supply-lapsed check — a 12-month-stale record
|
||||||
|
# always reads as lapsed, so checking lapsed first made this flag unreachable.
|
||||||
|
# A confirmed or CSV visit date within the last 12 months overrides this check,
|
||||||
|
# treating the patient as still active even if the shipment record is old.
|
||||||
|
NO_RECENT_SHIPMENT_DAYS = 365
|
||||||
|
days_since_shipment = (today - record.shipment_date).days
|
||||||
|
confirmed_visit_within_12m = (
|
||||||
|
confirmed_visit_date is not None
|
||||||
|
and (today - confirmed_visit_date).days < NO_RECENT_SHIPMENT_DAYS
|
||||||
|
)
|
||||||
|
csv_visit_within_12m = (
|
||||||
|
record.csv_visit_date is not None
|
||||||
|
and (today - record.csv_visit_date).days < NO_RECENT_SHIPMENT_DAYS
|
||||||
|
)
|
||||||
|
if (
|
||||||
|
days_since_shipment >= NO_RECENT_SHIPMENT_DAYS
|
||||||
|
and not confirmed_visit_within_12m
|
||||||
|
and not csv_visit_within_12m
|
||||||
|
):
|
||||||
|
_, confidence = _resolve_visit_date(record, confirmed_visit_date)
|
||||||
|
return CoverageResult(
|
||||||
|
patient_id=record.patient_id,
|
||||||
|
device_type=record.device_type,
|
||||||
|
payer=record.payer,
|
||||||
|
component=record.component,
|
||||||
|
last_shipment_date=record.shipment_date,
|
||||||
|
coverage_end_date=coverage_end,
|
||||||
|
next_visit_due_date=None,
|
||||||
|
flag=CoverageFlag.NO_RECENT_SHIPMENT,
|
||||||
|
days_until_coverage_end=days_until_end,
|
||||||
|
days_until_visit_due=None,
|
||||||
|
priority_score=5, # lowest — drops to bottom of worklist sort
|
||||||
|
visit_date_confidence=confidence,
|
||||||
|
rule_version=RULE_VERSION,
|
||||||
|
)
|
||||||
|
|
||||||
# Supply lapsed — coverage cycle ended
|
# Supply lapsed — coverage cycle ended
|
||||||
if days_until_end < 0:
|
if days_until_end < 0:
|
||||||
visit_date, confidence = _resolve_visit_date(record, confirmed_visit_date)
|
visit_date, confidence = _resolve_visit_date(record, confirmed_visit_date)
|
||||||
|
|
@ -305,30 +342,6 @@ def calculate_coverage(
|
||||||
if flag == CoverageFlag.ACTIVE and days_until_end <= refill_window_days:
|
if flag == CoverageFlag.ACTIVE and days_until_end <= refill_window_days:
|
||||||
flag = CoverageFlag.RESUPPLY_READY
|
flag = CoverageFlag.RESUPPLY_READY
|
||||||
|
|
||||||
# Catch-all: patient shows no active-care signals and last shipment was 12+ months ago.
|
|
||||||
# Only applies when no higher-priority flag has fired (flag is ACTIVE or RESUPPLY_READY).
|
|
||||||
# A confirmed visit date within the last 12 months overrides this check, treating the
|
|
||||||
# patient as still active even if the shipment record is old.
|
|
||||||
NO_RECENT_SHIPMENT_DAYS = 365
|
|
||||||
days_since_shipment = (today - record.shipment_date).days
|
|
||||||
confirmed_visit_within_12m = (
|
|
||||||
confirmed_visit_date is not None
|
|
||||||
and (today - confirmed_visit_date).days < NO_RECENT_SHIPMENT_DAYS
|
|
||||||
)
|
|
||||||
csv_visit_within_12m = (
|
|
||||||
record.csv_visit_date is not None
|
|
||||||
and (today - record.csv_visit_date).days < NO_RECENT_SHIPMENT_DAYS
|
|
||||||
)
|
|
||||||
if (
|
|
||||||
flag in (CoverageFlag.ACTIVE, CoverageFlag.RESUPPLY_READY)
|
|
||||||
and days_since_shipment >= NO_RECENT_SHIPMENT_DAYS
|
|
||||||
and not confirmed_visit_within_12m
|
|
||||||
and not csv_visit_within_12m
|
|
||||||
):
|
|
||||||
flag = CoverageFlag.NO_RECENT_SHIPMENT
|
|
||||||
days_until_visit = None
|
|
||||||
priority = 5 # lowest — drops to bottom of worklist sort
|
|
||||||
else:
|
|
||||||
priority = _compute_priority(flag, days_until_visit)
|
priority = _compute_priority(flag, days_until_visit)
|
||||||
|
|
||||||
return CoverageResult(
|
return CoverageResult(
|
||||||
|
|
|
||||||
|
|
@ -94,7 +94,7 @@ export default function Privacy() {
|
||||||
<p className="text-[14px] leading-[1.75] text-[var(--text-secondary,#5A7E7E)]">
|
<p className="text-[14px] leading-[1.75] text-[var(--text-secondary,#5A7E7E)]">
|
||||||
Your data is retained for the duration of your subscription plus 90 days. Upon written request,
|
Your data is retained for the duration of your subscription plus 90 days. Upon written request,
|
||||||
all data associated with your account will be deleted within 10 business days, and we will
|
all data associated with your account will be deleted within 10 business days, and we will
|
||||||
confirm deletion in writing. Audit logs are retained for 7 years for compliance purposes.
|
confirm deletion in writing. Audit logs are retained for 6 years for compliance purposes.
|
||||||
</p>
|
</p>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
|
@ -102,11 +102,15 @@ export default function Privacy() {
|
||||||
<section className="mb-8">
|
<section className="mb-8">
|
||||||
<h2 className="font-heading font-semibold text-[16px] text-[#0A3030] mb-3">HIPAA</h2>
|
<h2 className="font-heading font-semibold text-[16px] text-[#0A3030] mb-3">HIPAA</h2>
|
||||||
<p className="text-[14px] leading-[1.75] text-[var(--text-secondary,#5A7E7E)]">
|
<p className="text-[14px] leading-[1.75] text-[var(--text-secondary,#5A7E7E)]">
|
||||||
Signal processes de-identified operational data. To the extent that any data processed by Signal
|
Signal is designed so that the data it receives is not protected health information
|
||||||
could be considered protected health information under HIPAA, STTIL Solutions has executed a
|
under the Health Insurance Portability and Accountability Act (HIPAA). The patient
|
||||||
Business Associate Agreement (BAA) with its cloud infrastructure providers and operates
|
identifiers your staff upload are your internal account numbers, and the link between
|
||||||
appropriate administrative, physical, and technical safeguards. If you believe a BAA is required
|
those identifiers and any real identity stays with your organization at all times.
|
||||||
for your engagement with Signal, please contact us before uploading any data.
|
Because Signal's architecture is designed to avoid PHI ingestion, a Business Associate
|
||||||
|
Agreement (BAA) may not be required for your engagement. We encourage every customer
|
||||||
|
to consult their own compliance advisors. If your review concludes that a BAA is
|
||||||
|
required for your intended use, contact us before uploading any data and we will
|
||||||
|
review the data scope with you.
|
||||||
</p>
|
</p>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -12,14 +12,17 @@ export default function ConfirmVisitModal({ record, onClose, onConfirmed, onSave
|
||||||
const today = new Date().toISOString().split("T")[0];
|
const today = new Date().toISOString().split("T")[0];
|
||||||
const minDate = "2020-01-01";
|
const minDate = "2020-01-01";
|
||||||
|
|
||||||
// Compute shipment_date from coverage_end_date and days_until_coverage_end
|
// Real shipment date from the backend record. The legacy reconstruction from
|
||||||
|
// coverage_end_date is a fallback for records loaded before last_shipment_date
|
||||||
|
// existed; it never fabricates today's date.
|
||||||
function computeShipmentDate() {
|
function computeShipmentDate() {
|
||||||
|
if (record.last_shipment_date) return record.last_shipment_date;
|
||||||
if (record.coverage_end_date && record.days_until_coverage_end !== undefined) {
|
if (record.coverage_end_date && record.days_until_coverage_end !== undefined) {
|
||||||
const coverageEnd = new Date(record.coverage_end_date);
|
const coverageEnd = new Date(record.coverage_end_date);
|
||||||
const shipmentMs = coverageEnd.getTime() - record.days_until_coverage_end * 86400000;
|
const shipmentMs = coverageEnd.getTime() - record.days_until_coverage_end * 86400000;
|
||||||
return new Date(shipmentMs).toISOString().split("T")[0];
|
return new Date(shipmentMs).toISOString().split("T")[0];
|
||||||
}
|
}
|
||||||
return today;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleValidateAndAdvance() {
|
function handleValidateAndAdvance() {
|
||||||
|
|
@ -32,12 +35,16 @@ export default function ConfirmVisitModal({ record, onClose, onConfirmed, onSave
|
||||||
setError("Visit date cannot be in the future.");
|
setError("Visit date cannot be in the future.");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// Validate: visit must be within 6 months before the order/shipment date
|
// Validate: visit must be within 6 months (183 days, matching the backend)
|
||||||
|
// before the order/shipment date
|
||||||
const shipmentDateStr = computeShipmentDate();
|
const shipmentDateStr = computeShipmentDate();
|
||||||
|
if (!shipmentDateStr) {
|
||||||
|
setError("This record has no shipment date on file. Re-import the CSV and try again.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
const shipmentDateObj = new Date(shipmentDateStr + "T00:00:00");
|
const shipmentDateObj = new Date(shipmentDateStr + "T00:00:00");
|
||||||
const visitDateObj = new Date(visitDate + "T00:00:00");
|
const visitDateObj = new Date(visitDate + "T00:00:00");
|
||||||
const sixMonthsBeforeShipment = new Date(shipmentDateObj);
|
const sixMonthsBeforeShipment = new Date(shipmentDateObj.getTime() - 183 * 86400000);
|
||||||
sixMonthsBeforeShipment.setMonth(sixMonthsBeforeShipment.getMonth() - 6);
|
|
||||||
if (visitDateObj < sixMonthsBeforeShipment) {
|
if (visitDateObj < sixMonthsBeforeShipment) {
|
||||||
setError("Visit date must be within 6 months of the order date.");
|
setError("Visit date must be within 6 months of the order date.");
|
||||||
return;
|
return;
|
||||||
|
|
@ -55,8 +62,18 @@ export default function ConfirmVisitModal({ record, onClose, onConfirmed, onSave
|
||||||
shipment_date: computeShipmentDate(),
|
shipment_date: computeShipmentDate(),
|
||||||
payer: record.payer,
|
payer: record.payer,
|
||||||
device_type: record.device_type,
|
device_type: record.device_type,
|
||||||
quantity: 1,
|
quantity: record.quantity || 1,
|
||||||
component: record.component || "sensor",
|
component: record.component || "sensor",
|
||||||
|
// Echo doc status fields so the backend recompute preserves
|
||||||
|
// checklist state instead of regressing it to defaults
|
||||||
|
csv_visit_date: record.csv_visit_date || null,
|
||||||
|
csv_swo_status: record.csv_swo_status || null,
|
||||||
|
csv_pecos_verified: record.csv_pecos_verified || null,
|
||||||
|
csv_pa_status: record.csv_pa_status || null,
|
||||||
|
csv_diagnosis_on_file: record.csv_diagnosis_on_file || null,
|
||||||
|
csv_transfer_from: record.csv_transfer_from || null,
|
||||||
|
order_number: record.order_number || null,
|
||||||
|
hcpcs: record.hcpcs || null,
|
||||||
}, token);
|
}, token);
|
||||||
onConfirmed(record.patient_id, updated);
|
onConfirmed(record.patient_id, updated);
|
||||||
onSaveSuccess?.({ patient_id: record.patient_id });
|
onSaveSuccess?.({ patient_id: record.patient_id });
|
||||||
|
|
|
||||||
|
|
@ -85,6 +85,17 @@ export function apiRecordToLocal(r) {
|
||||||
is_transfer: r.is_transfer || false,
|
is_transfer: r.is_transfer || false,
|
||||||
doc_state: r.doc_state || null,
|
doc_state: r.doc_state || null,
|
||||||
cascade: r.cascade || [],
|
cascade: r.cascade || [],
|
||||||
|
order_number: r.order_number || null,
|
||||||
|
hcpcs: r.hcpcs || null,
|
||||||
|
recommended_next_step_code: r.recommended_next_step_code || "NO_ACTION_NEEDED",
|
||||||
|
last_shipment_date: r.last_shipment_date || null,
|
||||||
|
quantity: r.quantity || 1,
|
||||||
|
csv_visit_date: r.csv_visit_date || null,
|
||||||
|
csv_swo_status: r.csv_swo_status || null,
|
||||||
|
csv_pecos_verified: r.csv_pecos_verified || null,
|
||||||
|
csv_pa_status: r.csv_pa_status || null,
|
||||||
|
csv_diagnosis_on_file: r.csv_diagnosis_on_file || null,
|
||||||
|
csv_transfer_from: r.csv_transfer_from || null,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,7 @@
|
||||||
export const FLAG = {
|
export const FLAG = {
|
||||||
SUPPLY_LAPSED: "SUPPLY_LAPSED",
|
SUPPLY_LAPSED: "SUPPLY_LAPSED",
|
||||||
VISIT_REQUIRED: "VISIT_REQUIRED",
|
VISIT_REQUIRED: "VISIT_REQUIRED",
|
||||||
|
TRANSFER_PENDING: "TRANSFER_PENDING",
|
||||||
RENEWAL_CRITICAL: "RENEWAL_CRITICAL",
|
RENEWAL_CRITICAL: "RENEWAL_CRITICAL",
|
||||||
RENEWAL_ELEVATED: "RENEWAL_ELEVATED",
|
RENEWAL_ELEVATED: "RENEWAL_ELEVATED",
|
||||||
RENEWAL_SOON: "RENEWAL_SOON",
|
RENEWAL_SOON: "RENEWAL_SOON",
|
||||||
|
|
@ -17,20 +18,23 @@ export const FLAG = {
|
||||||
NO_RECENT_SHIPMENT: "NO_RECENT_SHIPMENT",
|
NO_RECENT_SHIPMENT: "NO_RECENT_SHIPMENT",
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Mirrors FLAG_LABELS in python-backend/api/main.py — keep in sync
|
||||||
const FLAG_LABELS = {
|
const FLAG_LABELS = {
|
||||||
[FLAG.SUPPLY_LAPSED]: "Supply Lapsed",
|
[FLAG.SUPPLY_LAPSED]: "Docs Required",
|
||||||
[FLAG.VISIT_REQUIRED]: "Visit Required",
|
[FLAG.VISIT_REQUIRED]: "Escalate",
|
||||||
[FLAG.RENEWAL_CRITICAL]: "Renewal Due",
|
[FLAG.TRANSFER_PENDING]: "New Patient",
|
||||||
[FLAG.RENEWAL_ELEVATED]: "Renewal Elevated",
|
[FLAG.RENEWAL_CRITICAL]: "Escalate",
|
||||||
[FLAG.RENEWAL_SOON]: "Renewal Soon",
|
[FLAG.RENEWAL_ELEVATED]: "Confirm Appointment",
|
||||||
[FLAG.RESUPPLY_READY]: "Resupply Ready",
|
[FLAG.RENEWAL_SOON]: "Begin Outreach",
|
||||||
[FLAG.ACTIVE]: "Active",
|
[FLAG.RESUPPLY_READY]: "Clear to Ship",
|
||||||
|
[FLAG.ACTIVE]: "On Track",
|
||||||
[FLAG.NO_RECENT_SHIPMENT]: "No Recent Shipment",
|
[FLAG.NO_RECENT_SHIPMENT]: "No Recent Shipment",
|
||||||
};
|
};
|
||||||
|
|
||||||
const FLAG_ACTIONS = {
|
const FLAG_ACTIONS = {
|
||||||
[FLAG.SUPPLY_LAPSED]: "Contact Prescriber",
|
[FLAG.SUPPLY_LAPSED]: "Contact Prescriber",
|
||||||
[FLAG.VISIT_REQUIRED]: "Schedule Visit",
|
[FLAG.VISIT_REQUIRED]: "Schedule Visit",
|
||||||
|
[FLAG.TRANSFER_PENDING]: "Obtain full documentation set (SWO, PECOS, PA, diagnosis) — Signal cannot assess status until records are on file",
|
||||||
[FLAG.RENEWAL_CRITICAL]: "Request Renewal",
|
[FLAG.RENEWAL_CRITICAL]: "Request Renewal",
|
||||||
[FLAG.RENEWAL_ELEVATED]: "Request Renewal",
|
[FLAG.RENEWAL_ELEVATED]: "Request Renewal",
|
||||||
[FLAG.RENEWAL_SOON]: "Plan Renewal",
|
[FLAG.RENEWAL_SOON]: "Plan Renewal",
|
||||||
|
|
@ -82,21 +86,24 @@ function getWearDays(deviceType, component) {
|
||||||
return device[component] ?? null;
|
return device[component] ?? null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function getPayerConfig(payer) {
|
// Mirrors payer_rules.json — every payer type currently uses a 180-day visit
|
||||||
const p = payer.toLowerCase();
|
// renewal and 30-day resupply window. Keep in sync with the backend config.
|
||||||
if (p.includes("medicare")) {
|
function getPayerConfig() {
|
||||||
return { visitRenewalDays: 180, refillWindowDays: 30 };
|
return { visitRenewalDays: 180, refillWindowDays: 30 };
|
||||||
}
|
|
||||||
return { visitRenewalDays: null, refillWindowDays: 30 };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function computePriority(flag, daysUntilEnd) {
|
// Mirrors _compute_priority in python-backend/core/coverage_calculator.py — keep in sync
|
||||||
if (flag === FLAG.SUPPLY_LAPSED) return 1000 + Math.abs(daysUntilEnd);
|
function computePriority(flag, daysUntilVisit) {
|
||||||
if (flag === FLAG.VISIT_REQUIRED) return 1500 + Math.abs(daysUntilEnd);
|
const urgencyDays = daysUntilVisit !== null && daysUntilVisit !== undefined
|
||||||
if (flag === FLAG.RENEWAL_CRITICAL) return 500 + Math.max(0, 90 - daysUntilEnd);
|
? Math.abs(daysUntilVisit)
|
||||||
if (flag === FLAG.RENEWAL_ELEVATED) return 700 + Math.max(0, 90 - daysUntilEnd);
|
: 0;
|
||||||
if (flag === FLAG.RENEWAL_SOON) return 400 + Math.max(0, 90 - daysUntilEnd);
|
if (flag === FLAG.SUPPLY_LAPSED) return 2000 + urgencyDays;
|
||||||
if (flag === FLAG.RESUPPLY_READY) return 200 + Math.max(0, 30 - daysUntilEnd);
|
if (flag === FLAG.TRANSFER_PENDING) return 1800;
|
||||||
|
if (flag === FLAG.VISIT_REQUIRED) return 1500 + urgencyDays;
|
||||||
|
if (flag === FLAG.RENEWAL_CRITICAL) return 1000 + (45 - Math.max(0, urgencyDays));
|
||||||
|
if (flag === FLAG.RENEWAL_ELEVATED) return 700 + (60 - Math.max(0, urgencyDays));
|
||||||
|
if (flag === FLAG.RENEWAL_SOON) return 400 + (90 - Math.max(0, urgencyDays));
|
||||||
|
if (flag === FLAG.RESUPPLY_READY) return 200;
|
||||||
if (flag === FLAG.NO_RECENT_SHIPMENT) return 5;
|
if (flag === FLAG.NO_RECENT_SHIPMENT) return 5;
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
|
|
@ -107,6 +114,19 @@ function computePriority(flag, daysUntilEnd) {
|
||||||
* @returns {{ flag, daysUntilEnd, daysUntilVisit, priority }} or null on error
|
* @returns {{ flag, daysUntilEnd, daysUntilVisit, priority }} or null on error
|
||||||
*/
|
*/
|
||||||
export function calculateCoverage(record) {
|
export function calculateCoverage(record) {
|
||||||
|
// Transferred patients: all docs need verification before anything else
|
||||||
|
const transferFrom = record.transfer_from || record.csv_transfer_from || record.previous_supplier;
|
||||||
|
if (transferFrom && String(transferFrom).trim()) {
|
||||||
|
return {
|
||||||
|
...record,
|
||||||
|
flag: FLAG.TRANSFER_PENDING,
|
||||||
|
daysUntilEnd: 0,
|
||||||
|
daysUntilVisit: null,
|
||||||
|
priority: computePriority(FLAG.TRANSFER_PENDING, null),
|
||||||
|
coverageEndDate: record.shipment_date,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
const wearDays = getWearDays(record.device_type, record.component);
|
const wearDays = getWearDays(record.device_type, record.component);
|
||||||
if (wearDays === null) return null;
|
if (wearDays === null) return null;
|
||||||
|
|
||||||
|
|
@ -115,11 +135,38 @@ export function calculateCoverage(record) {
|
||||||
const coverageEnd = addDays(record.shipment_date, totalWear);
|
const coverageEnd = addDays(record.shipment_date, totalWear);
|
||||||
const daysUntilEnd = daysDiff(coverageEnd, now);
|
const daysUntilEnd = daysDiff(coverageEnd, now);
|
||||||
|
|
||||||
const payerCfg = getPayerConfig(record.payer);
|
const payerCfg = getPayerConfig();
|
||||||
|
|
||||||
|
// Visit date resolution mirrors the backend: explicit visit date column when
|
||||||
|
// present, otherwise estimated as shipment date minus 30 days.
|
||||||
|
const rawVisitDate = record.visit_date || record.csv_visit_date || record.qualifying_visit_date;
|
||||||
|
const visitDate = rawVisitDate
|
||||||
|
? new Date(rawVisitDate + "T00:00:00")
|
||||||
|
: addDays(record.shipment_date, -30);
|
||||||
|
const visitDateValid = !isNaN(visitDate.getTime());
|
||||||
|
|
||||||
|
// Stale-record check FIRST — must precede supply-lapsed, since a 12-month-old
|
||||||
|
// shipment always reads as lapsed. A visit date within 12 months overrides.
|
||||||
|
const NO_RECENT_SHIPMENT_DAYS = 365;
|
||||||
|
const shipmentDate = new Date(record.shipment_date + "T00:00:00");
|
||||||
|
const daysSinceShipment = daysDiff(now, shipmentDate);
|
||||||
|
const visitWithin12m =
|
||||||
|
Boolean(rawVisitDate) && visitDateValid &&
|
||||||
|
daysDiff(now, visitDate) < NO_RECENT_SHIPMENT_DAYS;
|
||||||
|
if (daysSinceShipment >= NO_RECENT_SHIPMENT_DAYS && !visitWithin12m) {
|
||||||
|
return {
|
||||||
|
...record,
|
||||||
|
flag: FLAG.NO_RECENT_SHIPMENT,
|
||||||
|
daysUntilEnd,
|
||||||
|
daysUntilVisit: null,
|
||||||
|
priority: computePriority(FLAG.NO_RECENT_SHIPMENT, null),
|
||||||
|
coverageEndDate: coverageEnd.toISOString().slice(0, 10),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
let daysUntilVisit = null;
|
let daysUntilVisit = null;
|
||||||
if (payerCfg.visitRenewalDays) {
|
if (payerCfg.visitRenewalDays && visitDateValid) {
|
||||||
const visitDue = addDays(record.shipment_date, payerCfg.visitRenewalDays);
|
const visitDue = new Date(visitDate.getTime() + payerCfg.visitRenewalDays * 86400000);
|
||||||
daysUntilVisit = daysDiff(visitDue, now);
|
daysUntilVisit = daysDiff(visitDue, now);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -140,25 +187,12 @@ export function calculateCoverage(record) {
|
||||||
flag = FLAG.ACTIVE;
|
flag = FLAG.ACTIVE;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Catch-all: last shipment was 12+ months ago and no higher-priority flag fired.
|
|
||||||
// Only overrides ACTIVE or RESUPPLY_READY — all urgent flags take precedence.
|
|
||||||
const NO_RECENT_SHIPMENT_DAYS = 365;
|
|
||||||
const shipmentDate = new Date(record.shipment_date + "T00:00:00");
|
|
||||||
const daysSinceShipment = daysDiff(now, shipmentDate);
|
|
||||||
if (
|
|
||||||
(flag === FLAG.ACTIVE || flag === FLAG.RESUPPLY_READY) &&
|
|
||||||
daysSinceShipment >= NO_RECENT_SHIPMENT_DAYS
|
|
||||||
) {
|
|
||||||
flag = FLAG.NO_RECENT_SHIPMENT;
|
|
||||||
daysUntilVisit = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...record,
|
...record,
|
||||||
flag,
|
flag,
|
||||||
daysUntilEnd,
|
daysUntilEnd,
|
||||||
daysUntilVisit,
|
daysUntilVisit,
|
||||||
priority: computePriority(flag, daysUntilEnd),
|
priority: computePriority(flag, daysUntilVisit),
|
||||||
coverageEndDate: coverageEnd.toISOString().slice(0, 10),
|
coverageEndDate: coverageEnd.toISOString().slice(0, 10),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue