feat(parsers+api+ui+db): 999 ACK transaction set end-to-end (SP3 P3)

This commit is contained in:
Tyler
2026-06-20 08:03:37 -06:00
parent 7a20f732f2
commit fb2a98fc7a
24 changed files with 2618 additions and 1 deletions
+232 -1
View File
@@ -32,6 +32,9 @@ from cyclone.parsers.models_835 import ParseResult835
from cyclone.parsers.payer import PayerConfig, PayerConfig835
from cyclone.parsers.parse_837 import parse
from cyclone.parsers.parse_835 import parse as parse_835
from cyclone.parsers.parse_999 import parse_999_text
from cyclone.parsers.serialize_999 import serialize_999
from cyclone.parsers.batch_ack_builder import build_ack_for_batch
from cyclone.parsers.validator_835 import validate as validate_835
from cyclone.store import (
AlreadyMatchedError,
@@ -194,6 +197,7 @@ async def parse_837(
payer: str = Query("co_medicaid"),
include_raw_segments: bool = Query(True),
strict: bool = Query(False),
ack: bool = Query(False),
) -> Any:
raw = await file.read()
if not raw:
@@ -252,7 +256,12 @@ async def parse_837(
store.add(rec)
if _client_wants_json(request):
return JSONResponse(content=json.loads(result.model_dump_json()))
body = json.loads(result.model_dump_json())
if ack:
ack_body = _build_and_persist_ack(rec.id)
if ack_body is not None:
body["ack"] = ack_body
return JSONResponse(content=body)
# Default: NDJSON stream.
return StreamingResponse(
@@ -261,6 +270,43 @@ async def parse_837(
)
def _build_and_persist_ack(batch_id: str) -> dict | None:
"""Build a 999 ACK for ``batch_id`` and persist the row.
Returns the ack payload dict (matches the ``/api/parse-999``
response shape so the JSON and NDJSON clients can share the
schema) or None if the build failed. The build is fail-soft:
errors are logged but never abort the 837 ingest, because the
user-visible 837 result is still correct.
"""
try:
ack_result = build_ack_for_batch(batch_id)
except Exception:
log.exception("build_ack_for_batch failed for %s", batch_id)
return None
fg = ack_result.functional_group_acks[0] if ack_result.functional_group_acks else None
if fg is None:
return None
raw_text = serialize_999(ack_result, interchange_control_number=ack_result.envelope.control_number)
row = store.add_ack(
source_batch_id=batch_id,
accepted_count=fg.accepted_count,
rejected_count=fg.rejected_count,
received_count=fg.received_count,
ack_code=fg.ack_code,
raw_json=json.loads(ack_result.model_dump_json()),
)
return {
"id": row.id,
"accepted_count": fg.accepted_count,
"rejected_count": fg.rejected_count,
"received_count": fg.received_count,
"ack_code": fg.ack_code,
"source_batch_id": batch_id,
"raw_999_text": raw_text,
}
def _ndjson_stream(result: ParseResult) -> Iterator[bytes]:
"""Yield one JSON object per line: envelope → claims → summary."""
envelope_obj = (
@@ -441,6 +487,120 @@ def _ndjson_stream_835(result: ParseResult835) -> Iterator[bytes]:
yield (json.dumps({"type": "summary", "data": json.loads(result.summary.model_dump_json())}) + "\n").encode("utf-8")
# --------------------------------------------------------------------------- #
# 999 ACK (Implementation Acknowledgment)
# --------------------------------------------------------------------------- #
def _ack_count_summary(result) -> tuple[int, int, int, str]:
"""Aggregate (received, accepted, rejected, ack_code) from a ParseResult999.
The first functional group carries the canonical counts; falls back
to summing per-set codes if no AK9 was found.
"""
if result.functional_group_acks:
fg = result.functional_group_acks[0]
return (fg.received_count, fg.accepted_count, fg.rejected_count, fg.ack_code)
sets = result.set_responses
received = len(sets)
accepted = sum(1 for s in sets if s.set_accept_reject.code == "A")
rejected = received - accepted
if rejected == 0:
code = "A"
elif accepted == 0:
code = "R"
else:
code = "P"
return (received, accepted, rejected, code)
def _ack_synthetic_source_batch_id(interchange_control_number: str) -> str:
"""Return a synthetic batches.id for a received 999 with no source batch.
The acks.source_batch_id FK requires a row in batches; for received
999s we synthesize an id of the form ``999-<ISA13>``. The synthetic
row is NOT created in batches — the FK enforcement is a no-op in
SQLite without ``PRAGMA foreign_keys=ON`` (the project default for
tests). The dashboard never surfaces these synthetic ids; they exist
solely to satisfy the ORM contract.
"""
return f"999-{(interchange_control_number or '').strip() or '000000001'}"
@app.post("/api/parse-999")
async def parse_999_endpoint(
file: UploadFile = File(...),
) -> Any:
"""Parse a 999 ACK file, persist a row, and return JSON.
Behavior mirrors ``/api/parse-835``:
- 400 on empty / undecodable / malformed EDI (never 500).
- 200 on success with ``{"ack": {id, accepted_count, rejected_count,
received_count, ack_code, raw_999_text}, "parsed": <ParseResult999>}``.
The persisted ``acks.source_batch_id`` is a synthetic id
(``999-<ISA13>``) because a received 999 has no inbound 837 batch
to FK to. The dashboard's `/acks` list surfaces these; operators
can still see which interchange each row came from.
"""
raw = await file.read()
if not raw:
return JSONResponse(
status_code=400,
content={"error": "Empty file", "detail": "Uploaded file contained no bytes."},
)
try:
text = raw.decode("utf-8")
except UnicodeDecodeError as exc:
return JSONResponse(
status_code=400,
content={"error": "Encoding error", "detail": str(exc)},
)
try:
result = parse_999_text(text, input_file=file.filename or "")
except CycloneParseError as exc:
return JSONResponse(
status_code=400,
content={"error": "Parse error", "detail": str(exc)},
)
except Exception as exc: # pragma: no cover - safety net
log.exception("Unexpected parser failure on 999")
return JSONResponse(
status_code=500,
content={"error": "Internal server error", "detail": str(exc)},
)
received, accepted, rejected, ack_code = _ack_count_summary(result)
icn = result.envelope.control_number
synthetic_id = _ack_synthetic_source_batch_id(icn)
# Build the raw 999 text from the parsed result (round-trip).
raw_999_text = serialize_999(result, interchange_control_number=icn or "000000001")
row = store.add_ack(
source_batch_id=synthetic_id,
accepted_count=accepted,
rejected_count=rejected,
received_count=received,
ack_code=ack_code,
raw_json=json.loads(result.model_dump_json()),
)
return JSONResponse(content={
"ack": {
"id": row.id,
"accepted_count": accepted,
"rejected_count": rejected,
"received_count": received,
"ack_code": ack_code,
"source_batch_id": synthetic_id,
"raw_999_text": raw_999_text,
},
"parsed": json.loads(result.model_dump_json()),
})
# --------------------------------------------------------------------------- #
# GET endpoints (read views over the in-memory store)
# --------------------------------------------------------------------------- #
@@ -743,4 +903,75 @@ def list_activity(
}
# --------------------------------------------------------------------------- #
# 999 ACKs (read views)
# --------------------------------------------------------------------------- #
def _ack_to_ui(row) -> dict:
"""Map an ``Ack`` ORM row to the UI shape used by ``/api/acks``.
Field names match the rest of the Cyclone API (snake_case). The
frontend ``useAcks`` hook re-shapes this to the camelCase ``Ack``
interface in ``src/types/index.ts``.
"""
return {
"id": row.id,
"source_batch_id": row.source_batch_id,
"accepted_count": row.accepted_count,
"rejected_count": row.rejected_count,
"received_count": row.received_count,
"ack_code": row.ack_code,
"parsed_at": (
row.parsed_at.isoformat().replace("+00:00", "Z")
if row.parsed_at is not None
else ""
),
}
@app.get("/api/acks")
def list_acks_endpoint(
request: Request,
limit: int = Query(100, ge=1, le=1000),
) -> Any:
"""Return the list of persisted 999 ACKs, newest first."""
rows = store.list_acks()
items = [_ack_to_ui(r) for r in rows[:limit]]
total = len(rows)
returned = len(items)
has_more = total > returned
if _wants_ndjson(request):
return StreamingResponse(
_ndjson_stream_list(items, total, returned, has_more),
media_type="application/x-ndjson",
)
return {
"items": items,
"total": total,
"returned": returned,
"has_more": has_more,
}
@app.get("/api/acks/{ack_id}")
def get_ack_endpoint(ack_id: int) -> dict:
"""Return one persisted ACK row with its parsed detail.
Path param is ``ack_id`` (not ``id``) to avoid shadowing FastAPI's
internal ``id`` name and to keep OpenAPI docs self-describing.
Returns 404 when the ACK is missing — never 500.
"""
from cyclone import db as _db
row = store.get_ack(ack_id)
if row is None:
raise HTTPException(
status_code=404,
detail={"error": "Not found", "detail": f"Ack {ack_id} not found"},
)
body = _ack_to_ui(row)
body["raw_json"] = row.raw_json
return body
__all__ = ["app"]
+33
View File
@@ -328,3 +328,36 @@ class ActivityEvent(Base):
Index("ix_activity_events_ts", text("ts DESC")),
Index("ix_activity_events_kind", "kind"),
)
class Ack(Base):
"""SP3 P3 T13: persisted 999 ACK (Implementation Acknowledgment) row.
One row per generated (auto-ACK) or received (manual upload) 999
document. ``source_batch_id`` is a FK to ``batches.id``: for
auto-generated ACKs this is the originating 837 batch; for
received ACKs we synthesize an id (``999-<icn>``) when no batch
is available — see the ``add_ack`` helper in ``cyclone.store``.
The ``raw_json`` column carries the full ``ParseResult999`` as a
dict (via the ``JSONText`` TypeDecorator) so the detail endpoint
can surface the parsed envelope / AK1 / AK2 / AK5 segments
without re-parsing the raw X12.
"""
__tablename__ = "acks"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
source_batch_id: Mapped[str] = mapped_column(
String(32), ForeignKey("batches.id", ondelete="CASCADE"), nullable=False,
)
accepted_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
rejected_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
received_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
ack_code: Mapped[str] = mapped_column(String(8), nullable=False)
parsed_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
raw_json: Mapped[Optional[dict]] = mapped_column(JSONText, nullable=True)
__table_args__ = (
Index("ix_acks_source_batch_id", "source_batch_id"),
)
@@ -0,0 +1,13 @@
-- version: 2
-- SP3 P3 T13: acks table for 999 ACKs. One row per generated/received 999.
CREATE TABLE acks (
id INTEGER PRIMARY KEY AUTOINCREMENT,
source_batch_id TEXT NOT NULL REFERENCES batches(id) ON DELETE CASCADE,
accepted_count INTEGER NOT NULL DEFAULT 0,
rejected_count INTEGER NOT NULL DEFAULT 0,
received_count INTEGER NOT NULL DEFAULT 0,
ack_code TEXT NOT NULL,
parsed_at DATETIME NOT NULL,
raw_json TEXT
);
CREATE INDEX ix_acks_source_batch_id ON acks(source_batch_id);
+11
View File
@@ -40,6 +40,14 @@ _LAZY_EXPORTS: dict[str, str] = {
"ReassociationTrace": "cyclone.parsers.models_835",
"ServicePayment": "cyclone.parsers.models_835",
"claim_status_label": "cyclone.parsers.models_835",
# models (999 ACK — SP3 P3 T10)
"AcknowledgmentHeader": "cyclone.parsers.models_999",
"FunctionalGroupAck": "cyclone.parsers.models_999",
"ParseResult999": "cyclone.parsers.models_999",
"SegmentContext": "cyclone.parsers.models_999",
"SegmentError": "cyclone.parsers.models_999",
"SetAcceptReject": "cyclone.parsers.models_999",
"SetFunctionalGroupResponse": "cyclone.parsers.models_999",
# CARC lookup (SP3 P2 T6)
"reason_label": "cyclone.parsers.cas_codes",
"all_known_codes": "cyclone.parsers.cas_codes",
@@ -52,6 +60,9 @@ _LAZY_EXPORTS: dict[str, str] = {
# orchestrators
"parse": "cyclone.parsers.parse_837",
"parse_835": "cyclone.parsers.parse_835",
"parse_999": "cyclone.parsers.parse_999",
"serialize_999": "cyclone.parsers.serialize_999",
"build_ack_for_batch": "cyclone.parsers.batch_ack_builder",
# writers
"write_outputs": "cyclone.parsers.writer",
"write_outputs_835": "cyclone.parsers.writer_835",
@@ -0,0 +1,241 @@
"""Build a 999 ACK from a persisted 837P batch.
Used by the ``?ack=true`` path on ``POST /api/parse-837`` (SP3 P3 T15).
The builder reads the batch's persisted claim rows + their validation
reports, then constructs a :class:`ParseResult999` whose per-set AK5
codes reflect each claim's validation outcome:
- "A" (accepted) when ``claim.validation.passed`` is True
- "R" (rejected) when the claim has any validation errors
- "E" (accepted with errors) is reserved for the warnings-only case,
but the current 837P validator promotes all warnings to errors in
``strict`` mode, so v1 keeps the binary A/R split. The split is
documented inline below.
The :class:`FunctionalGroupAck` is derived from the per-set codes
(received = total sets, accepted = #A, rejected = #R, ack_code =
"A" / "R" / "P" for partial). The envelope is sourced from the
batch's stored ``raw_result_json.envelope`` — the receiver is
swapped with the sender (the 999 is sent back the other direction).
"""
from __future__ import annotations
import json
from datetime import date, datetime
from typing import Any
from sqlalchemy import select
from cyclone import db
from cyclone.db import Batch, Claim
from cyclone.parsers.models import BatchSummary, Envelope
from cyclone.parsers.models_999 import (
AcknowledgmentHeader,
FunctionalGroupAck,
ParseResult999,
SetAcceptReject,
SetFunctionalGroupResponse,
)
def _claim_set_code(claim_validation: dict) -> str:
"""Return the AK5 code for a single claim based on its validation report.
Auto-ACK semantics (SP3 P3 T15):
- "A" = accepted (validation.passed is True; no errors and no
warnings to report)
- "R" = rejected (validation.passed is False; errors present)
- "E" = accepted with errors (warnings present but no errors) —
currently unreachable from the 837P validator (warnings are
always paired with errors in practice); reserved for forward
compatibility.
The "rejected" signal uses the same ``validation.passed`` flag the
837P summary uses, so the 999 always agrees with the API response
(no second opinion).
"""
passed = bool(claim_validation.get("passed"))
if passed:
return "A"
return "R"
def _claim_set_code_from_orm(claim: Claim) -> str:
"""Return the AK5 code for one Claim ORM row, reading its raw_json."""
raw = claim.raw_json or {}
validation = raw.get("validation") or {}
return _claim_set_code(validation)
def _parse_envelope_date(value: Any) -> date | None:
"""Coerce an envelope.transaction_date value to a date.
The batch's raw_result_json round-trips through Pydantic so the
stored value may be a date, an ISO string, or a datetime. Try the
cheap paths first; fall back to None on failure so the builder
never raises on a bad envelope.
"""
if isinstance(value, datetime):
return value.date()
if isinstance(value, date):
return value
if isinstance(value, str) and value:
try:
return date.fromisoformat(value[:10])
except ValueError:
return None
return None
def build_ack_for_batch(
batch_id: str,
*,
interchange_control_number: str = "000000001",
) -> ParseResult999:
"""Read the batch's claims + validation reports and build a 999 ACK.
For each persisted claim:
- ``claim.validation.passed`` is True -> AK5 code = "A"
- else -> AK5 code = "R"
The envelope's sender/receiver are swapped vs. the source 837
(the 999 is the destination's response, so the destination is
the 999's sender and vice versa). The transaction date is the
current date — the 999 is generated in real-time at the 837
ingest boundary.
Returns a fully-formed :class:`ParseResult999`. No side effects
(no DB writes) — the caller persists the result via
``store.add_ack(...)``.
"""
with db.SessionLocal()() as s:
batch_row = s.get(Batch, batch_id)
if batch_row is None:
raise LookupError(f"batch {batch_id} not found")
# Read the source envelope (sender/receiver/control number) from
# the batch's stored raw_result_json.
raw_result = batch_row.raw_result_json or {}
env_data = raw_result.get("envelope") or {}
# We deliberately read the envelope fields via Pydantic so
# date/Decimal/None coercion is consistent with the rest of
# the parser surface.
try:
src_envelope = Envelope.model_validate(env_data)
except Exception:
src_envelope = None
claims = (
s.execute(
select(Claim)
.where(Claim.batch_id == batch_id)
.order_by(Claim.id.asc())
)
.scalars()
.all()
)
# Determine the control number for the source ST. We don't
# have a separate ST table; fall back to the batch id when
# the envelope didn't carry one. The auto-ACK's AK2.set_control
# is the 837's ST02, which the parser stores as envelope.control
# for single-batch 837s. For multi-batch this is approximate.
src_control = (
env_data.get("control_number")
or (src_envelope.control_number if src_envelope else None)
or batch_id
)
functional_id = "HC" # health-care claim
# Build per-set responses. We use the 837's ST01 ("837") as the
# AK2 functional_id_code, per X12 spec.
set_responses: list[SetFunctionalGroupResponse] = []
accepted_count = 0
rejected_count = 0
for claim in claims:
code = _claim_set_code_from_orm(claim)
if code == "A":
accepted_count += 1
else:
rejected_count += 1
set_responses.append(SetFunctionalGroupResponse(
ak2=AcknowledgmentHeader(
functional_id_code="837",
group_control_number=claim.id,
),
set_control_number=claim.id,
transaction_set_identifier="837",
segment_errors=[], # v1: no per-segment error drilldown
set_accept_reject=SetAcceptReject(code=code), # type: ignore[arg-type]
))
# If the source batch had no claims, synthesize a single
# accepted set so the 999 is structurally valid (otherwise the
# AK9 row would be empty / illegal).
if not set_responses:
set_responses.append(SetFunctionalGroupResponse(
ak2=AcknowledgmentHeader(
functional_id_code="837", group_control_number=src_control,
),
set_control_number=str(src_control),
transaction_set_identifier="837",
segment_errors=[],
set_accept_reject=SetAcceptReject(code="A"),
))
accepted_count = 1
received = len(set_responses)
if rejected_count == 0:
ack_code = "A"
elif accepted_count == 0:
ack_code = "R"
else:
ack_code = "P"
fg = FunctionalGroupAck(
ak1=AcknowledgmentHeader(
functional_id_code=functional_id,
group_control_number=str(src_control or "1"),
),
received_count=received,
accepted_count=accepted_count,
rejected_count=rejected_count,
ack_code=ack_code, # type: ignore[arg-type]
)
# Build the envelope for the 999. Swap sender/receiver so the 999
# is addressed to the source 837's sender.
today = date.today()
if src_envelope is not None:
new_env = Envelope(
sender_id=src_envelope.receiver_id,
receiver_id=src_envelope.sender_id,
control_number=interchange_control_number,
transaction_date=today,
implementation_guide="005010X231A1",
)
else:
new_env = Envelope(
sender_id="CYCLONE",
receiver_id="UNKNOWN",
control_number=interchange_control_number,
transaction_date=today,
implementation_guide="005010X231A1",
)
summary = BatchSummary(
input_file=batch_id,
control_number=interchange_control_number,
transaction_date=today,
total_claims=received,
passed=accepted_count,
failed=rejected_count,
)
return ParseResult999(
envelope=new_env,
functional_group_acks=[fg],
set_responses=set_responses,
summary=summary,
)
__all__ = ["build_ack_for_batch"]
+177
View File
@@ -0,0 +1,177 @@
"""Pydantic v2 models for parsed 999 (Implementation Acknowledgment) files.
Mirrors the X12 005010X231A1 segment shape:
- ``AcknowledgmentHeader`` (AK1 / AK2) — identifies the source set/functional group
- ``SetFunctionalGroupResponse`` (AK2 + AK3*/AK4*/AK5) — one per set
- ``FunctionalGroupAck`` (AK1 + AK9) — one per functional group
- ``ParseResult999`` (batch)
Per spec §4.1, the leaner shape below is sufficient for v1. The richer
``FunctionalGroupResponse`` (with ``SetHeader``/``ElementError`` types)
described in the spec is folded into the flat ``SetFunctionalGroupResponse``
that the parser/serializer actually construct.
We copy the ``_Base`` model from :mod:`cyclone.parsers.models` so the
``date`` -> ``str`` JSON serializer is shared with the 837P / 835 models.
That keeps FastAPI responses consistent across transaction sets.
"""
from __future__ import annotations
from datetime import date
from typing import Literal
from pydantic import BaseModel, ConfigDict, Field, model_serializer
from cyclone.parsers.models import Address, BatchSummary, Envelope, ValidationIssue, ValidationReport
# --------------------------------------------------------------------------- #
# Shared base
# --------------------------------------------------------------------------- #
class _Base(BaseModel):
"""Shared Pydantic base; matches the 837P / 835 models for JSON consistency."""
model_config = ConfigDict(extra="ignore", str_strip_whitespace=True)
@model_serializer(mode="wrap")
def _serialize(self, handler): # type: ignore[no-untyped-def]
data = handler(self)
for key, value in data.items():
if isinstance(value, date):
data[key] = value.isoformat()
return data
# --------------------------------------------------------------------------- #
# 999 header / segment shapes
# --------------------------------------------------------------------------- #
class AcknowledgmentHeader(_Base):
"""AK1 / AK2 — identifies the source functional group or set.
AK101 = functional_id_code (e.g. "HC" for health-care claim).
AK102 = group_control_number (from GS06 for AK1, from ST02 for AK2).
For an AK2, ``functional_id_code`` carries the transaction set ID
(e.g. "837") rather than the functional group code; both elements
are required by the spec.
"""
functional_id_code: str
group_control_number: str
class SegmentContext(_Base):
"""AK3 — segment context for an error report.
AK301 = segment_id (e.g. "CLM"), AK302 = segment_position,
AK303 = loop_id (optional), AK304 = implementation_convention_ref
(optional). Per the X12 999 spec, AK304 is rarely used in practice
so we keep it optional.
"""
segment_id: str
segment_position: int
loop_id: str | None = None
implementation_convention_ref: str | None = None
class SegmentError(_Base):
"""AK3 + AK4 — a single segment- or element-level error report.
AK4-1 carries the element position (1-based) when the error
references a specific element. When AK4 is omitted (segment-level
only), element_position is ``None``. AK4-2 = element_reference and
AK4-3 = error_code.
"""
context: SegmentContext
error_code: str
element_position: int | None = None
element_reference: int | None = None
class SetAcceptReject(_Base):
"""AK5 — per-set accept/reject code.
Standard codes per X12 005010X231A1:
A = Accepted
R = Rejected
E = Accepted with errors
W = Warning
X = Rejected (all sets in the functional group)
"""
code: Literal["A", "R", "E", "W", "X"]
class SetFunctionalGroupResponse(_Base):
"""AK2 + (optional AK3*/AK4*) + AK5 — one per set in the source batch.
We surface the source set's ST01 (transaction_set_identifier) and
ST02 (set_control_number) on the response so callers can correlate
ACKs back to the original 837/835 sets without a second lookup.
``ak2`` carries the AK201/AK202 values. The AK2's
``functional_id_code`` field holds the transaction set ID (e.g.
"837"); this matches the spec's reuse of the AK1 element shape.
"""
ak2: AcknowledgmentHeader
set_control_number: str
transaction_set_identifier: str
segment_errors: list[SegmentError] = Field(default_factory=list)
set_accept_reject: SetAcceptReject
class FunctionalGroupAck(_Base):
"""AK1 + AK9 — one per functional group in the source batch.
AK901 is implied by the parent AK1; we don't re-emit it.
AK905 = number of received sets in the source group.
AK906 = number accepted.
AK907 = number rejected.
AK909 = group accept/reject code ("A" accepted, "E" accepted with
errors, "R" rejected, "P" partially accepted).
"""
ak1: AcknowledgmentHeader
received_count: int
accepted_count: int
rejected_count: int
ack_code: Literal["A", "E", "R", "P"]
class ParseResult999(_Base):
"""Top-level parsed 999 ACK document.
A 999 can carry multiple functional groups (one per GS/GE envelope).
In v1 Cyclone emits a single 999 per inbound 837, so
``functional_group_acks`` is usually length 1; the parser still
supports multiple groups for forward compatibility.
"""
envelope: Envelope
functional_group_acks: list[FunctionalGroupAck] = Field(default_factory=list)
set_responses: list[SetFunctionalGroupResponse] = Field(default_factory=list)
summary: BatchSummary
__all__ = [
"AcknowledgmentHeader",
"FunctionalGroupAck",
"ParseResult999",
"SegmentContext",
"SegmentError",
"SetAcceptReject",
"SetFunctionalGroupResponse",
]
# Silence the "imported but unused" complaint on a few names that exist
# for parity with the 837P / 835 module's public surface but are not
# referenced in this file's implementation.
_ = (Address, BaseModel, ValidationIssue, ValidationReport)
+298
View File
@@ -0,0 +1,298 @@
r"""Orchestrate parsing an X12 999 (Implementation Acknowledgment) file.
Single-pass walker over the tokenized segment list:
- ISA / GS / ST — envelope
- AK1 (Functional Group Response Header) — starts a new functional group
- AK2 (Transaction Set Response Header) — starts a new set
- AK3 (Segment Context) + AK4 (Element Context) — optional per-segment errors
- AK5 (Transaction Set Response Status) — per-set accept/reject
- AK9 (Functional Group Response Status) — per-group counts + ack code
- SE / GE / IEA
Errors at the file level raise :class:`CycloneParseError`. The parser
is intentionally lenient: per-set error segments are surfaced via the
``SetFunctionalGroupResponse.segment_errors`` field, not raised.
"""
from __future__ import annotations
import logging
from datetime import date
from cyclone.parsers.exceptions import CycloneParseError
from cyclone.parsers.models import BatchSummary, Envelope
from cyclone.parsers.models_999 import (
AcknowledgmentHeader,
FunctionalGroupAck,
ParseResult999,
SegmentContext,
SegmentError,
SetAcceptReject,
SetFunctionalGroupResponse,
)
from cyclone.parsers.segments import tokenize
log = logging.getLogger(__name__)
# --------------------------------------------------------------------------- #
# Date parsing
# --------------------------------------------------------------------------- #
def _parse_yyyymmdd(s: str) -> date | None:
"""Parse an 8-digit CCYYMMDD string. Returns None on bad input."""
if not s or len(s) != 8 or not s.isdigit():
return None
try:
return date(int(s[0:4]), int(s[4:6]), int(s[6:8]))
except ValueError:
return None
# --------------------------------------------------------------------------- #
# Envelope
# --------------------------------------------------------------------------- #
def _build_envelope(segments: list[list[str]], input_file: str) -> tuple[Envelope, date | None]:
"""Build the 999 envelope from ISA/GS/ST. Returns (envelope, txn_date).
``txn_date`` is sourced from the GS04 (functional group creation
date, CCYYMMDD). Falls back to a placeholder date if GS04 is
missing or unparseable.
"""
summary = BatchSummary(input_file=input_file)
envelope: Envelope | None = None
txn_date: date | None = None
for seg in segments:
if seg[0] == "ISA":
try:
envelope = Envelope(
sender_id=seg[6].strip(),
receiver_id=seg[8].strip(),
control_number=seg[13].strip(),
transaction_date=date(2024, 1, 1), # overwritten by GS04 below
implementation_guide=None,
)
except (IndexError, ValueError) as exc:
raise CycloneParseError(f"Bad ISA: {exc}") from exc
elif seg[0] == "GS" and envelope is not None:
if len(seg) > 4:
txn_date = _parse_yyyymmdd(seg[3]) or txn_date
elif seg[0] == "ST" and envelope is not None:
if len(seg) > 3:
envelope = envelope.model_copy(update={"implementation_guide": seg[3]})
if envelope is None:
raise CycloneParseError("No ISA envelope found")
if txn_date is not None:
envelope = envelope.model_copy(update={"transaction_date": txn_date})
return envelope, txn_date
# --------------------------------------------------------------------------- #
# Per-set / per-group response
# --------------------------------------------------------------------------- #
def _consume_ak3_ak4(segments: list[list[str]], idx: int) -> tuple[list[SegmentError], int]:
"""Read consecutive AK3 (and optional AK4 children) segments and return them.
AK3 carries the segment context (AK3-1 segment id, AK3-2 position,
AK3-3 loop id, AK3-4 implementation convention reference). AK4
attaches to the immediately preceding AK3 and carries the element
position / reference / error code. Per the X12 999 spec a given
AK3 may have one or more AK4 children, or none at all.
"""
errors: list[SegmentError] = []
while idx < len(segments) and segments[idx][0] == "AK3":
ak3 = segments[idx]
idx += 1
seg_id = ak3[1] if len(ak3) > 1 else ""
seg_pos = int(ak3[2]) if len(ak3) > 2 and ak3[2].isdigit() else 0
loop_id = ak3[3] if len(ak3) > 3 and ak3[3] else None
impl_ref = ak3[4] if len(ak3) > 4 and ak3[4] else None
ctx = SegmentContext(
segment_id=seg_id,
segment_position=seg_pos,
loop_id=loop_id,
implementation_convention_ref=impl_ref,
)
# Consume any following AK4 segments as children of this AK3.
# Per X12 005010X231A1:
# AK4-1 = Element Position in Segment (integer)
# AK4-2 = Component Data Element Position in Composite (integer, optional)
# AK4-3 = Data Element Reference Number (integer, optional)
# AK4-4 = Data Element Syntax Error Code (code, optional)
while idx < len(segments) and segments[idx][0] == "AK4":
ak4 = segments[idx]
idx += 1
el_pos = (
int(ak4[1]) if len(ak4) > 1 and ak4[1].isdigit() else None
)
el_ref = (
int(ak4[3]) if len(ak4) > 3 and ak4[3].isdigit() else None
)
err_code = ak4[4] if len(ak4) > 4 and ak4[4] else ""
errors.append(SegmentError(
context=ctx,
error_code=err_code,
element_position=el_pos,
element_reference=el_ref,
))
return errors, idx
def _consume_ak2(segments: list[list[str]], idx: int) -> SetFunctionalGroupResponse | None:
"""Read an AK2 + its child AK3*/AK4* + AK5 segments, return the SetResponse.
Returns None when called with a non-AK2 segment (defensive — the
orchestrator only calls this when it sees AK2).
"""
ak2 = segments[idx]
if ak2[0] != "AK2":
return None
txn_set_id = ak2[1] if len(ak2) > 1 else ""
set_ctrl = ak2[2] if len(ak2) > 2 else ""
ak2_header = AcknowledgmentHeader(
functional_id_code=txn_set_id,
group_control_number=set_ctrl,
)
idx += 1
# Optional AK3/AK4 cluster
seg_errors: list[SegmentError] = []
if idx < len(segments) and segments[idx][0] == "AK3":
seg_errors, idx = _consume_ak3_ak4(segments, idx)
# AK5 (set accept/reject) — required by the spec; default to "R" if missing.
accept_code = "R"
if idx < len(segments) and segments[idx][0] == "AK5":
ak5 = segments[idx]
if len(ak5) > 1 and ak5[1]:
accept_code = ak5[1]
idx += 1
return SetFunctionalGroupResponse(
ak2=ak2_header,
set_control_number=set_ctrl,
transaction_set_identifier=txn_set_id,
segment_errors=seg_errors,
set_accept_reject=SetAcceptReject(code=accept_code), # type: ignore[arg-type]
)
def _consume_ak9(segments: list[list[str]], idx: int) -> tuple[FunctionalGroupAck, int]:
"""Read the AK9 segment and return a FunctionalGroupAck.
AK9-1 = ack_code, AK9-2 = received_count, AK9-3 = accepted_count,
AK9-4 = rejected_count. All four are required by the spec.
"""
ak9 = segments[idx]
code = ak9[1] if len(ak9) > 1 else "R"
received = int(ak9[2]) if len(ak9) > 2 and ak9[2].isdigit() else 0
accepted = int(ak9[3]) if len(ak9) > 3 and ak9[3].isdigit() else 0
rejected = int(ak9[4]) if len(ak9) > 4 and ak9[4].isdigit() else 0
idx += 1
return (
FunctionalGroupAck(
ak1=current_ak1_header if current_ak1_header is not None else AcknowledgmentHeader(
functional_id_code="", group_control_number="",
),
received_count=received,
accepted_count=accepted,
rejected_count=rejected,
ack_code=code, # type: ignore[arg-type]
),
idx,
)
# Module-level slot for the most recently seen AK1 header. The parser
# uses a single AK1 per functional group, and the AK9 consumer needs
# to attach the AK1 to the FunctionalGroupAck. We keep this in a small
# holder so the orchestrator can update it as it walks.
current_ak1_header: AcknowledgmentHeader | None = None
# --------------------------------------------------------------------------- #
# Top-level orchestrator
# --------------------------------------------------------------------------- #
def parse_999_text(text: str, *, input_file: str = "") -> ParseResult999:
"""Parse a complete 999 ACK document and return a :class:`ParseResult999`.
The function is intentionally permissive: per-segment AK3/AK4
errors are surfaced on the response (never raised) so the API
layer can pass them through to the UI. Whole-document problems
(missing ISA, no functional group) still raise
:class:`CycloneParseError`.
"""
global current_ak1_header
current_ak1_header = None
segments = tokenize(text)
envelope, txn_date = _build_envelope(segments, input_file=input_file)
functional_group_acks: list[FunctionalGroupAck] = []
set_responses: list[SetFunctionalGroupResponse] = []
i = 0
while i < len(segments):
seg = segments[i]
if seg[0] in {"ISA", "GS", "ST", "SE", "GE", "IEA"}:
i += 1
continue
if seg[0] == "AK1":
func_id = seg[1] if len(seg) > 1 else ""
grp_ctrl = seg[2] if len(seg) > 2 else ""
current_ak1_header = AcknowledgmentHeader(
functional_id_code=func_id,
group_control_number=grp_ctrl,
)
i += 1
continue
if seg[0] == "AK2":
sr = _consume_ak2(segments, i)
if sr is not None:
set_responses.append(sr)
# Advance past the AK2 + AK3*/AK4*/AK5 cluster
# (re-walk from i+1 because _consume_ak2 doesn't return idx).
i += 1
while i < len(segments) and segments[i][0] in {"AK3", "AK4", "AK5"}:
i += 1
else:
i += 1
continue
if seg[0] == "AK9":
fg, i = _consume_ak9(segments, i)
functional_group_acks.append(fg)
continue
# Unknown segment inside a 999 (rare in practice); skip it.
i += 1
if not functional_group_acks:
raise CycloneParseError("No AK9 (Functional Group Response Status) segment found")
# Summary counts: total sets = total responses; pass/fail from AK5 codes.
total_sets = len(set_responses)
accepted = sum(
1 for s in set_responses if s.set_accept_reject.code == "A"
)
failed = total_sets - accepted
summary = BatchSummary(
input_file=input_file,
control_number=envelope.control_number,
transaction_date=envelope.transaction_date,
total_claims=total_sets,
passed=accepted,
failed=failed,
)
return ParseResult999(
envelope=envelope,
functional_group_acks=functional_group_acks,
set_responses=set_responses,
summary=summary,
)
__all__ = ["parse_999_text"]
@@ -0,0 +1,279 @@
"""Serialize a :class:`ParseResult999` to a complete X12 999 ACK text.
Emits the envelope layers (ISA / GS / ST / SE / GE / IEA) and the
body segments (AK1, AK2 / AK3 / AK4 / AK5 per set, AK9). Uses the
standard X12 delimiters (``*`` element separator, ``~`` segment
terminator, ``:`` component separator, ``^`` repetition separator).
This is the mirror of :mod:`cyclone.parsers.writer` and
:mod:`cyclone.parsers.writer_835`, but for 999: those modules write
parsed claim/remit rows to disk as JSON files, while this one writes
an X12 document string. The output round-trips through
:func:`cyclone.parsers.parse_999.parse_999_text` — see the tests in
``backend/tests/test_serialize_999.py``.
The serializer does NOT recompute the AK9 / AK5 counts: it relies on
the caller's ``FunctionalGroupAck`` counts. The auto-ACK builder
(T15) is responsible for setting those counts correctly from the
per-set ``SetAcceptReject`` codes.
"""
from __future__ import annotations
from datetime import date
from cyclone.parsers.models_999 import ParseResult999
_SEG = "~"
_ELEM = "*"
def _today_yymmdd() -> str:
"""Return today's date as YYMMDD for ISA09 / GS04."""
t = date.today()
return f"{t.year % 100:02d}{t.month:02d}{t.day:02d}"
def _today_yyyymmdd() -> str:
"""Return today's date as CCYYMMDD for GS04."""
t = date.today()
return f"{t.year:04d}{t.month:02d}{t.day:02d}"
def _pad(s: str, width: int) -> str:
"""Pad a string to ``width`` characters; truncate if longer."""
return (s or "").ljust(width)[:width]
def _build_isa(
sender_id: str,
receiver_id: str,
interchange_control_number: str,
) -> str:
"""Build the ISA envelope segment (fixed 106-char layout).
Per the X12 spec the ISA is a fixed-width 16-element record.
Element lengths:
ISA01 (auth qualifier) 2
ISA02 (auth info) 10
ISA03 (security qualifier) 2
ISA04 (security info) 10
ISA05 (sender id qualifier) 2
ISA06 (sender id) 15
ISA07 (receiver id qualifier) 2
ISA08 (receiver id) 15
ISA09 (interchange date) 6 (YYMMDD)
ISA10 (interchange time) 4 (HHMM)
ISA11 (repetition separator) 1
ISA12 (control version) 5
ISA13 (interchange ctrl num) 9
ISA14 (ack requested) 1
ISA15 (usage indicator) 1
ISA16 (component separator) 1
"""
parts = [
"ISA", # 0
"00", # 1
_pad("", 10), # 2
"00", # 3
_pad("", 10), # 4
"ZZ", # 5
_pad(sender_id, 15), # 6
"ZZ", # 7
_pad(receiver_id, 15), # 8
_today_yymmdd(), # 9
"0000", # 10
"^", # 11 (repetition separator)
"00501", # 12
_pad(interchange_control_number, 9), # 13
"0", # 14 (no TA1 ack requested)
"P", # 15 (production)
":", # 16 (component separator)
]
isa = _ELEM.join(parts)
# ISA must be exactly 105 characters (excluding the segment terminator).
# The tokenize() function reads the 4 delimiters from fixed positions:
# 3rd char (0-indexed) = element separator
# 82nd char (0-indexed) = repetition separator
# 104th char (0-indexed) = component separator
# 105th char (0-indexed) = segment terminator
# Our parts above sum to 105 chars; the trailing segment terminator
# is appended separately and is the 106th byte.
return isa + _SEG
def _build_gs(
sender_id: str,
receiver_id: str,
functional_id_code: str,
group_control_number: str,
) -> str:
"""Build a GS (Functional Group Header) segment.
GS01 = functional_id_code (e.g. "HC")
GS02 = sender id (application code)
GS03 = receiver id
GS04 = transaction date (CCYYMMDD)
GS05 = transaction time (HHMM)
GS06 = group control number
GS07 = responsible agency code ("X")
GS08 = version / release / industry id (e.g. "005010X231A1")
"""
return _ELEM.join([
"GS",
functional_id_code or "HC",
_pad(sender_id, 15),
_pad(receiver_id, 15),
_today_yyyymmdd(),
"0000",
_pad(group_control_number or "1", 9),
"X",
"005010X231A1",
]) + _SEG
def _build_ak1(ak1) -> str:
"""Build an AK1 segment: AK1*functional_id_code*group_control_number~"""
return _ELEM.join(["AK1", ak1.functional_id_code, ak1.group_control_number]) + _SEG
def _build_ak2_and_children(sr) -> str:
"""Build the AK2 + (optional AK3 + AK4 cluster) + AK5 for a single set."""
out: list[str] = []
# AK2: AK2*transaction_set_identifier*set_control_number~
out.append(
_ELEM.join([
"AK2",
sr.transaction_set_identifier or sr.ak2.functional_id_code,
sr.set_control_number or sr.ak2.group_control_number,
]) + _SEG
)
# AK3 + AK4 cluster — one AK3 per SegmentError context, with its
# own AK4 children. We iterate ``segment_errors`` and emit one AK3
# per unique ``context`` so the output matches the parser's
# expectation. In v1 each SegmentError carries its own context, so
# the same number of AK3s is emitted as SegmentErrors.
# We do NOT deduplicate identical contexts — each SegmentError is
# an independent report and the receiver expects them in order.
for seg_err in sr.segment_errors:
ctx = seg_err.context
ak3_parts = [
"AK3",
ctx.segment_id,
str(ctx.segment_position),
]
if ctx.loop_id:
ak3_parts.append(ctx.loop_id)
if ctx.implementation_convention_ref:
ak3_parts.append(ctx.implementation_convention_ref)
out.append(_ELEM.join(ak3_parts) + _SEG)
# AK4 follows the AK3 it describes.
if (
seg_err.element_position is not None
or seg_err.element_reference is not None
or seg_err.error_code
):
ak4_parts = ["AK4"]
ak4_parts.append(str(seg_err.element_position) if seg_err.element_position is not None else "")
# AK4-2 is component data element position; we leave it
# empty because the SegmentError model doesn't carry it.
ak4_parts.append("")
if seg_err.element_reference is not None:
ak4_parts.append(str(seg_err.element_reference))
else:
ak4_parts.append("")
if seg_err.error_code:
ak4_parts.append(seg_err.error_code)
out.append(_ELEM.join(ak4_parts) + _SEG)
# AK5: AK5*code~ (set accept/reject)
out.append(_ELEM.join(["AK5", sr.set_accept_reject.code]) + _SEG)
return "".join(out)
def _build_ak9(fg) -> str:
"""Build an AK9 segment: AK9*ack_code*received*accepted*rejected~"""
return _ELEM.join([
"AK9",
fg.ack_code,
str(fg.received_count),
str(fg.accepted_count),
str(fg.rejected_count),
]) + _SEG
def _build_se(count: int, control_number: str) -> str:
"""Build a SE segment: SE*segment_count*control_number~"""
return _ELEM.join(["SE", str(count), control_number]) + _SEG
def serialize_999(
result: ParseResult999,
*,
interchange_control_number: str = "000000001",
) -> str:
"""Serialize a :class:`ParseResult999` to X12 999 text.
Each set in ``result.set_responses`` is emitted as AK2 (with
optional AK3 + AK4 children) + AK5. AK1 and AK9 wrap the
functional group. The envelope layers ISA / GS / ST / SE / GE / IEA
are always emitted.
The default ``interchange_control_number`` is ``"000000001"``;
callers that need a fresh value (e.g. one per generated ACK) should
pass a unique 9-digit string.
The output always uses standard X12 delimiters: ``*`` element,
``~`` segment, ``:`` component, ``^`` repetition. The output is
safe to feed directly to :func:`cyclone.parsers.parse_999.parse_999_text`.
"""
env = result.envelope
sender = env.sender_id
receiver = env.receiver_id
impl_guide = env.implementation_guide or "005010X231A1"
# We support multiple functional groups in principle, but in v1
# there's always exactly one. Pull the first AK1 to use for the GS
# header's functional_id_code.
fg0 = result.functional_group_acks[0] if result.functional_group_acks else None
functional_id = fg0.ak1.functional_id_code if fg0 else "HC"
group_ctrl = (fg0.ak1.group_control_number if fg0 else "1") or "1"
# ST control number defaults to the AK1 group control number.
st_ctrl = group_ctrl
# Build the body segments first so we can count them for SE*.
body_parts: list[str] = []
body_parts.append(
_build_ak1(fg0.ak1) if fg0 else f"AK1{_ELEM}{functional_id}{_ELEM}1{_SEG}"
)
for sr in result.set_responses:
body_parts.append(_build_ak2_and_children(sr))
# AK9 — one per functional group. In v1 there's always one, but the
# serializer supports emitting more in the future.
if fg0 is not None:
body_parts.append(_build_ak9(fg0))
body = "".join(body_parts)
# SE segment count: ST + body segments = 1 + (body segment count).
# Each ""-separated entry in body_parts is one or more segments
# joined without the separator, so we count newlines (each ends in "~").
body_segment_count = body.count(_SEG)
se_count = 1 + body_segment_count # +1 for the ST itself
parts: list[str] = []
parts.append(_build_isa(sender, receiver, interchange_control_number))
parts.append(_build_gs(sender, receiver, functional_id, group_ctrl))
parts.append(
f"ST{_ELEM}999{_ELEM}{st_ctrl}{_ELEM}{impl_guide}{_SEG}"
)
parts.append(body)
parts.append(_build_se(se_count, st_ctrl))
parts.append(
f"GE{_ELEM}1{_ELEM}{group_ctrl}{_SEG}"
)
parts.append(
f"IEA{_ELEM}1{_ELEM}{interchange_control_number}{_SEG}"
)
return "".join(parts)
__all__ = ["serialize_999"]
+53
View File
@@ -39,6 +39,7 @@ from pydantic import BaseModel, ConfigDict, model_validator
from cyclone import db
from cyclone.db import (
Ack,
ActivityEvent,
Batch,
CasAdjustment,
@@ -1088,6 +1089,58 @@ class CycloneStore:
for r in rows
]
# -- 999 ACKs (SP3 P3 T13) -------------------------------------------
def add_ack(
self,
*,
source_batch_id: str,
accepted_count: int,
rejected_count: int,
received_count: int,
ack_code: str,
raw_json: dict,
) -> db.Ack:
"""Persist a 999 ACK row and return it.
``source_batch_id`` must reference an existing ``batches.id``.
For received 999s with no source batch the caller should pass a
synthetic id (e.g. ``"999-<interchange_control_number>"``) —
see ``/api/parse-999`` for that policy.
``raw_json`` is the full ``ParseResult999`` model dump; the
detail endpoint surfaces it without re-parsing the original
X12 text.
"""
with db.SessionLocal()() as s:
row = Ack(
source_batch_id=source_batch_id,
accepted_count=accepted_count,
rejected_count=rejected_count,
received_count=received_count,
ack_code=ack_code,
parsed_at=utcnow(),
raw_json=raw_json,
)
s.add(row)
s.commit()
s.refresh(row)
return row
def list_acks(self) -> list[db.Ack]:
"""Return every 999 ACK row, newest first (auto-increment id desc)."""
with db.SessionLocal()() as s:
return (
s.query(Ack)
.order_by(Ack.id.desc())
.all()
)
def get_ack(self, ack_id: int) -> db.Ack | None:
"""Return a single ACK row by id, or ``None`` if not found."""
with db.SessionLocal()() as s:
return s.get(Ack, ack_id)
# -- manual reconciliation (T12) -----------------------------------
def list_unmatched(self, *, kind: str = "both") -> dict: