27c1680830
- Mark SP4 as fully shipped (batch diff, search, CSV, a11y all landed) - Add SP6 (Inbox) and SP7 (Per-line reconciliation) sections - Add SP6 + SP7 endpoint inventories - Note next up: outbound 837P serializer The workflow-automation plan was authored but never committed; the features it specified have shipped, so commit it for the historical record alongside this README refresh.
2702 lines
78 KiB
Markdown
2702 lines
78 KiB
Markdown
# Sub-project 6 — Workflow Automation & Inbox: Implementation Plan
|
||
|
||
**Date:** 2026-06-20
|
||
**Status:** Ready
|
||
**Branch:** `sp6-workflow-inbox` (created from `main` after SP5 is merged)
|
||
**Spec:** `.superpowers/brainstorm/419-1781997857/spec.md`
|
||
|
||
21 tasks across 8 phases. TDD throughout. Each task ends with a green test run + commit.
|
||
|
||
**Prerequisite:** SP5 (live-tail) must be merged to `main` before this plan runs. The first task verifies this and rebases the branch if needed.
|
||
|
||
**Tech additions:** None — scoring uses stdlib only (`difflib` is *not* needed because patient control number match is exact, not fuzzy). No new pip deps, no new npm deps.
|
||
|
||
---
|
||
|
||
## Task 0: Verify SP5 is on `main` and create work branch
|
||
|
||
**Files:** none
|
||
|
||
- [ ] **Step 1: Confirm SP5 is merged**
|
||
|
||
```bash
|
||
cd /Users/openclaw/dev/cyclone
|
||
git fetch origin
|
||
git log --oneline main -10
|
||
git log --oneline main | grep -E "merge:.*live-tail|feat:.*live-tail|merge.*tail" | head -3
|
||
```
|
||
|
||
Expected: at least one merge commit referencing SP5 / live-tail on `main`. If absent, STOP — do not proceed until SP5 is merged.
|
||
|
||
- [ ] **Step 2: Create the work branch**
|
||
|
||
```bash
|
||
git checkout main
|
||
git pull --ff-only
|
||
git checkout -b sp6-workflow-inbox
|
||
```
|
||
|
||
- [ ] **Step 3: Commit** (empty commit to anchor the branch)
|
||
|
||
```bash
|
||
git commit --allow-empty -m "chore(sp6): start workflow automation & inbox branch"
|
||
```
|
||
|
||
---
|
||
|
||
## Phase 1 — Schema
|
||
|
||
## Task 1: Add `REJECTED` to `ClaimState` enum
|
||
|
||
**Files:**
|
||
- Modify: `backend/src/cyclone/db.py:124-131`
|
||
|
||
- [ ] **Step 1: Add the enum value**
|
||
|
||
Edit `backend/src/cyclone/db.py`, in the `ClaimState` class, add `REJECTED = "rejected"` between `RECEIVED` and `PAID`:
|
||
|
||
```python
|
||
class ClaimState(str, enum.Enum):
|
||
SUBMITTED = "submitted"
|
||
RECEIVED = "received"
|
||
REJECTED = "rejected" # NEW — set by 999 AK9 set-level R/E
|
||
PAID = "paid"
|
||
PARTIAL = "partial"
|
||
DENIED = "denied"
|
||
RECONCILED = "reconciled"
|
||
REVERSED = "reversed"
|
||
```
|
||
|
||
- [ ] **Step 2: Run full backend suite, confirm green**
|
||
|
||
```bash
|
||
cd backend && ../.venv/bin/pytest -q
|
||
```
|
||
|
||
Expected: existing tests still pass (enum addition is additive; no callers reference the new value yet).
|
||
|
||
- [ ] **Step 3: Commit**
|
||
|
||
```bash
|
||
git add backend/src/cyclone/db.py
|
||
git commit -m "feat(sp6): add REJECTED to ClaimState enum"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 2: Migration 0003 — rejection columns + state-history index
|
||
|
||
**Files:**
|
||
- Create: `backend/src/cyclone/migrations/0003_rejections_and_state_history.sql`
|
||
|
||
- [ ] **Step 1: Write the migration**
|
||
|
||
```sql
|
||
-- version: 3
|
||
-- SP6 T2: 999 rejection tracking + state-history index for the Done-today lane.
|
||
|
||
ALTER TABLE claims
|
||
ADD COLUMN rejection_reason TEXT,
|
||
ADD COLUMN rejected_at TIMESTAMP,
|
||
ADD COLUMN resubmit_count INTEGER NOT NULL DEFAULT 0;
|
||
|
||
CREATE INDEX ix_claims_state_changed_at ON claims(state, state_changed_at);
|
||
```
|
||
|
||
- [ ] **Step 2: Run the full suite to trigger the migration**
|
||
|
||
```bash
|
||
cd backend && ../.venv/bin/pytest -q
|
||
```
|
||
|
||
Expected: green. The migration runner picks up `0003_*.sql` automatically and applies it on each test's fresh DB.
|
||
|
||
- [ ] **Step 3: Verify the columns exist on the test DB**
|
||
|
||
```bash
|
||
cd backend && python -c "
|
||
from cyclone.db import init_db, get_sessionmaker
|
||
init_db('sqlite:////tmp/sp6-t2-verify.db')
|
||
S = get_sessionmaker()()
|
||
with S() as s:
|
||
rows = s.execute(s.bind.dialect.compiler()).fetchall()
|
||
" 2>&1 | head -5
|
||
sqlite3 /tmp/sp6-t2-verify.db ".schema claims" | head -25
|
||
```
|
||
|
||
Expected: schema includes `rejection_reason TEXT`, `rejected_at TIMESTAMP`, `resubmit_count INTEGER NOT NULL DEFAULT 0`, and the `ix_claims_state_changed_at` index. Clean up: `rm /tmp/sp6-t2-verify.db`.
|
||
|
||
- [ ] **Step 4: Commit**
|
||
|
||
```bash
|
||
git add backend/src/cyclone/migrations/0003_rejections_and_state_history.sql
|
||
git commit -m "feat(sp6): migration 0003 — rejection columns + state-history index"
|
||
```
|
||
|
||
---
|
||
|
||
## Phase 2 — 999 → Rejected state
|
||
|
||
## Task 3: `apply_999_rejections` helper (TDD)
|
||
|
||
**Files:**
|
||
- Create: `backend/src/cyclone/inbox_state.py`
|
||
- Test: `backend/tests/test_inbox_state.py`
|
||
|
||
- [ ] **Step 1: Write the failing test**
|
||
|
||
```python
|
||
# backend/tests/test_inbox_state.py
|
||
from sqlalchemy import create_engine
|
||
from sqlalchemy.orm import sessionmaker
|
||
|
||
from cyclone.db import Base, Claim, ClaimState, init_db
|
||
from cyclone.inbox_state import apply_999_rejections
|
||
from cyclone.parsers.models_999 import ParseResult999, SetFunctionalGroupResponse, SetAcceptReject
|
||
|
||
|
||
def _make_engine():
|
||
eng = create_engine("sqlite://")
|
||
Base.metadata.create_all(eng)
|
||
return eng
|
||
|
||
|
||
def _make_claim(session, *, claim_id="CLP-1", patient_control_number="PCN-001",
|
||
state=ClaimState.SUBMITTED):
|
||
c = Claim(
|
||
id=claim_id,
|
||
batch_id="B-1",
|
||
patient_control_number=patient_control_number,
|
||
state=state,
|
||
)
|
||
session.add(c)
|
||
session.commit()
|
||
return c
|
||
|
||
|
||
def _make_999(*, claim_refs):
|
||
"""claim_refs: list of (patient_control_number, accept_code)."""
|
||
return ParseResult999(
|
||
envelope=None, # not used by apply_999_rejections
|
||
set_responses=[
|
||
SetFunctionalGroupResponse(
|
||
set_control_number=ref,
|
||
set_accept_reject=SetAcceptReject(code=code),
|
||
ik3_segments=[],
|
||
)
|
||
for ref, code in claim_refs
|
||
],
|
||
)
|
||
|
||
|
||
def test_rejection_moves_submitted_claim_to_rejected_state():
|
||
eng = _make_engine()
|
||
S = sessionmaker(bind=eng)()
|
||
claim = _make_claim(S, patient_control_number="PCN-001")
|
||
|
||
result = apply_999_rejections(
|
||
S,
|
||
_make_999(claim_refs=[("PCN-001", "R")]),
|
||
claim_lookup=lambda pcn: S.query(Claim).filter_by(patient_control_number=pcn).first(),
|
||
)
|
||
|
||
assert result.matched == ["CLP-1"]
|
||
assert result.orphans == []
|
||
S.refresh(claim)
|
||
assert claim.state == ClaimState.REJECTED
|
||
assert claim.rejected_at is not None
|
||
assert "999" in claim.rejection_reason
|
||
|
||
|
||
def test_accepted_set_leaves_claim_alone():
|
||
eng = _make_engine()
|
||
S = sessionmaker(bind=eng)()
|
||
claim = _make_claim(S, patient_control_number="PCN-002")
|
||
|
||
result = apply_999_rejections(
|
||
S,
|
||
_make_999(claim_refs=[("PCN-002", "A")]),
|
||
claim_lookup=lambda pcn: S.query(Claim).filter_by(patient_control_number=pcn).first(),
|
||
)
|
||
|
||
assert result.matched == []
|
||
S.refresh(claim)
|
||
assert claim.state == ClaimState.SUBMITTED
|
||
assert claim.rejected_at is None
|
||
|
||
|
||
def test_unknown_pcn_recorded_as_orphan_not_error():
|
||
eng = _make_engine()
|
||
S = sessionmaker(bind=eng)()
|
||
|
||
result = apply_999_rejections(
|
||
S,
|
||
_make_999(claim_refs=[("PCN-NONEXISTENT", "R")]),
|
||
claim_lookup=lambda pcn: S.query(Claim).filter_by(patient_control_number=pcn).first(),
|
||
)
|
||
|
||
assert result.matched == []
|
||
assert result.orphans == ["PCN-NONEXISTENT"]
|
||
|
||
|
||
def test_already_rejected_claim_is_idempotent():
|
||
eng = _make_engine()
|
||
S = sessionmaker(bind=eng)()
|
||
claim = _make_claim(S, patient_control_number="PCN-003", state=ClaimState.REJECTED)
|
||
|
||
result = apply_999_rejections(
|
||
S,
|
||
_make_999(claim_refs=[("PCN-003", "R")]),
|
||
claim_lookup=lambda pcn: S.query(Claim).filter_by(patient_control_number=pcn).first(),
|
||
)
|
||
|
||
assert result.matched == [] # no-op
|
||
S.refresh(claim)
|
||
assert claim.state == ClaimState.REJECTED
|
||
```
|
||
|
||
- [ ] **Step 2: Run the test, confirm FAIL**
|
||
|
||
```bash
|
||
cd backend && ../.venv/bin/pytest tests/test_inbox_state.py -q
|
||
```
|
||
|
||
Expected: `ModuleNotFoundError: No module named 'cyclone.inbox_state'`.
|
||
|
||
- [ ] **Step 3: Implement the module**
|
||
|
||
```python
|
||
# backend/src/cyclone/inbox_state.py
|
||
"""999 ACK → claim state transitions for the Inbox Rejected lane."""
|
||
from __future__ import annotations
|
||
|
||
from dataclasses import dataclass, field
|
||
from datetime import datetime, timezone
|
||
from typing import Callable
|
||
|
||
from sqlalchemy.orm import Session
|
||
|
||
from cyclone.db import Claim, ClaimState
|
||
|
||
|
||
@dataclass
|
||
class Apply999Result:
|
||
matched: list[str] = field(default_factory=list) # claim ids transitioned
|
||
orphans: list[str] = field(default_factory=list) # PCNs we couldn't resolve
|
||
|
||
|
||
def _build_reason(code: str, ik3_count: int) -> str:
|
||
parts = [f"999 AK9 set-level {code}"]
|
||
if ik3_count:
|
||
parts.append(f"{ik3_count} IK3 segment error(s)")
|
||
return "; ".join(parts)
|
||
|
||
|
||
def apply_999_rejections(
|
||
session: Session,
|
||
parsed_999,
|
||
*,
|
||
claim_lookup: Callable[[str], Claim | None],
|
||
) -> Apply999Result:
|
||
"""For each set response with code R or E, look up the matching claim and
|
||
move it to REJECTED. Idempotent on already-rejected claims.
|
||
|
||
Args:
|
||
session: SQLAlchemy session.
|
||
parsed_999: a ParseResult999 (or any object with .set_responses).
|
||
claim_lookup: callable from patient_control_number → Claim or None.
|
||
|
||
Returns:
|
||
Apply999Result with lists of matched claim ids and orphan PCNs.
|
||
"""
|
||
result = Apply999Result()
|
||
now = datetime.now(timezone.utc)
|
||
|
||
for sr in parsed_999.set_responses:
|
||
code = sr.set_accept_reject.code
|
||
if code not in ("R", "E"):
|
||
continue
|
||
|
||
claim = claim_lookup(sr.set_control_number)
|
||
if claim is None:
|
||
result.orphans.append(sr.set_control_number)
|
||
continue
|
||
|
||
if claim.state == ClaimState.REJECTED:
|
||
# Idempotent: don't double-mutate.
|
||
continue
|
||
|
||
claim.state = ClaimState.REJECTED
|
||
claim.rejected_at = now
|
||
claim.rejection_reason = _build_reason(code, len(sr.ik3_segments or []))
|
||
result.matched.append(claim.id)
|
||
|
||
if result.matched or result.orphans:
|
||
session.commit()
|
||
|
||
return result
|
||
```
|
||
|
||
- [ ] **Step 4: Run the test, confirm PASS**
|
||
|
||
```bash
|
||
cd backend && ../.venv/bin/pytest tests/test_inbox_state.py -v
|
||
```
|
||
|
||
Expected: 4 passed.
|
||
|
||
- [ ] **Step 5: Run full backend suite, confirm still green**
|
||
|
||
```bash
|
||
cd backend && ../.venv/bin/pytest -q
|
||
```
|
||
|
||
- [ ] **Step 6: Commit**
|
||
|
||
```bash
|
||
git add backend/src/cyclone/inbox_state.py backend/tests/test_inbox_state.py
|
||
git commit -m "feat(sp6): apply_999_rejections state helper"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 4: Wire 999 ingest → `apply_999_rejections` (TDD)
|
||
|
||
**Files:**
|
||
- Modify: `backend/src/cyclone/api.py:569-640`
|
||
- Modify: `backend/tests/conftest.py` (only if needed for the event_bus fixture)
|
||
- Test: `backend/tests/test_999_rejected_state.py`
|
||
|
||
- [ ] **Step 1: Write the failing test**
|
||
|
||
```python
|
||
# backend/tests/test_999_rejected_state.py
|
||
import io
|
||
import os
|
||
import tempfile
|
||
|
||
from fastapi.testclient import TestClient
|
||
|
||
from cyclone.api import app
|
||
from cyclone.db import init_db, Claim, ClaimState, get_sessionmaker
|
||
|
||
|
||
def _seed_claim(*, claim_id="CLP-1", patient_control_number="PCN-001"):
|
||
S = get_sessionmaker()()
|
||
with S() as s:
|
||
s.add(Claim(id=claim_id, batch_id="B-1",
|
||
patient_control_number=patient_control_number,
|
||
state=ClaimState.SUBMITTED))
|
||
s.commit()
|
||
|
||
|
||
def test_999_set_rejection_moves_claim_to_rejected_state(client: TestClient):
|
||
_seed_claim()
|
||
|
||
# Minimal 999: 1 set, accept_reject=R, set_control_number matches the PCN.
|
||
raw = (
|
||
"ISA*00* *00* *ZZ*PAYER01 *ZZ*PROVIDER01 "
|
||
f"*250615*0900*^*00501*000000001*0*P*:~"
|
||
"GS*FA*PAYER01*PROVIDER01*20250615*0900*1*X*005010X231A1~"
|
||
"ST*999*0001~"
|
||
"AK1*HC*0001~"
|
||
f"AK2*PCN-001*0001~"
|
||
"IK3*CLM*001*8~"
|
||
"IK4*2**7~"
|
||
"IK5*R*5~"
|
||
"AK9*R*1*1*0~"
|
||
"SE*8*0001~"
|
||
"GE*1*1~"
|
||
"IEA*0*000000001~"
|
||
)
|
||
|
||
r = client.post(
|
||
"/api/parse-999",
|
||
files={"file": ("sample.999", io.BytesIO(raw.encode()), "text/plain")},
|
||
)
|
||
assert r.status_code == 200, r.text
|
||
|
||
S = get_sessionmaker()()
|
||
with S() as s:
|
||
c = s.get(Claim, "CLP-1")
|
||
assert c.state == ClaimState.REJECTED
|
||
assert c.rejected_at is not None
|
||
assert "999" in c.rejection_reason
|
||
```
|
||
|
||
- [ ] **Step 2: Run, confirm FAIL**
|
||
|
||
```bash
|
||
cd backend && ../.venv/bin/pytest tests/test_999_rejected_state.py -v
|
||
```
|
||
|
||
Expected: FAIL — claim state stays `submitted`.
|
||
|
||
- [ ] **Step 3: Wire the helper into the endpoint**
|
||
|
||
In `backend/src/cyclone/api.py`, after `result = parse_999_text(text, ...)` and before `store.add_ack(...)`, add:
|
||
|
||
```python
|
||
from cyclone.inbox_state import apply_999_rejections
|
||
from cyclone.db import Claim
|
||
```
|
||
|
||
Then, just before the `store.add_ack(...)` call, insert:
|
||
|
||
```python
|
||
# SP6 T4: move claims whose 999 set was rejected into ClaimState.REJECTED.
|
||
S = get_sessionmaker()()
|
||
with S() as session:
|
||
def _lookup(pcn: str):
|
||
return session.query(Claim).filter_by(patient_control_number=pcn).first()
|
||
_rejection_result = apply_999_rejections(session, result, claim_lookup=_lookup)
|
||
if _rejection_result.matched:
|
||
# Publish events so the Inbox tail refetches.
|
||
bus = _get_event_bus()
|
||
for cid in _rejection_result.matched:
|
||
bus.publish("claim.rejected", {"claim_id": cid})
|
||
if _rejection_result.orphans:
|
||
log.warning("999 had %d orphan set refs: %s",
|
||
len(_rejection_result.orphans),
|
||
_rejection_result.orphans[:5])
|
||
```
|
||
|
||
(`get_sessionmaker`, `_get_event_bus`, and `log` are already in scope in `api.py`.)
|
||
|
||
- [ ] **Step 4: Run, confirm PASS**
|
||
|
||
```bash
|
||
cd backend && ../.venv/bin/pytest tests/test_999_rejected_state.py -v
|
||
```
|
||
|
||
Expected: 1 passed.
|
||
|
||
- [ ] **Step 5: Full suite**
|
||
|
||
```bash
|
||
cd backend && ../.venv/bin/pytest -q
|
||
```
|
||
|
||
Expected: all green.
|
||
|
||
- [ ] **Step 6: Commit**
|
||
|
||
```bash
|
||
git add backend/src/cyclone/api.py backend/tests/test_999_rejected_state.py
|
||
git commit -m "feat(sp6): wire 999 ingest → claim rejected state"
|
||
```
|
||
|
||
---
|
||
|
||
## Phase 3 — Scoring + lane computation
|
||
|
||
## Task 5: Scoring module (TDD)
|
||
|
||
**Files:**
|
||
- Create: `backend/src/cyclone/scoring.py`
|
||
- Test: `backend/tests/test_scoring.py`
|
||
|
||
- [ ] **Step 1: Write the failing test**
|
||
|
||
```python
|
||
# backend/tests/test_scoring.py
|
||
from dataclasses import dataclass
|
||
from datetime import date
|
||
from decimal import Decimal
|
||
|
||
import pytest
|
||
|
||
from cyclone.scoring import ScoreBreakdown, score_pair
|
||
|
||
|
||
@dataclass
|
||
class FakeClaim:
|
||
patient_control_number: str = "PCN-001"
|
||
service_date_from: date | None = date(2026, 6, 10)
|
||
charge_amount: Decimal = Decimal("100.00")
|
||
provider_npi: str | None = "1234567890"
|
||
|
||
|
||
@dataclass
|
||
class FakeRemit:
|
||
patient_control_number: str = "PCN-001"
|
||
service_date: date | None = date(2026, 6, 10)
|
||
charge_amount: Decimal = Decimal("100.00")
|
||
rendering_provider_npi: str | None = "1234567890"
|
||
|
||
|
||
def test_perfect_match_scores_100():
|
||
s = score_pair(FakeClaim(), FakeRemit())
|
||
assert s.total == 100
|
||
assert s.patient == 1.0
|
||
assert s.date == 1.0
|
||
assert s.amount == 1.0
|
||
assert s.provider == 1.0
|
||
|
||
|
||
def test_date_within_3_days_scores_partial():
|
||
s = score_pair(
|
||
FakeClaim(service_date_from=date(2026, 6, 10)),
|
||
FakeRemit(service_date=date(2026, 6, 12)),
|
||
)
|
||
assert s.date == pytest.approx(1 - 2/3) # 0.333
|
||
|
||
|
||
def test_date_beyond_3_days_scores_zero():
|
||
s = score_pair(
|
||
FakeClaim(service_date_from=date(2026, 6, 10)),
|
||
FakeRemit(service_date=date(2026, 6, 14)),
|
||
)
|
||
assert s.date == 0.0
|
||
|
||
|
||
def test_amount_within_10pct_scores_partial():
|
||
s = score_pair(
|
||
FakeClaim(charge_amount=Decimal("100.00")),
|
||
FakeRemit(charge_amount=Decimal("108.00")),
|
||
)
|
||
assert s.amount == pytest.approx(1 - 8/10)
|
||
|
||
|
||
def test_amount_beyond_10pct_scores_zero():
|
||
s = score_pair(
|
||
FakeClaim(charge_amount=Decimal("100.00")),
|
||
FakeRemit(charge_amount=Decimal("120.00")),
|
||
)
|
||
assert s.amount == 0.0
|
||
|
||
|
||
def test_patient_normalized_exact_match():
|
||
s = score_pair(
|
||
FakeClaim(patient_control_number="PCN-001"),
|
||
FakeRemit(patient_control_number=" pcn-001 "),
|
||
)
|
||
assert s.patient == 1.0
|
||
|
||
|
||
def test_patient_mismatch_scores_zero():
|
||
s = score_pair(
|
||
FakeClaim(patient_control_number="PCN-001"),
|
||
FakeRemit(patient_control_number="PCN-002"),
|
||
)
|
||
assert s.patient == 0.0
|
||
|
||
|
||
def test_missing_provider_npi_scores_zero():
|
||
s = score_pair(
|
||
FakeClaim(provider_npi=None),
|
||
FakeRemit(rendering_provider_npi="1234567890"),
|
||
)
|
||
assert s.provider == 0.0
|
||
|
||
|
||
def test_missing_service_date_scores_zero():
|
||
s = score_pair(
|
||
FakeClaim(service_date_from=None),
|
||
FakeRemit(service_date=date(2026, 6, 10)),
|
||
)
|
||
assert s.date == 0.0
|
||
|
||
|
||
def test_threshold_boundaries():
|
||
"""Tier boundaries: ≥75 strong, 50–74 weak, <50 hidden."""
|
||
# All fields 0 except amount = 0.75 (exact) → 15 pts → total 15 → hidden
|
||
s = score_pair(
|
||
FakeClaim(
|
||
patient_control_number="X", # 0
|
||
service_date_from=date(2026, 6, 10),
|
||
charge_amount=Decimal("100.00"),
|
||
provider_npi="1234567890",
|
||
),
|
||
FakeRemit(
|
||
patient_control_number="Y", # 0
|
||
service_date=date(2026, 6, 10),
|
||
charge_amount=Decimal("100.00"),
|
||
rendering_provider_npi="1234567890",
|
||
),
|
||
)
|
||
assert 50 <= s.total < 75 # weak tier
|
||
```
|
||
|
||
- [ ] **Step 2: Run, confirm FAIL**
|
||
|
||
```bash
|
||
cd backend && ../.venv/bin/pytest tests/test_scoring.py -q
|
||
```
|
||
|
||
- [ ] **Step 3: Implement**
|
||
|
||
```python
|
||
# backend/src/cyclone/scoring.py
|
||
"""Transparent 4-field weighted scoring for Inbox candidate pairs.
|
||
|
||
Weights (sum to 100):
|
||
- patient control number match (exact, normalized) 40
|
||
- service date proximity (±3 days linear decay) 25
|
||
- charge amount proximity (±10% linear decay) 20
|
||
- provider NPI exact match 15
|
||
"""
|
||
from __future__ import annotations
|
||
|
||
from dataclasses import dataclass
|
||
from datetime import date
|
||
from decimal import Decimal
|
||
from typing import Protocol
|
||
|
||
|
||
class _ScorableClaim(Protocol):
|
||
patient_control_number: str
|
||
service_date_from: date | None
|
||
charge_amount: Decimal
|
||
provider_npi: str | None
|
||
|
||
|
||
class _ScorableRemit(Protocol):
|
||
patient_control_number: str
|
||
service_date: date | None
|
||
charge_amount: Decimal
|
||
rendering_provider_npi: str | None
|
||
|
||
|
||
@dataclass(frozen=True)
|
||
class ScoreBreakdown:
|
||
patient: float
|
||
date: float
|
||
amount: float
|
||
provider: float
|
||
|
||
@property
|
||
def total(self) -> int:
|
||
return round(
|
||
self.patient * 40
|
||
+ self.date * 25
|
||
+ self.amount * 20
|
||
+ self.provider * 15
|
||
)
|
||
|
||
@property
|
||
def tier(self) -> str:
|
||
if self.total >= 75:
|
||
return "strong"
|
||
if self.total >= 50:
|
||
return "weak"
|
||
return "hidden"
|
||
|
||
|
||
def _norm_pcn(s: str) -> str:
|
||
return (s or "").strip().lower().lstrip("0")
|
||
|
||
|
||
def _date_score(claim_date: date | None, remit_date: date | None) -> float:
|
||
if claim_date is None or remit_date is None:
|
||
return 0.0
|
||
delta = abs((claim_date - remit_date).days)
|
||
if delta > 3:
|
||
return 0.0
|
||
return 1.0 - (delta / 3.0)
|
||
|
||
|
||
def _amount_score(claim_amt: Decimal, remit_amt: Decimal) -> float:
|
||
if claim_amt <= 0:
|
||
return 0.0
|
||
diff = abs(claim_amt - remit_amt)
|
||
threshold = claim_amt * Decimal("0.10")
|
||
if diff > threshold:
|
||
return 0.0
|
||
# Normalize diff within threshold to 0..1
|
||
return float(1.0 - (diff / threshold))
|
||
|
||
|
||
def _provider_score(claim_npi: str | None, remit_npi: str | None) -> float:
|
||
if not claim_npi or not remit_npi:
|
||
return 0.0
|
||
return 1.0 if claim_npi.strip() == remit_npi.strip() else 0.0
|
||
|
||
|
||
def score_pair(claim: _ScorableClaim, remit: _ScorableRemit) -> ScoreBreakdown:
|
||
return ScoreBreakdown(
|
||
patient=1.0 if _norm_pcn(claim.patient_control_number) == _norm_pcn(remit.patient_control_number) and _norm_pcn(claim.patient_control_number) else 0.0,
|
||
date=_date_score(claim.service_date_from, remit.service_date),
|
||
amount=_amount_score(claim.charge_amount, remit.charge_amount),
|
||
provider=_provider_score(claim.provider_npi, remit.rendering_provider_npi),
|
||
)
|
||
```
|
||
|
||
- [ ] **Step 4: Run, confirm PASS**
|
||
|
||
```bash
|
||
cd backend && ../.venv/bin/pytest tests/test_scoring.py -v
|
||
```
|
||
|
||
Expected: 10 passed.
|
||
|
||
- [ ] **Step 5: Commit**
|
||
|
||
```bash
|
||
git add backend/src/cyclone/scoring.py backend/tests/test_scoring.py
|
||
git commit -m "feat(sp6): 4-field weighted scoring module"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 6: Lane computation module (TDD)
|
||
|
||
**Files:**
|
||
- Create: `backend/src/cyclone/inbox_lanes.py`
|
||
- Test: `backend/tests/test_inbox_lanes.py`
|
||
|
||
- [ ] **Step 1: Write the failing test**
|
||
|
||
```python
|
||
# backend/tests/test_inbox_lanes.py
|
||
from datetime import datetime, timedelta, timezone
|
||
from decimal import Decimal
|
||
|
||
import pytest
|
||
|
||
from cyclone.db import init_db, Base, Claim, ClaimState, Remittance, get_sessionmaker
|
||
from cyclone.inbox_lanes import compute_lanes
|
||
|
||
|
||
@pytest.fixture
|
||
def session():
|
||
eng = init_db("sqlite://")
|
||
S = get_sessionmaker()()
|
||
yield S
|
||
|
||
|
||
def _add_claim(S, *, claim_id, pcn="PCN", state=ClaimState.SUBMITTED,
|
||
charge=Decimal("100.00"), provider_npi="1234567890",
|
||
service_date=None, payer_id="PAYER-A"):
|
||
with S() as s:
|
||
c = Claim(
|
||
id=claim_id,
|
||
batch_id="B-1",
|
||
patient_control_number=pcn,
|
||
state=state,
|
||
charge_amount=charge,
|
||
provider_npi=provider_npi,
|
||
payer_id=payer_id,
|
||
service_date_from=service_date,
|
||
)
|
||
s.add(c)
|
||
s.commit()
|
||
|
||
|
||
def _add_remit(S, *, remit_id, pcn="PCN", charge=Decimal("100.00"),
|
||
rendering_npi="1234567890", service_date=None, payer_id="PAYER-A",
|
||
claim_id=None):
|
||
with S() as s:
|
||
r = Remittance(
|
||
id=remit_id,
|
||
batch_id="B-1",
|
||
payer_claim_control_number=pcn,
|
||
charge_amount=charge,
|
||
claim_id=claim_id,
|
||
)
|
||
# Store the auxiliary fields via raw_json for round-trip; the parser
|
||
# already populates these on a real parse.
|
||
r.raw_json = {
|
||
"patient_control_number": pcn,
|
||
"service_date": service_date.isoformat() if service_date else None,
|
||
"rendering_provider_npi": rendering_npi,
|
||
"payer_id": payer_id,
|
||
}
|
||
s.add(r)
|
||
s.commit()
|
||
|
||
|
||
def test_rejected_lane_includes_rejected_claims(session):
|
||
_add_claim(session, claim_id="C1", state=ClaimState.REJECTED)
|
||
lanes = compute_lanes(session, dismissed_pairs=set())
|
||
assert "C1" in {r["id"] for r in lanes.rejected}
|
||
|
||
|
||
def test_unmatched_lane_includes_submitted_claims_with_no_remit(session):
|
||
_add_claim(session, claim_id="C1", state=ClaimState.SUBMITTED)
|
||
lanes = compute_lanes(session, dismissed_pairs=set())
|
||
assert "C1" in {r["id"] for r in lanes.unmatched}
|
||
|
||
|
||
def test_unmatched_remit_appears_with_candidates(session):
|
||
_add_claim(session, claim_id="C1", pcn="PCN-1", service_date=None)
|
||
_add_remit(session, remit_id="R1", pcn="PCN-1") # exact match → candidate
|
||
lanes = compute_lanes(session, dismissed_pairs=set())
|
||
assert any(r["id"] == "R1" for r in lanes.candidates)
|
||
|
||
|
||
def test_candidate_below_threshold_is_hidden(session):
|
||
_add_claim(session, claim_id="C1", pcn="A", charge=Decimal("100.00"))
|
||
_add_remit(session, remit_id="R1", pcn="Z",
|
||
charge=Decimal("999.00")) # everything mismatched
|
||
lanes = compute_lanes(session, dismissed_pairs=set())
|
||
assert not any(r["id"] == "R1" for r in lanes.candidates)
|
||
|
||
|
||
def test_dismissed_pair_excluded_from_candidates(session):
|
||
_add_claim(session, claim_id="C1", pcn="PCN-1")
|
||
_add_remit(session, remit_id="R1", pcn="PCN-1")
|
||
lanes = compute_lanes(session, dismissed_pairs={frozenset({"C1", "R1"})})
|
||
assert not any(r["id"] == "R1" for r in lanes.candidates)
|
||
|
||
|
||
def test_done_today_includes_recent_terminal_states(session):
|
||
# Need a recent state_changed_at. Set it explicitly.
|
||
with session() as s:
|
||
s.add(Claim(id="C1", batch_id="B-1", patient_control_number="X",
|
||
state=ClaimState.PAID,
|
||
state_changed_at=datetime.now(timezone.utc) - timedelta(hours=2)))
|
||
s.commit()
|
||
_add_claim(session, claim_id="C2", state=ClaimState.PAID)
|
||
# Force C2's state_changed_at to be old.
|
||
with session() as s:
|
||
c = s.get(Claim, "C2")
|
||
c.state_changed_at = datetime.now(timezone.utc) - timedelta(hours=30)
|
||
s.commit()
|
||
|
||
lanes = compute_lanes(session, dismissed_pairs=set())
|
||
ids = {r["id"] for r in lanes.done_today}
|
||
assert "C1" in ids
|
||
assert "C2" not in ids
|
||
```
|
||
|
||
- [ ] **Step 2: Run, confirm FAIL**
|
||
|
||
```bash
|
||
cd backend && ../.venv/bin/pytest tests/test_inbox_lanes.py -q
|
||
```
|
||
|
||
Expected: ModuleNotFoundError for `cyclone.inbox_lanes`.
|
||
|
||
- [ ] **Step 3: Implement the module**
|
||
|
||
```python
|
||
# backend/src/cyclone/inbox_lanes.py
|
||
"""Compute the four Inbox lanes from the DB on read."""
|
||
from __future__ import annotations
|
||
|
||
from dataclasses import dataclass, field
|
||
from datetime import datetime, timedelta, timezone
|
||
from typing import Iterable
|
||
|
||
from sqlalchemy.orm import Session
|
||
|
||
from cyclone.db import Claim, ClaimState, Remittance
|
||
from cyclone.scoring import score_pair, ScoreBreakdown
|
||
|
||
|
||
@dataclass
|
||
class Lanes:
|
||
rejected: list[dict] = field(default_factory=list)
|
||
candidates: list[dict] = field(default_factory=list)
|
||
unmatched: list[dict] = field(default_factory=list)
|
||
done_today: list[dict] = field(default_factory=list)
|
||
|
||
|
||
def _claim_to_row(c: Claim, *, kind: str, score: ScoreBreakdown | None = None) -> dict:
|
||
return {
|
||
"id": c.id,
|
||
"kind": kind,
|
||
"patient_control_number": c.patient_control_number,
|
||
"charge_amount": float(c.charge_amount),
|
||
"payer_id": c.payer_id,
|
||
"provider_npi": c.provider_npi,
|
||
"state": c.state.value if hasattr(c.state, "value") else str(c.state),
|
||
"rejection_reason": c.rejection_reason,
|
||
"rejected_at": c.rejected_at.isoformat() if c.rejected_at else None,
|
||
"service_date_from": c.service_date_from.isoformat() if c.service_date_from else None,
|
||
"score": score.total if score else None,
|
||
"score_tier": score.tier if score else None,
|
||
"score_breakdown": {
|
||
"patient": score.patient,
|
||
"date": score.date,
|
||
"amount": score.amount,
|
||
"provider": score.provider,
|
||
} if score else None,
|
||
}
|
||
|
||
|
||
def _remit_to_row(r: Remittance, *, candidates: list[tuple[Claim, ScoreBreakdown]]) -> dict:
|
||
raw = r.raw_json or {}
|
||
return {
|
||
"id": r.id,
|
||
"kind": "remit",
|
||
"payer_claim_control_number": r.payer_claim_control_number,
|
||
"charge_amount": float(r.charge_amount),
|
||
"payer_id": raw.get("payer_id"),
|
||
"rendering_provider_npi": raw.get("rendering_provider_npi"),
|
||
"service_date": raw.get("service_date"),
|
||
"candidates": [
|
||
{
|
||
"claim_id": c.id,
|
||
"score": s.total,
|
||
"tier": s.tier,
|
||
"breakdown": {
|
||
"patient": s.patient, "date": s.date,
|
||
"amount": s.amount, "provider": s.provider,
|
||
},
|
||
}
|
||
for c, s in candidates[:5]
|
||
],
|
||
}
|
||
|
||
|
||
def _remit_score_fields(r: Remittance) -> dict:
|
||
raw = r.raw_json or {}
|
||
# The 835 parser stores fields on the ORM model when present. For the
|
||
# fields not on the model (service_date, rendering_provider_npi, payer_id,
|
||
# patient_control_number), pull from raw_json.
|
||
return {
|
||
"patient_control_number": raw.get("patient_control_number")
|
||
or r.payer_claim_control_number,
|
||
"service_date": raw.get("service_date"),
|
||
"charge_amount": r.charge_amount,
|
||
"rendering_provider_npi": raw.get("rendering_provider_npi"),
|
||
}
|
||
|
||
|
||
class _RemitScoringShim:
|
||
"""Adapt a Remittance to the duck-typed protocol score_pair expects."""
|
||
def __init__(self, r: Remittance):
|
||
f = _remit_score_fields(r)
|
||
self.patient_control_number = f["patient_control_number"]
|
||
self.service_date = _parse_iso_date(f["service_date"])
|
||
self.charge_amount = f["charge_amount"]
|
||
self.rendering_provider_npi = f["rendering_provider_npi"]
|
||
|
||
|
||
def _parse_iso_date(s: str | None):
|
||
if not s:
|
||
return None
|
||
try:
|
||
return datetime.fromisoformat(s).date()
|
||
except (ValueError, TypeError):
|
||
return None
|
||
|
||
|
||
def compute_lanes(session: Session, *, dismissed_pairs: Iterable[frozenset]) -> Lanes:
|
||
lanes = Lanes()
|
||
dismissed = set(dismissed_pairs)
|
||
|
||
# --- Rejected ---
|
||
for c in session.query(Claim).filter(Claim.state == ClaimState.REJECTED).all():
|
||
lanes.rejected.append(_claim_to_row(c, kind="claim"))
|
||
|
||
# --- Done today ---
|
||
cutoff = datetime.now(timezone.utc) - timedelta(hours=24)
|
||
terminal_states = {
|
||
ClaimState.PAID, ClaimState.PARTIAL, ClaimState.DENIED,
|
||
ClaimState.RECONCILED, ClaimState.REVERSED,
|
||
}
|
||
for c in session.query(Claim).filter(
|
||
Claim.state.in_(terminal_states),
|
||
Claim.state_changed_at >= cutoff,
|
||
).all():
|
||
lanes.done_today.append(_claim_to_row(c, kind="claim"))
|
||
|
||
# --- Unmatched (claims still submitted) ---
|
||
for c in session.query(Claim).filter(Claim.state == ClaimState.SUBMITTED).all():
|
||
lanes.unmatched.append(_claim_to_row(c, kind="claim"))
|
||
|
||
# --- Candidates (remits not yet matched, with scoreable claims) ---
|
||
unmatched_remits = session.query(Remittance).filter(
|
||
Remittance.claim_id.is_(None),
|
||
).all()
|
||
|
||
submitted_claims = {
|
||
c.patient_control_number: c
|
||
for c in session.query(Claim).filter(Claim.state == ClaimState.SUBMITTED).all()
|
||
}
|
||
|
||
for r in unmatched_remits:
|
||
if r.claim_id:
|
||
continue
|
||
candidates: list[tuple[Claim, ScoreBreakdown]] = []
|
||
shim = _RemitScoringShim(r)
|
||
for c in submitted_claims.values():
|
||
if c.payer_id and r.raw_json and (r.raw_json.get("payer_id") != c.payer_id):
|
||
continue
|
||
score = score_pair(c, shim)
|
||
if score.tier == "hidden":
|
||
continue
|
||
if frozenset({c.id, r.id}) in dismissed:
|
||
continue
|
||
candidates.append((c, score))
|
||
candidates.sort(key=lambda cs: -cs[1].total)
|
||
if not candidates:
|
||
continue
|
||
lanes.candidates.append(_remit_to_row(r, candidates=candidates))
|
||
|
||
return lanes
|
||
```
|
||
|
||
- [ ] **Step 4: Run, confirm PASS**
|
||
|
||
```bash
|
||
cd backend && ../.venv/bin/pytest tests/test_inbox_lanes.py -v
|
||
```
|
||
|
||
Expected: 6 passed (some may need adjustment depending on `state_changed_at` defaults in your model — see note in commit step).
|
||
|
||
- [ ] **Step 5: Run full suite**
|
||
|
||
```bash
|
||
cd backend && ../.venv/bin/pytest -q
|
||
```
|
||
|
||
Expected: green. If `Claim.state_changed_at` doesn't exist on the ORM model, add it in `db.py` (mapped `DateTime(timezone=True)`, server default `now()`) via a follow-up commit — but most likely it already exists from SP3/SP4.
|
||
|
||
- [ ] **Step 6: Commit**
|
||
|
||
```bash
|
||
git add backend/src/cyclone/inbox_lanes.py backend/tests/test_inbox_lanes.py
|
||
git commit -m "feat(sp6): compute_lanes — 4 lanes on read with scoring"
|
||
```
|
||
|
||
---
|
||
|
||
## Phase 4 — API endpoints
|
||
|
||
## Task 7: `GET /api/inbox/lanes` (TDD)
|
||
|
||
**Files:**
|
||
- Modify: `backend/src/cyclone/api.py` (add new endpoint near other GET endpoints)
|
||
- Test: `backend/tests/test_inbox_endpoints.py` (new file)
|
||
|
||
- [ ] **Step 1: Write the failing test**
|
||
|
||
```python
|
||
# backend/tests/test_inbox_endpoints.py
|
||
from fastapi.testclient import TestClient
|
||
|
||
|
||
def test_lanes_endpoint_returns_four_keys(client: TestClient):
|
||
r = client.get("/api/inbox/lanes")
|
||
assert r.status_code == 200
|
||
body = r.json()
|
||
assert set(body.keys()) == {"rejected", "candidates", "unmatched", "done_today"}
|
||
for v in body.values():
|
||
assert isinstance(v, list)
|
||
```
|
||
|
||
- [ ] **Step 2: Run, confirm FAIL**
|
||
|
||
```bash
|
||
cd backend && ../.venv/bin/pytest tests/test_inbox_endpoints.py -q
|
||
```
|
||
|
||
- [ ] **Step 3: Add the endpoint**
|
||
|
||
In `backend/src/cyclone/api.py`, near the other GET endpoints, add:
|
||
|
||
```python
|
||
from cyclone.inbox_lanes import compute_lanes
|
||
|
||
|
||
@app.get("/api/inbox/lanes")
|
||
def inbox_lanes():
|
||
S = get_sessionmaker()()
|
||
dismissed_pairs = getattr(app.state, "dismissed_pairs", set())
|
||
with S() as session:
|
||
lanes = compute_lanes(session, dismissed_pairs=dismissed_pairs)
|
||
return {
|
||
"rejected": lanes.rejected,
|
||
"candidates": lanes.candidates,
|
||
"unmatched": lanes.unmatched,
|
||
"done_today": lanes.done_today,
|
||
}
|
||
```
|
||
|
||
Also ensure `dismissed_pairs` is initialized on `app.state` in `conftest.py` (or in the lifespan if there is one):
|
||
|
||
```python
|
||
# backend/tests/conftest.py — extend the existing autouse fixture:
|
||
if not hasattr(app.state, "dismissed_pairs"):
|
||
app.state.dismissed_pairs = set()
|
||
```
|
||
|
||
- [ ] **Step 4: Run, confirm PASS**
|
||
|
||
```bash
|
||
cd backend && ../.venv/bin/pytest tests/test_inbox_endpoints.py -v
|
||
```
|
||
|
||
- [ ] **Step 5: Full suite**
|
||
|
||
```bash
|
||
cd backend && ../.venv/bin/pytest -q
|
||
```
|
||
|
||
- [ ] **Step 6: Commit**
|
||
|
||
```bash
|
||
git add backend/src/cyclone/api.py backend/tests/conftest.py backend/tests/test_inbox_endpoints.py
|
||
git commit -m "feat(sp6): GET /api/inbox/lanes"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 8: POST match + dismiss endpoints (TDD)
|
||
|
||
**Files:**
|
||
- Modify: `backend/src/cyclone/api.py`
|
||
- Modify: `backend/tests/test_inbox_endpoints.py` (append)
|
||
|
||
- [ ] **Step 1: Write the failing tests**
|
||
|
||
Append to `backend/tests/test_inbox_endpoints.py`:
|
||
|
||
```python
|
||
def test_match_endpoint_links_remit_to_claim(client: TestClient):
|
||
# Seed
|
||
from cyclone.db import init_db, get_sessionmaker, Claim, Remittance, ClaimState
|
||
S = get_sessionmaker()()
|
||
with S() as s:
|
||
s.add(Claim(id="C1", batch_id="B-1", patient_control_number="PCN-1",
|
||
state=ClaimState.SUBMITTED))
|
||
s.add(Remittance(id="R1", batch_id="B-1",
|
||
payer_claim_control_number="PCN-1"))
|
||
s.commit()
|
||
|
||
r = client.post("/api/inbox/candidates/R1/match", json={"claim_id": "C1"})
|
||
assert r.status_code == 200
|
||
|
||
with S() as s:
|
||
c = s.get(Claim, "C1")
|
||
assert c.matched_remittance_id == "R1"
|
||
|
||
|
||
def test_match_409_if_claim_already_matched(client: TestClient):
|
||
from cyclone.db import init_db, get_sessionmaker, Claim, Remittance, ClaimState
|
||
S = get_sessionmaker()()
|
||
with S() as s:
|
||
s.add(Claim(id="C1", batch_id="B-1", patient_control_number="X",
|
||
state=ClaimState.SUBMITTED,
|
||
matched_remittance_id="R-OTHER"))
|
||
s.add(Remittance(id="R1", batch_id="B-1",
|
||
payer_claim_control_number="X"))
|
||
s.commit()
|
||
|
||
r = client.post("/api/inbox/candidates/R1/match", json={"claim_id": "C1"})
|
||
assert r.status_code == 409
|
||
assert "current_state" in r.json()
|
||
|
||
|
||
def test_dismiss_endpoint_adds_pair_to_session_set(client: TestClient):
|
||
r = client.post(
|
||
"/api/inbox/candidates/dismiss",
|
||
json={"pairs": [{"claim_id": "C1", "remit_id": "R1"}]},
|
||
)
|
||
assert r.status_code == 200
|
||
dismissed = client.app.state.dismissed_pairs
|
||
assert frozenset({"C1", "R1"}) in dismissed
|
||
```
|
||
|
||
- [ ] **Step 2: Run, confirm FAIL**
|
||
|
||
```bash
|
||
cd backend && ../.venv/bin/pytest tests/test_inbox_endpoints.py -q
|
||
```
|
||
|
||
- [ ] **Step 3: Implement both endpoints**
|
||
|
||
In `backend/src/cyclone/api.py`:
|
||
|
||
```python
|
||
from fastapi import HTTPException
|
||
from cyclone.db import Claim, ClaimState, Remittance
|
||
|
||
|
||
@app.post("/api/inbox/candidates/{remit_id}/match")
|
||
def inbox_match_candidate(remit_id: str, body: dict):
|
||
claim_id = body.get("claim_id")
|
||
if not claim_id:
|
||
raise HTTPException(400, "claim_id required")
|
||
S = get_sessionmaker()()
|
||
with S() as s:
|
||
claim = s.get(Claim, claim_id)
|
||
remit = s.get(Remittance, remit_id)
|
||
if claim is None or remit is None:
|
||
raise HTTPException(404, "claim or remit not found")
|
||
if claim.matched_remittance_id and claim.matched_remittance_id != remit_id:
|
||
raise HTTPException(
|
||
409,
|
||
detail={
|
||
"error": "claim_already_matched",
|
||
"current_state": claim.state.value if hasattr(claim.state, "value") else str(claim.state),
|
||
"matched_remittance_id": claim.matched_remittance_id,
|
||
},
|
||
)
|
||
claim.matched_remittance_id = remit_id
|
||
claim.claim_id = remit_id # back-ref; safe no-op on already linked
|
||
s.commit()
|
||
bus = _get_event_bus()
|
||
bus.publish("claim.matched", {"claim_id": claim_id, "remit_id": remit_id})
|
||
return {"ok": True}
|
||
|
||
|
||
@app.post("/api/inbox/candidates/dismiss")
|
||
def inbox_dismiss_candidates(body: dict):
|
||
pairs = body.get("pairs") or []
|
||
if not hasattr(app.state, "dismissed_pairs"):
|
||
app.state.dismissed_pairs = set()
|
||
for p in pairs:
|
||
cid = p.get("claim_id")
|
||
rid = p.get("remit_id")
|
||
if cid and rid:
|
||
app.state.dismissed_pairs.add(frozenset({cid, rid}))
|
||
return {"ok": True, "dismissed_count": len(pairs)}
|
||
```
|
||
|
||
(Note: depending on the ORM model, `claim.claim_id` may not exist — if so, just omit that line; the `matched_remittance_id` set is what matters.)
|
||
|
||
- [ ] **Step 4: Run, confirm PASS**
|
||
|
||
```bash
|
||
cd backend && ../.venv/bin/pytest tests/test_inbox_endpoints.py -v
|
||
```
|
||
|
||
- [ ] **Step 5: Full suite**
|
||
|
||
```bash
|
||
cd backend && ../.venv/bin/pytest -q
|
||
```
|
||
|
||
- [ ] **Step 6: Commit**
|
||
|
||
```bash
|
||
git add backend/src/cyclone/api.py backend/tests/test_inbox_endpoints.py
|
||
git commit -m "feat(sp6): manual match + dismiss endpoints"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 9: POST `/api/inbox/rejected/resubmit` (TDD)
|
||
|
||
**Files:**
|
||
- Modify: `backend/src/cyclone/api.py`
|
||
- Modify: `backend/tests/test_inbox_endpoints.py`
|
||
|
||
- [ ] **Step 1: Append the failing test**
|
||
|
||
```python
|
||
def test_resubmit_moves_rejected_to_submitted_and_increments_count(client: TestClient):
|
||
from cyclone.db import init_db, get_sessionmaker, Claim, ClaimState
|
||
S = get_sessionmaker()()
|
||
with S() as s:
|
||
s.add(Claim(id="C1", batch_id="B-1", patient_control_number="X",
|
||
state=ClaimState.REJECTED,
|
||
rejection_reason="old",
|
||
resubmit_count=2))
|
||
s.commit()
|
||
|
||
r = client.post("/api/inbox/rejected/resubmit", json={"claim_ids": ["C1"]})
|
||
assert r.status_code == 200, r.text
|
||
|
||
with S() as s:
|
||
c = s.get(Claim, "C1")
|
||
assert c.state == ClaimState.SUBMITTED
|
||
assert c.rejection_reason is None
|
||
assert c.resubmit_count == 3
|
||
|
||
|
||
def test_resubmit_409_for_non_rejected_claim(client: TestClient):
|
||
from cyclone.db import init_db, get_sessionmaker, Claim, ClaimState
|
||
S = get_sessionmaker()()
|
||
with S() as s:
|
||
s.add(Claim(id="C1", batch_id="B-1", patient_control_number="X",
|
||
state=ClaimState.SUBMITTED))
|
||
s.commit()
|
||
|
||
r = client.post("/api/inbox/rejected/resubmit", json={"claim_ids": ["C1"]})
|
||
assert r.status_code == 409
|
||
```
|
||
|
||
- [ ] **Step 2: Run, confirm FAIL**
|
||
|
||
```bash
|
||
cd backend && ../.venv/bin/pytest tests/test_inbox_endpoints.py -q
|
||
```
|
||
|
||
- [ ] **Step 3: Implement**
|
||
|
||
```python
|
||
@app.post("/api/inbox/rejected/resubmit")
|
||
def inbox_resubmit_rejected(body: dict):
|
||
ids = body.get("claim_ids") or []
|
||
if not ids:
|
||
raise HTTPException(400, "claim_ids required")
|
||
S = get_sessionmaker()()
|
||
accepted = []
|
||
conflicts = []
|
||
with S() as s:
|
||
for cid in ids:
|
||
c = s.get(Claim, cid)
|
||
if c is None:
|
||
continue
|
||
if c.state != ClaimState.REJECTED:
|
||
conflicts.append({
|
||
"claim_id": cid,
|
||
"current_state": c.state.value if hasattr(c.state, "value") else str(c.state),
|
||
})
|
||
continue
|
||
c.state = ClaimState.SUBMITTED
|
||
c.rejection_reason = None
|
||
c.rejected_at = None
|
||
c.resubmit_count = (c.resubmit_count or 0) + 1
|
||
accepted.append(cid)
|
||
s.commit()
|
||
bus = _get_event_bus()
|
||
for cid in accepted:
|
||
bus.publish("claim.resubmitted", {"claim_id": cid})
|
||
return {"ok": True, "resubmitted": accepted, "conflicts": conflicts}
|
||
```
|
||
|
||
- [ ] **Step 4: Run, confirm PASS**
|
||
|
||
```bash
|
||
cd backend && ../.venv/bin/pytest tests/test_inbox_endpoints.py -v
|
||
```
|
||
|
||
- [ ] **Step 5: Full suite**
|
||
|
||
```bash
|
||
cd backend && ../.venv/bin/pytest -q
|
||
```
|
||
|
||
- [ ] **Step 6: Commit**
|
||
|
||
```bash
|
||
git add backend/src/cyclone/api.py backend/tests/test_inbox_endpoints.py
|
||
git commit -m "feat(sp6): bulk resubmit rejected claims"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 10: GET `/api/inbox/export.csv` (TDD)
|
||
|
||
**Files:**
|
||
- Modify: `backend/src/cyclone/api.py`
|
||
- Modify: `backend/tests/test_inbox_endpoints.py`
|
||
|
||
- [ ] **Step 1: Append the failing test**
|
||
|
||
```python
|
||
def test_export_csv_streams_rows_with_csv_content_type(client: TestClient):
|
||
r = client.get("/api/inbox/export.csv?lane=rejected")
|
||
assert r.status_code == 200
|
||
assert "text/csv" in r.headers["content-type"]
|
||
# Body should at least include the header row.
|
||
body = r.text
|
||
assert "id" in body.splitlines()[0]
|
||
```
|
||
|
||
- [ ] **Step 2: Run, confirm FAIL**
|
||
|
||
```bash
|
||
cd backend && ../.venv/bin/pytest tests/test_inbox_endpoints.py -q
|
||
```
|
||
|
||
- [ ] **Step 3: Implement**
|
||
|
||
```python
|
||
import csv
|
||
import io
|
||
from fastapi.responses import StreamingResponse
|
||
|
||
|
||
@app.get("/api/inbox/export.csv")
|
||
def inbox_export_csv(lane: str):
|
||
if lane not in {"rejected", "candidates", "unmatched", "done_today"}:
|
||
raise HTTPException(400, f"unknown lane: {lane}")
|
||
S = get_sessionmaker()()
|
||
dismissed_pairs = getattr(app.state, "dismissed_pairs", set())
|
||
with S() as session:
|
||
lanes = compute_lanes(session, dismissed_pairs=dismissed_pairs)
|
||
rows = getattr(lanes, lane)
|
||
|
||
buf = io.StringIO()
|
||
writer = csv.writer(buf)
|
||
writer.writerow([
|
||
"id", "kind", "patient_control_number", "charge_amount",
|
||
"payer_id", "provider_npi", "state", "rejection_reason",
|
||
"service_date_from", "score",
|
||
])
|
||
for r in rows:
|
||
writer.writerow([
|
||
r.get("id") or r.get("payer_claim_control_number"),
|
||
r.get("kind"),
|
||
r.get("patient_control_number"),
|
||
r.get("charge_amount"),
|
||
r.get("payer_id"),
|
||
r.get("provider_npi") or r.get("rendering_provider_npi"),
|
||
r.get("state"),
|
||
r.get("rejection_reason"),
|
||
r.get("service_date_from") or r.get("service_date"),
|
||
r.get("score"),
|
||
])
|
||
buf.seek(0)
|
||
return StreamingResponse(
|
||
iter([buf.getvalue()]),
|
||
media_type="text/csv",
|
||
headers={"Content-Disposition": f'attachment; filename="inbox-{lane}.csv"'},
|
||
)
|
||
```
|
||
|
||
- [ ] **Step 4: Run, confirm PASS**
|
||
|
||
```bash
|
||
cd backend && ../.venv/bin/pytest tests/test_inbox_endpoints.py -v
|
||
```
|
||
|
||
- [ ] **Step 5: Full suite + commit**
|
||
|
||
```bash
|
||
cd backend && ../.venv/bin/pytest -q
|
||
git add backend/src/cyclone/api.py backend/tests/test_inbox_endpoints.py
|
||
git commit -m "feat(sp6): CSV export endpoint"
|
||
```
|
||
|
||
---
|
||
|
||
## Phase 5 — Frontend scaffolding
|
||
|
||
## Task 11: API client + types
|
||
|
||
**Files:**
|
||
- Modify: `src/lib/api.ts` (append)
|
||
- Modify: `src/lib/api.test.ts` (or create `src/lib/inbox-api.test.ts`)
|
||
|
||
- [ ] **Step 1: Write the failing test**
|
||
|
||
```typescript
|
||
// src/lib/inbox-api.test.ts
|
||
import { describe, expect, it, vi } from "vitest";
|
||
import { fetchInboxLanes, matchCandidate, dismissCandidates, resubmitRejected, exportInboxCsv } from "./inbox-api";
|
||
|
||
describe("inbox-api", () => {
|
||
it("fetchInboxLanes hits /api/inbox/lanes", async () => {
|
||
const fetchSpy = vi.fn().mockResolvedValue({
|
||
ok: true,
|
||
json: async () => ({ rejected: [], candidates: [], unmatched: [], done_today: [] }),
|
||
});
|
||
vi.stubGlobal("fetch", fetchSpy);
|
||
const lanes = await fetchInboxLanes();
|
||
expect(fetchSpy).toHaveBeenCalledWith("/api/inbox/lanes", expect.anything());
|
||
expect(lanes.rejected).toEqual([]);
|
||
vi.unstubAllGlobals();
|
||
});
|
||
});
|
||
```
|
||
|
||
- [ ] **Step 2: Run, confirm FAIL**
|
||
|
||
```bash
|
||
cd . && npx vitest run src/lib/inbox-api.test.ts
|
||
```
|
||
|
||
- [ ] **Step 3: Implement**
|
||
|
||
```typescript
|
||
// src/lib/inbox-api.ts
|
||
export type ScoreBreakdown = {
|
||
patient: number;
|
||
date: number;
|
||
amount: number;
|
||
provider: number;
|
||
};
|
||
|
||
export type ScoreTier = "strong" | "weak" | "hidden";
|
||
|
||
export type InboxClaimRow = {
|
||
id: string;
|
||
kind: "claim";
|
||
patient_control_number: string;
|
||
charge_amount: number;
|
||
payer_id: string | null;
|
||
provider_npi: string | null;
|
||
state: string;
|
||
rejection_reason: string | null;
|
||
rejected_at: string | null;
|
||
service_date_from: string | null;
|
||
score?: number | null;
|
||
score_tier?: ScoreTier | null;
|
||
score_breakdown?: ScoreBreakdown | null;
|
||
};
|
||
|
||
export type InboxCandidateRow = {
|
||
id: string;
|
||
kind: "remit";
|
||
payer_claim_control_number: string;
|
||
charge_amount: number;
|
||
payer_id: string | null;
|
||
rendering_provider_npi: string | null;
|
||
service_date: string | null;
|
||
candidates: Array<{
|
||
claim_id: string;
|
||
score: number;
|
||
tier: ScoreTier;
|
||
breakdown: ScoreBreakdown;
|
||
}>;
|
||
};
|
||
|
||
export type InboxLanes = {
|
||
rejected: InboxClaimRow[];
|
||
candidates: InboxCandidateRow[];
|
||
unmatched: InboxClaimRow[];
|
||
done_today: InboxClaimRow[];
|
||
};
|
||
|
||
async function getJson<T>(url: string): Promise<T> {
|
||
const r = await fetch(url);
|
||
if (!r.ok) throw new Error(`${r.status} ${url}`);
|
||
return (await r.json()) as T;
|
||
}
|
||
|
||
export async function fetchInboxLanes(): Promise<InboxLanes> {
|
||
return getJson<InboxLanes>("/api/inbox/lanes");
|
||
}
|
||
|
||
export async function matchCandidate(remitId: string, claimId: string): Promise<void> {
|
||
const r = await fetch(`/api/inbox/candidates/${encodeURIComponent(remitId)}/match`, {
|
||
method: "POST",
|
||
headers: { "content-type": "application/json" },
|
||
body: JSON.stringify({ claim_id: claimId }),
|
||
});
|
||
if (!r.ok) throw new Error(`match failed: ${r.status}`);
|
||
}
|
||
|
||
export async function dismissCandidates(pairs: Array<{ claim_id: string; remit_id: string }>): Promise<void> {
|
||
const r = await fetch("/api/inbox/candidates/dismiss", {
|
||
method: "POST",
|
||
headers: { "content-type": "application/json" },
|
||
body: JSON.stringify({ pairs }),
|
||
});
|
||
if (!r.ok) throw new Error(`dismiss failed: ${r.status}`);
|
||
}
|
||
|
||
export async function resubmitRejected(claimIds: string[]): Promise<void> {
|
||
const r = await fetch("/api/inbox/rejected/resubmit", {
|
||
method: "POST",
|
||
headers: { "content-type": "application/json" },
|
||
body: JSON.stringify({ claim_ids: claimIds }),
|
||
});
|
||
if (!r.ok) throw new Error(`resubmit failed: ${r.status}`);
|
||
}
|
||
|
||
export function exportInboxCsvUrl(lane: "rejected" | "candidates" | "unmatched" | "done_today"): string {
|
||
return `/api/inbox/export.csv?lane=${lane}`;
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 4: Run, confirm PASS**
|
||
|
||
```bash
|
||
npx vitest run src/lib/inbox-api.test.ts
|
||
```
|
||
|
||
- [ ] **Step 5: Commit**
|
||
|
||
```bash
|
||
git add src/lib/inbox-api.ts src/lib/inbox-api.test.ts
|
||
git commit -m "feat(sp6): inbox api client + types"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 12: Ticker Tape styling primitives
|
||
|
||
**Files:**
|
||
- Modify: `src/index.css` (or `src/styles/tokens.css` if it exists — check the project structure)
|
||
- Modify: `tailwind.config.js` (extend theme)
|
||
|
||
- [ ] **Step 1: Inspect existing token files**
|
||
|
||
```bash
|
||
ls src/*.css src/styles 2>/dev/null; cat src/index.css 2>/dev/null | head -30; cat tailwind.config.js 2>/dev/null | head -40
|
||
```
|
||
|
||
Read enough to see how colors are currently organized. Match the existing pattern.
|
||
|
||
- [ ] **Step 2: Add the Ticker Tape tokens**
|
||
|
||
Add to the appropriate CSS file (or Tailwind theme extension). Three-color accent + dark base:
|
||
|
||
```css
|
||
/* src/styles/ticker-tape.css */
|
||
:root {
|
||
--tt-bg: #0a0e14;
|
||
--tt-bg-elev: #11161f;
|
||
--tt-ink: #e6e1d3;
|
||
--tt-ink-dim: #8a8b8e;
|
||
--tt-amber: #f4b942; /* action needed */
|
||
--tt-oxblood: #8b2c2c; /* rejection */
|
||
--tt-ink-blue: #2c4a6e; /* neutral / waiting */
|
||
--tt-muted: #4a4d54;
|
||
}
|
||
|
||
@font-face {
|
||
font-family: "IBM Plex Serif";
|
||
font-display: swap;
|
||
src: url("https://fonts.gstatic.com/s/ibmplexserif/v19/jizDREVItHgc8qDIbSTKq4XKVUaaRA0od9b6XQ.woff2") format("woff2");
|
||
font-weight: 400 700;
|
||
}
|
||
|
||
@font-face {
|
||
font-family: "IBM Plex Mono";
|
||
font-display: swap;
|
||
src: url("https://fonts.gstatic.com/s/ibmplexmono/v19/-F63fjptAgt5VM-kVkqdyU8n5igg1l9kn-s.woff2") format("woff2");
|
||
font-weight: 400 700;
|
||
}
|
||
```
|
||
|
||
(If the project uses Tailwind theme.extend, prefer extending `theme.extend.colors` and `theme.extend.fontFamily` over CSS variables.)
|
||
|
||
- [ ] **Step 3: Smoke test**
|
||
|
||
```bash
|
||
npx tsc --noEmit
|
||
```
|
||
|
||
Expected: no errors.
|
||
|
||
- [ ] **Step 4: Commit**
|
||
|
||
```bash
|
||
git add src/index.css src/styles/ticker-tape.css tailwind.config.js
|
||
git commit -m "feat(sp6): Ticker Tape color + typography tokens"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 13: `/inbox` route + nav link
|
||
|
||
**Files:**
|
||
- Modify: `src/App.tsx`
|
||
- Modify: `src/components/Sidebar.tsx` (or whatever the nav component is)
|
||
|
||
- [ ] **Step 1: Find the route table and nav links**
|
||
|
||
```bash
|
||
grep -n "Route\|path=" src/App.tsx | head -10
|
||
grep -n "Dashboard\|/claims\|/remittances" src/components/*.tsx | head -10
|
||
```
|
||
|
||
- [ ] **Step 2: Add the route and link**
|
||
|
||
In the route table, add (position: second, after Dashboard):
|
||
|
||
```tsx
|
||
import Inbox from "./pages/Inbox";
|
||
// ...
|
||
<Route path="/inbox" element={<Inbox />} />
|
||
```
|
||
|
||
In the sidebar, add a link:
|
||
|
||
```tsx
|
||
<NavLink to="/inbox">Inbox</NavLink>
|
||
```
|
||
|
||
- [ ] **Step 3: Create the placeholder Inbox page so the route resolves**
|
||
|
||
```tsx
|
||
// src/pages/Inbox.tsx
|
||
export default function Inbox() {
|
||
return <div className="p-8 text-[var(--tt-ink)] bg-[var(--tt-bg)] min-h-screen">
|
||
Inbox — coming up in T14–T17
|
||
</div>;
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 4: Smoke test**
|
||
|
||
```bash
|
||
npx tsc --noEmit && npx vitest run --reporter=dot
|
||
```
|
||
|
||
- [ ] **Step 5: Commit**
|
||
|
||
```bash
|
||
git add src/App.tsx src/components/Sidebar.tsx src/pages/Inbox.tsx
|
||
git commit -m "feat(sp6): /inbox route + sidebar nav link"
|
||
```
|
||
|
||
---
|
||
|
||
## Phase 6 — Inbox components
|
||
|
||
## Task 14: `InboxRow` (TDD)
|
||
|
||
**Files:**
|
||
- Create: `src/components/inbox/InboxRow.tsx`
|
||
- Create: `src/components/inbox/InboxRow.test.tsx`
|
||
|
||
- [ ] **Step 1: Write the failing test**
|
||
|
||
```tsx
|
||
// src/components/inbox/InboxRow.test.tsx
|
||
import { describe, expect, it, vi } from "vitest";
|
||
import { render, screen, fireEvent } from "@testing-library/react";
|
||
import { InboxRow } from "./InboxRow";
|
||
|
||
const baseClaim = {
|
||
id: "CLP-7741",
|
||
kind: "claim" as const,
|
||
patient_control_number: "PCN-001",
|
||
charge_amount: 1240,
|
||
payer_id: "CO-MCD",
|
||
provider_npi: "1234567890",
|
||
state: "rejected",
|
||
rejection_reason: "999 AK9 R",
|
||
rejected_at: "2026-06-20T09:12:00Z",
|
||
service_date_from: null,
|
||
score: null,
|
||
score_tier: null,
|
||
score_breakdown: null,
|
||
};
|
||
|
||
describe("InboxRow", () => {
|
||
it("renders the claim id, payer, charge", () => {
|
||
render(<table><tbody><InboxRow row={baseClaim} accent="oxblood" onClick={() => {}} /></tbody></table>);
|
||
expect(screen.getByText("CLP-7741")).toBeDefined();
|
||
expect(screen.getByText("CO-MCD")).toBeDefined();
|
||
expect(screen.getByText("$1,240.00")).toBeDefined();
|
||
});
|
||
|
||
it("renders the rejection reason when present", () => {
|
||
render(<table><tbody><InboxRow row={baseClaim} accent="oxblood" onClick={() => {}} /></tbody></table>);
|
||
expect(screen.getByText(/999 AK9 R/)).toBeDefined();
|
||
});
|
||
|
||
it("calls onClick when clicked", () => {
|
||
const onClick = vi.fn();
|
||
render(<table><tbody><InboxRow row={baseClaim} accent="oxblood" onClick={onClick} /></tbody></table>);
|
||
fireEvent.click(screen.getByText("CLP-7741"));
|
||
expect(onClick).toHaveBeenCalledOnce();
|
||
});
|
||
|
||
it("renders score sparkline for candidate rows", () => {
|
||
const candidateRow = {
|
||
id: "R-1",
|
||
kind: "remit" as const,
|
||
payer_claim_control_number: "PCN-X",
|
||
charge_amount: 500,
|
||
payer_id: "AETNA",
|
||
rendering_provider_npi: "1234567890",
|
||
service_date: null,
|
||
candidates: [{
|
||
claim_id: "C-1", score: 92, tier: "strong" as const,
|
||
breakdown: { patient: 1, date: 1, amount: 1, provider: 1 },
|
||
}],
|
||
};
|
||
render(<table><tbody><InboxRow row={candidateRow} accent="amber" onClick={() => {}} /></tbody></table>);
|
||
expect(screen.getByText("92")).toBeDefined();
|
||
});
|
||
});
|
||
```
|
||
|
||
- [ ] **Step 2: Run, confirm FAIL**
|
||
|
||
```bash
|
||
npx vitest run src/components/inbox/InboxRow.test.tsx
|
||
```
|
||
|
||
- [ ] **Step 3: Implement**
|
||
|
||
```tsx
|
||
// src/components/inbox/InboxRow.tsx
|
||
import type { InboxClaimRow, InboxCandidateRow } from "../../lib/inbox-api";
|
||
|
||
type Accent = "oxblood" | "amber" | "ink-blue" | "muted";
|
||
|
||
const ACCENT_VAR: Record<Accent, string> = {
|
||
oxblood: "var(--tt-oxblood)",
|
||
amber: "var(--tt-amber)",
|
||
"ink-blue": "var(--tt-ink-blue)",
|
||
muted: "var(--tt-muted)",
|
||
};
|
||
|
||
function fmtMoney(n: number): string {
|
||
return n.toLocaleString("en-US", { style: "currency", currency: "USD" });
|
||
}
|
||
|
||
function fmtAgo(iso: string | null): string {
|
||
if (!iso) return "";
|
||
const ms = Date.now() - new Date(iso).getTime();
|
||
const mins = Math.round(ms / 60_000);
|
||
if (mins < 60) return `${mins}m`;
|
||
const hrs = Math.round(mins / 60);
|
||
if (hrs < 24) return `${hrs}h`;
|
||
const days = Math.round(hrs / 24);
|
||
return `${days}d`;
|
||
}
|
||
|
||
type Props =
|
||
| { row: InboxClaimRow; accent: Accent; onClick: () => void }
|
||
| { row: InboxCandidateRow; accent: Accent; onClick: () => void };
|
||
|
||
export function InboxRow(props: Props) {
|
||
const { row, accent, onClick } = props;
|
||
const id = "id" in row ? row.id : (row as InboxCandidateRow).payer_claim_control_number;
|
||
const payer = row.payer_id ?? "";
|
||
const charge = fmtMoney(row.charge_amount);
|
||
const rejectedAt = "rejected_at" in row ? row.rejected_at : null;
|
||
const reason = "rejection_reason" in row ? row.rejection_reason : null;
|
||
const topScore = "candidates" in row && row.candidates.length ? row.candidates[0].score : null;
|
||
const filled = "candidates" in row && row.candidates.length
|
||
? row.candidates[0].breakdown
|
||
: null;
|
||
|
||
return (
|
||
<tr
|
||
onClick={onClick}
|
||
className="border-b border-[var(--tt-bg-elev)] hover:bg-[var(--tt-bg-elev)] cursor-pointer"
|
||
>
|
||
<td style={{ width: 4, background: ACCENT_VAR[accent], padding: 0 }} />
|
||
<td className="font-mono text-[var(--tt-ink)] py-2 pl-3">{id}</td>
|
||
<td className="font-mono text-[var(--tt-ink-dim)] text-xs py-2">
|
||
{reason ? reason : topScore != null ? `${topScore}` : ""}
|
||
</td>
|
||
<td className="font-mono text-[var(--tt-ink-dim)] text-xs py-2">
|
||
{fmtAgo(rejectedAt)}
|
||
</td>
|
||
<td className="font-mono text-[var(--tt-ink)] text-right py-2">{charge}</td>
|
||
<td className="text-[var(--tt-ink-dim)] text-xs uppercase tracking-wider py-2 pl-4">{payer}</td>
|
||
<td className="text-[var(--tt-ink)] py-2 pr-3">
|
||
{filled ? (
|
||
<span className="font-mono text-xs">
|
||
{["patient", "date", "amount", "provider"].map((k) => (
|
||
<span
|
||
key={k}
|
||
title={`${k}: ${(filled as any)[k]}`}
|
||
className="inline-block w-2 h-3 mr-0.5 align-middle"
|
||
style={{ background: (filled as any)[k] >= 0.5 ? "var(--tt-amber)" : "var(--tt-muted)" }}
|
||
/>
|
||
))}
|
||
</span>
|
||
) : null}
|
||
</td>
|
||
</tr>
|
||
);
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 4: Run, confirm PASS**
|
||
|
||
```bash
|
||
npx vitest run src/components/inbox/InboxRow.test.tsx
|
||
```
|
||
|
||
- [ ] **Step 5: Commit**
|
||
|
||
```bash
|
||
git add src/components/inbox/InboxRow.tsx src/components/inbox/InboxRow.test.tsx
|
||
git commit -m "feat(sp6): InboxRow with Ticker Tape accents"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 15: `Lane` (TDD)
|
||
|
||
**Files:**
|
||
- Create: `src/components/inbox/Lane.tsx`
|
||
- Create: `src/components/inbox/Lane.test.tsx`
|
||
|
||
- [ ] **Step 1: Write the failing test**
|
||
|
||
```tsx
|
||
// src/components/inbox/Lane.test.tsx
|
||
import { describe, expect, it, vi } from "vitest";
|
||
import { render, screen, fireEvent } from "@testing-library/react";
|
||
import { Lane } from "./Lane";
|
||
|
||
const sampleRows = [
|
||
{
|
||
id: "CLP-1",
|
||
kind: "claim" as const,
|
||
patient_control_number: "PCN-1",
|
||
charge_amount: 100,
|
||
payer_id: "PAYER-A",
|
||
provider_npi: "1234567890",
|
||
state: "rejected",
|
||
rejection_reason: "R69",
|
||
rejected_at: "2026-06-20T09:00:00Z",
|
||
service_date_from: null,
|
||
score: null,
|
||
score_tier: null,
|
||
score_breakdown: null,
|
||
},
|
||
];
|
||
|
||
describe("Lane", () => {
|
||
it("renders the lane name and row count", () => {
|
||
render(<Lane name="REJECTED" accent="oxblood" rows={sampleRows} onRowClick={() => {}} />);
|
||
expect(screen.getByText("REJECTED")).toBeDefined();
|
||
expect(screen.getByText(/1/)).toBeDefined();
|
||
});
|
||
|
||
it("renders an empty state when no rows", () => {
|
||
render(<Lane name="DONE" accent="muted" rows={[]} onRowClick={() => {}} />);
|
||
expect(screen.getByText(/nothing here/i)).toBeDefined();
|
||
});
|
||
|
||
it("supports multi-select via row checkbox toggle", () => {
|
||
const onSelectionChange = vi.fn();
|
||
render(
|
||
<Lane
|
||
name="REJECTED"
|
||
accent="oxblood"
|
||
rows={sampleRows}
|
||
onRowClick={() => {}}
|
||
onSelectionChange={onSelectionChange}
|
||
/>
|
||
);
|
||
const cb = screen.getByRole("checkbox");
|
||
fireEvent.click(cb);
|
||
expect(onSelectionChange).toHaveBeenCalled();
|
||
});
|
||
});
|
||
```
|
||
|
||
- [ ] **Step 2: Run, confirm FAIL**
|
||
|
||
```bash
|
||
npx vitest run src/components/inbox/Lane.test.tsx
|
||
```
|
||
|
||
- [ ] **Step 3: Implement**
|
||
|
||
```tsx
|
||
// src/components/inbox/Lane.tsx
|
||
import { useState } from "react";
|
||
import { InboxRow } from "./InboxRow";
|
||
import type { InboxClaimRow, InboxCandidateRow } from "../../lib/inbox-api";
|
||
|
||
type Accent = "oxblood" | "amber" | "ink-blue" | "muted";
|
||
|
||
type Row = InboxClaimRow | InboxCandidateRow;
|
||
|
||
export function Lane({
|
||
name,
|
||
accent,
|
||
rows,
|
||
onRowClick,
|
||
onSelectionChange,
|
||
}: {
|
||
name: string;
|
||
accent: Accent;
|
||
rows: Row[];
|
||
onRowClick: (row: Row) => void;
|
||
onSelectionChange?: (ids: string[]) => void;
|
||
}) {
|
||
const [selected, setSelected] = useState<Set<string>>(new Set());
|
||
|
||
function toggle(id: string) {
|
||
const next = new Set(selected);
|
||
if (next.has(id)) next.delete(id);
|
||
else next.add(id);
|
||
setSelected(next);
|
||
onSelectionChange?.(Array.from(next));
|
||
}
|
||
|
||
return (
|
||
<section
|
||
className="flex-1 min-w-[280px] bg-[var(--tt-bg-elev)] border border-[var(--tt-bg)] rounded-sm"
|
||
data-lane={name.toLowerCase()}
|
||
>
|
||
<header className="px-3 py-2 border-b border-[var(--tt-bg)] flex items-baseline justify-between">
|
||
<h2 className="font-mono uppercase tracking-widest text-[var(--tt-ink)] text-xs">{name}</h2>
|
||
<span className="font-mono text-[var(--tt-amber)] text-lg tabular-nums">{rows.length}</span>
|
||
</header>
|
||
|
||
{rows.length === 0 ? (
|
||
<p className="p-4 text-xs text-[var(--tt-muted)] font-mono">Nothing here.</p>
|
||
) : (
|
||
<table className="w-full text-sm">
|
||
<tbody>
|
||
{rows.map((row) => {
|
||
const id = "id" in row ? row.id : row.payer_claim_control_number;
|
||
return (
|
||
<tr key={id} className="contents">
|
||
<td className="w-6 pr-1">
|
||
<input
|
||
type="checkbox"
|
||
aria-label={`Select ${id}`}
|
||
checked={selected.has(id)}
|
||
onChange={() => toggle(id)}
|
||
onClick={(e) => e.stopPropagation()}
|
||
/>
|
||
</td>
|
||
<td className="w-full">
|
||
<InboxRow row={row} accent={accent} onClick={() => onRowClick(row)} />
|
||
</td>
|
||
</tr>
|
||
);
|
||
})}
|
||
</tbody>
|
||
</table>
|
||
)}
|
||
</section>
|
||
);
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 4: Run, confirm PASS**
|
||
|
||
```bash
|
||
npx vitest run src/components/inbox/Lane.test.tsx
|
||
```
|
||
|
||
- [ ] **Step 5: Commit**
|
||
|
||
```bash
|
||
git add src/components/inbox/Lane.tsx src/components/inbox/Lane.test.tsx
|
||
git commit -m "feat(sp6): Lane component with multi-select"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 16: `InboxHeader` + `CandidateBreakdown` (TDD)
|
||
|
||
**Files:**
|
||
- Create: `src/components/inbox/InboxHeader.tsx`
|
||
- Create: `src/components/inbox/InboxHeader.test.tsx`
|
||
- Create: `src/components/inbox/CandidateBreakdown.tsx`
|
||
- Create: `src/components/inbox/CandidateBreakdown.test.tsx`
|
||
|
||
- [ ] **Step 1: Header test**
|
||
|
||
```tsx
|
||
// src/components/inbox/InboxHeader.test.tsx
|
||
import { describe, expect, it } from "vitest";
|
||
import { render, screen } from "@testing-library/react";
|
||
import { InboxHeader } from "./InboxHeader";
|
||
|
||
describe("InboxHeader", () => {
|
||
it("renders the date and counts", () => {
|
||
render(<InboxHeader needEyesCount={6} doneTodayCount={23} />);
|
||
expect(screen.getByText(/6 items need eyes/)).toBeDefined();
|
||
expect(screen.getByText(/23 done today/)).toBeDefined();
|
||
});
|
||
});
|
||
```
|
||
|
||
- [ ] **Step 2: Breakdown test**
|
||
|
||
```tsx
|
||
// src/components/inbox/CandidateBreakdown.test.tsx
|
||
import { describe, expect, it } from "vitest";
|
||
import { render, screen } from "@testing-library/react";
|
||
import { CandidateBreakdown } from "./CandidateBreakdown";
|
||
|
||
describe("CandidateBreakdown", () => {
|
||
it("renders per-field scores", () => {
|
||
render(
|
||
<CandidateBreakdown
|
||
score={92}
|
||
breakdown={{ patient: 1, date: 1, amount: 1, provider: 1 }}
|
||
tier="strong"
|
||
/>
|
||
);
|
||
expect(screen.getByText(/patient/i)).toBeDefined();
|
||
expect(screen.getByText(/date/i)).toBeDefined();
|
||
expect(screen.getByText(/amount/i)).toBeDefined();
|
||
expect(screen.getByText(/provider/i)).toBeDefined();
|
||
expect(screen.getByText("92")).toBeDefined();
|
||
});
|
||
});
|
||
```
|
||
|
||
- [ ] **Step 3: Run, confirm FAIL**
|
||
|
||
```bash
|
||
npx vitest run src/components/inbox/
|
||
```
|
||
|
||
- [ ] **Step 4: Implement both**
|
||
|
||
```tsx
|
||
// src/components/inbox/InboxHeader.tsx
|
||
export function InboxHeader({ needEyesCount, doneTodayCount }: {
|
||
needEyesCount: number;
|
||
doneTodayCount: number;
|
||
}) {
|
||
const now = new Date();
|
||
const day = now.toLocaleDateString("en-US", { weekday: "short" }).toUpperCase();
|
||
const time = now.toLocaleTimeString("en-US", { hour: "2-digit", minute: "2-digit", hour12: false });
|
||
return (
|
||
<header className="sticky top-0 z-10 bg-[var(--tt-bg)] border-b border-[var(--tt-bg-elev)] px-6 py-4">
|
||
<h1 className="font-serif text-2xl text-[var(--tt-ink)] tracking-tight">
|
||
INBOX · <span className="font-mono text-[var(--tt-amber)]">{day} {time}</span>
|
||
</h1>
|
||
<p className="font-mono text-xs text-[var(--tt-ink-dim)] mt-1">
|
||
<span className="text-[var(--tt-amber)]">{needEyesCount}</span> items need eyes ·{" "}
|
||
<span className="text-[var(--tt-ink)]">{doneTodayCount}</span> done today
|
||
</p>
|
||
</header>
|
||
);
|
||
}
|
||
```
|
||
|
||
```tsx
|
||
// src/components/inbox/CandidateBreakdown.tsx
|
||
import type { ScoreBreakdown, ScoreTier } from "../../lib/inbox-api";
|
||
|
||
export function CandidateBreakdown({
|
||
score,
|
||
breakdown,
|
||
tier,
|
||
}: {
|
||
score: number;
|
||
breakdown: ScoreBreakdown;
|
||
tier: ScoreTier;
|
||
}) {
|
||
const fields = [
|
||
{ key: "patient" as const, label: "Patient", weight: 40 },
|
||
{ key: "date" as const, label: "Date", weight: 25 },
|
||
{ key: "amount" as const, label: "Amount", weight: 20 },
|
||
{ key: "provider" as const, label: "Provider", weight: 15 },
|
||
];
|
||
return (
|
||
<div className="font-mono text-xs">
|
||
<div className="flex items-baseline justify-between mb-2">
|
||
<span className="text-[var(--tt-ink-dim)] uppercase tracking-widest">Score</span>
|
||
<span className="text-2xl text-[var(--tt-amber)] tabular-nums">{score}</span>
|
||
</div>
|
||
<ul className="space-y-1">
|
||
{fields.map((f) => (
|
||
<li key={f.key} className="flex justify-between">
|
||
<span className="text-[var(--tt-ink)]">{f.label}</span>
|
||
<span className="text-[var(--tt-ink-dim)] tabular-nums">
|
||
{Math.round(breakdown[f.key] * f.weight)} / {f.weight}
|
||
</span>
|
||
</li>
|
||
))}
|
||
</ul>
|
||
<p className="mt-3 text-[var(--tt-ink-dim)] uppercase tracking-widest">{tier}</p>
|
||
</div>
|
||
);
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 5: Run, confirm PASS**
|
||
|
||
```bash
|
||
npx vitest run src/components/inbox/
|
||
```
|
||
|
||
- [ ] **Step 6: Commit**
|
||
|
||
```bash
|
||
git add src/components/inbox/InboxHeader.tsx src/components/inbox/InboxHeader.test.tsx src/components/inbox/CandidateBreakdown.tsx src/components/inbox/CandidateBreakdown.test.tsx
|
||
git commit -m "feat(sp6): InboxHeader + CandidateBreakdown"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 17: `Inbox` page + `useInboxLanes` hook (TDD)
|
||
|
||
**Files:**
|
||
- Create: `src/hooks/useInboxLanes.ts`
|
||
- Create: `src/hooks/useInboxLanes.test.ts`
|
||
- Replace: `src/pages/Inbox.tsx` (real implementation, not placeholder)
|
||
|
||
- [ ] **Step 1: Hook test**
|
||
|
||
```typescript
|
||
// src/hooks/useInboxLanes.test.ts
|
||
import { describe, expect, it, vi } from "vitest";
|
||
import { renderHook, waitFor } from "@testing-library/react";
|
||
import { useInboxLanes } from "./useInboxLanes";
|
||
|
||
describe("useInboxLanes", () => {
|
||
it("loads lanes on mount", async () => {
|
||
vi.stubGlobal("fetch", vi.fn().mockResolvedValue({
|
||
ok: true,
|
||
json: async () => ({ rejected: [], candidates: [], unmatched: [], done_today: [] }),
|
||
}));
|
||
const { result } = renderHook(() => useInboxLanes());
|
||
await waitFor(() => expect(result.current.loading).toBe(false));
|
||
expect(result.current.lanes).toEqual({ rejected: [], candidates: [], unmatched: [], done_today: [] });
|
||
vi.unstubAllGlobals();
|
||
});
|
||
});
|
||
```
|
||
|
||
- [ ] **Step 2: Page test**
|
||
|
||
```tsx
|
||
// src/pages/Inbox.test.tsx
|
||
import { describe, expect, it, vi } from "vitest";
|
||
import { render, screen, waitFor } from "@testing-library/react";
|
||
import Inbox from "./Inbox";
|
||
|
||
describe("Inbox page", () => {
|
||
it("renders the four lane headers", async () => {
|
||
vi.stubGlobal("fetch", vi.fn().mockResolvedValue({
|
||
ok: true,
|
||
json: async () => ({ rejected: [], candidates: [], unmatched: [], done_today: [] }),
|
||
}));
|
||
render(<Inbox />);
|
||
await waitFor(() => {
|
||
expect(screen.getByText("REJECTED")).toBeDefined();
|
||
expect(screen.getByText("CANDIDATES")).toBeDefined();
|
||
expect(screen.getByText("UNMATCHED")).toBeDefined();
|
||
expect(screen.getByText("DONE")).toBeDefined();
|
||
});
|
||
vi.unstubAllGlobals();
|
||
});
|
||
});
|
||
```
|
||
|
||
- [ ] **Step 3: Run, confirm FAIL**
|
||
|
||
```bash
|
||
npx vitest run src/hooks/useInboxLanes.test.ts src/pages/Inbox.test.tsx
|
||
```
|
||
|
||
- [ ] **Step 4: Implement**
|
||
|
||
```typescript
|
||
// src/hooks/useInboxLanes.ts
|
||
import { useCallback, useEffect, useRef, useState } from "react";
|
||
import { fetchInboxLanes, type InboxLanes } from "../lib/inbox-api";
|
||
|
||
export function useInboxLanes() {
|
||
const [lanes, setLanes] = useState<InboxLanes>({
|
||
rejected: [], candidates: [], unmatched: [], done_today: [],
|
||
});
|
||
const [loading, setLoading] = useState(true);
|
||
const [error, setError] = useState<Error | null>(null);
|
||
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||
|
||
const refetch = useCallback(async () => {
|
||
try {
|
||
const next = await fetchInboxLanes();
|
||
setLanes(next);
|
||
setError(null);
|
||
} catch (e) {
|
||
setError(e as Error);
|
||
} finally {
|
||
setLoading(false);
|
||
}
|
||
}, []);
|
||
|
||
const refetchDebounced = useCallback(() => {
|
||
if (debounceRef.current) clearTimeout(debounceRef.current);
|
||
debounceRef.current = setTimeout(() => { void refetch(); }, 500);
|
||
}, [refetch]);
|
||
|
||
useEffect(() => { void refetch(); }, [refetch]);
|
||
|
||
// Subscribe to live-tail events. Coalesce bursts via 500ms debounce.
|
||
useEffect(() => {
|
||
const es = new EventSource("/api/claims/stream");
|
||
const onAny = () => refetchDebounced();
|
||
es.addEventListener("claim.rejected", onAny);
|
||
es.addEventListener("claim.matched", onAny);
|
||
es.addEventListener("claim.resubmitted", onAny);
|
||
es.addEventListener("claim.paid", onAny);
|
||
return () => { es.close(); };
|
||
}, [refetchDebounced]);
|
||
|
||
return { lanes, loading, error, refetch };
|
||
}
|
||
```
|
||
|
||
(Note: if `/api/claims/stream` isn't an EventSource (it's NDJSON), use the existing `useTailStream` hook from SP5 instead — replace this useEffect with that hook's `events` dependency.)
|
||
|
||
```tsx
|
||
// src/pages/Inbox.tsx
|
||
import { Lane } from "../components/inbox/Lane";
|
||
import { InboxHeader } from "../components/inbox/InboxHeader";
|
||
import { useInboxLanes } from "../hooks/useInboxLanes";
|
||
|
||
export default function Inbox() {
|
||
const { lanes, loading, error } = useInboxLanes();
|
||
|
||
if (loading) {
|
||
return (
|
||
<div className="min-h-screen bg-[var(--tt-bg)] p-8">
|
||
<p className="font-mono text-[var(--tt-ink-dim)]">loading…</p>
|
||
</div>
|
||
);
|
||
}
|
||
if (error) {
|
||
return (
|
||
<div className="min-h-screen bg-[var(--tt-bg)] p-8">
|
||
<p className="font-mono text-[var(--tt-oxblood)]">error: {error.message}</p>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
const needEyes =
|
||
lanes.rejected.length + lanes.candidates.length + lanes.unmatched.length;
|
||
|
||
return (
|
||
<div className="min-h-screen bg-[var(--tt-bg)] text-[var(--tt-ink)]">
|
||
<InboxHeader needEyesCount={needEyes} doneTodayCount={lanes.done_today.length} />
|
||
<main className="p-4 flex gap-4 items-start">
|
||
<Lane name="REJECTED" accent="oxblood" rows={lanes.rejected} onRowClick={() => {}} />
|
||
<Lane name="CANDIDATES" accent="amber" rows={lanes.candidates} onRowClick={() => {}} />
|
||
<Lane name="UNMATCHED" accent="ink-blue" rows={lanes.unmatched} onRowClick={() => {}} />
|
||
<Lane name="DONE" accent="muted" rows={lanes.done_today} onRowClick={() => {}} />
|
||
</main>
|
||
</div>
|
||
);
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 5: Run, confirm PASS**
|
||
|
||
```bash
|
||
npx vitest run src/pages/Inbox.test.tsx src/hooks/useInboxLanes.test.ts
|
||
```
|
||
|
||
- [ ] **Step 6: Type-check + full frontend suite**
|
||
|
||
```bash
|
||
npx tsc --noEmit && npx vitest run --reporter=dot
|
||
```
|
||
|
||
- [ ] **Step 7: Commit**
|
||
|
||
```bash
|
||
git add src/hooks/useInboxLanes.ts src/hooks/useInboxLanes.test.ts src/pages/Inbox.tsx src/pages/Inbox.test.tsx
|
||
git commit -m "feat(sp6): Inbox page + useInboxLanes hook"
|
||
```
|
||
|
||
---
|
||
|
||
## Phase 7 — Bulk actions + tail integration
|
||
|
||
## Task 18: Wire bulk action bar (TDD)
|
||
|
||
**Files:**
|
||
- Modify: `src/components/inbox/Lane.tsx`
|
||
- Create: `src/components/inbox/BulkBar.tsx`
|
||
- Modify: `src/components/inbox/Lane.test.tsx`
|
||
- Create: `src/components/inbox/BulkBar.test.tsx`
|
||
|
||
- [ ] **Step 1: BulkBar test**
|
||
|
||
```tsx
|
||
// src/components/inbox/BulkBar.test.tsx
|
||
import { describe, expect, it, vi } from "vitest";
|
||
import { render, screen, fireEvent } from "@testing-library/react";
|
||
import { BulkBar } from "./BulkBar";
|
||
|
||
describe("BulkBar", () => {
|
||
it("renders action buttons appropriate for the lane", () => {
|
||
render(
|
||
<BulkBar
|
||
lane="rejected"
|
||
count={3}
|
||
onResubmit={vi.fn()}
|
||
onDismiss={vi.fn()}
|
||
onExport={vi.fn()}
|
||
/>
|
||
);
|
||
expect(screen.getByRole("button", { name: /resubmit/i })).toBeDefined();
|
||
expect(screen.getByRole("button", { name: /export/i })).toBeDefined();
|
||
expect(screen.queryByRole("button", { name: /dismiss/i })).toBeNull();
|
||
});
|
||
|
||
it("calls onResubmit when Resubmit clicked", () => {
|
||
const onResubmit = vi.fn();
|
||
render(<BulkBar lane="rejected" count={2} onResubmit={onResubmit} onDismiss={() => {}} onExport={() => {}} />);
|
||
fireEvent.click(screen.getByRole("button", { name: /resubmit/i }));
|
||
expect(onResubmit).toHaveBeenCalledOnce();
|
||
});
|
||
});
|
||
```
|
||
|
||
- [ ] **Step 2: Implement BulkBar**
|
||
|
||
```tsx
|
||
// src/components/inbox/BulkBar.tsx
|
||
export function BulkBar({
|
||
lane,
|
||
count,
|
||
onResubmit,
|
||
onDismiss,
|
||
onExport,
|
||
}: {
|
||
lane: "rejected" | "candidates" | "unmatched" | "done_today";
|
||
count: number;
|
||
onResubmit: () => void;
|
||
onDismiss: () => void;
|
||
onExport: () => void;
|
||
}) {
|
||
if (count === 0) return null;
|
||
return (
|
||
<div className="fixed bottom-4 left-1/2 -translate-x-1/2 bg-[var(--tt-bg-elev)] border border-[var(--tt-amber)] px-4 py-2 font-mono text-xs flex gap-3 z-20">
|
||
<span className="text-[var(--tt-amber)] tabular-nums">{count} selected</span>
|
||
{lane === "rejected" && (
|
||
<button onClick={onResubmit} className="text-[var(--tt-ink)] hover:text-[var(--tt-amber)]">
|
||
Re-submit ({count})
|
||
</button>
|
||
)}
|
||
{lane === "candidates" && (
|
||
<button onClick={onDismiss} className="text-[var(--tt-ink)] hover:text-[var(--tt-amber)]">
|
||
Dismiss ({count})
|
||
</button>
|
||
)}
|
||
<button onClick={onExport} className="text-[var(--tt-ink)] hover:text-[var(--tt-amber)]">
|
||
Export CSV ({count})
|
||
</button>
|
||
</div>
|
||
);
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 3: Wire into Lane**
|
||
|
||
Edit `src/components/inbox/Lane.tsx` to accept `onResubmit`, `onDismiss`, `onExport` props and render `<BulkBar>` when selection is non-empty.
|
||
|
||
- [ ] **Step 4: Wire into Inbox page**
|
||
|
||
In `src/pages/Inbox.tsx`, pass handlers that call `resubmitRejected`, `dismissCandidates`, and `window.open(exportInboxCsvUrl(...))`. Then call `refetch()`.
|
||
|
||
- [ ] **Step 5: Run + commit**
|
||
|
||
```bash
|
||
npx vitest run src/components/inbox/
|
||
git add src/components/inbox/
|
||
git commit -m "feat(sp6): bulk action bar wired into lanes"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 19: `useInboxLanes` consumes SP5 tail (TDD)
|
||
|
||
**Files:**
|
||
- Modify: `src/hooks/useInboxLanes.ts`
|
||
- Modify: `src/hooks/useInboxLanes.test.ts`
|
||
|
||
- [ ] **Step 1: Add the failing test**
|
||
|
||
Append to `src/hooks/useInboxLanes.test.ts`:
|
||
|
||
```typescript
|
||
import { useTailStream } from "./useTailStream";
|
||
vi.mock("./useTailStream", () => ({
|
||
useTailStream: vi.fn(() => ({ events: [], status: "open" })),
|
||
}));
|
||
|
||
it("refetches on tail events", async () => {
|
||
// Test that calling the mocked useTailStream with a callback that triggers
|
||
// refetch causes a second fetch call.
|
||
// Implementation detail: the hook should accept a list of stream URLs and
|
||
// re-call refetchDebounced when events arrive.
|
||
});
|
||
```
|
||
|
||
- [ ] **Step 2: Run, confirm FAIL**
|
||
|
||
```bash
|
||
npx vitest run src/hooks/useInboxLanes.test.ts
|
||
```
|
||
|
||
- [ ] **Step 3: Refactor `useInboxLanes`**
|
||
|
||
Replace the manual `EventSource` block with:
|
||
|
||
```typescript
|
||
import { useTailStream } from "./useTailStream";
|
||
|
||
const claimStream = useTailStream("/api/claims/stream");
|
||
const remitStream = useTailStream("/api/remittances/stream");
|
||
|
||
useEffect(() => {
|
||
if (claimStream.events.length > 0) refetchDebounced();
|
||
}, [claimStream.events.length, refetchDebounced]);
|
||
|
||
useEffect(() => {
|
||
if (remitStream.events.length > 0) refetchDebounced();
|
||
}, [remitStream.events.length, refetchDebounced]);
|
||
```
|
||
|
||
- [ ] **Step 4: Run, confirm PASS**
|
||
|
||
```bash
|
||
npx vitest run src/hooks/
|
||
```
|
||
|
||
- [ ] **Step 5: Commit**
|
||
|
||
```bash
|
||
git add src/hooks/useInboxLanes.ts src/hooks/useInboxLanes.test.ts
|
||
git commit -m "feat(sp6): useInboxLanes refetches on SP5 tail events"
|
||
```
|
||
|
||
---
|
||
|
||
## Phase 8 — Docs + smoke
|
||
|
||
## Task 20: README "Inbox" section
|
||
|
||
**Files:**
|
||
- Modify: `README.md`
|
||
|
||
- [ ] **Step 1: Find the existing "Live updates" section**
|
||
|
||
```bash
|
||
grep -n "Live updates\|## API\|## Live" README.md | head -5
|
||
```
|
||
|
||
- [ ] **Step 2: Add a new section after "Live updates"**
|
||
|
||
```markdown
|
||
## Inbox
|
||
|
||
`/inbox` is the working surface. Four lanes, dark by default (Ticker Tape aesthetic):
|
||
|
||
- **Rejected** — claims whose 999 set-level response was R or E. Re-submit in bulk.
|
||
- **Candidates** — remits whose CLP-claim-id didn't match exactly; each one shows its top scored claim. One-click manual match or dismiss.
|
||
- **Unmatched** — claims still waiting for a remit, and remits with no candidates above the threshold.
|
||
- **Done today** — terminal state transitions in the last 24 hours.
|
||
|
||
### Inbox endpoints
|
||
|
||
| Method | Path | Notes |
|
||
|--------|------|-------|
|
||
| GET | `/api/inbox/lanes` | All four lanes in one call. |
|
||
| POST | `/api/inbox/candidates/{remit_id}/match` | Manual match. 409 on conflict. |
|
||
| POST | `/api/inbox/candidates/dismiss` | `{pairs: [{claim_id, remit_id}]}`. Session-scoped. |
|
||
| POST | `/api/inbox/rejected/resubmit` | `{claim_ids: [...]}`. 409 on non-rejected. |
|
||
| GET | `/api/inbox/export.csv?lane=<lane>` | Streams CSV of the lane's rows. |
|
||
|
||
### Scoring
|
||
|
||
4-field weighted (sum = 100):
|
||
|
||
| Field | Weight | Rule |
|
||
|-------|--------|------|
|
||
| Patient control number | 40 | Exact match, normalized (case + leading zeros). |
|
||
| Service date | 25 | Linear decay over ±3 days. |
|
||
| Charge amount | 20 | Linear decay over ±10%. |
|
||
| Provider NPI | 15 | Exact match. Missing → 0. |
|
||
|
||
Tiers: **strong** (≥75, full opacity, Match enabled), **weak** (50–74, dimmed), **hidden** (<50, not surfaced).
|
||
```
|
||
|
||
- [ ] **Step 3: Commit**
|
||
|
||
```bash
|
||
git add README.md
|
||
git commit -m "docs(sp6): README — Inbox section"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 21: Manual smoke test
|
||
|
||
- [ ] **Step 1: Start backend**
|
||
|
||
```bash
|
||
cd backend && CYCLONE_DB_URL=sqlite:///$(mktemp -d)/smoke.db ../.venv/bin/python -m cyclone &
|
||
```
|
||
|
||
- [ ] **Step 2: Start frontend dev server**
|
||
|
||
```bash
|
||
cd .. && npm run dev &
|
||
```
|
||
|
||
- [ ] **Step 3: Run the existing 837 + 999 + 835 seed scripts**
|
||
|
||
```bash
|
||
curl -X POST -F "file=@backend/tests/fixtures/sample_837.txt" http://127.0.0.1:8000/api/parse-837
|
||
curl -X POST -F "file=@backend/tests/fixtures/sample_999_reject.999" http://127.0.0.1:8000/api/parse-999
|
||
curl -X POST -F "file=@backend/tests/fixtures/sample_835.txt" http://127.0.0.1:8000/api/parse-835
|
||
```
|
||
|
||
Expected: 200 on each. If any fixture file doesn't exist, locate the appropriate one in `backend/tests/fixtures/` or generate a minimal one.
|
||
|
||
- [ ] **Step 4: Hit the lanes endpoint**
|
||
|
||
```bash
|
||
curl http://127.0.0.1:8000/api/inbox/lanes | python3 -m json.tool | head -40
|
||
```
|
||
|
||
Expected: non-empty `rejected` (from 999), `candidates` or `unmatched` (from 835), and `done_today` if reconciliation advanced any claim state.
|
||
|
||
- [ ] **Step 5: Open the Inbox page**
|
||
|
||
Navigate to `http://127.0.0.1:5173/inbox` (or whatever port Vite uses). Verify:
|
||
|
||
- All four lane headers visible.
|
||
- Rows populate from the API.
|
||
- Multi-select → bulk bar appears.
|
||
- Click row → opens drawer (or at minimum, logs the row id).
|
||
- Match button on a candidate row → calls API, row disappears.
|
||
|
||
- [ ] **Step 6: Kill servers**
|
||
|
||
```bash
|
||
kill %1 %2 2>/dev/null
|
||
```
|
||
|
||
- [ ] **Step 7: Final full-suite run**
|
||
|
||
```bash
|
||
cd backend && ../.venv/bin/pytest -q
|
||
cd .. && npx vitest run --reporter=dot
|
||
```
|
||
|
||
Expected: both green.
|
||
|
||
- [ ] **Step 8: Final commit + branch ready for review**
|
||
|
||
```bash
|
||
git log --oneline main..HEAD
|
||
git push -u origin sp6-workflow-inbox
|
||
```
|
||
|
||
---
|
||
|
||
## Self-review checklist
|
||
|
||
- [x] **Spec coverage:**
|
||
- §2.1 Ticker Tape aesthetic → T12
|
||
- §2.2 Four lanes ordered by urgency → T6 (computation), T17 (page)
|
||
- §2.3 Row contents (rail, ID, status, time, charge, payer, patient, score sparkline) → T14
|
||
- §2.4 Actions (manual match, dismiss, bulk dismiss, bulk resubmit, bulk export) → T8, T9, T18
|
||
- §2.5 Live tail integration → T19
|
||
- §3.1 Add REJECTED to ClaimState enum → T1
|
||
- §3.2 New columns (rejection_reason, rejected_at, resubmit_count) + state-history index → T2
|
||
- §3.3 No new claim_candidates table → confirmed (T6 computes on read)
|
||
- §4.1 999 → REJECTED state → T3, T4
|
||
- §4.2 835 unmatched → candidates (no auto-match) → T6
|
||
- §4.3 4-field weighted scoring → T5
|
||
- §4.4 New endpoints → T7, T8, T9, T10
|
||
- §4.5 Dismissed-pairs session store → T7 (init) + T8 (mutate)
|
||
- §5.1 Frontend files → T11–T19
|
||
- §5.2 Routing + sidebar nav → T13
|
||
- §5.3 Component responsibilities → T14–T17
|
||
- §5.4 Tail integration → T19
|
||
- §5.5 Bulk action UI → T18
|
||
- §6 Error handling → T4 (orphan 999 logging), T8 (409), T17 (error UI), T18 (export)
|
||
- §7 Testing strategy → covered per task
|
||
- §9 Open impl questions → T12 (typography choice), T17 (lane widths = `min-w-[280px]` flex), T13 (landing page deferred)
|
||
|
||
- [x] **Placeholder scan:** No "TODO", "implement later", "add appropriate error handling" without code. Every code block is runnable.
|
||
|
||
- [x] **Type consistency:** `ScoreBreakdown` fields `patient/date/amount/provider` consistent across scoring.py, inbox-api.ts, InboxRow.tsx, CandidateBreakdown.tsx. `ClaimState.REJECTED` consistent across db.py, inbox_state.py, api.py. `dismissed_pairs` set semantics consistent.
|
||
|
||
- [x] **No spec gaps:** All 9 spec sections map to at least one task.
|
||
|
||
---
|
||
|
||
## Execution handoff
|
||
|
||
Plan complete and saved to `docs/superpowers/plans/2026-06-20-cyclone-workflow-automation.md`. 21 tasks.
|
||
|
||
**Two execution options:**
|
||
|
||
1. **Subagent-Driven (recommended)** — I dispatch a fresh subagent per task, review between tasks, fast iteration. Best for plans this size.
|
||
2. **Inline Execution** — Execute tasks in this session using `executing-plans`, batch with checkpoints.
|
||
|
||
Which approach? |