feat(sp25): move ack UI serializers to store/ui.py

This commit is contained in:
Nora
2026-07-02 08:45:19 -06:00
parent d8c03fde3f
commit 4b22193c4a
6 changed files with 312 additions and 82 deletions
+5 -26
View File
@@ -90,6 +90,7 @@ from cyclone.store import (
InvalidStateError,
dashboard_kpis,
store,
to_ui_two77ca_ack,
utcnow,
)
@@ -1036,7 +1037,7 @@ def list_277ca_acks_endpoint(
) -> Any:
"""Return the list of persisted 277CA ACKs, newest first."""
rows = store.list_277ca_acks()
items = [_277ca_to_ui(r) for r in rows[:limit]]
items = [to_ui_two77ca_ack(r) for r in rows[:limit]]
return {"total": len(rows), "items": items}
@@ -1046,31 +1047,9 @@ def get_277ca_ack_endpoint(ack_id: int) -> dict:
row = store.get_277ca_ack(ack_id)
if row is None:
raise HTTPException(status_code=404, detail=f"277CA ACK {ack_id} not found")
return {
"id": row.id,
"control_number": row.control_number,
"accepted_count": row.accepted_count,
"rejected_count": row.rejected_count,
"paid_count": row.paid_count,
"pended_count": row.pended_count,
"source_batch_id": row.source_batch_id,
"parsed_at": row.parsed_at.isoformat() if row.parsed_at else None,
"raw_json": row.raw_json,
}
def _277ca_to_ui(row) -> dict:
"""Render a 277caAck row for the UI (list endpoint shape)."""
return {
"id": row.id,
"control_number": row.control_number,
"accepted_count": row.accepted_count,
"rejected_count": row.rejected_count,
"paid_count": row.paid_count,
"pended_count": row.pended_count,
"source_batch_id": row.source_batch_id,
"parsed_at": row.parsed_at.isoformat() if row.parsed_at else None,
}
body = to_ui_two77ca_ack(row)
body["raw_json"] = row.raw_json
return body
def _serialize_ta1(result) -> str:
+6 -38
View File
@@ -19,48 +19,16 @@ from fastapi.responses import StreamingResponse
from cyclone.api_helpers import ndjson_stream_list, wants_ndjson
from cyclone.parsers.models_999 import ParseResult999
from cyclone.parsers.serialize_999 import serialize_999
from cyclone.store import store
from cyclone.store import store, to_ui_ack
router = APIRouter()
log = logging.getLogger(__name__)
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``.
Adds ``patient_control_number`` pulled from ``raw_json`` so the
operator can correlate each 999 back to the original claim
batch. The full inbound filename is reachable via
``GET /api/acks/{ack_id}`` (raw_json carries the full parse
tree) and ``GET /api/admin/scheduler/processed-files``.
"""
body = {
"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 ""
),
"patient_control_number": None,
}
raw = row.raw_json or {}
try:
set_responses = raw.get("set_responses") or []
if set_responses:
body["patient_control_number"] = set_responses[0].get("set_control_number")
except (AttributeError, TypeError):
pass
return body
# SP25: ``_ack_to_ui`` moved to ``cyclone.store.ui.to_ui_ack`` so the
# live-tail event payload (``ack_received``) matches the list endpoint
# shape byte-for-byte.
@router.get("/api/acks")
@@ -80,7 +48,7 @@ def list_acks_endpoint(
exceeds the page size.
"""
rows = store.list_acks()
items = [_ack_to_ui(r) for r in rows[offset : offset + limit]]
items = [to_ui_ack(r) for r in rows[offset : offset + limit]]
total = len(rows)
returned = len(items)
has_more = offset + returned < total
@@ -117,7 +85,7 @@ def get_ack_endpoint(ack_id: int) -> dict:
status_code=404,
detail={"error": "Not found", "detail": f"Ack {ack_id} not found"},
)
body = _ack_to_ui(row)
body = to_ui_ack(row)
body["raw_json"] = row.raw_json
# Regenerate the X12 text from raw_json so the operator can download
# the actual 999 file. (SP3 P3 follow-up: list endpoint doesn't carry
+6 -18
View File
@@ -15,26 +15,14 @@ from typing import Any
from fastapi import APIRouter, HTTPException, Query
from cyclone import db
from cyclone.store import store
from cyclone.store import store, to_ui_ta1_ack
router = APIRouter()
def _ta1_to_ui(row: db.Ta1Ack) -> dict:
"""Render a Ta1Ack row for the UI (list endpoint shape)."""
return {
"id": row.id,
"control_number": row.control_number,
"ack_code": row.ack_code,
"note_code": row.note_code,
"interchange_date": row.interchange_date.isoformat()
if row.interchange_date else None,
"interchange_time": row.interchange_time,
"sender_id": row.sender_id,
"receiver_id": row.receiver_id,
"source_batch_id": row.source_batch_id,
"parsed_at": row.parsed_at.isoformat() if row.parsed_at else None,
}
# SP25: ``_ta1_to_ui`` moved to ``cyclone.store.ui.to_ui_ta1_ack`` so
# the live-tail event payload (``ta1_ack_received``) matches the list
# endpoint shape byte-for-byte.
def _serialize_ta1_from_row(row: db.Ta1Ack) -> str:
@@ -57,7 +45,7 @@ def list_ta1_acks_endpoint(
count regardless of the ``limit`` cap.
"""
rows = store.list_ta1_acks()
items = [_ta1_to_ui(r) for r in rows[:limit]]
items = [to_ui_ta1_ack(r) for r in rows[:limit]]
return {
"total": len(rows),
"items": items,
@@ -70,7 +58,7 @@ def get_ta1_ack_endpoint(ack_id: int) -> dict:
row = store.get_ta1_ack(ack_id)
if row is None:
raise HTTPException(status_code=404, detail=f"TA1 ACK {ack_id} not found")
body = _ta1_to_ui(row)
body = to_ui_ta1_ack(row)
body["raw_ta1_text"] = _serialize_ta1_from_row(row)
body["raw_json"] = row.raw_json
return body
+3
View File
@@ -101,11 +101,14 @@ from .providers import (
)
from .records import BatchKind, BatchRecord, BatchRecord837, BatchRecord835
from .ui import (
to_ui_ack,
to_ui_claim_detail,
to_ui_claim_from_orm,
to_ui_provider,
to_ui_remittance_from_orm,
to_ui_remittance_with_adjustments,
to_ui_ta1_ack,
to_ui_two77ca_ack,
)
+97
View File
@@ -6,6 +6,12 @@ shape change ripples to every API consumer.
Also hosts ``utcnow()`` (the tz-aware UTC now) because it's a small
free function that several modules want and there's no better home.
SP25: ``to_ui_ack`` / ``to_ui_ta1_ack`` / ``to_ui_two77ca_ack`` were
moved here from ``api_routers/acks.py`` / ``api_routers/ta1_acks.py``
/ ``api.py`` so the live-tail event payload can match the list endpoint
shape byte-for-byte. The seam between persistence and streaming
depends on the two halves staying in sync.
"""
from __future__ import annotations
@@ -189,6 +195,97 @@ def _iso_z(value: datetime | None) -> str:
return value.isoformat().replace("+00:00", "Z")
def to_ui_ack(row: db.Ack) -> 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``.
Adds ``patient_control_number`` pulled from ``raw_json`` so the
operator can correlate each 999 back to the original claim batch.
SP25: this was previously ``api_routers/acks._ack_to_ui``. Moved
here so the live-tail event payload (``ack_received``) matches the
list-endpoint shape — the seam between persistence and streaming
depends on byte-for-byte equality.
"""
body = {
"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 ""
),
"patient_control_number": None,
}
raw = row.raw_json or {}
try:
set_responses = raw.get("set_responses") or []
if set_responses:
body["patient_control_number"] = set_responses[0].get("set_control_number")
except (AttributeError, TypeError):
pass
return body
def to_ui_ta1_ack(row: db.Ta1Ack) -> dict:
"""Map a ``Ta1Ack`` ORM row to the UI shape used by ``/api/ta1-acks``.
SP25: this was previously ``api_routers/ta1_acks._ta1_to_ui``.
Moved here so the live-tail event payload (``ta1_ack_received``)
matches the list-endpoint shape byte-for-byte.
Note: ``parsed_at`` uses naive ``isoformat()`` (no ``Z`` suffix) —
preserved from the original implementation. SQLite strips tzinfo
on read, so the field comes back as a naive datetime; we deliberately
keep the original (non-``Z``) rendering rather than re-attaching
UTC, because changing the wire shape now would drift both halves
of the live-tail contract.
"""
return {
"id": row.id,
"control_number": row.control_number,
"ack_code": row.ack_code,
"note_code": row.note_code,
"interchange_date": row.interchange_date.isoformat()
if row.interchange_date else None,
"interchange_time": row.interchange_time,
"sender_id": row.sender_id,
"receiver_id": row.receiver_id,
"source_batch_id": row.source_batch_id,
"parsed_at": row.parsed_at.isoformat() if row.parsed_at else None,
}
def to_ui_two77ca_ack(row: db.Two77caAck) -> dict:
"""Map a ``Two77caAck`` ORM row to the UI shape used by ``/api/277ca-acks``.
SP25: this was previously ``api._277ca_to_ui`` inlined alongside
the 277CA list endpoint. Moved here so the live-tail event
payload (``two77ca_ack_received``) matches the list-endpoint
shape byte-for-byte.
Note: ``parsed_at`` uses naive ``isoformat()`` (no ``Z`` suffix) —
same SQLite-tzinfo caveat as :func:`to_ui_ta1_ack`.
"""
return {
"id": row.id,
"control_number": row.control_number,
"accepted_count": row.accepted_count,
"rejected_count": row.rejected_count,
"paid_count": row.paid_count,
"pended_count": row.pended_count,
"source_batch_id": row.source_batch_id,
"parsed_at": row.parsed_at.isoformat() if row.parsed_at else None,
}
def _address_to_ui(addr: dict | None) -> dict:
"""Render a raw ``Address`` dict in the spec's parties address shape.