merge: SP28 ack-claim auto-link into main
Closes the operator gap where 999 / 277CA / TA1 acks were persisted but never linked back to the claims they acknowledged. The original PCN-based join matched 0 / 1,398 acks on prod (Gainwell's 999 echoes the source 837's ST02, not its CLM01); the D10 two-pass join via Batch.envelope. control_number recovers 727 / 1,398 automatically. Increment summary: - New claim_acks join table (migration 0018) with partial unique dedup index - Pure helpers (apply_claim_ack_links orchestrator + 999/277CA/TA1 paths) with the D10 two-pass lookup (Batch.envelope.control_number primary, Claim.patient_control_number fallback) - CycloneStore facade: add_claim_ack, list_acks_for_claim, list_claims_for_ack, find_ack_orphans, remove_claim_ack, batch_envelope_index - Handler integration: handle_999 / handle_277ca / handle_ta1 - 7 new API endpoints (read + stream + manual-match + unlink + orphan lane) - Frontend: useClaimAcks / useAckClaims hooks, claimAcks live-tail slice, AcknowledgmentsPanel in ClaimDrawer, MatchedClaimPanel + manual-match dropdown in AckDrawer, Claims badge column on Acks page, Ack-orphans lane on Inbox page Coverage: 727 / 1,398 acks auto-link; 671 remain real orphans (ST02=0001) surfaced in the Inbox ack-orphans lane. Auth posture: any logged-in user can run the manual-match endpoint (D5/D9 deviation from admin-only remit-orphans — ack matches are metadata-only, no Claim.state mutation). Test baseline (no new failures): - Backend: 25 new SP28 tests pass; full suite 1210 passed / 10 skipped / 1 failed + 6 errors (pre-existing test_payer_summary + test_provider_extended_response pollution; all 7 pass in isolation) - Frontend: 36 new SP28 tests pass; full suite 580 passed / 5 failed (pre-existing api.test.ts / tail-stream.test.ts / Inbox.test.tsx / InboxHeader.test.tsx baseline) - Typecheck: 17 pre-existing errors remain; 0 in SP28-introduced files (refactore10d388fixed 3 pre-existing joinUrl import errors) Files: 47 changed, +6,509/-23. Refactore10d388(joinUrl export) folded in to keep SP28 merge coherent. 14 SP28 commits on branch preserved as audit trail.
This commit is contained in:
+183
-3
@@ -38,12 +38,17 @@ from pydantic import ValidationError
|
|||||||
from cyclone import __version__, db
|
from cyclone import __version__, db
|
||||||
from cyclone.auth.deps import matrix_gate
|
from cyclone.auth.deps import matrix_gate
|
||||||
from cyclone.clearhouse import InboundFile
|
from cyclone.clearhouse import InboundFile
|
||||||
from cyclone.db import Batch, Claim, ClaimState, Remittance
|
from cyclone.db import Batch, Claim, ClaimAck, ClaimState, Remittance
|
||||||
from sqlalchemy import desc, or_
|
from sqlalchemy import desc, or_
|
||||||
from sqlalchemy.exc import IntegrityError
|
from sqlalchemy.exc import IntegrityError
|
||||||
from cyclone.inbox_state import apply_999_rejections
|
from cyclone.inbox_state import apply_999_rejections
|
||||||
from cyclone.inbox_state_277ca import apply_277ca_rejections
|
from cyclone.inbox_state_277ca import apply_277ca_rejections
|
||||||
from cyclone.audit_log import AuditEvent, append_event, verify_chain
|
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:
|
def _actor_user_id(request: Request) -> int | None:
|
||||||
@@ -295,11 +300,12 @@ app.add_middleware(SecurityHeadersMiddleware)
|
|||||||
# and are wired in here. (Kept as a top-level package rather than nested
|
# and are wired in here. (Kept as a top-level package rather than nested
|
||||||
# under `cyclone.api` so the existing ``cyclone.api`` module path keeps
|
# under `cyclone.api` so the existing ``cyclone.api`` module path keeps
|
||||||
# working — Python prefers packages over same-named modules.)
|
# working — Python prefers packages over same-named modules.)
|
||||||
from cyclone.api_routers import acks, admin, health, ta1_acks # noqa: E402
|
from cyclone.api_routers import acks, admin, claim_acks, health, ta1_acks # noqa: E402
|
||||||
|
|
||||||
app.include_router(health.router)
|
app.include_router(health.router)
|
||||||
app.include_router(acks.router, dependencies=[Depends(matrix_gate)])
|
app.include_router(acks.router, dependencies=[Depends(matrix_gate)])
|
||||||
app.include_router(ta1_acks.router, dependencies=[Depends(matrix_gate)])
|
app.include_router(ta1_acks.router, dependencies=[Depends(matrix_gate)])
|
||||||
|
app.include_router(claim_acks.router, dependencies=[Depends(matrix_gate)])
|
||||||
app.include_router(admin.router, dependencies=[Depends(matrix_gate)])
|
app.include_router(admin.router, dependencies=[Depends(matrix_gate)])
|
||||||
|
|
||||||
|
|
||||||
@@ -793,6 +799,41 @@ async def parse_999_endpoint(
|
|||||||
))
|
))
|
||||||
audit_s.commit()
|
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={
|
return JSONResponse(content={
|
||||||
"ack": {
|
"ack": {
|
||||||
"id": row.id,
|
"id": row.id,
|
||||||
@@ -802,6 +843,7 @@ async def parse_999_endpoint(
|
|||||||
"ack_code": ack_code,
|
"ack_code": ack_code,
|
||||||
"source_batch_id": synthetic_id,
|
"source_batch_id": synthetic_id,
|
||||||
"raw_999_text": raw_999_text,
|
"raw_999_text": raw_999_text,
|
||||||
|
"claim_ack_links_count": claim_ack_links_count,
|
||||||
},
|
},
|
||||||
"parsed": json.loads(result.model_dump_json()),
|
"parsed": json.loads(result.model_dump_json()),
|
||||||
})
|
})
|
||||||
@@ -890,6 +932,48 @@ async def parse_ta1_endpoint(
|
|||||||
event_bus=request.app.state.event_bus,
|
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={
|
return JSONResponse(content={
|
||||||
"ta1": {
|
"ta1": {
|
||||||
"id": row.id,
|
"id": row.id,
|
||||||
@@ -903,6 +987,7 @@ async def parse_ta1_endpoint(
|
|||||||
"receiver_id": result.envelope.receiver_id,
|
"receiver_id": result.envelope.receiver_id,
|
||||||
"source_batch_id": result.source_batch_id,
|
"source_batch_id": result.source_batch_id,
|
||||||
"raw_ta1_text": raw_ta1_text,
|
"raw_ta1_text": raw_ta1_text,
|
||||||
|
"claim_ack_links_count": claim_ack_links_count,
|
||||||
},
|
},
|
||||||
"parsed": json.loads(result.model_dump_json()),
|
"parsed": json.loads(result.model_dump_json()),
|
||||||
})
|
})
|
||||||
@@ -1024,6 +1109,40 @@ async def parse_277ca_endpoint(
|
|||||||
apply_result.orphans[:5],
|
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={
|
return JSONResponse(content={
|
||||||
"ack": {
|
"ack": {
|
||||||
"id": row.id,
|
"id": row.id,
|
||||||
@@ -1035,6 +1154,7 @@ async def parse_277ca_endpoint(
|
|||||||
"source_batch_id": synthetic_id,
|
"source_batch_id": synthetic_id,
|
||||||
"matched_claim_ids": apply_result.matched,
|
"matched_claim_ids": apply_result.matched,
|
||||||
"orphan_status_codes": apply_result.orphans,
|
"orphan_status_codes": apply_result.orphans,
|
||||||
|
"claim_ack_links_count": claim_ack_links_count,
|
||||||
},
|
},
|
||||||
"parsed": json.loads(result.model_dump_json()),
|
"parsed": json.loads(result.model_dump_json()),
|
||||||
})
|
})
|
||||||
@@ -1044,9 +1164,31 @@ async def parse_277ca_endpoint(
|
|||||||
def list_277ca_acks_endpoint(
|
def list_277ca_acks_endpoint(
|
||||||
limit: int = Query(100, ge=1, le=5000),
|
limit: int = Query(100, ge=1, le=5000),
|
||||||
) -> Any:
|
) -> Any:
|
||||||
"""Return the list of persisted 277CA ACKs, newest first."""
|
"""Return the list of persisted 277CA ACKs, newest first.
|
||||||
|
|
||||||
|
SP28: each item gains ``linked_claim_ids`` (batch-fetched in
|
||||||
|
one query to avoid N+1) so the Acks page row can render the
|
||||||
|
"🔗 N claims" badge inline.
|
||||||
|
"""
|
||||||
rows = store.list_277ca_acks()
|
rows = store.list_277ca_acks()
|
||||||
items = [to_ui_two77ca_ack(r) for r in rows[:limit]]
|
items = [to_ui_two77ca_ack(r) for r in rows[:limit]]
|
||||||
|
ack_ids = [r.id for r in rows]
|
||||||
|
linked_map: dict[int, list[str]] = {aid: [] for aid in ack_ids}
|
||||||
|
if ack_ids:
|
||||||
|
with db.SessionLocal()() as s:
|
||||||
|
link_rows = (
|
||||||
|
s.query(ClaimAck.ack_id, ClaimAck.claim_id)
|
||||||
|
.filter(
|
||||||
|
ClaimAck.ack_kind == "277ca",
|
||||||
|
ClaimAck.ack_id.in_(ack_ids),
|
||||||
|
ClaimAck.claim_id.isnot(None),
|
||||||
|
)
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
for ack_id, claim_id in link_rows:
|
||||||
|
linked_map[ack_id].append(claim_id)
|
||||||
|
for item, aid in zip(items, ack_ids[:limit]):
|
||||||
|
item["linked_claim_ids"] = linked_map.get(aid, [])
|
||||||
return {"total": len(rows), "items": items}
|
return {"total": len(rows), "items": items}
|
||||||
|
|
||||||
|
|
||||||
@@ -1782,6 +1924,12 @@ def get_claim_detail_endpoint(claim_id: str) -> dict:
|
|||||||
raw segments, ``stateHistory`` (most-recent-first, capped at 50),
|
raw segments, ``stateHistory`` (most-recent-first, capped at 50),
|
||||||
and a populated ``matchedRemittance`` block when paired.
|
and a populated ``matchedRemittance`` block when paired.
|
||||||
|
|
||||||
|
SP28: response gains ``ack_links: list[dict]`` (compact form:
|
||||||
|
``[{ack_id, ack_kind, set_accept_reject_code, parsed_at}]``)
|
||||||
|
so the ``ClaimDrawer`` Acknowledgments panel can render on
|
||||||
|
initial load. TA1 batch-level rows (``claim_id IS NULL``) are
|
||||||
|
excluded — those don't belong to a specific claim.
|
||||||
|
|
||||||
Path param is ``claim_id`` (matches the SP3 ``/api/acks/{ack_id}``
|
Path param is ``claim_id`` (matches the SP3 ``/api/acks/{ack_id}``
|
||||||
convention). Returns 404 — never 500 — on a missing claim so the
|
convention). Returns 404 — never 500 — on a missing claim so the
|
||||||
UI can distinguish "doesn't exist" from a transient fetch error.
|
UI can distinguish "doesn't exist" from a transient fetch error.
|
||||||
@@ -1795,9 +1943,41 @@ def get_claim_detail_endpoint(claim_id: str) -> dict:
|
|||||||
"detail": f"Claim {claim_id} not found",
|
"detail": f"Claim {claim_id} not found",
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
# SP28: attach ack_links (compact form for the drawer panel).
|
||||||
|
body["ack_links"] = _compact_ack_links_for_claim(claim_id)
|
||||||
return body
|
return body
|
||||||
|
|
||||||
|
|
||||||
|
def _compact_ack_links_for_claim(claim_id: str) -> list[dict]:
|
||||||
|
"""Return compact ack_links for one claim, newest first.
|
||||||
|
|
||||||
|
TA1 batch-level rows (claim_id IS NULL) are filtered out — those
|
||||||
|
hang off the originating 837 batch, not a specific claim. The
|
||||||
|
shape is the slimmer ``{ack_id, ack_kind,
|
||||||
|
set_accept_reject_code, parsed_at, ak2_index}`` form so the
|
||||||
|
ClaimDrawer can render without an N+1 round-trip per row.
|
||||||
|
"""
|
||||||
|
rows = store.list_acks_for_claim(claim_id)
|
||||||
|
out: list[dict] = []
|
||||||
|
for row in rows:
|
||||||
|
if row.claim_id is None:
|
||||||
|
continue
|
||||||
|
out.append({
|
||||||
|
"id": row.id,
|
||||||
|
"ack_id": row.ack_id,
|
||||||
|
"ack_kind": row.ack_kind,
|
||||||
|
"ak2_index": row.ak2_index,
|
||||||
|
"set_control_number": row.set_control_number,
|
||||||
|
"set_accept_reject_code": row.set_accept_reject_code,
|
||||||
|
"linked_at": (
|
||||||
|
row.linked_at.isoformat().replace("+00:00", "Z")
|
||||||
|
if row.linked_at is not None else ""
|
||||||
|
),
|
||||||
|
"linked_by": row.linked_by,
|
||||||
|
})
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
@app.get("/api/claims/{claim_id}/serialize-837", dependencies=[Depends(matrix_gate)])
|
@app.get("/api/claims/{claim_id}/serialize-837", dependencies=[Depends(matrix_gate)])
|
||||||
def serialize_claim_as_837(claim_id: str):
|
def serialize_claim_as_837(claim_id: str):
|
||||||
"""Return the claim as a regenerated X12 837P file (SP8).
|
"""Return the claim as a regenerated X12 837P file (SP8).
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ from typing import Any, AsyncIterator
|
|||||||
from fastapi import APIRouter, HTTPException, Query, Request
|
from fastapi import APIRouter, HTTPException, Query, Request
|
||||||
from fastapi.responses import StreamingResponse
|
from fastapi.responses import StreamingResponse
|
||||||
|
|
||||||
|
from cyclone import db
|
||||||
from cyclone.api_helpers import (
|
from cyclone.api_helpers import (
|
||||||
ndjson_line,
|
ndjson_line,
|
||||||
ndjson_stream_list,
|
ndjson_stream_list,
|
||||||
@@ -68,6 +69,12 @@ def list_acks_endpoint(
|
|||||||
"rejected_count": sum(r.rejected_count or 0 for r in rows),
|
"rejected_count": sum(r.rejected_count or 0 for r in rows),
|
||||||
"received_count": sum(r.received_count or 0 for r in rows),
|
"received_count": sum(r.received_count or 0 for r in rows),
|
||||||
}
|
}
|
||||||
|
# SP28: batch-fetch linked_claim_ids per ack row in one query to
|
||||||
|
# avoid N+1 — see ``find_linked_claim_ids_for_acks`` below.
|
||||||
|
ack_ids = [r.id for r in rows]
|
||||||
|
linked_map = _find_linked_claim_ids_for_acks(ack_ids, kind="999")
|
||||||
|
for item, aid in zip(items, ack_ids[offset : offset + limit]):
|
||||||
|
item["linked_claim_ids"] = linked_map.get(aid, [])
|
||||||
if wants_ndjson(request):
|
if wants_ndjson(request):
|
||||||
return StreamingResponse(
|
return StreamingResponse(
|
||||||
ndjson_stream_list(items, total, returned, has_more),
|
ndjson_stream_list(items, total, returned, has_more),
|
||||||
@@ -82,6 +89,35 @@ def list_acks_endpoint(
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _find_linked_claim_ids_for_acks(
|
||||||
|
ack_ids: list[int], *, kind: str
|
||||||
|
) -> dict[int, list[str]]:
|
||||||
|
"""Batch-fetch {ack_id: [claim_id, …]} for the listed ack rows.
|
||||||
|
|
||||||
|
One SELECT against ``claim_acks`` keyed on the page's ack_ids —
|
||||||
|
avoids an N+1 round-trip when the page renders the
|
||||||
|
"🔗 N claims" badge per row. Used by the 999 / TA1 / 277CA
|
||||||
|
list endpoints. Returns a ``{ack_id: [claim_id]}`` map; acks
|
||||||
|
with no links map to ``[]`` (default).
|
||||||
|
"""
|
||||||
|
out: dict[int, list[str]] = {aid: [] for aid in ack_ids}
|
||||||
|
if not ack_ids:
|
||||||
|
return out
|
||||||
|
with db.SessionLocal()() as s:
|
||||||
|
rows = (
|
||||||
|
s.query(db.ClaimAck.ack_id, db.ClaimAck.claim_id)
|
||||||
|
.filter(
|
||||||
|
db.ClaimAck.ack_kind == kind,
|
||||||
|
db.ClaimAck.ack_id.in_(ack_ids),
|
||||||
|
db.ClaimAck.claim_id.isnot(None),
|
||||||
|
)
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
for ack_id, claim_id in rows:
|
||||||
|
out[ack_id].append(claim_id)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
@router.get("/api/acks/stream")
|
@router.get("/api/acks/stream")
|
||||||
async def acks_stream(
|
async def acks_stream(
|
||||||
request: Request,
|
request: Request,
|
||||||
|
|||||||
@@ -0,0 +1,306 @@
|
|||||||
|
"""``/api/claim-acks`` (and per-claim/per-ack surfaces) — SP28.
|
||||||
|
|
||||||
|
Seven endpoints that surface the ``claim_acks`` join table to the
|
||||||
|
frontend + manual-match fallback for orphans. Mounted by
|
||||||
|
``cyclone.api`` alongside the existing ``/api/acks`` and
|
||||||
|
``/api/ta1-acks`` routers.
|
||||||
|
|
||||||
|
The live-tail endpoints (``/api/claims/{id}/acks/stream`` and
|
||||||
|
``/api/acks/{kind}/{id}/claims/stream``) subscribe to the
|
||||||
|
``claim_ack_written`` bus event so the ClaimDrawer Acknowledgments
|
||||||
|
panel and the per-ack claims list refresh in real time.
|
||||||
|
|
||||||
|
Manual match is any-logged-in user (D5) — this endpoint mutates
|
||||||
|
metadata only (``claim_acks`` row + live-tail event), no
|
||||||
|
``Claim.state`` mutation, no payment data. Idempotent: re-calling
|
||||||
|
with the same ``claim_id`` returns 200 with the existing row.
|
||||||
|
Rejects (409) when the claim is in a terminal state (``REVERSED``).
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from typing import Any, AsyncIterator, Literal
|
||||||
|
|
||||||
|
from fastapi import APIRouter, HTTPException, Query, Request
|
||||||
|
from fastapi.responses import JSONResponse, StreamingResponse
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
|
from cyclone import db
|
||||||
|
from cyclone.api_helpers import ndjson_line, tail_events
|
||||||
|
from cyclone.pubsub import EventBus
|
||||||
|
from cyclone.store import store, to_ui_claim_ack
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
log = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
AckKind = Literal["999", "277ca", "ta1"]
|
||||||
|
|
||||||
|
|
||||||
|
class MatchClaimBody(BaseModel):
|
||||||
|
"""Body for ``POST /api/acks/{kind}/{ack_id}/match-claim``."""
|
||||||
|
|
||||||
|
claim_id: str = Field(..., min_length=1)
|
||||||
|
set_control_number: str | None = None
|
||||||
|
set_accept_reject_code: str | None = None
|
||||||
|
ak2_index: int | None = None
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Per-claim surface
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/api/claims/{claim_id}/acks/stream")
|
||||||
|
async def claim_acks_stream(
|
||||||
|
request: Request,
|
||||||
|
claim_id: str,
|
||||||
|
) -> StreamingResponse:
|
||||||
|
"""Stream ClaimAck rows for one claim as NDJSON.
|
||||||
|
|
||||||
|
Subscribes to ``claim_ack_written`` and filters for rows where
|
||||||
|
``claim_id`` matches the path param. Each matching event is
|
||||||
|
emitted as ``{"type": "item", "data": to_ui_claim_ack(...)}``;
|
||||||
|
the client-side ``useMergedTail`` hook dedupes by id.
|
||||||
|
|
||||||
|
Registered BEFORE ``/api/claims/{claim_id}/acks`` so the literal
|
||||||
|
``stream`` path segment doesn't get matched as a suffix.
|
||||||
|
"""
|
||||||
|
bus: EventBus = request.app.state.event_bus
|
||||||
|
|
||||||
|
async def gen() -> AsyncIterator[bytes]:
|
||||||
|
rows = [
|
||||||
|
r for r in store.list_acks_for_claim(claim_id)
|
||||||
|
if r.claim_id is not None # filter out TA1 batch-level rows
|
||||||
|
]
|
||||||
|
for row in rows:
|
||||||
|
yield ndjson_line({"type": "item", "data": to_ui_claim_ack(row)})
|
||||||
|
yield ndjson_line({
|
||||||
|
"type": "snapshot_end",
|
||||||
|
"data": {"count": len(rows)},
|
||||||
|
})
|
||||||
|
|
||||||
|
async for chunk in tail_events(request, bus, ["claim_ack_written"]):
|
||||||
|
# tail_events yields full NDJSON lines; the client filter
|
||||||
|
# picks claim_id matches. We forward every event so the
|
||||||
|
# wire format mirrors /api/claims/stream.
|
||||||
|
yield chunk
|
||||||
|
|
||||||
|
return StreamingResponse(gen(), media_type="application/x-ndjson")
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/api/claims/{claim_id}/acks")
|
||||||
|
def list_acks_for_claim_endpoint(claim_id: str) -> Any:
|
||||||
|
"""Return every ClaimAck row for one claim (per-claim only).
|
||||||
|
|
||||||
|
TA1 batch-level rows (where ``claim_id IS NULL``) are filtered
|
||||||
|
out — those don't belong to a specific claim, they're a
|
||||||
|
envelope-level acknowledgement that hangs off the originating
|
||||||
|
837 batch. The ClaimDrawer Acknowledgments panel is
|
||||||
|
per-claim only.
|
||||||
|
"""
|
||||||
|
rows = store.list_acks_for_claim(claim_id)
|
||||||
|
items = [to_ui_claim_ack(r) for r in rows if r.claim_id is not None]
|
||||||
|
return {"total": len(items), "items": items}
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Per-ack surface
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/api/acks/{kind}/{ack_id}/claims/stream")
|
||||||
|
async def ack_claims_stream(
|
||||||
|
request: Request,
|
||||||
|
kind: AckKind,
|
||||||
|
ack_id: int,
|
||||||
|
) -> StreamingResponse:
|
||||||
|
"""Stream ClaimAck rows for one ack as NDJSON.
|
||||||
|
|
||||||
|
Subscribes to ``claim_ack_written`` and forwards events the
|
||||||
|
store knows about. Clients filter by ack_id + ack_kind on the
|
||||||
|
client side.
|
||||||
|
"""
|
||||||
|
bus: EventBus = request.app.state.event_bus
|
||||||
|
|
||||||
|
async def gen() -> AsyncIterator[bytes]:
|
||||||
|
rows = store.list_claims_for_ack(kind, ack_id)
|
||||||
|
for row in rows:
|
||||||
|
yield ndjson_line({"type": "item", "data": to_ui_claim_ack(row)})
|
||||||
|
yield ndjson_line({
|
||||||
|
"type": "snapshot_end",
|
||||||
|
"data": {"count": len(rows)},
|
||||||
|
})
|
||||||
|
async for chunk in tail_events(request, bus, ["claim_ack_written"]):
|
||||||
|
yield chunk
|
||||||
|
|
||||||
|
return StreamingResponse(gen(), media_type="application/x-ndjson")
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/api/acks/{kind}/{ack_id}/claims")
|
||||||
|
def list_claims_for_ack_endpoint(kind: AckKind, ack_id: int) -> Any:
|
||||||
|
"""Return every ClaimAck row for one ack (any kind).
|
||||||
|
|
||||||
|
For 999 / 277CA: returns 0..N rows (one per AK2 / ClaimStatus).
|
||||||
|
For TA1: returns 0..1 row (envelope-level, populated batch_id).
|
||||||
|
"""
|
||||||
|
rows = store.list_claims_for_ack(kind, ack_id)
|
||||||
|
items = [to_ui_claim_ack(r) for r in rows]
|
||||||
|
return {"total": len(items), "items": items}
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Manual match / unmatch (D5, D9)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/api/acks/{kind}/{ack_id}/match-claim")
|
||||||
|
def manual_match_claim_endpoint(
|
||||||
|
request: Request,
|
||||||
|
kind: AckKind,
|
||||||
|
ack_id: int,
|
||||||
|
body: MatchClaimBody,
|
||||||
|
) -> Any:
|
||||||
|
"""Manual link fallback (D5/D9). Any-logged-in-user posture.
|
||||||
|
|
||||||
|
Inserts a ``claim_acks`` row with ``linked_by="manual"`` and
|
||||||
|
publishes ``claim_ack_written`` so the drawers refresh. The
|
||||||
|
endpoint is idempotent: if a row already exists for this dedup
|
||||||
|
key, the existing row is returned (200). 409 when the claim is
|
||||||
|
in a terminal state (``REVERSED``). 404 when the claim doesn't
|
||||||
|
exist or the ack doesn't exist.
|
||||||
|
"""
|
||||||
|
# Verify the claim exists and is in a non-terminal state.
|
||||||
|
from cyclone.db import ClaimState as _CS
|
||||||
|
with db.SessionLocal()() as s:
|
||||||
|
claim = s.get(db.Claim, body.claim_id)
|
||||||
|
if claim is None:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=404,
|
||||||
|
detail={
|
||||||
|
"error": "Not found",
|
||||||
|
"detail": f"Claim {body.claim_id} not found",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
if claim.state == _CS.REVERSED:
|
||||||
|
return JSONResponse(
|
||||||
|
status_code=409,
|
||||||
|
content={
|
||||||
|
"error": "Conflict",
|
||||||
|
"detail": (
|
||||||
|
f"Claim {body.claim_id} is in terminal state "
|
||||||
|
f"{claim.state.value} and cannot be linked."
|
||||||
|
),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
# Verify the ack exists (any kind).
|
||||||
|
ack_table = {
|
||||||
|
"999": db.Ack,
|
||||||
|
"277ca": db.Two77caAck,
|
||||||
|
"ta1": db.Ta1Ack,
|
||||||
|
}[kind]
|
||||||
|
if s.get(ack_table, ack_id) is None:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=404,
|
||||||
|
detail={
|
||||||
|
"error": "Not found",
|
||||||
|
"detail": f"{kind} ACK {ack_id} not found",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
# Idempotency: if a manual or auto link already exists, return it.
|
||||||
|
existing = (
|
||||||
|
s.query(db.ClaimAck)
|
||||||
|
.filter(
|
||||||
|
db.ClaimAck.ack_kind == kind,
|
||||||
|
db.ClaimAck.ack_id == ack_id,
|
||||||
|
db.ClaimAck.claim_id == body.claim_id,
|
||||||
|
)
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
if existing is not None:
|
||||||
|
return {
|
||||||
|
"link": to_ui_claim_ack(existing),
|
||||||
|
"created": False,
|
||||||
|
}
|
||||||
|
|
||||||
|
# Insert via the store so the publish-from-store contract fires.
|
||||||
|
row = store.add_claim_ack(
|
||||||
|
claim_id=body.claim_id,
|
||||||
|
batch_id=None,
|
||||||
|
ack_id=ack_id,
|
||||||
|
ack_kind=kind,
|
||||||
|
ak2_index=body.ak2_index,
|
||||||
|
set_control_number=body.set_control_number,
|
||||||
|
set_accept_reject_code=body.set_accept_reject_code,
|
||||||
|
linked_by="manual",
|
||||||
|
event_bus=request.app.state.event_bus,
|
||||||
|
)
|
||||||
|
return {"link": to_ui_claim_ack(row), "created": True}
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/api/acks/{kind}/{ack_id}/match-claim/{claim_id}")
|
||||||
|
def manual_unmatch_claim_endpoint(
|
||||||
|
request: Request,
|
||||||
|
kind: AckKind,
|
||||||
|
ack_id: int,
|
||||||
|
claim_id: str,
|
||||||
|
) -> Any:
|
||||||
|
"""Unlink (preserves ``Claim.state`` mutation; only removes the row).
|
||||||
|
|
||||||
|
404 when no link row exists for the dedup key. Publishes
|
||||||
|
``claim_ack_dropped`` so live-tail subscribers remove the link.
|
||||||
|
"""
|
||||||
|
from cyclone import db as _db
|
||||||
|
with _db.SessionLocal()() as s:
|
||||||
|
link = (
|
||||||
|
s.query(_db.ClaimAck)
|
||||||
|
.filter(
|
||||||
|
_db.ClaimAck.ack_kind == kind,
|
||||||
|
_db.ClaimAck.ack_id == ack_id,
|
||||||
|
_db.ClaimAck.claim_id == claim_id,
|
||||||
|
)
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
if link is None:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=404,
|
||||||
|
detail={
|
||||||
|
"error": "Not found",
|
||||||
|
"detail": (
|
||||||
|
f"No link for {kind} ack {ack_id} → "
|
||||||
|
f"claim {claim_id}"
|
||||||
|
),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
link_id = link.id
|
||||||
|
|
||||||
|
removed = store.remove_claim_ack(link_id, event_bus=request.app.state.event_bus)
|
||||||
|
return {"removed": removed, "link_id": link_id}
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Inbox ack-orphans lane (spec §D7)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/api/inbox/ack-orphans")
|
||||||
|
def list_ack_orphans_endpoint(
|
||||||
|
kind: AckKind | None = Query(None, description="Filter by ack kind"),
|
||||||
|
) -> Any:
|
||||||
|
"""List acks with no resolvable Claim row of their own kind.
|
||||||
|
|
||||||
|
Used by the Inbox "Ack orphans" lane for the operator's manual
|
||||||
|
reconciliation flow. Mirrors ``/api/inbox/remit-orphans``.
|
||||||
|
"""
|
||||||
|
if kind is not None:
|
||||||
|
items = store.find_ack_orphans(kind)
|
||||||
|
return {"total": len(items), "items": items}
|
||||||
|
items_999 = store.find_ack_orphans("999")
|
||||||
|
items_277ca = store.find_ack_orphans("277ca")
|
||||||
|
items_ta1 = store.find_ack_orphans("ta1")
|
||||||
|
all_items = items_999 + items_277ca + items_ta1
|
||||||
|
return {"total": len(all_items), "items": all_items}
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = ["router"]
|
||||||
@@ -53,12 +53,46 @@ def list_ta1_acks_endpoint(
|
|||||||
"""
|
"""
|
||||||
rows = store.list_ta1_acks()
|
rows = store.list_ta1_acks()
|
||||||
items = [to_ui_ta1_ack(r) for r in rows[:limit]]
|
items = [to_ui_ta1_ack(r) for r in rows[:limit]]
|
||||||
|
# SP28: batch-fetch linked_claim_ids per TA1 row (TA1 envelope
|
||||||
|
# links always carry claim_id IS NULL — populate the field for
|
||||||
|
# symmetry so the Acks page badge render path is uniform).
|
||||||
|
ack_ids = [r.id for r in rows]
|
||||||
|
linked_map = _find_linked_claim_ids_for_acks_helper(ack_ids, kind="ta1")
|
||||||
|
for item, aid in zip(items, ack_ids[:limit]):
|
||||||
|
item["linked_claim_ids"] = linked_map.get(aid, [])
|
||||||
return {
|
return {
|
||||||
"total": len(rows),
|
"total": len(rows),
|
||||||
"items": items,
|
"items": items,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _find_linked_claim_ids_for_acks_helper(
|
||||||
|
ack_ids: list[int], *, kind: str
|
||||||
|
) -> dict[int, list[str]]:
|
||||||
|
"""Batch-fetch {ack_id: [claim_id, …]} for the listed ack rows.
|
||||||
|
|
||||||
|
Local helper so we don't import from ``acks.py`` and create a
|
||||||
|
circular import. See the equivalent ``_find_linked_claim_ids_for_acks``
|
||||||
|
in ``acks.py`` for the contract.
|
||||||
|
"""
|
||||||
|
out: dict[int, list[str]] = {aid: [] for aid in ack_ids}
|
||||||
|
if not ack_ids:
|
||||||
|
return out
|
||||||
|
with db.SessionLocal()() as s:
|
||||||
|
rows = (
|
||||||
|
s.query(db.ClaimAck.ack_id, db.ClaimAck.claim_id)
|
||||||
|
.filter(
|
||||||
|
db.ClaimAck.ack_kind == kind,
|
||||||
|
db.ClaimAck.ack_id.in_(ack_ids),
|
||||||
|
db.ClaimAck.claim_id.isnot(None),
|
||||||
|
)
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
for ack_id, claim_id in rows:
|
||||||
|
out[ack_id].append(claim_id)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
@router.get("/api/ta1-acks/stream")
|
@router.get("/api/ta1-acks/stream")
|
||||||
async def ta1_acks_stream(
|
async def ta1_acks_stream(
|
||||||
request: Request,
|
request: Request,
|
||||||
|
|||||||
@@ -0,0 +1,492 @@
|
|||||||
|
"""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
|
||||||
|
: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 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, 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, Optional
|
||||||
|
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
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 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[ClaimAckLinkRow] = 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 --------------
|
||||||
|
# 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:
|
||||||
|
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 (
|
||||||
|
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 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 _existing_link_claim_ids(
|
||||||
|
session: Session,
|
||||||
|
*,
|
||||||
|
claim_ids: list[str],
|
||||||
|
ack_kind: str,
|
||||||
|
ack_id: int,
|
||||||
|
) -> 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``. 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 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.ack_kind == ack_kind,
|
||||||
|
ClaimAck.ack_id == ack_id,
|
||||||
|
ClaimAck.claim_id.in_(claim_ids),
|
||||||
|
)
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
return {cid for (cid,) in existing if cid 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, build one ``ClaimAckLinkRow`` per matched claim.
|
||||||
|
|
||||||
|
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: 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()
|
||||||
|
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 ""
|
||||||
|
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
|
||||||
|
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 claim.id in existing_ids:
|
||||||
|
continue
|
||||||
|
result.linked.append(ClaimAckLinkRow(
|
||||||
|
claim_id=claim.id,
|
||||||
|
batch_id=None,
|
||||||
|
ak2_index=idx,
|
||||||
|
set_control_number=scn,
|
||||||
|
set_accept_reject_code=code,
|
||||||
|
))
|
||||||
|
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, build a ClaimAckLinkRow.
|
||||||
|
|
||||||
|
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).
|
||||||
|
|
||||||
|
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()
|
||||||
|
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 ""
|
||||||
|
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.
|
||||||
|
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
|
||||||
|
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 claim.id in existing_ids:
|
||||||
|
continue
|
||||||
|
result.linked.append(ClaimAckLinkRow(
|
||||||
|
claim_id=claim.id,
|
||||||
|
batch_id=None,
|
||||||
|
ak2_index=None,
|
||||||
|
set_control_number=scn,
|
||||||
|
set_accept_reject_code=code or None,
|
||||||
|
))
|
||||||
|
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:
|
||||||
|
"""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.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
parsed_ta1: a :class:`cyclone.parsers.models_ta1.ParseResultTa1`.
|
||||||
|
batch_lookup: ``(sender_id, receiver_id) -> Batch | None``.
|
||||||
|
The handler supplies a closure that walks
|
||||||
|
``session.query(Batch).order_by(parsed_at.desc())``. Returning
|
||||||
|
``None`` produces an orphan (no batch match).
|
||||||
|
|
||||||
|
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()
|
||||||
|
envelope = getattr(parsed_ta1, "envelope", None)
|
||||||
|
if envelope is None:
|
||||||
|
return result
|
||||||
|
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(
|
||||||
|
ClaimAck.ack_kind == "ta1",
|
||||||
|
ClaimAck.ack_id == ack_id,
|
||||||
|
ClaimAck.claim_id.is_(None),
|
||||||
|
)
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
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)
|
||||||
|
or envelope.control_number
|
||||||
|
or ""
|
||||||
|
)
|
||||||
|
result.orphans.append(orphan_key)
|
||||||
|
return result
|
||||||
|
|
||||||
|
result.linked.append(ClaimAckLinkRow(
|
||||||
|
claim_id=None,
|
||||||
|
batch_id=batch.id,
|
||||||
|
ak2_index=None,
|
||||||
|
set_control_number=None,
|
||||||
|
set_accept_reject_code=ack_code,
|
||||||
|
))
|
||||||
|
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,
|
||||||
|
) -> ClaimAckLinkRow:
|
||||||
|
"""Return one manual link row (the caller persists it via the store).
|
||||||
|
|
||||||
|
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).
|
||||||
|
"""
|
||||||
|
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")
|
||||||
|
return ClaimAckLinkRow(
|
||||||
|
claim_id=claim_id,
|
||||||
|
batch_id=None,
|
||||||
|
ak2_index=ak2_index,
|
||||||
|
set_control_number=set_control_number,
|
||||||
|
set_accept_reject_code=set_accept_reject_code,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"ClaimAckLinkResult",
|
||||||
|
"ClaimAckLinkRow",
|
||||||
|
"apply_999_acceptances",
|
||||||
|
"apply_277ca_acks",
|
||||||
|
"apply_ta1_envelope_link",
|
||||||
|
"link_manual",
|
||||||
|
"lookup_claims_for_ack_set_response",
|
||||||
|
]
|
||||||
@@ -645,6 +645,76 @@ class Two77caAck(Base):
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# SP28: per-ACK auto-link join table (claim_acks)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class ClaimAck(Base):
|
||||||
|
"""One row per AK2 set-response / ClaimStatus / TA1 envelope link.
|
||||||
|
|
||||||
|
SP28. The durable record of "this ACK acknowledges this claim (or
|
||||||
|
set, or batch)". One 999 row carries many AK2s; one 277CA carries
|
||||||
|
many ClaimStatuses; each gets its own ClaimAck row so the operator
|
||||||
|
can answer "which claims does this ack acknowledge?" with a single
|
||||||
|
SELECT on ``claim_acks``.
|
||||||
|
|
||||||
|
See ``docs/superpowers/specs/2026-07-02-cyclone-ack-claim-auto-link-design.md``
|
||||||
|
§3.1 for the schema decisions (per-AK2 granularity, the two-pass
|
||||||
|
join, idempotency via the unique index).
|
||||||
|
"""
|
||||||
|
|
||||||
|
__tablename__ = "claim_acks"
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||||
|
# FK to claims.id with ON DELETE CASCADE so removing a claim
|
||||||
|
# drops every link row referencing it. NULLable so TA1 envelope-level
|
||||||
|
# rows can populate ``batch_id`` instead (the table CHECK constraint
|
||||||
|
# requires at least one of the two).
|
||||||
|
claim_id: Mapped[Optional[str]] = mapped_column(
|
||||||
|
String(64), ForeignKey("claims.id", ondelete="CASCADE"), nullable=True,
|
||||||
|
)
|
||||||
|
# FK to batches.id (a Batch row in the 837 case, or the synthetic
|
||||||
|
# inbound-batch id when the ack arrived outside the SFTP pipeline).
|
||||||
|
batch_id: Mapped[Optional[str]] = mapped_column(
|
||||||
|
String(32), ForeignKey("batches.id", ondelete="CASCADE"), nullable=True,
|
||||||
|
)
|
||||||
|
# Discriminated union over acks / ta1_acks / two77ca_acks. No FK
|
||||||
|
# constraint because the three target tables are separate; the
|
||||||
|
# application enforces the discriminator + the matching row's id.
|
||||||
|
ack_id: Mapped[int] = mapped_column(Integer, nullable=False)
|
||||||
|
ack_kind: Mapped[str] = mapped_column(String(8), nullable=False)
|
||||||
|
ak2_index: Mapped[Optional[int]] = mapped_column(Integer, nullable=True)
|
||||||
|
# The set_control_number the upstream ack ACTUALLY CARRIED
|
||||||
|
# (== source 837 ST02 for Gainwell batches). Preserved on the link
|
||||||
|
# row for orphan traceability — the join may have resolved the
|
||||||
|
# link via the PCN fallback instead, but the operator still sees
|
||||||
|
# the value the 999/277CA originally carried.
|
||||||
|
set_control_number: Mapped[Optional[str]] = mapped_column(String(64), nullable=True)
|
||||||
|
# AK5 code (A/E/R/X) for 999, STC category (A1/A2/A3/A4/A6/A7)
|
||||||
|
# for 277CA, envelope ack_code for TA1.
|
||||||
|
set_accept_reject_code: Mapped[Optional[str]] = mapped_column(String(8), nullable=True)
|
||||||
|
linked_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
|
||||||
|
linked_by: Mapped[str] = mapped_column(String(16), nullable=False)
|
||||||
|
|
||||||
|
__table_args__ = (
|
||||||
|
Index("ix_claim_acks_claim_id", "claim_id"),
|
||||||
|
Index("ix_claim_acks_batch_id", "batch_id"),
|
||||||
|
Index("ix_claim_acks_ack", "ack_kind", "ack_id"),
|
||||||
|
# Mirror the dedup unique index declared in 0018_claim_acks.sql so
|
||||||
|
# ``Base.metadata.create_all`` (the test-time safety net) emits the
|
||||||
|
# same partial-unique constraint that the production migration runner
|
||||||
|
# applies. Without this a fresh in-memory test DB would not enforce
|
||||||
|
# idempotency at the DB layer.
|
||||||
|
Index(
|
||||||
|
"ux_claim_acks_dedup",
|
||||||
|
"claim_id", "ack_kind", "ack_id", "ak2_index",
|
||||||
|
unique=True,
|
||||||
|
sqlite_where=text("claim_id IS NOT NULL AND ak2_index IS NOT NULL"),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# SP11: tamper-evident hash-chained audit_log
|
# SP11: tamper-evident hash-chained audit_log
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ import logging
|
|||||||
|
|
||||||
from cyclone import db
|
from cyclone import db
|
||||||
from cyclone.audit_log import AuditEvent, append_event
|
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.handlers._ack_id import two77ca_synthetic_source_batch_id
|
||||||
from cyclone.inbox_state_277ca import apply_277ca_rejections
|
from cyclone.inbox_state_277ca import apply_277ca_rejections
|
||||||
from cyclone.parsers.exceptions import CycloneParseError
|
from cyclone.parsers.exceptions import CycloneParseError
|
||||||
@@ -72,6 +73,12 @@ def handle(
|
|||||||
1 for s in result.claim_statuses if s.classification == "pended"
|
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:
|
with db.SessionLocal()() as session:
|
||||||
row = cycl_store.add_277ca_ack(
|
row = cycl_store.add_277ca_ack(
|
||||||
source_batch_id=synthetic_id,
|
source_batch_id=synthetic_id,
|
||||||
@@ -100,6 +107,46 @@ def handle(
|
|||||||
payload={"source_batch_id": synthetic_id, "277ca_id": row.id},
|
payload={"source_batch_id": synthetic_id, "277ca_id": row.id},
|
||||||
actor="277ca-parser-scheduler",
|
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()
|
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))
|
return ("parse_277ca", len(result.claim_statuses))
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ import logging
|
|||||||
|
|
||||||
from cyclone import db
|
from cyclone import db
|
||||||
from cyclone.audit_log import AuditEvent, append_event
|
from cyclone.audit_log import AuditEvent, append_event
|
||||||
|
from cyclone.claim_acks import apply_999_acceptances
|
||||||
from cyclone.handlers._ack_id import (
|
from cyclone.handlers._ack_id import (
|
||||||
ack_count_summary,
|
ack_count_summary,
|
||||||
ack_synthetic_source_batch_id,
|
ack_synthetic_source_batch_id,
|
||||||
@@ -75,6 +76,12 @@ def handle(
|
|||||||
icn, pcn=pcn, source_filename=source_file,
|
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:
|
with db.SessionLocal()() as session:
|
||||||
def _lookup(pcn: str):
|
def _lookup(pcn: str):
|
||||||
return (
|
return (
|
||||||
@@ -102,6 +109,47 @@ def handle(
|
|||||||
ack_code=ack_code,
|
ack_code=ack_code,
|
||||||
raw_json=json.loads(result.model_dump_json()),
|
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()
|
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)
|
return ("parse_999", received)
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import json
|
|||||||
import logging
|
import logging
|
||||||
|
|
||||||
from cyclone import db
|
from cyclone import db
|
||||||
|
from cyclone.claim_acks import apply_ta1_envelope_link
|
||||||
from cyclone.parsers.exceptions import CycloneParseError
|
from cyclone.parsers.exceptions import CycloneParseError
|
||||||
from cyclone.parsers.parse_ta1 import parse_ta1_text
|
from cyclone.parsers.parse_ta1 import parse_ta1_text
|
||||||
from cyclone.store import store as cycl_store
|
from cyclone.store import store as cycl_store
|
||||||
@@ -55,7 +56,7 @@ def handle(
|
|||||||
raise ValueError(f"TA1 parse error: {exc}") from exc
|
raise ValueError(f"TA1 parse error: {exc}") from exc
|
||||||
|
|
||||||
with db.SessionLocal()() as session:
|
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,
|
source_batch_id=result.source_batch_id,
|
||||||
control_number=result.ta1.control_number,
|
control_number=result.ta1.control_number,
|
||||||
interchange_date=result.ta1.interchange_date,
|
interchange_date=result.ta1.interchange_date,
|
||||||
@@ -67,6 +68,56 @@ def handle(
|
|||||||
receiver_id=result.envelope.receiver_id,
|
receiver_id=result.envelope.receiver_id,
|
||||||
raw_json=json.loads(result.model_dump_json()),
|
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()
|
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)
|
return ("parse_ta1", 1)
|
||||||
|
|||||||
@@ -0,0 +1,52 @@
|
|||||||
|
-- version: 18
|
||||||
|
-- SP28: per-ACK auto-link join table.
|
||||||
|
--
|
||||||
|
-- Closes the operator gap where every inbound 999 / 277CA / TA1 ack was
|
||||||
|
-- persisted but never durably linked back to the claim it
|
||||||
|
-- acknowledges. One row per AK2 set-response for 999, per ClaimStatus
|
||||||
|
-- for 277CA, per TA1 envelope (with claim_id NULL + batch_id set).
|
||||||
|
--
|
||||||
|
-- Granularity (per-AK2) is preserved by ``ak2_index`` and the unique
|
||||||
|
-- index ``ux_claim_acks_dedup`` — an auto-link of
|
||||||
|
-- (claim, 999, ak2_index=3) is idempotent on re-ingest of the same
|
||||||
|
-- 999 file (the index enforces this at the DB layer; the helper
|
||||||
|
-- pre-checks to avoid IntegrityError log noise).
|
||||||
|
--
|
||||||
|
-- Notes:
|
||||||
|
-- * ``claim_id`` is nullable so TA1 envelope-level links to the
|
||||||
|
-- originating Batch can land here (FK to batches.id). The
|
||||||
|
-- CHECK constraint makes sure at least one of (claim_id,
|
||||||
|
-- batch_id) is set on every row — see spec §3.1.
|
||||||
|
-- * ``set_control_number`` records the value the upstream ACK
|
||||||
|
-- ACTUALLY CARRIED (== source 837 ST02 for Gainwell batches). It
|
||||||
|
-- is the orphan-traceability field — the link survives even when
|
||||||
|
-- the join had to fall back from ST02 to PCN matching.
|
||||||
|
-- * ``set_accept_reject_code`` carries the AK5 code (A/E/R/X) for
|
||||||
|
-- 999 or the STC category code (A1/A2/A3/A4/A6/A7 etc.) for 277CA.
|
||||||
|
-- TA1 stores the envelope-level ack code here (A/R/E).
|
||||||
|
-- * No FK constraint on ``(ack_kind, ack_id)`` — there are three
|
||||||
|
-- separate ack tables (``acks``, ``ta1_acks``, ``two77ca_acks``).
|
||||||
|
-- Application code enforces the discriminator.
|
||||||
|
|
||||||
|
CREATE TABLE claim_acks (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
claim_id TEXT REFERENCES claims(id) ON DELETE CASCADE,
|
||||||
|
batch_id TEXT REFERENCES batches(id) ON DELETE CASCADE,
|
||||||
|
ack_id INTEGER NOT NULL,
|
||||||
|
ack_kind TEXT NOT NULL CHECK (ack_kind IN ('999', '277ca', 'ta1')),
|
||||||
|
ak2_index INTEGER,
|
||||||
|
set_control_number TEXT,
|
||||||
|
set_accept_reject_code TEXT,
|
||||||
|
linked_at DATETIME NOT NULL,
|
||||||
|
linked_by TEXT NOT NULL CHECK (linked_by IN ('auto', 'manual')),
|
||||||
|
CHECK ((claim_id IS NOT NULL) OR (batch_id IS NOT NULL))
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX ix_claim_acks_claim_id ON claim_acks(claim_id);
|
||||||
|
CREATE INDEX ix_claim_acks_batch_id ON claim_acks(batch_id);
|
||||||
|
CREATE INDEX ix_claim_acks_ack ON claim_acks(ack_kind, ack_id);
|
||||||
|
|
||||||
|
-- Dedup: an auto-link of (claim, 999, ak2_index=3) is idempotent on re-ingest.
|
||||||
|
CREATE UNIQUE INDEX ux_claim_acks_dedup
|
||||||
|
ON claim_acks(claim_id, ack_kind, ack_id, ak2_index)
|
||||||
|
WHERE claim_id IS NOT NULL AND ak2_index IS NOT NULL;
|
||||||
@@ -80,6 +80,14 @@ from .acks import (
|
|||||||
list_acks,
|
list_acks,
|
||||||
list_ta1_acks,
|
list_ta1_acks,
|
||||||
)
|
)
|
||||||
|
from .claim_acks import (
|
||||||
|
add_claim_ack as _add_claim_ack,
|
||||||
|
batch_envelope_index as _batch_envelope_index,
|
||||||
|
find_ack_orphans as _find_ack_orphans,
|
||||||
|
list_acks_for_claim as _list_acks_for_claim,
|
||||||
|
list_claims_for_ack as _list_claims_for_ack,
|
||||||
|
remove_claim_ack as _remove_claim_ack,
|
||||||
|
)
|
||||||
from .backups import add_backup_pending
|
from .backups import add_backup_pending
|
||||||
from .exceptions import AlreadyMatchedError, InvalidStateError, NotMatchedError
|
from .exceptions import AlreadyMatchedError, InvalidStateError, NotMatchedError
|
||||||
from .inbox import list_unmatched, manual_match, manual_unmatch
|
from .inbox import list_unmatched, manual_match, manual_unmatch
|
||||||
@@ -102,6 +110,7 @@ from .providers import (
|
|||||||
from .records import BatchKind, BatchRecord, BatchRecord837, BatchRecord835
|
from .records import BatchKind, BatchRecord, BatchRecord837, BatchRecord835
|
||||||
from .ui import (
|
from .ui import (
|
||||||
to_ui_ack,
|
to_ui_ack,
|
||||||
|
to_ui_claim_ack,
|
||||||
to_ui_claim_detail,
|
to_ui_claim_detail,
|
||||||
to_ui_claim_from_orm,
|
to_ui_claim_from_orm,
|
||||||
to_ui_provider,
|
to_ui_provider,
|
||||||
@@ -287,6 +296,38 @@ class CycloneStore:
|
|||||||
def get_277ca_ack(self, ack_id):
|
def get_277ca_ack(self, ack_id):
|
||||||
return get_277ca_ack(ack_id)
|
return get_277ca_ack(ack_id)
|
||||||
|
|
||||||
|
# -- SP28: claim↔ack auto-link join table --------------------------
|
||||||
|
|
||||||
|
def add_claim_ack(self, **kwargs):
|
||||||
|
"""Persist one claim_acks link row.
|
||||||
|
|
||||||
|
Mirrors the publish-from-store contract used by the ACK paths:
|
||||||
|
when ``event_bus`` is passed, the matching ``claim_ack_written``
|
||||||
|
event fires after commit so live-tail subscribers on both the
|
||||||
|
claim and the ack side see the row immediately.
|
||||||
|
"""
|
||||||
|
return _add_claim_ack(**kwargs)
|
||||||
|
|
||||||
|
def list_acks_for_claim(self, claim_id):
|
||||||
|
"""Return every ClaimAck row for one claim (newest first)."""
|
||||||
|
return _list_acks_for_claim(claim_id)
|
||||||
|
|
||||||
|
def list_claims_for_ack(self, kind, ack_id):
|
||||||
|
"""Return every ClaimAck row for one ack."""
|
||||||
|
return _list_claims_for_ack(kind, ack_id)
|
||||||
|
|
||||||
|
def find_ack_orphans(self, kind):
|
||||||
|
"""Return acks with no resolvable link (Inbox ack-orphans lane)."""
|
||||||
|
return _find_ack_orphans(kind)
|
||||||
|
|
||||||
|
def remove_claim_ack(self, link_id, *, event_bus=None):
|
||||||
|
"""Unlink one row. Publishes ``claim_ack_dropped`` on the bus."""
|
||||||
|
return _remove_claim_ack(link_id, event_bus=event_bus)
|
||||||
|
|
||||||
|
def batch_envelope_index(self):
|
||||||
|
"""Return a {envelope.control_number: batch.id} map for D10 Pass 1."""
|
||||||
|
return _batch_envelope_index()
|
||||||
|
|
||||||
# -- SP17: encrypted DB backups -------------------------------------
|
# -- SP17: encrypted DB backups -------------------------------------
|
||||||
|
|
||||||
def add_backup_pending(self, *, filename: str, backup_dir: str):
|
def add_backup_pending(self, *, filename: str, backup_dir: str):
|
||||||
|
|||||||
@@ -0,0 +1,360 @@
|
|||||||
|
"""SP28: claim_acks persistence + batch envelope index (D10).
|
||||||
|
|
||||||
|
Five methods on top of the new ``ClaimAck`` ORM table:
|
||||||
|
|
||||||
|
* ``add_claim_ack`` — insert one link row (manual or auto) and
|
||||||
|
publish ``claim_ack_written`` on the bus.
|
||||||
|
* ``list_acks_for_claim`` — every link row for one claim (per-claim
|
||||||
|
only; TA1 batch-level rows are filtered out by the API layer).
|
||||||
|
* ``list_claims_for_ack`` — every link row for one ack.
|
||||||
|
* ``find_ack_orphans`` — acks with no resolvable claim (Inbox
|
||||||
|
ack-orphans lane).
|
||||||
|
* ``remove_claim_ack`` — unlink. Publishes ``claim_ack_dropped``.
|
||||||
|
* ``batch_envelope_index`` — D10 in-memory map of
|
||||||
|
``Batch.envelope.control_number → batch.id`` (cheap to rebuild;
|
||||||
|
re-built once per ingest).
|
||||||
|
|
||||||
|
Each mutating method opens its own ``db.SessionLocal()()`` session
|
||||||
|
so callers don't have to manage session lifecycles. Publishes are
|
||||||
|
best-effort (wrapped in :func:`_safe_publish`) so a failing bus
|
||||||
|
subscriber cannot roll back the persisted row.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
|
from cyclone import db
|
||||||
|
from cyclone.db import (
|
||||||
|
Ack,
|
||||||
|
Batch,
|
||||||
|
Claim,
|
||||||
|
ClaimAck,
|
||||||
|
Ta1Ack,
|
||||||
|
Two77caAck,
|
||||||
|
)
|
||||||
|
|
||||||
|
from . import utcnow
|
||||||
|
from .ui import to_ui_claim_ack
|
||||||
|
from .write import _sync_publish
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from cyclone.pubsub import EventBus
|
||||||
|
|
||||||
|
|
||||||
|
log = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def _safe_publish(event_bus: "EventBus | None", kind: str, payload: dict) -> None:
|
||||||
|
"""Best-effort publish wrapped in try/except.
|
||||||
|
|
||||||
|
Mirrors ``cyclone.store.acks._safe_publish`` — never raises,
|
||||||
|
never rolls back the persisted row.
|
||||||
|
"""
|
||||||
|
if event_bus is None:
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
_sync_publish(event_bus, kind, payload)
|
||||||
|
except Exception: # noqa: BLE001
|
||||||
|
log.exception("claim_acks store: %s publish failed", kind)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# D10 batch envelope index
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def batch_envelope_index() -> dict[str, str]:
|
||||||
|
"""Build a ``{envelope.control_number: batch.id}`` map.
|
||||||
|
|
||||||
|
D10 primary join key (spec §D10). Built once per ingest from
|
||||||
|
every Batch row whose ``raw_result_json`` carries an envelope —
|
||||||
|
cost is O(N batches), currently ~16, so trivial. Kept as a
|
||||||
|
plain dict so callers can ``index.get(scn)`` to resolve.
|
||||||
|
"""
|
||||||
|
out: dict[str, str] = {}
|
||||||
|
with db.SessionLocal()() as s:
|
||||||
|
rows = (
|
||||||
|
s.query(Batch.id, Batch.raw_result_json)
|
||||||
|
.filter(Batch.kind == "837")
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
for bid, raw in rows:
|
||||||
|
env = (raw or {}).get("envelope") or {}
|
||||||
|
ctrl = env.get("control_number")
|
||||||
|
if isinstance(ctrl, str) and ctrl:
|
||||||
|
out[ctrl] = bid
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Mutators
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def add_claim_ack(
|
||||||
|
*,
|
||||||
|
claim_id: str | None,
|
||||||
|
batch_id: str | None,
|
||||||
|
ack_id: int,
|
||||||
|
ack_kind: str,
|
||||||
|
ak2_index: int | None = None,
|
||||||
|
set_control_number: str | None = None,
|
||||||
|
set_accept_reject_code: str | None = None,
|
||||||
|
linked_by: str = "auto",
|
||||||
|
event_bus: "EventBus | None" = None,
|
||||||
|
now: datetime | None = None,
|
||||||
|
) -> ClaimAck:
|
||||||
|
"""Persist one link row and return it.
|
||||||
|
|
||||||
|
The DB-level 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`` enforces
|
||||||
|
idempotency for the per-AK2 case. The 277CA (ak2_index NULL)
|
||||||
|
and TA1 (claim_id NULL) paths are deduplicated by application
|
||||||
|
code — see :func:`cyclone.claim_acks._link_exists`.
|
||||||
|
|
||||||
|
When ``event_bus`` is provided, publishes one ``claim_ack_written``
|
||||||
|
event (with the full :func:`cyclone.store.ui.to_ui_claim_ack`
|
||||||
|
payload) so the live-tail subscribers on both
|
||||||
|
``/api/claims/{id}/acks/stream`` and
|
||||||
|
``/api/acks/{kind}/{id}/claims/stream`` see new rows the moment
|
||||||
|
they land.
|
||||||
|
"""
|
||||||
|
if ack_kind not in ("999", "277ca", "ta1"):
|
||||||
|
raise ValueError(f"add_claim_ack: unknown ack_kind={ack_kind!r}")
|
||||||
|
if linked_by not in ("auto", "manual"):
|
||||||
|
raise ValueError(f"add_claim_ack: linked_by={linked_by!r}")
|
||||||
|
if claim_id is None and batch_id is None:
|
||||||
|
raise ValueError(
|
||||||
|
"add_claim_ack: at least one of claim_id/batch_id must be set"
|
||||||
|
)
|
||||||
|
ts = now or utcnow()
|
||||||
|
with db.SessionLocal()() as s:
|
||||||
|
row = ClaimAck(
|
||||||
|
claim_id=claim_id,
|
||||||
|
batch_id=batch_id,
|
||||||
|
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=linked_by,
|
||||||
|
)
|
||||||
|
s.add(row)
|
||||||
|
s.commit()
|
||||||
|
s.refresh(row)
|
||||||
|
row_id = row.id
|
||||||
|
|
||||||
|
if event_bus is not None:
|
||||||
|
with db.SessionLocal()() as s:
|
||||||
|
payload = to_ui_claim_ack(s.get(ClaimAck, row_id))
|
||||||
|
_safe_publish(event_bus, "claim_ack_written", payload)
|
||||||
|
|
||||||
|
return row
|
||||||
|
|
||||||
|
|
||||||
|
def remove_claim_ack(
|
||||||
|
link_id: int,
|
||||||
|
*,
|
||||||
|
event_bus: "EventBus | None" = None,
|
||||||
|
) -> bool:
|
||||||
|
"""Unlink. Returns ``True`` when a row was deleted.
|
||||||
|
|
||||||
|
Publishes ``claim_ack_dropped`` with ``{"id", "claim_id"}`` so
|
||||||
|
the live-tail subscribers can remove the link from their local
|
||||||
|
store. Does NOT touch ``Claim.state`` — the unlink is purely
|
||||||
|
about the link row, per spec §D6.
|
||||||
|
"""
|
||||||
|
with db.SessionLocal()() as s:
|
||||||
|
row = s.get(ClaimAck, link_id)
|
||||||
|
if row is None:
|
||||||
|
return False
|
||||||
|
payload = {
|
||||||
|
"id": row.id,
|
||||||
|
"claim_id": row.claim_id,
|
||||||
|
"ack_id": row.ack_id,
|
||||||
|
"ack_kind": row.ack_kind,
|
||||||
|
}
|
||||||
|
s.delete(row)
|
||||||
|
s.commit()
|
||||||
|
|
||||||
|
if event_bus is not None:
|
||||||
|
_safe_publish(event_bus, "claim_ack_dropped", payload)
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Readers
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def list_acks_for_claim(claim_id: str) -> list[ClaimAck]:
|
||||||
|
"""Return every ClaimAck row for one claim (newest first)."""
|
||||||
|
with db.SessionLocal()() as s:
|
||||||
|
return (
|
||||||
|
s.query(ClaimAck)
|
||||||
|
.filter(ClaimAck.claim_id == claim_id)
|
||||||
|
.order_by(ClaimAck.id.desc())
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def list_claims_for_ack(kind: str, ack_id: int) -> list[ClaimAck]:
|
||||||
|
"""Return every ClaimAck row for one ack (any kind).
|
||||||
|
|
||||||
|
For 999 / 277CA: returns 0..N rows (one per AK2 / ClaimStatus).
|
||||||
|
For TA1: returns 0..1 row (envelope-level, populated batch_id).
|
||||||
|
"""
|
||||||
|
if kind not in ("999", "277ca", "ta1"):
|
||||||
|
raise ValueError(f"list_claims_for_ack: unknown kind={kind!r}")
|
||||||
|
with db.SessionLocal()() as s:
|
||||||
|
return (
|
||||||
|
s.query(ClaimAck)
|
||||||
|
.filter(
|
||||||
|
ClaimAck.ack_kind == kind,
|
||||||
|
ClaimAck.ack_id == ack_id,
|
||||||
|
)
|
||||||
|
.order_by(ClaimAck.id.asc())
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Orphan detection (Inbox "Ack orphans" lane — spec §D7)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def find_ack_orphans(kind: str) -> list[dict]:
|
||||||
|
"""Return acks with no resolvable Claim row of their own kind.
|
||||||
|
|
||||||
|
Used by the Inbox ack-orphans lane for the operator's manual
|
||||||
|
reconciliation flow. The downstream ``/api/inbox/ack-orphans``
|
||||||
|
endpoint calls this and returns the rendered shape.
|
||||||
|
|
||||||
|
"Orphan" means: for 999 / 277CA, ``ack_kind=kind AND ack_id IN
|
||||||
|
(acks_with_no_claim_acks_link)``. For TA1, "orphan" means the
|
||||||
|
TA1 row exists but no Batch with matching sender/receiver was
|
||||||
|
resolved (so no link row was created).
|
||||||
|
|
||||||
|
Output dict shape (one row per orphan ack, rendered by
|
||||||
|
:func:`to_ui_claim_ack`-style serialization):
|
||||||
|
|
||||||
|
* ``kind`` — "999" / "277ca" / "ta1"
|
||||||
|
* ``ack_id`` — the ack row's id
|
||||||
|
* ``control_number`` — for 999/277CA, envelope.control_number;
|
||||||
|
for TA1, ta1.control_number
|
||||||
|
* ``set_control_numbers`` — empty list when no claims match
|
||||||
|
* ``raw_summary`` — flat copy of the ack's UI shape for the
|
||||||
|
lane-header counts
|
||||||
|
"""
|
||||||
|
if kind not in ("999", "277ca", "ta1"):
|
||||||
|
raise ValueError(f"find_ack_orphans: unknown kind={kind!r}")
|
||||||
|
|
||||||
|
out: list[dict] = []
|
||||||
|
if kind == "999":
|
||||||
|
ack_table = Ack
|
||||||
|
ctrl_attr = None
|
||||||
|
elif kind == "277ca":
|
||||||
|
ack_table = Two77caAck
|
||||||
|
ctrl_attr = "control_number"
|
||||||
|
else:
|
||||||
|
ack_table = Ta1Ack
|
||||||
|
ctrl_attr = "control_number"
|
||||||
|
|
||||||
|
with db.SessionLocal()() as s:
|
||||||
|
# Every ack row of the given kind, with a LEFT JOIN against
|
||||||
|
# any claim_acks link; orphan when NO link was created.
|
||||||
|
if kind == "999":
|
||||||
|
# For 999, the "ack has no link" means no ClaimAck row
|
||||||
|
# was emitted at all (the auto-linker emits one per AK2
|
||||||
|
# even when the AK2 is rejected, so 999 with at least
|
||||||
|
# one AK2 that resolved to a claim is never an orphan).
|
||||||
|
# We treat a 999 as orphan when it has zero ClaimAck
|
||||||
|
# rows tied to its id.
|
||||||
|
all_acks = s.query(Ack).order_by(Ack.id.desc()).all()
|
||||||
|
for ack_row in all_acks:
|
||||||
|
count = (
|
||||||
|
s.query(ClaimAck)
|
||||||
|
.filter(ClaimAck.ack_kind == "999",
|
||||||
|
ClaimAck.ack_id == ack_row.id)
|
||||||
|
.count()
|
||||||
|
)
|
||||||
|
if count == 0:
|
||||||
|
out.append({
|
||||||
|
"kind": "999",
|
||||||
|
"ack_id": ack_row.id,
|
||||||
|
"control_number": _ack_control_number(ack_row, "999"),
|
||||||
|
"parsed_at": (
|
||||||
|
ack_row.parsed_at.isoformat().replace(
|
||||||
|
"+00:00", "Z"
|
||||||
|
) if ack_row.parsed_at else None
|
||||||
|
),
|
||||||
|
})
|
||||||
|
elif kind == "277ca":
|
||||||
|
all_acks = s.query(Two77caAck).order_by(Two77caAck.id.desc()).all()
|
||||||
|
for ack_row in all_acks:
|
||||||
|
count = (
|
||||||
|
s.query(ClaimAck)
|
||||||
|
.filter(ClaimAck.ack_kind == "277ca",
|
||||||
|
ClaimAck.ack_id == ack_row.id)
|
||||||
|
.count()
|
||||||
|
)
|
||||||
|
if count == 0:
|
||||||
|
out.append({
|
||||||
|
"kind": "277ca",
|
||||||
|
"ack_id": ack_row.id,
|
||||||
|
"control_number": ack_row.control_number or "",
|
||||||
|
"parsed_at": (
|
||||||
|
ack_row.parsed_at.isoformat().replace(
|
||||||
|
"+00:00", "Z"
|
||||||
|
) if ack_row.parsed_at else None
|
||||||
|
),
|
||||||
|
})
|
||||||
|
else:
|
||||||
|
all_acks = s.query(Ta1Ack).order_by(Ta1Ack.id.desc()).all()
|
||||||
|
for ack_row in all_acks:
|
||||||
|
count = (
|
||||||
|
s.query(ClaimAck)
|
||||||
|
.filter(ClaimAck.ack_kind == "ta1",
|
||||||
|
ClaimAck.ack_id == ack_row.id)
|
||||||
|
.count()
|
||||||
|
)
|
||||||
|
if count == 0:
|
||||||
|
out.append({
|
||||||
|
"kind": "ta1",
|
||||||
|
"ack_id": ack_row.id,
|
||||||
|
"control_number": ack_row.control_number or "",
|
||||||
|
"parsed_at": (
|
||||||
|
ack_row.parsed_at.isoformat().replace(
|
||||||
|
"+00:00", "Z"
|
||||||
|
) if ack_row.parsed_at else None
|
||||||
|
),
|
||||||
|
})
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def _ack_control_number(ack_row: Ack, kind: str) -> str:
|
||||||
|
"""Best-effort control-number lookup for a 999 ack row.
|
||||||
|
|
||||||
|
The 999 ORM row doesn't carry the envelope's control_number in
|
||||||
|
a dedicated column; we re-derive it from ``raw_json`` (the same
|
||||||
|
source :func:`cyclone.store.ui.to_ui_ack` uses for the patient
|
||||||
|
control number).
|
||||||
|
"""
|
||||||
|
raw = ack_row.raw_json or {}
|
||||||
|
env = raw.get("envelope") or {}
|
||||||
|
return env.get("control_number") or ""
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"add_claim_ack",
|
||||||
|
"batch_envelope_index",
|
||||||
|
"find_ack_orphans",
|
||||||
|
"list_acks_for_claim",
|
||||||
|
"list_claims_for_ack",
|
||||||
|
"remove_claim_ack",
|
||||||
|
]
|
||||||
@@ -8,6 +8,15 @@ Also hosts ``utcnow()`` (the tz-aware UTC now) because it's a small
|
|||||||
free function that several modules want and there's no better home.
|
free function that several modules want and there's no better home.
|
||||||
|
|
||||||
SP25: ``to_ui_ack`` / ``to_ui_ta1_ack`` / ``to_ui_two77ca_ack`` were
|
SP25: ``to_ui_ack`` / ``to_ui_ta1_ack`` / ``to_ui_two77ca_ack`` were
|
||||||
|
moved here from ``api_routers/acks.py`` / ``api_routers/ta1_acks.py``
|
||||||
|
/ ``api.py`` so the live-tail event payload matches the list endpoint
|
||||||
|
shape byte-for-byte. The seam between persistence and streaming
|
||||||
|
depends on the two halves staying in sync.
|
||||||
|
|
||||||
|
SP28 adds ``to_ui_claim_ack`` for the same reason — the
|
||||||
|
``claim_ack_written`` event payload must match the
|
||||||
|
``GET /api/acks/{kind}/{id}/claims`` list shape byte-for-byte.
|
||||||
|
|
||||||
moved here from ``api_routers/acks.py`` / ``api_routers/ta1_acks.py``
|
moved here from ``api_routers/acks.py`` / ``api_routers/ta1_acks.py``
|
||||||
/ ``api.py`` so the live-tail event payload can match the list endpoint
|
/ ``api.py`` so the live-tail event payload can match the list endpoint
|
||||||
shape byte-for-byte. The seam between persistence and streaming
|
shape byte-for-byte. The seam between persistence and streaming
|
||||||
@@ -20,7 +29,7 @@ from datetime import datetime, timezone
|
|||||||
from decimal import Decimal
|
from decimal import Decimal
|
||||||
|
|
||||||
from cyclone import db
|
from cyclone import db
|
||||||
from cyclone.db import Claim, Remittance
|
from cyclone.db import Claim, ClaimAck, Remittance
|
||||||
from cyclone.parsers.models import ClaimOutput
|
from cyclone.parsers.models import ClaimOutput
|
||||||
from cyclone.parsers.models_835 import ClaimPayment
|
from cyclone.parsers.models_835 import ClaimPayment
|
||||||
from cyclone.parsers.payer import PayerConfig835
|
from cyclone.parsers.payer import PayerConfig835
|
||||||
@@ -626,4 +635,49 @@ def _date_in_bounds(
|
|||||||
return False
|
return False
|
||||||
if date_to is not None and date_part > date_to:
|
if date_to is not None and date_part > date_to:
|
||||||
return False
|
return False
|
||||||
return True
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def to_ui_claim_ack(row: ClaimAck) -> dict:
|
||||||
|
"""SP28: map a ClaimAck ORM row to the API shape.
|
||||||
|
|
||||||
|
Mirrors the rest of the to_ui_* serializers. The wire shape is
|
||||||
|
identical between the matching list endpoint (``GET /api/acks/{kind}/
|
||||||
|
{id}/claims``) and the ``claim_ack_written`` pubsub event so the
|
||||||
|
live-tail subscribers can rehydrate the snapshot from the bus
|
||||||
|
without diverging from the API response.
|
||||||
|
|
||||||
|
``claim_state`` is queried via a session round-trip
|
||||||
|
(``SELECT state FROM claims WHERE id = :claim_id``) so the drawer
|
||||||
|
panel can render the colored ClaimStateBadge inline. For TA1 rows
|
||||||
|
with ``claim_id IS NULL`` (batch-level envelope link),
|
||||||
|
``claim_state`` is ``"n/a"``.
|
||||||
|
"""
|
||||||
|
claim_state: str = "n/a"
|
||||||
|
if row.claim_id:
|
||||||
|
with db.SessionLocal()() as lookup_s:
|
||||||
|
crow = lookup_s.get(Claim, row.claim_id)
|
||||||
|
if crow is not None:
|
||||||
|
claim_state = (
|
||||||
|
crow.state.value
|
||||||
|
if hasattr(crow.state, "value")
|
||||||
|
else str(crow.state)
|
||||||
|
)
|
||||||
|
linked_iso = (
|
||||||
|
row.linked_at.isoformat().replace("+00:00", "Z")
|
||||||
|
if row.linked_at is not None
|
||||||
|
else ""
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
"id": row.id,
|
||||||
|
"claim_id": row.claim_id,
|
||||||
|
"batch_id": row.batch_id,
|
||||||
|
"ack_id": row.ack_id,
|
||||||
|
"ack_kind": row.ack_kind,
|
||||||
|
"ak2_index": row.ak2_index,
|
||||||
|
"set_control_number": row.set_control_number,
|
||||||
|
"set_accept_reject_code": row.set_accept_reject_code,
|
||||||
|
"linked_at": linked_iso,
|
||||||
|
"linked_by": row.linked_by,
|
||||||
|
"claim_state": claim_state,
|
||||||
|
}
|
||||||
@@ -51,7 +51,7 @@ def test_migration_0002_creates_acks_table():
|
|||||||
|
|
||||||
def test_migration_latest_idempotent_on_fresh_db():
|
def test_migration_latest_idempotent_on_fresh_db():
|
||||||
"""Re-running the migration on the same DB must be a no-op (PRAGMA
|
"""Re-running the migration on the same DB must be a no-op (PRAGMA
|
||||||
user_version already at the latest version — currently 16 after
|
user_version already at the latest version — currently 18 after
|
||||||
0004-0006 line_reconciliation, 0005 ta1_acks, SP9's 0007
|
0004-0006 line_reconciliation, 0005 ta1_acks, SP9's 0007
|
||||||
providers/payers/clearhouse, SP10's 0008 payer_rejected,
|
providers/payers/clearhouse, SP10's 0008 payer_rejected,
|
||||||
SP11's 0009 audit_log, SP14's 0010 payer_rejected_acknowledged,
|
SP11's 0009 audit_log, SP14's 0010 payer_rejected_acknowledged,
|
||||||
@@ -59,15 +59,16 @@ def test_migration_latest_idempotent_on_fresh_db():
|
|||||||
SP-auth's 0013 users + sessions, SP-audit's 0014 audit_log.user_id,
|
SP-auth's 0013 users + sessions, SP-audit's 0014 audit_log.user_id,
|
||||||
SP22's 0015 drop_claims_unique_constraint, SP27-Task 11's 0016
|
SP22's 0015 drop_claims_unique_constraint, SP27-Task 11's 0016
|
||||||
claims.matched_remittance_id index, SP27-Task 17's 0017
|
claims.matched_remittance_id index, SP27-Task 17's 0017
|
||||||
claim.patient_control_number backfill UPDATE)."""
|
claim.patient_control_number backfill UPDATE, SP28's 0018
|
||||||
|
claim_acks join table)."""
|
||||||
with db.engine().begin() as c:
|
with db.engine().begin() as c:
|
||||||
v1 = c.exec_driver_sql("PRAGMA user_version").scalar() or 0
|
v1 = c.exec_driver_sql("PRAGMA user_version").scalar() or 0
|
||||||
assert v1 == 17
|
assert v1 == 18
|
||||||
# A second run should not raise and should not bump the version.
|
# A second run should not raise and should not bump the version.
|
||||||
db_migrate.run(db.engine())
|
db_migrate.run(db.engine())
|
||||||
with db.engine().begin() as c:
|
with db.engine().begin() as c:
|
||||||
v2 = c.exec_driver_sql("PRAGMA user_version").scalar() or 0
|
v2 = c.exec_driver_sql("PRAGMA user_version").scalar() or 0
|
||||||
assert v2 == 17
|
assert v2 == 18
|
||||||
|
|
||||||
|
|
||||||
def test_add_ack_persists_row():
|
def test_add_ack_persists_row():
|
||||||
|
|||||||
@@ -0,0 +1,359 @@
|
|||||||
|
"""API tests for SP28 ack-claim surface.
|
||||||
|
|
||||||
|
Spec §6 mandates these named tests:
|
||||||
|
|
||||||
|
* ``test_claim_detail_includes_ack_links`` — GET ``/api/claims/{id}``
|
||||||
|
after ingesting a 999, assert ``ack_links`` is populated.
|
||||||
|
* ``test_acks_list_includes_linked_claim_ids`` — GET ``/api/acks``
|
||||||
|
after ingesting, assert ``linked_claim_ids`` is populated per row.
|
||||||
|
* ``test_claim_acks_stream_emits_claim_ack_written`` — the live-tail
|
||||||
|
endpoint emits claim_ack_written on the bus.
|
||||||
|
|
||||||
|
Plus manual-match (D5/D9):
|
||||||
|
|
||||||
|
* ``test_manual_match_creates_link_via_api``
|
||||||
|
* ``test_manual_match_idempotent_returns_existing``
|
||||||
|
* ``test_manual_match_terminal_claim_returns_409``
|
||||||
|
* ``test_manual_unlink_via_api``
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from decimal import Decimal
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
from cyclone import db
|
||||||
|
from cyclone.api import app
|
||||||
|
from cyclone.db import Batch, Claim, ClaimAck, ClaimState
|
||||||
|
from cyclone.store import store
|
||||||
|
|
||||||
|
|
||||||
|
GAINWELL_999 = (
|
||||||
|
Path(__file__).parent / "fixtures" / "minimal_999_ik5_gainwell.txt"
|
||||||
|
)
|
||||||
|
ACCEPTED_999 = Path(__file__).parent / "fixtures" / "minimal_999.txt"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def clear_store():
|
||||||
|
with store._lock:
|
||||||
|
store._batches.clear()
|
||||||
|
yield
|
||||||
|
with store._lock:
|
||||||
|
store._batches.clear()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def client() -> TestClient:
|
||||||
|
return TestClient(app)
|
||||||
|
|
||||||
|
|
||||||
|
def _seed_batch_envelope(*, batch_id: str, envelope_control: str):
|
||||||
|
with db.SessionLocal()() as s:
|
||||||
|
b = Batch(
|
||||||
|
id=batch_id,
|
||||||
|
kind="837",
|
||||||
|
input_filename=f"{batch_id}.txt",
|
||||||
|
parsed_at=datetime(2026, 7, 2, 12, 0, tzinfo=timezone.utc),
|
||||||
|
raw_result_json={
|
||||||
|
"envelope": {
|
||||||
|
"sender_id": "SUBMITTER",
|
||||||
|
"receiver_id": "RECEIVER",
|
||||||
|
"control_number": envelope_control,
|
||||||
|
"transaction_date": "2026-07-02",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
s.add(b)
|
||||||
|
s.commit()
|
||||||
|
|
||||||
|
|
||||||
|
def _seed_claim(*, claim_id: str, batch_id: str, patient_control_number: str,
|
||||||
|
state: ClaimState = ClaimState.SUBMITTED):
|
||||||
|
with db.SessionLocal()() as s:
|
||||||
|
c = Claim(
|
||||||
|
id=claim_id,
|
||||||
|
batch_id=batch_id,
|
||||||
|
patient_control_number=patient_control_number,
|
||||||
|
charge_amount=Decimal("100.00"),
|
||||||
|
state=state,
|
||||||
|
)
|
||||||
|
s.add(c)
|
||||||
|
s.commit()
|
||||||
|
|
||||||
|
|
||||||
|
def test_claim_detail_includes_ack_links(client: TestClient):
|
||||||
|
"""GET /api/claims/{id} after ingesting a 999, assert ack_links
|
||||||
|
is populated."""
|
||||||
|
_seed_batch_envelope(batch_id="B-A", envelope_control="991102989")
|
||||||
|
_seed_claim(claim_id="CLM-DETAIL", batch_id="B-A",
|
||||||
|
patient_control_number="t991102989o1cA")
|
||||||
|
# Ingest a 999 that resolves via Pass 1.
|
||||||
|
text = GAINWELL_999.read_text()
|
||||||
|
resp = client.post(
|
||||||
|
"/api/parse-999",
|
||||||
|
files={"file": ("minimal_999_ik5_gainwell.txt", text, "text/plain")},
|
||||||
|
headers={"Accept": "application/json"},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200, resp.text
|
||||||
|
|
||||||
|
# GET /api/claims/{claim_id} — the SP4 detail endpoint must
|
||||||
|
# surface ack_links so the ClaimDrawer panel can render.
|
||||||
|
detail_resp = client.get("/api/claims/CLM-DETAIL")
|
||||||
|
assert detail_resp.status_code == 200, detail_resp.text
|
||||||
|
body = detail_resp.json()
|
||||||
|
assert "ack_links" in body
|
||||||
|
assert len(body["ack_links"]) == 1
|
||||||
|
link = body["ack_links"][0]
|
||||||
|
assert link["ack_kind"] == "999"
|
||||||
|
assert link["set_accept_reject_code"] == "A"
|
||||||
|
assert link["linked_by"] == "auto"
|
||||||
|
|
||||||
|
|
||||||
|
def test_acks_list_includes_linked_claim_ids(client: TestClient):
|
||||||
|
"""GET /api/acks after ingesting, assert linked_claim_ids is
|
||||||
|
populated per row."""
|
||||||
|
_seed_batch_envelope(batch_id="B-LIST", envelope_control="991102989")
|
||||||
|
_seed_claim(claim_id="CLM-LIST-1", batch_id="B-LIST",
|
||||||
|
patient_control_number="t991102989o1cA")
|
||||||
|
text = GAINWELL_999.read_text()
|
||||||
|
client.post(
|
||||||
|
"/api/parse-999",
|
||||||
|
files={"file": ("minimal_999_ik5_gainwell.txt", text, "text/plain")},
|
||||||
|
headers={"Accept": "application/json"},
|
||||||
|
)
|
||||||
|
|
||||||
|
resp = client.get("/api/acks")
|
||||||
|
assert resp.status_code == 200, resp.text
|
||||||
|
body = resp.json()
|
||||||
|
assert body["total"] == 1
|
||||||
|
assert "linked_claim_ids" in body["items"][0]
|
||||||
|
assert body["items"][0]["linked_claim_ids"] == ["CLM-LIST-1"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_claim_acks_stream_emits_claim_ack_written(client: TestClient):
|
||||||
|
"""The per-claim live-tail endpoint streams claim_ack_written
|
||||||
|
events the moment a link is created.
|
||||||
|
|
||||||
|
Uses the direct-endpoint-invocation pattern from
|
||||||
|
``test_api_stream_live.py`` so we can iterate the body iterator
|
||||||
|
with byte-level streaming + asyncio cancellation cleanup.
|
||||||
|
"""
|
||||||
|
import asyncio
|
||||||
|
from starlette.requests import Request as StarletteRequest
|
||||||
|
from cyclone.api_routers.claim_acks import (
|
||||||
|
claim_acks_stream,
|
||||||
|
)
|
||||||
|
|
||||||
|
_seed_batch_envelope(batch_id="B-STREAM", envelope_control="991102989")
|
||||||
|
_seed_claim(claim_id="CLM-STREAM", batch_id="B-STREAM",
|
||||||
|
patient_control_number="t991102989o1cA")
|
||||||
|
|
||||||
|
async def _drain_until_disconnect(body_iter):
|
||||||
|
try:
|
||||||
|
await body_iter.aclose()
|
||||||
|
except (asyncio.CancelledError, GeneratorExit):
|
||||||
|
pass
|
||||||
|
|
||||||
|
async def _read_until_type(body_iter, target, *, timeout=5.0):
|
||||||
|
buffer = b""
|
||||||
|
async with asyncio.timeout(timeout):
|
||||||
|
while True:
|
||||||
|
chunk = await body_iter.__anext__()
|
||||||
|
buffer += chunk
|
||||||
|
while b"\n" in buffer:
|
||||||
|
raw, buffer = buffer.split(b"\n", 1)
|
||||||
|
if not raw:
|
||||||
|
continue
|
||||||
|
obj = json.loads(raw.decode("utf-8"))
|
||||||
|
if obj.get("type") == target:
|
||||||
|
return obj
|
||||||
|
return None
|
||||||
|
|
||||||
|
async def _scenario():
|
||||||
|
# Ingest a 999 that resolves via Pass 1 so we have a link
|
||||||
|
# row to stream.
|
||||||
|
text = GAINWELL_999.read_text()
|
||||||
|
resp = client.post(
|
||||||
|
"/api/parse-999",
|
||||||
|
files={"file": ("minimal_999_ik5_gainwell.txt", text, "text/plain")},
|
||||||
|
headers={"Accept": "application/json"},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200, resp.text
|
||||||
|
|
||||||
|
# Build a synthetic request and call the endpoint coroutine
|
||||||
|
# directly so we get true byte-streaming.
|
||||||
|
async def receive():
|
||||||
|
await asyncio.Event().wait() # never delivered
|
||||||
|
|
||||||
|
scope = {
|
||||||
|
"type": "http",
|
||||||
|
"method": "GET",
|
||||||
|
"headers": [],
|
||||||
|
"query_string": b"",
|
||||||
|
"path": "/api/claims/CLM-STREAM/acks/stream",
|
||||||
|
"app": app,
|
||||||
|
"scheme": "http",
|
||||||
|
"server": ("testserver", 80),
|
||||||
|
"client": ("testclient", 50000),
|
||||||
|
}
|
||||||
|
request = StarletteRequest(scope, receive=receive)
|
||||||
|
response = await claim_acks_stream(request, claim_id="CLM-STREAM")
|
||||||
|
assert response.media_type.startswith("application/x-ndjson")
|
||||||
|
|
||||||
|
# The snapshot has 1 item + 1 snapshot_end. Read until
|
||||||
|
# snapshot_end.
|
||||||
|
snap_end = await _read_until_type(
|
||||||
|
response.body_iterator, "snapshot_end", timeout=2.0,
|
||||||
|
)
|
||||||
|
assert snap_end is not None
|
||||||
|
assert snap_end["data"]["count"] == 1
|
||||||
|
# The first emitted item was the snapshot itself; check it
|
||||||
|
# carries the right claim_id.
|
||||||
|
await _drain_until_disconnect(response.body_iterator)
|
||||||
|
|
||||||
|
asyncio.run(_scenario())
|
||||||
|
|
||||||
|
|
||||||
|
def test_manual_match_creates_link_via_api(client: TestClient):
|
||||||
|
"""POST /api/acks/{kind}/{ack_id}/match-claim creates a link row."""
|
||||||
|
# Pre-seed an ack via the API so we have an ack_id to reference.
|
||||||
|
_seed_batch_envelope(batch_id="B-MM", envelope_control="991102989")
|
||||||
|
_seed_claim(claim_id="CLM-MM", batch_id="B-MM",
|
||||||
|
patient_control_number="t991102989o1cA")
|
||||||
|
text = GAINWELL_999.read_text()
|
||||||
|
resp = client.post(
|
||||||
|
"/api/parse-999",
|
||||||
|
files={"file": ("minimal_999_ik5_gainwell.txt", text, "text/plain")},
|
||||||
|
headers={"Accept": "application/json"},
|
||||||
|
)
|
||||||
|
body = resp.json()
|
||||||
|
ack_id = body["ack"]["id"]
|
||||||
|
|
||||||
|
# Now manually match a second claim to the same ack.
|
||||||
|
_seed_claim(claim_id="CLM-MM-MANUAL", batch_id="B-MM",
|
||||||
|
patient_control_number="manual-pcn-001")
|
||||||
|
resp2 = client.post(
|
||||||
|
f"/api/acks/999/{ack_id}/match-claim",
|
||||||
|
json={"claim_id": "CLM-MM-MANUAL"},
|
||||||
|
)
|
||||||
|
assert resp2.status_code == 200, resp2.text
|
||||||
|
body2 = resp2.json()
|
||||||
|
assert body2["created"] is True
|
||||||
|
assert body2["link"]["claim_id"] == "CLM-MM-MANUAL"
|
||||||
|
assert body2["link"]["linked_by"] == "manual"
|
||||||
|
|
||||||
|
|
||||||
|
def test_manual_match_idempotent_returns_existing(client: TestClient):
|
||||||
|
"""Re-calling match-claim with the same claim_id returns the
|
||||||
|
existing row (200), not a duplicate.
|
||||||
|
|
||||||
|
Uses a 999 that the auto-linker can't resolve (no matching
|
||||||
|
Batch.envelope.control_number, no matching PCN) so the manual
|
||||||
|
match is the FIRST link to land.
|
||||||
|
"""
|
||||||
|
# Seed a Batch + Claim whose envelope control and PCN both do
|
||||||
|
# NOT match the 999's ST02 / set_control_number.
|
||||||
|
_seed_batch_envelope(batch_id="B-MM-IDEMP", envelope_control="UNRELATED")
|
||||||
|
_seed_claim(claim_id="CLM-MM-IDEMP", batch_id="B-MM-IDEMP",
|
||||||
|
patient_control_number="manual-test-pcn")
|
||||||
|
text = GAINWELL_999.read_text()
|
||||||
|
resp = client.post(
|
||||||
|
"/api/parse-999",
|
||||||
|
files={"file": ("minimal_999_ik5_gainwell.txt", text, "text/plain")},
|
||||||
|
headers={"Accept": "application/json"},
|
||||||
|
)
|
||||||
|
ack_id = resp.json()["ack"]["id"]
|
||||||
|
|
||||||
|
# First call — created.
|
||||||
|
r1 = client.post(
|
||||||
|
f"/api/acks/999/{ack_id}/match-claim",
|
||||||
|
json={"claim_id": "CLM-MM-IDEMP"},
|
||||||
|
)
|
||||||
|
assert r1.status_code == 200, r1.text
|
||||||
|
body1 = r1.json()
|
||||||
|
assert body1["created"] is True
|
||||||
|
|
||||||
|
# Second call — same row, idempotent.
|
||||||
|
r2 = client.post(
|
||||||
|
f"/api/acks/999/{ack_id}/match-claim",
|
||||||
|
json={"claim_id": "CLM-MM-IDEMP"},
|
||||||
|
)
|
||||||
|
assert r2.status_code == 200, r2.text
|
||||||
|
body2 = r2.json()
|
||||||
|
assert body2["created"] is False
|
||||||
|
assert body2["link"]["id"] == body1["link"]["id"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_manual_match_terminal_claim_returns_409(client: TestClient):
|
||||||
|
"""A REVERSED claim cannot be matched — 409."""
|
||||||
|
_seed_batch_envelope(batch_id="B-MM-TERM", envelope_control="991102989")
|
||||||
|
_seed_claim(claim_id="CLM-MM-TERM", batch_id="B-MM-TERM",
|
||||||
|
patient_control_number="t991102989o1cA",
|
||||||
|
state=ClaimState.REVERSED)
|
||||||
|
text = GAINWELL_999.read_text()
|
||||||
|
resp = client.post(
|
||||||
|
"/api/parse-999",
|
||||||
|
files={"file": ("minimal_999_ik5_gainwell.txt", text, "text/plain")},
|
||||||
|
headers={"Accept": "application/json"},
|
||||||
|
)
|
||||||
|
ack_id = resp.json()["ack"]["id"]
|
||||||
|
|
||||||
|
resp2 = client.post(
|
||||||
|
f"/api/acks/999/{ack_id}/match-claim",
|
||||||
|
json={"claim_id": "CLM-MM-TERM"},
|
||||||
|
)
|
||||||
|
assert resp2.status_code == 409, resp2.text
|
||||||
|
|
||||||
|
|
||||||
|
def test_manual_unlink_via_api(client: TestClient):
|
||||||
|
"""DELETE /api/acks/{kind}/{ack_id}/match-claim/{claim_id} removes
|
||||||
|
the link row and publishes claim_ack_dropped."""
|
||||||
|
_seed_batch_envelope(batch_id="B-UNL", envelope_control="991102989")
|
||||||
|
_seed_claim(claim_id="CLM-UNL", batch_id="B-UNL",
|
||||||
|
patient_control_number="t991102989o1cA")
|
||||||
|
text = GAINWELL_999.read_text()
|
||||||
|
resp = client.post(
|
||||||
|
"/api/parse-999",
|
||||||
|
files={"file": ("minimal_999_ik5_gainwell.txt", text, "text/plain")},
|
||||||
|
headers={"Accept": "application/json"},
|
||||||
|
)
|
||||||
|
ack_id = resp.json()["ack"]["id"]
|
||||||
|
|
||||||
|
resp_del = client.delete(f"/api/acks/999/{ack_id}/match-claim/CLM-UNL")
|
||||||
|
assert resp_del.status_code == 200, resp_del.text
|
||||||
|
assert resp_del.json()["removed"] is True
|
||||||
|
|
||||||
|
# Verify the row is gone.
|
||||||
|
with db.SessionLocal()() as s:
|
||||||
|
n = (
|
||||||
|
s.query(ClaimAck)
|
||||||
|
.filter_by(claim_id="CLM-UNL", ack_kind="999", ack_id=ack_id)
|
||||||
|
.count()
|
||||||
|
)
|
||||||
|
assert n == 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_inbox_ack_orphans_returns_unresolved_acks(client: TestClient):
|
||||||
|
"""An ack that resolves to no claim surfaces in the inbox
|
||||||
|
ack-orphans lane."""
|
||||||
|
# Ingest a 999 with no matching batch + no matching PCN — orphan.
|
||||||
|
text = ACCEPTED_999.read_text()
|
||||||
|
resp = client.post(
|
||||||
|
"/api/parse-999",
|
||||||
|
files={"file": ("minimal_999.txt", text, "text/plain")},
|
||||||
|
headers={"Accept": "application/json"},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200, resp.text
|
||||||
|
|
||||||
|
orphans_resp = client.get("/api/inbox/ack-orphans")
|
||||||
|
assert orphans_resp.status_code == 200, orphans_resp.text
|
||||||
|
body = orphans_resp.json()
|
||||||
|
assert body["total"] >= 1
|
||||||
|
# The 999 we just ingested is in the orphan list.
|
||||||
|
kinds = [item["kind"] for item in body["items"]]
|
||||||
|
assert "999" in kinds
|
||||||
@@ -0,0 +1,845 @@
|
|||||||
|
"""Tests for :mod:`cyclone.claim_acks` (SP28 auto-linker).
|
||||||
|
|
||||||
|
Two flavours:
|
||||||
|
* Pure-unit tests (no DB) for the walk-through logic of
|
||||||
|
``apply_999_acceptances`` etc. via stubs.
|
||||||
|
* Integration tests against the test DB (autouse conftest) that
|
||||||
|
exercise ``lookup_claims_for_ack_set_response``'s two-pass D10 join,
|
||||||
|
per-AK2 granularity, idempotent re-ingest, and the manual link.
|
||||||
|
|
||||||
|
The 8 named tests from spec §6 are here as
|
||||||
|
``test_999_*`` / ``test_277ca_*`` / ``test_ta1_*`` /
|
||||||
|
``test_reingest_*`` / ``test_manual_link_*`` /
|
||||||
|
``test_unlink_does_not_revert_claim_state``.
|
||||||
|
|
||||||
|
Additional tests cover the D10 two-pass join (Step 2.5 of the plan)
|
||||||
|
and the store facade wiring.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import date, datetime, timezone
|
||||||
|
from decimal import Decimal
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from cyclone import claim_acks as ca
|
||||||
|
from cyclone import db
|
||||||
|
from cyclone.claim_acks import (
|
||||||
|
ClaimAckLinkResult,
|
||||||
|
ClaimAckLinkRow,
|
||||||
|
apply_277ca_acks,
|
||||||
|
apply_999_acceptances,
|
||||||
|
apply_ta1_envelope_link,
|
||||||
|
link_manual,
|
||||||
|
lookup_claims_for_ack_set_response,
|
||||||
|
)
|
||||||
|
from cyclone.db import (
|
||||||
|
Batch,
|
||||||
|
Claim,
|
||||||
|
ClaimState,
|
||||||
|
ClaimAck,
|
||||||
|
)
|
||||||
|
from cyclone.parsers.models import (
|
||||||
|
BatchSummary,
|
||||||
|
ClaimHeader,
|
||||||
|
Envelope,
|
||||||
|
ParseResult,
|
||||||
|
)
|
||||||
|
from cyclone.parsers.models_277ca import (
|
||||||
|
AcknowledgmentHeader,
|
||||||
|
ClaimStatus,
|
||||||
|
ParseResult277CA,
|
||||||
|
)
|
||||||
|
from cyclone.parsers.models_999 import (
|
||||||
|
AcknowledgmentHeader as AckHeader999,
|
||||||
|
ParseResult999,
|
||||||
|
SetAcceptReject,
|
||||||
|
SetFunctionalGroupResponse,
|
||||||
|
)
|
||||||
|
from cyclone.parsers.models_ta1 import ParseResultTa1, Ta1Ack
|
||||||
|
from cyclone.store import store as cycl_store, store
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Fixture seeds
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def _seed_batch(
|
||||||
|
session,
|
||||||
|
*,
|
||||||
|
batch_id: str = "B1",
|
||||||
|
envelope_control: str = "991102989",
|
||||||
|
sender_id: str = "SENDER",
|
||||||
|
receiver_id: str = "RECEIVER",
|
||||||
|
):
|
||||||
|
"""Insert one Batch row whose raw_result_json envelope carries
|
||||||
|
``envelope_control`` so D10 Pass 1 can resolve it via
|
||||||
|
``batch_envelope_index``."""
|
||||||
|
b = Batch(
|
||||||
|
id=batch_id,
|
||||||
|
kind="837",
|
||||||
|
input_filename=f"{batch_id}.txt",
|
||||||
|
parsed_at=datetime(2026, 7, 2, 12, 0, tzinfo=timezone.utc),
|
||||||
|
raw_result_json={
|
||||||
|
"envelope": {
|
||||||
|
"sender_id": sender_id,
|
||||||
|
"receiver_id": receiver_id,
|
||||||
|
"control_number": envelope_control,
|
||||||
|
"transaction_date": "2026-07-02",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
session.add(b)
|
||||||
|
session.commit()
|
||||||
|
session.refresh(b)
|
||||||
|
return b
|
||||||
|
|
||||||
|
|
||||||
|
def _seed_claim(
|
||||||
|
session,
|
||||||
|
*,
|
||||||
|
claim_id: str,
|
||||||
|
batch_id: str,
|
||||||
|
patient_control_number: str | None = None,
|
||||||
|
state: ClaimState = ClaimState.SUBMITTED,
|
||||||
|
):
|
||||||
|
c = Claim(
|
||||||
|
id=claim_id,
|
||||||
|
batch_id=batch_id,
|
||||||
|
patient_control_number=patient_control_number or claim_id,
|
||||||
|
charge_amount=Decimal("100.00"),
|
||||||
|
state=state,
|
||||||
|
)
|
||||||
|
session.add(c)
|
||||||
|
session.commit()
|
||||||
|
session.refresh(c)
|
||||||
|
return c
|
||||||
|
|
||||||
|
|
||||||
|
def _seed_ack_link_index(*, envelope_control_to_batch: dict[str, str]):
|
||||||
|
"""Build a closure compatible with ``lookup_claims_for_ack_set_response``."""
|
||||||
|
|
||||||
|
def _lookup(scn: str) -> str | None:
|
||||||
|
return envelope_control_to_batch.get(scn)
|
||||||
|
|
||||||
|
return _lookup
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_999_two_ak2s() -> ParseResult999:
|
||||||
|
"""Two-AK2 999 — one accepted, one rejected."""
|
||||||
|
sr_accepted = SetFunctionalGroupResponse(
|
||||||
|
ak2=AckHeader999(functional_id_code="837", group_control_number="991102989"),
|
||||||
|
set_control_number="991102989",
|
||||||
|
transaction_set_identifier="837",
|
||||||
|
segment_errors=[],
|
||||||
|
set_accept_reject=SetAcceptReject(code="A"),
|
||||||
|
)
|
||||||
|
sr_rejected = SetFunctionalGroupResponse(
|
||||||
|
ak2=AckHeader999(functional_id_code="837", group_control_number="991102990"),
|
||||||
|
set_control_number="991102990",
|
||||||
|
transaction_set_identifier="837",
|
||||||
|
segment_errors=[],
|
||||||
|
set_accept_reject=SetAcceptReject(code="R"),
|
||||||
|
)
|
||||||
|
return ParseResult999(
|
||||||
|
envelope=Envelope(
|
||||||
|
sender_id="PAYER", receiver_id="SUBMITTER",
|
||||||
|
control_number="000000001", transaction_date=date(2026, 7, 2),
|
||||||
|
),
|
||||||
|
functional_group_acks=[],
|
||||||
|
set_responses=[sr_accepted, sr_rejected],
|
||||||
|
summary=BatchSummary(
|
||||||
|
input_file="two_ak2.txt", control_number="000000001",
|
||||||
|
transaction_date=date(2026, 7, 2),
|
||||||
|
total_claims=2, passed=1, failed=1,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_277ca_one_accepted() -> ParseResult277CA:
|
||||||
|
return ParseResult277CA(
|
||||||
|
envelope=Envelope(
|
||||||
|
sender_id="PAYER", receiver_id="SUBMITTER",
|
||||||
|
control_number="000000077", transaction_date=date(2026, 7, 2),
|
||||||
|
),
|
||||||
|
bht=AcknowledgmentHeader(
|
||||||
|
hierarchical_structure_code="0085",
|
||||||
|
transaction_set_purpose_code="08",
|
||||||
|
reference_identification="REFNUM001",
|
||||||
|
),
|
||||||
|
summary=BatchSummary(
|
||||||
|
input_file="277_one_accepted.txt", control_number="000000077",
|
||||||
|
transaction_date=date(2026, 7, 2),
|
||||||
|
total_claims=1, passed=1, failed=0,
|
||||||
|
),
|
||||||
|
claim_statuses=[
|
||||||
|
ClaimStatus(
|
||||||
|
status_code="A3:19:PR",
|
||||||
|
classification="accepted",
|
||||||
|
payer_claim_control_number="CLAIM001",
|
||||||
|
),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_277ca_one_rejected() -> ParseResult277CA:
|
||||||
|
return ParseResult277CA(
|
||||||
|
envelope=Envelope(
|
||||||
|
sender_id="PAYER", receiver_id="SUBMITTER",
|
||||||
|
control_number="000000078", transaction_date=date(2026, 7, 2),
|
||||||
|
),
|
||||||
|
bht=AcknowledgmentHeader(
|
||||||
|
hierarchical_structure_code="0085",
|
||||||
|
transaction_set_purpose_code="08",
|
||||||
|
reference_identification="REFNUM002",
|
||||||
|
),
|
||||||
|
summary=BatchSummary(
|
||||||
|
input_file="277_one_rejected.txt", control_number="000000078",
|
||||||
|
transaction_date=date(2026, 7, 2),
|
||||||
|
total_claims=1, passed=0, failed=1,
|
||||||
|
),
|
||||||
|
claim_statuses=[
|
||||||
|
ClaimStatus(
|
||||||
|
status_code="A6:19:PR",
|
||||||
|
classification="rejected",
|
||||||
|
payer_claim_control_number="CLAIM002",
|
||||||
|
),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_ta1() -> ParseResultTa1:
|
||||||
|
return ParseResultTa1(
|
||||||
|
envelope=Envelope(
|
||||||
|
sender_id="RECEIVER", receiver_id="SENDER",
|
||||||
|
control_number="000000001", transaction_date=date(2026, 7, 2),
|
||||||
|
),
|
||||||
|
ta1=Ta1Ack(
|
||||||
|
control_number="000000001",
|
||||||
|
interchange_date=date(2026, 7, 2),
|
||||||
|
interchange_time="1200",
|
||||||
|
ack_code="A",
|
||||||
|
note_code="000",
|
||||||
|
ack_generated_date=date(2026, 7, 2),
|
||||||
|
),
|
||||||
|
source_batch_id="TA1-000000001",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Spec §6 tests
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_999_auto_creates_per_ak2_link_rows():
|
||||||
|
"""Step 2.5 (named after spec §6).
|
||||||
|
|
||||||
|
Two AK2s in one 999 — one accepted, one rejected. They reference
|
||||||
|
two distinct batches (one claim per batch), so per-AK2 granularity
|
||||||
|
produces two link rows with two different set_accept_reject codes.
|
||||||
|
"""
|
||||||
|
with db.SessionLocal()() as s:
|
||||||
|
_seed_batch(s, batch_id="B-999A", envelope_control="991102989")
|
||||||
|
_seed_claim(s, claim_id="CLM-A", batch_id="B-999A",
|
||||||
|
patient_control_number="t991102989o1cA")
|
||||||
|
_seed_batch(s, batch_id="B-999B", envelope_control="991102990")
|
||||||
|
_seed_claim(s, claim_id="CLM-B", batch_id="B-999B",
|
||||||
|
patient_control_number="t991102990o1cB")
|
||||||
|
# Index keyed by set_control_number -> batch_id (D10 batch
|
||||||
|
# envelope index).
|
||||||
|
idx = _seed_ack_link_index(envelope_control_to_batch={
|
||||||
|
"991102989": "B-999A",
|
||||||
|
"991102990": "B-999B",
|
||||||
|
})
|
||||||
|
|
||||||
|
parsed = _parse_999_two_ak2s()
|
||||||
|
out = apply_999_acceptances(s, parsed, ack_id=42,
|
||||||
|
batch_envelope_index=idx)
|
||||||
|
s.commit()
|
||||||
|
|
||||||
|
# Helpers now return ClaimAckLinkRow dataclasses; the caller
|
||||||
|
# (the handler) persists them via cycl_store.add_claim_ack.
|
||||||
|
# Here we test the helper shape directly — claim_ids and
|
||||||
|
# ak2_indices match the per-AK2 granularity from spec §D1.
|
||||||
|
assert {(r.claim_id, r.ak2_index) for r in out.linked} == {
|
||||||
|
("CLM-A", 0),
|
||||||
|
("CLM-B", 1),
|
||||||
|
}
|
||||||
|
assert out.orphans == []
|
||||||
|
# The helper does NOT persist; rows are zero before the caller
|
||||||
|
# inserts. Verify that.
|
||||||
|
with db.SessionLocal()() as s:
|
||||||
|
n = s.query(ClaimAck).filter(ClaimAck.ack_kind == "999", ClaimAck.ack_id == 42).count()
|
||||||
|
assert n == 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_999_orphan_does_not_create_link():
|
||||||
|
"""An AK2 whose set_control_number doesn't resolve stays orphan."""
|
||||||
|
with db.SessionLocal()() as s:
|
||||||
|
_seed_batch(s, batch_id="B-999-ORPHAN", envelope_control="0001")
|
||||||
|
parsed = _parse_999_two_ak2s()
|
||||||
|
idx = _seed_ack_link_index(envelope_control_to_batch={})
|
||||||
|
out = apply_999_acceptances(s, parsed, ack_id=99,
|
||||||
|
batch_envelope_index=idx)
|
||||||
|
s.commit()
|
||||||
|
|
||||||
|
assert out.linked == []
|
||||||
|
assert sorted(out.orphans) == ["991102989", "991102990"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_277ca_accepted_creates_link_with_no_state_mutation():
|
||||||
|
"""Accepted 277CA → link row, no payer_rejected stamp."""
|
||||||
|
with db.SessionLocal()() as s:
|
||||||
|
_seed_batch(s, batch_id="B-277A", envelope_control="991102989")
|
||||||
|
claim = _seed_claim(s, claim_id="CLM-Z", batch_id="B-277A",
|
||||||
|
patient_control_number="CLAIM001")
|
||||||
|
idx = _seed_ack_link_index(envelope_control_to_batch={})
|
||||||
|
|
||||||
|
parsed = _parse_277ca_one_accepted()
|
||||||
|
|
||||||
|
def _pcn_lookup(pcn: str):
|
||||||
|
return (s.query(Claim)
|
||||||
|
.filter_by(patient_control_number=pcn)
|
||||||
|
.first())
|
||||||
|
|
||||||
|
out = apply_277ca_acks(s, parsed, ack_id=7,
|
||||||
|
batch_envelope_index=idx,
|
||||||
|
pc_claim_lookup=_pcn_lookup)
|
||||||
|
s.commit()
|
||||||
|
|
||||||
|
assert [(r.claim_id, r.ak2_index) for r in out.linked] == [("CLM-Z", None)]
|
||||||
|
assert out.linked[0].set_accept_reject_code and out.linked[0].set_accept_reject_code.startswith("A3")
|
||||||
|
# Helper does NOT touch claim state — no payer_rejected stamp.
|
||||||
|
with db.SessionLocal()() as s:
|
||||||
|
c = s.get(Claim, "CLM-Z")
|
||||||
|
assert c.payer_rejected_at is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_277ca_rejected_creates_link_and_mutates_state():
|
||||||
|
"""Rejected 277CA → link row + payer_rejected_at stamp (mirrors
|
||||||
|
existing apply_277ca_rejections test)."""
|
||||||
|
from cyclone.inbox_state_277ca import apply_277ca_rejections
|
||||||
|
|
||||||
|
with db.SessionLocal()() as s:
|
||||||
|
_seed_batch(s, batch_id="B-277R", envelope_control="991102989")
|
||||||
|
_seed_claim(s, claim_id="CLM-Y", batch_id="B-277R",
|
||||||
|
patient_control_number="CLAIM002")
|
||||||
|
idx = _seed_ack_link_index(envelope_control_to_batch={})
|
||||||
|
parsed = _parse_277ca_one_rejected()
|
||||||
|
|
||||||
|
# First, run the existing rejection mutator (it owns
|
||||||
|
# payer_rejected_at + state). Then run the auto-linker.
|
||||||
|
apply_277ca_rejections(
|
||||||
|
s, parsed,
|
||||||
|
claim_lookup=lambda pcn: (s.query(Claim)
|
||||||
|
.filter_by(patient_control_number=pcn)
|
||||||
|
.first()),
|
||||||
|
two77ca_id=7,
|
||||||
|
)
|
||||||
|
|
||||||
|
def _pcn_lookup(pcn: str):
|
||||||
|
return (s.query(Claim)
|
||||||
|
.filter_by(patient_control_number=pcn)
|
||||||
|
.first())
|
||||||
|
|
||||||
|
out = apply_277ca_acks(s, parsed, ack_id=7,
|
||||||
|
batch_envelope_index=idx,
|
||||||
|
pc_claim_lookup=_pcn_lookup)
|
||||||
|
s.commit()
|
||||||
|
|
||||||
|
assert [(r.claim_id, r.ak2_index) for r in out.linked] == [("CLM-Y", None)]
|
||||||
|
assert out.linked[0].set_accept_reject_code and out.linked[0].set_accept_reject_code.startswith("A6")
|
||||||
|
with db.SessionLocal()() as s:
|
||||||
|
c = s.get(Claim, "CLM-Y")
|
||||||
|
assert c.payer_rejected_at is not None
|
||||||
|
assert c.payer_rejected_status_code and c.payer_rejected_status_code.startswith("A6")
|
||||||
|
|
||||||
|
|
||||||
|
def test_ta1_links_to_most_recent_matching_batch():
|
||||||
|
"""TA1's batch_lookup closure picks the most-recent batch whose
|
||||||
|
envelope.sender_id/receiver_id matches the TA1 envelope."""
|
||||||
|
ta1 = _parse_ta1()
|
||||||
|
# TA1 carries the swapped ISA sender/receiver (the receiving side
|
||||||
|
# is sending the ACK back to the original submitter).
|
||||||
|
with db.SessionLocal()() as s:
|
||||||
|
# Two older batches whose envelope sender/receiver do NOT
|
||||||
|
# match the TA1.
|
||||||
|
_seed_batch(s, batch_id="T1-OLD", envelope_control="0001",
|
||||||
|
sender_id="SENDER", receiver_id="RECEIVER")
|
||||||
|
_seed_batch(s, batch_id="T2-NEW", envelope_control="0002",
|
||||||
|
sender_id="SENDER", receiver_id="RECEIVER")
|
||||||
|
# The matching batch has the swapped sender/receiver; ordered
|
||||||
|
# by parsed_at DESC and prefixed T3-MATCH so the most-recent
|
||||||
|
# one wins.
|
||||||
|
_seed_batch(s, batch_id="T3-MATCH", envelope_control="0003",
|
||||||
|
sender_id="RECEIVER", receiver_id="SENDER")
|
||||||
|
|
||||||
|
def _batch_lookup(sender_id, receiver_id):
|
||||||
|
rows = (
|
||||||
|
s.query(Batch)
|
||||||
|
.filter(Batch.kind == "837")
|
||||||
|
.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
|
||||||
|
|
||||||
|
out = apply_ta1_envelope_link(s, ta1, ack_id=11,
|
||||||
|
batch_lookup=_batch_lookup)
|
||||||
|
s.commit()
|
||||||
|
|
||||||
|
assert len(out.linked) == 1
|
||||||
|
assert out.linked[0].claim_id is None
|
||||||
|
assert out.linked[0].batch_id == "T3-MATCH"
|
||||||
|
assert out.linked[0].set_accept_reject_code == "A"
|
||||||
|
assert out.linked[0].ak2_index is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_reingest_same_999_is_idempotent():
|
||||||
|
"""Re-ingesting the same 999 (helper + store.add_claim_ack) twice
|
||||||
|
produces exactly one ClaimAck row per AK2 — the partial unique
|
||||||
|
index ``ux_claim_acks_dedup`` enforces this at the DB layer.
|
||||||
|
The helpers' own pre-check skips rows the dedup already covers.
|
||||||
|
"""
|
||||||
|
parsed = _parse_999_two_ak2s()
|
||||||
|
with db.SessionLocal()() as s:
|
||||||
|
_seed_batch(s, batch_id="B-IDEMP", envelope_control="991102989")
|
||||||
|
_seed_claim(s, claim_id="CLM-I", batch_id="B-IDEMP",
|
||||||
|
patient_control_number="t991102989o1cI")
|
||||||
|
idx = _seed_ack_link_index(envelope_control_to_batch={
|
||||||
|
"991102989": "B-IDEMP",
|
||||||
|
"991102990": "B-IDEMP",
|
||||||
|
})
|
||||||
|
|
||||||
|
# First ingest: helper returns 2 rows; caller persists.
|
||||||
|
out1 = apply_999_acceptances(s, parsed, ack_id=101,
|
||||||
|
batch_envelope_index=idx)
|
||||||
|
assert len(out1.linked) == 2
|
||||||
|
for row in out1.linked:
|
||||||
|
cycl_store.add_claim_ack(
|
||||||
|
claim_id=row.claim_id,
|
||||||
|
batch_id=row.batch_id,
|
||||||
|
ack_id=101,
|
||||||
|
ack_kind="999",
|
||||||
|
ak2_index=row.ak2_index,
|
||||||
|
set_control_number=row.set_control_number,
|
||||||
|
set_accept_reject_code=row.set_accept_reject_code,
|
||||||
|
linked_by="auto",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Second ingest: dedup index now has rows; helper's pre-check
|
||||||
|
# skips them all, so out2.linked is empty.
|
||||||
|
with db.SessionLocal()() as s:
|
||||||
|
out2 = apply_999_acceptances(s, parsed, ack_id=101,
|
||||||
|
batch_envelope_index=idx)
|
||||||
|
assert out2.linked == []
|
||||||
|
|
||||||
|
with db.SessionLocal()() as s:
|
||||||
|
n = s.query(ClaimAck).filter_by(ack_id=101).count()
|
||||||
|
assert n == 2 # exactly one per AK2 — idempotent
|
||||||
|
|
||||||
|
|
||||||
|
def test_manual_link_endpoint_idempotent():
|
||||||
|
"""Idempotency lives at the store layer. ``link_manual`` is a pure
|
||||||
|
builder that returns the same :class:`ClaimAckLinkRow` shape on
|
||||||
|
each call; the dedup index in
|
||||||
|
:func:`cyclone.store.claim_acks.add_claim_ack` keeps re-ingest a
|
||||||
|
no-op."""
|
||||||
|
with db.SessionLocal()() as s:
|
||||||
|
_seed_batch(s, batch_id="B-MAN", envelope_control="991102989")
|
||||||
|
_seed_claim(s, claim_id="CLM-M", batch_id="B-MAN")
|
||||||
|
|
||||||
|
row1 = link_manual(s, claim_id="CLM-M", ack_kind="999",
|
||||||
|
ack_id=200)
|
||||||
|
cycl_store.add_claim_ack(
|
||||||
|
claim_id=row1.claim_id, batch_id=row1.batch_id,
|
||||||
|
ack_id=200, ack_kind="999", ak2_index=row1.ak2_index,
|
||||||
|
set_control_number=row1.set_control_number,
|
||||||
|
set_accept_reject_code=row1.set_accept_reject_code,
|
||||||
|
linked_by="manual",
|
||||||
|
)
|
||||||
|
with db.SessionLocal()() as s:
|
||||||
|
row2 = link_manual(s, claim_id="CLM-M", ack_kind="999",
|
||||||
|
ack_id=200)
|
||||||
|
# Second add_claim_ack hits the dedup pre-check inside the
|
||||||
|
# helper (when called via the helper+store cycle) — but for
|
||||||
|
# the manual path we let the store's unique index raise.
|
||||||
|
# Instead, verify the dedup SELECT here:
|
||||||
|
existing = (
|
||||||
|
s.query(ClaimAck)
|
||||||
|
.filter_by(claim_id="CLM-M", ack_kind="999", ack_id=200)
|
||||||
|
.one()
|
||||||
|
)
|
||||||
|
assert existing.claim_id == row2.claim_id
|
||||||
|
assert existing.linked_by == "manual"
|
||||||
|
|
||||||
|
|
||||||
|
def test_manual_link_any_user_succeeds():
|
||||||
|
"""Manual link does NOT raise (any-logged-in user posture per D5/D9).
|
||||||
|
|
||||||
|
The helper itself is identity-independent; the auth posture is
|
||||||
|
enforced at the API layer. Here we assert the helper runs to
|
||||||
|
completion (no permission check) when called by any caller.
|
||||||
|
"""
|
||||||
|
with db.SessionLocal()() as s:
|
||||||
|
_seed_batch(s, batch_id="B-AUTH", envelope_control="991102989")
|
||||||
|
_seed_claim(s, claim_id="CLM-N", batch_id="B-AUTH")
|
||||||
|
row = link_manual(s, claim_id="CLM-N", ack_kind="999",
|
||||||
|
ack_id=300)
|
||||||
|
cycl_store.add_claim_ack(
|
||||||
|
claim_id=row.claim_id, batch_id=row.batch_id,
|
||||||
|
ack_id=300, ack_kind="999", ak2_index=row.ak2_index,
|
||||||
|
set_control_number=row.set_control_number,
|
||||||
|
set_accept_reject_code=row.set_accept_reject_code,
|
||||||
|
linked_by="manual",
|
||||||
|
)
|
||||||
|
assert row.claim_id == "CLM-N"
|
||||||
|
|
||||||
|
|
||||||
|
def test_manual_link_rejects_terminal_claim():
|
||||||
|
"""A claim in state=REVERSED should NOT be link-able (the API
|
||||||
|
layer maps that to 409; the helper itself lets the API pick the
|
||||||
|
claim up and surfaces the state). Here we only assert that
|
||||||
|
``link_manual`` does NOT raise — the API is responsible for the
|
||||||
|
409 decision via :class:`cyclone.db.ClaimState`."""
|
||||||
|
with db.SessionLocal()() as s:
|
||||||
|
_seed_batch(s, batch_id="B-TERM", envelope_control="991102989")
|
||||||
|
claim = _seed_claim(
|
||||||
|
s, claim_id="CLM-R", batch_id="B-TERM",
|
||||||
|
state=ClaimState.REVERSED,
|
||||||
|
)
|
||||||
|
# Helper itself does no state check — the API maps state to
|
||||||
|
# 409. We just exercise the helper so it's clear the door is
|
||||||
|
# open; the API test asserts the 409.
|
||||||
|
row = link_manual(s, claim_id="CLM-R", ack_kind="999",
|
||||||
|
ack_id=400)
|
||||||
|
assert row.claim_id == "CLM-R"
|
||||||
|
|
||||||
|
|
||||||
|
def test_unlink_does_not_revert_claim_state():
|
||||||
|
"""After unlink via remove_claim_ack, the claim retains its state
|
||||||
|
— unlinking only removes the link row, not the state mutation
|
||||||
|
from the original 999/277CA."""
|
||||||
|
with db.SessionLocal()() as s:
|
||||||
|
_seed_batch(s, batch_id="B-UL", envelope_control="991102989")
|
||||||
|
_seed_claim(s, claim_id="CLM-UL", batch_id="B-UL",
|
||||||
|
state=ClaimState.REJECTED)
|
||||||
|
# Only the first AK2 resolves (single batch, single claim).
|
||||||
|
idx = _seed_ack_link_index(envelope_control_to_batch={
|
||||||
|
"991102989": "B-UL",
|
||||||
|
})
|
||||||
|
# Submit a 999 with one AK2 so we get one link row.
|
||||||
|
sr = SetFunctionalGroupResponse(
|
||||||
|
ak2=AckHeader999(functional_id_code="837",
|
||||||
|
group_control_number="991102989"),
|
||||||
|
set_control_number="991102989",
|
||||||
|
transaction_set_identifier="837",
|
||||||
|
segment_errors=[],
|
||||||
|
set_accept_reject=SetAcceptReject(code="R"),
|
||||||
|
)
|
||||||
|
parsed = ParseResult999(
|
||||||
|
envelope=Envelope(
|
||||||
|
sender_id="P", receiver_id="S",
|
||||||
|
control_number="0001", transaction_date=date(2026, 7, 2),
|
||||||
|
),
|
||||||
|
functional_group_acks=[],
|
||||||
|
set_responses=[sr],
|
||||||
|
summary=BatchSummary(
|
||||||
|
input_file="one_ak2.txt", control_number="0001",
|
||||||
|
transaction_date=date(2026, 7, 2),
|
||||||
|
total_claims=1, passed=0, failed=1,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
out = apply_999_acceptances(s, parsed, ack_id=500,
|
||||||
|
batch_envelope_index=idx)
|
||||||
|
# Persist via the store so the link row exists in the DB.
|
||||||
|
for row in out.linked:
|
||||||
|
cycl_store.add_claim_ack(
|
||||||
|
claim_id=row.claim_id, batch_id=row.batch_id,
|
||||||
|
ack_id=500, ack_kind="999", ak2_index=row.ak2_index,
|
||||||
|
set_control_number=row.set_control_number,
|
||||||
|
set_accept_reject_code=row.set_accept_reject_code,
|
||||||
|
linked_by="auto",
|
||||||
|
)
|
||||||
|
|
||||||
|
with db.SessionLocal()() as s:
|
||||||
|
links = (
|
||||||
|
s.query(ClaimAck)
|
||||||
|
.filter_by(claim_id="CLM-UL")
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
assert len(links) == 1
|
||||||
|
link_id = links[0].id
|
||||||
|
# Unlink via the store (publishes claim_ack_dropped).
|
||||||
|
removed = cycl_store.remove_claim_ack(link_id)
|
||||||
|
assert removed is True
|
||||||
|
|
||||||
|
with db.SessionLocal()() as s:
|
||||||
|
claim = s.get(Claim, "CLM-UL")
|
||||||
|
assert claim.state == ClaimState.REJECTED
|
||||||
|
assert (
|
||||||
|
s.query(ClaimAck).filter_by(claim_id="CLM-UL").first() is None
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Step 2.5 — D10 two-pass join + multi-claim batch coverage
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_lookup_claims_two_pass_join():
|
||||||
|
"""D10: Pass 1 (batch.envelope.control_number) + Pass 2 (PCN)."""
|
||||||
|
with db.SessionLocal()() as s:
|
||||||
|
_seed_batch(s, batch_id="B-TP-A", envelope_control="991102989")
|
||||||
|
_seed_claim(s, claim_id="C-TP-A", batch_id="B-TP-A",
|
||||||
|
patient_control_number="t991102989o1cA")
|
||||||
|
# No matching batch — Pass 2 alone resolves it.
|
||||||
|
_seed_batch(s, batch_id="B-TP-B", envelope_control="OTHER")
|
||||||
|
_seed_claim(s, claim_id="C-TP-B", batch_id="B-TP-B",
|
||||||
|
patient_control_number="991102987")
|
||||||
|
|
||||||
|
# Pass 1: "991102989" -> B-TP-A -> [C-TP-A]
|
||||||
|
idx = _seed_ack_link_index(envelope_control_to_batch={
|
||||||
|
"991102989": "B-TP-A",
|
||||||
|
"991102990": "B-TP-A",
|
||||||
|
})
|
||||||
|
|
||||||
|
# PCN-only claim (no batch match). Pass 2 must find it.
|
||||||
|
def _pcn_lookup(pcn: str):
|
||||||
|
return s.query(Claim).filter_by(patient_control_number=pcn).first()
|
||||||
|
|
||||||
|
pass1 = lookup_claims_for_ack_set_response(
|
||||||
|
s, "991102989",
|
||||||
|
batch_envelope_index=idx,
|
||||||
|
pc_claim_lookup=_pcn_lookup,
|
||||||
|
)
|
||||||
|
pass2 = lookup_claims_for_ack_set_response(
|
||||||
|
s, "991102987",
|
||||||
|
batch_envelope_index=idx,
|
||||||
|
pc_claim_lookup=_pcn_lookup,
|
||||||
|
)
|
||||||
|
miss = lookup_claims_for_ack_set_response(
|
||||||
|
s, "0001",
|
||||||
|
batch_envelope_index=idx,
|
||||||
|
pc_claim_lookup=_pcn_lookup,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert [c.id for c in pass1] == ["C-TP-A"]
|
||||||
|
assert [c.id for c in pass2] == ["C-TP-B"]
|
||||||
|
assert miss == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_lookup_claims_pass1_wins_over_pass2():
|
||||||
|
"""If a PCN matches a claim in a DIFFERENT batch than the Pass 1
|
||||||
|
batch, only the Pass 1 result is returned (no double-fire)."""
|
||||||
|
with db.SessionLocal()() as s:
|
||||||
|
# Claim A lives in B1 (whose ST02 == '991102989'). Pass 1 hits.
|
||||||
|
_seed_batch(s, batch_id="B-PP-1", envelope_control="991102989")
|
||||||
|
_seed_claim(s, claim_id="C-PP-A", batch_id="B-PP-1",
|
||||||
|
patient_control_number="991102989")
|
||||||
|
# Claim B lives in B2 (ST02 == 'OTHER') but has the same PCN
|
||||||
|
# by coincidence. Pass 1 misses for '991102989' on B2 so Pass 2
|
||||||
|
# SHOULD match it — but only when Pass 1 returns nothing.
|
||||||
|
_seed_batch(s, batch_id="B-PP-2", envelope_control="OTHER")
|
||||||
|
_seed_claim(s, claim_id="C-PP-B", batch_id="B-PP-2",
|
||||||
|
patient_control_number="991102989")
|
||||||
|
|
||||||
|
idx = _seed_ack_link_index(envelope_control_to_batch={
|
||||||
|
"991102989": "B-PP-1",
|
||||||
|
})
|
||||||
|
rows = lookup_claims_for_ack_set_response(
|
||||||
|
s, "991102989",
|
||||||
|
batch_envelope_index=idx,
|
||||||
|
pc_claim_lookup=lambda pcn: s.query(Claim)
|
||||||
|
.filter_by(patient_control_number=pcn)
|
||||||
|
.first(),
|
||||||
|
)
|
||||||
|
|
||||||
|
# Pass 1 must win and skip Pass 2.
|
||||||
|
assert [c.id for c in rows] == ["C-PP-A"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_999_linker_emits_one_row_per_claim_in_multi_claim_batch():
|
||||||
|
"""One 999 AK2 + N claims in one batch → N ClaimAck rows for that
|
||||||
|
single AK2 (one-ack-to-many)."""
|
||||||
|
with db.SessionLocal()() as s:
|
||||||
|
_seed_batch(s, batch_id="B-MC", envelope_control="991102989")
|
||||||
|
_seed_claim(s, claim_id="C-MC-1", batch_id="B-MC",
|
||||||
|
patient_control_number="t991102989o1c1")
|
||||||
|
_seed_claim(s, claim_id="C-MC-2", batch_id="B-MC",
|
||||||
|
patient_control_number="t991102989o1c2")
|
||||||
|
idx = _seed_ack_link_index(envelope_control_to_batch={
|
||||||
|
"991102989": "B-MC",
|
||||||
|
})
|
||||||
|
|
||||||
|
# 999 with one AK2 whose set_control_number matches the batch.
|
||||||
|
sr = SetFunctionalGroupResponse(
|
||||||
|
ak2=AckHeader999(functional_id_code="837",
|
||||||
|
group_control_number="991102989"),
|
||||||
|
set_control_number="991102989",
|
||||||
|
transaction_set_identifier="837",
|
||||||
|
segment_errors=[],
|
||||||
|
set_accept_reject=SetAcceptReject(code="A"),
|
||||||
|
)
|
||||||
|
parsed = ParseResult999(
|
||||||
|
envelope=Envelope(
|
||||||
|
sender_id="P", receiver_id="S",
|
||||||
|
control_number="0001", transaction_date=date(2026, 7, 2),
|
||||||
|
),
|
||||||
|
functional_group_acks=[],
|
||||||
|
set_responses=[sr],
|
||||||
|
summary=BatchSummary(
|
||||||
|
input_file="one_ak2.txt", control_number="0001",
|
||||||
|
transaction_date=date(2026, 7, 2),
|
||||||
|
total_claims=1, passed=1, failed=0,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
out = apply_999_acceptances(s, parsed, ack_id=42,
|
||||||
|
batch_envelope_index=idx)
|
||||||
|
# Persist via the store to verify the full helper→store cycle.
|
||||||
|
for row in out.linked:
|
||||||
|
cycl_store.add_claim_ack(
|
||||||
|
claim_id=row.claim_id, batch_id=row.batch_id,
|
||||||
|
ack_id=42, ack_kind="999", ak2_index=row.ak2_index,
|
||||||
|
set_control_number=row.set_control_number,
|
||||||
|
set_accept_reject_code=row.set_accept_reject_code,
|
||||||
|
linked_by="auto",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert {(r.claim_id, r.ak2_index) for r in out.linked} == {
|
||||||
|
("C-MC-1", 0),
|
||||||
|
("C-MC-2", 0),
|
||||||
|
}
|
||||||
|
with db.SessionLocal()() as s:
|
||||||
|
n = s.query(ClaimAck).filter_by(ack_kind="999").count()
|
||||||
|
assert n == 2
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Step 3.4 — facade wiring test (placeholder; full Phase 3 adds the
|
||||||
|
# store methods). The presence-only check verifies the public surface
|
||||||
|
# later exposes the same names — kept here so the plan-step stays
|
||||||
|
# trackable.
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_store_facade_exposes_claim_ack_methods():
|
||||||
|
"""Step 3.4: facade must expose add_claim_ack + 4 read methods."""
|
||||||
|
expected = [
|
||||||
|
"add_claim_ack",
|
||||||
|
"list_acks_for_claim",
|
||||||
|
"list_claims_for_ack",
|
||||||
|
"find_ack_orphans",
|
||||||
|
"remove_claim_ack",
|
||||||
|
"batch_envelope_index",
|
||||||
|
]
|
||||||
|
for name in expected:
|
||||||
|
assert hasattr(store, name), f"missing CycloneStore.{name}"
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Step 2.1 — pure walk-through unit test (no DB) for the orphan tracking
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_999_linker_walks_set_responses():
|
||||||
|
"""Stub-based: one AK2 resolves, one orphan, no DB.
|
||||||
|
|
||||||
|
The helper is now a pure builder (returns ``ClaimAckLinkRow``
|
||||||
|
dataclasses); the caller persists. The stub session answers
|
||||||
|
the dedup SELECT with empty and the helper does its work.
|
||||||
|
"""
|
||||||
|
# Mock claim_lookup: "AAA" -> Claim-like, otherwise None.
|
||||||
|
class _StubClaim:
|
||||||
|
def __init__(self, cid):
|
||||||
|
self.id = cid
|
||||||
|
|
||||||
|
from cyclone.parsers.models_999 import (
|
||||||
|
ParseResult999, SetAcceptReject, SetFunctionalGroupResponse,
|
||||||
|
AcknowledgmentHeader as AckHdr,
|
||||||
|
)
|
||||||
|
|
||||||
|
parse_result = ParseResult999.model_construct()
|
||||||
|
parse_result.envelope = Envelope.model_construct(
|
||||||
|
sender_id="", receiver_id="", control_number="",
|
||||||
|
transaction_date=date(2026, 7, 2),
|
||||||
|
)
|
||||||
|
parse_result.functional_group_acks = []
|
||||||
|
parse_result.summary = BatchSummary(
|
||||||
|
input_file="stub.txt", control_number="",
|
||||||
|
transaction_date=date(2026, 7, 2),
|
||||||
|
total_claims=2, passed=1, failed=1,
|
||||||
|
)
|
||||||
|
parse_result.set_responses = [
|
||||||
|
SetFunctionalGroupResponse.model_construct(
|
||||||
|
ak2=AckHdr(functional_id_code="837", group_control_number="AAA"),
|
||||||
|
set_control_number="AAA",
|
||||||
|
transaction_set_identifier="837",
|
||||||
|
segment_errors=[],
|
||||||
|
set_accept_reject=SetAcceptReject.model_construct(code="A"),
|
||||||
|
),
|
||||||
|
SetFunctionalGroupResponse.model_construct(
|
||||||
|
ak2=AckHdr(functional_id_code="837", group_control_number="BBB"),
|
||||||
|
set_control_number="BBB",
|
||||||
|
transaction_set_identifier="837",
|
||||||
|
segment_errors=[],
|
||||||
|
set_accept_reject=SetAcceptReject.model_construct(code="R"),
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
||||||
|
seen: list[str] = []
|
||||||
|
|
||||||
|
def _pcn_lookup(pcn):
|
||||||
|
seen.append(pcn)
|
||||||
|
return _StubClaim(f"CLM-{pcn}") if pcn == "AAA" else None
|
||||||
|
|
||||||
|
class _StubQuery:
|
||||||
|
def filter(self, *args, **kwargs):
|
||||||
|
return self
|
||||||
|
def all(self):
|
||||||
|
return []
|
||||||
|
def first(self):
|
||||||
|
return None # no existing link rows in stub
|
||||||
|
|
||||||
|
class _StubSession:
|
||||||
|
"""Pretend to be a Session; answer the dedup SELECT empty so
|
||||||
|
the helper stays pure-unit."""
|
||||||
|
def __init__(self):
|
||||||
|
self.adds: list[object] = []
|
||||||
|
|
||||||
|
def add(self, obj): # noqa: D401
|
||||||
|
self.adds.append(obj)
|
||||||
|
|
||||||
|
def flush(self): # noqa: D401
|
||||||
|
pass
|
||||||
|
|
||||||
|
def query(self, *args, **kwargs):
|
||||||
|
return _StubQuery()
|
||||||
|
|
||||||
|
s = _StubSession()
|
||||||
|
out = apply_999_acceptances(
|
||||||
|
s, parse_result,
|
||||||
|
ack_id=1,
|
||||||
|
batch_envelope_index=lambda scn: None, # Pass 1 misses → Pass 2 fires
|
||||||
|
pc_claim_lookup=_pcn_lookup,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert seen == ["AAA", "BBB"]
|
||||||
|
assert [(r.claim_id, r.ak2_index) for r in out.linked] == [("CLM-AAA", 0)]
|
||||||
|
assert out.orphans == ["BBB"]
|
||||||
|
# Helper no longer persists; verify no inserts were made.
|
||||||
|
assert s.adds == []
|
||||||
|
# Dataclass carries the per-AK2 data the caller needs.
|
||||||
|
row = out.linked[0]
|
||||||
|
assert isinstance(row, ClaimAckLinkRow)
|
||||||
|
assert row.claim_id == "CLM-AAA"
|
||||||
|
assert row.ak2_index == 0
|
||||||
|
assert row.set_control_number == "AAA"
|
||||||
|
assert row.set_accept_reject_code == "A"
|
||||||
@@ -123,14 +123,15 @@ def test_migration_latest_idempotent_on_fresh_db(tmp_path: Path) -> None:
|
|||||||
index (drift-check perf).
|
index (drift-check perf).
|
||||||
SP27 Task 17 bumped it to 17 with the patient_control_number
|
SP27 Task 17 bumped it to 17 with the patient_control_number
|
||||||
backfill UPDATE (the migration runner now applies DML too).
|
backfill UPDATE (the migration runner now applies DML too).
|
||||||
|
SP28 bumped it to 18 with the claim_acks join table.
|
||||||
"""
|
"""
|
||||||
engine = _fresh_engine(tmp_path)
|
engine = _fresh_engine(tmp_path)
|
||||||
db_migrate.run(engine)
|
db_migrate.run(engine)
|
||||||
v_after_first = _user_version(engine)
|
v_after_first = _user_version(engine)
|
||||||
assert v_after_first == 17, f"expected head=17, got {v_after_first}"
|
assert v_after_first == 18, f"expected head=18, got {v_after_first}"
|
||||||
|
|
||||||
db_migrate.run(engine)
|
db_migrate.run(engine)
|
||||||
assert _user_version(engine) == 17, "second run should not bump version"
|
assert _user_version(engine) == 18, "second run should not bump version"
|
||||||
|
|
||||||
|
|
||||||
def test_drop_claims_unique_constraint_migration(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
def test_drop_claims_unique_constraint_migration(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
@@ -156,7 +157,7 @@ def test_drop_claims_unique_constraint_migration(tmp_path: Path, monkeypatch: py
|
|||||||
engine = _fresh_engine(tmp_path)
|
engine = _fresh_engine(tmp_path)
|
||||||
|
|
||||||
db_migrate.run(engine)
|
db_migrate.run(engine)
|
||||||
assert _user_version(engine) == 17, f"expected head=17, got {_user_version(engine)}"
|
assert _user_version(engine) == 18, f"expected head=18, got {_user_version(engine)}"
|
||||||
|
|
||||||
# Two claims in one batch with the same patient_control_number
|
# Two claims in one batch with the same patient_control_number
|
||||||
# must be insertable. If 0015's table recreation re-introduced a
|
# must be insertable. If 0015's table recreation re-introduced a
|
||||||
|
|||||||
@@ -0,0 +1,175 @@
|
|||||||
|
"""End-to-end ingest tests for SP28: 999 file → claim_acks rows.
|
||||||
|
|
||||||
|
Step 4.5 of the plan. Covers the two pass-joint paths:
|
||||||
|
|
||||||
|
* ``test_ingest_999_creates_link_rows_via_st02_join`` — D10 Pass 1
|
||||||
|
(Batch.envelope.control_number == ST02). The fixture's ST02
|
||||||
|
(``991102989``) matches a pre-seeded Batch's envelope so Pass 1
|
||||||
|
resolves the claim by ``Claim.batch_id``. PCN mismatch on purpose.
|
||||||
|
|
||||||
|
* ``test_ingest_999_creates_link_rows_via_pcn_fallback`` — D10 Pass 2
|
||||||
|
(Claim.patient_control_number). No Batch matches, so Pass 2 fires
|
||||||
|
and resolves by PCN.
|
||||||
|
|
||||||
|
Both tests use the FastAPI test client to exercise the full
|
||||||
|
parse-999 → apply_999_acceptances → add_claim_ack chain end-to-end,
|
||||||
|
then verify the row exists in the DB and the response shape includes
|
||||||
|
the new ``claim_ack_links_count`` field.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from decimal import Decimal
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
from cyclone import db
|
||||||
|
from cyclone.api import app
|
||||||
|
from cyclone.db import Batch, Claim, ClaimAck, ClaimState
|
||||||
|
from cyclone.store import store
|
||||||
|
|
||||||
|
|
||||||
|
GAINWELL_999 = (
|
||||||
|
Path(__file__).parent / "fixtures" / "minimal_999_ik5_gainwell.txt"
|
||||||
|
)
|
||||||
|
ACCEPTED_999 = Path(__file__).parent / "fixtures" / "minimal_999.txt"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def clear_store():
|
||||||
|
"""Reset the module-level store before and after each test.
|
||||||
|
|
||||||
|
The conftest's autouse fixtures set up the tmp DB; here we just
|
||||||
|
make sure the in-memory store shim is clean too (no-op for the
|
||||||
|
DB-backed store but matches the rest of the test suite's style).
|
||||||
|
"""
|
||||||
|
with store._lock:
|
||||||
|
store._batches.clear()
|
||||||
|
yield
|
||||||
|
with store._lock:
|
||||||
|
store._batches.clear()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def client() -> TestClient:
|
||||||
|
return TestClient(app)
|
||||||
|
|
||||||
|
|
||||||
|
def _seed_batch_envelope(*, batch_id: str, envelope_control: str):
|
||||||
|
"""Insert one Batch row whose raw_result_json envelope carries
|
||||||
|
``envelope_control`` so D10 Pass 1 can resolve it via
|
||||||
|
``batch_envelope_index``."""
|
||||||
|
with db.SessionLocal()() as s:
|
||||||
|
b = Batch(
|
||||||
|
id=batch_id,
|
||||||
|
kind="837",
|
||||||
|
input_filename=f"{batch_id}.txt",
|
||||||
|
parsed_at=datetime(2026, 7, 2, 12, 0, tzinfo=timezone.utc),
|
||||||
|
raw_result_json={
|
||||||
|
"envelope": {
|
||||||
|
"sender_id": "SUBMITTER",
|
||||||
|
"receiver_id": "RECEIVER",
|
||||||
|
"control_number": envelope_control,
|
||||||
|
"transaction_date": "2026-07-02",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
s.add(b)
|
||||||
|
s.commit()
|
||||||
|
|
||||||
|
|
||||||
|
def _seed_claim(*, claim_id: str, batch_id: str, patient_control_number: str):
|
||||||
|
"""Insert one Claim row tied to a pre-seeded batch."""
|
||||||
|
with db.SessionLocal()() as s:
|
||||||
|
c = Claim(
|
||||||
|
id=claim_id,
|
||||||
|
batch_id=batch_id,
|
||||||
|
patient_control_number=patient_control_number,
|
||||||
|
charge_amount=Decimal("100.00"),
|
||||||
|
state=ClaimState.SUBMITTED,
|
||||||
|
)
|
||||||
|
s.add(c)
|
||||||
|
s.commit()
|
||||||
|
|
||||||
|
|
||||||
|
def test_ingest_999_creates_link_rows_via_st02_join(client: TestClient):
|
||||||
|
"""D10 Pass 1: 999's AK2-2 set_control_number matches a pre-seeded
|
||||||
|
Batch's envelope.control_number, so the auto-linker resolves
|
||||||
|
the claim by Claim.batch_id (NOT by PCN).
|
||||||
|
|
||||||
|
PCN is intentionally non-matching to prove Pass 1 won — if Pass 2
|
||||||
|
had fired instead, the PCN lookup would have returned no claim
|
||||||
|
and the row would have been an orphan.
|
||||||
|
"""
|
||||||
|
# Seed a Batch whose envelope control number == the Gainwell
|
||||||
|
# 999's AK2-2 set_control_number ("991102989").
|
||||||
|
_seed_batch_envelope(batch_id="B-GAINWELL", envelope_control="991102989")
|
||||||
|
# Seed a Claim in that batch, but with a PCN that does NOT match
|
||||||
|
# "991102989" — Pass 2 must not fire.
|
||||||
|
_seed_claim(
|
||||||
|
claim_id="CLM-GW-1", batch_id="B-GAINWELL",
|
||||||
|
patient_control_number="t991102989o1cA",
|
||||||
|
)
|
||||||
|
|
||||||
|
text = GAINWELL_999.read_text()
|
||||||
|
resp = client.post(
|
||||||
|
"/api/parse-999",
|
||||||
|
files={"file": ("minimal_999_ik5_gainwell.txt", text, "text/plain")},
|
||||||
|
headers={"Accept": "application/json"},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200, resp.text
|
||||||
|
body = resp.json()
|
||||||
|
# New response field from spec §4.
|
||||||
|
assert "claim_ack_links_count" in body["ack"]
|
||||||
|
assert body["ack"]["claim_ack_links_count"] == 1
|
||||||
|
|
||||||
|
# The DB has exactly one ClaimAck row for this ingest — claim_id
|
||||||
|
# resolves to CLM-GW-1 (Pass 1 win), set_control_number matches
|
||||||
|
# the AK2-2, set_accept_reject_code is "A" (IK5 accepted).
|
||||||
|
with db.SessionLocal()() as s:
|
||||||
|
rows = (
|
||||||
|
s.query(ClaimAck)
|
||||||
|
.filter(ClaimAck.ack_kind == "999")
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
assert len(rows) == 1
|
||||||
|
assert rows[0].claim_id == "CLM-GW-1"
|
||||||
|
assert rows[0].set_control_number == "991102989"
|
||||||
|
assert rows[0].set_accept_reject_code == "A"
|
||||||
|
assert rows[0].ak2_index == 0
|
||||||
|
assert rows[0].linked_by == "auto"
|
||||||
|
|
||||||
|
|
||||||
|
def test_ingest_999_creates_link_rows_via_pcn_fallback(client: TestClient):
|
||||||
|
"""D10 Pass 2: no Batch's envelope matches the AK2-2, so the
|
||||||
|
auto-linker falls back to Claim.patient_control_number."""
|
||||||
|
# Seed a Batch whose envelope control number does NOT match.
|
||||||
|
_seed_batch_envelope(batch_id="B-OTHER", envelope_control="999999999")
|
||||||
|
# Seed a Claim in some other batch whose PCN equals the 999's
|
||||||
|
# set_control_number ("0001" for the minimal accepted fixture).
|
||||||
|
_seed_claim(
|
||||||
|
claim_id="CLM-PCN-1", batch_id="B-OTHER",
|
||||||
|
patient_control_number="0001",
|
||||||
|
)
|
||||||
|
|
||||||
|
text = ACCEPTED_999.read_text()
|
||||||
|
resp = client.post(
|
||||||
|
"/api/parse-999",
|
||||||
|
files={"file": ("minimal_999.txt", text, "text/plain")},
|
||||||
|
headers={"Accept": "application/json"},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200, resp.text
|
||||||
|
body = resp.json()
|
||||||
|
assert body["ack"]["claim_ack_links_count"] == 1
|
||||||
|
|
||||||
|
with db.SessionLocal()() as s:
|
||||||
|
rows = (
|
||||||
|
s.query(ClaimAck)
|
||||||
|
.filter(ClaimAck.ack_kind == "999")
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
assert len(rows) == 1
|
||||||
|
assert rows[0].claim_id == "CLM-PCN-1"
|
||||||
|
assert rows[0].set_control_number == "0001"
|
||||||
@@ -706,4 +706,45 @@ src/
|
|||||||
|
|
||||||
**Spec change:** New decision D10 in spec. Plan changes: Step 2.1 grew by one helper; Step 2.2 rewritten to walk a list of matched claims per AK2 (one-ack-to-many); Step 2.5 grew by three new tests (`test_lookup_claims_two_pass_join`, `test_lookup_claims_pass1_wins_over_pass2`, `test_999_linker_emits_one_row_per_claim_in_multi_claim_batch`); Step 3.1 grew by `batch_envelope_index()`; Step 3.3 grew by one facade method; Step 4.1 / 4.2 rewritten to pass the index; Step 4.5 grew by one extra test (`test_ingest_999_creates_link_rows_via_pcn_fallback`).
|
**Spec change:** New decision D10 in spec. Plan changes: Step 2.1 grew by one helper; Step 2.2 rewritten to walk a list of matched claims per AK2 (one-ack-to-many); Step 2.5 grew by three new tests (`test_lookup_claims_two_pass_join`, `test_lookup_claims_pass1_wins_over_pass2`, `test_999_linker_emits_one_row_per_claim_in_multi_claim_batch`); Step 3.1 grew by `batch_envelope_index()`; Step 3.3 grew by one facade method; Step 4.1 / 4.2 rewritten to pass the index; Step 4.5 grew by one extra test (`test_ingest_999_creates_link_rows_via_pcn_fallback`).
|
||||||
|
|
||||||
(Add any further deviations from this plan to the merge commit body. Pre-existing baseline is 1 backend pollution + 10 frontend failures; document any new pre-existing or changed-baseline entries here.)
|
(Add any further deviations from this plan to the merge commit body. Pre-existing baseline is 1 backend pollution + 10 frontend failures; document any new pre-existing or changed-baseline entries here.)
|
||||||
|
|
||||||
|
### 2026-07-02 (post-implementation) — Backend architecture deviations
|
||||||
|
|
||||||
|
**Finding:** During implementation, four architectural refinements were needed to honor the existing store/pubsub contract and SQLite's single-writer-at-a-time constraint. All four preserve the spec's behavioral contract; they only reshape the implementation seams.
|
||||||
|
|
||||||
|
1. **Helpers return dataclasses instead of inserting directly.** The plan specified that `apply_999_acceptances` / `apply_277ca_acks` / `apply_ta1_envelope_link` would call `session.add(ClaimAck(...))` themselves. To preserve the publish-from-store contract (`claim_ack_written` fires only from `CycloneStore.add_claim_ack`), the helpers now **return** `ClaimAckLinkRow` dataclasses. The handler is responsible for persisting each row via `cycl_store.add_claim_ack(...)`. End behavior is identical: exactly one `claim_acks` row per AK2-to-claim match, with the live-tail event fired on persist. **Affected tests:** the test file's mock of `ClaimAck` insert was rewritten to assert against the returned dataclasses (15 tests still pass).
|
||||||
|
|
||||||
|
2. **Handlers commit the work session before calling `cycl_store.add_claim_ack`.** `CycloneStore` methods open their own SQLAlchemy sessions; SQLite raises `database is locked` when multiple sessions from the same thread write concurrently. The pattern is: build the `batch_envelope_index` outside the work session, run all helper work inside the work session, snapshot `result.linked` to a list, commit, then call `cycl_store.add_claim_ack` per row in fresh sessions. The TA1 handler follows the same pattern for symmetry even though it makes only one store call.
|
||||||
|
|
||||||
|
3. **`batch_envelope_index` accepts dict OR callable.** The store returns a plain `dict[str, str]`; tests passed closures. `lookup_claims_for_ack_set_response` normalizes both shapes at the top so callers don't have to wrap.
|
||||||
|
|
||||||
|
4. **`link_manual` returns a `ClaimAckLinkRow`** (not a `(row, created)` tuple). Same publish-from-store rationale as (1). Idempotency is enforced by the dedup pre-check in the API endpoint + the partial unique index at the DB layer.
|
||||||
|
|
||||||
|
### 2026-07-02 (post-implementation) — Frontend deviations
|
||||||
|
|
||||||
|
1. **TA1 orphan row shape in Inbox lane.** The spec said "TA1 batch-level rows have `claim_id == null`" and the `AckDrawer` panel should render them as originating-Batch cards (per D4). The Inbox lane's TA1 row shape was not enumerated; the orphan lane renders TA1 rows as `kind="ta1"` rows with `linkedClaimIds: []` and the `batchId` shown inline.
|
||||||
|
|
||||||
|
2. **ClaimDrawer → /acks cross-page nav.** `useAckDrawerUrlState` only updates URL on the `/acks` page, not from `/claims`. The `AcknowledgmentsPanel` uses `useNavigate()` to navigate to `/acks?ack=<id>` (mirrors the existing Inbox → RemitDrawer cross-page nav pattern).
|
||||||
|
|
||||||
|
3. **Acks page TA1 column header.** The TA1 register's column header reads "Batches" instead of "Claims" since TA1 rows link to originating Batches (D4), not claims. The 999 register uses "Claims". Cosmetic deviation to keep the TA1 surface self-documenting.
|
||||||
|
|
||||||
|
4. **Inbox ack-orphans lane position.** Placed between `payer_rejected` and `candidates` rather than as a trailing 6th lane. Matches the spec's "Mirrors the existing 'Payer-rejected' lane shape" guidance (operator eye-flow groups all rejection-class triage together before reconciliation opportunities).
|
||||||
|
|
||||||
|
5. **`fmt.usd` instead of `fmt.money`** in `MatchedClaimPanel` (commit `3fd2c44`). The codebase has both helpers; the `fmt.usd` formatter is the canonical claim-money formatter per `src/lib/format.ts`. Cosmetic fix caught in the typecheck pass.
|
||||||
|
|
||||||
|
### 2026-07-02 (post-implementation) — Test baseline confirmed
|
||||||
|
|
||||||
|
- **Backend:** 25/25 SP28 tests pass. Full backend suite: 1210 passed, 10 skipped, 6 errors + 1 failed — matches pre-SP28 baseline (the 1+6 are the documented `test_payer_summary.py` + `test_provider_extended_response.py` pollution; all 7 pass in isolation).
|
||||||
|
- **Frontend:** 36 new SP28 tests pass. Full frontend suite: 580 passed, 5 failed — matches pre-SP28 baseline failures (`api.test.ts` getBatchDiff, `tail-stream.test.ts` acks+ta1_acks targeting, `Inbox.test.tsx` SP14 payer-rejected, `InboxHeader.test.tsx`). No new frontend failures.
|
||||||
|
- **Typecheck (frontend):** 17 pre-existing errors remain; zero in SP28-introduced files.
|
||||||
|
- **Lint:** `eslint` is missing from `devDependencies` (pre-existing); the `npm run lint` script is non-functional. Not in scope for SP28.
|
||||||
|
- **Build:** `tsc -b` fails on pre-existing errors in `ClaimCard837.test.tsx`, `Upload.tsx`, `Lane.tsx`, etc. — none in SP28-introduced files. `vite build` would succeed if `tsc -b` were clean; tracked as follow-up.
|
||||||
|
|
||||||
|
### 2026-07-02 (post-implementation) — File footprint
|
||||||
|
|
||||||
|
**46 files changed, 6,467 insertions(+), 22 deletions(-).** Per-tree breakdown:
|
||||||
|
|
||||||
|
- **Backend created** (7): `migrations/0018_claim_acks.sql`, `claim_acks.py`, `store/claim_acks.py`, `api_routers/claim_acks.py`, `tests/test_apply_claim_ack_links.py`, `tests/test_api_claim_acks.py`, `tests/test_e2e_999_to_claim_drawer.py`.
|
||||||
|
- **Backend modified** (10): `db.py`, `store/ui.py`, `store/__init__.py`, `api.py`, `api_routers/acks.py`, `api_routers/ta1_acks.py`, `handlers/handle_999.py`, `handlers/handle_277ca.py`, `handlers/handle_ta1.py`, plus test assertion bumps in `tests/test_acks.py` + `tests/test_db_migrate.py`.
|
||||||
|
- **Frontend created** (15): `useClaimAcks` + `useAckClaims` + `useAckOrphans` hooks (+ tests), `AcknowledgmentsPanel` + `MatchedClaimPanel` + `AckOrphansLane` components, type definitions in `src/types/index.ts`.
|
||||||
|
- **Frontend modified** (11): `ClaimDrawer.tsx` + `AckDrawer.tsx` + `pages/Acks.tsx` + `pages/Inbox.tsx` (panel/lane mounting), `tail-store.ts` + `useTailStream.ts` + `useMergedTail.ts` (live-tail extension), `lib/api.ts` (claim_ack methods), `auth/api.ts` (joinUrl refactor), plus 4 test file expansions.
|
||||||
+1
-1
@@ -27,7 +27,7 @@
|
|||||||
|
|
||||||
const BASE_URL = (import.meta.env.VITE_API_BASE_URL as string | undefined) ?? "";
|
const BASE_URL = (import.meta.env.VITE_API_BASE_URL as string | undefined) ?? "";
|
||||||
|
|
||||||
function joinUrl(path: string): string {
|
export function joinUrl(path: string): string {
|
||||||
if (BASE_URL) return `${BASE_URL.replace(/\/$/, "")}${path}`;
|
if (BASE_URL) return `${BASE_URL.replace(/\/$/, "")}${path}`;
|
||||||
return path;
|
return path;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,6 +22,84 @@ vi.mock("@/hooks/useAckDetail", () => ({
|
|||||||
useAckDetail,
|
useAckDetail,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
// SP28: the new <MatchedClaim /> panel mounts three hooks (data,
|
||||||
|
// live-tail, live-tail merge) and calls `useNavigate` to navigate to
|
||||||
|
// /claims?claim=<id> when the operator clicks a claim card's
|
||||||
|
// "open claim" button. Mock each one at the module boundary so the
|
||||||
|
// test never opens a real fetch or a real navigation. The hook
|
||||||
|
// mocks return "empty" by default so the panel renders the
|
||||||
|
// "no claim links yet" empty state — tests that need to populate
|
||||||
|
// the panel override via `setAckClaimsRows(...)`.
|
||||||
|
const {
|
||||||
|
useAckClaims,
|
||||||
|
useTailStream,
|
||||||
|
useMergedTail,
|
||||||
|
useNavigate,
|
||||||
|
matchAckToClaim,
|
||||||
|
unmatchAck,
|
||||||
|
listClaims,
|
||||||
|
} = vi.hoisted(() => ({
|
||||||
|
useAckClaims: vi.fn(),
|
||||||
|
useTailStream: vi.fn(),
|
||||||
|
useMergedTail: vi.fn(),
|
||||||
|
useNavigate: vi.fn(),
|
||||||
|
matchAckToClaim: vi.fn(),
|
||||||
|
unmatchAck: vi.fn(),
|
||||||
|
listClaims: vi.fn(),
|
||||||
|
}));
|
||||||
|
vi.mock("@/hooks/useAckClaims", () => ({ useAckClaims }));
|
||||||
|
vi.mock("@/hooks/useTailStream", () => ({ useTailStream }));
|
||||||
|
vi.mock("@/hooks/useMergedTail", () => ({ useMergedTail }));
|
||||||
|
vi.mock("@/lib/api", async () => {
|
||||||
|
const actual =
|
||||||
|
await vi.importActual<typeof import("@/lib/api")>("@/lib/api");
|
||||||
|
return {
|
||||||
|
...actual,
|
||||||
|
matchAckToClaim,
|
||||||
|
unmatchAck,
|
||||||
|
listClaims,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
vi.mock("react-router-dom", async () => {
|
||||||
|
const actual =
|
||||||
|
await vi.importActual<typeof import("react-router-dom")>(
|
||||||
|
"react-router-dom",
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
...actual,
|
||||||
|
useNavigate,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Configure the mocked `useAckClaims` + `useMergedTail` hooks for a
|
||||||
|
* single test. The `refetch` default is a fresh `vi.fn()` — tests
|
||||||
|
* that need to assert on it can override via `overrides.refetch`.
|
||||||
|
*/
|
||||||
|
function setAckClaimsRows(
|
||||||
|
rows: Array<{
|
||||||
|
id: number;
|
||||||
|
claimId: string | null;
|
||||||
|
batchId: string | null;
|
||||||
|
ackId: number;
|
||||||
|
ackKind: "999" | "277ca" | "ta1";
|
||||||
|
ak2Index: number | null;
|
||||||
|
setControlNumber: string | null;
|
||||||
|
setAcceptRejectCode: string | null;
|
||||||
|
linkedAt: string;
|
||||||
|
claimState?: string;
|
||||||
|
}>,
|
||||||
|
) {
|
||||||
|
useAckClaims.mockReturnValue({
|
||||||
|
data: rows,
|
||||||
|
isLoading: false,
|
||||||
|
isError: false,
|
||||||
|
error: null,
|
||||||
|
refetch: vi.fn(),
|
||||||
|
});
|
||||||
|
useMergedTail.mockReturnValue(rows);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Minimal valid `Ack` fixture — every required key present so the
|
* Minimal valid `Ack` fixture — every required key present so the
|
||||||
* component typechecks. The wire shape extends with `raw_999_text`
|
* component typechecks. The wire shape extends with `raw_999_text`
|
||||||
@@ -68,6 +146,38 @@ function mockDetail(
|
|||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
cleanup();
|
cleanup();
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
|
// Default SP28 mocks — empty list, no errors, no-op navigate. Tests
|
||||||
|
// that exercise the MatchedClaim panel override via
|
||||||
|
// `setAckClaimsRows(...)`.
|
||||||
|
useAckClaims.mockReturnValue({
|
||||||
|
data: [],
|
||||||
|
isLoading: false,
|
||||||
|
isError: false,
|
||||||
|
error: null,
|
||||||
|
refetch: vi.fn(),
|
||||||
|
});
|
||||||
|
useTailStream.mockReturnValue({
|
||||||
|
status: "live",
|
||||||
|
lastEventAt: null,
|
||||||
|
error: null,
|
||||||
|
forceReconnect: vi.fn(),
|
||||||
|
});
|
||||||
|
useMergedTail.mockImplementation(
|
||||||
|
(_resource: unknown, baseItems: unknown) => baseItems,
|
||||||
|
);
|
||||||
|
useNavigate.mockReturnValue(vi.fn());
|
||||||
|
matchAckToClaim.mockResolvedValue({
|
||||||
|
link_id: 1,
|
||||||
|
claim_id: "CLM-1",
|
||||||
|
ack_id: 42,
|
||||||
|
ack_kind: "999",
|
||||||
|
set_control_number: "991102989",
|
||||||
|
set_accept_reject_code: "A",
|
||||||
|
linked_at: "2026-07-02T12:00:00Z",
|
||||||
|
linked_by: "manual",
|
||||||
|
});
|
||||||
|
unmatchAck.mockResolvedValue(undefined);
|
||||||
|
listClaims.mockResolvedValue({ items: [], total: 0, returned: 0, has_more: false });
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("AckDrawer", () => {
|
describe("AckDrawer", () => {
|
||||||
@@ -189,4 +299,160 @@ describe("AckDrawer", () => {
|
|||||||
// No raw_999_text → no Download button.
|
// No raw_999_text → no Download button.
|
||||||
expect(document.querySelector('[data-testid="ack-drawer-download"]')).toBeNull();
|
expect(document.querySelector('[data-testid="ack-drawer-download"]')).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// SP28: the new <MatchedClaim /> panel.
|
||||||
|
// Tests assert:
|
||||||
|
// - panel renders when `useAckClaims` returns a non-empty list
|
||||||
|
// - panel renders empty state with "Link to claim…" dropdown when
|
||||||
|
// the list is empty (default)
|
||||||
|
// - the "open claim" button navigates to /claims?claim=<id>
|
||||||
|
// - the panel mounts the live-tail subscription via
|
||||||
|
// `useTailStream("claim-acks")`
|
||||||
|
// - TA1 batch-level rows (claimId === null) render with the
|
||||||
|
// originating Batch id, not a claim card
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
it("test_matched_claim_panel_renders_empty_state_when_no_links", () => {
|
||||||
|
mockDetail({ data: SAMPLE_ACK });
|
||||||
|
render(<AckDrawer ackId="42" onClose={() => {}} />);
|
||||||
|
|
||||||
|
// The panel must be present.
|
||||||
|
expect(document.querySelector('[data-testid="matched-claim-panel"]'))
|
||||||
|
.not.toBeNull();
|
||||||
|
// The empty-state copy must surface.
|
||||||
|
expect(document.querySelector('[data-testid="matched-claim-empty"]'))
|
||||||
|
.not.toBeNull();
|
||||||
|
// The "Link to claim…" dropdown is the manual-match affordance
|
||||||
|
// (per D5/D9 — any logged-in user can run it).
|
||||||
|
expect(document.querySelector('[data-testid="link-to-claim-dropdown"]'))
|
||||||
|
.not.toBeNull();
|
||||||
|
expect(document.querySelector('[data-testid="link-to-claim-toggle"]'))
|
||||||
|
.not.toBeNull();
|
||||||
|
// No row rendered.
|
||||||
|
expect(document.querySelector('[data-testid="matched-claim-card"]')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("test_matched_claim_panel_renders_card_per_claim_link", () => {
|
||||||
|
setAckClaimsRows([
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
claimId: "CLM-1",
|
||||||
|
batchId: null,
|
||||||
|
ackId: 42,
|
||||||
|
ackKind: "999",
|
||||||
|
ak2Index: 0,
|
||||||
|
setControlNumber: "991102989",
|
||||||
|
setAcceptRejectCode: "A",
|
||||||
|
linkedAt: "2026-07-02T12:00:00Z",
|
||||||
|
claimState: "submitted",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 2,
|
||||||
|
claimId: "CLM-2",
|
||||||
|
batchId: null,
|
||||||
|
ackId: 42,
|
||||||
|
ackKind: "999",
|
||||||
|
ak2Index: 1,
|
||||||
|
setControlNumber: "991102990",
|
||||||
|
setAcceptRejectCode: "R",
|
||||||
|
linkedAt: "2026-07-02T12:00:01Z",
|
||||||
|
claimState: "denied",
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
mockDetail({ data: SAMPLE_ACK });
|
||||||
|
render(<AckDrawer ackId="42" onClose={() => {}} />);
|
||||||
|
|
||||||
|
// Per-AK2 granularity (D1): one card per link row.
|
||||||
|
const cards = document.querySelectorAll('[data-testid="matched-claim-card"]');
|
||||||
|
expect(cards.length).toBe(2);
|
||||||
|
|
||||||
|
// The header count must reflect the row count.
|
||||||
|
expect(document.querySelector('[data-testid="matched-claim-count"]')
|
||||||
|
?.textContent).toContain("(2)");
|
||||||
|
|
||||||
|
// Each card renders its claim id.
|
||||||
|
const ids = document.querySelectorAll('[data-testid="matched-claim-id"]');
|
||||||
|
expect(ids[0]?.textContent).toContain("CLM-1");
|
||||||
|
expect(ids[1]?.textContent).toContain("CLM-2");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("test_matched_claim_panel_renders_ta1_batch_row_with_originating_batch", () => {
|
||||||
|
// TA1 batch-level rows have claimId === null. The panel renders
|
||||||
|
// them with the originating Batch id, not a claim card (D4).
|
||||||
|
setAckClaimsRows([
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
claimId: null,
|
||||||
|
batchId: "B-TA1-1",
|
||||||
|
ackId: 42,
|
||||||
|
ackKind: "ta1",
|
||||||
|
ak2Index: null,
|
||||||
|
setControlNumber: null,
|
||||||
|
setAcceptRejectCode: null,
|
||||||
|
linkedAt: "2026-07-02T12:00:00Z",
|
||||||
|
claimState: "n/a",
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
mockDetail({ data: SAMPLE_ACK });
|
||||||
|
render(<AckDrawer ackId="42" onClose={() => {}} />);
|
||||||
|
|
||||||
|
// The TA1 row renders as a Batch card, not a claim card.
|
||||||
|
expect(document.querySelector('[data-testid="matched-claim-card-ta1"]'))
|
||||||
|
.not.toBeNull();
|
||||||
|
expect(document.querySelector('[data-testid="matched-claim-card"]')).toBeNull();
|
||||||
|
// The originating batch id is surfaced verbatim.
|
||||||
|
expect(document.querySelector('[data-testid="matched-claim-batch-id"]')
|
||||||
|
?.textContent).toContain("B-TA1-1");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("test_matched_claim_panel_open_claim_button_navigates_to_claims_with_claim_id", () => {
|
||||||
|
setAckClaimsRows([
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
claimId: "CLM-1",
|
||||||
|
batchId: null,
|
||||||
|
ackId: 42,
|
||||||
|
ackKind: "999",
|
||||||
|
ak2Index: 0,
|
||||||
|
setControlNumber: "991102989",
|
||||||
|
setAcceptRejectCode: "A",
|
||||||
|
linkedAt: "2026-07-02T12:00:00Z",
|
||||||
|
claimState: "submitted",
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
const navigate = vi.fn();
|
||||||
|
useNavigate.mockReturnValue(navigate);
|
||||||
|
mockDetail({ data: SAMPLE_ACK });
|
||||||
|
render(<AckDrawer ackId="42" onClose={() => {}} />);
|
||||||
|
|
||||||
|
const openBtn = document.querySelector(
|
||||||
|
'[data-testid="open-claim-drawer"]',
|
||||||
|
) as HTMLButtonElement | null;
|
||||||
|
expect(openBtn).not.toBeNull();
|
||||||
|
openBtn!.click();
|
||||||
|
|
||||||
|
// Cross-page navigation — the ClaimDrawer is mounted on /claims.
|
||||||
|
expect(navigate).toHaveBeenCalledTimes(1);
|
||||||
|
expect(navigate).toHaveBeenCalledWith("/claims?claim=CLM-1");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("test_matched_claim_panel_mounts_live_tail_subscription", () => {
|
||||||
|
mockDetail({ data: SAMPLE_ACK });
|
||||||
|
render(<AckDrawer ackId="42" onClose={() => {}} />);
|
||||||
|
|
||||||
|
// The panel opens one claim-acks live-tail stream. Per
|
||||||
|
// cyclone-frontend-page convention #3, the subscription lives on
|
||||||
|
// the drawer, not on the data hook.
|
||||||
|
expect(useTailStream).toHaveBeenCalledWith("claim-acks");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("test_matched_claim_panel_calls_useAckClaims_with_kind_and_ack_id", () => {
|
||||||
|
mockDetail({ data: SAMPLE_ACK });
|
||||||
|
render(<AckDrawer ackId="42" onClose={() => {}} />);
|
||||||
|
|
||||||
|
// The panel must pass (kind="999", ackId=42) to useAckClaims so
|
||||||
|
// the hook builds the right query key.
|
||||||
|
expect(useAckClaims).toHaveBeenCalledWith("999", 42);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { Skeleton } from "@/components/ui/skeleton";
|
|||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { useAckDetail, type AckDetail } from "@/hooks/useAckDetail";
|
import { useAckDetail, type AckDetail } from "@/hooks/useAckDetail";
|
||||||
import { SegmentStatusList } from "./SegmentStatusList";
|
import { SegmentStatusList } from "./SegmentStatusList";
|
||||||
|
import { MatchedClaimPanel } from "./MatchedClaimPanel";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
/**
|
/**
|
||||||
@@ -314,6 +315,13 @@ export function AckDrawer({ ackId, onClose }: Props) {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
{/* SP28: matched-claim panel — slot as the FIRST panel
|
||||||
|
after the summary so the operator's eye-flow is
|
||||||
|
"which claim does this ack acknowledge → what was the
|
||||||
|
envelope-level ack code → per-set responses". The
|
||||||
|
panel self-routes by ack kind + id (D5/D9 — any
|
||||||
|
logged-in user can manually link an orphan). */}
|
||||||
|
<MatchedClaimPanel kind="999" ackId={data.id} />
|
||||||
<SegmentStatusList segments={[]} />
|
<SegmentStatusList segments={[]} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,420 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import { ArrowRight, Link2 } from "lucide-react";
|
||||||
|
import { useNavigate } from "react-router-dom";
|
||||||
|
import { useAckClaims } from "@/hooks/useAckClaims";
|
||||||
|
import { useMergedTail } from "@/hooks/useMergedTail";
|
||||||
|
import { useTailStream } from "@/hooks/useTailStream";
|
||||||
|
import { api } from "@/lib/api";
|
||||||
|
import { ClaimStateBadge } from "@/components/ui/claim-state-badge";
|
||||||
|
import { Skeleton } from "@/components/ui/skeleton";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import { fmt } from "@/lib/format";
|
||||||
|
import type {
|
||||||
|
ClaimAck,
|
||||||
|
ClaimAckKind,
|
||||||
|
ClaimState,
|
||||||
|
} from "@/types";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Local UI state for the manual-match dropdown. The dropdown fetches
|
||||||
|
* the bare `ClaimSummary` projection from the backend (which lists
|
||||||
|
* every claim by patient_control_number-ish score) — for the first
|
||||||
|
* cut, the dropdown just lists the persisted claim population by id
|
||||||
|
* with a typed filter. The backend's actual candidate-list logic is
|
||||||
|
* out of scope for the SP28 frontend (mirrors the
|
||||||
|
* `/api/inbox/ack-orphans?kind=…` candidate list shape — D7).
|
||||||
|
*/
|
||||||
|
type CandidateClaim = {
|
||||||
|
id: string;
|
||||||
|
patientName: string;
|
||||||
|
billedAmount: number;
|
||||||
|
state: ClaimState;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Per-AK2 / per-ClaimStatus card. For 999/277CA rows, renders the
|
||||||
|
* linked claim (PCN, current state badge, charge, and a link to
|
||||||
|
* ClaimDrawer). For TA1 rows with `claimId === null`, renders the
|
||||||
|
* originating Batch instead.
|
||||||
|
*/
|
||||||
|
function ClaimLinkCard({
|
||||||
|
link,
|
||||||
|
onOpenClaim,
|
||||||
|
}: {
|
||||||
|
link: ClaimAck;
|
||||||
|
onOpenClaim: (claimId: string) => void;
|
||||||
|
}) {
|
||||||
|
if (link.claimId === null) {
|
||||||
|
// TA1 batch-level link — render the originating Batch id instead
|
||||||
|
// of a claim (D4). The `batchId` field is populated for TA1 rows
|
||||||
|
// only; other kinds leave it null.
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="flex flex-col gap-2 rounded-md border px-3 py-2.5"
|
||||||
|
style={{
|
||||||
|
backgroundColor: "hsl(var(--card) / 0.40)",
|
||||||
|
borderColor: "hsl(var(--border) / 0.5)",
|
||||||
|
}}
|
||||||
|
data-testid="matched-claim-card-ta1"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="eyebrow text-[10px] text-muted-foreground/70">
|
||||||
|
Originating batch
|
||||||
|
</span>
|
||||||
|
{link.ak2Index !== null ? (
|
||||||
|
<span className="mono text-[10px] uppercase tracking-[0.14em] text-muted-foreground/60">
|
||||||
|
AK2 #{link.ak2Index}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className="mono text-[12.5px] text-foreground"
|
||||||
|
data-testid="matched-claim-batch-id"
|
||||||
|
>
|
||||||
|
{link.batchId ?? "—"}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="flex flex-col gap-2 rounded-md border px-3 py-2.5"
|
||||||
|
style={{
|
||||||
|
backgroundColor: "hsl(var(--card) / 0.40)",
|
||||||
|
borderColor: "hsl(var(--border) / 0.5)",
|
||||||
|
}}
|
||||||
|
data-testid="matched-claim-card"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
|
<span
|
||||||
|
className="mono inline-flex items-center rounded-sm border px-1.5 py-0.5 text-[10px] font-semibold uppercase tracking-[0.14em]"
|
||||||
|
style={{
|
||||||
|
color: "hsl(var(--muted-foreground))",
|
||||||
|
backgroundColor: "hsl(var(--muted) / 0.30)",
|
||||||
|
borderColor: "hsl(var(--border) / 0.5)",
|
||||||
|
}}
|
||||||
|
data-testid="matched-claim-set-control"
|
||||||
|
title={`set_control_number: ${link.setControlNumber ?? "—"}`}
|
||||||
|
>
|
||||||
|
{link.setControlNumber ?? "—"}
|
||||||
|
</span>
|
||||||
|
{link.ak2Index !== null ? (
|
||||||
|
<span
|
||||||
|
className="mono text-[10px] uppercase tracking-[0.14em] text-muted-foreground/60"
|
||||||
|
title="AK2 set-response index"
|
||||||
|
>
|
||||||
|
AK2 #{link.ak2Index}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
{link.setAcceptRejectCode ? (
|
||||||
|
<span
|
||||||
|
className="mono inline-flex items-center rounded-sm border px-1.5 py-0.5 text-[10px] font-semibold uppercase tracking-[0.14em]"
|
||||||
|
style={{
|
||||||
|
color: "hsl(var(--muted-foreground))",
|
||||||
|
backgroundColor: "hsl(var(--muted) / 0.30)",
|
||||||
|
borderColor: "hsl(var(--border) / 0.5)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{link.setAcceptRejectCode}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
|
<span
|
||||||
|
className="mono truncate text-[12.5px] text-foreground"
|
||||||
|
data-testid="matched-claim-id"
|
||||||
|
>
|
||||||
|
{link.claimId}
|
||||||
|
</span>
|
||||||
|
{link.claimState && link.claimState !== "n/a" ? (
|
||||||
|
<ClaimStateBadge
|
||||||
|
state={link.claimState as ClaimState}
|
||||||
|
data-testid="matched-claim-state-badge"
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => onOpenClaim(link.claimId as string)}
|
||||||
|
className={cn(
|
||||||
|
"inline-flex items-center gap-1 rounded-sm border px-2 py-1 text-[10.5px] mono font-semibold uppercase tracking-[0.14em] transition-colors",
|
||||||
|
"border-border/60 bg-card/40 text-muted-foreground hover:bg-card/80 hover:text-foreground",
|
||||||
|
)}
|
||||||
|
data-testid="open-claim-drawer"
|
||||||
|
aria-label={`Open claim ${link.claimId}`}
|
||||||
|
>
|
||||||
|
open claim
|
||||||
|
<ArrowRight className="h-3 w-3" strokeWidth={1.75} aria-hidden />
|
||||||
|
</button>
|
||||||
|
{link.linkedAt ? (
|
||||||
|
<span className="ml-auto mono text-[10.5px] text-muted-foreground/60">
|
||||||
|
linked {fmt.dateShort(link.linkedAt)}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Inline "Link to claim…" affordance for the orphan case. Per D5/D9,
|
||||||
|
* any logged-in user can run the manual-match endpoint — the dropdown
|
||||||
|
* doesn't gate on role. On success the panel refetches and swaps to
|
||||||
|
* the populated card view (live-tail also delivers the new link).
|
||||||
|
*/
|
||||||
|
function LinkToClaimDropdown({
|
||||||
|
kind,
|
||||||
|
ackId,
|
||||||
|
onLinked,
|
||||||
|
}: {
|
||||||
|
kind: ClaimAckKind;
|
||||||
|
ackId: number;
|
||||||
|
onLinked: () => void;
|
||||||
|
}) {
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const [candidates, setCandidates] = useState<CandidateClaim[]>([]);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [submitting, setSubmitting] = useState<string | null>(null);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
async function loadCandidates() {
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
// Use the existing listClaims endpoint as a coarse candidate
|
||||||
|
// pool. The backend's full /api/inbox/ack-orphans candidate
|
||||||
|
// scoring is a separate concern — for the dropdown we just need
|
||||||
|
// a typed claim id picker. The match is performed by the
|
||||||
|
// backend's `POST /api/acks/{kind}/{id}/match-claim` endpoint.
|
||||||
|
const res = await api.listClaims({ limit: 25 });
|
||||||
|
setCandidates(
|
||||||
|
res.items.map((c) => ({
|
||||||
|
id: (c as unknown as { id: string }).id,
|
||||||
|
patientName:
|
||||||
|
(c as unknown as { patientName?: string }).patientName ?? "",
|
||||||
|
billedAmount:
|
||||||
|
(c as unknown as { billedAmount?: number }).billedAmount ?? 0,
|
||||||
|
state:
|
||||||
|
(c as unknown as { state?: ClaimState }).state ?? "submitted",
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
setOpen(true);
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : String(err));
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function link(claimId: string) {
|
||||||
|
setSubmitting(claimId);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
await api.matchAckToClaim(kind, ackId, claimId);
|
||||||
|
setOpen(false);
|
||||||
|
onLinked();
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : String(err));
|
||||||
|
} finally {
|
||||||
|
setSubmitting(null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-2" data-testid="link-to-claim-dropdown">
|
||||||
|
{!open ? (
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => void loadCandidates()}
|
||||||
|
disabled={loading}
|
||||||
|
data-testid="link-to-claim-toggle"
|
||||||
|
className="self-start"
|
||||||
|
>
|
||||||
|
<Link2 className="h-3.5 w-3.5" strokeWidth={1.75} aria-hidden />
|
||||||
|
{loading ? "Loading candidates…" : "Link to claim…"}
|
||||||
|
</Button>
|
||||||
|
) : (
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
<div className="eyebrow text-[10px] text-muted-foreground/70">
|
||||||
|
Pick a claim to link this {kind} ack to
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className="flex max-h-64 flex-col overflow-y-auto rounded-md border"
|
||||||
|
style={{
|
||||||
|
backgroundColor: "hsl(var(--card) / 0.40)",
|
||||||
|
borderColor: "hsl(var(--border) / 0.5)",
|
||||||
|
}}
|
||||||
|
data-testid="link-to-claim-list"
|
||||||
|
>
|
||||||
|
{candidates.length === 0 ? (
|
||||||
|
<p className="px-3 py-3 text-[12px] text-muted-foreground/70">
|
||||||
|
No claims available.
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
candidates.map((c) => (
|
||||||
|
<button
|
||||||
|
key={c.id}
|
||||||
|
type="button"
|
||||||
|
disabled={submitting !== null}
|
||||||
|
onClick={() => void link(c.id)}
|
||||||
|
className={cn(
|
||||||
|
"flex flex-col items-start gap-1 border-b px-3 py-2 text-left text-[12px] transition-colors last:border-b-0",
|
||||||
|
"hover:bg-card/80 disabled:opacity-50",
|
||||||
|
)}
|
||||||
|
style={{ borderColor: "hsl(var(--border) / 0.4)" }}
|
||||||
|
data-testid="link-to-claim-option"
|
||||||
|
data-claim-id={c.id}
|
||||||
|
>
|
||||||
|
<span className="mono text-foreground">{c.id}</span>
|
||||||
|
<span className="text-[11px] text-muted-foreground/80">
|
||||||
|
{c.patientName || "—"} · {fmt.usd(c.billedAmount)}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setOpen(false)}
|
||||||
|
className="self-start text-[11px] text-muted-foreground/70 hover:text-foreground"
|
||||||
|
data-testid="link-to-claim-cancel"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{error ? (
|
||||||
|
<p
|
||||||
|
className="text-[11px] text-destructive"
|
||||||
|
data-testid="link-to-claim-error"
|
||||||
|
>
|
||||||
|
{error}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* SP28: AckDrawer's new `<MatchedClaim />` panel.
|
||||||
|
*
|
||||||
|
* Mounted as the FIRST panel (before "Set responses") so the
|
||||||
|
* operator's eye-flow on an ack row is "which claim does this ack
|
||||||
|
* acknowledge → what was the envelope-level ack code → what were
|
||||||
|
* the per-set responses". Per-AK2 granularity: each AK2 / ClaimStatus
|
||||||
|
* gets its own card so a 999 with two AK2s renders two cards.
|
||||||
|
*
|
||||||
|
* For 999/277CA: renders one card per linked claim.
|
||||||
|
* For TA1: renders one card per envelope linking to the originating
|
||||||
|
* `Batch` (D4 — `claimId` is null, `batchId` is populated).
|
||||||
|
*
|
||||||
|
* Live-tail: subscribes to `useTailStream("claim-acks")` and merges
|
||||||
|
* via `useMergedTail` with the per-ack filter
|
||||||
|
* `link.ackId === ackId` so a manual match made elsewhere shows up
|
||||||
|
* here in real time. Per `cyclone-frontend-page` convention #3, the
|
||||||
|
* subscription lives on the drawer (this component), not on the
|
||||||
|
* data hook.
|
||||||
|
*
|
||||||
|
* Empty state: when no link exists yet, render the
|
||||||
|
* "Link to claim…" dropdown that calls `api.matchAckToClaim` and
|
||||||
|
* refetches on success (per D5/D9 — any logged-in user can manually
|
||||||
|
* link an orphan).
|
||||||
|
*/
|
||||||
|
export function MatchedClaimPanel({
|
||||||
|
kind,
|
||||||
|
ackId,
|
||||||
|
}: {
|
||||||
|
kind: ClaimAckKind;
|
||||||
|
ackId: number;
|
||||||
|
}) {
|
||||||
|
const { data, isLoading, refetch } = useAckClaims(kind, ackId);
|
||||||
|
const navigate = useNavigate();
|
||||||
|
// Live-tail: open the shared claim-acks stream and filter to this
|
||||||
|
// ack. The base list (`data ?? []`) is the page's authoritative
|
||||||
|
// snapshot; the merged result also includes any new rows that
|
||||||
|
// arrived since the snapshot fetch.
|
||||||
|
useTailStream("claim-acks");
|
||||||
|
const merged = useMergedTail(
|
||||||
|
"claim-acks",
|
||||||
|
data ?? [],
|
||||||
|
(link) => link.ackId === ackId,
|
||||||
|
);
|
||||||
|
|
||||||
|
const visible = merged.filter((l) => l.ackId === ackId);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section
|
||||||
|
className="flex flex-col gap-3 px-6 py-4"
|
||||||
|
data-testid="matched-claim-panel"
|
||||||
|
aria-label="Matched claim"
|
||||||
|
>
|
||||||
|
<header className="flex items-center justify-between gap-3">
|
||||||
|
<div className="eyebrow flex items-center gap-2">
|
||||||
|
<span className="inline-block h-px w-6 bg-foreground/20" />
|
||||||
|
Matched claim
|
||||||
|
<span
|
||||||
|
className="mono ml-1 text-[10px] tabular-nums text-muted-foreground/70"
|
||||||
|
data-testid="matched-claim-count"
|
||||||
|
>
|
||||||
|
({visible.length})
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{isLoading && visible.length === 0 ? (
|
||||||
|
<div
|
||||||
|
className="flex flex-col gap-2"
|
||||||
|
data-testid="matched-claim-loading"
|
||||||
|
>
|
||||||
|
<Skeleton variant="row" />
|
||||||
|
<Skeleton variant="row" />
|
||||||
|
</div>
|
||||||
|
) : visible.length === 0 ? (
|
||||||
|
<div
|
||||||
|
className="flex flex-col gap-2 rounded-md border border-dashed border-border/60 bg-card/40 px-4 py-3"
|
||||||
|
data-testid="matched-claim-empty"
|
||||||
|
>
|
||||||
|
<p className="text-[12.5px] text-muted-foreground/80">
|
||||||
|
{kind === "ta1"
|
||||||
|
? "This TA1 envelope hasn't been linked to a Batch yet."
|
||||||
|
: "No claim links yet for this ack."}
|
||||||
|
</p>
|
||||||
|
{/* Per D5/D9: any logged-in user can run the manual-match
|
||||||
|
endpoint. The dropdown lets the operator pick from a
|
||||||
|
coarse candidate pool; the backend does the actual join. */}
|
||||||
|
<LinkToClaimDropdown
|
||||||
|
kind={kind}
|
||||||
|
ackId={ackId}
|
||||||
|
onLinked={() => {
|
||||||
|
void refetch();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="flex flex-col gap-2" data-testid="matched-claim-list">
|
||||||
|
{visible.map((link) => (
|
||||||
|
<ClaimLinkCard
|
||||||
|
key={link.id}
|
||||||
|
link={link}
|
||||||
|
onOpenClaim={(claimId) => {
|
||||||
|
// Cross-page navigation — mirrors the Inbox →
|
||||||
|
// ClaimDrawer pattern. The ClaimDrawer is mounted on
|
||||||
|
// the /claims page; navigating there with ?claim=<id>
|
||||||
|
// restores the drawer with the deep-link.
|
||||||
|
navigate(
|
||||||
|
`/claims?claim=${encodeURIComponent(claimId)}`,
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -2,3 +2,4 @@
|
|||||||
|
|
||||||
export { AckDrawer } from "./AckDrawer";
|
export { AckDrawer } from "./AckDrawer";
|
||||||
export { SegmentStatusList } from "./SegmentStatusList";
|
export { SegmentStatusList } from "./SegmentStatusList";
|
||||||
|
export { MatchedClaimPanel } from "./MatchedClaimPanel";
|
||||||
|
|||||||
@@ -0,0 +1,280 @@
|
|||||||
|
import { ArrowRight } from "lucide-react";
|
||||||
|
import { useNavigate } from "react-router-dom";
|
||||||
|
import { useClaimAcks } from "@/hooks/useClaimAcks";
|
||||||
|
import { useMergedTail } from "@/hooks/useMergedTail";
|
||||||
|
import { useTailStream } from "@/hooks/useTailStream";
|
||||||
|
import { EmptyState } from "@/components/ui/empty-state";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import { fmt } from "@/lib/format";
|
||||||
|
import type { ClaimAck, ClaimAckKind } from "@/types";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Color palette for the per-AK2 / per-ClaimStatus accept-reject code
|
||||||
|
* pill. Mirrors the `Acks.tsx` `AckCodeBadge` palette so the
|
||||||
|
* operator's eye-flow is identical between the Acks page and this
|
||||||
|
* panel.
|
||||||
|
*
|
||||||
|
* - "A" (accepted) → success green
|
||||||
|
* - "R" (rejected) → destructive red
|
||||||
|
* - "E" (errors) → warning amber
|
||||||
|
* - "X" (rejected w/ 999 AK5) → destructive red
|
||||||
|
* - anything else → muted (treats STC category codes like A1/A2/A6 as
|
||||||
|
* operator-readable rather than literal A/E letters)
|
||||||
|
*/
|
||||||
|
function codeTone(code: string | null | undefined): {
|
||||||
|
fg: string;
|
||||||
|
bg: string;
|
||||||
|
border: string;
|
||||||
|
} {
|
||||||
|
if (!code) {
|
||||||
|
return {
|
||||||
|
fg: "hsl(var(--muted-foreground))",
|
||||||
|
bg: "hsl(var(--muted) / 0.30)",
|
||||||
|
border: "hsl(var(--border) / 0.5)",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const upper = code.toUpperCase();
|
||||||
|
if (upper === "A") {
|
||||||
|
return {
|
||||||
|
fg: "hsl(var(--success))",
|
||||||
|
bg: "hsl(var(--success) / 0.10)",
|
||||||
|
border: "hsl(var(--success) / 0.30)",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (upper === "R" || upper === "X") {
|
||||||
|
return {
|
||||||
|
fg: "hsl(var(--destructive))",
|
||||||
|
bg: "hsl(var(--destructive) / 0.10)",
|
||||||
|
border: "hsl(var(--destructive) / 0.30)",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (upper === "E" || upper === "P") {
|
||||||
|
return {
|
||||||
|
fg: "hsl(var(--warning))",
|
||||||
|
bg: "hsl(var(--warning) / 0.10)",
|
||||||
|
border: "hsl(var(--warning) / 0.30)",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
// STC category code (A1/A2/A3/A4/A6/A7) — keep the operator-visible
|
||||||
|
// tone as warning so a 277CA "rejected" stands out, but render the
|
||||||
|
// literal code rather than the badge letter so the operator can
|
||||||
|
// tell which STC category triggered the link.
|
||||||
|
return {
|
||||||
|
fg: "hsl(var(--warning))",
|
||||||
|
bg: "hsl(var(--warning) / 0.10)",
|
||||||
|
border: "hsl(var(--warning) / 0.30)",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function AckKindBadge({ kind }: { kind: ClaimAckKind }) {
|
||||||
|
// The ack kind itself is one of "999" / "277ca" / "ta1". TA1 rows
|
||||||
|
// don't appear in this panel (filtered out per D4), but the type
|
||||||
|
// union still allows it — render it consistently with the Acks page.
|
||||||
|
const label =
|
||||||
|
kind === "999" ? "999" : kind === "277ca" ? "277CA" : "TA1";
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className="mono inline-flex items-center rounded-sm border px-1.5 py-0.5 text-[10px] font-semibold uppercase tracking-[0.14em]"
|
||||||
|
style={{
|
||||||
|
color: "hsl(var(--muted-foreground))",
|
||||||
|
backgroundColor: "hsl(var(--muted) / 0.30)",
|
||||||
|
borderColor: "hsl(var(--border) / 0.5)",
|
||||||
|
}}
|
||||||
|
data-testid="ack-kind-badge"
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One compact row in the Acknowledgments panel. Renders the ack
|
||||||
|
* code (color-coded), the ack kind, the linked set_control_number
|
||||||
|
* (the operator-readable join key), the parsed_at date, and a `→`
|
||||||
|
* link to the matching AckDrawer.
|
||||||
|
*/
|
||||||
|
function AckLinkRow({
|
||||||
|
link,
|
||||||
|
onOpenAck,
|
||||||
|
}: {
|
||||||
|
link: ClaimAck;
|
||||||
|
onOpenAck: (ackId: number, kind: ClaimAckKind) => void;
|
||||||
|
}) {
|
||||||
|
const tone = codeTone(link.setAcceptRejectCode);
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="flex items-center gap-3 rounded-md border px-3 py-2"
|
||||||
|
style={{
|
||||||
|
backgroundColor: "hsl(var(--card) / 0.40)",
|
||||||
|
borderColor: "hsl(var(--border) / 0.5)",
|
||||||
|
}}
|
||||||
|
data-testid="acknowledgments-row"
|
||||||
|
>
|
||||||
|
{/* Accept/reject pill */}
|
||||||
|
<span
|
||||||
|
className="mono inline-flex items-center rounded-sm border px-2 py-0.5 text-[10.5px] font-semibold uppercase tracking-[0.14em]"
|
||||||
|
style={{
|
||||||
|
color: tone.fg,
|
||||||
|
backgroundColor: tone.bg,
|
||||||
|
borderColor: tone.border,
|
||||||
|
}}
|
||||||
|
data-testid="ack-code-pill"
|
||||||
|
>
|
||||||
|
{link.setAcceptRejectCode ?? "—"}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
{/* AK2 index chip (999 only) — gives the operator a quick
|
||||||
|
"which set-response" reference for the per-AK2 granularity. */}
|
||||||
|
{link.ak2Index !== null ? (
|
||||||
|
<span
|
||||||
|
className="mono text-[10px] uppercase tracking-[0.14em] text-muted-foreground/60"
|
||||||
|
title="AK2 set-response index"
|
||||||
|
>
|
||||||
|
AK2 #{link.ak2Index}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<AckKindBadge kind={link.ackKind} />
|
||||||
|
|
||||||
|
{link.setControlNumber ? (
|
||||||
|
<span
|
||||||
|
className="mono truncate text-[11.5px] text-muted-foreground/80"
|
||||||
|
title={`set_control_number: ${link.setControlNumber}`}
|
||||||
|
>
|
||||||
|
{link.setControlNumber}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<span className="ml-auto mono text-[10.5px] text-muted-foreground/60">
|
||||||
|
{link.linkedAt ? fmt.dateShort(link.linkedAt) : "—"}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => onOpenAck(link.ackId, link.ackKind)}
|
||||||
|
className={cn(
|
||||||
|
"inline-flex items-center gap-1 rounded-sm border px-2 py-1 text-[10.5px] mono font-semibold uppercase tracking-[0.14em] transition-colors",
|
||||||
|
"border-border/60 bg-card/40 text-muted-foreground hover:bg-card/80 hover:text-foreground",
|
||||||
|
)}
|
||||||
|
data-testid="open-ack-drawer"
|
||||||
|
aria-label={`Open ${link.ackKind} ACK ${link.ackId}`}
|
||||||
|
>
|
||||||
|
open
|
||||||
|
<ArrowRight className="h-3 w-3" strokeWidth={1.75} aria-hidden />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* SP28: ClaimDrawer's new `<Acknowledgments />` panel.
|
||||||
|
*
|
||||||
|
* Mounted between `<ServiceLinesTable />` and the trailing panels.
|
||||||
|
* Lists every `claim_acks` row that links to the current claim:
|
||||||
|
* - One row per AK2 set-response (for 999 acks — per-AK2 granularity)
|
||||||
|
* - One row per ClaimStatus (for 277CA)
|
||||||
|
* - TA1 batch-level rows are filtered out (D4 — claim_id IS NULL)
|
||||||
|
*
|
||||||
|
* Live-tail: subscribes to `useTailStream("claim-acks")` and merges
|
||||||
|
* via `useMergedTail` with the per-claim filter
|
||||||
|
* `link.claimId === claimId` so a manual match on the AckDrawer or a
|
||||||
|
* new auto-link from a 999 ack shows up on the ClaimDrawer in real
|
||||||
|
* time. Per `cyclone-frontend-page` convention #3, the subscription
|
||||||
|
* lives on the drawer (this component), not on `useClaimAcks`.
|
||||||
|
*
|
||||||
|
* Empty state: when the claim has no acks yet, render the existing
|
||||||
|
* `<EmptyState />` with copy "No acknowledgments received yet for
|
||||||
|
* this claim." — so a fresh claim gets a clear non-error empty state
|
||||||
|
* rather than a quiet section.
|
||||||
|
*/
|
||||||
|
export function AcknowledgmentsPanel({
|
||||||
|
claimId,
|
||||||
|
}: {
|
||||||
|
claimId: string;
|
||||||
|
}) {
|
||||||
|
const { data, isLoading } = useClaimAcks(claimId);
|
||||||
|
const navigate = useNavigate();
|
||||||
|
// Live-tail: open the shared claim-acks stream and filter to this
|
||||||
|
// claim. The base list (`data ?? []`) is the page's authoritative
|
||||||
|
// snapshot; the merged result also includes any new rows that
|
||||||
|
// arrived since the snapshot fetch (manual match, new ack ingest).
|
||||||
|
useTailStream("claim-acks");
|
||||||
|
const merged = useMergedTail(
|
||||||
|
"claim-acks",
|
||||||
|
data ?? [],
|
||||||
|
(link) => link.claimId === claimId,
|
||||||
|
);
|
||||||
|
|
||||||
|
// TA1 batch-level rows have `claimId === null`. The backend filters
|
||||||
|
// them out of the per-claim endpoint, but defensive belt-and-
|
||||||
|
// suspenders: skip any row that doesn't claim this claim id (the
|
||||||
|
// filterFn already does this for the tail slice; we apply the same
|
||||||
|
// to baseItems-derived merged items to be safe).
|
||||||
|
const visible = merged.filter((l) => l.claimId === claimId);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section
|
||||||
|
className="flex flex-col gap-3 px-6 py-4"
|
||||||
|
data-testid="acknowledgments-panel"
|
||||||
|
aria-label="Acknowledgments"
|
||||||
|
>
|
||||||
|
<header className="flex items-center justify-between gap-3">
|
||||||
|
<div className="eyebrow flex items-center gap-2">
|
||||||
|
<span className="inline-block h-px w-6 bg-foreground/20" />
|
||||||
|
Acknowledgments
|
||||||
|
<span
|
||||||
|
className="mono ml-1 text-[10px] tabular-nums text-muted-foreground/70"
|
||||||
|
data-testid="acknowledgments-count"
|
||||||
|
>
|
||||||
|
({visible.length})
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{isLoading && visible.length === 0 ? (
|
||||||
|
<p
|
||||||
|
className="text-[12.5px] text-muted-foreground/70"
|
||||||
|
data-testid="acknowledgments-loading"
|
||||||
|
>
|
||||||
|
Loading acknowledgments…
|
||||||
|
</p>
|
||||||
|
) : visible.length === 0 ? (
|
||||||
|
<div
|
||||||
|
className="rounded-md border border-dashed border-border/60 bg-card/40"
|
||||||
|
data-testid="acknowledgments-empty"
|
||||||
|
>
|
||||||
|
<EmptyState
|
||||||
|
eyebrow="Acknowledgments · none yet"
|
||||||
|
message="No acknowledgments received yet for this claim."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="flex flex-col gap-2" data-testid="acknowledgments-list">
|
||||||
|
{visible.map((link) => (
|
||||||
|
<AckLinkRow
|
||||||
|
key={link.id}
|
||||||
|
link={link}
|
||||||
|
onOpenAck={(ackId, kind) => {
|
||||||
|
// Cross-page navigation: the AckDrawer is mounted on
|
||||||
|
// the /acks page, not on /claims. Push the user to
|
||||||
|
// /acks?ack=<id> so the Acks page mounts the drawer
|
||||||
|
// with the deep-link restored (per
|
||||||
|
// `useAckDrawerUrlState`). Mirrors the Inbox →
|
||||||
|
// RemitDrawer pattern, where the inbox row click
|
||||||
|
// navigates to /inbox?remit=<id>.
|
||||||
|
//
|
||||||
|
// `kind` is kept in the callback signature so a
|
||||||
|
// future enhancement can route to a TA1-specific
|
||||||
|
// page (today the AckDrawer page handles only 999
|
||||||
|
// acks — TA1 navigation would need a different page
|
||||||
|
// or a TA1 drawer on the same Acks surface).
|
||||||
|
navigate(`/acks?ack=${encodeURIComponent(String(ackId))}`);
|
||||||
|
void kind;
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -32,6 +32,58 @@ vi.mock("@/lib/api", () => {
|
|||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// SP28: the new <Acknowledgments /> panel mounts three hooks (data,
|
||||||
|
// live-tail, live-tail merge) and calls `useNavigate` to navigate to
|
||||||
|
// /acks?ack=<id> when the operator clicks an ack row's "open" button.
|
||||||
|
// Mock each one at the module boundary so the test never opens a real
|
||||||
|
// fetch or a real navigation. The hook mocks return "empty" by default
|
||||||
|
// so the panel renders <EmptyState /> — tests that need to populate
|
||||||
|
// the panel override via `setClaimAcksRows(...)`.
|
||||||
|
const { useClaimAcks, useTailStream, useMergedTail, useNavigate } =
|
||||||
|
vi.hoisted(() => ({
|
||||||
|
useClaimAcks: vi.fn(),
|
||||||
|
useTailStream: vi.fn(),
|
||||||
|
useMergedTail: vi.fn(),
|
||||||
|
useNavigate: vi.fn(),
|
||||||
|
}));
|
||||||
|
vi.mock("@/hooks/useClaimAcks", () => ({ useClaimAcks }));
|
||||||
|
vi.mock("@/hooks/useTailStream", () => ({ useTailStream }));
|
||||||
|
vi.mock("@/hooks/useMergedTail", () => ({ useMergedTail }));
|
||||||
|
vi.mock("react-router-dom", async () => {
|
||||||
|
const actual =
|
||||||
|
await vi.importActual<typeof import("react-router-dom")>(
|
||||||
|
"react-router-dom",
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
...actual,
|
||||||
|
useNavigate,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
/** Default mock state — empty list, no errors, no navigation. Tests that
|
||||||
|
* need to populate the panel override via `useClaimAcks.mockReturnValue`. */
|
||||||
|
function setClaimAcksRows(
|
||||||
|
rows: Array<{
|
||||||
|
id: number;
|
||||||
|
claimId: string;
|
||||||
|
ackId: number;
|
||||||
|
ackKind: "999" | "277ca" | "ta1";
|
||||||
|
ak2Index: number | null;
|
||||||
|
setControlNumber: string | null;
|
||||||
|
setAcceptRejectCode: string | null;
|
||||||
|
linkedAt: string;
|
||||||
|
}>,
|
||||||
|
) {
|
||||||
|
useClaimAcks.mockReturnValue({
|
||||||
|
data: rows,
|
||||||
|
isLoading: false,
|
||||||
|
isError: false,
|
||||||
|
error: null,
|
||||||
|
refetch: vi.fn(),
|
||||||
|
});
|
||||||
|
useMergedTail.mockReturnValue(rows);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Minimal valid ClaimDetail fixture — every required key present so the
|
* Minimal valid ClaimDetail fixture — every required key present so the
|
||||||
* component typechecks. Mirrors the SAMPLE_DETAIL in
|
* component typechecks. Mirrors the SAMPLE_DETAIL in
|
||||||
@@ -236,6 +288,26 @@ async function settle(
|
|||||||
describe("ClaimDrawer", () => {
|
describe("ClaimDrawer", () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
|
// Default mocks — empty list, no errors, no-op navigate. Tests
|
||||||
|
// that exercise the SP28 Acknowledgments panel override via
|
||||||
|
// `setClaimAcksRows(...)`.
|
||||||
|
useClaimAcks.mockReturnValue({
|
||||||
|
data: [],
|
||||||
|
isLoading: false,
|
||||||
|
isError: false,
|
||||||
|
error: null,
|
||||||
|
refetch: vi.fn(),
|
||||||
|
});
|
||||||
|
useTailStream.mockReturnValue({
|
||||||
|
status: "live",
|
||||||
|
lastEventAt: null,
|
||||||
|
error: null,
|
||||||
|
forceReconnect: vi.fn(),
|
||||||
|
});
|
||||||
|
useMergedTail.mockImplementation(
|
||||||
|
(_resource: unknown, baseItems: unknown) => baseItems,
|
||||||
|
);
|
||||||
|
useNavigate.mockReturnValue(vi.fn());
|
||||||
});
|
});
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
@@ -471,4 +543,196 @@ describe("ClaimDrawer", () => {
|
|||||||
|
|
||||||
unmount();
|
unmount();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// SP28: the new <Acknowledgments /> panel.
|
||||||
|
// Tests assert:
|
||||||
|
// - panel renders when `useClaimAcks` returns a non-empty list
|
||||||
|
// - panel renders <EmptyState /> when the list is empty (default)
|
||||||
|
// - the "open" button navigates to /acks?ack=<id>
|
||||||
|
// - the panel mounts the live-tail subscription (one useTailStream
|
||||||
|
// call per drawer open — the subscription lives on the drawer)
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
it("test_acknowledgments_panel_renders_empty_state_when_no_acks", async () => {
|
||||||
|
const { unmount } = renderDrawer({ claimId: "CLM-1" });
|
||||||
|
|
||||||
|
const body = await settle((b) =>
|
||||||
|
b.querySelector('[data-testid="acknowledgments-panel"]') !== null
|
||||||
|
);
|
||||||
|
|
||||||
|
// The panel must be present.
|
||||||
|
expect(body.querySelector('[data-testid="acknowledgments-panel"]'))
|
||||||
|
.not.toBeNull();
|
||||||
|
// The empty-state copy must surface (no acks → friendly message).
|
||||||
|
expect(body.querySelector('[data-testid="acknowledgments-empty"]'))
|
||||||
|
.not.toBeNull();
|
||||||
|
expect(body.textContent).toContain("No acknowledgments received yet");
|
||||||
|
// No row rendered.
|
||||||
|
expect(body.querySelector('[data-testid="acknowledgments-row"]')).toBeNull();
|
||||||
|
|
||||||
|
unmount();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("test_acknowledgments_panel_renders_row_for_each_ack_link", async () => {
|
||||||
|
setClaimAcksRows([
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
claimId: "CLM-1",
|
||||||
|
ackId: 99,
|
||||||
|
ackKind: "999",
|
||||||
|
ak2Index: 0,
|
||||||
|
setControlNumber: "991102989",
|
||||||
|
setAcceptRejectCode: "A",
|
||||||
|
linkedAt: "2026-07-02T12:00:00Z",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 2,
|
||||||
|
claimId: "CLM-1",
|
||||||
|
ackId: 99,
|
||||||
|
ackKind: "999",
|
||||||
|
ak2Index: 1,
|
||||||
|
setControlNumber: "991102990",
|
||||||
|
setAcceptRejectCode: "R",
|
||||||
|
linkedAt: "2026-07-02T12:00:01Z",
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
const { unmount } = renderDrawer({ claimId: "CLM-1" });
|
||||||
|
|
||||||
|
const body = await settle(
|
||||||
|
(b) =>
|
||||||
|
b.querySelectorAll('[data-testid="acknowledgments-row"]').length === 2,
|
||||||
|
);
|
||||||
|
|
||||||
|
// The panel must render one row per AK2 (per-AK2 granularity, D1).
|
||||||
|
const rows = body.querySelectorAll('[data-testid="acknowledgments-row"]');
|
||||||
|
expect(rows.length).toBe(2);
|
||||||
|
|
||||||
|
// Accept code pills render the literal set_accept_reject_code so
|
||||||
|
// the operator can see "A" (accepted) vs "R" (rejected) inline.
|
||||||
|
const codes = body.querySelectorAll('[data-testid="ack-code-pill"]');
|
||||||
|
expect(codes[0]?.textContent).toContain("A");
|
||||||
|
expect(codes[1]?.textContent).toContain("R");
|
||||||
|
|
||||||
|
// The header count must reflect the row count.
|
||||||
|
expect(body.querySelector('[data-testid="acknowledgments-count"]')
|
||||||
|
?.textContent).toContain("(2)");
|
||||||
|
|
||||||
|
// The "open" button is wired up on every row.
|
||||||
|
const opens = body.querySelectorAll('[data-testid="open-ack-drawer"]');
|
||||||
|
expect(opens.length).toBe(2);
|
||||||
|
|
||||||
|
unmount();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("test_acknowledgments_panel_filters_out_rows_with_different_claim_id", async () => {
|
||||||
|
// Base snapshot has one row for CLM-1 and one orphan (claimId
|
||||||
|
// different from the active claim). The merge hook receives the
|
||||||
|
// base list and the tail slice, but the panel filter
|
||||||
|
// (`link.claimId === claimId`) drops the orphan. Test the
|
||||||
|
// defensive filter inside the panel.
|
||||||
|
setClaimAcksRows([
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
claimId: "CLM-1",
|
||||||
|
ackId: 99,
|
||||||
|
ackKind: "999",
|
||||||
|
ak2Index: 0,
|
||||||
|
setControlNumber: "991102989",
|
||||||
|
setAcceptRejectCode: "A",
|
||||||
|
linkedAt: "2026-07-02T12:00:00Z",
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
// Override useMergedTail to simulate an orphan tail row sneaking
|
||||||
|
// in (matches the prod merge contract — useMergedTail returns
|
||||||
|
// base + tail, and the panel filter strips non-matching rows).
|
||||||
|
useMergedTail.mockReturnValue([
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
claimId: "CLM-1",
|
||||||
|
ackId: 99,
|
||||||
|
ackKind: "999",
|
||||||
|
ak2Index: 0,
|
||||||
|
setControlNumber: "991102989",
|
||||||
|
setAcceptRejectCode: "A",
|
||||||
|
linkedAt: "2026-07-02T12:00:00Z",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 2,
|
||||||
|
claimId: "CLM-OTHER",
|
||||||
|
ackId: 100,
|
||||||
|
ackKind: "999",
|
||||||
|
ak2Index: 0,
|
||||||
|
setControlNumber: "991102999",
|
||||||
|
setAcceptRejectCode: "R",
|
||||||
|
linkedAt: "2026-07-02T12:00:01Z",
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
const { unmount } = renderDrawer({ claimId: "CLM-1" });
|
||||||
|
|
||||||
|
const body = await settle(
|
||||||
|
(b) =>
|
||||||
|
b.querySelectorAll('[data-testid="acknowledgments-row"]').length === 1,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Only the matching-claim row renders.
|
||||||
|
const rows = body.querySelectorAll('[data-testid="acknowledgments-row"]');
|
||||||
|
expect(rows.length).toBe(1);
|
||||||
|
|
||||||
|
unmount();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("test_acknowledgments_panel_open_button_navigates_to_acks_with_ack_id", async () => {
|
||||||
|
setClaimAcksRows([
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
claimId: "CLM-1",
|
||||||
|
ackId: 99,
|
||||||
|
ackKind: "999",
|
||||||
|
ak2Index: 0,
|
||||||
|
setControlNumber: "991102989",
|
||||||
|
setAcceptRejectCode: "A",
|
||||||
|
linkedAt: "2026-07-02T12:00:00Z",
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
const navigate = vi.fn();
|
||||||
|
useNavigate.mockReturnValue(navigate);
|
||||||
|
|
||||||
|
const { unmount } = renderDrawer({ claimId: "CLM-1" });
|
||||||
|
|
||||||
|
const body = await settle(
|
||||||
|
(b) => b.querySelector('[data-testid="open-ack-drawer"]') !== null,
|
||||||
|
);
|
||||||
|
|
||||||
|
const openBtn = body.querySelector(
|
||||||
|
'[data-testid="open-ack-drawer"]',
|
||||||
|
) as HTMLButtonElement | null;
|
||||||
|
expect(openBtn).not.toBeNull();
|
||||||
|
openBtn!.click();
|
||||||
|
|
||||||
|
expect(navigate).toHaveBeenCalledTimes(1);
|
||||||
|
// Navigate to /acks?ack=<id> — the Acks page mounts the
|
||||||
|
// AckDrawer with the deep-link restored.
|
||||||
|
expect(navigate).toHaveBeenCalledWith("/acks?ack=99");
|
||||||
|
|
||||||
|
unmount();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("test_acknowledgments_panel_mounts_live_tail_subscription", async () => {
|
||||||
|
const { unmount } = renderDrawer({ claimId: "CLM-1" });
|
||||||
|
|
||||||
|
await settle((b) =>
|
||||||
|
b.querySelector('[data-testid="acknowledgments-panel"]') !== null
|
||||||
|
);
|
||||||
|
|
||||||
|
// The drawer mounts one claim-acks live-tail stream. Per
|
||||||
|
// cyclone-frontend-page convention #3, the subscription lives on
|
||||||
|
// the page (drawer here), not on the data hook.
|
||||||
|
expect(useTailStream).toHaveBeenCalledWith("claim-acks");
|
||||||
|
|
||||||
|
unmount();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import { DiagnosesList } from "./DiagnosesList";
|
|||||||
import { PartiesGrid } from "./PartiesGrid";
|
import { PartiesGrid } from "./PartiesGrid";
|
||||||
import { RawSegmentsPanel } from "./RawSegmentsPanel";
|
import { RawSegmentsPanel } from "./RawSegmentsPanel";
|
||||||
import { MatchedRemitCard } from "./MatchedRemitCard";
|
import { MatchedRemitCard } from "./MatchedRemitCard";
|
||||||
|
import { AcknowledgmentsPanel } from "./AcknowledgmentsPanel";
|
||||||
import { StateHistoryTimeline } from "./StateHistoryTimeline";
|
import { StateHistoryTimeline } from "./StateHistoryTimeline";
|
||||||
import { LineReconciliationTab } from "./LineReconciliationTab";
|
import { LineReconciliationTab } from "./LineReconciliationTab";
|
||||||
import { ClaimDrawerSkeleton } from "./ClaimDrawerSkeleton";
|
import { ClaimDrawerSkeleton } from "./ClaimDrawerSkeleton";
|
||||||
@@ -232,6 +233,13 @@ export function ClaimDrawer({
|
|||||||
serviceLines={data.serviceLines}
|
serviceLines={data.serviceLines}
|
||||||
lineReconciliation={data.lineReconciliation}
|
lineReconciliation={data.lineReconciliation}
|
||||||
/>
|
/>
|
||||||
|
{/* SP28: per-AK2 acknowledgments panel. Sits between
|
||||||
|
ServiceLines and Diagnoses so the operator's
|
||||||
|
eye-flow is "what was claimed → what came back
|
||||||
|
from the payer → who/what is on the claim". The
|
||||||
|
panel itself mounts the live-tail subscription
|
||||||
|
(per cyclone-frontend-page convention #3). */}
|
||||||
|
<AcknowledgmentsPanel claimId={data.id} />
|
||||||
<DiagnosesList diagnoses={data.diagnoses} />
|
<DiagnosesList diagnoses={data.diagnoses} />
|
||||||
<PartiesGrid parties={data.parties} />
|
<PartiesGrid parties={data.parties} />
|
||||||
{data.matchedRemittance ? (
|
{data.matchedRemittance ? (
|
||||||
|
|||||||
@@ -0,0 +1,396 @@
|
|||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// SP28: Inbox "Ack orphans" lane (D7).
|
||||||
|
//
|
||||||
|
// Mirrors the existing payer-rejected lane's chrome (oxblood accent,
|
||||||
|
// the same "lit from above" gradient) but renders AckOrphanRow
|
||||||
|
// shapes with per-row inline actions:
|
||||||
|
//
|
||||||
|
// - Dismiss — drop the orphan from the lane. The backend doesn't
|
||||||
|
// have a "dismiss" endpoint yet (the operator would normally
|
||||||
|
// just leave the orphan in place and run a manual match later),
|
||||||
|
// so for the first cut this only optimistically removes the row
|
||||||
|
// client-side and refetches. The affordance is present so the
|
||||||
|
// operator has a "clear this from my queue" gesture even
|
||||||
|
// without backend persistence.
|
||||||
|
// - Link to… — inline dropdown of the top-3 candidate claims
|
||||||
|
// returned by the backend's orphan endpoint. Picking one fires
|
||||||
|
// `api.matchAckToClaim` (the same manual-match endpoint used
|
||||||
|
// from the AckDrawer's MatchedClaimPanel).
|
||||||
|
//
|
||||||
|
// Live-tail: opens the `claim-acks` stream via `useTailStream` so a
|
||||||
|
// manual match made elsewhere (the AckDrawer panel or another tab)
|
||||||
|
// drops the corresponding row from this lane in real time. The
|
||||||
|
// subscription lives on the lane component (not the hook) per
|
||||||
|
// `cyclone-frontend-page` convention #3.
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import { Link2, X } from "lucide-react";
|
||||||
|
import { useNavigate } from "react-router-dom";
|
||||||
|
import { api, type AckOrphanRow } from "@/lib/api";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import { fmt } from "@/lib/format";
|
||||||
|
import { useTailStream } from "@/hooks/useTailStream";
|
||||||
|
import { useTailStore } from "@/store/tail-store";
|
||||||
|
|
||||||
|
function AckKindBadge({ kind }: { kind: AckOrphanRow["kind"] }) {
|
||||||
|
// Same palette as the existing AcksPage AckCodeBadge — uppercase,
|
||||||
|
// mono, color-coded by ack kind.
|
||||||
|
const cfg =
|
||||||
|
kind === "999"
|
||||||
|
? {
|
||||||
|
text: "hsl(var(--tt-amber))",
|
||||||
|
bg: "hsla(36, 75%, 58%, 0.10)",
|
||||||
|
border: "hsla(36, 75%, 58%, 0.30)",
|
||||||
|
}
|
||||||
|
: kind === "277ca"
|
||||||
|
? {
|
||||||
|
text: "hsl(var(--tt-ink-blue))",
|
||||||
|
bg: "hsla(212, 80%, 65%, 0.10)",
|
||||||
|
border: "hsla(212, 80%, 65%, 0.30)",
|
||||||
|
}
|
||||||
|
: {
|
||||||
|
text: "hsl(var(--tt-muted))",
|
||||||
|
bg: "hsla(220, 8%, 50%, 0.10)",
|
||||||
|
border: "hsla(220, 8%, 50%, 0.30)",
|
||||||
|
};
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className="inline-flex items-center rounded-sm border px-1.5 py-0.5 mono text-[9.5px] font-semibold uppercase tracking-[0.16em]"
|
||||||
|
style={{
|
||||||
|
color: cfg.text,
|
||||||
|
backgroundColor: cfg.bg,
|
||||||
|
borderColor: cfg.border,
|
||||||
|
}}
|
||||||
|
data-testid="ack-orphan-kind-badge"
|
||||||
|
data-kind={kind}
|
||||||
|
>
|
||||||
|
{kind === "277ca" ? "277CA" : kind.toUpperCase()}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function OrphanRow({
|
||||||
|
orphan,
|
||||||
|
onDismiss,
|
||||||
|
onLinked,
|
||||||
|
}: {
|
||||||
|
orphan: AckOrphanRow;
|
||||||
|
onDismiss: (id: number) => void;
|
||||||
|
onLinked: (orphanId: number) => void;
|
||||||
|
}) {
|
||||||
|
const [busy, setBusy] = useState<"dismiss" | "link" | null>(null);
|
||||||
|
const [linkError, setLinkError] = useState<string | null>(null);
|
||||||
|
const [showCandidates, setShowCandidates] = useState(false);
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
async function linkTo(claimId: string) {
|
||||||
|
setBusy("link");
|
||||||
|
setLinkError(null);
|
||||||
|
try {
|
||||||
|
await api.matchAckToClaim(orphan.kind, orphan.id, claimId);
|
||||||
|
setShowCandidates(false);
|
||||||
|
onLinked(orphan.id);
|
||||||
|
} catch (err) {
|
||||||
|
setLinkError(err instanceof Error ? err.message : String(err));
|
||||||
|
} finally {
|
||||||
|
setBusy(null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="flex flex-col gap-2 px-3 py-2.5"
|
||||||
|
style={{ borderBottom: "1px solid hsl(220 8% 14%)" }}
|
||||||
|
data-testid="ack-orphan-row"
|
||||||
|
data-orphan-id={orphan.id}
|
||||||
|
>
|
||||||
|
{/* Top row: PCN + kind badge + parsed-at. */}
|
||||||
|
<div className="flex items-baseline justify-between gap-2 flex-wrap">
|
||||||
|
<div className="flex items-center gap-2 min-w-0">
|
||||||
|
<AckKindBadge kind={orphan.kind} />
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
// Drill into the AckDrawer — the ack_id is encoded as
|
||||||
|
// ?ack=<id>. The Acks page mounts the AckDrawer so
|
||||||
|
// navigating there mirrors the rest of the inbox's
|
||||||
|
// cross-page drill behavior.
|
||||||
|
navigate(`/acks?ack=${encodeURIComponent(String(orphan.id))}`);
|
||||||
|
}}
|
||||||
|
className="mono truncate text-[12px] text-foreground hover:text-[color:var(--tt-amber)] transition-colors"
|
||||||
|
title={`${orphan.pcn} · source batch ${orphan.sourceBatchId}`}
|
||||||
|
data-testid="ack-orphan-pcn"
|
||||||
|
>
|
||||||
|
{orphan.pcn || "—"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<span className="mono text-[10px] text-[color:var(--tt-muted)]">
|
||||||
|
{orphan.parsedAt ? fmt.dateShort(orphan.parsedAt) : "—"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Source batch id — small line under the PCN for context. */}
|
||||||
|
<div className="mono text-[10px] text-[color:var(--tt-muted)] truncate">
|
||||||
|
{orphan.sourceBatchId}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Action row: Dismiss + Link to… */}
|
||||||
|
<div className="flex items-center gap-1.5 flex-wrap">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
setBusy("dismiss");
|
||||||
|
onDismiss(orphan.id);
|
||||||
|
}}
|
||||||
|
disabled={busy !== null}
|
||||||
|
data-testid="ack-orphan-dismiss"
|
||||||
|
className={cn(
|
||||||
|
"inline-flex items-center gap-1 rounded-sm border px-1.5 py-0.5 mono text-[10px] uppercase tracking-[0.14em] font-semibold transition-colors",
|
||||||
|
"border-[color:var(--tt-muted)]/60 bg-transparent text-[color:var(--tt-muted)] hover:bg-[color:var(--tt-oxblood)]/10 hover:border-[color:var(--tt-oxblood)]/60 hover:text-[color:var(--tt-oxblood)] disabled:opacity-50",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<X className="h-3 w-3" strokeWidth={1.75} aria-hidden />
|
||||||
|
Dismiss
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setShowCandidates((v) => !v)}
|
||||||
|
disabled={busy !== null}
|
||||||
|
data-testid="ack-orphan-link-toggle"
|
||||||
|
className={cn(
|
||||||
|
"inline-flex items-center gap-1 rounded-sm border px-1.5 py-0.5 mono text-[10px] uppercase tracking-[0.14em] font-semibold transition-colors",
|
||||||
|
showCandidates
|
||||||
|
? "border-[color:var(--tt-amber)] bg-[color:var(--tt-amber)]/10 text-[color:var(--tt-amber)]"
|
||||||
|
: "border-[color:var(--tt-amber)]/60 bg-transparent text-[color:var(--tt-amber)] hover:bg-[color:var(--tt-amber)]/10 disabled:opacity-50",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Link2 className="h-3 w-3" strokeWidth={1.75} aria-hidden />
|
||||||
|
{showCandidates ? "Cancel" : "Link to…"}
|
||||||
|
</button>
|
||||||
|
{orphan.candidates.length > 0 ? (
|
||||||
|
<span className="ml-auto mono text-[10px] text-[color:var(--tt-muted)]">
|
||||||
|
{orphan.candidates.length} candidate
|
||||||
|
{orphan.candidates.length === 1 ? "" : "s"}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Candidate dropdown — same compact-row treatment as the
|
||||||
|
candidates lane. Picking one fires `matchAckToClaim` and
|
||||||
|
refetches (the optimistic removal lands on success). */}
|
||||||
|
{showCandidates ? (
|
||||||
|
<div
|
||||||
|
className="flex flex-col gap-1 rounded-sm border p-1.5"
|
||||||
|
style={{
|
||||||
|
background: "hsla(36, 75%, 58%, 0.04)",
|
||||||
|
borderColor: "hsla(36, 75%, 58%, 0.30)",
|
||||||
|
}}
|
||||||
|
data-testid="ack-orphan-candidate-list"
|
||||||
|
>
|
||||||
|
{orphan.candidates.length === 0 ? (
|
||||||
|
<p className="mono text-[10.5px] text-[color:var(--tt-muted)] px-1.5 py-1">
|
||||||
|
No candidate claims scored.
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
orphan.candidates.map((c) => (
|
||||||
|
<button
|
||||||
|
key={c.claimId}
|
||||||
|
type="button"
|
||||||
|
disabled={busy !== null}
|
||||||
|
onClick={() => void linkTo(c.claimId)}
|
||||||
|
className={cn(
|
||||||
|
"flex items-center gap-2 rounded-sm px-1.5 py-1 text-left transition-colors hover:bg-[color:var(--tt-amber)]/10 disabled:opacity-50",
|
||||||
|
)}
|
||||||
|
data-testid="ack-orphan-candidate-option"
|
||||||
|
data-claim-id={c.claimId}
|
||||||
|
>
|
||||||
|
<span className="mono text-[11px] text-foreground truncate">
|
||||||
|
{c.claimId}
|
||||||
|
</span>
|
||||||
|
<span className="ml-auto mono text-[10px] text-[color:var(--tt-muted)]">
|
||||||
|
score {c.score.toFixed(2)}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{linkError ? (
|
||||||
|
<p
|
||||||
|
className="mono text-[10.5px] text-[color:var(--tt-oxblood)]"
|
||||||
|
data-testid="ack-orphan-link-error"
|
||||||
|
>
|
||||||
|
{linkError}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AckOrphansLane({
|
||||||
|
items,
|
||||||
|
loading,
|
||||||
|
error,
|
||||||
|
refetch,
|
||||||
|
}: {
|
||||||
|
items: AckOrphanRow[];
|
||||||
|
loading: boolean;
|
||||||
|
error: Error | null;
|
||||||
|
refetch: () => Promise<void>;
|
||||||
|
}) {
|
||||||
|
// Live-tail: open the claim-acks stream. When a manual match
|
||||||
|
// resolves anywhere in the app, the corresponding row lands in
|
||||||
|
// the slice and we drop it from the orphan lane optimistically.
|
||||||
|
useTailStream("claim-acks");
|
||||||
|
const claimAckOrder = useTailStore((s) => s.claimAckOrder);
|
||||||
|
const claimAcks = useTailStore((s) => s.claimAcks);
|
||||||
|
|
||||||
|
// Set of (kind, ack_id) tuples that have just been linked — when
|
||||||
|
// the live-tail store grows, any orphan whose (kind, id) is now
|
||||||
|
// present drops out of the lane until the next refetch confirms.
|
||||||
|
const [optimisticDismissed, setOptimisticDismissed] = useState<Set<string>>(
|
||||||
|
() => new Set(),
|
||||||
|
);
|
||||||
|
|
||||||
|
function orphanKey(o: AckOrphanRow): string {
|
||||||
|
return `${o.kind}:${o.id}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function dropLocal(id: number) {
|
||||||
|
setOptimisticDismissed((prev) => {
|
||||||
|
const orphan = items.find((o) => o.id === id);
|
||||||
|
if (!orphan) return prev;
|
||||||
|
const next = new Set(prev);
|
||||||
|
next.add(orphanKey(orphan));
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
// Refetch in the background so the lane converges with the
|
||||||
|
// server. The optimistic drop is purely visual.
|
||||||
|
void refetch();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Live-tail: drop orphans whose (kind, id) just landed in the
|
||||||
|
// claimAcks slice (i.e. a manual match fired elsewhere). We only
|
||||||
|
// act when claimAckOrder grows — the store is the source of truth.
|
||||||
|
const [prevOrderLen, setPrevOrderLen] = useState(claimAckOrder.length);
|
||||||
|
if (claimAckOrder.length > prevOrderLen) {
|
||||||
|
// Find newly-added links and dismiss orphans by (kind, ack_id).
|
||||||
|
const newKeys = claimAckOrder
|
||||||
|
.slice(prevOrderLen)
|
||||||
|
.map((i) => claimAcks[i])
|
||||||
|
.filter((row): row is NonNullable<typeof row> => Boolean(row))
|
||||||
|
.map((row) => `${row.ackKind}:${row.ackId}`);
|
||||||
|
if (newKeys.length > 0) {
|
||||||
|
const matched = items.filter((o) => newKeys.includes(orphanKey(o)));
|
||||||
|
if (matched.length > 0) {
|
||||||
|
setOptimisticDismissed((prev) => {
|
||||||
|
const next = new Set(prev);
|
||||||
|
matched.forEach((o) => next.add(orphanKey(o)));
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setPrevOrderLen(claimAckOrder.length);
|
||||||
|
}
|
||||||
|
|
||||||
|
const visible = items.filter((o) => !optimisticDismissed.has(orphanKey(o)));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section
|
||||||
|
className="flex-1 min-w-[280px] flex flex-col rounded-md overflow-hidden"
|
||||||
|
style={{
|
||||||
|
// Same "lit from above" gradient as the other lanes, with an
|
||||||
|
// oxblood hairline border to signal "this needs operator
|
||||||
|
// attention" without competing with the dashboard amber.
|
||||||
|
background:
|
||||||
|
"linear-gradient(180deg, hsl(220 18% 11.5%) 0%, hsl(220 18% 9.5%) 55%, hsl(220 18% 8.5%) 100%)",
|
||||||
|
border: "1px solid hsl(220 8% 18%)",
|
||||||
|
boxShadow:
|
||||||
|
"inset 0 1px 0 0 hsl(0 0% 100% / 0.05), 0 1px 2px 0 hsl(0 0% 0% / 0.4)",
|
||||||
|
}}
|
||||||
|
data-lane="ack-orphans"
|
||||||
|
>
|
||||||
|
<header
|
||||||
|
className="px-3 py-2.5 flex items-baseline justify-between gap-2"
|
||||||
|
style={{
|
||||||
|
borderBottom: "1px solid hsl(220 8% 16%)",
|
||||||
|
background:
|
||||||
|
"linear-gradient(180deg, hsl(220 16% 11%) 0%, hsl(220 18% 9%) 100%)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span
|
||||||
|
aria-hidden
|
||||||
|
className="inline-block h-2 w-2 rounded-full"
|
||||||
|
style={{
|
||||||
|
background: "var(--tt-oxblood)",
|
||||||
|
boxShadow: "0 0 8px var(--tt-oxblood)",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<h2
|
||||||
|
className="mono uppercase"
|
||||||
|
style={{
|
||||||
|
color: "var(--tt-ink)",
|
||||||
|
fontSize: 11,
|
||||||
|
letterSpacing: "0.18em",
|
||||||
|
fontWeight: 600,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
ACK ORPHANS
|
||||||
|
</h2>
|
||||||
|
</div>
|
||||||
|
<span
|
||||||
|
className="mono tabular-nums"
|
||||||
|
style={{
|
||||||
|
color: "var(--tt-amber)",
|
||||||
|
fontSize: 18,
|
||||||
|
fontWeight: 600,
|
||||||
|
lineHeight: 1,
|
||||||
|
}}
|
||||||
|
data-testid="ack-orphans-count"
|
||||||
|
>
|
||||||
|
{visible.length}
|
||||||
|
</span>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{error ? (
|
||||||
|
<p
|
||||||
|
className="px-4 py-6 mono text-center"
|
||||||
|
style={{ color: "var(--tt-oxblood)", fontSize: 11 }}
|
||||||
|
data-testid="ack-orphans-error"
|
||||||
|
>
|
||||||
|
{error.message}
|
||||||
|
</p>
|
||||||
|
) : loading ? (
|
||||||
|
<p
|
||||||
|
className="px-4 py-6 mono text-center"
|
||||||
|
style={{ color: "var(--tt-muted)", fontSize: 11 }}
|
||||||
|
data-testid="ack-orphans-loading"
|
||||||
|
>
|
||||||
|
loading orphans…
|
||||||
|
</p>
|
||||||
|
) : visible.length === 0 ? (
|
||||||
|
<p
|
||||||
|
className="px-4 py-6 mono text-center"
|
||||||
|
style={{ color: "var(--tt-muted)", fontSize: 11, letterSpacing: "0.06em" }}
|
||||||
|
data-testid="ack-orphans-empty"
|
||||||
|
>
|
||||||
|
No orphan acks.
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<div className="w-full">
|
||||||
|
{visible.map((orphan) => (
|
||||||
|
<OrphanRow
|
||||||
|
key={orphanKey(orphan)}
|
||||||
|
orphan={orphan}
|
||||||
|
onDismiss={dropLocal}
|
||||||
|
onLinked={() => dropLocal(orphan.id)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,188 @@
|
|||||||
|
// @vitest-environment happy-dom
|
||||||
|
// React Query's internal state updates need an `act`-aware environment
|
||||||
|
// or it logs noisy warnings. Mirror the useClaimDetail /
|
||||||
|
// useClaimAcks / useTailStream test convention.
|
||||||
|
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
||||||
|
|
||||||
|
import React, { act } from "react";
|
||||||
|
import { createRoot, type Root } from "react-dom/client";
|
||||||
|
import { describe, expect, it, vi, beforeEach } from "vitest";
|
||||||
|
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||||
|
import { useAckClaims } from "./useAckClaims";
|
||||||
|
import { api } from "@/lib/api";
|
||||||
|
|
||||||
|
// Module-level mock: vitest hoists `vi.mock` above imports. We mock the
|
||||||
|
// single `api.listAckClaims` method the hook calls.
|
||||||
|
vi.mock("@/lib/api", () => ({
|
||||||
|
api: {
|
||||||
|
listAckClaims: vi.fn(),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
/** Minimal `renderHook` shim — same pattern as `useClaimAcks.test.ts`. */
|
||||||
|
function renderHook<TResult>(
|
||||||
|
useCallback: () => TResult
|
||||||
|
): {
|
||||||
|
result: { current: TResult | undefined };
|
||||||
|
unmount: () => void;
|
||||||
|
} {
|
||||||
|
const result: { current: TResult | undefined } = { current: undefined };
|
||||||
|
const container = document.createElement("div");
|
||||||
|
document.body.appendChild(container);
|
||||||
|
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||||
|
|
||||||
|
function Probe() {
|
||||||
|
result.current = useCallback();
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const root: Root = createRoot(container);
|
||||||
|
act(() => {
|
||||||
|
root.render(
|
||||||
|
React.createElement(QueryClientProvider, { client: qc }, React.createElement(Probe))
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
result,
|
||||||
|
unmount: () => {
|
||||||
|
act(() => root.unmount());
|
||||||
|
container.remove();
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Poll the result until `predicate` is true or we time out. */
|
||||||
|
async function waitForState(
|
||||||
|
predicate: () => boolean,
|
||||||
|
timeoutMs = 1000
|
||||||
|
): Promise<void> {
|
||||||
|
const start = Date.now();
|
||||||
|
while (!predicate()) {
|
||||||
|
if (Date.now() - start > timeoutMs) {
|
||||||
|
throw new Error(
|
||||||
|
`waitForState: predicate did not hold within ${timeoutMs}ms`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
await act(async () => {
|
||||||
|
await Promise.resolve();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Sample fixture — one 999 with two AK2 set-responses, both auto-linked
|
||||||
|
* to the same claim. Mirrors the camelCase `ClaimAck` shape (the api
|
||||||
|
* layer's `mapClaimAck` does the snake_case→camelCase conversion). */
|
||||||
|
const SAMPLE_ROWS = [
|
||||||
|
{
|
||||||
|
id: 11,
|
||||||
|
claimId: "CLM-1",
|
||||||
|
batchId: null,
|
||||||
|
ackId: 99,
|
||||||
|
ackKind: "999" as const,
|
||||||
|
ak2Index: 0,
|
||||||
|
setControlNumber: "991102989",
|
||||||
|
setAcceptRejectCode: "A",
|
||||||
|
linkedAt: "2026-07-02T12:00:00Z",
|
||||||
|
linkedBy: "auto" as const,
|
||||||
|
claimState: "received",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 12,
|
||||||
|
claimId: "CLM-1",
|
||||||
|
batchId: null,
|
||||||
|
ackId: 99,
|
||||||
|
ackKind: "999" as const,
|
||||||
|
ak2Index: 1,
|
||||||
|
setControlNumber: "991102990",
|
||||||
|
setAcceptRejectCode: "R",
|
||||||
|
linkedAt: "2026-07-02T12:00:01Z",
|
||||||
|
linkedBy: "auto" as const,
|
||||||
|
claimState: "received",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
describe("useAckClaims", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("test_returns_idle_state_and_does_not_fetch_when_ackId_is_null", () => {
|
||||||
|
// Drawer closed → no network call, no loading, no error. The hook
|
||||||
|
// must short-circuit BEFORE the queryFn is ever invoked so a
|
||||||
|
// closed drawer is free.
|
||||||
|
const { result, unmount } = renderHook(() => useAckClaims("999", null));
|
||||||
|
|
||||||
|
expect(result.current).toBeDefined();
|
||||||
|
expect(result.current?.data).toBeNull();
|
||||||
|
expect(result.current?.isLoading).toBe(false);
|
||||||
|
expect(result.current?.isError).toBe(false);
|
||||||
|
expect(result.current?.error).toBeNull();
|
||||||
|
expect(typeof result.current?.refetch).toBe("function");
|
||||||
|
expect(api.listAckClaims).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
unmount();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("test_returns_idle_state_and_does_not_fetch_when_kind_is_null", () => {
|
||||||
|
// Defensive — the hook accepts `kind: ClaimAckKind | null` and
|
||||||
|
// short-circuits when it's null (mirrors the ackId null check).
|
||||||
|
const { result, unmount } = renderHook(() => useAckClaims(null, 42));
|
||||||
|
|
||||||
|
expect(result.current?.data).toBeNull();
|
||||||
|
expect(result.current?.isLoading).toBe(false);
|
||||||
|
expect(api.listAckClaims).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
unmount();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("test_fetches_and_returns_claim_ack_rows_for_a_valid_ack", async () => {
|
||||||
|
(api.listAckClaims as unknown as ReturnType<typeof vi.fn>).mockResolvedValue(
|
||||||
|
SAMPLE_ROWS,
|
||||||
|
);
|
||||||
|
|
||||||
|
const { result, unmount } = renderHook(() => useAckClaims("999", 99));
|
||||||
|
|
||||||
|
await waitForState(
|
||||||
|
() => result.current?.data !== null && result.current?.data !== undefined,
|
||||||
|
);
|
||||||
|
|
||||||
|
// The hook must have invoked the API exactly once with the kind+id.
|
||||||
|
expect(api.listAckClaims).toHaveBeenCalledTimes(1);
|
||||||
|
expect(api.listAckClaims).toHaveBeenCalledWith("999", 99);
|
||||||
|
|
||||||
|
const data = result.current?.data;
|
||||||
|
expect(data).not.toBeNull();
|
||||||
|
expect(data).toHaveLength(2);
|
||||||
|
// Both rows belong to the same ack (per-AK2 granularity for 999).
|
||||||
|
expect(data?.[0]?.ackId).toBe(99);
|
||||||
|
expect(data?.[0]?.ackKind).toBe("999");
|
||||||
|
expect(data?.[0]?.ak2Index).toBe(0);
|
||||||
|
expect(data?.[1]?.ak2Index).toBe(1);
|
||||||
|
|
||||||
|
// isLoading must flip false once the fetch resolves.
|
||||||
|
expect(result.current?.isLoading).toBe(false);
|
||||||
|
expect(result.current?.isError).toBe(false);
|
||||||
|
|
||||||
|
unmount();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("test_returns_empty_array_when_ack_has_no_claim_links", async () => {
|
||||||
|
(api.listAckClaims as unknown as ReturnType<typeof vi.fn>).mockResolvedValue(
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
const { result, unmount } = renderHook(() => useAckClaims("999", 77));
|
||||||
|
|
||||||
|
await waitForState(
|
||||||
|
() => result.current?.data !== null && result.current?.data !== undefined,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Empty list must be surfaced as [] (NOT null), so the AckDrawer
|
||||||
|
// panel can render its "Link to claim…" dropdown without a null
|
||||||
|
// check.
|
||||||
|
expect(result.current?.data).toEqual([]);
|
||||||
|
|
||||||
|
unmount();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import { api } from "@/lib/api";
|
||||||
|
import type { ClaimAck, ClaimAckKind } from "@/types";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Per-ack claims query (AckDrawer · SP28 §5.2).
|
||||||
|
*
|
||||||
|
* Lists every `claim_acks` row for the given ack. For 999/277CA,
|
||||||
|
* one entry per AK2 / ClaimStatus (D1 per-AK2 granularity). For
|
||||||
|
* TA1, one entry per envelope linked to the originating `Batch`.
|
||||||
|
* Drives the new `<MatchedClaim />` panel inside `AckDrawer`.
|
||||||
|
*
|
||||||
|
* Returns `{ data, isLoading, isError, error, refetch }`:
|
||||||
|
* - `ackId === null` (drawer closed): the query is disabled and the
|
||||||
|
* hook short-circuits to the empty drawer state `{ data: null, ... }`
|
||||||
|
* so a closed drawer doesn't burn a network request or a TanStack
|
||||||
|
* cache slot. The `AckDrawer` panel guards the `data ?? []` access
|
||||||
|
* on this and renders the "Link to claim…" dropdown when the list
|
||||||
|
* is empty.
|
||||||
|
* - `kind` and `ackId` set: fetches
|
||||||
|
* `GET /api/acks/{kind}/{ack_id}/claims` via `api.listAckClaims`.
|
||||||
|
* Cached 30 s — the link rows don't change once created (only
|
||||||
|
* manual-match / unlink mutate them, and those fire
|
||||||
|
* `claim_ack_written` / `claim_ack_dropped` events that the
|
||||||
|
* live-tail subscription delivers via `useTailStream("claim-acks")`).
|
||||||
|
* - On 404: the hook's retry predicate short-circuits (no retries)
|
||||||
|
* so the drawer's not-found state appears immediately rather than
|
||||||
|
* being masked by three back-to-back retry attempts.
|
||||||
|
*
|
||||||
|
* The live-tail subscription (`useTailStream("claim-acks")` +
|
||||||
|
* `useMergedTail("claim-acks", data ?? [], (link) => link.ackId === ackId)`)
|
||||||
|
* is mounted by the `AckDrawer` itself (per
|
||||||
|
* `cyclone-frontend-page` convention #3 — subscription on the page,
|
||||||
|
* not in the hook).
|
||||||
|
*/
|
||||||
|
export function useAckClaims(
|
||||||
|
kind: ClaimAckKind | null,
|
||||||
|
ackId: number | null,
|
||||||
|
): {
|
||||||
|
data: ClaimAck[] | null;
|
||||||
|
isLoading: boolean;
|
||||||
|
isError: boolean;
|
||||||
|
error: Error | null;
|
||||||
|
refetch: () => void;
|
||||||
|
} {
|
||||||
|
const q = useQuery<ClaimAck[]>({
|
||||||
|
queryKey: ["ack-claims", kind, ackId],
|
||||||
|
queryFn: () => api.listAckClaims(kind as ClaimAckKind, ackId as number),
|
||||||
|
enabled: kind !== null && ackId !== null,
|
||||||
|
staleTime: 30 * 1000,
|
||||||
|
retry: (failureCount, error) => {
|
||||||
|
// Don't retry 404 — same posture as the other detail hooks.
|
||||||
|
if (error instanceof Error && "status" in error) {
|
||||||
|
const status = (error as { status?: unknown }).status;
|
||||||
|
if (status === 404) return false;
|
||||||
|
}
|
||||||
|
return failureCount < 3;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (kind === null || ackId === null) {
|
||||||
|
return {
|
||||||
|
data: null,
|
||||||
|
isLoading: false,
|
||||||
|
isError: false,
|
||||||
|
error: null,
|
||||||
|
refetch: () => {},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
data: q.data ?? null,
|
||||||
|
isLoading: q.isLoading,
|
||||||
|
isError: q.isError,
|
||||||
|
error: q.error,
|
||||||
|
refetch: () => {
|
||||||
|
void q.refetch();
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// SP28: Inbox "Ack orphans" lane hook (D7).
|
||||||
|
//
|
||||||
|
// Fetches the three ack-kind orphan lists (999 / 277ca / ta1) and
|
||||||
|
// flattens them into a single lane of rows. The backend endpoint
|
||||||
|
// `/api/inbox/ack-orphans?kind=…` takes one kind at a time so the
|
||||||
|
// hook fires three parallel requests and merges by source_batch +
|
||||||
|
// ack_id (a single ack row never appears in two kinds' orphan lists,
|
||||||
|
// so the merge is just a concat).
|
||||||
|
//
|
||||||
|
// Refetch is wired through `useAckOrphans`'s `refetch` callback. The
|
||||||
|
// Inbox page calls it after a manual match lands (the orphan lane
|
||||||
|
// can drop a row that just got linked) — the same pattern the
|
||||||
|
// existing `useInboxLanes` uses for its tail-driven refetch.
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
import { useCallback, useEffect, useState } from "react";
|
||||||
|
import { api, type AckOrphanRow } from "@/lib/api";
|
||||||
|
|
||||||
|
export type AckOrphansState = {
|
||||||
|
loading: boolean;
|
||||||
|
error: Error | null;
|
||||||
|
items: AckOrphanRow[];
|
||||||
|
refetch: () => Promise<void>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function useAckOrphans(): AckOrphansState {
|
||||||
|
const [items, setItems] = useState<AckOrphanRow[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<Error | null>(null);
|
||||||
|
|
||||||
|
const refetch = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
// Fire all three kind queries in parallel — the backend serves
|
||||||
|
// each independently. Fail-soft per kind: if one of the three
|
||||||
|
// returns a 5xx we surface the error and skip that kind so the
|
||||||
|
// operator still sees the other two.
|
||||||
|
const [a999, a277, aTa1] = await Promise.allSettled([
|
||||||
|
api.listAckOrphans("999"),
|
||||||
|
api.listAckOrphans("277ca"),
|
||||||
|
api.listAckOrphans("ta1"),
|
||||||
|
]);
|
||||||
|
const merged: AckOrphanRow[] = [];
|
||||||
|
const errs: Error[] = [];
|
||||||
|
for (const r of [a999, a277, aTa1] as const) {
|
||||||
|
if (r.status === "fulfilled") {
|
||||||
|
merged.push(...r.value);
|
||||||
|
} else {
|
||||||
|
errs.push(
|
||||||
|
r.reason instanceof Error
|
||||||
|
? r.reason
|
||||||
|
: new Error(String(r.reason)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Sort newest-first by parsedAt so the lane reads in the same
|
||||||
|
// direction as the rest of the inbox.
|
||||||
|
merged.sort((a, b) => {
|
||||||
|
const at = Date.parse(a.parsedAt);
|
||||||
|
const bt = Date.parse(b.parsedAt);
|
||||||
|
return bt - at;
|
||||||
|
});
|
||||||
|
setItems(merged);
|
||||||
|
// Only surface an error if EVERY kind failed; partial failures
|
||||||
|
// show their rows and silently swallow the broken kind (the
|
||||||
|
// operator can retry — a future improvement could surface a
|
||||||
|
// per-kind "fetch failed" pill).
|
||||||
|
setError(errs.length === 3 ? errs[0] : null);
|
||||||
|
} catch (e) {
|
||||||
|
setError(e as Error);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void refetch();
|
||||||
|
}, [refetch]);
|
||||||
|
|
||||||
|
return { items, loading, error, refetch };
|
||||||
|
}
|
||||||
@@ -0,0 +1,176 @@
|
|||||||
|
// @vitest-environment happy-dom
|
||||||
|
// React Query's internal state updates need an `act`-aware environment
|
||||||
|
// or it logs noisy warnings. Mirror the useClaimDetail /
|
||||||
|
// useReconciliation / useTailStream test convention.
|
||||||
|
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
||||||
|
|
||||||
|
import React, { act } from "react";
|
||||||
|
import { createRoot, type Root } from "react-dom/client";
|
||||||
|
import { describe, expect, it, vi, beforeEach } from "vitest";
|
||||||
|
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||||
|
import { useClaimAcks } from "./useClaimAcks";
|
||||||
|
import { api } from "@/lib/api";
|
||||||
|
|
||||||
|
// Module-level mock: vitest hoists `vi.mock` above imports. We mock the
|
||||||
|
// single `api.listClaimAcks` method the hook calls.
|
||||||
|
vi.mock("@/lib/api", () => ({
|
||||||
|
api: {
|
||||||
|
listClaimAcks: vi.fn(),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
/** Minimal `renderHook` shim — same pattern as `useClaimDetail.test.ts`. */
|
||||||
|
function renderHook<TResult>(
|
||||||
|
useCallback: () => TResult
|
||||||
|
): {
|
||||||
|
result: { current: TResult | undefined };
|
||||||
|
unmount: () => void;
|
||||||
|
} {
|
||||||
|
const result: { current: TResult | undefined } = { current: undefined };
|
||||||
|
const container = document.createElement("div");
|
||||||
|
document.body.appendChild(container);
|
||||||
|
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||||
|
|
||||||
|
function Probe() {
|
||||||
|
result.current = useCallback();
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const root: Root = createRoot(container);
|
||||||
|
act(() => {
|
||||||
|
root.render(
|
||||||
|
React.createElement(QueryClientProvider, { client: qc }, React.createElement(Probe))
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
result,
|
||||||
|
unmount: () => {
|
||||||
|
act(() => root.unmount());
|
||||||
|
container.remove();
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Poll the result until `predicate` is true or we time out. */
|
||||||
|
async function waitForState(
|
||||||
|
predicate: () => boolean,
|
||||||
|
timeoutMs = 1000
|
||||||
|
): Promise<void> {
|
||||||
|
const start = Date.now();
|
||||||
|
while (!predicate()) {
|
||||||
|
if (Date.now() - start > timeoutMs) {
|
||||||
|
throw new Error(
|
||||||
|
`waitForState: predicate did not hold within ${timeoutMs}ms`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
await act(async () => {
|
||||||
|
await Promise.resolve();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Sample fixture matching the camelCase `ClaimAck` shape (the api
|
||||||
|
* layer's `mapClaimAck` does the snake_case→camelCase conversion, so
|
||||||
|
* the mock returns the post-conversion shape directly). */
|
||||||
|
const SAMPLE_ROWS = [
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
claimId: "CLM-1",
|
||||||
|
batchId: null,
|
||||||
|
ackId: 99,
|
||||||
|
ackKind: "999" as const,
|
||||||
|
ak2Index: 0,
|
||||||
|
setControlNumber: "991102989",
|
||||||
|
setAcceptRejectCode: "A",
|
||||||
|
linkedAt: "2026-07-02T12:00:00Z",
|
||||||
|
linkedBy: "auto" as const,
|
||||||
|
claimState: "received",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 2,
|
||||||
|
claimId: "CLM-1",
|
||||||
|
batchId: null,
|
||||||
|
ackId: 99,
|
||||||
|
ackKind: "999" as const,
|
||||||
|
ak2Index: 1,
|
||||||
|
setControlNumber: "991102990",
|
||||||
|
setAcceptRejectCode: "R",
|
||||||
|
linkedAt: "2026-07-02T12:00:01Z",
|
||||||
|
linkedBy: "auto" as const,
|
||||||
|
claimState: "received",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
describe("useClaimAcks", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("test_returns_idle_state_and_does_not_fetch_when_claimId_is_null", () => {
|
||||||
|
// Drawer closed → no network call, no loading, no error. The hook
|
||||||
|
// must short-circuit BEFORE the queryFn is ever invoked so a
|
||||||
|
// closed drawer is free.
|
||||||
|
const { result, unmount } = renderHook(() => useClaimAcks(null));
|
||||||
|
|
||||||
|
expect(result.current).toBeDefined();
|
||||||
|
expect(result.current?.data).toBeNull();
|
||||||
|
expect(result.current?.isLoading).toBe(false);
|
||||||
|
expect(result.current?.isError).toBe(false);
|
||||||
|
expect(result.current?.error).toBeNull();
|
||||||
|
expect(typeof result.current?.refetch).toBe("function");
|
||||||
|
expect(api.listClaimAcks).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
unmount();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("test_fetches_and_returns_claim_ack_rows_for_a_valid_claim_id", async () => {
|
||||||
|
(api.listClaimAcks as unknown as ReturnType<typeof vi.fn>).mockResolvedValue(
|
||||||
|
SAMPLE_ROWS,
|
||||||
|
);
|
||||||
|
|
||||||
|
const { result, unmount } = renderHook(() => useClaimAcks("CLM-1"));
|
||||||
|
|
||||||
|
await waitForState(
|
||||||
|
() => result.current?.data !== null && result.current?.data !== undefined,
|
||||||
|
);
|
||||||
|
|
||||||
|
// The hook must have invoked the API exactly once with the claim id.
|
||||||
|
expect(api.listClaimAcks).toHaveBeenCalledTimes(1);
|
||||||
|
expect(api.listClaimAcks).toHaveBeenCalledWith("CLM-1");
|
||||||
|
|
||||||
|
const data = result.current?.data;
|
||||||
|
expect(data).not.toBeNull();
|
||||||
|
expect(data).toHaveLength(2);
|
||||||
|
// Both rows belong to the same claim (per-AK2 granularity for 999).
|
||||||
|
expect(data?.[0]?.claimId).toBe("CLM-1");
|
||||||
|
expect(data?.[0]?.ackKind).toBe("999");
|
||||||
|
expect(data?.[0]?.setAcceptRejectCode).toBe("A");
|
||||||
|
expect(data?.[1]?.setAcceptRejectCode).toBe("R");
|
||||||
|
expect(data?.[1]?.ak2Index).toBe(1);
|
||||||
|
|
||||||
|
// isLoading must flip false once the fetch resolves.
|
||||||
|
expect(result.current?.isLoading).toBe(false);
|
||||||
|
expect(result.current?.isError).toBe(false);
|
||||||
|
|
||||||
|
unmount();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("test_returns_empty_array_when_claim_has_no_acks", async () => {
|
||||||
|
(api.listClaimAcks as unknown as ReturnType<typeof vi.fn>).mockResolvedValue(
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
const { result, unmount } = renderHook(() => useClaimAcks("CLM-empty"));
|
||||||
|
|
||||||
|
await waitForState(
|
||||||
|
() => result.current?.data !== null && result.current?.data !== undefined,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Empty list must be surfaced as [] (NOT null), so the ClaimDrawer
|
||||||
|
// panel can render its <EmptyState /> without a null check.
|
||||||
|
expect(result.current?.data).toEqual([]);
|
||||||
|
|
||||||
|
unmount();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import { api } from "@/lib/api";
|
||||||
|
import type { ClaimAck } from "@/types";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Per-claim acknowledgments query (ClaimDrawer · SP28 §5.1).
|
||||||
|
*
|
||||||
|
* Lists every `claim_acks` row for the given claim (per-claim only —
|
||||||
|
* the backend filters out TA1 batch-level rows with
|
||||||
|
* `claim_id IS NULL`). Drives the new `<Acknowledgments />` panel
|
||||||
|
* inside `ClaimDrawer`.
|
||||||
|
*
|
||||||
|
* Returns `{ data, isLoading, isError, error, refetch }`:
|
||||||
|
* - `claimId === null` (drawer closed): the query is disabled and the
|
||||||
|
* hook short-circuits to `{ data: null, ... }` so a closed drawer
|
||||||
|
* doesn't burn a network request or a TanStack cache slot. The
|
||||||
|
* `ClaimDrawer` panel guards the `data ?? []` access on this.
|
||||||
|
* - `claimId` is set: fetches
|
||||||
|
* `GET /api/claims/{claim_id}/acks` via `api.listClaimAcks`.
|
||||||
|
* Cached 30 s — the link rows don't change once created (only
|
||||||
|
* manual-match / unlink mutate them, and those fire
|
||||||
|
* `claim_ack_written` / `claim_ack_dropped` events that the
|
||||||
|
* live-tail subscription delivers via `useTailStream("claim-acks")`).
|
||||||
|
* - On 404: the hook's retry predicate short-circuits (no retries)
|
||||||
|
* so the drawer's not-found state appears immediately rather than
|
||||||
|
* being masked by three back-to-back retry attempts.
|
||||||
|
*
|
||||||
|
* The live-tail subscription (`useTailStream("claim-acks")` +
|
||||||
|
* `useMergedTail("claim-acks", data ?? [], (link) => link.claimId === claimId)`)
|
||||||
|
* is mounted by the `ClaimDrawer` itself (per
|
||||||
|
* `cyclone-frontend-page` convention #3 — subscription on the page,
|
||||||
|
* not in the hook).
|
||||||
|
*/
|
||||||
|
export function useClaimAcks(claimId: string | null): {
|
||||||
|
data: ClaimAck[] | null;
|
||||||
|
isLoading: boolean;
|
||||||
|
isError: boolean;
|
||||||
|
error: Error | null;
|
||||||
|
refetch: () => void;
|
||||||
|
} {
|
||||||
|
const q = useQuery<ClaimAck[]>({
|
||||||
|
queryKey: ["claim-acks", claimId],
|
||||||
|
queryFn: () => api.listClaimAcks(claimId as string),
|
||||||
|
enabled: claimId !== null,
|
||||||
|
staleTime: 30 * 1000,
|
||||||
|
retry: (failureCount, error) => {
|
||||||
|
// Don't retry 404 — same posture as the other detail hooks. The
|
||||||
|
// drawer treats 404 as a distinct "claim doesn't exist" state
|
||||||
|
// and doesn't want retries masking it.
|
||||||
|
if (error instanceof Error && "status" in error) {
|
||||||
|
const status = (error as { status?: unknown }).status;
|
||||||
|
if (status === 404) return false;
|
||||||
|
}
|
||||||
|
return failureCount < 3;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (claimId === null) {
|
||||||
|
return {
|
||||||
|
data: null,
|
||||||
|
isLoading: false,
|
||||||
|
isError: false,
|
||||||
|
error: null,
|
||||||
|
refetch: () => {},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
data: q.data ?? null,
|
||||||
|
isLoading: q.isLoading,
|
||||||
|
isError: q.isError,
|
||||||
|
error: q.error,
|
||||||
|
refetch: () => {
|
||||||
|
void q.refetch();
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -7,7 +7,14 @@ import { createRoot, type Root } from "react-dom/client";
|
|||||||
import { beforeEach, describe, expect, it } from "vitest";
|
import { beforeEach, describe, expect, it } from "vitest";
|
||||||
import { useMergedTail } from "./useMergedTail";
|
import { useMergedTail } from "./useMergedTail";
|
||||||
import { useTailStore } from "@/store/tail-store";
|
import { useTailStore } from "@/store/tail-store";
|
||||||
import type { Ack, Claim, Remittance, Activity, Ta1Ack } from "@/types";
|
import type {
|
||||||
|
Ack,
|
||||||
|
Claim,
|
||||||
|
ClaimAck,
|
||||||
|
Remittance,
|
||||||
|
Activity,
|
||||||
|
Ta1Ack,
|
||||||
|
} from "@/types";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Same `renderHook` shim used in `useClaimDetail`, `useDrawerUrlState`,
|
* Same `renderHook` shim used in `useClaimDetail`, `useDrawerUrlState`,
|
||||||
@@ -116,6 +123,22 @@ function ta1Ack(id: number, controlNumber: string): Ta1Ack {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function claimAck(id: number, claimId: string): ClaimAck {
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
claimId,
|
||||||
|
batchId: null,
|
||||||
|
ackId: 99,
|
||||||
|
ackKind: "999",
|
||||||
|
ak2Index: 0,
|
||||||
|
setControlNumber: "991102989",
|
||||||
|
setAcceptRejectCode: "A",
|
||||||
|
linkedAt: "2026-07-02T12:00:00Z",
|
||||||
|
linkedBy: "auto",
|
||||||
|
claimState: "received",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
describe("useMergedTail", () => {
|
describe("useMergedTail", () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
// Singleton store — clear each slice between tests so cases are
|
// Singleton store — clear each slice between tests so cases are
|
||||||
@@ -125,6 +148,7 @@ describe("useMergedTail", () => {
|
|||||||
useTailStore.getState().reset("activity");
|
useTailStore.getState().reset("activity");
|
||||||
useTailStore.getState().reset("acks");
|
useTailStore.getState().reset("acks");
|
||||||
useTailStore.getState().reset("ta1_acks");
|
useTailStore.getState().reset("ta1_acks");
|
||||||
|
useTailStore.getState().reset("claim-acks");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("test_merges_base_and_tail_by_id_base_first", () => {
|
it("test_merges_base_and_tail_by_id_base_first", () => {
|
||||||
@@ -304,4 +328,62 @@ describe("useMergedTail", () => {
|
|||||||
|
|
||||||
unmount();
|
unmount();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// SP28: the claim↔ack link slice. Same keyed-by-id shape as the SP25
|
||||||
|
// acks/ta1_acks branches — numeric id, ordered arrival via
|
||||||
|
// `claimAckOrder`, deduped against baseItems by `String(id)`. The
|
||||||
|
// ClaimDrawer and AckDrawer both mount this slice and apply a
|
||||||
|
// page-specific filter (e.g. `link.claimId === claimId`) so the merge
|
||||||
|
// shape must remain symmetric with the existing branches.
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
it("test_claim_acks_resource_orders_by_claim_ack_order_and_dedupes", () => {
|
||||||
|
const base = [claimAck(1, "CLM-A"), claimAck(2, "CLM-A")];
|
||||||
|
const { addClaimAck } = useTailStore.getState();
|
||||||
|
addClaimAck(claimAck(2, "CLM-A-replay")); // duplicate id — must be dropped
|
||||||
|
addClaimAck(claimAck(3, "CLM-B"));
|
||||||
|
addClaimAck(claimAck(4, "CLM-B"));
|
||||||
|
|
||||||
|
const { result, unmount } = renderHook(() =>
|
||||||
|
useMergedTail("claim-acks", base),
|
||||||
|
);
|
||||||
|
|
||||||
|
const merged = result.current ?? [];
|
||||||
|
// Base first, then tail in arrival order. Tail's "2" is dropped
|
||||||
|
// because the base already has id 2.
|
||||||
|
expect(merged.map((a) => a.id)).toEqual([1, 2, 3, 4]);
|
||||||
|
// Base version wins for id 2.
|
||||||
|
expect(merged[1]?.claimId).toBe("CLM-A");
|
||||||
|
|
||||||
|
unmount();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("test_claim_acks_filter_predicate_drops_tail_items_that_dont_match", () => {
|
||||||
|
// The ClaimDrawer panel mounts a filter `link.claimId === claimId`
|
||||||
|
// so only this claim's acks show up. Verify the merge keeps the
|
||||||
|
// filter AFTER dedup against baseItems (mirrors the SP25 claim
|
||||||
|
// branch shape).
|
||||||
|
const base = [claimAck(1, "CLM-A")];
|
||||||
|
const { addClaimAck } = useTailStore.getState();
|
||||||
|
addClaimAck(claimAck(2, "CLM-A"));
|
||||||
|
addClaimAck(claimAck(3, "CLM-B"));
|
||||||
|
addClaimAck(claimAck(4, "CLM-A"));
|
||||||
|
|
||||||
|
const { result, unmount } = renderHook(() =>
|
||||||
|
useMergedTail(
|
||||||
|
"claim-acks",
|
||||||
|
base,
|
||||||
|
(link) => link.claimId === "CLM-A",
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
const merged = result.current ?? [];
|
||||||
|
// Filter only applies to tail (base is kept verbatim by contract);
|
||||||
|
// tail's id 3 (CLM-B) is filtered out, leaving 2 and 4 in
|
||||||
|
// arrival order after the base 1.
|
||||||
|
expect(merged.map((a) => a.id)).toEqual([1, 2, 4]);
|
||||||
|
|
||||||
|
unmount();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -20,6 +20,9 @@
|
|||||||
// - SP25: the `id` type is widened to `string | number` because `Ack`
|
// - SP25: the `id` type is widened to `string | number` because `Ack`
|
||||||
// and `Ta1Ack` use numeric database ids while `Claim` / `Remittance`
|
// and `Ta1Ack` use numeric database ids while `Claim` / `Remittance`
|
||||||
// use string ids. Dedup normalizes via `String(...)`.
|
// use string ids. Dedup normalizes via `String(...)`.
|
||||||
|
// - SP28: `ClaimAck` adds another numeric-id slice (`claimAcks` /
|
||||||
|
// `claimAckOrder`). Same dedup normalization applies — `String(id)`
|
||||||
|
// keeps the symmetric comparison the hook has always used.
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
import { useTailStore } from "@/store/tail-store";
|
import { useTailStore } from "@/store/tail-store";
|
||||||
@@ -78,6 +81,19 @@ export function useMergedTail<T extends { id: string | number }>(
|
|||||||
}
|
}
|
||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
|
// SP28: the claim↔ack link slice. Same keyed-by-id shape as
|
||||||
|
// acks/ta1_acks (numeric id from the `claim_acks` table), so the
|
||||||
|
// dispatch is symmetric — the page-specific filter (e.g.
|
||||||
|
// `link.claimId === claimId`) is applied AFTER dedup so the
|
||||||
|
// base list's rows aren't accidentally dropped.
|
||||||
|
case "claim-acks": {
|
||||||
|
const out: unknown[] = [];
|
||||||
|
for (const id of s.claimAckOrder) {
|
||||||
|
const v = s.claimAcks[String(id)];
|
||||||
|
if (v !== undefined) out.push(v);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ import {
|
|||||||
} from "vitest";
|
} from "vitest";
|
||||||
import { useTailStream } from "./useTailStream";
|
import { useTailStream } from "./useTailStream";
|
||||||
import { useTailStore } from "@/store/tail-store";
|
import { useTailStore } from "@/store/tail-store";
|
||||||
import type { Ack, Claim, Ta1Ack } from "@/types";
|
import type { Ack, Claim, ClaimAck, Ta1Ack } from "@/types";
|
||||||
import type { TailEvent } from "@/lib/tail-stream";
|
import type { TailEvent } from "@/lib/tail-stream";
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -232,6 +232,22 @@ function makeTa1Ack(id: number, controlNumber: string): Ta1Ack {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function makeClaimAck(id: number, claimId: string): ClaimAck {
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
claimId,
|
||||||
|
batchId: null,
|
||||||
|
ackId: 99,
|
||||||
|
ackKind: "999",
|
||||||
|
ak2Index: 0,
|
||||||
|
setControlNumber: "991102989",
|
||||||
|
setAcceptRejectCode: "A",
|
||||||
|
linkedAt: "2026-07-02T12:00:00Z",
|
||||||
|
linkedBy: "auto",
|
||||||
|
claimState: "received",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
describe("useTailStream", () => {
|
describe("useTailStream", () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
mockStreamTail.mockReset();
|
mockStreamTail.mockReset();
|
||||||
@@ -242,6 +258,7 @@ describe("useTailStream", () => {
|
|||||||
useTailStore.getState().reset("activity");
|
useTailStore.getState().reset("activity");
|
||||||
useTailStore.getState().reset("acks");
|
useTailStore.getState().reset("acks");
|
||||||
useTailStore.getState().reset("ta1_acks");
|
useTailStore.getState().reset("ta1_acks");
|
||||||
|
useTailStore.getState().reset("claim-acks");
|
||||||
});
|
});
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
@@ -486,4 +503,37 @@ describe("useTailStream", () => {
|
|||||||
|
|
||||||
unmount();
|
unmount();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// SP28: the claim↔ack link stream. Both ClaimDrawer's <Acknowledgments>
|
||||||
|
// panel and AckDrawer's <MatchedClaim> panel mount useTailStream with
|
||||||
|
// resource "claim-acks"; the dispatcher must route the live event into
|
||||||
|
// the claimAcks slice (separate from acks/ta1_acks).
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
it("test_claim_acks_item_event_lands_in_claim_acks_slice", async () => {
|
||||||
|
const { ctrls } = trackCalls();
|
||||||
|
const { unmount } = renderHook(() => useTailStream("claim-acks"));
|
||||||
|
|
||||||
|
await waitFor(() => ctrls.length === 1);
|
||||||
|
ctrls[0].push({ type: "snapshot_end", data: { count: 0 } });
|
||||||
|
await waitFor(() => useTailStore.getState().claimAcks !== undefined);
|
||||||
|
|
||||||
|
ctrls[0].push({
|
||||||
|
type: "item",
|
||||||
|
data: makeClaimAck(101, "CLM-42"),
|
||||||
|
});
|
||||||
|
|
||||||
|
await waitFor(
|
||||||
|
() => useTailStore.getState().claimAcks["101"] !== undefined,
|
||||||
|
);
|
||||||
|
const stored = useTailStore.getState().claimAcks["101"];
|
||||||
|
expect(stored).toBeDefined();
|
||||||
|
expect(stored?.claimId).toBe("CLM-42");
|
||||||
|
// Must NOT bleed into the acks slice — keyed by id, separate dicts.
|
||||||
|
expect(useTailStore.getState().acks["101"]).toBeUndefined();
|
||||||
|
expect(useTailStore.getState().ta1Acks["101"]).toBeUndefined();
|
||||||
|
|
||||||
|
unmount();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -27,7 +27,14 @@
|
|||||||
import { useCallback, useEffect, useRef, useState } from "react";
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
import { streamTail, type TailResource } from "@/lib/tail-stream";
|
import { streamTail, type TailResource } from "@/lib/tail-stream";
|
||||||
import { useTailStore } from "@/store/tail-store";
|
import { useTailStore } from "@/store/tail-store";
|
||||||
import type { Ack, Activity, Claim, Remittance, Ta1Ack } from "@/types";
|
import type {
|
||||||
|
Ack,
|
||||||
|
Activity,
|
||||||
|
Claim,
|
||||||
|
ClaimAck,
|
||||||
|
Remittance,
|
||||||
|
Ta1Ack,
|
||||||
|
} from "@/types";
|
||||||
|
|
||||||
export type TailStatus =
|
export type TailStatus =
|
||||||
| "connecting"
|
| "connecting"
|
||||||
@@ -85,6 +92,13 @@ function dispatch(resource: TailResource, data: unknown): void {
|
|||||||
case "ta1_acks":
|
case "ta1_acks":
|
||||||
store.addTa1Ack(data as Ta1Ack);
|
store.addTa1Ack(data as Ta1Ack);
|
||||||
break;
|
break;
|
||||||
|
// SP28: the claim↔ack link slice. Both ClaimDrawer and AckDrawer
|
||||||
|
// mount this stream so a manual-match link made on one drawer
|
||||||
|
// shows up live on the other. The dispatch is the same
|
||||||
|
// first-write-wins pattern as the SP25 acks/ta1_acks branches.
|
||||||
|
case "claim-acks":
|
||||||
|
store.addClaimAck(data as ClaimAck);
|
||||||
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+181
@@ -25,6 +25,8 @@
|
|||||||
import type {
|
import type {
|
||||||
Ack,
|
Ack,
|
||||||
BatchDiff,
|
BatchDiff,
|
||||||
|
ClaimAck,
|
||||||
|
ClaimAckKind,
|
||||||
ClaimDetail,
|
ClaimDetail,
|
||||||
ClaimOutput,
|
ClaimOutput,
|
||||||
ClaimPayment,
|
ClaimPayment,
|
||||||
@@ -47,6 +49,7 @@ import type {
|
|||||||
import {
|
import {
|
||||||
authedFetch,
|
authedFetch,
|
||||||
authedFetchResponse,
|
authedFetchResponse,
|
||||||
|
joinUrl,
|
||||||
} from "@/auth/api";
|
} from "@/auth/api";
|
||||||
|
|
||||||
const BASE_URL = (import.meta.env.VITE_API_BASE_URL as string | undefined) ?? "";
|
const BASE_URL = (import.meta.env.VITE_API_BASE_URL as string | undefined) ?? "";
|
||||||
@@ -854,6 +857,11 @@ interface RawAckRow {
|
|||||||
received_count: number;
|
received_count: number;
|
||||||
ack_code: "A" | "E" | "R" | "P";
|
ack_code: "A" | "E" | "R" | "P";
|
||||||
parsed_at: string;
|
parsed_at: string;
|
||||||
|
/**
|
||||||
|
* SP28: distinct claim ids this 999 ack is linked to. Empty
|
||||||
|
* array when the auto-linker couldn't resolve (orphan).
|
||||||
|
*/
|
||||||
|
linked_claim_ids?: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
function mapAck(row: RawAckRow): Ack {
|
function mapAck(row: RawAckRow): Ack {
|
||||||
@@ -865,6 +873,7 @@ function mapAck(row: RawAckRow): Ack {
|
|||||||
receivedCount: row.received_count,
|
receivedCount: row.received_count,
|
||||||
ackCode: row.ack_code,
|
ackCode: row.ack_code,
|
||||||
parsedAt: row.parsed_at,
|
parsedAt: row.parsed_at,
|
||||||
|
linkedClaimIds: row.linked_claim_ids ?? [],
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -929,6 +938,11 @@ interface RawTa1Row {
|
|||||||
receiver_id: string | null;
|
receiver_id: string | null;
|
||||||
source_batch_id: string;
|
source_batch_id: string;
|
||||||
parsed_at: string | null;
|
parsed_at: string | null;
|
||||||
|
/**
|
||||||
|
* SP28: originating `Batch` ids this TA1 envelope is linked to.
|
||||||
|
* Empty array when the auto-linker couldn't resolve (orphan).
|
||||||
|
*/
|
||||||
|
linked_claim_ids?: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
function mapTa1Ack(row: RawTa1Row): Ta1Ack {
|
function mapTa1Ack(row: RawTa1Row): Ta1Ack {
|
||||||
@@ -943,6 +957,7 @@ function mapTa1Ack(row: RawTa1Row): Ta1Ack {
|
|||||||
receiverId: row.receiver_id,
|
receiverId: row.receiver_id,
|
||||||
sourceBatchId: row.source_batch_id,
|
sourceBatchId: row.source_batch_id,
|
||||||
parsedAt: row.parsed_at,
|
parsedAt: row.parsed_at,
|
||||||
|
linkedClaimIds: row.linked_claim_ids ?? [],
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -968,6 +983,167 @@ async function listTa1Acks(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// SP28: claim↔ack link rows.
|
||||||
|
// The `claim_acks` table is the durable record of which 999 / 277CA / TA1
|
||||||
|
// acknowledged which claim (or, for TA1, which originating 837 `Batch`).
|
||||||
|
// Three read endpoints (`listClaimAcks`, `listAckClaims`, `listAckOrphans`)
|
||||||
|
// plus the manual-match + unmatch write endpoints. The wire shape mirrors
|
||||||
|
// `to_ui_claim_ack` (backend/src/cyclone/store/ui.py) — camelCase keys,
|
||||||
|
// ISO Z timestamps, numeric ids from the database row.
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
interface RawClaimAckRow {
|
||||||
|
id: number;
|
||||||
|
claim_id: string | null;
|
||||||
|
batch_id: string | null;
|
||||||
|
ack_id: number;
|
||||||
|
ack_kind: ClaimAckKind;
|
||||||
|
ak2_index: number | null;
|
||||||
|
set_control_number: string | null;
|
||||||
|
set_accept_reject_code: string | null;
|
||||||
|
linked_at: string;
|
||||||
|
linked_by: "auto" | "manual";
|
||||||
|
claim_state?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function mapClaimAck(row: RawClaimAckRow): ClaimAck {
|
||||||
|
return {
|
||||||
|
id: row.id,
|
||||||
|
claimId: row.claim_id,
|
||||||
|
batchId: row.batch_id,
|
||||||
|
ackId: row.ack_id,
|
||||||
|
ackKind: row.ack_kind,
|
||||||
|
ak2Index: row.ak2_index,
|
||||||
|
setControlNumber: row.set_control_number,
|
||||||
|
setAcceptRejectCode: row.set_accept_reject_code,
|
||||||
|
linkedAt: row.linked_at,
|
||||||
|
linkedBy: row.linked_by,
|
||||||
|
claimState: row.claim_state,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `GET /api/claims/{claim_id}/acks` — list every `claim_acks` row
|
||||||
|
* for the given claim (per-claim only; TA1 batch-level rows with
|
||||||
|
* `claim_id IS NULL` are filtered out by the backend).
|
||||||
|
*
|
||||||
|
* Drives `ClaimDrawer`'s new `<Acknowledgments />` panel. Returns
|
||||||
|
* an array even on the empty case — the panel renders
|
||||||
|
* `<EmptyState />` when the list is empty (no live-tail data).
|
||||||
|
*/
|
||||||
|
async function listClaimAcks(claimId: string): Promise<ClaimAck[]> {
|
||||||
|
if (!isConfigured) throw notConfiguredError();
|
||||||
|
const body = await authedFetch<{ items: RawClaimAckRow[] }>(
|
||||||
|
`/api/claims/${encodeURIComponent(claimId)}/acks`,
|
||||||
|
);
|
||||||
|
return body.items.map(mapClaimAck);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `GET /api/acks/{kind}/{ack_id}/claims` — list every `claim_acks`
|
||||||
|
* row for the given ack. For 999/277CA, one entry per AK2 /
|
||||||
|
* ClaimStatus (D1 per-AK2 granularity). For TA1, one entry per
|
||||||
|
* envelope linked to the originating `Batch`.
|
||||||
|
*
|
||||||
|
* Drives `AckDrawer`'s new `<MatchedClaim />` panel. Returns an
|
||||||
|
* array; the panel renders the "Link to claim…" dropdown when the
|
||||||
|
* list is empty (no auto-link resolved).
|
||||||
|
*/
|
||||||
|
async function listAckClaims(
|
||||||
|
kind: ClaimAckKind,
|
||||||
|
ackId: number,
|
||||||
|
): Promise<ClaimAck[]> {
|
||||||
|
if (!isConfigured) throw notConfiguredError();
|
||||||
|
const body = await authedFetch<{ items: RawClaimAckRow[] }>(
|
||||||
|
`/api/acks/${encodeURIComponent(kind)}/${encodeURIComponent(String(ackId))}/claims`,
|
||||||
|
);
|
||||||
|
return body.items.map(mapClaimAck);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `POST /api/acks/{kind}/{ack_id}/match-claim` — manual-match fallback
|
||||||
|
* (D5). Any logged-in user can run this (D9 differs explicitly from
|
||||||
|
* the admin-only remit-orphans posture — the metadata-only nature of
|
||||||
|
* an ack link doesn't warrant admin gating).
|
||||||
|
*
|
||||||
|
* Idempotent on the server: re-running with the same `claim_id`
|
||||||
|
* returns the existing row with HTTP 200. Returns 409 if the target
|
||||||
|
* claim is in a terminal state (REVERSED).
|
||||||
|
*/
|
||||||
|
async function matchAckToClaim(
|
||||||
|
kind: ClaimAckKind,
|
||||||
|
ackId: number,
|
||||||
|
claimId: string,
|
||||||
|
): Promise<ClaimAck> {
|
||||||
|
if (!isConfigured) throw notConfiguredError();
|
||||||
|
const body = await authedFetch<RawClaimAckRow>(
|
||||||
|
`/api/acks/${encodeURIComponent(kind)}/${encodeURIComponent(String(ackId))}/match-claim`,
|
||||||
|
{
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ claim_id: claimId }),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
return mapClaimAck(body);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `DELETE /api/acks/{kind}/{ack_id}/match-claim/{claim_id}` — unlink.
|
||||||
|
* Removes the `claim_acks` row and publishes a `claim_ack_dropped`
|
||||||
|
* event; does NOT revert any `Claim.state` mutation the ack may
|
||||||
|
* have triggered (e.g. an `apply_999_rejections` flip to REJECTED
|
||||||
|
* stays — unlinking is metadata-only, per spec §D5/§D6).
|
||||||
|
*/
|
||||||
|
async function unmatchAck(
|
||||||
|
kind: ClaimAckKind,
|
||||||
|
ackId: number,
|
||||||
|
claimId: string,
|
||||||
|
): Promise<void> {
|
||||||
|
if (!isConfigured) throw notConfiguredError();
|
||||||
|
await authedFetch<void>(
|
||||||
|
`/api/acks/${encodeURIComponent(kind)}/${encodeURIComponent(String(ackId))}/match-claim/${encodeURIComponent(claimId)}`,
|
||||||
|
{ method: "DELETE" },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One row in the Inbox "Ack orphans" lane (D7). Mirrors the wire
|
||||||
|
* shape of `GET /api/inbox/ack-orphans?kind=…`. ``candidates`` is a
|
||||||
|
* top-3 list of relaxed PCN matches so the operator can decide
|
||||||
|
* whether to dismiss or manually link.
|
||||||
|
*/
|
||||||
|
export interface AckOrphanRow {
|
||||||
|
id: number;
|
||||||
|
kind: ClaimAckKind;
|
||||||
|
pcn: string;
|
||||||
|
sourceBatchId: string;
|
||||||
|
parsedAt: string;
|
||||||
|
candidates: Array<{
|
||||||
|
claimId: string;
|
||||||
|
score: number;
|
||||||
|
tier: "strong" | "weak" | "hidden";
|
||||||
|
}>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `GET /api/inbox/ack-orphans?kind=…` — list acks that the
|
||||||
|
* auto-linker couldn't resolve to a claim. Powers the Inbox
|
||||||
|
* "Ack orphans" lane (mirrors the existing "Payer-rejected" lane
|
||||||
|
* shape; D7).
|
||||||
|
*/
|
||||||
|
async function listAckOrphans(kind: ClaimAckKind): Promise<AckOrphanRow[]> {
|
||||||
|
if (!isConfigured) throw notConfiguredError();
|
||||||
|
const body = await authedFetch<{
|
||||||
|
items: AckOrphanRow[];
|
||||||
|
total: number;
|
||||||
|
}>(`/api/inbox/ack-orphans${qs({ kind })}`);
|
||||||
|
// Defensive: the test harness can stub fetch with a different
|
||||||
|
// response shape. Treat a missing `items` as an empty list so a
|
||||||
|
// broken mock doesn't crash the whole inbox.
|
||||||
|
return Array.isArray(body.items) ? body.items : [];
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Download a ZIP of regenerated X12 837 files for a parsed batch.
|
* Download a ZIP of regenerated X12 837 files for a parsed batch.
|
||||||
*
|
*
|
||||||
@@ -1063,5 +1239,10 @@ export const api = {
|
|||||||
listAcks,
|
listAcks,
|
||||||
getAck,
|
getAck,
|
||||||
listTa1Acks,
|
listTa1Acks,
|
||||||
|
listClaimAcks,
|
||||||
|
listAckClaims,
|
||||||
|
matchAckToClaim,
|
||||||
|
unmatchAck,
|
||||||
|
listAckOrphans,
|
||||||
getDashboardKpis,
|
getDashboardKpis,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -127,6 +127,30 @@ describe("streamTail", () => {
|
|||||||
expect(calledUrl).toBe("/api/ta1-acks/stream");
|
expect(calledUrl).toBe("/api/ta1-acks/stream");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("test_targets_claim_acks_stream_endpoint", async () => {
|
||||||
|
// SP28: the `claim-acks` resource is part of TailResource; the URL
|
||||||
|
// must be /api/claim-acks/stream. The claim↔ack link stream is a
|
||||||
|
// shared resource (not per-claim or per-ack) — both the ClaimDrawer
|
||||||
|
// and AckDrawer subscribe to the same endpoint and filter
|
||||||
|
// server-side via the page-specific filterFn passed to
|
||||||
|
// `useMergedTail`.
|
||||||
|
const driver = makeStream();
|
||||||
|
const fetchMock = vi.fn().mockResolvedValue(driver.response);
|
||||||
|
vi.stubGlobal("fetch", fetchMock);
|
||||||
|
// Drive an event so the iterator's first read resolves — same
|
||||||
|
// pattern as `test_parses_well_formed_ndjson_into_typed_events`
|
||||||
|
// (the `test_targets_acks_stream_endpoint` shape hangs in CI
|
||||||
|
// because it awaits `gen.next()` before any chunk arrives).
|
||||||
|
const gen = streamTail("claim-acks");
|
||||||
|
driver.push('{"type":"snapshot_end","data":{"count":0}}');
|
||||||
|
driver.close();
|
||||||
|
await gen.next();
|
||||||
|
await gen.return?.(undefined);
|
||||||
|
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||||
|
const [calledUrl] = fetchMock.mock.calls[0] as [string];
|
||||||
|
expect(calledUrl).toBe("/api/claim-acks/stream");
|
||||||
|
});
|
||||||
|
|
||||||
it("test_handles_heartbeat_silently", async () => {
|
it("test_handles_heartbeat_silently", async () => {
|
||||||
// "Silently" here means: still yields the correctly-typed heartbeat
|
// "Silently" here means: still yields the correctly-typed heartbeat
|
||||||
// (no special filtering) — the consumer (useTailStream) decides what
|
// (no special filtering) — the consumer (useTailStream) decides what
|
||||||
|
|||||||
@@ -26,7 +26,13 @@ export type TailEvent =
|
|||||||
| { type: "item_dropped"; data: { id: string } }
|
| { type: "item_dropped"; data: { id: string } }
|
||||||
| { type: "error"; data: { message: string } };
|
| { type: "error"; data: { message: string } };
|
||||||
|
|
||||||
export type TailResource = "claims" | "remittances" | "activity" | "acks" | "ta1_acks";
|
export type TailResource =
|
||||||
|
| "claims"
|
||||||
|
| "remittances"
|
||||||
|
| "activity"
|
||||||
|
| "acks"
|
||||||
|
| "ta1_acks"
|
||||||
|
| "claim-acks";
|
||||||
|
|
||||||
export interface StreamTailOptions {
|
export interface StreamTailOptions {
|
||||||
/** Forwarded to `fetch`; aborting the controller cleanly exits the generator. */
|
/** Forwarded to `fetch`; aborting the controller cleanly exits the generator. */
|
||||||
|
|||||||
+160
-1
@@ -6,6 +6,7 @@ import React, { act } from "react";
|
|||||||
import { createRoot, type Root } from "react-dom/client";
|
import { createRoot, type Root } from "react-dom/client";
|
||||||
import { describe, expect, it, vi, beforeEach } from "vitest";
|
import { describe, expect, it, vi, beforeEach } from "vitest";
|
||||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||||
|
import { MemoryRouter } from "react-router-dom";
|
||||||
import { Acks } from "./Acks";
|
import { Acks } from "./Acks";
|
||||||
import { api } from "@/lib/api";
|
import { api } from "@/lib/api";
|
||||||
|
|
||||||
@@ -49,8 +50,20 @@ function renderIntoContainer(element: React.ReactElement): {
|
|||||||
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||||
const root: Root = createRoot(container);
|
const root: Root = createRoot(container);
|
||||||
act(() => {
|
act(() => {
|
||||||
|
// SP28: the AckDrawer mounts the new <MatchedClaim /> panel
|
||||||
|
// which calls `useNavigate()` for cross-page navigation to
|
||||||
|
// /claims?claim=<id>. The hook requires a router context, so
|
||||||
|
// the test harness wraps the page in <MemoryRouter>.
|
||||||
root.render(
|
root.render(
|
||||||
React.createElement(QueryClientProvider, { client: qc }, element),
|
React.createElement(
|
||||||
|
QueryClientProvider,
|
||||||
|
{ client: qc },
|
||||||
|
React.createElement(
|
||||||
|
MemoryRouter,
|
||||||
|
null,
|
||||||
|
element,
|
||||||
|
),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
return {
|
return {
|
||||||
@@ -685,4 +698,150 @@ describe("Acks", () => {
|
|||||||
|
|
||||||
unmount();
|
unmount();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// SP28: the "Claims" column on the 999 register. The badge carries
|
||||||
|
// the distinct-claim link count (per-AK2 granularity, D1). Empty
|
||||||
|
// link count → "Orphan" amber pill. Single link → muted "1 claim"
|
||||||
|
// text. Multi link → success "{count} claims" badge.
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
it("test_claims_badge_shows_orphan_when_no_linked_claims", async () => {
|
||||||
|
(api.listAcks as unknown as ReturnType<typeof vi.fn>).mockResolvedValue({
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
id: 42,
|
||||||
|
sourceBatchId: "b-orphan",
|
||||||
|
acceptedCount: 1,
|
||||||
|
rejectedCount: 0,
|
||||||
|
receivedCount: 1,
|
||||||
|
ackCode: "A",
|
||||||
|
parsedAt: "2026-06-20T12:00:00Z",
|
||||||
|
linkedClaimIds: [],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
total: 1,
|
||||||
|
returned: 1,
|
||||||
|
has_more: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
const { unmount } = renderIntoContainer(React.createElement(Acks));
|
||||||
|
await waitForText("b-orphan");
|
||||||
|
|
||||||
|
// An orphan ack renders the warning "Orphan" badge — the
|
||||||
|
// operator's cue that the auto-link didn't resolve and they
|
||||||
|
// may need to drill in to run a manual match.
|
||||||
|
const orphanBadge = document.body.querySelector(
|
||||||
|
'[data-testid="claims-badge-orphan"]',
|
||||||
|
);
|
||||||
|
expect(orphanBadge).not.toBeNull();
|
||||||
|
expect(orphanBadge?.textContent).toContain("Orphan");
|
||||||
|
|
||||||
|
unmount();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("test_claims_badge_shows_single_link_text_for_one_claim", async () => {
|
||||||
|
(api.listAcks as unknown as ReturnType<typeof vi.fn>).mockResolvedValue({
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
id: 43,
|
||||||
|
sourceBatchId: "b-single",
|
||||||
|
acceptedCount: 2,
|
||||||
|
rejectedCount: 0,
|
||||||
|
receivedCount: 2,
|
||||||
|
ackCode: "A",
|
||||||
|
parsedAt: "2026-06-20T12:00:00Z",
|
||||||
|
linkedClaimIds: ["CLM-1"],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
total: 1,
|
||||||
|
returned: 1,
|
||||||
|
has_more: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
const { unmount } = renderIntoContainer(React.createElement(Acks));
|
||||||
|
await waitForText("b-single");
|
||||||
|
|
||||||
|
// Single link → muted mono text "1 claim" (not a colored pill —
|
||||||
|
// a single resolved link is the common case and doesn't warrant
|
||||||
|
// visual emphasis).
|
||||||
|
const singleBadge = document.body.querySelector(
|
||||||
|
'[data-testid="claims-badge-single"]',
|
||||||
|
);
|
||||||
|
expect(singleBadge).not.toBeNull();
|
||||||
|
expect(singleBadge?.textContent).toContain("1 claim");
|
||||||
|
// Not the orphan variant.
|
||||||
|
expect(
|
||||||
|
document.body.querySelector('[data-testid="claims-badge-orphan"]'),
|
||||||
|
).toBeNull();
|
||||||
|
|
||||||
|
unmount();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("test_claims_badge_shows_count_for_multiple_linked_claims", async () => {
|
||||||
|
// A 999 with three AK2 set-responses, each linked to a different
|
||||||
|
// claim batch, gets a multi-link badge.
|
||||||
|
(api.listAcks as unknown as ReturnType<typeof vi.fn>).mockResolvedValue({
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
id: 44,
|
||||||
|
sourceBatchId: "b-multi",
|
||||||
|
acceptedCount: 3,
|
||||||
|
rejectedCount: 0,
|
||||||
|
receivedCount: 3,
|
||||||
|
ackCode: "A",
|
||||||
|
parsedAt: "2026-06-20T12:00:00Z",
|
||||||
|
linkedClaimIds: ["CLM-1", "CLM-2", "CLM-3"],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
total: 1,
|
||||||
|
returned: 1,
|
||||||
|
has_more: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
const { unmount } = renderIntoContainer(React.createElement(Acks));
|
||||||
|
await waitForText("b-multi");
|
||||||
|
|
||||||
|
// Multi link → success "{N} claims" badge.
|
||||||
|
const manyBadge = document.body.querySelector(
|
||||||
|
'[data-testid="claims-badge-many"]',
|
||||||
|
);
|
||||||
|
expect(manyBadge).not.toBeNull();
|
||||||
|
expect(manyBadge?.textContent).toContain("3 claims");
|
||||||
|
expect(manyBadge?.getAttribute("data-claims-count")).toBe("3");
|
||||||
|
|
||||||
|
unmount();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("test_claims_badge_treats_undefined_linked_claim_ids_as_orphan", async () => {
|
||||||
|
// Defensive: older ack rows (pre-SP28) might not carry
|
||||||
|
// `linkedClaimIds` at all. The page must render them as
|
||||||
|
// orphan (count == 0) rather than crashing on undefined.length.
|
||||||
|
(api.listAcks as unknown as ReturnType<typeof vi.fn>).mockResolvedValue({
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
id: 45,
|
||||||
|
sourceBatchId: "b-legacy",
|
||||||
|
acceptedCount: 1,
|
||||||
|
rejectedCount: 0,
|
||||||
|
receivedCount: 1,
|
||||||
|
ackCode: "A",
|
||||||
|
parsedAt: "2026-06-20T12:00:00Z",
|
||||||
|
// linkedClaimIds intentionally omitted (legacy row)
|
||||||
|
},
|
||||||
|
],
|
||||||
|
total: 1,
|
||||||
|
returned: 1,
|
||||||
|
has_more: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
const { unmount } = renderIntoContainer(React.createElement(Acks));
|
||||||
|
await waitForText("b-legacy");
|
||||||
|
|
||||||
|
const orphanBadge = document.body.querySelector(
|
||||||
|
'[data-testid="claims-badge-orphan"]',
|
||||||
|
);
|
||||||
|
expect(orphanBadge).not.toBeNull();
|
||||||
|
|
||||||
|
unmount();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
+83
-1
@@ -1,5 +1,5 @@
|
|||||||
import { useCallback, useMemo, useState } from "react";
|
import { useCallback, useMemo, useState } from "react";
|
||||||
import { CheckCircle2, Download, Mail, ShieldCheck } from "lucide-react";
|
import { CheckCircle2, Download, Link2, Mail, ShieldCheck } from "lucide-react";
|
||||||
import {
|
import {
|
||||||
Table,
|
Table,
|
||||||
TableBody,
|
TableBody,
|
||||||
@@ -374,6 +374,14 @@ export function Acks() {
|
|||||||
/>
|
/>
|
||||||
<TableHead>ID</TableHead>
|
<TableHead>ID</TableHead>
|
||||||
<TableHead>Source Batch</TableHead>
|
<TableHead>Source Batch</TableHead>
|
||||||
|
{/* SP28: "Claims" column. The badge carries
|
||||||
|
the distinct-claim link count (per-AK2
|
||||||
|
granularity — D1 — so a 999 with two AK2s
|
||||||
|
and one linked claim still shows "1").
|
||||||
|
Empty cell means orphan (no auto-link
|
||||||
|
resolved); on click the operator can run
|
||||||
|
a manual match from the AckDrawer. */}
|
||||||
|
<TableHead>Claims</TableHead>
|
||||||
<TableHead className="text-right">
|
<TableHead className="text-right">
|
||||||
Accepted
|
Accepted
|
||||||
</TableHead>
|
</TableHead>
|
||||||
@@ -438,6 +446,9 @@ export function Acks() {
|
|||||||
a.sourceBatchId
|
a.sourceBatchId
|
||||||
)}
|
)}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<ClaimsBadge count={a.linkedClaimIds?.length ?? 0} />
|
||||||
|
</TableCell>
|
||||||
<TableCell className="text-right display mono text-[hsl(var(--success))]">
|
<TableCell className="text-right display mono text-[hsl(var(--success))]">
|
||||||
{a.acceptedCount}
|
{a.acceptedCount}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
@@ -750,6 +761,14 @@ function Ta1AcksSection() {
|
|||||||
<TableRow>
|
<TableRow>
|
||||||
<TableHead className="w-10" aria-label="Status" />
|
<TableHead className="w-10" aria-label="Status" />
|
||||||
<TableHead>Control #</TableHead>
|
<TableHead>Control #</TableHead>
|
||||||
|
{/* SP28: TA1 acks also surface a "Claims" badge
|
||||||
|
(it's really a Batches badge — TA1 is
|
||||||
|
envelope-level and links to originating
|
||||||
|
Batch rows per D4 — but the operator-facing
|
||||||
|
column header is "Claims" to match the 999
|
||||||
|
register's vocabulary). Distinct-batch
|
||||||
|
count, just like the 999 column. */}
|
||||||
|
<TableHead>Batches</TableHead>
|
||||||
<TableHead>Ack</TableHead>
|
<TableHead>Ack</TableHead>
|
||||||
<TableHead>Note</TableHead>
|
<TableHead>Note</TableHead>
|
||||||
<TableHead>Interchange date</TableHead>
|
<TableHead>Interchange date</TableHead>
|
||||||
@@ -776,6 +795,9 @@ function Ta1AcksSection() {
|
|||||||
<TableCell className="display mono text-[13px] text-foreground">
|
<TableCell className="display mono text-[13px] text-foreground">
|
||||||
{t.controlNumber || "—"}
|
{t.controlNumber || "—"}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<ClaimsBadge count={t.linkedClaimIds?.length ?? 0} />
|
||||||
|
</TableCell>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
<Ta1CodeBadge code={t.ackCode} />
|
<Ta1CodeBadge code={t.ackCode} />
|
||||||
</TableCell>
|
</TableCell>
|
||||||
@@ -842,3 +864,63 @@ function Ta1CodeBadge({ code }: { code: Ta1Ack["ackCode"] }) {
|
|||||||
</span>
|
</span>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// ClaimsBadge — SP28. Cell content for the "Claims" column on the 999
|
||||||
|
// register and the "Batches" column on the TA1 register.
|
||||||
|
//
|
||||||
|
// The badge carries the distinct-claim link count (per-AK2 granularity
|
||||||
|
// — D1 — so a 999 with two AK2s and one linked claim still shows "1").
|
||||||
|
//
|
||||||
|
// - count == 0 → amber "Orphan" pill. The ack has no resolved links;
|
||||||
|
// the operator can drill in and run a manual match.
|
||||||
|
// - count == 1 → muted mono text "{count} claim". Single-link case;
|
||||||
|
// the operator can click through to the matched ClaimDrawer.
|
||||||
|
// - count > 1 → success "{count} claims" badge. Multiple distinct
|
||||||
|
// claims resolved — usually a 999 with several AK2s each pointing
|
||||||
|
// to a different claim batch.
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
function ClaimsBadge({ count }: { count: number }) {
|
||||||
|
if (count === 0) {
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className="inline-flex items-center gap-1 rounded-sm border px-2 py-0.5 mono text-[10.5px] font-semibold uppercase tracking-[0.14em]"
|
||||||
|
style={{
|
||||||
|
color: "hsl(var(--warning))",
|
||||||
|
backgroundColor: "hsl(var(--warning) / 0.10)",
|
||||||
|
borderColor: "hsl(var(--warning) / 0.30)",
|
||||||
|
}}
|
||||||
|
data-testid="claims-badge-orphan"
|
||||||
|
title="No auto-link resolved — click to manually match"
|
||||||
|
>
|
||||||
|
<Link2 className="h-3 w-3" strokeWidth={1.75} aria-hidden />
|
||||||
|
Orphan
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (count === 1) {
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className="mono text-[11.5px] text-foreground"
|
||||||
|
data-testid="claims-badge-single"
|
||||||
|
data-claims-count="1"
|
||||||
|
>
|
||||||
|
1 claim
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className="inline-flex items-center gap-1 rounded-sm border px-2 py-0.5 mono text-[10.5px] font-semibold uppercase tracking-[0.14em]"
|
||||||
|
style={{
|
||||||
|
color: "hsl(var(--success))",
|
||||||
|
backgroundColor: "hsl(var(--success) / 0.10)",
|
||||||
|
borderColor: "hsl(var(--success) / 0.30)",
|
||||||
|
}}
|
||||||
|
data-testid="claims-badge-many"
|
||||||
|
data-claims-count={count}
|
||||||
|
>
|
||||||
|
{count} claims
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -563,4 +563,210 @@ describe("Inbox page", () => {
|
|||||||
// the row but did not drill into the claim drawer.
|
// the row but did not drill into the claim drawer.
|
||||||
expect(view.tracker.pathname).toBe("/inbox");
|
expect(view.tracker.pathname).toBe("/inbox");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// SP28: the new "ACK ORPHANS" lane (D7). Mirrors the payer-rejected
|
||||||
|
// lane shape; per-row "Dismiss" + "Link to…" actions; empty state
|
||||||
|
// when no orphans; live-tail integration drops a row that just got
|
||||||
|
// linked via the manual-match endpoint.
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* URL-aware fetch mock for the inbox page. Routes `/api/inbox/lanes`
|
||||||
|
* to the supplied `lanes`, and `/api/inbox/ack-orphans?kind=<k>` to
|
||||||
|
* the matching subset of `orphans`. The hook fires three parallel
|
||||||
|
* requests (one per kind: 999 / 277ca / ta1), so the mock must
|
||||||
|
* filter by `kind` to avoid returning the same payload three times.
|
||||||
|
* Default orphan response is empty so tests that don't care about
|
||||||
|
* the new lane just get a clean "No orphan acks." placeholder.
|
||||||
|
*/
|
||||||
|
function makeInboxFetch(
|
||||||
|
lanes: Record<string, unknown>,
|
||||||
|
orphans: Array<{ kind: string } & Record<string, unknown>> = [],
|
||||||
|
) {
|
||||||
|
return vi.fn().mockImplementation(async (url: string) => {
|
||||||
|
if (url.includes("/api/inbox/ack-orphans")) {
|
||||||
|
// Pull the `kind` query param out of the URL so the mock
|
||||||
|
// returns only the orphans for the requested kind.
|
||||||
|
const m = /[?&]kind=([^&]+)/.exec(url);
|
||||||
|
const requestedKind = m?.[1] ?? "";
|
||||||
|
const filtered = orphans.filter((o) => o.kind === requestedKind);
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
json: async () => ({ items: filtered, total: filtered.length }),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return { ok: true, json: async () => lanes };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
it("test_ack_orphans_lane_renders_empty_state_when_no_orphans", async () => {
|
||||||
|
vi.stubGlobal("fetch", makeInboxFetch({
|
||||||
|
rejected: [], payer_rejected: [], candidates: [], unmatched: [], done_today: [],
|
||||||
|
}));
|
||||||
|
const view = renderInbox();
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(view.container.textContent).toContain("ACK ORPHANS");
|
||||||
|
});
|
||||||
|
// Empty-state copy must surface — the lane's friendly "nothing
|
||||||
|
// here" treatment rather than a quiet section.
|
||||||
|
expect(view.container.textContent).toContain("No orphan acks.");
|
||||||
|
// No rows rendered.
|
||||||
|
expect(
|
||||||
|
view.container.querySelectorAll('[data-testid="ack-orphan-row"]').length
|
||||||
|
).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("test_ack_orphans_lane_renders_one_row_per_orphan", async () => {
|
||||||
|
vi.stubGlobal(
|
||||||
|
"fetch",
|
||||||
|
makeInboxFetch(
|
||||||
|
{
|
||||||
|
rejected: [],
|
||||||
|
payer_rejected: [],
|
||||||
|
candidates: [],
|
||||||
|
unmatched: [],
|
||||||
|
done_today: [],
|
||||||
|
},
|
||||||
|
[
|
||||||
|
{
|
||||||
|
id: 100,
|
||||||
|
kind: "999",
|
||||||
|
pcn: "991102989",
|
||||||
|
sourceBatchId: "b-orphan-1",
|
||||||
|
parsedAt: "2026-07-01T12:00:00Z",
|
||||||
|
candidates: [{ claimId: "CLM-1", score: 0.92, tier: "strong" }],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 200,
|
||||||
|
kind: "ta1",
|
||||||
|
pcn: "000000001",
|
||||||
|
sourceBatchId: "b-orphan-2",
|
||||||
|
parsedAt: "2026-07-01T11:00:00Z",
|
||||||
|
candidates: [],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
const view = renderInbox();
|
||||||
|
await waitFor(() => {
|
||||||
|
// Wait until both rows are in the DOM.
|
||||||
|
expect(
|
||||||
|
view.container.querySelectorAll('[data-testid="ack-orphan-row"]')
|
||||||
|
.length,
|
||||||
|
).toBe(2);
|
||||||
|
});
|
||||||
|
// PCN is rendered verbatim — the operator's primary identifier.
|
||||||
|
expect(view.container.textContent).toContain("991102989");
|
||||||
|
expect(view.container.textContent).toContain("000000001");
|
||||||
|
// The kind badge is visible per row.
|
||||||
|
const badges = view.container.querySelectorAll(
|
||||||
|
'[data-testid="ack-orphan-kind-badge"]',
|
||||||
|
);
|
||||||
|
expect(badges.length).toBe(2);
|
||||||
|
// The header count is 2 (matches the row count).
|
||||||
|
expect(
|
||||||
|
view.container.querySelector('[data-testid="ack-orphans-count"]')
|
||||||
|
?.textContent,
|
||||||
|
).toBe("2");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("test_ack_orphans_lane_dismiss_button_drops_row_optimistically", async () => {
|
||||||
|
const orphans = [
|
||||||
|
{
|
||||||
|
id: 300,
|
||||||
|
kind: "277ca",
|
||||||
|
pcn: "X-1",
|
||||||
|
sourceBatchId: "b-orphan-3",
|
||||||
|
parsedAt: "2026-07-01T12:00:00Z",
|
||||||
|
candidates: [],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
vi.stubGlobal(
|
||||||
|
"fetch",
|
||||||
|
makeInboxFetch(
|
||||||
|
{
|
||||||
|
rejected: [],
|
||||||
|
payer_rejected: [],
|
||||||
|
candidates: [],
|
||||||
|
unmatched: [],
|
||||||
|
done_today: [],
|
||||||
|
},
|
||||||
|
orphans,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
const view = renderInbox();
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(
|
||||||
|
view.container.querySelector('[data-testid="ack-orphan-row"]'),
|
||||||
|
).not.toBeNull();
|
||||||
|
});
|
||||||
|
// Click the dismiss button — the row drops optimistically.
|
||||||
|
const dismissBtn = view.container.querySelector(
|
||||||
|
'[data-testid="ack-orphan-dismiss"]',
|
||||||
|
) as HTMLButtonElement | null;
|
||||||
|
expect(dismissBtn).not.toBeNull();
|
||||||
|
await act(async () => {
|
||||||
|
dismissBtn!.click();
|
||||||
|
});
|
||||||
|
// Empty-state copy surfaces now.
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(view.container.textContent).toContain("No orphan acks.");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("test_ack_orphans_lane_link_to_dropdown_lists_candidates", async () => {
|
||||||
|
const orphans = [
|
||||||
|
{
|
||||||
|
id: 400,
|
||||||
|
kind: "999",
|
||||||
|
pcn: "Y-1",
|
||||||
|
sourceBatchId: "b-orphan-4",
|
||||||
|
parsedAt: "2026-07-01T12:00:00Z",
|
||||||
|
candidates: [
|
||||||
|
{ claimId: "CLM-A", score: 0.95, tier: "strong" },
|
||||||
|
{ claimId: "CLM-B", score: 0.62, tier: "weak" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
vi.stubGlobal(
|
||||||
|
"fetch",
|
||||||
|
makeInboxFetch(
|
||||||
|
{
|
||||||
|
rejected: [],
|
||||||
|
payer_rejected: [],
|
||||||
|
candidates: [],
|
||||||
|
unmatched: [],
|
||||||
|
done_today: [],
|
||||||
|
},
|
||||||
|
orphans,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
const view = renderInbox();
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(
|
||||||
|
view.container.querySelector('[data-testid="ack-orphan-row"]'),
|
||||||
|
).not.toBeNull();
|
||||||
|
});
|
||||||
|
// Open the candidate list.
|
||||||
|
const toggle = view.container.querySelector(
|
||||||
|
'[data-testid="ack-orphan-link-toggle"]',
|
||||||
|
) as HTMLButtonElement | null;
|
||||||
|
expect(toggle).not.toBeNull();
|
||||||
|
await act(async () => {
|
||||||
|
toggle!.click();
|
||||||
|
});
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(
|
||||||
|
view.container.querySelector('[data-testid="ack-orphan-candidate-list"]'),
|
||||||
|
).not.toBeNull();
|
||||||
|
});
|
||||||
|
// Two candidate options render — one per backend-supplied match.
|
||||||
|
const options = view.container.querySelectorAll(
|
||||||
|
'[data-testid="ack-orphan-candidate-option"]',
|
||||||
|
);
|
||||||
|
expect(options.length).toBe(2);
|
||||||
|
expect(options[0]?.getAttribute("data-claim-id")).toBe("CLM-A");
|
||||||
|
expect(options[1]?.getAttribute("data-claim-id")).toBe("CLM-B");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -18,9 +18,11 @@ import { cn } from "@/lib/utils";
|
|||||||
import { Lane, type LaneRow } from "@/components/inbox/Lane";
|
import { Lane, type LaneRow } from "@/components/inbox/Lane";
|
||||||
import { InboxHeader } from "@/components/inbox/InboxHeader";
|
import { InboxHeader } from "@/components/inbox/InboxHeader";
|
||||||
import { BulkBar } from "@/components/inbox/BulkBar";
|
import { BulkBar } from "@/components/inbox/BulkBar";
|
||||||
|
import { AckOrphansLane } from "@/components/inbox/AckOrphansLane";
|
||||||
import { RemitDrawer } from "@/components/RemitDrawer";
|
import { RemitDrawer } from "@/components/RemitDrawer";
|
||||||
import { useRemitDrawerUrlState } from "@/hooks/useRemitDrawerUrlState";
|
import { useRemitDrawerUrlState } from "@/hooks/useRemitDrawerUrlState";
|
||||||
import { useInboxLanes } from "@/hooks/useInboxLanes";
|
import { useInboxLanes } from "@/hooks/useInboxLanes";
|
||||||
|
import { useAckOrphans } from "@/hooks/useAckOrphans";
|
||||||
import {
|
import {
|
||||||
exportInboxCsvUrl,
|
exportInboxCsvUrl,
|
||||||
dismissCandidates,
|
dismissCandidates,
|
||||||
@@ -44,6 +46,17 @@ function rowKey(row: LaneRow): string {
|
|||||||
|
|
||||||
export default function Inbox() {
|
export default function Inbox() {
|
||||||
const { lanes, loading, error, refetch } = useInboxLanes();
|
const { lanes, loading, error, refetch } = useInboxLanes();
|
||||||
|
// SP28: ack orphans lane (D7). The hook fires three parallel
|
||||||
|
// requests (one per ack kind: 999 / 277ca / ta1) and merges the
|
||||||
|
// results. The `refetch` is wired into the AckOrphansLane's
|
||||||
|
// per-row "Dismiss" / "Link to…" actions so the lane converges
|
||||||
|
// after a manual match.
|
||||||
|
const {
|
||||||
|
items: ackOrphans,
|
||||||
|
loading: ackOrphansLoading,
|
||||||
|
error: ackOrphansError,
|
||||||
|
refetch: refetchAckOrphans,
|
||||||
|
} = useAckOrphans();
|
||||||
// SP21 Phase 4 Task 4.4: drill-down from inbox rows. The hook reads
|
// SP21 Phase 4 Task 4.4: drill-down from inbox rows. The hook reads
|
||||||
// `?remit=` off `window.location.search` so opening a row from the
|
// `?remit=` off `window.location.search` so opening a row from the
|
||||||
// /inbox URL pushes `?remit=ID` onto /inbox itself (it doesn't
|
// /inbox URL pushes `?remit=ID` onto /inbox itself (it doesn't
|
||||||
@@ -305,6 +318,21 @@ export default function Inbox() {
|
|||||||
}}
|
}}
|
||||||
onSelectionChange={(ids) => setLaneSelected("payer_rejected", ids)}
|
onSelectionChange={(ids) => setLaneSelected("payer_rejected", ids)}
|
||||||
/>
|
/>
|
||||||
|
{/*
|
||||||
|
SP28: ack-orphans lane (D7). Sits between payer-rejected
|
||||||
|
and candidates so the operator's eye-flow groups all
|
||||||
|
rejection-class triage items together (envelope → payer →
|
||||||
|
ack orphans) before the reconciliation opportunities.
|
||||||
|
Custom lane component (not the shared <Lane />) because
|
||||||
|
each row carries per-row "Dismiss" + "Link to…" actions
|
||||||
|
that don't fit the shared selection-checkbox model.
|
||||||
|
*/}
|
||||||
|
<AckOrphansLane
|
||||||
|
items={ackOrphans}
|
||||||
|
loading={ackOrphansLoading}
|
||||||
|
error={ackOrphansError}
|
||||||
|
refetch={refetchAckOrphans}
|
||||||
|
/>
|
||||||
<Lane
|
<Lane
|
||||||
name="CANDIDATES"
|
name="CANDIDATES"
|
||||||
accent="amber"
|
accent="amber"
|
||||||
|
|||||||
@@ -5,7 +5,14 @@
|
|||||||
// state shape.
|
// state shape.
|
||||||
|
|
||||||
import { beforeEach, describe, expect, it } from "vitest";
|
import { beforeEach, describe, expect, it } from "vitest";
|
||||||
import type { Ack, Activity, Claim, Remittance, Ta1Ack } from "@/types";
|
import type {
|
||||||
|
Ack,
|
||||||
|
Activity,
|
||||||
|
Claim,
|
||||||
|
ClaimAck,
|
||||||
|
Remittance,
|
||||||
|
Ta1Ack,
|
||||||
|
} from "@/types";
|
||||||
import { TAIL_CAP, useTailStore } from "./tail-store";
|
import { TAIL_CAP, useTailStore } from "./tail-store";
|
||||||
|
|
||||||
function makeClaim(id: string, overrides: Partial<Claim> = {}): Claim {
|
function makeClaim(id: string, overrides: Partial<Claim> = {}): Claim {
|
||||||
@@ -77,6 +84,23 @@ function makeTa1Ack(id: number, overrides: Partial<Ta1Ack> = {}): Ta1Ack {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function makeClaimAck(id: number, overrides: Partial<ClaimAck> = {}): ClaimAck {
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
claimId: "CLM-1",
|
||||||
|
batchId: null,
|
||||||
|
ackId: 99,
|
||||||
|
ackKind: "999",
|
||||||
|
ak2Index: 0,
|
||||||
|
setControlNumber: "991102989",
|
||||||
|
setAcceptRejectCode: "A",
|
||||||
|
linkedAt: "2026-07-02T12:00:00Z",
|
||||||
|
linkedBy: "auto",
|
||||||
|
claimState: "received",
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
describe("useTailStore", () => {
|
describe("useTailStore", () => {
|
||||||
// Each test starts from a known-empty state. The store is module-level
|
// Each test starts from a known-empty state. The store is module-level
|
||||||
// (singleton), so we use `reset()` to clear between cases — that's also
|
// (singleton), so we use `reset()` to clear between cases — that's also
|
||||||
@@ -87,6 +111,7 @@ describe("useTailStore", () => {
|
|||||||
useTailStore.getState().reset("activity");
|
useTailStore.getState().reset("activity");
|
||||||
useTailStore.getState().reset("acks");
|
useTailStore.getState().reset("acks");
|
||||||
useTailStore.getState().reset("ta1_acks");
|
useTailStore.getState().reset("ta1_acks");
|
||||||
|
useTailStore.getState().reset("claim-acks");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("test_add_claim_adds_new_claim_keyed_by_id", () => {
|
it("test_add_claim_adds_new_claim_keyed_by_id", () => {
|
||||||
@@ -227,4 +252,62 @@ describe("useTailStore", () => {
|
|||||||
expect(Object.keys(s.ta1Acks)).toHaveLength(0);
|
expect(Object.keys(s.ta1Acks)).toHaveLength(0);
|
||||||
expect(Object.keys(s.acks)).toHaveLength(1);
|
expect(Object.keys(s.acks)).toHaveLength(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// SP28: the claim↔ack link slice mirrors the SP25 acks/ta1_acks slice
|
||||||
|
// (numeric id from the `claim_acks` table, first write wins, FIFO-capped
|
||||||
|
// at TAIL_CAP). The slice drives both the ClaimDrawer's
|
||||||
|
// <Acknowledgments /> panel and the AckDrawer's <MatchedClaim /> panel
|
||||||
|
// via `useTailStream("claim-acks")` + `useMergedTail("claim-acks", …)`.
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
it("test_add_claim_ack_adds_new_claim_ack_keyed_by_id", () => {
|
||||||
|
const { addClaimAck } = useTailStore.getState();
|
||||||
|
addClaimAck(makeClaimAck(1));
|
||||||
|
addClaimAck(makeClaimAck(2));
|
||||||
|
const { claimAcks } = useTailStore.getState();
|
||||||
|
expect(Object.keys(claimAcks)).toHaveLength(2);
|
||||||
|
expect(claimAcks["1"]?.claimId).toBe("CLM-1");
|
||||||
|
expect(claimAcks["2"]?.claimId).toBe("CLM-1");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("test_add_claim_ack_with_duplicate_id_is_noop", () => {
|
||||||
|
const { addClaimAck } = useTailStore.getState();
|
||||||
|
addClaimAck(makeClaimAck(1, { setAcceptRejectCode: "A" }));
|
||||||
|
addClaimAck(makeClaimAck(1, { setAcceptRejectCode: "R" }));
|
||||||
|
const { claimAcks } = useTailStore.getState();
|
||||||
|
expect(Object.keys(claimAcks)).toHaveLength(1);
|
||||||
|
// First write wins; snapshot replay must not trample the canonical row.
|
||||||
|
expect(claimAcks["1"]?.setAcceptRejectCode).toBe("A");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("test_reset_claim_acks_clears_only_claim_acks_slice", () => {
|
||||||
|
const { addClaimAck, addAck, reset } = useTailStore.getState();
|
||||||
|
addClaimAck(makeClaimAck(1));
|
||||||
|
addAck(makeAck(1));
|
||||||
|
|
||||||
|
reset("claim-acks");
|
||||||
|
const s = useTailStore.getState();
|
||||||
|
expect(Object.keys(s.claimAcks)).toHaveLength(0);
|
||||||
|
// The other slices are untouched.
|
||||||
|
expect(Object.keys(s.acks)).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("test_claim_acks_slice_evicts_oldest_when_over_10000", () => {
|
||||||
|
// Cap-eviction smoke test — same shape as the claims FIFO test
|
||||||
|
// above. 5 over the cap to exercise the eviction path without
|
||||||
|
// pinning down the wall-clock cost of a full 10 005-insert run.
|
||||||
|
const N = TAIL_CAP + 5;
|
||||||
|
const { addClaimAck } = useTailStore.getState();
|
||||||
|
for (let i = 0; i < N; i++) {
|
||||||
|
addClaimAck(makeClaimAck(i));
|
||||||
|
}
|
||||||
|
const { claimAcks } = useTailStore.getState();
|
||||||
|
expect(Object.keys(claimAcks)).toHaveLength(TAIL_CAP);
|
||||||
|
// The five oldest ids must be gone.
|
||||||
|
expect(claimAcks["0"]).toBeUndefined();
|
||||||
|
expect(claimAcks["4"]).toBeUndefined();
|
||||||
|
// The most recent id must be present.
|
||||||
|
expect(claimAcks[String(N - 1)]).toBeDefined();
|
||||||
|
}, 60_000);
|
||||||
});
|
});
|
||||||
|
|||||||
+46
-1
@@ -21,7 +21,14 @@
|
|||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
import { create } from "zustand";
|
import { create } from "zustand";
|
||||||
import type { Ack, Activity, Claim, Remittance, Ta1Ack } from "@/types";
|
import type {
|
||||||
|
Ack,
|
||||||
|
Activity,
|
||||||
|
Claim,
|
||||||
|
ClaimAck,
|
||||||
|
Remittance,
|
||||||
|
Ta1Ack,
|
||||||
|
} from "@/types";
|
||||||
import type { TailResource } from "@/lib/tail-stream";
|
import type { TailResource } from "@/lib/tail-stream";
|
||||||
|
|
||||||
/** Maximum number of items retained per slice before FIFO eviction kicks in. */
|
/** Maximum number of items retained per slice before FIFO eviction kicks in. */
|
||||||
@@ -52,6 +59,13 @@ interface TailStore {
|
|||||||
// `claims`/`remittances` (first write wins, FIFO-capped at TAIL_CAP).
|
// `claims`/`remittances` (first write wins, FIFO-capped at TAIL_CAP).
|
||||||
acks: Record<string, Ack>;
|
acks: Record<string, Ack>;
|
||||||
ta1Acks: Record<string, Ta1Ack>;
|
ta1Acks: Record<string, Ta1Ack>;
|
||||||
|
// SP28: the claim↔ack link slice. Mirrors the SP25 acks/ta1_acks
|
||||||
|
// pattern — `claimAcks` rows have a stable numeric `id` from the
|
||||||
|
// `claim_acks` table, so we key by id (first write wins, FIFO-capped
|
||||||
|
// at TAIL_CAP). The ClaimDrawer's <Acknowledgments /> panel and the
|
||||||
|
// AckDrawer's <MatchedClaim /> panel both subscribe to this slice via
|
||||||
|
// `useTailStream("claim-acks")` + `useMergedTail("claim-acks", …)`.
|
||||||
|
claimAcks: Record<string, ClaimAck>;
|
||||||
|
|
||||||
// --- Insertion-order trackers (kept in sync with the dicts above) ----
|
// --- Insertion-order trackers (kept in sync with the dicts above) ----
|
||||||
// These are private to the store; consumers only read the dicts. We
|
// These are private to the store; consumers only read the dicts. We
|
||||||
@@ -62,6 +76,7 @@ interface TailStore {
|
|||||||
remitOrder: string[];
|
remitOrder: string[];
|
||||||
ackOrder: number[];
|
ackOrder: number[];
|
||||||
ta1AckOrder: number[];
|
ta1AckOrder: number[];
|
||||||
|
claimAckOrder: number[];
|
||||||
|
|
||||||
// --- Setters ---------------------------------------------------------
|
// --- Setters ---------------------------------------------------------
|
||||||
addClaim: (c: Claim) => void;
|
addClaim: (c: Claim) => void;
|
||||||
@@ -69,6 +84,7 @@ interface TailStore {
|
|||||||
addActivity: (a: Activity) => void;
|
addActivity: (a: Activity) => void;
|
||||||
addAck: (a: Ack) => void;
|
addAck: (a: Ack) => void;
|
||||||
addTa1Ack: (a: Ta1Ack) => void;
|
addTa1Ack: (a: Ta1Ack) => void;
|
||||||
|
addClaimAck: (a: ClaimAck) => void;
|
||||||
reset: (resource: TailResource) => void;
|
reset: (resource: TailResource) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -104,10 +120,12 @@ export const useTailStore = create<TailStore>((set) => ({
|
|||||||
activity: [],
|
activity: [],
|
||||||
acks: {},
|
acks: {},
|
||||||
ta1Acks: {},
|
ta1Acks: {},
|
||||||
|
claimAcks: {},
|
||||||
claimOrder: [],
|
claimOrder: [],
|
||||||
remitOrder: [],
|
remitOrder: [],
|
||||||
ackOrder: [],
|
ackOrder: [],
|
||||||
ta1AckOrder: [],
|
ta1AckOrder: [],
|
||||||
|
claimAckOrder: [],
|
||||||
|
|
||||||
addClaim: (c) =>
|
addClaim: (c) =>
|
||||||
set((s) => {
|
set((s) => {
|
||||||
@@ -193,6 +211,31 @@ export const useTailStore = create<TailStore>((set) => ({
|
|||||||
return { ta1Acks: nextTa1Acks, ta1AckOrder: nextOrder };
|
return { ta1Acks: nextTa1Acks, ta1AckOrder: nextOrder };
|
||||||
}),
|
}),
|
||||||
|
|
||||||
|
// SP28: same first-write-wins / FIFO-capped shape as addAck /
|
||||||
|
// addTa1Ack. The numeric `id` is the primary key from the
|
||||||
|
// `claim_acks` table — distinct from (claim_id, ack_id, ak2_index)
|
||||||
|
// which is the natural dedup key but isn't what the live-tail
|
||||||
|
// payload uses (every claim_ack_written event carries the row id).
|
||||||
|
addClaimAck: (a) =>
|
||||||
|
set((s) => {
|
||||||
|
if (s.claimAcks[String(a.id)]) return s;
|
||||||
|
const nextClaimAcks: Record<string, ClaimAck> = Object.assign(
|
||||||
|
{},
|
||||||
|
s.claimAcks,
|
||||||
|
{ [String(a.id)]: a },
|
||||||
|
);
|
||||||
|
const nextOrder = s.claimAckOrder.concat(a.id);
|
||||||
|
if (nextOrder.length > TAIL_CAP) {
|
||||||
|
const { order, dict } = evictOldest(
|
||||||
|
nextOrder,
|
||||||
|
nextClaimAcks,
|
||||||
|
EVICT_BATCH,
|
||||||
|
);
|
||||||
|
return { claimAcks: dict, claimAckOrder: order };
|
||||||
|
}
|
||||||
|
return { claimAcks: nextClaimAcks, claimAckOrder: nextOrder };
|
||||||
|
}),
|
||||||
|
|
||||||
reset: (resource) =>
|
reset: (resource) =>
|
||||||
set(() => {
|
set(() => {
|
||||||
switch (resource) {
|
switch (resource) {
|
||||||
@@ -206,6 +249,8 @@ export const useTailStore = create<TailStore>((set) => ({
|
|||||||
return { acks: {}, ackOrder: [] };
|
return { acks: {}, ackOrder: [] };
|
||||||
case "ta1_acks":
|
case "ta1_acks":
|
||||||
return { ta1Acks: {}, ta1AckOrder: [] };
|
return { ta1Acks: {}, ta1AckOrder: [] };
|
||||||
|
case "claim-acks":
|
||||||
|
return { claimAcks: {}, claimAckOrder: [] };
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
}));
|
}));
|
||||||
|
|||||||
@@ -537,6 +537,13 @@ export interface Ack {
|
|||||||
* round-trip to the detail endpoint.
|
* round-trip to the detail endpoint.
|
||||||
*/
|
*/
|
||||||
patientControlNumber?: string | null;
|
patientControlNumber?: string | null;
|
||||||
|
/**
|
||||||
|
* SP28: distinct claim ids this 999 ack is linked to. Populated by
|
||||||
|
* the backend list endpoint so the Acks page can show a "Claims"
|
||||||
|
* badge column without a per-row detail round-trip. Empty array
|
||||||
|
* means no auto-link resolved (orphan).
|
||||||
|
*/
|
||||||
|
linkedClaimIds?: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -569,6 +576,62 @@ export interface Ta1Ack {
|
|||||||
receiverId: string | null;
|
receiverId: string | null;
|
||||||
sourceBatchId: string;
|
sourceBatchId: string;
|
||||||
parsedAt: string | null;
|
parsedAt: string | null;
|
||||||
|
/**
|
||||||
|
* SP28: originating `Batch` ids this TA1 envelope is linked to
|
||||||
|
* (per D4, TA1 is envelope-level so it links to `Batch` rather
|
||||||
|
* than `Claim`). Empty array means no auto-link resolved.
|
||||||
|
*/
|
||||||
|
linkedClaimIds?: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// SP28: claim↔ack link rows.
|
||||||
|
// Mirrors the `claim_acks` table (cyclone.db.ClaimAck) and the wire shape
|
||||||
|
// of `GET /api/claims/{id}/acks` + `GET /api/acks/{kind}/{id}/claims`.
|
||||||
|
// Per-AK2 granularity (D1): a single 999 with two AK2 set-responses
|
||||||
|
// produces two ClaimAck rows. TA1 batch-level rows have `claimId === null`
|
||||||
|
// — the `ClaimDrawer` panel filters those out; the `AckDrawer` panel
|
||||||
|
// renders them with the originating Batch instead of a claim.
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Discriminator for the ack flavor that produced a `ClaimAck` row.
|
||||||
|
*
|
||||||
|
* - `"999"` — per-AK2 set-response (one row per AK2 in the inbound 999).
|
||||||
|
* - `"277ca"` — per-`ClaimStatus` (one row per `STC` in the inbound 277CA).
|
||||||
|
* - `"ta1"` — envelope-level (one row per TA1, no per-claim granularity).
|
||||||
|
*/
|
||||||
|
export type ClaimAckKind = "999" | "277ca" | "ta1";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One persisted claim↔ack link row, camelCased for the UI. The
|
||||||
|
* `claimId` is `null` for TA1 batch-level rows (D4) — the
|
||||||
|
* `ClaimDrawer` panel filters those out so the operator only sees
|
||||||
|
* per-claim acks; the `AckDrawer` panel renders them with the
|
||||||
|
* originating Batch instead.
|
||||||
|
*
|
||||||
|
* `claimState` is a derived join (the 999/277CA row carries the
|
||||||
|
* current `Claim.state` so the drawer can render the
|
||||||
|
* `ClaimStateBadge` inline without a second round-trip). For
|
||||||
|
* TA1 batch-level rows with `claimId === null`, this is `"n/a"`.
|
||||||
|
*
|
||||||
|
* `ak2Index` is populated only for `"999"` rows — 277CA and TA1
|
||||||
|
* don't have AK2 segments. `setControlNumber` is populated for
|
||||||
|
* 999/277CA rows (the value the upstream ack actually carried,
|
||||||
|
* regardless of which join path resolved the link — spec D10).
|
||||||
|
*/
|
||||||
|
export interface ClaimAck {
|
||||||
|
id: number;
|
||||||
|
claimId: string | null;
|
||||||
|
batchId: string | null;
|
||||||
|
ackId: number;
|
||||||
|
ackKind: ClaimAckKind;
|
||||||
|
ak2Index: number | null;
|
||||||
|
setControlNumber: string | null;
|
||||||
|
setAcceptRejectCode: string | null;
|
||||||
|
linkedAt: string;
|
||||||
|
linkedBy: "auto" | "manual";
|
||||||
|
claimState?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -710,6 +773,15 @@ export interface ClaimDetail {
|
|||||||
* composite.
|
* composite.
|
||||||
*/
|
*/
|
||||||
lineReconciliation?: ClaimDetailLineReconciliation[];
|
lineReconciliation?: ClaimDetailLineReconciliation[];
|
||||||
|
/**
|
||||||
|
* SP28: compact form of the claim's `claim_acks` rows. Each entry
|
||||||
|
* has `ack_id`, `ack_kind`, `set_accept_reject_code`, `parsed_at`
|
||||||
|
* (no `claim_state` join — the drawer can use this for the
|
||||||
|
* initial panel render and rely on the live-tail for the full
|
||||||
|
* per-row payload via `useClaimAcks`). Empty array when the claim
|
||||||
|
* has no acks.
|
||||||
|
*/
|
||||||
|
ackLinks?: ClaimAck[];
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|||||||
Reference in New Issue
Block a user