feat(sp33): cli resubmit-rejected-claims + fixture/test payer_id refresh

- Adds `cyclone resubmit-rejected-claims` to push corrected single-claim
  837 files to the Gainwell ToHPE SFTP dir. Idempotent (stat-then-skip by
  byte size). One persistent paramiko session per batch with
  reconnect-every=50 to dodge MOVEit's silent per-session file cap
  (~200 puts/session, no exception).
- Validates each file via `parse_837` before upload and rejects any
  whose payer_id is not `CO_TXIX` (catches a bad byte-fix early).
- Refreshes test fixtures (`minimal_837p.txt`, `co_medicaid_837p.txt`,
  `co_medicaid_837p_with_renderer.txt`) and the corresponding test
  assertions (`test_payer.py`, `test_payer_summary.py`,
  `test_co_medicaid_fixture.py`, `test_parse_837.py`) from the old
  `SKCO0`/`COHCPF` payer IDs to `CO_TXIX`, matching
  PayerConfig.co_medicaid() and the HCPF 837P Companion Guide.
- Adds `ingest/` to .gitignore — local scratch / production-data
  staging only.
This commit is contained in:
Nora
2026-07-02 21:50:23 -06:00
parent 244015a361
commit f10ab83628
8 changed files with 268 additions and 21 deletions
+2 -2
View File
@@ -17,7 +17,7 @@ NM1*IL*1*Balliache*Marianela****MI*P060946~
N3*1811 PAVILION DR APT 303~
N4*Montrose*CO*814016072~
DMG*D8*19590223*F~
NM1*PR*2*COHCPF*****PI*SKCO0~
NM1*PR*2*CO_TXIX*****PI*CO_TXIX~
CLM*t991102984o1c1d*85.40***12:B:1*Y*A*Y*Y~
REF*G1*3173~
HI*ABK:R69~
@@ -35,7 +35,7 @@ NM1*IL*1*Barella*Victoria****MI*H582447~
N3*1900 Kellie DR~
N4*Montrose*CO*814019524~
DMG*D8*19570727*F~
NM1*PR*2*COHCPF*****PI*SKCO0~
NM1*PR*2*CO_TXIX*****PI*CO_TXIX~
CLM*t991102984o1c2d*155.76***12:B:1*Y*A*Y*Y~
REF*G1*3173~
HI*ABK:R69~
+2 -2
View File
@@ -17,7 +17,7 @@ NM1*IL*1*Balliache*Marianela****MI*P060946~
N3*1811 PAVILION DR APT 303~
N4*Montrose*CO*814016072~
DMG*D8*19590223*F~
NM1*PR*2*COHCPF*****PI*SKCO0~
NM1*PR*2*CO_TXIX*****PI*CO_TXIX~
CLM*t991102984o1c1d*85.40***12:B:1*Y*A*Y*Y~
REF*G1*3173~
HI*ABK:R69~
@@ -36,7 +36,7 @@ NM1*IL*1*Barella*Victoria****MI*H582447~
N3*1900 Kellie DR~
N4*Montrose*CO*814019524~
DMG*D8*19570727*F~
NM1*PR*2*COHCPF*****PI*SKCO0~
NM1*PR*2*CO_TXIX*****PI*CO_TXIX~
CLM*t991102984o1c2d*155.76***12:B:1*Y*A*Y*Y~
REF*G1*3173~
HI*ABK:R69~
+1 -1
View File
@@ -17,7 +17,7 @@ NM1*IL*1*Doe*John****MI*ABC123~
N3*456 Member St~
N4*Denver*CO*80203~
DMG*D8*19800101*M~
NM1*PR*2*COHCPF*****PI*SKCO0~
NM1*PR*2*CO_TXIX*****PI*CO_TXIX~
CLM*CLM001*100.00***12:B:1*Y*A*Y*Y~
REF*G1*PA123~
HI*ABK:Z00~
+2 -2
View File
@@ -36,5 +36,5 @@ def test_subscriber_payer_reused_across_claims():
result = parse(FIXTURE.read_text(), PayerConfig.co_medicaid())
assert result.claims[0].billing_provider.npi == "1881068062"
assert result.claims[1].billing_provider.npi == "1881068062"
assert result.claims[0].payer.id == "SKCO0"
assert result.claims[1].payer.id == "SKCO0"
assert result.claims[0].payer.id == "CO_TXIX"
assert result.claims[1].payer.id == "CO_TXIX"
+1 -1
View File
@@ -18,7 +18,7 @@ def test_parse_minimal_fixture_returns_one_claim():
assert claim.subscriber.last_name == "Doe"
assert claim.subscriber.first_name == "John"
assert claim.subscriber.member_id == "ABC123"
assert claim.payer.id == "SKCO0"
assert claim.payer.id == "CO_TXIX"
assert claim.claim.frequency_code == "1"
assert claim.claim.place_of_service == "12"
assert claim.claim.prior_auth == "PA123"
+9 -9
View File
@@ -5,13 +5,13 @@ The endpoint is the payer-level aggregate that the drill-down UI's
denial rate, and the top 5 NPIs by claim volume for one payer_id,
cached in-process for 60s.
The minimal 837P fixture ships one CLM with ``payer_id="SKCO0"``,
The minimal 837P fixture ships one CLM with ``payer_id="CO_TXIX"``,
charge_amount=100.00; the minimal 835 carries one CLP for the same
claim with total_paid=85.00. So ``/api/payers/SKCO0/summary`` returns
claim with total_paid=85.00. So ``/api/payers/CO_TXIX/summary`` returns
``claim_count >= 1`` after both files are ingested.
Note: the spec calls this ``payer_id`` (the X12 NM1*PR*PI qualifier,
e.g. ``SKCO0``). It is NOT the configured payer name from
e.g. ``CO_TXIX``). It is NOT the configured payer name from
``config/payers.yaml``. The filter key in the store layer is
``Claim.payer_id`` — not the ``payer=`` substring filter used by
``/api/claims?payer=...``.
@@ -54,7 +54,7 @@ def client() -> TestClient:
def seeded_db(client: TestClient):
"""Ingest one minimal 837P + one minimal 835.
Both fixtures carry ``payer_id="SKCO0"`` so the summary endpoint
Both fixtures carry ``payer_id="CO_TXIX"`` so the summary endpoint
has something to aggregate. ``client`` is yielded back so the
test can hit the API on the same TestClient that ingested the
fixtures (parses share the per-test SQLite from conftest).
@@ -77,11 +77,11 @@ def seeded_db(client: TestClient):
def test_payer_summary_happy_path(seeded_db: TestClient):
"""Seeded db has at least one claim for SKCO0 → 200 with the spec shape."""
resp = seeded_db.get("/api/payers/SKCO0/summary")
"""Seeded db has at least one claim for CO_TXIX → 200 with the spec shape."""
resp = seeded_db.get("/api/payers/CO_TXIX/summary")
assert resp.status_code == 200, resp.text
data = resp.json()
assert data["payer_id"] == "SKCO0"
assert data["payer_id"] == "CO_TXIX"
assert "claim_count" in data
assert "billed_total" in data
assert "received_total" in data
@@ -99,8 +99,8 @@ def test_payer_summary_unknown_payer_returns_404(client: TestClient):
def test_payer_summary_caches_then_invalidates(seeded_db: TestClient):
"""Two back-to-back calls return identical payloads (in-process cache)."""
resp1 = seeded_db.get("/api/payers/SKCO0/summary")
resp2 = seeded_db.get("/api/payers/SKCO0/summary")
resp1 = seeded_db.get("/api/payers/CO_TXIX/summary")
resp2 = seeded_db.get("/api/payers/CO_TXIX/summary")
assert resp1.status_code == 200
assert resp2.status_code == 200
assert resp1.json() == resp2.json()