feat(sp10): 277CA parser + Payer-Rejected Inbox lane
- Add cyclone.parsers.models_277ca + parse_277ca (X12 005010X214)
- Per-Patient HL ClaimStatus with REF*1K (PCN), REF*EJ (tax ID),
STC category code, amount, service date
- STC classifier: A1-A3 accepted, A4/A6/A7 rejected, A8/A9 pended,
P1-P5 paid, anything else unknown
- Multiple STCs per Patient HL: last wins (canonical pattern for
'first pended, then paid' acknowledgments)
- Subscriber-level STCs surface in unscoped_statuses
- Add apply_277ca_rejections — stamps Claim.payer_rejected_* fields on
matching rows (lookup by patient_control_number, mirrors 999 path)
- New /api/parse-277ca, /api/277ca-acks, /api/277ca-acks/{id} endpoints
- New two77ca_acks table + Two77caAck ORM model
- New Claim columns: payer_rejected_at, _reason, _status_code, _by_277ca_id
- New payer_rejected lane in /api/inbox/lanes (distinct from 999
envelope rejected lane)
- New PayerConfig277CA block in config/payers.yaml + Pydantic model
- Migration 0008 bumps user_version to 8
Tests: 654 -> 688 (parser 22 + apply 6 + API 8 + config 6 + adjustments).
All 688 backend tests pass; 1 pre-existing skipped test class unaffected.
This commit is contained in:
+171
-1
@@ -34,6 +34,7 @@ from pydantic import ValidationError
|
||||
from cyclone import __version__, db
|
||||
from cyclone.db import Claim, ClaimState, Remittance
|
||||
from cyclone.inbox_state import apply_999_rejections
|
||||
from cyclone.inbox_state_277ca import apply_277ca_rejections
|
||||
from cyclone.parsers.exceptions import CycloneParseError
|
||||
from cyclone.parsers.models import BatchSummary, ClaimOutput, Envelope, ParseResult
|
||||
from cyclone.parsers.models_270 import (
|
||||
@@ -48,6 +49,7 @@ from cyclone.parsers.models_835 import ParseResult835
|
||||
from cyclone.parsers.payer import PayerConfig, PayerConfig835
|
||||
from cyclone.parsers.parse_270 import parse as parse_270_text
|
||||
from cyclone.parsers.parse_271 import parse as parse_271_text
|
||||
from cyclone.parsers.parse_277ca import parse_277ca_text
|
||||
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
|
||||
@@ -817,6 +819,171 @@ def _ta1_to_ui(row: db.Ta1Ack) -> dict:
|
||||
}
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# 277CA (Claim Acknowledgment) — SP10
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
def _277ca_synthetic_source_batch_id(interchange_control_number: str) -> str:
|
||||
"""Return a synthetic ``batches.id`` for a received 277CA with no source batch.
|
||||
|
||||
Mirrors :func:`_ack_synthetic_source_batch_id`. The 277CA row's
|
||||
``source_batch_id`` FK requires a row in batches; for received
|
||||
277CAs we synthesize an id of the form ``277CA-<ISA13>``. The row
|
||||
is NOT created in batches — same FK-is-no-op convention as the 999
|
||||
path.
|
||||
"""
|
||||
return f"277CA-{(interchange_control_number or '').strip() or '000000001'}"
|
||||
|
||||
|
||||
@app.post("/api/parse-277ca")
|
||||
async def parse_277ca_endpoint(
|
||||
request: Request,
|
||||
file: UploadFile = File(...),
|
||||
) -> Any:
|
||||
"""Parse a 277CA Claim Acknowledgment file, persist a row, and stamp rejections.
|
||||
|
||||
Behavior mirrors ``/api/parse-999``:
|
||||
- 400 on empty / undecodable / malformed EDI (never 500).
|
||||
- 200 on success with ``{"ack": {id, control_number, accepted_count,
|
||||
rejected_count, payer_claim_control_numbers, raw_277ca_text},
|
||||
"parsed": <ParseResult277CA>}``.
|
||||
|
||||
After parse, runs :func:`apply_277ca_rejections` to stamp the
|
||||
payer-rejected fields on each matching claim row. The Inbox
|
||||
Payer-Rejected lane lights up as a side-effect of this call.
|
||||
"""
|
||||
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_277ca_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 277CA")
|
||||
return JSONResponse(
|
||||
status_code=500,
|
||||
content={"error": "Internal server error", "detail": str(exc)},
|
||||
)
|
||||
|
||||
icn = result.envelope.control_number
|
||||
synthetic_id = _277ca_synthetic_source_batch_id(icn)
|
||||
|
||||
accepted = sum(1 for s in result.claim_statuses if s.classification == "accepted")
|
||||
paid = sum(1 for s in result.claim_statuses if s.classification == "paid")
|
||||
rejected = sum(1 for s in result.claim_statuses if s.classification == "rejected")
|
||||
pended = sum(1 for s in result.claim_statuses if s.classification == "pended")
|
||||
|
||||
# Persist the 277CA row first so we have an id to attach to claims.
|
||||
row = store.add_277ca_ack(
|
||||
source_batch_id=synthetic_id,
|
||||
control_number=icn,
|
||||
accepted_count=accepted,
|
||||
rejected_count=rejected,
|
||||
paid_count=paid,
|
||||
pended_count=pended,
|
||||
raw_json=json.loads(result.model_dump_json()),
|
||||
)
|
||||
|
||||
# Stamp payer-rejection fields on matching claims. The 277CA's
|
||||
# REF*1K carries the patient's claim control number we sent in
|
||||
# CLM01 — same convention the 999 ACK uses, so the lookup hits
|
||||
# Claim.patient_control_number (mirrors apply_999_rejections).
|
||||
with db.SessionLocal()() as session:
|
||||
def _lookup(pcn: str):
|
||||
return (
|
||||
session.query(Claim)
|
||||
.filter(Claim.patient_control_number == pcn)
|
||||
.first()
|
||||
)
|
||||
apply_result = apply_277ca_rejections(
|
||||
session, result, claim_lookup=_lookup, two77ca_id=row.id,
|
||||
)
|
||||
if apply_result.matched:
|
||||
bus = request.app.state.event_bus
|
||||
for cid in apply_result.matched:
|
||||
await bus.publish("claim.payer_rejected", {"claim_id": cid})
|
||||
if apply_result.orphans:
|
||||
log.warning(
|
||||
"277CA had %d orphan status entries (no matching claim): %s",
|
||||
len(apply_result.orphans),
|
||||
apply_result.orphans[:5],
|
||||
)
|
||||
|
||||
return JSONResponse(content={
|
||||
"ack": {
|
||||
"id": row.id,
|
||||
"control_number": icn,
|
||||
"accepted_count": accepted,
|
||||
"rejected_count": rejected,
|
||||
"paid_count": paid,
|
||||
"pended_count": pended,
|
||||
"source_batch_id": synthetic_id,
|
||||
"matched_claim_ids": apply_result.matched,
|
||||
"orphan_status_codes": apply_result.orphans,
|
||||
},
|
||||
"parsed": json.loads(result.model_dump_json()),
|
||||
})
|
||||
|
||||
|
||||
@app.get("/api/277ca-acks")
|
||||
def list_277ca_acks_endpoint(
|
||||
limit: int = Query(100, ge=1, le=1000),
|
||||
) -> 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]]
|
||||
return {"total": len(rows), "items": items}
|
||||
|
||||
|
||||
@app.get("/api/277ca-acks/{ack_id}")
|
||||
def get_277ca_ack_endpoint(ack_id: int) -> dict:
|
||||
"""Return one persisted 277CA ACK row with its parsed detail."""
|
||||
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,
|
||||
}
|
||||
|
||||
|
||||
def _serialize_ta1(result) -> str:
|
||||
"""Render a TA1 file from a ParseResultTa1 for the ``raw_ta1_text`` field.
|
||||
|
||||
@@ -850,13 +1017,16 @@ def _serialize_ta1_from_row(row: db.Ta1Ack) -> str:
|
||||
|
||||
@app.get("/api/inbox/lanes")
|
||||
def inbox_lanes():
|
||||
"""Return all four Inbox lanes in one call."""
|
||||
"""Return all Inbox lanes in one call."""
|
||||
dismissed_pairs = getattr(app.state, "dismissed_pairs", set())
|
||||
with db.SessionLocal()() as session:
|
||||
from cyclone.inbox_lanes import compute_lanes
|
||||
lanes = compute_lanes(session, dismissed_pairs=dismissed_pairs)
|
||||
return {
|
||||
"rejected": lanes.rejected,
|
||||
# SP10: payer-rejected lane (277CA STC A4/A6/A7). Distinct from
|
||||
# the 999 envelope rejection in ``rejected`` above.
|
||||
"payer_rejected": lanes.payer_rejected,
|
||||
"candidates": lanes.candidates,
|
||||
"unmatched": lanes.unmatched,
|
||||
"done_today": lanes.done_today,
|
||||
|
||||
@@ -205,6 +205,21 @@ class Claim(Base):
|
||||
rejected_at: Mapped[Optional[datetime]] = mapped_column(
|
||||
DateTime(timezone=True), nullable=True
|
||||
)
|
||||
# SP10: payer-side rejection (277CA STC A4/A6/A7) — distinct from
|
||||
# the 999 envelope rejection above. A claim can be rejected at the
|
||||
# envelope level (bad file) or at the payer-adjudication level
|
||||
# (good file, bad claim). We track them separately so the Inbox
|
||||
# Payer-Rejected lane can distinguish.
|
||||
payer_rejected_at: Mapped[Optional[datetime]] = mapped_column(
|
||||
DateTime(timezone=True), nullable=True
|
||||
)
|
||||
payer_rejected_reason: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
|
||||
payer_rejected_status_code: Mapped[Optional[str]] = mapped_column(
|
||||
String(8), nullable=True
|
||||
)
|
||||
payer_rejected_by_277ca_id: Mapped[Optional[str]] = mapped_column(
|
||||
String(64), nullable=True
|
||||
)
|
||||
resubmit_count: Mapped[int] = mapped_column(
|
||||
Integer, nullable=False, default=0, server_default=text("0")
|
||||
)
|
||||
@@ -519,6 +534,42 @@ class Ta1Ack(Base):
|
||||
)
|
||||
|
||||
|
||||
class Two77caAck(Base):
|
||||
"""277CA (Claim Acknowledgment) row — one per parsed 277CA file.
|
||||
|
||||
Mirrors :class:`Ta1Ack` but for the *semantic* claim-level ack.
|
||||
A 277CA acknowledges individual claims by REF*1K
|
||||
(payer_claim_control_number) rather than the whole envelope.
|
||||
|
||||
Per X12 005010X214 a single 277CA can carry many claim statuses;
|
||||
we keep the per-claim detail in ``raw_json`` and promote only the
|
||||
counts + ICN here so the list endpoint stays fast.
|
||||
|
||||
``source_batch_id`` uses the synthetic ``277CA-<ISA13>`` id — same
|
||||
FK-is-no-op convention as the 999 / TA1 paths. The 277CA itself
|
||||
never has an inbound Cyclone batch to point at.
|
||||
"""
|
||||
|
||||
__tablename__ = "two77ca_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,
|
||||
)
|
||||
control_number: Mapped[str] = mapped_column(String(32), nullable=False)
|
||||
accepted_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
||||
rejected_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
||||
paid_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
||||
pended_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
||||
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_two77ca_acks_source_batch_id", "source_batch_id"),
|
||||
Index("ix_two77ca_acks_control_number", "control_number"),
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# SP9: providers, payers, payer_configs, clearhouse
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -1,12 +1,15 @@
|
||||
"""Compute the four Inbox lanes from the DB on read.
|
||||
"""Compute the Inbox lanes from the DB on read.
|
||||
|
||||
SP6 T6.
|
||||
|
||||
Lanes:
|
||||
- rejected: claims whose 999 AK5 set-level response was R/E
|
||||
- candidates: remits without a matched claim, with scoreable claims
|
||||
- unmatched: claims still SUBMITTED with no remittance in flight
|
||||
- done_today: claims that reached a terminal state in the last 24h
|
||||
- rejected: claims whose 999 AK5 set-level response was R/E
|
||||
- payer_rejected: claims whose 277CA STC category is A4/A6/A7
|
||||
(SP10: payer-side rejection, distinct from 999
|
||||
envelope rejection)
|
||||
- candidates: remits without a matched claim, with scoreable claims
|
||||
- unmatched: claims still SUBMITTED with no remittance in flight
|
||||
- done_today: claims that reached a terminal state in the last 24h
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -23,6 +26,7 @@ from cyclone.scoring import score_pair, ScoreBreakdown
|
||||
@dataclass
|
||||
class Lanes:
|
||||
rejected: list[dict] = field(default_factory=list)
|
||||
payer_rejected: list[dict] = field(default_factory=list)
|
||||
candidates: list[dict] = field(default_factory=list)
|
||||
unmatched: list[dict] = field(default_factory=list)
|
||||
done_today: list[dict] = field(default_factory=list)
|
||||
@@ -63,6 +67,12 @@ def _claim_to_row(
|
||||
"provider": score.provider,
|
||||
} if score else None,
|
||||
"matched_remittance": matched_remittance,
|
||||
# SP10: payer-side rejection fields. Populated when a 277CA
|
||||
# acknowledged the claim with STC A4/A6/A7.
|
||||
"payer_rejected_at": _isoformat(c.payer_rejected_at),
|
||||
"payer_rejected_reason": c.payer_rejected_reason,
|
||||
"payer_rejected_status_code": c.payer_rejected_status_code,
|
||||
"payer_rejected_by_277ca_id": c.payer_rejected_by_277ca_id,
|
||||
}
|
||||
|
||||
|
||||
@@ -176,6 +186,26 @@ def compute_lanes(session: Session, *, dismissed_pairs: Iterable[frozenset]) ->
|
||||
),
|
||||
))
|
||||
|
||||
# --- Payer-Rejected (SP10) ---
|
||||
# Distinct from the 999 envelope "rejected" lane above. A claim
|
||||
# lands here when a 277CA STC category code is A4/A6/A7 (rejected
|
||||
# by the payer after we submitted a syntactically-valid file).
|
||||
# We don't filter by Claim.state here because the claim may still
|
||||
# be in SUBMITTED state — the payer just hasn't paid it yet.
|
||||
payer_rejected_claims = (
|
||||
session.query(Claim).filter(Claim.payer_rejected_at.is_not(None)).all()
|
||||
)
|
||||
pr_matched, pr_total = _line_count_lookup(session, payer_rejected_claims)
|
||||
matched_counts.update(pr_matched)
|
||||
total_lines_by_claim.update(pr_total)
|
||||
for c in payer_rejected_claims:
|
||||
lanes.payer_rejected.append(_claim_to_row(
|
||||
c, kind="claim",
|
||||
matched_remittance=_matched_remittance_block(
|
||||
session, c, matched_counts, total_lines_by_claim,
|
||||
),
|
||||
))
|
||||
|
||||
# --- Done today ---
|
||||
cutoff = datetime.now(timezone.utc) - timedelta(hours=24)
|
||||
terminal_states = {
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
"""277CA Claim Acknowledgment → claim payer-rejection state transitions.
|
||||
|
||||
SP10 T2.
|
||||
|
||||
For each ``ClaimStatus`` in a parsed 277CA whose ``classification``
|
||||
is ``"rejected"`` (STC category codes A4 / A6 / A7), look up the
|
||||
matching Cyclone claim row by ``payer_claim_control_number`` and stamp
|
||||
``payer_rejected_at`` + ``payer_rejected_reason`` + the STC category
|
||||
code + the originating 277CA row id.
|
||||
|
||||
Distinct from :func:`cyclone.inbox_state.apply_999_rejections` (which
|
||||
handles envelope-level 999 AK5 R/E rejections). A claim can be:
|
||||
* rejected at the envelope level only (999 R, no 277CA yet)
|
||||
* payer-rejected only (999 A, then 277CA STC A6 — file was fine,
|
||||
claim was denied)
|
||||
* both (rare — envelope retry after payer rejection)
|
||||
|
||||
We never overwrite a previous ``payer_rejected_at`` with ``NULL``
|
||||
(empty STC list) — that prevents a later, looser 277CA from
|
||||
accidentally clearing the rejection flag.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime, timezone
|
||||
from typing import Callable
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from cyclone.db import Claim
|
||||
|
||||
|
||||
@dataclass
|
||||
class Apply277CAResult:
|
||||
matched: list[str] = field(default_factory=list)
|
||||
orphans: list[str] = field(default_factory=list)
|
||||
already_rejected: list[str] = field(default_factory=list)
|
||||
|
||||
|
||||
def _build_reason(status_code: str, status_description: str | None) -> str:
|
||||
parts = [f"277CA STC {status_code}"]
|
||||
if status_description:
|
||||
parts.append(f"({status_description})")
|
||||
return " ".join(parts)
|
||||
|
||||
|
||||
def apply_277ca_rejections(
|
||||
session: Session,
|
||||
parsed_277ca,
|
||||
*,
|
||||
claim_lookup: Callable[[str], Claim | None],
|
||||
two77ca_id: str,
|
||||
) -> Apply277CAResult:
|
||||
"""For each rejected ``ClaimStatus``, look up the matching claim and stamp it.
|
||||
|
||||
Args:
|
||||
session: SQLAlchemy session.
|
||||
parsed_277ca: a :class:`cyclone.parsers.models_277ca.ParseResult277CA`.
|
||||
claim_lookup: callable from ``payer_claim_control_number`` →
|
||||
``Claim`` or ``None``. Use REF*1K (the payer claim control
|
||||
number) — that's the canonical cross-reference.
|
||||
two77ca_id: the id of the persisted 277CA row that produced
|
||||
this parsed result. Stored on each claim so an operator
|
||||
can trace the rejection back to the source file.
|
||||
|
||||
Returns:
|
||||
Apply277CAResult with matched/orphan/already-rejected lists.
|
||||
"""
|
||||
result = Apply277CAResult()
|
||||
now = datetime.now(timezone.utc)
|
||||
|
||||
for status in parsed_277ca.claim_statuses:
|
||||
if status.classification != "rejected":
|
||||
continue
|
||||
pcn = status.payer_claim_control_number
|
||||
if not pcn:
|
||||
# No REF*1K — we can't tie this rejection to a Cyclone claim.
|
||||
# Surface it as an orphan so the operator knows we saw it.
|
||||
result.orphans.append(status.status_code or "")
|
||||
continue
|
||||
|
||||
claim = claim_lookup(pcn)
|
||||
if claim is None:
|
||||
result.orphans.append(pcn)
|
||||
continue
|
||||
|
||||
if claim.payer_rejected_at is not None:
|
||||
# Idempotent — already stamped. Update only if this is a
|
||||
# newer status code (e.g. A6 → A7 escalation).
|
||||
if (claim.payer_rejected_status_code or "") == status.status_code:
|
||||
result.already_rejected.append(claim.id)
|
||||
continue
|
||||
|
||||
claim.payer_rejected_at = now
|
||||
claim.payer_rejected_reason = _build_reason(
|
||||
status.status_code, status.status_description,
|
||||
)
|
||||
claim.payer_rejected_status_code = status.status_code
|
||||
claim.payer_rejected_by_277ca_id = two77ca_id
|
||||
result.matched.append(claim.id)
|
||||
|
||||
if result.matched or result.already_rejected:
|
||||
session.commit()
|
||||
|
||||
return result
|
||||
|
||||
|
||||
__all__ = ["Apply277CAResult", "apply_277ca_rejections"]
|
||||
@@ -0,0 +1,36 @@
|
||||
-- version: 8
|
||||
-- SP10: 277CA Payer-Rejected lane
|
||||
-- Adds columns to capture the payer-side rejection state separate from
|
||||
-- the existing 999-envelope rejection (file-level). A claim can be:
|
||||
-- * REJECTED via 999 ACK AK5 R/E (envelope rejection, already covered
|
||||
-- by rejected_at + rejection_reason from migration 0004)
|
||||
-- * PAYER_REJECTED via 277CA STC A4/A6/A7 (claim-level rejection by
|
||||
-- the payer after envelope acceptance)
|
||||
-- We keep these distinct so operators can tell *why* a claim isn't
|
||||
-- paid: was it our file (999), or was it the payer's adjudication?
|
||||
--
|
||||
-- Also creates two77ca_acks to persist parsed 277CA files (one row per
|
||||
-- inbound 277CA file, with the per-claim status detail in raw_json).
|
||||
|
||||
ALTER TABLE claims ADD COLUMN payer_rejected_at TEXT;
|
||||
ALTER TABLE claims ADD COLUMN payer_rejected_reason TEXT;
|
||||
ALTER TABLE claims ADD COLUMN payer_rejected_status_code TEXT;
|
||||
ALTER TABLE claims ADD COLUMN payer_rejected_by_277ca_id TEXT;
|
||||
|
||||
CREATE INDEX idx_claims_payer_rejected_at ON claims(payer_rejected_at);
|
||||
|
||||
CREATE TABLE two77ca_acks (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
source_batch_id TEXT NOT NULL REFERENCES batches(id) ON DELETE CASCADE,
|
||||
control_number TEXT NOT NULL,
|
||||
accepted_count INTEGER NOT NULL DEFAULT 0,
|
||||
rejected_count INTEGER NOT NULL DEFAULT 0,
|
||||
paid_count INTEGER NOT NULL DEFAULT 0,
|
||||
pended_count INTEGER NOT NULL DEFAULT 0,
|
||||
parsed_at TEXT NOT NULL,
|
||||
raw_json TEXT
|
||||
);
|
||||
|
||||
CREATE INDEX ix_two77ca_acks_source_batch_id ON two77ca_acks(source_batch_id);
|
||||
CREATE INDEX ix_two77ca_acks_control_number ON two77ca_acks(control_number);
|
||||
|
||||
@@ -66,6 +66,15 @@ _LAZY_EXPORTS: dict[str, str] = {
|
||||
"SERVICE_TYPE_CODES": "cyclone.parsers.models_271",
|
||||
"LAST_UPDATED": "cyclone.parsers.models_271",
|
||||
"service_type_description": "cyclone.parsers.models_271",
|
||||
# models (277CA — SP10)
|
||||
"ACCEPTED_CODES": "cyclone.parsers.models_277ca",
|
||||
"AcknowledgmentHeader277": "cyclone.parsers.models_277ca",
|
||||
"ClaimStatus": "cyclone.parsers.models_277ca",
|
||||
"PAID_CODES": "cyclone.parsers.models_277ca",
|
||||
"PENDED_CODES": "cyclone.parsers.models_277ca",
|
||||
"ParseResult277CA": "cyclone.parsers.models_277ca",
|
||||
"REJECTED_CODES": "cyclone.parsers.models_277ca",
|
||||
"classify_status_code": "cyclone.parsers.models_277ca",
|
||||
# CARC lookup (SP3 P2 T6)
|
||||
"reason_label": "cyclone.parsers.cas_codes",
|
||||
"all_known_codes": "cyclone.parsers.cas_codes",
|
||||
@@ -81,6 +90,7 @@ _LAZY_EXPORTS: dict[str, str] = {
|
||||
"parse_999": "cyclone.parsers.parse_999",
|
||||
"parse_270": "cyclone.parsers.parse_270",
|
||||
"parse_271": "cyclone.parsers.parse_271",
|
||||
"parse_277ca": "cyclone.parsers.parse_277ca",
|
||||
"serialize_999": "cyclone.parsers.serialize_999",
|
||||
"serialize_270": "cyclone.parsers.serialize_270",
|
||||
"build_ack_for_batch": "cyclone.parsers.batch_ack_builder",
|
||||
|
||||
@@ -0,0 +1,161 @@
|
||||
"""Pydantic v2 models for parsed 277CA (Claim Acknowledgment) files.
|
||||
|
||||
Mirrors the X12 005010X214 segment shape (HCPF sends this transaction
|
||||
back after we submit an 837P batch):
|
||||
|
||||
- ``AcknowledgmentHeader`` (BHT) — beginning of hierarchical transaction
|
||||
- ``ClaimStatus`` (STC + REF*1K + REF*EJ + AMT*YU + DTP*472) — one per
|
||||
payer-acknowledged claim. Carries the category/status code and the
|
||||
payer claim control number used to match back to a Cyclone claim row.
|
||||
- ``ParseResult277CA`` — top-level envelope + per-claim status list.
|
||||
|
||||
Why this lean shape: the 277CA is a status-reporting transaction, not a
|
||||
claim. We surface just enough information to (a) match each STC row back
|
||||
to a Cyclone claim by ``payer_claim_control_number`` (REF*1K) and
|
||||
(b) classify it as accepted / pended / rejected / paid so the Inbox
|
||||
Payer-Rejected lane can light up.
|
||||
|
||||
Per HCPF X12 File Naming Standards, the inbound filename uses ``277``
|
||||
in the file_type slot (the transaction-set id ``277CA`` is conveyed in
|
||||
the ST segment itself, not in the filename).
|
||||
"""
|
||||
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 BatchSummary, Envelope, ValidationIssue, ValidationReport
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Shared base
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
class _Base(BaseModel):
|
||||
"""Shared Pydantic base; matches the 999 / 835 / 270 / 271 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
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# 277CA segment shapes
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
# Status category codes per X12 005010X214 §1.4.2 STC01-1.
|
||||
# We categorize each STC into a small set of lanes the Inbox cares about.
|
||||
ACCEPTED_CODES = {"A1", "A2", "A3"}
|
||||
REJECTED_CODES = {"A4", "A6", "A7"}
|
||||
PENDED_CODES = {"A8", "A9"}
|
||||
PAID_CODES = {"P1", "P2", "P3", "P4", "P5"}
|
||||
|
||||
|
||||
def classify_status_code(code: str) -> Literal["accepted", "rejected", "pended", "paid", "unknown"]:
|
||||
"""Map an STC01-1 category code to an Inbox lane label.
|
||||
|
||||
Unknown codes (rare; new payer-specific codes) become ``"unknown"``
|
||||
so the parser doesn't raise — operators can still see them in the
|
||||
277CA detail view.
|
||||
"""
|
||||
code = (code or "").strip().upper()
|
||||
if code in ACCEPTED_CODES:
|
||||
return "accepted"
|
||||
if code in REJECTED_CODES:
|
||||
return "rejected"
|
||||
if code in PENDED_CODES:
|
||||
return "pended"
|
||||
if code in PAID_CODES:
|
||||
return "paid"
|
||||
return "unknown"
|
||||
|
||||
|
||||
class AcknowledgmentHeader(_Base):
|
||||
"""BHT — Beginning of Hierarchical Transaction.
|
||||
|
||||
BHT01 = hierarchical_structure_code (e.g. "0085" for Claim Ack).
|
||||
BHT02 = transaction_set_purpose_code (e.g. "08" for Status).
|
||||
BHT03 = reference_identification (often the submitter batch id).
|
||||
BHT04 = transaction_set_creation_date (CCYYMMDD).
|
||||
BHT05 = transaction_set_creation_time (HHMM).
|
||||
BHT06 = transaction_type_code (e.g. "TH").
|
||||
"""
|
||||
|
||||
hierarchical_structure_code: str
|
||||
transaction_set_purpose_code: str
|
||||
reference_identification: str | None = None
|
||||
transaction_set_creation_date: date | None = None
|
||||
transaction_set_creation_time: str | None = None
|
||||
transaction_type_code: str | None = None
|
||||
|
||||
|
||||
class ClaimStatus(_Base):
|
||||
"""STC + REF*1K + REF*EJ + AMT*YU + DTP*472 — one payer-acknowledged claim.
|
||||
|
||||
STC01 is a composite of three elements: ``category_code:status_code:entity_identifier``.
|
||||
HCPF only populates the category code (e.g. ``A3:19:PR``); the parser
|
||||
surfaces ``category_code`` and ``status_code`` separately.
|
||||
|
||||
``payer_claim_control_number`` is the REF*1K value the payer assigned
|
||||
(or echoed back) for the claim. This is what matches back to a
|
||||
Cyclone claim row (we store it on Claim.payer_claim_control_number
|
||||
during 837 serialize).
|
||||
|
||||
``amount`` is the AMT*YU value (total claim charge amount
|
||||
acknowledged by the payer). Optional — HCPF omits it for non-claim
|
||||
statuses (e.g. subscriber-level pends).
|
||||
"""
|
||||
|
||||
status_code: str
|
||||
status_description: str | None = None
|
||||
entity_identifier: str | None = None
|
||||
status_effective_date: date | None = None
|
||||
status_action_code: str | None = None
|
||||
total_claim_charge_amount: float | None = None
|
||||
payer_claim_control_number: str | None = None
|
||||
billing_provider_tax_id: str | None = None
|
||||
service_date: date | None = None
|
||||
classification: Literal["accepted", "rejected", "pended", "paid", "unknown"] = "unknown"
|
||||
|
||||
|
||||
class ParseResult277CA(_Base):
|
||||
"""Top-level parsed 277CA document.
|
||||
|
||||
Multiple claims can be acknowledged in a single 277CA; the parser
|
||||
flattens the HL hierarchy (20→21→19→PT) into one ClaimStatus per
|
||||
Patient-level HL. Subscriber- and provider-level statuses (when no
|
||||
Patient HL exists) are also captured under ``unscoped_statuses``
|
||||
for operator visibility.
|
||||
"""
|
||||
|
||||
envelope: Envelope
|
||||
bht: AcknowledgmentHeader
|
||||
claim_statuses: list[ClaimStatus] = Field(default_factory=list)
|
||||
unscoped_statuses: list[ClaimStatus] = Field(default_factory=list)
|
||||
summary: BatchSummary
|
||||
|
||||
|
||||
__all__ = [
|
||||
"ACCEPTED_CODES",
|
||||
"AcknowledgmentHeader",
|
||||
"ClaimStatus",
|
||||
"PAID_CODES",
|
||||
"PENDED_CODES",
|
||||
"ParseResult277CA",
|
||||
"REJECTED_CODES",
|
||||
"classify_status_code",
|
||||
]
|
||||
|
||||
|
||||
# Silence unused-import lints.
|
||||
_ = (BaseModel, ValidationIssue, ValidationReport)
|
||||
@@ -0,0 +1,355 @@
|
||||
"""Parse an X12 277CA (Claim Acknowledgment) file.
|
||||
|
||||
The 277CA is the *semantic* ack a payer (or its clearinghouse) sends
|
||||
back after they accept our 837P batch. It contrasts with the 999 ACK
|
||||
which only reports the *syntactic* envelope status. A 999 "Accepted"
|
||||
plus a 277CA with STC*A6 means: the file was syntactically valid, but
|
||||
one or more specific claims were rejected by the payer.
|
||||
|
||||
Layout (simplified, single claim)::
|
||||
|
||||
ISA*…~
|
||||
GS*HN*…*…*20240620*1200*1*X*005010X214~
|
||||
ST*277*0001*005010X214~ (HCPF sometimes sends ST*277CA*0001*005010X214)
|
||||
BHT*0085*08*REFNUM*20240620*1200*TH~
|
||||
HL*1**20*1~ (Information Source — the payer)
|
||||
NM1*PR*2*COLORADO MEDICAL ASSISTANCE PROGRAM*****PI*COMEDASSISTPROG~
|
||||
HL*2*1*21*1~ (Information Receiver — us)
|
||||
NM1*41*2*DZINESCO*****46*DZINESCO~
|
||||
HL*3*2*19*1~ (Subscriber)
|
||||
NM1*IL*1*DOE*JOHN****MI*MEMBERID~
|
||||
HL*4*3*PT~ (Patient — a claim follows)
|
||||
NM1*QC*1*DOE*JANE~
|
||||
REF*1K*PAYERCLAIMID123~
|
||||
REF*EJ*721587149~
|
||||
STC*A6:19:PR*20240620*U*150.00~ (rejected)
|
||||
QTY*90*1~
|
||||
AMT*YU*150.00~
|
||||
DTP*472*RD8*20240601-20240601~
|
||||
SE*XX*0001~
|
||||
GE*1*1~
|
||||
IEA*1*000000001~
|
||||
|
||||
The parser walks the HL hierarchy and, for each ``HL*…*PT`` segment,
|
||||
collects the trailing ``STC*``, ``REF*1K``, ``REF*EJ``, ``AMT*YU`` and
|
||||
``DTP*472`` siblings into a :class:`ClaimStatus`. STC segments under
|
||||
non-Patient HLs (provider- or subscriber-level) go into
|
||||
``unscoped_statuses`` — HCPF sometimes sends a blanket reject at the
|
||||
subscriber level which we want to surface even though we can't tie it
|
||||
to a specific Cyclone claim row.
|
||||
"""
|
||||
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_277ca import (
|
||||
AcknowledgmentHeader,
|
||||
ClaimStatus,
|
||||
ParseResult277CA,
|
||||
classify_status_code,
|
||||
)
|
||||
from cyclone.parsers.segments import tokenize
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Date / amount parsing
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
def _parse_yyyymmdd(s: str) -> date | None:
|
||||
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
|
||||
|
||||
|
||||
def _parse_yymmdd(s: str) -> date | None:
|
||||
if not s or len(s) != 6 or not s.isdigit():
|
||||
return None
|
||||
try:
|
||||
return date(2000 + int(s[0:2]), int(s[2:4]), int(s[4:6]))
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
|
||||
def _parse_date_loose(s: str) -> date | None:
|
||||
"""Parse an X12 date — accept 8-digit CCYYMMDD or 6-digit YYMMDD."""
|
||||
if not s or not s.isdigit():
|
||||
return None
|
||||
if len(s) == 8:
|
||||
return _parse_yyyymmdd(s)
|
||||
if len(s) == 6:
|
||||
return _parse_yymmdd(s)
|
||||
return None
|
||||
|
||||
|
||||
def _parse_service_date(s: str) -> date | None:
|
||||
"""Parse DTP*472 values. HCPF sends ``RD8*20240601-20240601`` (range) or ``D8*20240601``.
|
||||
|
||||
For ranges we return the start date — that's what matches the
|
||||
Cyclone claim's ``service_date_from``.
|
||||
"""
|
||||
if not s:
|
||||
return None
|
||||
# RD8 = range of dates (CCYYMMDD-CCYYMMDD); take the start.
|
||||
if "-" in s:
|
||||
start = s.split("-", 1)[0]
|
||||
return _parse_date_loose(start)
|
||||
return _parse_date_loose(s)
|
||||
|
||||
|
||||
def _parse_amount(s: str) -> float | None:
|
||||
"""Parse an X12 monetary value. Returns None on bad input."""
|
||||
if not s:
|
||||
return None
|
||||
try:
|
||||
return float(s)
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Envelope
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
def _build_envelope(segments: list[list[str]], input_file: str) -> tuple[Envelope, str]:
|
||||
"""Build the envelope from ISA/GS/ST. Returns ``(envelope, tx_set_id)``.
|
||||
|
||||
``tx_set_id`` carries the ST01 value (``"277"`` or ``"277CA"``) so
|
||||
the rest of the parser doesn't have to re-scan the segment list.
|
||||
"""
|
||||
summary = BatchSummary(input_file=input_file)
|
||||
envelope: Envelope | None = None
|
||||
tx_set_id: str = ""
|
||||
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),
|
||||
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_date_loose(seg[3]) or txn_date
|
||||
elif seg[0] == "ST" and envelope is not None:
|
||||
if len(seg) > 1:
|
||||
tx_set_id = seg[1]
|
||||
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, tx_set_id
|
||||
|
||||
|
||||
def _build_bht(segments: list[list[str]]) -> AcknowledgmentHeader:
|
||||
"""Find the BHT segment and return it. Falls back to an empty header."""
|
||||
for seg in segments:
|
||||
if seg[0] == "BHT":
|
||||
return AcknowledgmentHeader(
|
||||
hierarchical_structure_code=seg[1] if len(seg) > 1 else "",
|
||||
transaction_set_purpose_code=seg[2] if len(seg) > 2 else "",
|
||||
reference_identification=seg[3] if len(seg) > 3 and seg[3] else None,
|
||||
transaction_set_creation_date=_parse_date_loose(seg[4]) if len(seg) > 4 else None,
|
||||
transaction_set_creation_time=seg[5] if len(seg) > 5 and seg[5] else None,
|
||||
transaction_type_code=seg[6] if len(seg) > 6 and seg[6] else None,
|
||||
)
|
||||
return AcknowledgmentHeader(
|
||||
hierarchical_structure_code="",
|
||||
transaction_set_purpose_code="",
|
||||
)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# HL-walking and per-claim status construction
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
def _parse_stc(seg: list[str]) -> ClaimStatus:
|
||||
"""Convert an STC segment into a partial ClaimStatus.
|
||||
|
||||
STC is a composite segment: ``STC*cat:stat:entity*date*action*amount``.
|
||||
HCPF only populates ``cat`` (``A6:19:PR``); the rest are optional.
|
||||
"""
|
||||
composite = seg[1] if len(seg) > 1 else ""
|
||||
parts = composite.split(":") if composite else []
|
||||
category = parts[0] if parts else ""
|
||||
status = parts[1] if len(parts) > 1 and parts[1] else None
|
||||
entity = parts[2] if len(parts) > 2 and parts[2] else None
|
||||
effective = _parse_date_loose(seg[2]) if len(seg) > 2 else None
|
||||
action = seg[3] if len(seg) > 3 and seg[3] else None
|
||||
amount = _parse_amount(seg[4]) if len(seg) > 4 else None
|
||||
return ClaimStatus(
|
||||
status_code=category,
|
||||
status_description=status,
|
||||
entity_identifier=entity,
|
||||
status_effective_date=effective,
|
||||
status_action_code=action,
|
||||
total_claim_charge_amount=amount,
|
||||
classification=classify_status_code(category),
|
||||
)
|
||||
|
||||
|
||||
def _consume_patient_block(segments: list[list[str]], idx: int) -> tuple[ClaimStatus | None, int]:
|
||||
"""Read all segments under an HL*…*PT block (until the next HL or SE).
|
||||
|
||||
Returns ``(status, next_idx)``. ``status`` is ``None`` when the block
|
||||
contained no STC segments (a Patient HL with no status is unusual
|
||||
but not illegal — surface it as ``None`` so the caller can log).
|
||||
"""
|
||||
parts: dict[str, object] = {}
|
||||
stc: ClaimStatus | None = None
|
||||
i = idx
|
||||
while i < len(segments):
|
||||
seg = segments[i]
|
||||
if seg[0] in {"HL", "SE"}:
|
||||
break
|
||||
if seg[0] == "REF":
|
||||
qualifier = seg[1] if len(seg) > 1 else ""
|
||||
value = seg[2] if len(seg) > 2 else ""
|
||||
if qualifier == "1K":
|
||||
parts["payer_claim_control_number"] = value
|
||||
elif qualifier == "EJ":
|
||||
parts["billing_provider_tax_id"] = value
|
||||
elif seg[0] == "STC":
|
||||
# Per X12 005010X214 a Patient HL can have multiple STC
|
||||
# segments (one per status). We take the last one as
|
||||
# authoritative — that's typically the most recent action
|
||||
# (e.g. STC*A1 then STC*A3 means "first pended, then paid").
|
||||
stc = _parse_stc(seg)
|
||||
elif seg[0] == "AMT":
|
||||
qualifier = seg[1] if len(seg) > 1 else ""
|
||||
value = _parse_amount(seg[2]) if len(seg) > 2 else None
|
||||
if qualifier == "YU" and value is not None:
|
||||
parts["total_claim_charge_amount"] = value
|
||||
elif seg[0] == "DTP":
|
||||
qualifier = seg[1] if len(seg) > 1 else ""
|
||||
fmt = seg[2] if len(seg) > 2 else ""
|
||||
value = seg[3] if len(seg) > 3 else ""
|
||||
if qualifier == "472" and fmt in ("D8", "RD8"):
|
||||
parts["service_date"] = _parse_service_date(value)
|
||||
i += 1
|
||||
|
||||
if stc is None:
|
||||
return None, i
|
||||
|
||||
# Merge the REF/AMT/DTP capture onto the last STC.
|
||||
merged = stc.model_copy(update=parts)
|
||||
return merged, i
|
||||
|
||||
|
||||
def _consume_subscriber_block(segments: list[list[str]], idx: int) -> tuple[list[ClaimStatus], int]:
|
||||
"""Read STC segments under a Subscriber (HL*…*19*1) block.
|
||||
|
||||
Subscriber-level STCs have no REF*1K — they apply to the whole
|
||||
subscriber's claim batch. Surface them in
|
||||
:attr:`ParseResult277CA.unscoped_statuses`.
|
||||
"""
|
||||
out: list[ClaimStatus] = []
|
||||
i = idx
|
||||
while i < len(segments):
|
||||
seg = segments[i]
|
||||
if seg[0] in {"HL", "SE"}:
|
||||
break
|
||||
if seg[0] == "STC":
|
||||
out.append(_parse_stc(seg))
|
||||
i += 1
|
||||
return out, i
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Top-level orchestrator
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
def parse_277ca_text(text: str, *, input_file: str = "") -> ParseResult277CA:
|
||||
"""Parse a complete 277CA document and return a :class:`ParseResult277CA`.
|
||||
|
||||
Both ``ST*277*`` and ``ST*277CA*`` shapes are accepted. Per X12
|
||||
005010X214 a 277CA can have multiple Patient HLs — we surface them
|
||||
all in ``claim_statuses``. STC segments under Subscriber HLs go
|
||||
into ``unscoped_statuses`` because we can't tie them to a Cyclone
|
||||
claim row without an explicit REF*1K.
|
||||
|
||||
Whole-document problems (missing ISA, no ST) raise
|
||||
:class:`CycloneParseError`. Per-segment quirks are surfaced on the
|
||||
result and never raised, matching the 999 parser's contract.
|
||||
"""
|
||||
segments = tokenize(text)
|
||||
envelope, tx_set_id = _build_envelope(segments, input_file=input_file)
|
||||
|
||||
if tx_set_id not in ("277", "277CA"):
|
||||
raise CycloneParseError(
|
||||
f"Expected ST*277 or ST*277CA, got ST*{tx_set_id!r}"
|
||||
)
|
||||
|
||||
bht = _build_bht(segments)
|
||||
|
||||
claim_statuses: list[ClaimStatus] = []
|
||||
unscoped_statuses: list[ClaimStatus] = []
|
||||
|
||||
# Walk all HL segments, in order.
|
||||
for i, seg in enumerate(segments):
|
||||
if seg[0] != "HL":
|
||||
continue
|
||||
# HL04 carries the level code: "20" (info source), "21" (info
|
||||
# receiver), "19" (subscriber), "PT" (patient). Only PT and 19
|
||||
# carry claim-level STCs.
|
||||
level_code = seg[3] if len(seg) > 3 else ""
|
||||
if level_code == "PT":
|
||||
status, _next = _consume_patient_block(segments, i + 1)
|
||||
if status is not None:
|
||||
claim_statuses.append(status)
|
||||
elif level_code == "19":
|
||||
# Subscriber-level: only surface when there's actually an
|
||||
# STC under it (otherwise it's a blank frame).
|
||||
inner, _next = _consume_subscriber_block(segments, i + 1)
|
||||
if inner:
|
||||
unscoped_statuses.extend(inner)
|
||||
|
||||
# Summary counts — for the Inbox lane counts.
|
||||
total = len(claim_statuses)
|
||||
accepted = sum(1 for s in claim_statuses if s.classification == "accepted")
|
||||
rejected = sum(1 for s in claim_statuses if s.classification == "rejected")
|
||||
pended = sum(1 for s in claim_statuses if s.classification == "pended")
|
||||
paid = sum(1 for s in claim_statuses if s.classification == "paid")
|
||||
# "failed" is what the Inbox cares about: rejected + pended.
|
||||
# Pended claims aren't final (the payer may still pay them) but we
|
||||
# surface them under a different lane (Payer-Pended, added later).
|
||||
# For the SP10 Payer-Rejected lane only "rejected" counts.
|
||||
failed = rejected
|
||||
summary = BatchSummary(
|
||||
input_file=input_file,
|
||||
control_number=envelope.control_number,
|
||||
transaction_date=envelope.transaction_date,
|
||||
total_claims=total,
|
||||
passed=accepted + paid,
|
||||
failed=failed,
|
||||
)
|
||||
_ = pended # surfaced separately in future SPs; tracked but unused here
|
||||
|
||||
return ParseResult277CA(
|
||||
envelope=envelope,
|
||||
bht=bht,
|
||||
claim_statuses=claim_statuses,
|
||||
unscoped_statuses=unscoped_statuses,
|
||||
summary=summary,
|
||||
)
|
||||
|
||||
|
||||
__all__ = ["parse_277ca_text"]
|
||||
@@ -20,7 +20,7 @@ from typing import Any
|
||||
import yaml
|
||||
from pydantic import ValidationError
|
||||
|
||||
from cyclone.providers import PayerConfig837, PayerConfig835
|
||||
from cyclone.providers import PayerConfig277CA, PayerConfig837, PayerConfig835
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
@@ -42,8 +42,10 @@ def _tx_to_model(transaction_type: str) -> type:
|
||||
return PayerConfig837
|
||||
if tx_str == "835":
|
||||
return PayerConfig835
|
||||
# Other tx types (999, TA1, 270, 271, 277CA) reuse the 837 schema
|
||||
# for now — they have similar per-payer config shapes.
|
||||
if tx_str == "277CA":
|
||||
return PayerConfig277CA
|
||||
# Other tx types (999, TA1, 270, 271) reuse the 837 schema for now
|
||||
# — they have similar per-payer config shapes.
|
||||
return PayerConfig837
|
||||
|
||||
|
||||
|
||||
@@ -98,6 +98,27 @@ class PayerConfig835(BaseModel):
|
||||
payer_name_pattern: str = ""
|
||||
|
||||
|
||||
class PayerConfig277CA(BaseModel):
|
||||
"""Per-payer, per-277CA-transaction-type configuration block.
|
||||
|
||||
Carries the status-code classification sets the parser and Inbox
|
||||
lane logic consult. HCPF publishes the canonical STC category codes
|
||||
(A1-A9 for adjudication states, P1-P5 for paid states); this block
|
||||
lets the operator override the classification per-payer without
|
||||
touching code.
|
||||
"""
|
||||
|
||||
model_config = ConfigDict(extra="ignore")
|
||||
|
||||
rejected_status_codes: list[str] = Field(default_factory=lambda: ["A4", "A6", "A7"])
|
||||
pended_status_codes: list[str] = Field(default_factory=lambda: ["A8", "A9"])
|
||||
accepted_status_codes: list[str] = Field(default_factory=lambda: ["A1", "A2", "A3"])
|
||||
paid_status_codes: list[str] = Field(default_factory=lambda: ["P1", "P2", "P3", "P4", "P5"])
|
||||
# HCPF uses ST*277CA but the X12 spec also allows ST*277. Both accepted.
|
||||
transaction_set_ids_allowed: list[str] = Field(default_factory=lambda: ["277", "277CA"])
|
||||
implementation_guide: str = "005010X214"
|
||||
|
||||
|
||||
class PayerConfigRow(BaseModel):
|
||||
"""One row in the payer_configs table.
|
||||
|
||||
|
||||
@@ -1800,6 +1800,55 @@ class CycloneStore:
|
||||
with db.SessionLocal()() as s:
|
||||
return s.get(db.Ta1Ack, ack_id)
|
||||
|
||||
# -- 277CA (SP10) --------------------------------------------------
|
||||
|
||||
def add_277ca_ack(
|
||||
self,
|
||||
*,
|
||||
source_batch_id: str,
|
||||
control_number: str,
|
||||
accepted_count: int,
|
||||
rejected_count: int,
|
||||
paid_count: int,
|
||||
pended_count: int,
|
||||
raw_json: dict,
|
||||
) -> db.Two77caAck:
|
||||
"""Persist a 277CA (Claim Acknowledgment) row and return it.
|
||||
|
||||
Mirrors :meth:`add_ack` but for the claim-level ack. The
|
||||
per-claim status detail stays in ``raw_json``; only the four
|
||||
counts are promoted so the list endpoint stays fast.
|
||||
"""
|
||||
with db.SessionLocal()() as s:
|
||||
row = db.Two77caAck(
|
||||
source_batch_id=source_batch_id,
|
||||
control_number=control_number,
|
||||
accepted_count=accepted_count,
|
||||
rejected_count=rejected_count,
|
||||
paid_count=paid_count,
|
||||
pended_count=pended_count,
|
||||
parsed_at=utcnow(),
|
||||
raw_json=raw_json,
|
||||
)
|
||||
s.add(row)
|
||||
s.commit()
|
||||
s.refresh(row)
|
||||
return row
|
||||
|
||||
def list_277ca_acks(self) -> list[db.Two77caAck]:
|
||||
"""Return every 277CA ACK row, newest first (auto-increment id desc)."""
|
||||
with db.SessionLocal()() as s:
|
||||
return (
|
||||
s.query(db.Two77caAck)
|
||||
.order_by(db.Two77caAck.id.desc())
|
||||
.all()
|
||||
)
|
||||
|
||||
def get_277ca_ack(self, ack_id: int) -> db.Two77caAck | None:
|
||||
"""Return a single 277CA ACK row by id, or ``None`` if not found."""
|
||||
with db.SessionLocal()() as s:
|
||||
return s.get(db.Two77caAck, ack_id)
|
||||
|
||||
# -- manual reconciliation (T12) -----------------------------------
|
||||
|
||||
def list_unmatched(self, *, kind: str = "both") -> dict:
|
||||
|
||||
Reference in New Issue
Block a user