feat(sp8): resubmit endpoint supports ?download=zip for regenerated 837s

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.
This commit is contained in:
Tyler
2026-06-20 20:49:08 -06:00
parent 1764df0cd5
commit ec9eae7a2c
2 changed files with 169 additions and 4 deletions
+103
View File
@@ -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"}