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:
@@ -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.parse_ta1 import parse_ta1_text
|
||||||
from cyclone.parsers.serialize_270 import serialize_270
|
from cyclone.parsers.serialize_270 import serialize_270
|
||||||
from cyclone.parsers.serialize_999 import serialize_999
|
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.batch_ack_builder import build_ack_for_batch
|
||||||
from cyclone.parsers.validator_835 import validate as validate_835
|
from cyclone.parsers.validator_835 import validate as validate_835
|
||||||
from cyclone.pubsub import EventBus
|
from cyclone.pubsub import EventBus
|
||||||
@@ -901,13 +901,31 @@ def inbox_dismiss_candidates(body: dict):
|
|||||||
|
|
||||||
|
|
||||||
@app.post("/api/inbox/rejected/resubmit")
|
@app.post("/api/inbox/rejected/resubmit")
|
||||||
def inbox_resubmit_rejected(body: dict):
|
def inbox_resubmit_rejected(
|
||||||
"""Bulk move REJECTED claims back to SUBMITTED."""
|
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 []
|
ids = body.get("claim_ids") or []
|
||||||
if not ids:
|
if not ids:
|
||||||
raise HTTPException(400, "claim_ids required")
|
raise HTTPException(400, "claim_ids required")
|
||||||
accepted: list[str] = []
|
accepted: list[str] = []
|
||||||
conflicts: list[dict] = []
|
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:
|
with db.SessionLocal()() as s:
|
||||||
for cid in ids:
|
for cid in ids:
|
||||||
c = s.get(Claim, cid)
|
c = s.get(Claim, cid)
|
||||||
@@ -928,8 +946,52 @@ def inbox_resubmit_rejected(body: dict):
|
|||||||
c.rejected_at = None
|
c.rejected_at = None
|
||||||
c.resubmit_count = (c.resubmit_count or 0) + 1
|
c.resubmit_count = (c.resubmit_count or 0) + 1
|
||||||
accepted.append(cid)
|
accepted.append(cid)
|
||||||
|
accepted_with_rows.append((cid, c))
|
||||||
s.commit()
|
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")
|
@app.get("/api/inbox/export.csv")
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ Tasks 7-10 (T7-T10).
|
|||||||
"""
|
"""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import io
|
||||||
|
import zipfile
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
import pytest
|
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"]
|
assert "text/csv" in r.headers["content-type"]
|
||||||
body = r.text
|
body = r.text
|
||||||
assert "id" in body.splitlines()[0]
|
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"}
|
||||||
|
|||||||
Reference in New Issue
Block a user