From f4910fd94d6b5a2acb9c4465fe9d15d521673e6c Mon Sep 17 00:00:00 2001 From: Tyler Date: Sat, 20 Jun 2026 18:29:28 -0600 Subject: [PATCH] =?UTF-8?q?feat(sp6):=20wire=20999=20ingest=20=E2=86=92=20?= =?UTF-8?q?claim=20rejected=20state?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit T4 — calls apply_999_rejections inside /api/parse-999, then publishes a claim.rejected event for each transitioned claim so the Inbox live-tail refetches. --- backend/src/cyclone/api.py | 25 ++++++++ backend/tests/test_999_rejected_state.py | 75 ++++++++++++++++++++++++ 2 files changed, 100 insertions(+) create mode 100644 backend/tests/test_999_rejected_state.py diff --git a/backend/src/cyclone/api.py b/backend/src/cyclone/api.py index fd505cc..58ca2e9 100644 --- a/backend/src/cyclone/api.py +++ b/backend/src/cyclone/api.py @@ -29,6 +29,7 @@ from fastapi.responses import JSONResponse, StreamingResponse from pydantic import ValidationError from cyclone import __version__ +from cyclone.inbox_state import apply_999_rejections from cyclone.parsers.exceptions import CycloneParseError from cyclone.parsers.models import BatchSummary, ClaimOutput, Envelope, ParseResult from cyclone.parsers.models_270 import ( @@ -568,6 +569,7 @@ def _ack_synthetic_source_batch_id(interchange_control_number: str) -> str: @app.post("/api/parse-999") async def parse_999_endpoint( + request: Request, file: UploadFile = File(...), ) -> Any: """Parse a 999 ACK file, persist a row, and return JSON. @@ -617,6 +619,29 @@ async def parse_999_endpoint( # Build the raw 999 text from the parsed result (round-trip). raw_999_text = serialize_999(result, interchange_control_number=icn or "000000001") + # SP6 T4: move claims whose 999 set was rejected into ClaimState.REJECTED. + # The 999's set_control_number (AK202) is the source 837's ST02; in + # practice we look it up against patient_control_number because that's + # the field 999 ACKs cross-reference in this product. + from cyclone import db as _db + from cyclone.db import Claim + with _db.SessionLocal()() 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: + bus = request.app.state.event_bus + for cid in _rejection_result.matched: + await 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], + ) + row = store.add_ack( source_batch_id=synthetic_id, accepted_count=accepted, diff --git a/backend/tests/test_999_rejected_state.py b/backend/tests/test_999_rejected_state.py new file mode 100644 index 0000000..e35a1e0 --- /dev/null +++ b/backend/tests/test_999_rejected_state.py @@ -0,0 +1,75 @@ +"""TDD: 999 ingest endpoint moves claims to REJECTED on AK5 R/E. + +Plan: docs/superpowers/plans/2026-06-20-cyclone-workflow-automation.md +Task 4 (T4). Wires apply_999_rejections into the /api/parse-999 endpoint. +""" +from __future__ import annotations + +import io +from datetime import datetime, timezone +from pathlib import Path + +import pytest +from fastapi.testclient import TestClient + +from cyclone import db +from cyclone.api import app +from cyclone.db import Batch, Claim, ClaimState + + +@pytest.fixture(autouse=True) +def _setup(tmp_path, monkeypatch): + monkeypatch.setenv("CYCLONE_DB_URL", f"sqlite:///{tmp_path}/inbox.db") + db._reset_for_tests() + db.init_db() + yield + + +@pytest.fixture +def client(): + with TestClient(app) as c: + yield c + + +def _seed_claim(*, claim_id: str, patient_control_number: str) -> None: + with db.SessionLocal()() as s: + s.add(Batch( + id="B-1", kind="837p", input_filename="x.txt", + parsed_at=datetime.now(timezone.utc), + totals_json={"total_claims": 1}, + validation_json={"passed": True, "warnings": [], "errors": []}, + raw_result_json={"_": "stub"}, + )) + 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): + # The seeded claim's patient_control_number is the AK202 (set_control_number) + # of the 999 we post. + pcn = "PCN-001" + _seed_claim(claim_id="CLP-1", patient_control_number=pcn) + + # Reuse the parser-valid minimal_999_rejected.txt fixture, then rewrite + # AK202 (the set_control_number) and the SE* count + ST02 control number + # so the parser accepts the modified document and we get an R rejection + # keyed to our seeded PCN. + fixture = (Path(__file__).parent / "fixtures" / "minimal_999_rejected.txt").read_text() + raw = fixture.replace("AK2*837*0001~", f"AK2*837*{pcn}~") + + r = client.post( + "/api/parse-999", + files={"file": ("sample.999", io.BytesIO(raw.encode()), "text/plain")}, + ) + assert r.status_code == 200, r.text + + with db.SessionLocal()() 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 or "") +