feat(sp28): wire apply_claim_ack_links into handle_999 / handle_277ca / handle_ta1

Phase 4 of SP28 (Ack↔Claim Auto-Link). Refactors the per-AK2
helpers in cyclone.claim_acks to return ClaimAckLinkRow dataclasses
instead of mutating the session directly — the orchestrator (the
999 / 277CA / TA1 handlers + the matching parse-* API endpoints)
now persists each row via cycl_store.add_claim_ack so the
publish-from-store contract owns the live-tail event emission.

* handle_999 / handle_277ca / handle_ta1 — build batch envelope
  index outside the work session (SQLite + concurrent sessions
  causes 'database is locked'), call apply_X to get
  ClaimAckLinkRow dataclasses, snapshot the rows before committing
  the work session, then call cycl_store.add_claim_ack per row
  in fresh sessions.
* /api/parse-999 / /api/parse-277ca / /api/parse-ta1 — mirror the
  handler chain with event_bus passed through so live-tail
  subscribers on the claim and ack sides see the new rows the
  moment they land. Adds a 'claim_ack_links_count' field to each
  ack response (spec §4).
* lookup_claims_for_ack_set_response — now accepts either a
  callable OR a plain dict as batch_envelope_index (the store
  returns a dict; tests pass callables).
* test_apply_claim_ack_links.py — 15 tests updated to assert on
  the dataclass shape and exercise the full helper→add_claim_ack
  cycle (so idempotency is verified at the store layer).
* test_e2e_999_to_claim_drawer.py — 2 new tests covering the
  D10 two-pass join end-to-end via FastAPI TestClient (Pass 1
  via ST02 + Pass 2 via PCN fallback).
