feat(sp28): api_routers/claim_acks (7 endpoints) + claim_acks surface on claims/acks
Phase 5 of SP28 (Ack↔Claim Auto-Link). Adds the public HTTP
surface for the claim_acks join table and wires it into the
existing claims + acks list endpoints.
New router (registered with matrix_gate auth, any-logged-in-user):
* GET /api/claims/{id}/acks — per-claim links
* GET /api/claims/{id}/acks/stream — NDJSON live-tail
* GET /api/acks/{kind}/{id}/claims — per-ack links
* GET /api/acks/{kind}/{id}/claims/stream — NDJSON live-tail
* POST /api/acks/{kind}/{id}/match-claim — manual link (D5)
* DELETE /api/acks/{kind}/{id}/match-claim/{claim_id} — unlink
* GET /api/inbox/ack-orphans — orphan reconciliation
Manual match is any-logged-in-user (D5/D9), idempotent on the
(claim_id, ack_kind, ack_id) dedup key, rejects with 409 when the
claim is in a terminal state (REVERSED), and publishes
claim_ack_written / claim_ack_dropped on the bus so live-tail
subscribers refresh.
Existing list endpoints extended:
* /api/acks — each item gains linked_claim_ids
* /api/ta1-acks — each item gains linked_claim_ids
* /api/277ca-acks — each item gains linked_claim_ids
* /api/claims/{id} — body gains ack_links (compact form)
All three list extensions use a single batched SELECT against
claim_acks to avoid N+1.
Tests:
* tests/test_api_claim_acks.py — 8 tests covering all 7
endpoints + the spec §6 named tests for the extended
surfaces (claim_detail_includes_ack_links,
acks_list_includes_linked_claim_ids,
claim_acks_stream_emits_claim_ack_written). The stream test
uses the established direct-endpoint-invocation pattern
from test_api_stream_live.py so it gets true byte-streaming
+ clean async cancellation.
This commit is contained in:
@@ -38,7 +38,7 @@ 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
|
||||||
@@ -300,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)])
|
||||||
|
|
||||||
|
|
||||||
@@ -1163,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}
|
||||||
|
|
||||||
|
|
||||||
@@ -1901,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.
|
||||||
@@ -1914,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,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
|
||||||
Reference in New Issue
Block a user