Files
cyclone/backend/tests/test_parse_999.py
T
Nora 6507a8c874 fix(acks): accept IK5 from Gainwell, trust set-level codes over bogus AK9, surface TA1 in UI
Two related fixes land together because the UI was reporting
"1 accepted 1 rejected" for every 999 even though every inbound file
Gainwell ships has IK5=A.

  1. Gainwell's MFT uses IK5 where the X12 005010X231A1 spec calls
     for AK5 (the per-set accept/reject segment). The parser only
     recognized AK5, so set_responses[0].set_accept_reject.code
     defaulted to 'R' and the count summary showed all rejections.
     _consume_ak2 now accepts either AK5 or IK5; the orchestrator's
     segment-skip set picks up IK5 too. A new fixture
     (minimal_999_ik5_gainwell.txt) is a verbatim copy of one of the
     files in the FromHPE inbound staging dir.

  2. _ack_count_summary (api + scheduler) now trusts the per-set
     IK5 codes over the functional-group AK9. Gainwell's AK9 is
     internally inconsistent — the per-claim IK5=A but the AK9
     reports accepted=1, rejected=1, received=1 (sum exceeds
     received). Trusting the per-set codes restores the right
     answer: accepted=1, rejected=0, code='A'.

  3. The Acks page now has a TA1 envelope register alongside the
     999 register. TA1s are the lower-level sibling of the 999
     (one row per inbound ISA/IEA). The backend surface (parser,
     store, API at /api/ta1-acks) was already in place; this
     adds the UI: Ta1Ack type, listTa1Acks API method, useTa1Acks
     hook, and a Ta1AcksSection card with KPIs + table.

After reprocessing 1056 cached 999s through the new code: every row
shows code='A' with accepted=1, rejected=0 — matches the Gainwell
portal's per-claim accepted state. The user's earlier observation
("the claims look to be accepted in the portal") was correct: the
underlying claim state was always fine, only the displayed count was
wrong.

  - backend/src/cyclone/parsers/parse_999.py | 21 ++-
  - backend/src/cyclone/api.py               | 11 +-
  - backend/src/cyclone/scheduler.py         | 13 +-
  - backend/tests/test_parse_999.py          | 32 ++++
  - backend/tests/fixtures/minimal_999_ik5_gainwell.txt
  - src/types/index.ts                       | 32 ++++
  - src/lib/api.ts                           | 62 +++++-
  - src/hooks/useTa1Acks.ts                  | 26 +++ (new)
  - src/pages/Acks.tsx                       | 209 +++++++++++++++++++-
2026-06-25 00:26:13 -06:00

114 lines
4.5 KiB
Python

"""Tests for the 999 ACK parser orchestrator."""
from __future__ import annotations
from datetime import date
from pathlib import Path
import pytest
from cyclone.parsers.exceptions import CycloneParseError
from cyclone.parsers.parse_999 import parse_999_text
ACCEPTED = Path(__file__).parent / "fixtures" / "minimal_999.txt"
REJECTED = Path(__file__).parent / "fixtures" / "minimal_999_rejected.txt"
# Gainwell's MFT ships the set-level accept/reject segment under the
# sender-specific id ``IK5`` instead of the spec-defined ``AK5`` (X12
# 005010X231A1). This fixture is a verbatim copy of one of the files
# in the FromHPE inbound staging dir — see
# backend/src/cyclone/parsers/parse_999.py for the rationale.
GAINWELL_IK5 = Path(__file__).parent / "fixtures" / "minimal_999_ik5_gainwell.txt"
def test_parse_minimal_999_returns_accepted():
"""The minimal accepted fixture must surface an 'A' ack code on the
functional group and a single accepted set."""
text = ACCEPTED.read_text()
result = parse_999_text(text, input_file=ACCEPTED.name)
assert len(result.functional_group_acks) == 1
fg = result.functional_group_acks[0]
assert fg.ack_code == "A"
assert fg.received_count == 1
assert fg.accepted_count == 1
assert fg.rejected_count == 0
assert fg.ak1.functional_id_code == "HC"
assert fg.ak1.group_control_number == "0001"
def test_parse_999_set_response_accepted():
"""The single set response is an AK5=A (accepted)."""
text = ACCEPTED.read_text()
result = parse_999_text(text, input_file=ACCEPTED.name)
assert len(result.set_responses) == 1
s = result.set_responses[0]
assert s.set_accept_reject.code == "A"
assert s.transaction_set_identifier == "837"
assert s.set_control_number == "0001"
assert s.ak2.functional_id_code == "837"
assert s.segment_errors == [] # no AK3/AK4 in the accepted fixture
def test_parse_999_envelope_built():
"""Envelope must capture sender/receiver/control number from ISA/GS/ST."""
text = ACCEPTED.read_text()
result = parse_999_text(text, input_file=ACCEPTED.name)
env = result.envelope
assert env.sender_id == "SUBMITTERID" # ISA06
assert env.receiver_id == "RECEIVERID" # ISA08
assert env.control_number == "000000001" # ISA13
assert env.implementation_guide == "005010X231A1"
# transaction_date parsed from the GS04 (20240101)
assert env.transaction_date == date(2024, 1, 1)
def test_parse_999_ak3_ak4_optional():
"""A 999 with no AK3/AK4 segments parses cleanly with empty errors."""
text = ACCEPTED.read_text()
result = parse_999_text(text, input_file=ACCEPTED.name)
for s in result.set_responses:
assert s.segment_errors == []
def test_parse_999_rejected_set():
"""The rejected fixture has AK5=R; the parser must surface it."""
text = REJECTED.read_text()
result = parse_999_text(text, input_file=REJECTED.name)
assert len(result.functional_group_acks) == 1
fg = result.functional_group_acks[0]
assert fg.ack_code == "R"
assert fg.rejected_count == 1
assert fg.accepted_count == 0
s = result.set_responses[0]
assert s.set_accept_reject.code == "R"
def test_parse_999_garbage_raises():
"""Non-EDI input must raise CycloneParseError, not return a half-built result."""
with pytest.raises(CycloneParseError):
parse_999_text("not edi at all", input_file="bad.txt")
def test_parse_999_gainwell_ik5_segment_accepted():
"""The IK5 set-level segment Gainwell ships must parse as 'A'.
The X12 005010X231A1 spec calls for ``AK5``; Gainwell's MFT uses
``IK5`` as a sender-specific synonym. The parser must treat either
id as the set-level accept/reject signal so the per-claim
accepted/rejected counts reflect the real outcome (not the bogus
AK9 the same file carries — Gainwell's ``AK9*A*1*1*1`` is
internally inconsistent: accepted + rejected > received).
"""
text = GAINWELL_IK5.read_text()
result = parse_999_text(text, input_file=GAINWELL_IK5.name)
assert len(result.set_responses) == 1
s = result.set_responses[0]
assert s.set_accept_reject.code == "A"
assert s.transaction_set_identifier == "837"
assert s.set_control_number == "991102989"
# AK9 is parsed but the per-set signal is what the UI trusts.
assert result.functional_group_acks[0].ack_code == "A"
assert result.functional_group_acks[0].received_count == 1
# ``summary`` rolls the per-set codes up — this is the field the
# API/UI count summary derives from.
assert result.summary.passed == 1
assert result.summary.failed == 0