diff --git a/backend/src/cyclone/db.py b/backend/src/cyclone/db.py index 508b89a..b65135d 100644 --- a/backend/src/cyclone/db.py +++ b/backend/src/cyclone/db.py @@ -221,6 +221,13 @@ class Batch(Base): totals_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) + # 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( back_populates="batch", cascade="all, delete-orphan" diff --git a/backend/src/cyclone/parsers/models.py b/backend/src/cyclone/parsers/models.py index 2f3090e..f4b8917 100644 --- a/backend/src/cyclone/parsers/models.py +++ b/backend/src/cyclone/parsers/models.py @@ -120,6 +120,13 @@ class Envelope(_Base): implementation_guide: str | None = None # SP3 P1 T2: BHT06 transaction type code (was: transaction_set_purpose_code, which is BHT02). 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): diff --git a/backend/src/cyclone/parsers/parse_837.py b/backend/src/cyclone/parsers/parse_837.py index 1953d49..5f44d3a 100644 --- a/backend/src/cyclone/parsers/parse_837.py +++ b/backend/src/cyclone/parsers/parse_837.py @@ -83,6 +83,12 @@ def _build_envelope(segments: list[list[str]], input_file: str = "") -> tuple[En except (IndexError, ValueError) as exc: log.warning("Could not parse BHT date: %s", exc) 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: envelope = envelope.model_copy(update={"implementation_guide": seg[3]}) return envelope, summary diff --git a/backend/src/cyclone/store/write.py b/backend/src/cyclone/store/write.py index e4b05a1..8da42d6 100644 --- a/backend/src/cyclone/store/write.py +++ b/backend/src/cyclone/store/write.py @@ -77,6 +77,17 @@ def add_record(record: BatchRecord, *, event_bus=None) -> None: totals_json=None, validation_json=None, 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) diff --git a/backend/tests/test_batch_txn_set_cn.py b/backend/tests/test_batch_txn_set_cn.py new file mode 100644 index 0000000..ef68da5 --- /dev/null +++ b/backend/tests/test_batch_txn_set_cn.py @@ -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" \ No newline at end of file