From ec9eae7a2c00ccba0d35286cd7f0f9d62a907604 Mon Sep 17 00:00:00 2001 From: Tyler Date: Sat, 20 Jun 2026 20:49:08 -0600 Subject: [PATCH] feat(sp8): resubmit endpoint supports ?download=zip for regenerated 837s MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add an optional ``?download=true`` query param to ``POST /api/inbox/rejected/resubmit`` that returns the same operation result as a ZIP archive of regenerated 837P files (one ``claim-{id}.x12`` per successfully resubmitted claim) rather than the JSON envelope. Why: operators who mass-resubmit rejected claims want to hand the files straight to their clearinghouse; round-tripping through copy- paste is error-prone. Implementation notes: * Uses ``serialize_837_for_resubmit`` so each X12 file in the bundle gets a unique interchange/group control number (back-to-back resubmits would otherwise collide on ISA13/GS06 = "000000001"). * Conflicts and missing ids are deliberately excluded from the ZIP — the user already saw them in the JSON path on prior calls; the download is the "give me the files I asked for" payload. * Empty resubmit + download returns 200 with an empty ZIP so the UI can still hand the user a downloadable artifact. Tests (test_inbox_endpoints.py): 2 new tests covering the success shape (one .x12 per accepted claim) and the conflict-exclusion contract. --- backend/src/cyclone/api.py | 70 ++++++++++++++++- backend/tests/test_inbox_endpoints.py | 103 ++++++++++++++++++++++++++ 2 files changed, 169 insertions(+), 4 deletions(-) diff --git a/backend/src/cyclone/api.py b/backend/src/cyclone/api.py index 0446dc0..cf38481 100644 --- a/backend/src/cyclone/api.py +++ b/backend/src/cyclone/api.py @@ -54,7 +54,7 @@ from cyclone.parsers.parse_999 import parse_999_text from cyclone.parsers.parse_ta1 import parse_ta1_text from cyclone.parsers.serialize_270 import serialize_270 from cyclone.parsers.serialize_999 import serialize_999 -from cyclone.parsers.serialize_837 import SerializeError as SerializeError837, serialize_837 +from cyclone.parsers.serialize_837 import SerializeError as SerializeError837, serialize_837, serialize_837_for_resubmit from cyclone.parsers.batch_ack_builder import build_ack_for_batch from cyclone.parsers.validator_835 import validate as validate_835 from cyclone.pubsub import EventBus @@ -901,13 +901,31 @@ def inbox_dismiss_candidates(body: dict): @app.post("/api/inbox/rejected/resubmit") -def inbox_resubmit_rejected(body: dict): - """Bulk move REJECTED claims back to SUBMITTED.""" +def inbox_resubmit_rejected( + request: Request, + body: dict, + download: bool = Query(False, description="When true, return a ZIP of regenerated 837 files for the resubmitted claims (instead of JSON)."), +): + """Bulk move REJECTED claims back to SUBMITTED. + + With ``?download=true``, the response is a ``application/zip`` archive + containing one ``claim-{id}.x12`` per successfully resubmitted claim + (regenerated via ``serialize_837_for_resubmit`` so each file gets a + unique interchange/group control number). Conflicts are omitted from + the ZIP — they remain visible to the caller via the JSON shape of the + non-download path. Empty resubmit + download → 200 with an empty zip + so the UI can still hand the user a downloadable artifact. + """ ids = body.get("claim_ids") or [] if not ids: raise HTTPException(400, "claim_ids required") accepted: list[str] = [] conflicts: list[dict] = [] + # Track which claims are about to be resubmitted (and their index in + # the bundle) so the download path can serialize them with unique + # control numbers — back-to-back resubmits in the same file would + # otherwise all share ISA13/GS06 = "000000001". + accepted_with_rows: list[tuple[str, "Claim"]] = [] with db.SessionLocal()() as s: for cid in ids: c = s.get(Claim, cid) @@ -928,8 +946,52 @@ def inbox_resubmit_rejected(body: dict): c.rejected_at = None c.resubmit_count = (c.resubmit_count or 0) + 1 accepted.append(cid) + accepted_with_rows.append((cid, c)) s.commit() - return {"ok": True, "resubmitted": accepted, "conflicts": conflicts} + + if not download: + return {"ok": True, "resubmitted": accepted, "conflicts": conflicts} + + # Build a ZIP of regenerated 837s for the accepted claims. Conflicts + # and missing ids are deliberately excluded — the user already saw + # them in the JSON response on prior actions; the download is the + # "give me the files I asked for" payload. + import zipfile + buf = io.BytesIO() + serialize_errors: list[dict] = [] + with zipfile.ZipFile(buf, mode="w", compression=zipfile.ZIP_DEFLATED) as zf: + for idx, (cid, c) in enumerate(accepted_with_rows, start=1): + if not c.raw_json: + serialize_errors.append({"claim_id": cid, "reason": "no raw_json"}) + continue + try: + claim_obj = ClaimOutput.model_validate(c.raw_json) + except Exception as exc: + serialize_errors.append({"claim_id": cid, "reason": f"raw_json invalid: {exc}"}) + continue + try: + text = serialize_837_for_resubmit(claim_obj, interchange_index=idx) + except SerializeError837 as exc: + serialize_errors.append({"claim_id": cid, "reason": str(exc)}) + continue + zf.writestr(f"claim-{cid}.x12", text) + buf.seek(0) + headers = { + "Content-Disposition": ( + f'attachment; filename="resubmit-{len(accepted)}-claims.zip"' + ), + } + # Surface per-claim serialization failures as a custom response header + # so the UI can show "10 resubmitted, 2 couldn't be regenerated" without + # parsing the binary. The header value is JSON-encoded; the UI is + # expected to JSON.parse it after a fetch with response.ok. + if serialize_errors: + headers["X-Cyclone-Serialize-Errors"] = json.dumps(serialize_errors) + return Response( + content=buf.getvalue(), + media_type="application/zip", + headers=headers, + ) @app.get("/api/inbox/export.csv") diff --git a/backend/tests/test_inbox_endpoints.py b/backend/tests/test_inbox_endpoints.py index ecd38e0..388d2d0 100644 --- a/backend/tests/test_inbox_endpoints.py +++ b/backend/tests/test_inbox_endpoints.py @@ -5,6 +5,8 @@ Tasks 7-10 (T7-T10). """ from __future__ import annotations +import io +import zipfile from datetime import datetime, timezone import pytest @@ -141,3 +143,104 @@ def test_export_csv_streams_rows_with_csv_content_type(client: TestClient): assert "text/csv" in r.headers["content-type"] body = r.text assert "id" in body.splitlines()[0] + + +# --------------------------------------------------------------------------- +# /api/inbox/rejected/resubmit?download=true — SP8 bundle download +# --------------------------------------------------------------------------- +# +# These tests seed a REJECTED claim with a real ClaimOutput in raw_json +# (produced by ingesting a real 837 fixture) so the download path has +# something to serialize. The bundle is built in-memory; we unzip the +# response body and assert the per-claim .x12 files round-trip back +# through the parser. + + +def _seed_rejected_claim_with_raw_837(client: TestClient) -> str: + """Parse the canonical CO Medicaid 837, move its claim into REJECTED, + and return the claim_id. The raw_json column gets populated by the + /api/parse-837 endpoint as a side effect of ingesting the file.""" + from pathlib import Path + + fixture = Path("tests/fixtures/co_medicaid_837p.txt").read_text() + r = client.post( + "/api/parse-837", + files={"file": ("claim.txt", io.BytesIO(fixture.encode()), "text/plain")}, + headers={"Accept": "application/json"}, + ) + assert r.status_code == 200, r.text + real_id = r.json()["claims"][0]["claim_id"] + _seed_batch() + with db.SessionLocal()() as s: + c = s.get(Claim, real_id) + assert c is not None + c.state = ClaimState.REJECTED + c.rejection_reason = "test fixture" + s.commit() + return real_id + + +def test_resubmit_with_download_returns_zip_with_one_x12_per_accepted_claim(client: TestClient): + """Happy path: 1 rejected claim → zip with 1 .x12 file that round-trips + back through the parser. Filename in Content-Disposition reflects count.""" + from cyclone.parsers.parse_837 import parse as parse_837 + from cyclone.parsers.payer import PayerConfig + + real_id = _seed_rejected_claim_with_raw_837(client) + + r = client.post( + "/api/inbox/rejected/resubmit?download=true", + json={"claim_ids": [real_id]}, + ) + assert r.status_code == 200, r.text + assert r.headers["content-type"].startswith("application/zip") + cd = r.headers["content-disposition"] + assert "attachment" in cd + # Count in filename comes from the number of accepted (resubmitted) + # claims, not the input list length — conflicts are excluded. + assert "resubmit-1-claims.zip" in cd + + # The zip body is a real archive with one x12 per accepted claim. + buf = io.BytesIO(r.content) + with zipfile.ZipFile(buf) as zf: + names = zf.namelist() + assert names == [f"claim-{real_id}.x12"] + body = zf.read(names[0]).decode("utf-8") + + # And it parses back to the same claim id. + result = parse_837(body, PayerConfig(name="CO_MEDICAID")) + assert any(c.claim_id == real_id for c in result.claims) + + # Side effect: claim actually moved to SUBMITTED. + with db.SessionLocal()() as s: + c = s.get(Claim, real_id) + assert c.state == ClaimState.SUBMITTED + + +def test_resubmit_with_download_excludes_conflicts_from_zip(client: TestClient): + """The download is a 'give me the files I asked for' payload — only + claims that were successfully resubmitted end up in the zip. Missing + ids and non-rejected conflicts are omitted (the user already saw them + in the JSON response on the non-download path). + """ + _seed_batch() + accepted_id = _seed_rejected_claim_with_raw_837(client) + conflict_id = "C-NOT-REJECTED" + with db.SessionLocal()() as s: + s.add(Claim(id=conflict_id, batch_id="B-1", + patient_control_number="X", + state=ClaimState.SUBMITTED)) + s.commit() + + r = client.post( + "/api/inbox/rejected/resubmit?download=true", + json={"claim_ids": [accepted_id, conflict_id, "C-DOES-NOT-EXIST"]}, + ) + assert r.status_code == 200, r.text + # Filename count = accepted count, not input length. + assert "resubmit-1-claims.zip" in r.headers["content-disposition"] + + buf = io.BytesIO(r.content) + with zipfile.ZipFile(buf) as zf: + names = set(zf.namelist()) + assert names == {f"claim-{accepted_id}.x12"}