feat(sp28): apply_claim_ack_links orchestrator + 999/277CA/TA1 helpers

The auto-linker closes the operator gap where every inbound 999 /
277CA / TA1 ack was persisted but never linked back to the claim it
acknowledges. Five pure helpers land in cyclone/claim_acks.py:

- lookup_claims_for_ack_set_response — D10 two-pass join. Primary is
  Batch.envelope.control_number (== source 837 ST02 for Gainwell
  batches); fallback is Claim.patient_control_number. Pass 1 wins,
  the two paths cannot both fire.
- apply_999_acceptances — walks parsed_999.set_responses, emits one
  ClaimAck per AK2 per matched claim (one-ack-to-many supported).
  Both accepted AND rejected AK2s link; per-AK2 granularity.
- apply_277ca_acks — same shape for parsed_277ca.claim_statuses.
  STC category code carried on the link row's
  set_accept_reject_code so the UI can render the lane inline
  without re-parsing raw_json.
- apply_ta1_envelope_link — envelope-level link. The link row has
  claim_id NULL + batch_id populated (the spec's batch-level TA1
  trace). Sender/receiver matching is delegated to a closure the
  caller supplies.
- link_manual — manually link an ack to a claim. Used by the new
  /api/acks/{kind}/{id}/match-claim endpoint. Idempotent.

All five helpers are pure (callers own the session); idempotent via
the partial unique index ux_claim_acks_dedup (helpers pre-check to
avoid IntegrityError log noise on re-ingest); flush-only (callers
commit).

14 of 15 named tests pass (the facade surface check belongs in
Phase 3 once store/claim_acks.py lands).

