feat(sp6): wire 999 ingest → claim rejected state

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.
This commit is contained in:
Tyler
2026-06-20 18:29:28 -06:00
parent 78ed75a31d
commit f4910fd94d
2 changed files with 100 additions and 0 deletions
+25
View File
@@ -29,6 +29,7 @@ from fastapi.responses import JSONResponse, StreamingResponse
from pydantic import ValidationError from pydantic import ValidationError
from cyclone import __version__ from cyclone import __version__
from cyclone.inbox_state import apply_999_rejections
from cyclone.parsers.exceptions import CycloneParseError from cyclone.parsers.exceptions import CycloneParseError
from cyclone.parsers.models import BatchSummary, ClaimOutput, Envelope, ParseResult from cyclone.parsers.models import BatchSummary, ClaimOutput, Envelope, ParseResult
from cyclone.parsers.models_270 import ( 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") @app.post("/api/parse-999")
async def parse_999_endpoint( async def parse_999_endpoint(
request: Request,
file: UploadFile = File(...), file: UploadFile = File(...),
) -> Any: ) -> Any:
"""Parse a 999 ACK file, persist a row, and return JSON. """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). # Build the raw 999 text from the parsed result (round-trip).
raw_999_text = serialize_999(result, interchange_control_number=icn or "000000001") 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( row = store.add_ack(
source_batch_id=synthetic_id, source_batch_id=synthetic_id,
accepted_count=accepted, accepted_count=accepted,
+75
View File
@@ -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 "")