feat(sp37): populate batches.transaction_set_control_number on 837P ingest
The parser now captures the source 837's ST02 (transaction set control number) on the Envelope model as 'transaction_set_control_number'. add_record reads it off the envelope and stores it on the Batch row, mirroring how 'envelope.control_number' (ISA13) was already handled. Unlocks the SP37 join-key update so 999 set_control_number (AK201) values resolve back to the right batch via Pass 1.
This commit is contained in:
@@ -221,6 +221,13 @@ class Batch(Base):
|
|||||||
totals_json: Mapped[Optional[dict]] = mapped_column(JSONText, nullable=True)
|
totals_json: Mapped[Optional[dict]] = mapped_column(JSONText, nullable=True)
|
||||||
validation_json: Mapped[Optional[dict]] = mapped_column(JSONText, nullable=True)
|
validation_json: Mapped[Optional[dict]] = mapped_column(JSONText, nullable=True)
|
||||||
raw_result_json: Mapped[Optional[dict]] = mapped_column(JSONText, nullable=True)
|
raw_result_json: Mapped[Optional[dict]] = mapped_column(JSONText, nullable=True)
|
||||||
|
# SP37 Task 2: source 837's ST02 (transaction set control number).
|
||||||
|
# Populated from ``Envelope.transaction_set_control_number`` by
|
||||||
|
# ``store.write.add_record`` for 837P batches; NULL for 835 batches
|
||||||
|
# (the column is an 837P-specific join key for 999 AK2 resolution).
|
||||||
|
# Migration 0020 adds the column additively; no backfill required for
|
||||||
|
# pre-existing rows that lack the value.
|
||||||
|
transaction_set_control_number: Mapped[Optional[str]] = mapped_column(String(32), nullable=True)
|
||||||
|
|
||||||
claims: Mapped[list["Claim"]] = relationship(
|
claims: Mapped[list["Claim"]] = relationship(
|
||||||
back_populates="batch", cascade="all, delete-orphan"
|
back_populates="batch", cascade="all, delete-orphan"
|
||||||
|
|||||||
@@ -120,6 +120,13 @@ class Envelope(_Base):
|
|||||||
implementation_guide: str | None = None
|
implementation_guide: str | None = None
|
||||||
# SP3 P1 T2: BHT06 transaction type code (was: transaction_set_purpose_code, which is BHT02).
|
# SP3 P1 T2: BHT06 transaction type code (was: transaction_set_purpose_code, which is BHT02).
|
||||||
transaction_type_code: str | None = None
|
transaction_type_code: str | None = None
|
||||||
|
# SP37 Task 2: X12 ST02 (transaction set control number). Distinct
|
||||||
|
# from ``control_number`` above, which is the ISA13 interchange
|
||||||
|
# control number. 999 acks echo ST02 back as AK201, so this is the
|
||||||
|
# join key that lets ``add_record``'s batch row round-trip back to
|
||||||
|
# its source 837. Populated only by the 837P parser today; other
|
||||||
|
# parsers share this class but leave the field None.
|
||||||
|
transaction_set_control_number: str | None = None
|
||||||
|
|
||||||
|
|
||||||
class BatchSummary(_Base):
|
class BatchSummary(_Base):
|
||||||
|
|||||||
@@ -83,6 +83,12 @@ def _build_envelope(segments: list[list[str]], input_file: str = "") -> tuple[En
|
|||||||
except (IndexError, ValueError) as exc:
|
except (IndexError, ValueError) as exc:
|
||||||
log.warning("Could not parse BHT date: %s", exc)
|
log.warning("Could not parse BHT date: %s", exc)
|
||||||
elif seg[0] == "ST" and envelope is not None:
|
elif seg[0] == "ST" and envelope is not None:
|
||||||
|
# SP37 Task 2: capture ST02 (transaction set control number).
|
||||||
|
# 999 acks echo this back as AK201, so this is what makes the
|
||||||
|
# batch row joinable once the 999 ingests. Distinct from ISA13
|
||||||
|
# (which is already on ``control_number``).
|
||||||
|
if len(seg) > 2:
|
||||||
|
envelope = envelope.model_copy(update={"transaction_set_control_number": seg[2].strip()})
|
||||||
if len(seg) > 3:
|
if len(seg) > 3:
|
||||||
envelope = envelope.model_copy(update={"implementation_guide": seg[3]})
|
envelope = envelope.model_copy(update={"implementation_guide": seg[3]})
|
||||||
return envelope, summary
|
return envelope, summary
|
||||||
|
|||||||
@@ -77,6 +77,17 @@ def add_record(record: BatchRecord, *, event_bus=None) -> None:
|
|||||||
totals_json=None,
|
totals_json=None,
|
||||||
validation_json=None,
|
validation_json=None,
|
||||||
raw_result_json=json.loads(record.result.model_dump_json()),
|
raw_result_json=json.loads(record.result.model_dump_json()),
|
||||||
|
# SP37 Task 2: mirror the parsed 837's ST02 onto the batch
|
||||||
|
# row so 999 AK201 set_control_numbers can resolve back via
|
||||||
|
# Pass 1. The ``getattr`` chain handles the 835 path: the
|
||||||
|
# shared ``Envelope`` class is used by both 837P and 835
|
||||||
|
# parsers, but only ``parse_837`` populates this field — for
|
||||||
|
# 835 records it stays ``None`` and the column is NULL.
|
||||||
|
transaction_set_control_number=getattr(
|
||||||
|
getattr(record.result, "envelope", None),
|
||||||
|
"transaction_set_control_number",
|
||||||
|
None,
|
||||||
|
),
|
||||||
)
|
)
|
||||||
s.add(batch_row)
|
s.add(batch_row)
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,103 @@
|
|||||||
|
"""SP37 Task 2: add_record populates Batch.transaction_set_control_number.
|
||||||
|
|
||||||
|
The column should mirror the source 837 envelope's
|
||||||
|
``transaction_set_control_number`` (ST02) so the join-key update in
|
||||||
|
Task 3 can resolve 999 ``set_control_number`` (AK201) values back to
|
||||||
|
the right batch. The 835 path leaves the column NULL because the
|
||||||
|
field is an 837P-specific join key (835 remittances don't need to
|
||||||
|
resolve back to a source 837 — they're the response side of the loop).
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import date, datetime, timezone
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from cyclone import db as db_mod
|
||||||
|
from cyclone.db import Batch
|
||||||
|
from cyclone.parsers.parse_837 import parse as parse_837_text
|
||||||
|
from cyclone.parsers.payer import PayerConfig
|
||||||
|
from cyclone.store import store as cycl_store
|
||||||
|
from cyclone.store.records import BatchRecord837, BatchRecord835
|
||||||
|
|
||||||
|
FIXTURE_837 = Path(__file__).parent / "fixtures" / "minimal_837p.txt"
|
||||||
|
FIXTURE_835 = Path(__file__).parent / "fixtures" / "minimal_835.txt"
|
||||||
|
|
||||||
|
|
||||||
|
def test_add_record_populates_transaction_set_control_number_for_837():
|
||||||
|
"""parse_837 → add_record writes the parsed ST02 onto the Batch row.
|
||||||
|
|
||||||
|
The fixture's ST02 (``991102977``) must round-trip into the new
|
||||||
|
``batches.transaction_set_control_number`` column. This is the
|
||||||
|
join key Task 3's 999-ingest Pass 1 update relies on.
|
||||||
|
"""
|
||||||
|
text = FIXTURE_837.read_text()
|
||||||
|
parsed = parse_837_text(text, PayerConfig.co_medicaid())
|
||||||
|
|
||||||
|
record = BatchRecord837(
|
||||||
|
id="b-txn-cn-1",
|
||||||
|
input_filename="minimal_837p.txt",
|
||||||
|
parsed_at=datetime.now(timezone.utc),
|
||||||
|
result=parsed,
|
||||||
|
)
|
||||||
|
cycl_store.add(record)
|
||||||
|
|
||||||
|
with db_mod.SessionLocal()() as s:
|
||||||
|
row = s.get(Batch, "b-txn-cn-1")
|
||||||
|
assert row is not None
|
||||||
|
assert row.transaction_set_control_number == parsed.envelope.transaction_set_control_number
|
||||||
|
# Sanity: the fixture's ST02 must be the parsed-and-stored value.
|
||||||
|
assert row.transaction_set_control_number == "991102977"
|
||||||
|
|
||||||
|
|
||||||
|
def test_envelope_model_exposes_transaction_set_control_number():
|
||||||
|
"""The Envelope Pydantic model carries the new field with a None default.
|
||||||
|
|
||||||
|
Guards against a regression where someone removes the field from
|
||||||
|
the model — the rest of the chain (parser + ORM + write path)
|
||||||
|
silently degrades to None if the model loses the attribute.
|
||||||
|
"""
|
||||||
|
from cyclone.parsers.models import Envelope
|
||||||
|
|
||||||
|
env = Envelope(
|
||||||
|
sender_id="S",
|
||||||
|
receiver_id="R",
|
||||||
|
control_number="000000001",
|
||||||
|
transaction_date=date(2026, 1, 1),
|
||||||
|
)
|
||||||
|
assert env.transaction_set_control_number is None
|
||||||
|
|
||||||
|
env2 = env.model_copy(update={"transaction_set_control_number": "0001"})
|
||||||
|
assert env2.transaction_set_control_number == "0001"
|
||||||
|
|
||||||
|
|
||||||
|
def test_add_record_leaves_835_column_null():
|
||||||
|
"""835 batches don't carry an ST02 join key; column stays NULL.
|
||||||
|
|
||||||
|
The shared ``Envelope`` class is used by both 837P and 835 parsers,
|
||||||
|
but only ``parse_837`` populates ``transaction_set_control_number``.
|
||||||
|
For 835 records the field is None and ``add_record`` writes NULL
|
||||||
|
to the column — verified end-to-end via a real 835 ingest.
|
||||||
|
"""
|
||||||
|
text = FIXTURE_835.read_text()
|
||||||
|
from cyclone.parsers.parse_835 import parse as parse_835_text
|
||||||
|
parsed835 = parse_835_text(text, payer_config=None) # type: ignore[arg-type]
|
||||||
|
|
||||||
|
# Sanity: the 835 envelope also has the field (shared model class),
|
||||||
|
# but the parser doesn't populate it — so it must be None.
|
||||||
|
assert parsed835.envelope.transaction_set_control_number is None
|
||||||
|
|
||||||
|
record = BatchRecord835(
|
||||||
|
id="b-txn-cn-835-1",
|
||||||
|
input_filename="minimal_835.txt",
|
||||||
|
parsed_at=datetime.now(timezone.utc),
|
||||||
|
result=parsed835,
|
||||||
|
)
|
||||||
|
cycl_store.add(record)
|
||||||
|
|
||||||
|
with db_mod.SessionLocal()() as s:
|
||||||
|
row = s.get(Batch, "b-txn-cn-835-1")
|
||||||
|
assert row is not None
|
||||||
|
assert row.transaction_set_control_number is None
|
||||||
|
# And the kind/kind round-trip is correct (sanity check that
|
||||||
|
# we did exercise the 835 path, not the 837 path).
|
||||||
|
assert row.kind == "835"
|
||||||
Reference in New Issue
Block a user