Steps 2.1/2.2/2.3/2.4/2.5 of the SP28 implementation plan.
This commit is contained in:
Nora
2026-07-02 11:29:02 -06:00
parent d2512945ca
commit a97f1d1350
2 changed files with 1265 additions and 0 deletions
+478
View File
@@ -0,0 +1,478 @@
"""SP28: per-ACK auto-linker.
Three helpers, one per ACK kind, all run inside the same DB
transaction that persists the Ack row. Each returns a slice of
``ClaimAckLinkResult`` describing what was linked / orphaned / skipped.
Plus :func:`lookup_claims_for_ack_set_response` (the two-pass join from
spec D10) and :func:`link_manual` (the manual-fallback used by
``/api/acks/{kind}/{id}/match-claim``).
The module is pure (no DB session ownership — callers pass in the
session, just like ``cyclone.inbox_state.apply_999_rejections``). The
helpers emit ``ClaimAck`` ORM rows via ``add`` + ``flush``, and the
caller commits.
See ``docs/superpowers/specs/2026-07-02-cyclone-ack-claim-auto-link-design.md``
§3 for the per-AK2 granularity, the two-pass join (ST02 via batch
envelope index primary + Claim.patient_control_number fallback), and
the idempotency contract enforced by ``ux_claim_acks_dedup``.
"""
from __future__ import annotations
import logging
from dataclasses import dataclass, field
from datetime import datetime, timezone
from typing import Callable, Iterable, Optional
from sqlalchemy.orm import Session
from cyclone.db import Batch, Claim, ClaimAck
log = logging.getLogger(__name__)
@dataclass
class ClaimAckLinkResult:
"""Outcome of a single helper call.
``linked`` is a list of ``(claim_id, ak2_index)`` tuples — the link
rows actually written. ``ak2_index`` is ``None`` for 277CA / TA1
links (no per-segment index in those ack kinds) and an int for
999. ``orphans`` is a list of ``set_control_number`` strings the
join couldn't resolve to any claim (or, for TA1, the ICN when no
matching batch was found).
"""
linked: list[tuple[Optional[str], Optional[int]]] = field(default_factory=list)
orphans: list[str] = field(default_factory=list)
# ---------------------------------------------------------------------------
# D10 two-pass join
# ---------------------------------------------------------------------------
def lookup_claims_for_ack_set_response(
session: Session,
set_control_number: str,
*,
batch_envelope_index: Optional[Callable[[str], Optional[str]]] = None,
pc_claim_lookup: Optional[Callable[[str], Optional[Claim]]] = None,
) -> list[Claim]:
"""Two-pass join for a single AK2 set_response / 277CA claim_status.
D10 (spec): for a 999 AK2-2 ``set_control_number`` or a 277CA REF*1K
``payer_claim_control_number``, return every claim this ack
acknowledges. The primary join is
``Batch.envelope.control_number == set_control_number`` (== source
837's ST02 on Gainwell batches); the fallback is
``Claim.patient_control_number == set_control_number``.
Returns 0..N matching claims (one-ack-to-many when one 837 batch
shipped multiple claims under one ST02).
Args:
session: SQLAlchemy session the caller owns.
set_control_number: the AK2-2 / REF*1K value to resolve.
batch_envelope_index: optional pre-built index that maps
``Batch.envelope.control_number`` → ``batch.id`` (built
once per ingest via ``store.batch_envelope_index``).
Pass to skip the per-set-response ``Batch`` scan in Pass 1.
pc_claim_lookup: optional pre-built callable that maps a PCN
to a single claim. Falls back to a session-wide query
when not supplied.
Pass 1 wins. The two paths cannot both fire for the same
``set_control_number`` — if Pass 1 returns one or more claims,
Pass 2 is skipped. This is the false-positive guard from
spec §7.
"""
if not set_control_number:
return []
# -- Pass 1: Batch.envelope.control_number primary --------------
matched_ids: list[str] = []
if batch_envelope_index is not None:
batch_id = batch_envelope_index(set_control_number)
if batch_id is not None:
matched_ids = [
cid for (cid,) in (
session.query(Claim.id)
.filter(Claim.batch_id == batch_id)
.all()
)
]
else:
# Fallback: scan all batches once per call. Slow but correct;
# callers SHOULD pass the index.
rows = (
session.query(Batch.id, Batch.raw_result_json)
.filter(Batch.kind == "837")
.all()
)
for bid, raw in rows:
env = (raw or {}).get("envelope") or {}
if env.get("control_number") == set_control_number:
matched_ids = [
cid for (cid,) in (
session.query(Claim.id)
.filter(Claim.batch_id == bid)
.all()
)
]
break
if matched_ids:
claims = (
session.query(Claim)
.filter(Claim.id.in_(matched_ids))
.all()
)
if claims:
return list(claims)
# -- Pass 2: Claim.patient_control_number fallback ---------------
if pc_claim_lookup is not None:
single = pc_claim_lookup(set_control_number)
if single is not None:
return [single]
return []
matches = (
session.query(Claim)
.filter(Claim.patient_control_number == set_control_number)
.all()
)
return list(matches)
# ---------------------------------------------------------------------------
# Per-ACK helpers — walk the parsed result and create one ClaimAck per
# matched claim. Idempotent via dedup pre-check + ux_claim_acks_dedup.
# ---------------------------------------------------------------------------
def _link_exists(
session: Session,
*,
claim_id: str,
ack_kind: str,
ack_id: int,
ak2_index: Optional[int],
) -> bool:
"""True when a link row already exists for this dedup key.
Mirrors the partial unique index
``ux_claim_acks_dedup(claim_id, ack_kind, ack_id, ak2_index)
WHERE claim_id IS NOT NULL AND ak2_index IS NOT NULL``. For TA1 /
277CA where ``ak2_index`` is NULL we still want to dedup at
``(claim_id, ack_kind, ack_id)`` (the Inbox ack-orphans lane
shouldn't show the same PCN twice); a second narrower query
covers that case so a re-ingest of the same file is a no-op.
"""
if ak2_index is not None:
return (
session.query(ClaimAck.id)
.filter(
ClaimAck.claim_id == claim_id,
ClaimAck.ack_kind == ack_kind,
ClaimAck.ack_id == ack_id,
ClaimAck.ak2_index == ak2_index,
)
.first()
is not None
)
return (
session.query(ClaimAck.id)
.filter(
ClaimAck.claim_id == claim_id,
ClaimAck.ack_kind == ack_kind,
ClaimAck.ack_id == ack_id,
ClaimAck.ak2_index.is_(None),
)
.first()
is not None
)
def apply_999_acceptances(
session: Session,
parsed_999,
*,
ack_id: int,
batch_envelope_index: Optional[Callable[[str], Optional[str]]] = None,
pc_claim_lookup: Optional[Callable[[str], Optional[Claim]]] = None,
now: Optional[datetime] = None,
) -> ClaimAckLinkResult:
"""For every AK2 set-response, create one ``ClaimAck`` row per matched claim.
Both accepted AND rejected AK2s get a link row (so the
ClaimDrawer panel can show the rejection inline via the
``set_accept_reject_code`` color-coded chip). Orphans are returned
but not linked.
Idempotent: if a ``(claim_id, '999', ack_id, ak2_index=i)`` row
already exists, the duplicate is skipped silently — DB-level
enforcement via ``ux_claim_acks_dedup``; the pre-check is here to
avoid IntegrityError log noise.
One AK2 can produce multiple ``claim_acks`` rows when the source
837 batch carried more than one claim under a shared ST02 (rare
on this codebase but supported by D10 / the schema).
"""
result = ClaimAckLinkResult()
ts = now or datetime.now(timezone.utc)
set_responses = getattr(parsed_999, "set_responses", None) or []
for idx, sr in enumerate(set_responses):
scn = getattr(sr, "set_control_number", None) or ""
code = getattr(sr.set_accept_reject, "code", None) or ""
claims = lookup_claims_for_ack_set_response(
session, scn,
batch_envelope_index=batch_envelope_index,
pc_claim_lookup=pc_claim_lookup,
)
if not claims:
result.orphans.append(scn)
continue
for claim in claims:
if _link_exists(
session,
claim_id=claim.id,
ack_kind="999",
ack_id=ack_id,
ak2_index=idx,
):
continue
session.add(ClaimAck(
claim_id=claim.id,
batch_id=None,
ack_id=ack_id,
ack_kind="999",
ak2_index=idx,
set_control_number=scn,
set_accept_reject_code=code,
linked_at=ts,
linked_by="auto",
))
result.linked.append((claim.id, idx))
if result.linked:
session.flush()
return result
def apply_277ca_acks(
session: Session,
parsed_277ca,
*,
ack_id: int,
batch_envelope_index: Optional[Callable[[str], Optional[str]]] = None,
pc_claim_lookup: Optional[Callable[[str], Optional[Claim]]] = None,
now: Optional[datetime] = None,
) -> ClaimAckLinkResult:
"""For every ClaimStatus with a payer_claim_control_number, create a ClaimAck.
Accepted AND rejected ClaimStatuses both link — the
``set_accept_reject_code`` carries the STC category code. The
``claim_acks`` row is independent of the existing
``Claim.payer_rejected_at`` mutation from
:func:`cyclone.inbox_state_277ca.apply_277ca_rejections` (which
fires before this helper in the handler).
Idempotent via dedup key ``(claim_id, '277ca', ack_id, NULL)``.
Args:
parsed_277ca: a :class:`cyclone.parsers.models_277ca.ParseResult277CA`.
"""
result = ClaimAckLinkResult()
ts = now or datetime.now(timezone.utc)
statuses = getattr(parsed_277ca, "claim_statuses", None) or []
for status in statuses:
scn = getattr(status, "payer_claim_control_number", None) or ""
code = (
getattr(getattr(status, "status_code", None), "__class__", None)
and (status.status_code or "")
)
# ``status_code`` may be a category like "A6:19:PR" — keep the
# whole STC composite so the UI can render the category without
# re-parsing raw_json. Truncate to 8 chars (column width).
code = (code or "")[:8]
if not scn:
# No REF*1K — orphan. Surface the STC composite so the
# operator can correlate via the ack's raw_json.
orphan_key = code or "(no REF*1K)"
result.orphans.append(orphan_key)
continue
claims = lookup_claims_for_ack_set_response(
session, scn,
batch_envelope_index=batch_envelope_index,
pc_claim_lookup=pc_claim_lookup,
)
if not claims:
result.orphans.append(scn)
continue
for claim in claims:
if _link_exists(
session,
claim_id=claim.id,
ack_kind="277ca",
ack_id=ack_id,
ak2_index=None,
):
continue
session.add(ClaimAck(
claim_id=claim.id,
batch_id=None,
ack_id=ack_id,
ack_kind="277ca",
ak2_index=None,
set_control_number=scn,
set_accept_reject_code=code or None,
linked_at=ts,
linked_by="auto",
))
result.linked.append((claim.id, None))
if result.linked:
session.flush()
return result
def apply_ta1_envelope_link(
session: Session,
parsed_ta1,
*,
ack_id: int,
batch_lookup: Callable[[str, str], Optional[Batch]],
now: Optional[datetime] = None,
) -> ClaimAckLinkResult:
"""Link a TA1 to the most-recent Batch whose sender_id/receiver_id match.
TA1 is envelope-level only (ISA/IEA, no per-claim granularity).
The link row has ``claim_id IS NULL`` and ``batch_id`` populated.
Args:
parsed_ta1: a :class:`cyclone.parsers.models_ta1.ParseResultTa1`.
batch_lookup: ``(sender_id, receiver_id) -> Batch | None``.
The handler supplies a closure that calls
``session.query(Batch).order_by(parsed_at.desc()).first()``.
Returning ``None`` produces an orphan (no batch match).
Idempotent via dedup key ``(None, 'ta1', ack_id, NULL)`` — since
``claim_id IS NULL`` the partial unique index does not catch it
and we have to fall back to a Python-level check. Because TA1 is
a singleton per file this is cheap.
"""
result = ClaimAckLinkResult()
ts = now or datetime.now(timezone.utc)
envelope = getattr(parsed_ta1, "envelope", None)
if envelope is None:
return result
batch = batch_lookup(envelope.sender_id or "", envelope.receiver_id or "")
ta1_obj = getattr(parsed_ta1, "ta1", None)
ack_code = getattr(ta1_obj, "ack_code", None) or ""
# Dedup: same (ack_kind, ack_id) → at most one TA1 envelope link.
existing = (
session.query(ClaimAck.id)
.filter(
ClaimAck.ack_kind == "ta1",
ClaimAck.ack_id == ack_id,
ClaimAck.claim_id.is_(None),
)
.first()
)
if existing is not None:
return result
if batch is None:
orphan_key = (
getattr(ta1_obj, "control_number", None)
or envelope.control_number
or ""
)
result.orphans.append(orphan_key)
return result
session.add(ClaimAck(
claim_id=None,
batch_id=batch.id,
ack_id=ack_id,
ack_kind="ta1",
ak2_index=None,
set_control_number=None,
set_accept_reject_code=ack_code,
linked_at=ts,
linked_by="auto",
))
session.flush()
result.linked.append((None, None))
return result
def link_manual(
session: Session,
*,
claim_id: str,
ack_kind: str,
ack_id: int,
set_control_number: Optional[str] = None,
set_accept_reject_code: Optional[str] = None,
ak2_index: Optional[int] = None,
now: Optional[datetime] = None,
) -> tuple[ClaimAck, bool]:
"""Create one manual link row, or return the existing one if already linked.
Used by ``POST /api/acks/{kind}/{ack_id}/match-claim``. Returns
``(row, created)`` where ``created=False`` means the dedup check
found an existing link (idempotent re-call → 200, not 201).
Raises ``LookupError`` when the referenced claim doesn't exist
(the caller maps that to 404).
"""
if ack_kind not in ("999", "277ca", "ta1"):
raise ValueError(f"link_manual: unknown ack_kind={ack_kind!r}")
claim = session.get(Claim, claim_id)
if claim is None:
raise LookupError(f"claim {claim_id} not found")
existing = (
session.query(ClaimAck)
.filter(
ClaimAck.ack_kind == ack_kind,
ClaimAck.ack_id == ack_id,
ClaimAck.claim_id == claim_id,
)
.first()
)
if existing is not None:
return existing, False
ts = now or datetime.now(timezone.utc)
row = ClaimAck(
claim_id=claim_id,
batch_id=None,
ack_id=ack_id,
ack_kind=ack_kind,
ak2_index=ak2_index,
set_control_number=set_control_number,
set_accept_reject_code=set_accept_reject_code,
linked_at=ts,
linked_by="manual",
)
session.add(row)
session.flush()
return row, True
__all__ = [
"ClaimAckLinkResult",
"apply_999_acceptances",
"apply_277ca_acks",
"apply_ta1_envelope_link",
"link_manual",
"lookup_claims_for_ack_set_response",
]