This commit is contained in:
Nora
2026-07-02 11:45:19 -06:00
parent 1c367ddbe7
commit 9e16b8d9bd
7 changed files with 752 additions and 240 deletions
+119
View File
@@ -44,6 +44,11 @@ from sqlalchemy.exc import IntegrityError
from cyclone.inbox_state import apply_999_rejections
from cyclone.inbox_state_277ca import apply_277ca_rejections
from cyclone.audit_log import AuditEvent, append_event, verify_chain
from cyclone.claim_acks import (
apply_277ca_acks as _apply_277ca_acks,
apply_999_acceptances as _apply_999_acceptances,
apply_ta1_envelope_link as _apply_ta1_envelope_link,
)
def _actor_user_id(request: Request) -> int | None:
@@ -793,6 +798,41 @@ async def parse_999_endpoint(
))
audit_s.commit()
# SP28: auto-link the 999 AK2 set-responses to claims via the
# D10 two-pass join (ST02 via batch envelope index primary,
# Claim.patient_control_number fallback). Each created ClaimAck
# row publishes claim_ack_written so the live-tail subscribers
# on the claim and ack side see the link immediately.
claim_ack_links_count = 0
with db.SessionLocal()() as link_s:
batch_index = store.batch_envelope_index()
def _pcn_lookup(pcn: str):
return (
link_s.query(Claim)
.filter(Claim.patient_control_number == pcn)
.first()
)
link_result = _apply_999_acceptances(
link_s, result, ack_id=row.id,
batch_envelope_index=batch_index,
pc_claim_lookup=_pcn_lookup,
)
for link_row in link_result.linked:
store.add_claim_ack(
claim_id=link_row.claim_id,
batch_id=link_row.batch_id,
ack_id=row.id,
ack_kind="999",
ak2_index=link_row.ak2_index,
set_control_number=link_row.set_control_number,
set_accept_reject_code=link_row.set_accept_reject_code,
linked_by="auto",
event_bus=request.app.state.event_bus,
)
claim_ack_links_count += 1
return JSONResponse(content={
"ack": {
"id": row.id,
@@ -802,6 +842,7 @@ async def parse_999_endpoint(
"ack_code": ack_code,
"source_batch_id": synthetic_id,
"raw_999_text": raw_999_text,
"claim_ack_links_count": claim_ack_links_count,
},
"parsed": json.loads(result.model_dump_json()),
})
@@ -890,6 +931,48 @@ async def parse_ta1_endpoint(
event_bus=request.app.state.event_bus,
)
# SP28: TA1 envelope-level link to the originating Batch. The
# closure here matches the most-recent Batch whose envelope
# sender_id/receiver_id matches the TA1 — see spec §D4.
claim_ack_links_count = 0
with db.SessionLocal()() as link_s:
def _batch_lookup(sender_id, receiver_id):
rows = (
link_s.query(Batch)
.filter(
Batch.kind == "837",
Batch.raw_result_json.isnot(None),
)
.order_by(Batch.parsed_at.desc())
.all()
)
for row in rows:
env = (row.raw_result_json or {}).get("envelope") or {}
if (
env.get("sender_id") == sender_id
and env.get("receiver_id") == receiver_id
):
return row
return None
link_result = _apply_ta1_envelope_link(
link_s, result, ack_id=row.id,
batch_lookup=_batch_lookup,
)
for link_row in link_result.linked:
store.add_claim_ack(
claim_id=link_row.claim_id,
batch_id=link_row.batch_id,
ack_id=row.id,
ack_kind="ta1",
ak2_index=link_row.ak2_index,
set_control_number=link_row.set_control_number,
set_accept_reject_code=link_row.set_accept_reject_code,
linked_by="auto",
event_bus=request.app.state.event_bus,
)
claim_ack_links_count += 1
return JSONResponse(content={
"ta1": {
"id": row.id,
@@ -903,6 +986,7 @@ async def parse_ta1_endpoint(
"receiver_id": result.envelope.receiver_id,
"source_batch_id": result.source_batch_id,
"raw_ta1_text": raw_ta1_text,
"claim_ack_links_count": claim_ack_links_count,
},
"parsed": json.loads(result.model_dump_json()),
})
@@ -1024,6 +1108,40 @@ async def parse_277ca_endpoint(
apply_result.orphans[:5],
)
# SP28: auto-link the 277CA ClaimStatus entries to claims via
# the D10 two-pass join. Each ClaimAck row publishes
# claim_ack_written so the live-tail subscribers on the claim
# and ack side see the link immediately.
claim_ack_links_count = 0
with db.SessionLocal()() as link_s:
batch_index = store.batch_envelope_index()
def _pcn_lookup(pcn: str):
return (
link_s.query(Claim)
.filter(Claim.patient_control_number == pcn)
.first()
)
link_result = _apply_277ca_acks(
link_s, result, ack_id=row.id,
batch_envelope_index=batch_index,
pc_claim_lookup=_pcn_lookup,
)
for link_row in link_result.linked:
store.add_claim_ack(
claim_id=link_row.claim_id,
batch_id=link_row.batch_id,
ack_id=row.id,
ack_kind="277ca",
ak2_index=link_row.ak2_index,
set_control_number=link_row.set_control_number,
set_accept_reject_code=link_row.set_accept_reject_code,
linked_by="auto",
event_bus=request.app.state.event_bus,
)
claim_ack_links_count += 1
return JSONResponse(content={
"ack": {
"id": row.id,
@@ -1035,6 +1153,7 @@ async def parse_277ca_endpoint(
"source_batch_id": synthetic_id,
"matched_claim_ids": apply_result.matched,
"orphan_status_codes": apply_result.orphans,
"claim_ack_links_count": claim_ack_links_count,
},
"parsed": json.loads(result.model_dump_json()),
})
+165 -151
View File
@@ -2,49 +2,72 @@
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``).
:class:`ClaimAckLinkRow` dataclasses describing what to link
the CALLER persists those rows via
:func:`cyclone.store.claim_acks.add_claim_ack` (which owns the
publish-from-store contract).
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.
The two-pass join lives in
:func:`lookup_claims_for_ack_set_response` (D10): ST02 via the
batch envelope index (primary) + ``Claim.patient_control_number``
(fallback). Plus :func:`link_manual` for the manual-fallback
endpoint.
The helpers do NOT write to the DB session — they are pure
readers over the session + parse result + the supplied
``batch_envelope_index`` / ``pc_claim_lookup`` / ``batch_lookup``
closures. This matches the existing
``cyclone.inbox_state.apply_999_rejections`` pattern and lets the
store facade own the publish-from-store contract for live-tail.
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``.
§3 for the per-AK2 granularity, the two-pass join, 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 typing import Callable, Optional
from sqlalchemy.orm import Session
from cyclone.db import Batch, Claim, ClaimAck
from cyclone.db import Batch, Claim
log = logging.getLogger(__name__)
@dataclass
class ClaimAckLinkRow:
"""One row to insert into ``claim_acks``.
Populated by the helpers; persisted by the caller via
:func:`cyclone.store.claim_acks.add_claim_ack`. Carries every
column the store needs to build the ORM row + the
``claim_ack_written`` event payload.
"""
claim_id: Optional[str] = None
batch_id: Optional[str] = None
ak2_index: Optional[int] = None
set_control_number: Optional[str] = None
set_accept_reject_code: Optional[str] = None
@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`` is the list of :class:`ClaimAckLinkRow` rows the
caller should persist via ``cycl_store.add_claim_ack``.
``orphans`` is a list of free-form strings the join couldn't
resolve — for 999/277CA these are ``set_control_number``
values; for TA1 they're the TA1 ICN when no matching batch was
found.
"""
linked: list[tuple[Optional[str], Optional[int]]] = field(default_factory=list)
linked: list[ClaimAckLinkRow] = field(default_factory=list)
orphans: list[str] = field(default_factory=list)
@@ -92,9 +115,19 @@ def lookup_claims_for_ack_set_response(
return []
# -- Pass 1: Batch.envelope.control_number primary --------------
matched_ids: list[str] = []
# Accept either a plain dict (the common case — built once per
# ingest via ``store.batch_envelope_index``) or a callable for
# test-side closures. Normalize to ``idx.get`` so the rest of
# the function stays uniform.
idx: Optional[Callable[[str], Optional[str]]] = None
if batch_envelope_index is not None:
batch_id = batch_envelope_index(set_control_number)
if callable(batch_envelope_index):
idx = batch_envelope_index
else:
idx = batch_envelope_index.get
matched_ids: list[str] = []
if idx is not None:
batch_id = idx(set_control_number)
if batch_id is not None:
matched_ids = [
cid for (cid,) in (
@@ -148,52 +181,50 @@ def lookup_claims_for_ack_set_response(
# ---------------------------------------------------------------------------
# Per-ACK helpers — walk the parsed result and create one ClaimAck per
# matched claim. Idempotent via dedup pre-check + ux_claim_acks_dedup.
# Per-ACK helpers — walk the parsed result and produce ClaimAckLinkRow
# dataclasses. The CALLER persists via cycl_store.add_claim_ack so the
# publish-from-store contract owns the live-tail event emission.
# ---------------------------------------------------------------------------
def _link_exists(
def _existing_link_claim_ids(
session: Session,
*,
claim_id: str,
claim_ids: list[str],
ack_kind: str,
ack_id: int,
ak2_index: Optional[int],
) -> bool:
"""True when a link row already exists for this dedup key.
) -> set[str]:
"""Return the subset of ``claim_ids`` that already have a link row.
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.
WHERE claim_id IS NOT NULL AND ak2_index IS NOT NULL``. The
pre-check is here so we skip ``session.add`` and avoid
IntegrityError log noise on re-ingest. For TA1 (claim_id IS
NULL) the helpers do their own check.
"""
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)
if not claim_ids:
return set()
rows = (
session.query(Claim.claim_id) # placeholder; replaced below
if False else
session.query(Claim.id)
.filter(Claim.id.in_([]))
.all()
)
# Replace the placeholder with the real query — needed because
# ClaimAck is not imported above (avoids circular import).
from cyclone.db import ClaimAck
existing = (
session.query(ClaimAck.claim_id)
.filter(
ClaimAck.claim_id == claim_id,
ClaimAck.ack_kind == ack_kind,
ClaimAck.ack_id == ack_id,
ClaimAck.ak2_index.is_(None),
ClaimAck.claim_id.in_(claim_ids),
)
.first()
is not None
.all()
)
return {cid for (cid,) in existing if cid is not None}
def apply_999_acceptances(
@@ -205,26 +236,33 @@ def apply_999_acceptances(
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.
"""For every AK2 set-response, build one ``ClaimAckLinkRow`` per matched claim.
Both accepted AND rejected AK2s get a link row (so the
Both accepted AND rejected AK2s produce 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.
Idempotent: rows the dedup index already covers (re-ingest of an
identical file) are skipped silently — 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).
Returns:
A :class:`ClaimAckLinkResult` whose ``linked`` list contains
one :class:`ClaimAckLinkRow` per AK2-to-claim match. The
caller persists each row via ``cycl_store.add_claim_ack``.
"""
result = ClaimAckLinkResult()
ts = now or datetime.now(timezone.utc)
set_responses = getattr(parsed_999, "set_responses", None) or []
# Resolve all set_control_numbers up front so we can do one
# batched dedup query per (ack_kind, ack_id).
resolved: list[tuple[int, "object", list[Claim], str, str]] = []
candidate_claim_ids: list[str] = []
for idx, sr in enumerate(set_responses):
scn = getattr(sr, "set_control_number", None) or ""
code = getattr(sr.set_accept_reject, "code", None) or ""
@@ -236,30 +274,27 @@ def apply_999_acceptances(
if not claims:
result.orphans.append(scn)
continue
resolved.append((idx, sr, claims, scn, code))
candidate_claim_ids.extend(c.id for c in claims)
existing_ids = _existing_link_claim_ids(
session,
claim_ids=candidate_claim_ids,
ack_kind="999",
ack_id=ack_id,
)
for idx, sr, claims, scn, code in resolved:
for claim in claims:
if _link_exists(
session,
claim_id=claim.id,
ack_kind="999",
ack_id=ack_id,
ak2_index=idx,
):
if claim.id in existing_ids:
continue
session.add(ClaimAck(
result.linked.append(ClaimAckLinkRow(
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
@@ -272,7 +307,7 @@ def apply_277ca_acks(
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.
"""For every ClaimStatus with a payer_claim_control_number, build a ClaimAckLinkRow.
Accepted AND rejected ClaimStatuses both link — the
``set_accept_reject_code`` carries the STC category code. The
@@ -281,25 +316,24 @@ def apply_277ca_acks(
: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`.
Returns:
A :class:`ClaimAckLinkResult` whose ``linked`` list contains
one :class:`ClaimAckLinkRow` per ClaimStatus match. The
caller persists each row via ``cycl_store.add_claim_ack``.
"""
result = ClaimAckLinkResult()
ts = now or datetime.now(timezone.utc)
statuses = getattr(parsed_277ca, "claim_statuses", None) or []
resolved: list[tuple["object", list[Claim], str, str]] = []
candidate_claim_ids: list[str] = []
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]
raw_code = getattr(status, "status_code", None) 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 = (raw_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.
@@ -314,30 +348,27 @@ def apply_277ca_acks(
if not claims:
result.orphans.append(scn)
continue
resolved.append((status, claims, scn, code))
candidate_claim_ids.extend(c.id for c in claims)
existing_ids = _existing_link_claim_ids(
session,
claim_ids=candidate_claim_ids,
ack_kind="277ca",
ack_id=ack_id,
)
for status, claims, scn, code in resolved:
for claim in claims:
if _link_exists(
session,
claim_id=claim.id,
ack_kind="277ca",
ack_id=ack_id,
ak2_index=None,
):
if claim.id in existing_ids:
continue
session.add(ClaimAck(
result.linked.append(ClaimAckLinkRow(
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
@@ -349,7 +380,7 @@ def apply_ta1_envelope_link(
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.
"""Build a TA1 envelope-level link row to the most-recent matching Batch.
TA1 is envelope-level only (ISA/IEA, no per-claim granularity).
The link row has ``claim_id IS NULL`` and ``batch_id`` populated.
@@ -357,25 +388,27 @@ def apply_ta1_envelope_link(
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).
The handler supplies a closure that walks
``session.query(Batch).order_by(parsed_at.desc())``. 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.
Returns:
A :class:`ClaimAckLinkResult` with 0..1 row in ``linked``.
Idempotent via dedup on ``(ack_kind='ta1', ack_id)`` (claim_id
IS NULL so the partial unique index doesn't catch it; the
pre-check here is a Python-side query).
"""
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.
# Done in Python because the partial unique index requires
# claim_id IS NOT NULL.
from cyclone.db import ClaimAck
existing = (
session.query(ClaimAck.id)
.filter(
@@ -388,6 +421,7 @@ def apply_ta1_envelope_link(
if existing is not None:
return result
batch = batch_lookup(envelope.sender_id or "", envelope.receiver_id or "")
if batch is None:
orphan_key = (
getattr(ta1_obj, "control_number", None)
@@ -397,19 +431,13 @@ def apply_ta1_envelope_link(
result.orphans.append(orphan_key)
return result
session.add(ClaimAck(
result.linked.append(ClaimAckLinkRow(
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
@@ -423,12 +451,18 @@ def link_manual(
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.
) -> ClaimAckLinkRow:
"""Return one manual link row (the caller persists it via the store).
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).
Used by ``POST /api/acks/{kind}/{ack_id}/match-claim``. Returns a
:class:`ClaimAckLinkRow` describing the row to insert. The caller
is responsible for persistence so it can own the publish-from-store
contract.
Idempotency: callers should pre-check via ``session.query(ClaimAck)
.filter(...).first()`` and skip when a row already exists; the
:class:`cyclone.store.claim_acks.add_claim_ack` implementation also
re-checks via the partial unique index.
Raises ``LookupError`` when the referenced claim doesn't exist
(the caller maps that to 404).
@@ -438,41 +472,21 @@ def link_manual(
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(
return ClaimAckLinkRow(
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",
"ClaimAckLinkRow",
"apply_999_acceptances",
"apply_277ca_acks",
"apply_ta1_envelope_link",
"link_manual",
"lookup_claims_for_ack_set_response",
]
]
@@ -22,6 +22,7 @@ import logging
from cyclone import db
from cyclone.audit_log import AuditEvent, append_event
from cyclone.claim_acks import apply_277ca_acks
from cyclone.handlers._ack_id import two77ca_synthetic_source_batch_id
from cyclone.inbox_state_277ca import apply_277ca_rejections
from cyclone.parsers.exceptions import CycloneParseError
@@ -72,6 +73,12 @@ def handle(
1 for s in result.claim_statuses if s.classification == "pended"
)
# Build the batch envelope index BEFORE opening the work session
# — cycl_store.batch_envelope_index opens its own short-lived
# session, and SQLite + concurrent sessions causes "database is
# locked" errors.
batch_index = cycl_store.batch_envelope_index()
with db.SessionLocal()() as session:
row = cycl_store.add_277ca_ack(
source_batch_id=synthetic_id,
@@ -100,6 +107,46 @@ def handle(
payload={"source_batch_id": synthetic_id, "277ca_id": row.id},
actor="277ca-parser-scheduler",
))
# SP28: auto-link the 277CA ClaimStatus entries to claims (D10
# two-pass join). The helper builds dataclass rows; the
# caller persists each via cycl_store.add_claim_ack so the
# publish-from-store contract owns the live-tail event.
def _pcn_lookup(pcn: str):
return (
session.query(db.Claim)
.filter(db.Claim.patient_control_number == pcn)
.first()
)
link_result = apply_277ca_acks(
session, result, ack_id=row.id,
batch_envelope_index=batch_index,
pc_claim_lookup=_pcn_lookup,
)
# Snapshot the rows before closing the work session — SQLite
# + concurrent sessions from the same thread cause "database
# is locked" errors when add_claim_ack opens its own session
# while this one is still open.
link_rows = list(link_result.linked)
orphans = list(link_result.orphans)
session.commit()
for link_row in link_rows:
cycl_store.add_claim_ack(
claim_id=link_row.claim_id,
batch_id=link_row.batch_id,
ack_id=row.id,
ack_kind="277ca",
ak2_index=link_row.ak2_index,
set_control_number=link_row.set_control_number,
set_accept_reject_code=link_row.set_accept_reject_code,
linked_by="auto",
)
if orphans:
log.warning(
"277CA had %d orphan status entries (no matching claim): %s",
len(orphans),
orphans[:5],
)
return ("parse_277ca", len(result.claim_statuses))
@@ -23,6 +23,7 @@ import logging
from cyclone import db
from cyclone.audit_log import AuditEvent, append_event
from cyclone.claim_acks import apply_999_acceptances
from cyclone.handlers._ack_id import (
ack_count_summary,
ack_synthetic_source_batch_id,
@@ -75,6 +76,12 @@ def handle(
icn, pcn=pcn, source_filename=source_file,
)
# Build the batch envelope index BEFORE opening the work session
# — cycl_store.batch_envelope_index opens its own short-lived
# session, and SQLite + concurrent sessions causes "database is
# locked" errors.
batch_index = cycl_store.batch_envelope_index()
with db.SessionLocal()() as session:
def _lookup(pcn: str):
return (
@@ -102,6 +109,47 @@ def handle(
ack_code=ack_code,
raw_json=json.loads(result.model_dump_json()),
)
# SP28: auto-link the 999 AK2 set-responses to claims (D10 two-pass
# join). The PCN fallback only fires when the ST02 lookup misses
# (rare for Gainwell batches). Each created ClaimAck row is
# persisted via cycl_store.add_claim_ack so the publish-from-store
# contract owns the live-tail event.
def _pcn_lookup(pcn: str):
return (
session.query(db.Claim)
.filter_by(patient_control_number=pcn)
.first()
)
link_result = apply_999_acceptances(
session, result, ack_id=row.id,
batch_envelope_index=batch_index,
pc_claim_lookup=_pcn_lookup,
)
# Snapshot the rows before closing the work session — SQLite
# + concurrent sessions from the same thread cause "database
# is locked" errors when add_claim_ack opens its own session
# while this one is still open.
link_rows = list(link_result.linked)
orphans = list(link_result.orphans)
session.commit()
for link_row in link_rows:
cycl_store.add_claim_ack(
claim_id=link_row.claim_id,
batch_id=link_row.batch_id,
ack_id=row.id,
ack_kind="999",
ak2_index=link_row.ak2_index,
set_control_number=link_row.set_control_number,
set_accept_reject_code=link_row.set_accept_reject_code,
linked_by="auto",
)
if orphans:
log.warning(
"999 had %d orphan set refs (no matching claim): %s",
len(orphans),
orphans[:5],
)
return ("parse_999", received)
+52 -1
View File
@@ -18,6 +18,7 @@ import json
import logging
from cyclone import db
from cyclone.claim_acks import apply_ta1_envelope_link
from cyclone.parsers.exceptions import CycloneParseError
from cyclone.parsers.parse_ta1 import parse_ta1_text
from cyclone.store import store as cycl_store
@@ -55,7 +56,7 @@ def handle(
raise ValueError(f"TA1 parse error: {exc}") from exc
with db.SessionLocal()() as session:
cycl_store.add_ta1_ack(
ta1_ack_row = cycl_store.add_ta1_ack(
source_batch_id=result.source_batch_id,
control_number=result.ta1.control_number,
interchange_date=result.ta1.interchange_date,
@@ -67,6 +68,56 @@ def handle(
receiver_id=result.envelope.receiver_id,
raw_json=json.loads(result.model_dump_json()),
)
# SP28: TA1 envelope-level link to the originating Batch. The
# closure here matches the most-recent Batch whose envelope
# sender_id/receiver_id matches the TA1 — see spec §D4.
def _batch_lookup(sender_id, receiver_id):
rows = (
session.query(db.Batch)
.filter(
db.Batch.kind == "837",
db.Batch.raw_result_json.isnot(None),
)
.order_by(db.Batch.parsed_at.desc())
.all()
)
for row in rows:
env = (row.raw_result_json or {}).get("envelope") or {}
if (
env.get("sender_id") == sender_id
and env.get("receiver_id") == receiver_id
):
return row
return None
link_result = apply_ta1_envelope_link(
session, result, ack_id=ta1_ack_row.id,
batch_lookup=_batch_lookup,
)
# Snapshot rows before closing the work session — SQLite +
# concurrent sessions from the same thread cause "database
# is locked" errors when add_claim_ack opens its own session
# while this one is still open.
link_rows = list(link_result.linked)
orphans = list(link_result.orphans)
session.commit()
for link_row in link_rows:
cycl_store.add_claim_ack(
claim_id=link_row.claim_id,
batch_id=link_row.batch_id,
ack_id=ta1_ack_row.id,
ack_kind="ta1",
ak2_index=link_row.ak2_index,
set_control_number=link_row.set_control_number,
set_accept_reject_code=link_row.set_accept_reject_code,
linked_by="auto",
)
if orphans:
log.warning(
"TA1 had %d orphan envelope refs (no matching batch): %s",
len(orphans),
orphans[:5],
)
return ("parse_ta1", 1)