feat(sp41): serialize_837 overload for member-week batches
This commit is contained in:
@@ -48,6 +48,7 @@ from __future__ import annotations
|
|||||||
import logging
|
import logging
|
||||||
from datetime import date, datetime
|
from datetime import date, datetime
|
||||||
from decimal import Decimal
|
from decimal import Decimal
|
||||||
|
from types import SimpleNamespace
|
||||||
|
|
||||||
from cyclone.parsers.models import ClaimOutput
|
from cyclone.parsers.models import ClaimOutput
|
||||||
|
|
||||||
@@ -686,4 +687,144 @@ def serialize_837_for_resubmit(
|
|||||||
interchange_control_number=f"{interchange_index:09d}",
|
interchange_control_number=f"{interchange_index:09d}",
|
||||||
group_control_number=str(interchange_index),
|
group_control_number=str(interchange_index),
|
||||||
**kwargs,
|
**kwargs,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Pipeline B overload: MemberWeekBatch → one 837P, one CLM per visit
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def _build_member_week_claim(visit, claim_id: str) -> tuple[str, str, str]:
|
||||||
|
"""Build the CLM / SV1 / DTP*472 segments for a single MemberWeekBatch visit.
|
||||||
|
|
||||||
|
Returns a tuple of three segment strings (CLM, SV1, DTP*472) emitted in
|
||||||
|
document order. The visit is a :class:`cyclone.rebill.reconcile.VisitRow`
|
||||||
|
— only ``date`` / ``member_id`` / ``procedure`` / ``billed`` are read.
|
||||||
|
"""
|
||||||
|
# Minimal stand-ins for the Pydantic models that ``_build_clm`` /
|
||||||
|
# ``_build_sv1`` expect. SimpleNamespace avoids constructing full
|
||||||
|
# ClaimOutput / ServiceLine objects just to drop the member-level
|
||||||
|
# context (subscriber address, billing provider NPI, etc.) that
|
||||||
|
# Pipeline B doesn't have on its input shape.
|
||||||
|
procedure = SimpleNamespace(qualifier="HC", code=visit.procedure, modifiers=[])
|
||||||
|
claim = SimpleNamespace(
|
||||||
|
claim_id=claim_id,
|
||||||
|
total_charge=visit.billed,
|
||||||
|
place_of_service="11",
|
||||||
|
facility_code_qualifier="B",
|
||||||
|
frequency_code="1",
|
||||||
|
provider_signature="Y",
|
||||||
|
assignment="Y",
|
||||||
|
benefits_assignment_certification="Y",
|
||||||
|
release_of_info="Y",
|
||||||
|
)
|
||||||
|
line = SimpleNamespace(
|
||||||
|
procedure=procedure,
|
||||||
|
charge=visit.billed,
|
||||||
|
unit_type="UN",
|
||||||
|
units=Decimal("1"),
|
||||||
|
place_of_service="11",
|
||||||
|
dx_pointer=None,
|
||||||
|
)
|
||||||
|
return (
|
||||||
|
_build_clm(claim),
|
||||||
|
_build_sv1(line, dx_pointer=""),
|
||||||
|
_build_dtp_472(visit.date),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def serialize_member_week_batch(
|
||||||
|
batch: "MemberWeekBatch",
|
||||||
|
*,
|
||||||
|
payer_id: str = "CO_TXIX",
|
||||||
|
tpid: str = "11525703",
|
||||||
|
) -> bytes:
|
||||||
|
"""Emit a single 837P envelope containing one CLM per visit in the batch.
|
||||||
|
|
||||||
|
SP41 / Pipeline B. Each :class:`cyclone.rebill.reconcile.VisitRow` in
|
||||||
|
``batch.visits`` becomes its own CLM with one SV1 and one DTP*472
|
||||||
|
service-line date. The envelope wraps all of them under a single
|
||||||
|
ISA/GS/ST header and a single SE/GE/IEA footer, matching the
|
||||||
|
standard clearinghouse batch shape (one envelope, many claims).
|
||||||
|
|
||||||
|
Building blocks are reused from :func:`serialize_837` so segment
|
||||||
|
layout stays consistent: the per-visit CLM is built by
|
||||||
|
``_build_clm`` (with place_of_service ``"11"`` /
|
||||||
|
facility_code_qualifier ``"B"`` / frequency_code ``"1"`` — the
|
||||||
|
canonical outpatient professional defaults), the per-visit SV1 is
|
||||||
|
built by ``_build_sv1`` (HC:<procedure>, 1 unit, no diagnosis
|
||||||
|
pointer), and the service date is built by ``_build_dtp_472``.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
batch: A :class:`cyclone.rebill.pipeline_b.MemberWeekBatch` —
|
||||||
|
one member × one ISO-week worth of rebillable visits.
|
||||||
|
payer_id: The receiver (NM1*40) identifier. Defaults to
|
||||||
|
``"CO_TXIX"`` for CO Medicaid.
|
||||||
|
tpid: The trading-partner / submitter (NM1*41) identifier.
|
||||||
|
Defaults to Gainwell's ``"11525703"``.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
The complete 837P document as ASCII bytes. The caller writes
|
||||||
|
it to disk with HCPF-spec filenames via
|
||||||
|
:func:`cyclone.edi.filenames.build_outbound_filename`.
|
||||||
|
"""
|
||||||
|
# Deterministic control numbers derived from (member, iso_year,
|
||||||
|
# iso_week). Two batches with the same key get the same control
|
||||||
|
# numbers — fine for serialization idempotency, and the
|
||||||
|
# post-emission filename is also deterministic so the operator
|
||||||
|
# sees the same outbound filename on retry. Control-number
|
||||||
|
# uniqueness across different batches isn't required (the 837P
|
||||||
|
# ISA13 / GS06 are regenerated per-transmission by the SFTP
|
||||||
|
# submitter downstream).
|
||||||
|
control = f"{batch.member_id}{batch.iso_year:04d}{batch.iso_week:02d}"
|
||||||
|
interchange_control_number = control[:9].rjust(9, "0")
|
||||||
|
group_control_number = control[:9].lstrip("0") or "1"
|
||||||
|
st_control_number = control[:9].rjust(4, "0")[-4:]
|
||||||
|
|
||||||
|
segments: list[str] = [
|
||||||
|
_build_isa(tpid, payer_id, interchange_control_number),
|
||||||
|
_build_gs(tpid, payer_id, group_control_number),
|
||||||
|
_build_st(st_control_number),
|
||||||
|
_build_bht(
|
||||||
|
transaction_type_code="CH",
|
||||||
|
reference_id=f"MW-{batch.member_id}-W{batch.iso_week:02d}",
|
||||||
|
transaction_date=None,
|
||||||
|
transaction_time=None,
|
||||||
|
),
|
||||||
|
# Submitter block (Loop 1000A) — minimal but spec-valid.
|
||||||
|
# Member-week batches are emitted by the rebill pipeline, not
|
||||||
|
# the operator-facing single-claim download path, so the
|
||||||
|
# production clearhouse contact is not threaded through here
|
||||||
|
# (Task 12's orchestrator can wrap this overload with the
|
||||||
|
# clearhouse config if needed). PER*IC with a placeholder
|
||||||
|
# contact keeps the envelope byte-clean for the SP41 test
|
||||||
|
# suite without coupling this overload to the live Clearhouse
|
||||||
|
# ORM row.
|
||||||
|
_build_nm1("41", "41", tpid, "46", tpid),
|
||||||
|
_build_per("CUSTOMER SERVICE", "8005550100"),
|
||||||
|
# Receiver block (Loop 1000B).
|
||||||
|
_build_nm1("40", "40", payer_id, "46", payer_id),
|
||||||
|
]
|
||||||
|
|
||||||
|
for idx, visit in enumerate(batch.visits, start=1):
|
||||||
|
svc_date = visit.date
|
||||||
|
claim_id = f"MW-{batch.member_id}-{svc_date.isoformat()}-{idx:02d}"
|
||||||
|
clm, sv1, dtp = _build_member_week_claim(visit, claim_id)
|
||||||
|
segments.append(clm)
|
||||||
|
segments.append(sv1)
|
||||||
|
if dtp:
|
||||||
|
segments.append(dtp)
|
||||||
|
|
||||||
|
# SE segment count = ST (1) + everything between ST and SE inclusive.
|
||||||
|
# The existing serialize_837 computes `len(segments) - 2 + 1` because
|
||||||
|
# it subtracts ISA/GS and adds 1 for SE. That math reduces to
|
||||||
|
# `len(segments) - 1` at SE-emit time (since ISA/GS are in the
|
||||||
|
# list at that point and SE has not been added yet).
|
||||||
|
seg_count = len(segments) - 1
|
||||||
|
segments.append(_build_se(seg_count, st_control_number))
|
||||||
|
|
||||||
|
segments.append(f"GE*1*{group_control_number}{_SEG}")
|
||||||
|
segments.append(f"IEA*1*{interchange_control_number}{_SEG}")
|
||||||
|
|
||||||
|
return "".join(segments).encode("ascii")
|
||||||
@@ -73,3 +73,48 @@ def test_override_flag_set_on_past_window_visit_batch():
|
|||||||
assert out_default[0].member_id == "OLD"
|
assert out_default[0].member_id == "OLD"
|
||||||
assert out_default[0].iso_week == 26
|
assert out_default[0].iso_week == 26
|
||||||
assert out_default[0].has_overridden_visits is False
|
assert out_default[0].has_overridden_visits is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_serialize_member_week_batch_emits_one_envelope():
|
||||||
|
"""One 837P envelope per MemberWeekBatch — one CLM + one SV1 + one
|
||||||
|
DTP*472 service date per visit.
|
||||||
|
|
||||||
|
The SP41 plan spec wrote ``DTM*472*`` but the canonical 837P service
|
||||||
|
date segment is ``DTP*472*`` (per :func:`cyclone.parsers.serialize_837.
|
||||||
|
_build_dtp_472` and X12 005010X222A1). This test asserts against the
|
||||||
|
canonical segment name so the batch overload stays consistent with
|
||||||
|
the existing ``serialize_837`` building blocks.
|
||||||
|
"""
|
||||||
|
from cyclone.parsers.serialize_837 import serialize_member_week_batch
|
||||||
|
visits = [
|
||||||
|
_v(date(2026, 6, 23), "J813715", "T1019", "2.32"),
|
||||||
|
_v(date(2026, 6, 25), "J813715", "T1019", "2.32"),
|
||||||
|
]
|
||||||
|
batches = build_pipeline_b_batches(visits, as_of=date(2026, 7, 7), override=False)
|
||||||
|
assert len(batches) == 1
|
||||||
|
body = serialize_member_week_batch(batches[0])
|
||||||
|
text = body.decode("utf-8", errors="ignore") if isinstance(body, bytes) else body
|
||||||
|
# CLM* segment appears twice (once per visit)
|
||||||
|
assert text.count("CLM*") == 2
|
||||||
|
# SV1* appears once per visit (one service line per claim)
|
||||||
|
assert text.count("SV1*") == 2
|
||||||
|
# DTP*472* service-date segment appears twice (canonical 837P segment name)
|
||||||
|
assert text.count("DTP*472*") == 2
|
||||||
|
# Single envelope (single ISA / single IEA), not per-visit envelopes
|
||||||
|
assert text.count("ISA*") == 1
|
||||||
|
assert text.count("IEA*") == 1
|
||||||
|
# Deterministic per-visit claim_id pattern (member_id + date + 1-based idx)
|
||||||
|
assert "MW-J813715-2026-06-23-01" in text
|
||||||
|
assert "MW-J813715-2026-06-25-02" in text
|
||||||
|
|
||||||
|
|
||||||
|
def test_serialize_member_week_batch_return_type_is_bytes():
|
||||||
|
"""Task 14 spec: ``serialize_member_week_batch`` returns ``bytes``
|
||||||
|
(the existing ``serialize_837`` returns ``str``; this overload
|
||||||
|
diverges intentionally so callers can write the file directly)."""
|
||||||
|
from cyclone.parsers.serialize_837 import serialize_member_week_batch
|
||||||
|
visits = [_v(date(2026, 6, 23), "J813715", "T1019", "2.32")]
|
||||||
|
batches = build_pipeline_b_batches(visits, as_of=date(2026, 7, 7), override=False)
|
||||||
|
assert len(batches) == 1
|
||||||
|
body = serialize_member_week_batch(batches[0])
|
||||||
|
assert isinstance(body, bytes)
|
||||||
|
|||||||
Reference in New Issue
Block a user