feat(parsers+api+ui+db): 999 ACK transaction set end-to-end (SP3 P3)
This commit is contained in:
+232
-1
@@ -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"]
|
||||
|
||||
@@ -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);
|
||||
@@ -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"]
|
||||
@@ -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)
|
||||
@@ -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"]
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user