eb674f890f
Extracts the 3 /api/remittances endpoints from api.py into
cyclone.api_routers.remittances:
GET /api/remittances (paginated list with filters)
GET /api/remittances/stream (NDJSON live-tail)
GET /api/remittances/{remit_id} (detail with CAS adjustments)
Mirror of the claims router for the 835 / Remittance resource.
No private helpers — every endpoint is a thin wrapper over
store.iter_remittances() / store.get_remittance().
remittances_stream is re-exported at the cyclone.api module level
so test_api_stream_live.py's direct import keeps working.
Route ordering preserved: /stream is declared before /{remit_id} in
the router source so FastAPI's first-match routing doesn't swallow
the literal 'stream' segment as a remittance id.
api.py: 1841 -> 1753 (-88). Net diff: +170 / -96.
Pytest: 8 failed / 735 passed / 16 skipped — identical to baseline.
11/11 remittance-targeted tests pass.
Live smoke: all 3 routes return expected status codes (200 for
list/stream, 404 for missing-remit detail).
1754 lines
64 KiB
Python
1754 lines
64 KiB
Python
"""FastAPI HTTP surface for the Cyclone 837P and 835 ERA parsers.
|
||
|
||
The frontend (Vite/React on http://localhost:5173) uploads an X12 file via
|
||
``POST /api/parse-837`` or ``POST /api/parse-835`` and receives either:
|
||
|
||
* a single ``ParseResult`` (or ``ParseResult835``) JSON object when it
|
||
sends ``Accept: application/json``, or
|
||
* a stream of newline-delimited JSON objects (``application/x-ndjson``)
|
||
— one envelope, one line per claim/payout, then one summary — so the
|
||
UI can render records incrementally as they're produced.
|
||
|
||
CORS is configured to allow the Vite dev origin (``http://localhost:5173``)
|
||
plus GET/POST with any header.
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import csv
|
||
import io
|
||
import json
|
||
import logging
|
||
import uuid
|
||
from datetime import datetime, timezone
|
||
from contextlib import asynccontextmanager
|
||
from typing import Any, AsyncIterator
|
||
|
||
from fastapi import FastAPI, File, HTTPException, Query, Request, UploadFile
|
||
from fastapi.middleware.cors import CORSMiddleware
|
||
from fastapi.responses import JSONResponse, Response, StreamingResponse
|
||
from pydantic import ValidationError
|
||
|
||
from cyclone import __version__, db
|
||
from cyclone.db import Claim, ClaimState, Remittance
|
||
from cyclone.inbox_state import apply_999_rejections
|
||
from cyclone.inbox_state_277ca import apply_277ca_rejections
|
||
from cyclone.audit_log import AuditEvent, append_event, verify_chain
|
||
from cyclone.parsers.exceptions import CycloneParseError
|
||
from cyclone.parsers.models import BatchSummary, ClaimOutput, Envelope, ParseResult
|
||
from cyclone.parsers.models_270 import (
|
||
EligibilityBenefitInquiry,
|
||
InformationReceiver270,
|
||
InformationSource270,
|
||
ParseResult270,
|
||
Subscriber270,
|
||
)
|
||
from cyclone.parsers.models_271 import ParseResult271
|
||
from cyclone.parsers.models_835 import ParseResult835
|
||
from cyclone.parsers.payer import PayerConfig, PayerConfig835
|
||
from cyclone.parsers.parse_270 import parse as parse_270_text
|
||
from cyclone.parsers.parse_271 import parse as parse_271_text
|
||
from cyclone.parsers.parse_277ca import parse_277ca_text
|
||
from cyclone.parsers.parse_837 import parse
|
||
from cyclone.parsers.parse_835 import parse as parse_835
|
||
from cyclone.parsers.parse_999 import parse_999_text
|
||
from cyclone.parsers.parse_ta1 import parse_ta1_text
|
||
from cyclone.parsers.serialize_270 import serialize_270
|
||
from cyclone.parsers.serialize_999 import serialize_999
|
||
from cyclone.parsers.serialize_837 import SerializeError as SerializeError837, serialize_837, serialize_837_for_resubmit
|
||
from cyclone.parsers.batch_ack_builder import build_ack_for_batch
|
||
from cyclone.parsers.validator_835 import validate as validate_835
|
||
from cyclone.pubsub import EventBus
|
||
from cyclone.store import (
|
||
AlreadyMatchedError,
|
||
BatchRecord,
|
||
InvalidStateError,
|
||
store,
|
||
utcnow,
|
||
)
|
||
|
||
log = logging.getLogger(__name__)
|
||
|
||
# Cross-cutting helpers (NDJSON serialization, content negotiation, strict
|
||
# rewrites, live-tail generator). See api_helpers.py for the rationale.
|
||
from cyclone.api_helpers import ( # noqa: E402
|
||
client_wants_json as _client_wants_json,
|
||
drop_raw_segments_837 as _drop_raw_segments,
|
||
drop_raw_segments_835 as _drop_raw_segments_835,
|
||
has_claim_validation_errors as _has_claim_validation_errors,
|
||
has_835_validation_errors as _has_835_validation_errors,
|
||
heartbeat_seconds as _heartbeat_seconds,
|
||
ndjson_line as _ndjson_line,
|
||
ndjson_stream_837 as _ndjson_stream,
|
||
ndjson_stream_835 as _ndjson_stream_835,
|
||
ndjson_stream_list as _ndjson_stream_list,
|
||
strict_rewrite_837 as _strict_rewrite,
|
||
strict_rewrite_835 as _strict_rewrite_835,
|
||
tail_events as _tail_events,
|
||
wants_ndjson as _wants_ndjson,
|
||
)
|
||
|
||
|
||
@asynccontextmanager
|
||
async def lifespan(app: FastAPI) -> AsyncIterator[None]:
|
||
"""Initialize per-process resources (DB + EventBus) before the app
|
||
serves requests. No teardown needed for Cyclone's local-only posture.
|
||
"""
|
||
from cyclone import db, payers as payer_loader
|
||
|
||
db.init_db()
|
||
app.state.event_bus = EventBus()
|
||
# SP9: load payer config and seed singleton + 3 providers + CO_TXIX.
|
||
try:
|
||
payer_loader.load_payer_configs()
|
||
store.ensure_clearhouse_seeded()
|
||
except Exception as exc: # noqa: BLE001
|
||
log.exception("SP9 seed failed: %s", exc)
|
||
yield
|
||
|
||
|
||
# Mirror cli._PAYER_FACTORIES. Kept local so the API doesn't depend on the
|
||
# Click-based CLI module.
|
||
PAYER_FACTORIES: dict[str, Any] = {
|
||
"co_medicaid": PayerConfig.co_medicaid,
|
||
"generic_837p": PayerConfig.generic_837p,
|
||
}
|
||
|
||
PAYER_FACTORIES_835: dict[str, Any] = {
|
||
"co_medicaid_835": PayerConfig835.co_medicaid_835,
|
||
"generic_835": PayerConfig835.generic_835,
|
||
}
|
||
|
||
VITE_DEV_ORIGIN = "http://localhost:5173"
|
||
|
||
app = FastAPI(
|
||
title="Cyclone 837P / 835 Parser API",
|
||
version=__version__,
|
||
description="Upload X12 837P and 835 files and receive parsed records as JSON or NDJSON.",
|
||
lifespan=lifespan,
|
||
)
|
||
|
||
app.add_middleware(
|
||
CORSMiddleware,
|
||
allow_origins=[VITE_DEV_ORIGIN],
|
||
allow_credentials=False,
|
||
allow_methods=["GET", "POST"],
|
||
allow_headers=["*"],
|
||
)
|
||
|
||
# Resource-group routers. Each module owns its own APIRouter and is
|
||
# registered below. New resources go in `cyclone.api_routers.<name>`
|
||
# 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
|
||
# working — Python prefers packages over same-named modules.)
|
||
from cyclone.api_routers import ( # noqa: E402
|
||
acks,
|
||
activity,
|
||
batches,
|
||
claims,
|
||
clearhouse,
|
||
config,
|
||
health,
|
||
providers,
|
||
remittances,
|
||
ta1_acks,
|
||
)
|
||
|
||
app.include_router(health.router)
|
||
app.include_router(acks.router)
|
||
app.include_router(ta1_acks.router)
|
||
app.include_router(providers.router)
|
||
app.include_router(clearhouse.router)
|
||
app.include_router(config.router)
|
||
app.include_router(activity.router)
|
||
app.include_router(batches.router)
|
||
app.include_router(claims.router)
|
||
app.include_router(remittances.router)
|
||
|
||
# Backwards-compat re-exports: test_api_stream_live.py imports these
|
||
# names directly from ``cyclone.api`` to invoke the endpoint coroutines
|
||
# in-process. Re-pointing at the router keeps the public surface stable
|
||
# while the bodies live in the routers package.
|
||
activity_stream = activity.activity_stream_endpoint
|
||
claims_stream = claims.claims_stream_endpoint
|
||
remittances_stream = remittances.remittances_stream_endpoint
|
||
|
||
|
||
def _resolve_payer(name: str) -> PayerConfig:
|
||
if name not in PAYER_FACTORIES:
|
||
raise HTTPException(
|
||
status_code=400,
|
||
detail={
|
||
"error": "Unknown payer",
|
||
"detail": f"Unknown payer {name!r}. Choose from: {', '.join(PAYER_FACTORIES)}",
|
||
},
|
||
)
|
||
return PAYER_FACTORIES[name]()
|
||
|
||
|
||
def _resolve_payer_835(name: str) -> PayerConfig835:
|
||
if name not in PAYER_FACTORIES_835:
|
||
raise HTTPException(
|
||
status_code=400,
|
||
detail={
|
||
"error": "Unknown payer",
|
||
"detail": f"Unknown payer {name!r}. Choose from: {', '.join(PAYER_FACTORIES_835)}",
|
||
},
|
||
)
|
||
return PAYER_FACTORIES_835[name]()
|
||
|
||
|
||
@app.post("/api/parse-837")
|
||
async def parse_837(
|
||
request: Request,
|
||
file: UploadFile = File(...),
|
||
payer: str = Query("co_medicaid"),
|
||
include_raw_segments: bool = Query(True),
|
||
strict: bool = Query(False),
|
||
ack: bool = Query(False),
|
||
) -> Any:
|
||
raw = await file.read()
|
||
if not raw:
|
||
return JSONResponse(
|
||
status_code=400,
|
||
content={"error": "Empty file", "detail": "Uploaded file contained no bytes."},
|
||
)
|
||
try:
|
||
text = raw.decode("utf-8")
|
||
except UnicodeDecodeError as exc:
|
||
return JSONResponse(
|
||
status_code=400,
|
||
content={"error": "Encoding error", "detail": str(exc)},
|
||
)
|
||
|
||
config = _resolve_payer(payer)
|
||
|
||
try:
|
||
result = parse(text, config, input_file=file.filename or "")
|
||
except CycloneParseError as exc:
|
||
return JSONResponse(
|
||
status_code=400,
|
||
content={"error": "Parse error", "detail": str(exc)},
|
||
)
|
||
except Exception as exc: # pragma: no cover - safety net
|
||
log.exception("Unexpected parser failure")
|
||
return JSONResponse(
|
||
status_code=500,
|
||
content={"error": "Internal server error", "detail": str(exc)},
|
||
)
|
||
|
||
if strict:
|
||
result = _strict_rewrite(result)
|
||
if not include_raw_segments:
|
||
result = _drop_raw_segments(result)
|
||
|
||
if _has_claim_validation_errors(result):
|
||
# Per spec: 422 when claims failed validation.
|
||
# Body still includes the full ParseResult so the client can show errors.
|
||
# Validation-failed parses are NOT persisted (the data is suspect);
|
||
# only parses that survive validation end up in the store.
|
||
return JSONResponse(
|
||
status_code=422,
|
||
content=json.loads(result.model_dump_json()),
|
||
)
|
||
|
||
# Persist the cleaned-up result so the cleaned result is what clients
|
||
# retrieve via /api/batches/{id}.
|
||
rec = BatchRecord(
|
||
id=uuid.uuid4().hex,
|
||
kind="837p",
|
||
input_filename=file.filename or "upload.txt",
|
||
parsed_at=utcnow(),
|
||
result=result,
|
||
)
|
||
store.add(rec, event_bus=request.app.state.event_bus)
|
||
|
||
if _client_wants_json(request):
|
||
body = json.loads(result.model_dump_json())
|
||
if ack:
|
||
ack_body = _build_and_persist_ack(rec.id)
|
||
if ack_body is not None:
|
||
body["ack"] = ack_body
|
||
return JSONResponse(content=body)
|
||
|
||
# Default: NDJSON stream.
|
||
return StreamingResponse(
|
||
_ndjson_stream(result),
|
||
media_type="application/x-ndjson",
|
||
)
|
||
|
||
|
||
def _build_and_persist_ack(batch_id: str) -> dict | None:
|
||
"""Build a 999 ACK for ``batch_id`` and persist the row.
|
||
|
||
Returns the ack payload dict (matches the ``/api/parse-999``
|
||
response shape so the JSON and NDJSON clients can share the
|
||
schema) or None if the build failed. The build is fail-soft:
|
||
errors are logged but never abort the 837 ingest, because the
|
||
user-visible 837 result is still correct.
|
||
"""
|
||
try:
|
||
ack_result = build_ack_for_batch(batch_id)
|
||
except Exception:
|
||
log.exception("build_ack_for_batch failed for %s", batch_id)
|
||
return None
|
||
fg = ack_result.functional_group_acks[0] if ack_result.functional_group_acks else None
|
||
if fg is None:
|
||
return None
|
||
raw_text = serialize_999(ack_result, interchange_control_number=ack_result.envelope.control_number)
|
||
row = store.add_ack(
|
||
source_batch_id=batch_id,
|
||
accepted_count=fg.accepted_count,
|
||
rejected_count=fg.rejected_count,
|
||
received_count=fg.received_count,
|
||
ack_code=fg.ack_code,
|
||
raw_json=json.loads(ack_result.model_dump_json()),
|
||
)
|
||
return {
|
||
"id": row.id,
|
||
"accepted_count": fg.accepted_count,
|
||
"rejected_count": fg.rejected_count,
|
||
"received_count": fg.received_count,
|
||
"ack_code": fg.ack_code,
|
||
"source_batch_id": batch_id,
|
||
"raw_999_text": raw_text,
|
||
}
|
||
|
||
|
||
# --------------------------------------------------------------------------- #
|
||
# 835 ERA (Health Care Claim Payment/Advice)
|
||
# --------------------------------------------------------------------------- #
|
||
|
||
|
||
def _reconciliation_summary_for_batch(batch_id: str) -> dict:
|
||
"""Return ``{matched, unmatched_claims, unmatched_remittances, skipped}`` for a batch.
|
||
|
||
Reads from the DB after ``store.add()`` has already triggered T10
|
||
reconciliation synchronously (via ``_run_reconcile``). Counts are
|
||
observed at this moment; a subsequent manual match/unmatch will not
|
||
be reflected until the next request.
|
||
|
||
``skipped`` is reserved for future use — the T10 orchestrator tracks
|
||
skipped claims internally but does not surface a queryable count.
|
||
"""
|
||
from sqlalchemy import func, select
|
||
from cyclone import db
|
||
from cyclone.db import Match, Remittance
|
||
|
||
with db.SessionLocal()() as s:
|
||
matched = s.execute(
|
||
select(func.count(Match.id)).where(
|
||
Match.remittance_id.in_(
|
||
select(Remittance.id).where(Remittance.batch_id == batch_id)
|
||
)
|
||
)
|
||
).scalar_one()
|
||
|
||
# Pull unmatched via the store (small result set; cheap).
|
||
unmatched = store.list_unmatched(kind="both")
|
||
return {
|
||
"matched": matched,
|
||
"unmatched_claims": len(unmatched["claims"]),
|
||
"unmatched_remittances": len(unmatched["remittances"]),
|
||
"skipped": 0, # reserved — T10 does not persist a skipped count
|
||
}
|
||
|
||
|
||
@app.post("/api/parse-835")
|
||
async def parse_835_endpoint(
|
||
request: Request,
|
||
file: UploadFile = File(...),
|
||
payer: str = Query("co_medicaid_835"),
|
||
include_raw_segments: bool = Query(True),
|
||
strict: bool = Query(False),
|
||
) -> Any:
|
||
raw = await file.read()
|
||
if not raw:
|
||
return JSONResponse(
|
||
status_code=400,
|
||
content={"error": "Empty file", "detail": "Uploaded file contained no bytes."},
|
||
)
|
||
try:
|
||
text = raw.decode("utf-8")
|
||
except UnicodeDecodeError as exc:
|
||
return JSONResponse(
|
||
status_code=400,
|
||
content={"error": "Encoding error", "detail": str(exc)},
|
||
)
|
||
|
||
config = _resolve_payer_835(payer)
|
||
|
||
try:
|
||
result = parse_835(text, config, input_file=file.filename or "")
|
||
except CycloneParseError as exc:
|
||
return JSONResponse(
|
||
status_code=400,
|
||
content={"error": "Parse error", "detail": str(exc)},
|
||
)
|
||
except Exception as exc: # pragma: no cover - safety net
|
||
log.exception("Unexpected parser failure")
|
||
return JSONResponse(
|
||
status_code=500,
|
||
content={"error": "Internal server error", "detail": str(exc)},
|
||
)
|
||
|
||
# Always run the validator; attach the report so the JSON path can
|
||
# surface it and the NDJSON path can fold the counts into the summary.
|
||
# 835 validation is batch-level, so pass/fail applies uniformly to every
|
||
# claim payment in the batch (passed=N or 0, failed=0 or N).
|
||
report = validate_835(result, config)
|
||
n = len(result.claims)
|
||
claim_ids = [c.payer_claim_control_number for c in result.claims]
|
||
if report.passed:
|
||
passed, failed, failed_claim_ids = n, 0, []
|
||
else:
|
||
passed, failed, failed_claim_ids = 0, n, claim_ids
|
||
result = result.model_copy(update={
|
||
"validation": report,
|
||
"summary": result.summary.model_copy(update={
|
||
"passed": passed,
|
||
"failed": failed,
|
||
"failed_claim_ids": failed_claim_ids,
|
||
}),
|
||
})
|
||
|
||
if strict:
|
||
result = _strict_rewrite_835(result)
|
||
if not include_raw_segments:
|
||
result = _drop_raw_segments_835(result)
|
||
|
||
if _has_835_validation_errors(result):
|
||
return JSONResponse(
|
||
status_code=422,
|
||
content=json.loads(result.model_dump_json()),
|
||
)
|
||
|
||
# Persist the cleaned-up result so the cleaned result is what clients
|
||
# retrieve via /api/batches/{id}.
|
||
rec = BatchRecord(
|
||
id=uuid.uuid4().hex,
|
||
kind="835",
|
||
input_filename=file.filename or "upload.txt",
|
||
parsed_at=utcnow(),
|
||
result=result,
|
||
)
|
||
store.add(rec, event_bus=request.app.state.event_bus)
|
||
|
||
if _client_wants_json(request):
|
||
body = json.loads(result.model_dump_json())
|
||
body["reconciliation"] = _reconciliation_summary_for_batch(rec.id)
|
||
return JSONResponse(content=body)
|
||
|
||
# Default: NDJSON stream.
|
||
return StreamingResponse(
|
||
_ndjson_stream_835(result),
|
||
media_type="application/x-ndjson",
|
||
)
|
||
|
||
|
||
# --------------------------------------------------------------------------- #
|
||
# 999 ACK (Implementation Acknowledgment)
|
||
# --------------------------------------------------------------------------- #
|
||
|
||
|
||
def _ack_count_summary(result) -> tuple[int, int, int, str]:
|
||
"""Aggregate (received, accepted, rejected, ack_code) from a ParseResult999.
|
||
|
||
The first functional group carries the canonical counts; falls back
|
||
to summing per-set codes if no AK9 was found.
|
||
"""
|
||
if result.functional_group_acks:
|
||
fg = result.functional_group_acks[0]
|
||
return (fg.received_count, fg.accepted_count, fg.rejected_count, fg.ack_code)
|
||
sets = result.set_responses
|
||
received = len(sets)
|
||
accepted = sum(1 for s in sets if s.set_accept_reject.code == "A")
|
||
rejected = received - accepted
|
||
if rejected == 0:
|
||
code = "A"
|
||
elif accepted == 0:
|
||
code = "R"
|
||
else:
|
||
code = "P"
|
||
return (received, accepted, rejected, code)
|
||
|
||
|
||
def _ack_synthetic_source_batch_id(interchange_control_number: str) -> str:
|
||
"""Return a synthetic batches.id for a received 999 with no source batch.
|
||
|
||
The acks.source_batch_id FK requires a row in batches; for received
|
||
999s we synthesize an id of the form ``999-<ISA13>``. The synthetic
|
||
row is NOT created in batches — the FK enforcement is a no-op in
|
||
SQLite without ``PRAGMA foreign_keys=ON`` (the project default for
|
||
tests). The dashboard never surfaces these synthetic ids; they exist
|
||
solely to satisfy the ORM contract.
|
||
"""
|
||
return f"999-{(interchange_control_number or '').strip() or '000000001'}"
|
||
|
||
|
||
@app.post("/api/parse-999")
|
||
async def parse_999_endpoint(
|
||
request: Request,
|
||
file: UploadFile = File(...),
|
||
) -> Any:
|
||
"""Parse a 999 ACK file, persist a row, and return JSON.
|
||
|
||
Behavior mirrors ``/api/parse-835``:
|
||
- 400 on empty / undecodable / malformed EDI (never 500).
|
||
- 200 on success with ``{"ack": {id, accepted_count, rejected_count,
|
||
received_count, ack_code, raw_999_text}, "parsed": <ParseResult999>}``.
|
||
|
||
The persisted ``acks.source_batch_id`` is a synthetic id
|
||
(``999-<ISA13>``) because a received 999 has no inbound 837 batch
|
||
to FK to. The dashboard's `/acks` list surfaces these; operators
|
||
can still see which interchange each row came from.
|
||
"""
|
||
raw = await file.read()
|
||
if not raw:
|
||
return JSONResponse(
|
||
status_code=400,
|
||
content={"error": "Empty file", "detail": "Uploaded file contained no bytes."},
|
||
)
|
||
try:
|
||
text = raw.decode("utf-8")
|
||
except UnicodeDecodeError as exc:
|
||
return JSONResponse(
|
||
status_code=400,
|
||
content={"error": "Encoding error", "detail": str(exc)},
|
||
)
|
||
|
||
try:
|
||
result = parse_999_text(text, input_file=file.filename or "")
|
||
except CycloneParseError as exc:
|
||
return JSONResponse(
|
||
status_code=400,
|
||
content={"error": "Parse error", "detail": str(exc)},
|
||
)
|
||
except Exception as exc: # pragma: no cover - safety net
|
||
log.exception("Unexpected parser failure on 999")
|
||
return JSONResponse(
|
||
status_code=500,
|
||
content={"error": "Internal server error", "detail": str(exc)},
|
||
)
|
||
|
||
received, accepted, rejected, ack_code = _ack_count_summary(result)
|
||
icn = result.envelope.control_number
|
||
synthetic_id = _ack_synthetic_source_batch_id(icn)
|
||
|
||
# Build the raw 999 text from the parsed result (round-trip).
|
||
raw_999_text = serialize_999(result, interchange_control_number=icn or "000000001")
|
||
|
||
# SP6 T4: move claims whose 999 set was rejected into ClaimState.REJECTED.
|
||
# The 999's set_control_number (AK202) is the source 837's ST02; in
|
||
# practice we look it up against patient_control_number because that's
|
||
# the field 999 ACKs cross-reference in this product.
|
||
with db.SessionLocal()() as session:
|
||
def _lookup(pcn: str):
|
||
return session.query(Claim).filter_by(patient_control_number=pcn).first()
|
||
_rejection_result = apply_999_rejections(
|
||
session, result, claim_lookup=_lookup,
|
||
)
|
||
if _rejection_result.matched:
|
||
bus = request.app.state.event_bus
|
||
for cid in _rejection_result.matched:
|
||
await bus.publish("claim.rejected", {"claim_id": cid})
|
||
if _rejection_result.orphans:
|
||
log.warning(
|
||
"999 had %d orphan set refs: %s",
|
||
len(_rejection_result.orphans),
|
||
_rejection_result.orphans[:5],
|
||
)
|
||
|
||
row = store.add_ack(
|
||
source_batch_id=synthetic_id,
|
||
accepted_count=accepted,
|
||
rejected_count=rejected,
|
||
received_count=received,
|
||
ack_code=ack_code,
|
||
raw_json=json.loads(result.model_dump_json()),
|
||
)
|
||
|
||
# SP11: append one audit row per rejected claim. Each row chains
|
||
# to the previous one — see cyclone.audit_log.
|
||
if _rejection_result.matched:
|
||
with db.SessionLocal()() as audit_s:
|
||
for cid in _rejection_result.matched:
|
||
append_event(audit_s, AuditEvent(
|
||
event_type="claim.rejected",
|
||
entity_type="claim",
|
||
entity_id=cid,
|
||
payload={"source_batch_id": synthetic_id, "ack_id": row.id},
|
||
actor="999-parser",
|
||
))
|
||
audit_s.commit()
|
||
|
||
return JSONResponse(content={
|
||
"ack": {
|
||
"id": row.id,
|
||
"accepted_count": accepted,
|
||
"rejected_count": rejected,
|
||
"received_count": received,
|
||
"ack_code": ack_code,
|
||
"source_batch_id": synthetic_id,
|
||
"raw_999_text": raw_999_text,
|
||
},
|
||
"parsed": json.loads(result.model_dump_json()),
|
||
})
|
||
|
||
|
||
# --------------------------------------------------------------------------- #
|
||
# TA1 (Interchange Acknowledgment)
|
||
# --------------------------------------------------------------------------- #
|
||
|
||
|
||
def _ta1_synthetic_source_batch_id(interchange_control_number: str) -> str:
|
||
"""Return a synthetic ``batches.id`` for a received TA1 with no source batch.
|
||
|
||
Mirrors :func:`_ack_synthetic_source_batch_id`. The ta1_acks.source_batch_id
|
||
FK requires a row in batches; for received TA1s we synthesize an id of
|
||
the form ``TA1-<ISA13>``. The row is NOT created in batches (same
|
||
FK-is-no-op convention as the 999 path).
|
||
"""
|
||
return f"TA1-{(interchange_control_number or '').strip() or '000000001'}"
|
||
|
||
|
||
@app.post("/api/parse-ta1")
|
||
async def parse_ta1_endpoint(
|
||
file: UploadFile = File(...),
|
||
) -> Any:
|
||
"""Parse a TA1 (Interchange Acknowledgment) file, persist a row, return JSON.
|
||
|
||
Mirrors ``/api/parse-999`` but for the lower-level envelope ack:
|
||
- 400 on empty / undecodable / malformed EDI (never 500).
|
||
- 200 on success with ``{"ta1": {id, control_number, ack_code,
|
||
note_code, interchange_date, interchange_time, sender_id,
|
||
receiver_id, source_batch_id, raw_ta1_text}, "parsed":
|
||
<ParseResultTa1>}``.
|
||
|
||
The persisted ``ta1_acks.source_batch_id`` is a synthetic id
|
||
(``TA1-<ISA13>``) because a received TA1 has no inbound batch to
|
||
FK to. The dashboard's ``/ta1-acks`` list surfaces these.
|
||
"""
|
||
raw = await file.read()
|
||
if not raw:
|
||
return JSONResponse(
|
||
status_code=400,
|
||
content={"error": "Empty file", "detail": "Uploaded file contained no bytes."},
|
||
)
|
||
try:
|
||
text = raw.decode("utf-8")
|
||
except UnicodeDecodeError as exc:
|
||
return JSONResponse(
|
||
status_code=400,
|
||
content={"error": "Encoding error", "detail": str(exc)},
|
||
)
|
||
|
||
try:
|
||
result = parse_ta1_text(text, input_file=file.filename or "")
|
||
except CycloneParseError as exc:
|
||
return JSONResponse(
|
||
status_code=400,
|
||
content={"error": "Parse error", "detail": str(exc)},
|
||
)
|
||
except Exception as exc: # pragma: no cover - safety net
|
||
log.exception("Unexpected parser failure on TA1")
|
||
return JSONResponse(
|
||
status_code=500,
|
||
content={"error": "Internal server error", "detail": str(exc)},
|
||
)
|
||
|
||
# Build the raw TA1 text from the parsed result (round-trip).
|
||
raw_ta1_text = _serialize_ta1(result)
|
||
|
||
row = store.add_ta1_ack(
|
||
source_batch_id=result.source_batch_id,
|
||
control_number=result.ta1.control_number,
|
||
interchange_date=result.ta1.interchange_date,
|
||
interchange_time=result.ta1.interchange_time,
|
||
ack_code=result.ta1.ack_code,
|
||
note_code=result.ta1.note_code,
|
||
ack_generated_date=result.ta1.ack_generated_date,
|
||
sender_id=result.envelope.sender_id,
|
||
receiver_id=result.envelope.receiver_id,
|
||
raw_json=json.loads(result.model_dump_json()),
|
||
)
|
||
|
||
return JSONResponse(content={
|
||
"ta1": {
|
||
"id": row.id,
|
||
"control_number": result.ta1.control_number,
|
||
"ack_code": result.ta1.ack_code,
|
||
"note_code": result.ta1.note_code,
|
||
"interchange_date": result.ta1.interchange_date.isoformat()
|
||
if result.ta1.interchange_date else None,
|
||
"interchange_time": result.ta1.interchange_time,
|
||
"sender_id": result.envelope.sender_id,
|
||
"receiver_id": result.envelope.receiver_id,
|
||
"source_batch_id": result.source_batch_id,
|
||
"raw_ta1_text": raw_ta1_text,
|
||
},
|
||
"parsed": json.loads(result.model_dump_json()),
|
||
})
|
||
|
||
|
||
# --------------------------------------------------------------------------- #
|
||
# 277CA (Claim Acknowledgment) — SP10
|
||
# --------------------------------------------------------------------------- #
|
||
|
||
|
||
def _277ca_synthetic_source_batch_id(interchange_control_number: str) -> str:
|
||
"""Return a synthetic ``batches.id`` for a received 277CA with no source batch.
|
||
|
||
Mirrors :func:`_ack_synthetic_source_batch_id`. The 277CA row's
|
||
``source_batch_id`` FK requires a row in batches; for received
|
||
277CAs we synthesize an id of the form ``277CA-<ISA13>``. The row
|
||
is NOT created in batches — same FK-is-no-op convention as the 999
|
||
path.
|
||
"""
|
||
return f"277CA-{(interchange_control_number or '').strip() or '000000001'}"
|
||
|
||
|
||
@app.post("/api/parse-277ca")
|
||
async def parse_277ca_endpoint(
|
||
request: Request,
|
||
file: UploadFile = File(...),
|
||
) -> Any:
|
||
"""Parse a 277CA Claim Acknowledgment file, persist a row, and stamp rejections.
|
||
|
||
Behavior mirrors ``/api/parse-999``:
|
||
- 400 on empty / undecodable / malformed EDI (never 500).
|
||
- 200 on success with ``{"ack": {id, control_number, accepted_count,
|
||
rejected_count, payer_claim_control_numbers, raw_277ca_text},
|
||
"parsed": <ParseResult277CA>}``.
|
||
|
||
After parse, runs :func:`apply_277ca_rejections` to stamp the
|
||
payer-rejected fields on each matching claim row. The Inbox
|
||
Payer-Rejected lane lights up as a side-effect of this call.
|
||
"""
|
||
raw = await file.read()
|
||
if not raw:
|
||
return JSONResponse(
|
||
status_code=400,
|
||
content={"error": "Empty file", "detail": "Uploaded file contained no bytes."},
|
||
)
|
||
try:
|
||
text = raw.decode("utf-8")
|
||
except UnicodeDecodeError as exc:
|
||
return JSONResponse(
|
||
status_code=400,
|
||
content={"error": "Encoding error", "detail": str(exc)},
|
||
)
|
||
|
||
try:
|
||
result = parse_277ca_text(text, input_file=file.filename or "")
|
||
except CycloneParseError as exc:
|
||
return JSONResponse(
|
||
status_code=400,
|
||
content={"error": "Parse error", "detail": str(exc)},
|
||
)
|
||
except Exception as exc: # pragma: no cover - safety net
|
||
log.exception("Unexpected parser failure on 277CA")
|
||
return JSONResponse(
|
||
status_code=500,
|
||
content={"error": "Internal server error", "detail": str(exc)},
|
||
)
|
||
|
||
icn = result.envelope.control_number
|
||
synthetic_id = _277ca_synthetic_source_batch_id(icn)
|
||
|
||
accepted = sum(1 for s in result.claim_statuses if s.classification == "accepted")
|
||
paid = sum(1 for s in result.claim_statuses if s.classification == "paid")
|
||
rejected = sum(1 for s in result.claim_statuses if s.classification == "rejected")
|
||
pended = sum(1 for s in result.claim_statuses if s.classification == "pended")
|
||
|
||
# Persist the 277CA row first so we have an id to attach to claims.
|
||
row = store.add_277ca_ack(
|
||
source_batch_id=synthetic_id,
|
||
control_number=icn,
|
||
accepted_count=accepted,
|
||
rejected_count=rejected,
|
||
paid_count=paid,
|
||
pended_count=pended,
|
||
raw_json=json.loads(result.model_dump_json()),
|
||
)
|
||
|
||
# Stamp payer-rejection fields on matching claims. The 277CA's
|
||
# REF*1K carries the patient's claim control number we sent in
|
||
# CLM01 — same convention the 999 ACK uses, so the lookup hits
|
||
# Claim.patient_control_number (mirrors apply_999_rejections).
|
||
with db.SessionLocal()() as session:
|
||
def _lookup(pcn: str):
|
||
return (
|
||
session.query(Claim)
|
||
.filter(Claim.patient_control_number == pcn)
|
||
.first()
|
||
)
|
||
apply_result = apply_277ca_rejections(
|
||
session, result, claim_lookup=_lookup, two77ca_id=row.id,
|
||
)
|
||
if apply_result.matched:
|
||
bus = request.app.state.event_bus
|
||
for cid in apply_result.matched:
|
||
await bus.publish("claim.payer_rejected", {"claim_id": cid})
|
||
# SP11: audit trail for each payer-rejected claim.
|
||
with db.SessionLocal()() as audit_s:
|
||
for cid in apply_result.matched:
|
||
append_event(audit_s, AuditEvent(
|
||
event_type="claim.payer_rejected",
|
||
entity_type="claim",
|
||
entity_id=cid,
|
||
payload={
|
||
"source_batch_id": synthetic_id,
|
||
"277ca_id": row.id,
|
||
},
|
||
actor="277ca-parser",
|
||
))
|
||
audit_s.commit()
|
||
if apply_result.orphans:
|
||
log.warning(
|
||
"277CA had %d orphan status entries (no matching claim): %s",
|
||
len(apply_result.orphans),
|
||
apply_result.orphans[:5],
|
||
)
|
||
|
||
return JSONResponse(content={
|
||
"ack": {
|
||
"id": row.id,
|
||
"control_number": icn,
|
||
"accepted_count": accepted,
|
||
"rejected_count": rejected,
|
||
"paid_count": paid,
|
||
"pended_count": pended,
|
||
"source_batch_id": synthetic_id,
|
||
"matched_claim_ids": apply_result.matched,
|
||
"orphan_status_codes": apply_result.orphans,
|
||
},
|
||
"parsed": json.loads(result.model_dump_json()),
|
||
})
|
||
|
||
|
||
@app.get("/api/277ca-acks")
|
||
def list_277ca_acks_endpoint(
|
||
limit: int = Query(100, ge=1, le=1000),
|
||
) -> Any:
|
||
"""Return the list of persisted 277CA ACKs, newest first."""
|
||
rows = store.list_277ca_acks()
|
||
items = [_277ca_to_ui(r) for r in rows[:limit]]
|
||
return {"total": len(rows), "items": items}
|
||
|
||
|
||
@app.get("/api/277ca-acks/{ack_id}")
|
||
def get_277ca_ack_endpoint(ack_id: int) -> dict:
|
||
"""Return one persisted 277CA ACK row with its parsed detail."""
|
||
row = store.get_277ca_ack(ack_id)
|
||
if row is None:
|
||
raise HTTPException(status_code=404, detail=f"277CA ACK {ack_id} not found")
|
||
return {
|
||
"id": row.id,
|
||
"control_number": row.control_number,
|
||
"accepted_count": row.accepted_count,
|
||
"rejected_count": row.rejected_count,
|
||
"paid_count": row.paid_count,
|
||
"pended_count": row.pended_count,
|
||
"source_batch_id": row.source_batch_id,
|
||
"parsed_at": row.parsed_at.isoformat() if row.parsed_at else None,
|
||
"raw_json": row.raw_json,
|
||
}
|
||
|
||
|
||
def _277ca_to_ui(row) -> dict:
|
||
"""Render a 277caAck row for the UI (list endpoint shape)."""
|
||
return {
|
||
"id": row.id,
|
||
"control_number": row.control_number,
|
||
"accepted_count": row.accepted_count,
|
||
"rejected_count": row.rejected_count,
|
||
"paid_count": row.paid_count,
|
||
"pended_count": row.pended_count,
|
||
"source_batch_id": row.source_batch_id,
|
||
"parsed_at": row.parsed_at.isoformat() if row.parsed_at else None,
|
||
}
|
||
|
||
|
||
def _serialize_ta1(result) -> str:
|
||
"""Render a TA1 file from a ParseResultTa1 for the ``raw_ta1_text`` field.
|
||
|
||
Mirrors what the parser consumed: ISA envelope → TA1 → IEA. We
|
||
rebuild minimal ISA fields from the envelope plus the TA1 segment
|
||
verbatim. The serializer is intentionally tiny — TA1 has no GS/ST,
|
||
so there's no functional-group structure to round-trip.
|
||
"""
|
||
ta1 = result.ta1
|
||
parts = [
|
||
f"TA1*{ta1.control_number}*{ta1.interchange_date.strftime('%y%m%d') if ta1.interchange_date else ''}*"
|
||
f"{ta1.interchange_time or ''}*{ta1.ack_code}*{ta1.note_code or ''}*"
|
||
f"{ta1.ack_generated_date.strftime('%y%m%d') if ta1.ack_generated_date else ''}",
|
||
]
|
||
return "~".join(parts) + "~"
|
||
|
||
|
||
def _serialize_ta1_from_row(row: db.Ta1Ack) -> str:
|
||
"""Reconstruct a TA1 segment from the persisted flat row (for the detail endpoint)."""
|
||
date_s = row.interchange_date.strftime("%y%m%d") if row.interchange_date else ""
|
||
return (
|
||
f"TA1*{row.control_number}*{date_s}*{row.interchange_time or ''}*"
|
||
f"{row.ack_code}*{row.note_code or ''}~"
|
||
)
|
||
|
||
|
||
# --------------------------------------------------------------------------- #
|
||
# SP6 — Inbox endpoints
|
||
# --------------------------------------------------------------------------- #
|
||
|
||
|
||
@app.get("/api/inbox/lanes")
|
||
def inbox_lanes():
|
||
"""Return all Inbox lanes in one call."""
|
||
dismissed_pairs = getattr(app.state, "dismissed_pairs", set())
|
||
with db.SessionLocal()() as session:
|
||
from cyclone.inbox_lanes import compute_lanes
|
||
lanes = compute_lanes(session, dismissed_pairs=dismissed_pairs)
|
||
return {
|
||
"rejected": lanes.rejected,
|
||
# SP10: payer-rejected lane (277CA STC A4/A6/A7). Distinct from
|
||
# the 999 envelope rejection in ``rejected`` above.
|
||
"payer_rejected": lanes.payer_rejected,
|
||
"candidates": lanes.candidates,
|
||
"unmatched": lanes.unmatched,
|
||
"done_today": lanes.done_today,
|
||
}
|
||
|
||
|
||
@app.post("/api/inbox/candidates/{remit_id}/match")
|
||
def inbox_match_candidate(remit_id: str, body: dict):
|
||
"""Manually link a remit to a claim."""
|
||
claim_id = body.get("claim_id")
|
||
if not claim_id:
|
||
raise HTTPException(400, "claim_id required")
|
||
with db.SessionLocal()() as s:
|
||
claim = s.get(Claim, claim_id)
|
||
remit = s.get(Remittance, remit_id)
|
||
if claim is None or remit is None:
|
||
raise HTTPException(404, "claim or remit not found")
|
||
if claim.matched_remittance_id and claim.matched_remittance_id != remit_id:
|
||
raise HTTPException(
|
||
409,
|
||
detail={
|
||
"error": "claim_already_matched",
|
||
"current_state": (
|
||
claim.state.value if hasattr(claim.state, "value")
|
||
else str(claim.state)
|
||
),
|
||
"matched_remittance_id": claim.matched_remittance_id,
|
||
},
|
||
)
|
||
claim.matched_remittance_id = remit_id
|
||
remit.claim_id = claim_id
|
||
s.commit()
|
||
return {"ok": True, "claim_id": claim_id, "remit_id": remit_id}
|
||
|
||
|
||
@app.post("/api/inbox/candidates/dismiss")
|
||
def inbox_dismiss_candidates(body: dict):
|
||
"""Add candidate pairs to the session-scoped dismissed set."""
|
||
pairs = body.get("pairs") or []
|
||
if not hasattr(app.state, "dismissed_pairs"):
|
||
app.state.dismissed_pairs = set()
|
||
for p in pairs:
|
||
cid = p.get("claim_id")
|
||
rid = p.get("remit_id")
|
||
if cid and rid:
|
||
app.state.dismissed_pairs.add(frozenset({cid, rid}))
|
||
return {"ok": True, "dismissed_count": len(pairs)}
|
||
|
||
|
||
# --------------------------------------------------------------------------- #
|
||
# SP14: Payer-Rejected acknowledge
|
||
#
|
||
# Operator hits "Acknowledge" on the Payer-Rejected Inbox lane to clear
|
||
# the claim from the working surface. We don't delete the rejection
|
||
# (the original payer_rejected_* fields stay for SP11 audit), we just
|
||
# set payer_rejected_acknowledged_at so the lane query filters it out.
|
||
#
|
||
# Idempotent: re-acknowledging an already-acknowledged claim is a noop
|
||
# (the timestamp is not bumped). Returns the count actually transitioned
|
||
# so the UI can show "3 of 5 were already acknowledged".
|
||
# --------------------------------------------------------------------------- #
|
||
@app.post("/api/inbox/payer-rejected/acknowledge")
|
||
def inbox_acknowledge_payer_rejected(body: dict):
|
||
"""Mark Payer-Rejected claims as acknowledged by the operator."""
|
||
claim_ids = body.get("claim_ids") or []
|
||
actor = body.get("actor") or "operator"
|
||
if not isinstance(claim_ids, list) or not claim_ids:
|
||
raise HTTPException(400, "claim_ids must be a non-empty list")
|
||
if not all(isinstance(c, str) for c in claim_ids):
|
||
raise HTTPException(400, "claim_ids must be a list of strings")
|
||
|
||
with db.SessionLocal()() as session:
|
||
from cyclone.db import Claim
|
||
now = datetime.now(timezone.utc)
|
||
transitioned = 0
|
||
already_acked = 0
|
||
not_found = 0
|
||
not_rejected = 0
|
||
for cid in claim_ids:
|
||
claim = session.get(Claim, cid)
|
||
if claim is None:
|
||
not_found += 1
|
||
continue
|
||
if claim.payer_rejected_at is None:
|
||
not_rejected += 1
|
||
continue
|
||
if claim.payer_rejected_acknowledged_at is not None:
|
||
already_acked += 1
|
||
continue
|
||
claim.payer_rejected_acknowledged_at = now
|
||
claim.payer_rejected_acknowledged_actor = actor
|
||
transitioned += 1
|
||
# SP11: audit event for the acknowledge action.
|
||
try:
|
||
from cyclone.audit_log import append_event, AuditEvent
|
||
append_event(session, AuditEvent(
|
||
event_type="claim.payer_rejected_acknowledged",
|
||
entity_type="claim",
|
||
entity_id=claim.id,
|
||
actor=actor,
|
||
payload={
|
||
"payer_rejected_status_code": claim.payer_rejected_status_code,
|
||
"payer_rejected_by_277ca_id": claim.payer_rejected_by_277ca_id,
|
||
},
|
||
))
|
||
except Exception: # noqa: BLE001
|
||
# Audit append is best-effort; don't block the operator's
|
||
# acknowledge action on an audit-log failure.
|
||
pass
|
||
if transitioned:
|
||
session.commit()
|
||
|
||
return {
|
||
"ok": True,
|
||
"transitioned": transitioned,
|
||
"already_acked": already_acked,
|
||
"not_found": not_found,
|
||
"not_rejected": not_rejected,
|
||
}
|
||
|
||
|
||
@app.post("/api/inbox/rejected/resubmit")
|
||
def inbox_resubmit_rejected(
|
||
request: Request,
|
||
body: dict,
|
||
download: bool = Query(False, description="When true, return a ZIP of regenerated 837 files for the resubmitted claims (instead of JSON)."),
|
||
):
|
||
"""Bulk move REJECTED claims back to SUBMITTED.
|
||
|
||
With ``?download=true``, the response is a ``application/zip`` archive
|
||
containing one ``claim-{id}.x12`` per successfully resubmitted claim
|
||
(regenerated via ``serialize_837_for_resubmit`` so each file gets a
|
||
unique interchange/group control number). Conflicts are omitted from
|
||
the ZIP — they remain visible to the caller via the JSON shape of the
|
||
non-download path. Empty resubmit + download → 200 with an empty zip
|
||
so the UI can still hand the user a downloadable artifact.
|
||
"""
|
||
ids = body.get("claim_ids") or []
|
||
if not ids:
|
||
raise HTTPException(400, "claim_ids required")
|
||
accepted: list[str] = []
|
||
conflicts: list[dict] = []
|
||
# Track which claims are about to be resubmitted (and their index in
|
||
# the bundle) so the download path can serialize them with unique
|
||
# control numbers — back-to-back resubmits in the same file would
|
||
# otherwise all share ISA13/GS06 = "000000001".
|
||
accepted_with_rows: list[tuple[str, "Claim"]] = []
|
||
with db.SessionLocal()() as s:
|
||
for cid in ids:
|
||
c = s.get(Claim, cid)
|
||
if c is None:
|
||
continue
|
||
if c.state != ClaimState.REJECTED:
|
||
conflicts.append({
|
||
"claim_id": cid,
|
||
"current_state": (
|
||
c.state.value if hasattr(c.state, "value")
|
||
else str(c.state)
|
||
),
|
||
})
|
||
continue
|
||
c.state = ClaimState.SUBMITTED
|
||
c.state_changed_at = datetime.now(timezone.utc)
|
||
c.rejection_reason = None
|
||
c.rejected_at = None
|
||
c.resubmit_count = (c.resubmit_count or 0) + 1
|
||
accepted.append(cid)
|
||
accepted_with_rows.append((cid, c))
|
||
s.commit()
|
||
|
||
if not download:
|
||
return {"ok": True, "resubmitted": accepted, "conflicts": conflicts}
|
||
|
||
# Build a ZIP of regenerated 837s for the accepted claims. Conflicts
|
||
# and missing ids are deliberately excluded — the user already saw
|
||
# them in the JSON response on prior actions; the download is the
|
||
# "give me the files I asked for" payload.
|
||
import zipfile
|
||
buf = io.BytesIO()
|
||
serialize_errors: list[dict] = []
|
||
with zipfile.ZipFile(buf, mode="w", compression=zipfile.ZIP_DEFLATED) as zf:
|
||
for idx, (cid, c) in enumerate(accepted_with_rows, start=1):
|
||
if not c.raw_json:
|
||
serialize_errors.append({"claim_id": cid, "reason": "no raw_json"})
|
||
continue
|
||
try:
|
||
claim_obj = ClaimOutput.model_validate(c.raw_json)
|
||
except Exception as exc:
|
||
serialize_errors.append({"claim_id": cid, "reason": f"raw_json invalid: {exc}"})
|
||
continue
|
||
try:
|
||
text = serialize_837_for_resubmit(claim_obj, interchange_index=idx)
|
||
except SerializeError837 as exc:
|
||
serialize_errors.append({"claim_id": cid, "reason": str(exc)})
|
||
continue
|
||
zf.writestr(f"claim-{cid}.x12", text)
|
||
buf.seek(0)
|
||
headers = {
|
||
"Content-Disposition": (
|
||
f'attachment; filename="resubmit-{len(accepted)}-claims.zip"'
|
||
),
|
||
}
|
||
# Surface per-claim serialization failures as a custom response header
|
||
# so the UI can show "10 resubmitted, 2 couldn't be regenerated" without
|
||
# parsing the binary. The header value is JSON-encoded; the UI is
|
||
# expected to JSON.parse it after a fetch with response.ok.
|
||
if serialize_errors:
|
||
headers["X-Cyclone-Serialize-Errors"] = json.dumps(serialize_errors)
|
||
return Response(
|
||
content=buf.getvalue(),
|
||
media_type="application/zip",
|
||
headers=headers,
|
||
)
|
||
|
||
|
||
@app.get("/api/inbox/export.csv")
|
||
def inbox_export_csv(lane: str):
|
||
"""Stream a CSV for a single lane."""
|
||
if lane not in {"rejected", "candidates", "unmatched", "done_today"}:
|
||
raise HTTPException(400, f"unknown lane: {lane}")
|
||
dismissed_pairs = getattr(app.state, "dismissed_pairs", set())
|
||
with db.SessionLocal()() as session:
|
||
from cyclone.inbox_lanes import compute_lanes
|
||
lanes = compute_lanes(session, dismissed_pairs=dismissed_pairs)
|
||
rows = getattr(lanes, lane)
|
||
|
||
buf = io.StringIO()
|
||
writer = csv.writer(buf)
|
||
writer.writerow([
|
||
"id", "kind", "patient_control_number", "charge_amount",
|
||
"payer_id", "provider_npi", "state", "rejection_reason",
|
||
"service_date", "score",
|
||
])
|
||
for r in rows:
|
||
writer.writerow([
|
||
r.get("id") or r.get("payer_claim_control_number"),
|
||
r.get("kind"),
|
||
r.get("patient_control_number"),
|
||
r.get("charge_amount"),
|
||
r.get("payer_id"),
|
||
r.get("provider_npi") or r.get("rendering_provider_npi"),
|
||
r.get("state"),
|
||
r.get("rejection_reason"),
|
||
r.get("service_date_from") or r.get("service_date"),
|
||
r.get("score"),
|
||
])
|
||
buf.seek(0)
|
||
return StreamingResponse(
|
||
iter([buf.getvalue()]),
|
||
media_type="text/csv",
|
||
headers={"Content-Disposition": f'attachment; filename="inbox-{lane}.csv"'},
|
||
)
|
||
|
||
|
||
# --- /api/batches lives in cyclone.api_routers.batches ---
|
||
|
||
|
||
# --- /api/claims lives in cyclone.api_routers.claims ---
|
||
|
||
@app.get("/api/reconciliation/unmatched")
|
||
def get_reconciliation_unmatched() -> dict:
|
||
"""Return unmatched Claims (left) and unmatched Remittances (right).
|
||
|
||
Powers the reconciliation review surface: every Claim with no
|
||
paired Remittance appears on the left, every Remittance with no
|
||
paired Claim appears on the right. The two lists are always present
|
||
(empty list, never absent) so the UI can index unconditionally.
|
||
"""
|
||
return store.list_unmatched(kind="both")
|
||
|
||
|
||
# --------------------------------------------------------------------------- #
|
||
# Side-by-side diff between two batches (SP3 P4 / T18)
|
||
# --------------------------------------------------------------------------- #
|
||
|
||
|
||
@app.get("/api/batch-diff")
|
||
def get_batch_diff(
|
||
a: str | None = Query(None),
|
||
b: str | None = Query(None),
|
||
) -> dict:
|
||
"""Return a side-by-side diff of two batches identified by id.
|
||
|
||
Query params: ``a=<batch_id>``, ``b=<batch_id>`` (both required).
|
||
|
||
Response body (snake_case keys, see :mod:`cyclone.batch_diff` for the
|
||
projector shapes):
|
||
- ``a`` / ``b`` — small metadata blocks (id, kind, parsedAt,
|
||
inputFilename, claimCount)
|
||
- ``added`` — claims present in B but not A
|
||
- ``removed`` — claims present in A but not B
|
||
- ``changed`` — claims present in both, with field deltas
|
||
- ``summary`` — precomputed counts
|
||
|
||
Errors:
|
||
- 400 — missing ``a`` or ``b``
|
||
- 404 — either batch id is unknown
|
||
|
||
Pure read endpoint — never mutates the store. Both 837P and 835
|
||
batches are accepted (mixed-kind diffs are valid: comparing the
|
||
submitted claims against the matching remittances).
|
||
"""
|
||
if not a or not b:
|
||
raise HTTPException(
|
||
status_code=400,
|
||
detail={"error": "Missing param", "detail": "Both ?a=<batch_id> and ?b=<batch_id> are required."},
|
||
)
|
||
try:
|
||
a_rec, b_rec = store.load_two_for_diff(a, b)
|
||
except LookupError as exc:
|
||
raise HTTPException(
|
||
status_code=404,
|
||
detail={"error": "Not found", "detail": str(exc)},
|
||
)
|
||
|
||
# Lazy import — keeps the module's import surface small until the
|
||
# endpoint is actually hit. Mirrors the same pattern used by other
|
||
# endpoint-local helpers (e.g. reconciler).
|
||
from cyclone.batch_diff import diff_batches_to_wire
|
||
|
||
return diff_batches_to_wire(a_rec, b_rec)
|
||
|
||
|
||
@app.post("/api/reconciliation/match")
|
||
def post_reconciliation_match(body: dict) -> dict:
|
||
"""Manually pair a Claim with a Remittance (operator override).
|
||
|
||
Body: ``{"claim_id": ..., "remit_id": ...}``. Returns
|
||
``{"claim": <ui>, "match": <ui>}`` on success. Errors:
|
||
- 400: missing ``claim_id`` or ``remit_id``
|
||
- 404: claim or remittance not found
|
||
- 409: claim already matched, or apply_* returned a noop
|
||
(claim in terminal state) — detail echoes ``current_state``
|
||
and ``activity_kind`` so the UI can render a precise message.
|
||
"""
|
||
claim_id = body.get("claim_id")
|
||
remit_id = body.get("remit_id")
|
||
if not claim_id or not remit_id:
|
||
raise HTTPException(
|
||
status_code=400,
|
||
detail="claim_id and remit_id required",
|
||
)
|
||
try:
|
||
return store.manual_match(claim_id, remit_id)
|
||
except AlreadyMatchedError as e:
|
||
raise HTTPException(
|
||
status_code=409,
|
||
detail={"error": "already_matched", "message": str(e)},
|
||
)
|
||
except InvalidStateError as e:
|
||
raise HTTPException(
|
||
status_code=409,
|
||
detail={
|
||
"error": "invalid_state",
|
||
"current_state": e.current_state,
|
||
"activity_kind": e.activity_kind,
|
||
},
|
||
)
|
||
except LookupError:
|
||
# manual_match raises LookupError when the claim or remittance
|
||
# row is missing (we catch the parent class so any future
|
||
# KeyError subclasses in the store get the same treatment).
|
||
raise HTTPException(
|
||
status_code=404,
|
||
detail="claim_or_remit_not_found",
|
||
)
|
||
|
||
|
||
@app.post("/api/reconciliation/unmatch")
|
||
def post_reconciliation_unmatch(body: dict) -> dict:
|
||
"""Remove the current match for a Claim; reset Claim to submitted.
|
||
|
||
Body: ``{"claim_id": ...}``. Returns
|
||
``{"claim": <ui>, "deletedMatches": <count>}``. Errors:
|
||
- 400: missing ``claim_id``
|
||
- 404: claim not found
|
||
- 409: claim has no current match (NotMatchedError is mapped
|
||
by the store; we surface 409 to match the manual_match contract)
|
||
"""
|
||
from cyclone.store import NotMatchedError
|
||
claim_id = body.get("claim_id")
|
||
if not claim_id:
|
||
raise HTTPException(
|
||
status_code=400,
|
||
detail="claim_id required",
|
||
)
|
||
try:
|
||
return store.manual_unmatch(claim_id)
|
||
except NotMatchedError as e:
|
||
raise HTTPException(
|
||
status_code=409,
|
||
detail={"error": "not_matched", "message": str(e)},
|
||
)
|
||
except LookupError:
|
||
raise HTTPException(
|
||
status_code=404,
|
||
detail="claim_not_found",
|
||
)
|
||
|
||
|
||
# --- /api/remittances lives in cyclone.api_routers.remittances ---
|
||
|
||
|
||
|
||
|
||
|
||
|
||
# --------------------------------------------------------------------------- #
|
||
# 999 ACKs (read views)
|
||
# --------------------------------------------------------------------------- #
|
||
|
||
|
||
# --------------------------------------------------------------------------- #
|
||
# 270 / 271 eligibility (SP3 P4 T23–T24) — API-only, no DB persistence
|
||
# --------------------------------------------------------------------------- #
|
||
|
||
|
||
def _validate_eligibility_request(body: dict) -> tuple[ParseResult270, str]:
|
||
"""Build a :class:`ParseResult270` from a request body dict.
|
||
|
||
The body shape is the minimum surface needed to build a valid 270
|
||
inquiry (per spec section 3.4 — operator-driven, ephemeral):
|
||
|
||
::
|
||
|
||
{
|
||
"subscriber": {first_name, last_name, member_id, dob},
|
||
"provider": {npi, name},
|
||
"payer": {id, name},
|
||
"service_type_code": "1"
|
||
}
|
||
|
||
Returns ``(ParseResult270, service_type_code)``. Raises
|
||
:class:`HTTPException` (400) when the body is missing required
|
||
fields.
|
||
"""
|
||
subscriber_in = body.get("subscriber") or {}
|
||
provider_in = body.get("provider") or {}
|
||
payer_in = body.get("payer") or {}
|
||
service_type_code = (body.get("service_type_code") or "").strip()
|
||
|
||
# Required-field checks. We surface a single 400 with the first
|
||
# missing field name to match the rest of the API's error contract.
|
||
if not service_type_code:
|
||
raise HTTPException(
|
||
status_code=400,
|
||
detail={"error": "Bad request", "detail": "service_type_code is required"},
|
||
)
|
||
if not subscriber_in.get("member_id"):
|
||
raise HTTPException(
|
||
status_code=400,
|
||
detail={"error": "Bad request", "detail": "subscriber.member_id is required"},
|
||
)
|
||
if not provider_in.get("npi"):
|
||
raise HTTPException(
|
||
status_code=400,
|
||
detail={"error": "Bad request", "detail": "provider.npi is required"},
|
||
)
|
||
if not payer_in.get("name"):
|
||
raise HTTPException(
|
||
status_code=400,
|
||
detail={"error": "Bad request", "detail": "payer.name is required"},
|
||
)
|
||
|
||
# Build the Pydantic models. The serializer handles all envelope
|
||
# generation (sender_id/receiver_id/control_number/transaction_date
|
||
# are filled in by the serializer with sensible defaults).
|
||
from datetime import date as _date
|
||
|
||
subscriber_dob_raw = subscriber_in.get("dob")
|
||
subscriber_dob: _date | None = None
|
||
if subscriber_dob_raw:
|
||
try:
|
||
subscriber_dob = _date.fromisoformat(subscriber_dob_raw)
|
||
except (TypeError, ValueError) as exc:
|
||
raise HTTPException(
|
||
status_code=400,
|
||
detail={
|
||
"error": "Bad request",
|
||
"detail": f"subscriber.dob must be YYYY-MM-DD: {exc}",
|
||
},
|
||
) from exc
|
||
|
||
result = ParseResult270(
|
||
envelope=Envelope(
|
||
sender_id="SUBMITTERID",
|
||
receiver_id=str(payer_in.get("id") or "RECEIVERID"),
|
||
control_number="000000001",
|
||
transaction_date=_date.today(),
|
||
implementation_guide="005010X279A1",
|
||
),
|
||
information_source=InformationSource270(
|
||
name=str(payer_in["name"]),
|
||
id=str(payer_in.get("id") or "") or None,
|
||
),
|
||
information_receiver=InformationReceiver270(
|
||
name=str(provider_in.get("name") or ""),
|
||
npi=str(provider_in["npi"]),
|
||
),
|
||
subscriber=Subscriber270(
|
||
member_id=str(subscriber_in["member_id"]),
|
||
first_name=str(subscriber_in.get("first_name") or "") or None,
|
||
last_name=str(subscriber_in.get("last_name") or "") or None,
|
||
dob=subscriber_dob,
|
||
),
|
||
inquiries=[EligibilityBenefitInquiry(service_type_code=service_type_code)],
|
||
summary=BatchSummary(
|
||
input_file="eligibility_request",
|
||
control_number="000000001",
|
||
transaction_date=_date.today(),
|
||
total_claims=1,
|
||
passed=1,
|
||
failed=0,
|
||
),
|
||
)
|
||
return result, service_type_code
|
||
|
||
|
||
@app.post("/api/eligibility/request")
|
||
def post_eligibility_request(body: dict) -> Any:
|
||
"""Build a 270 eligibility inquiry from a small JSON body.
|
||
|
||
Returns ``{"raw_270_text": <X12>, "parsed": <ParseResult270>}``
|
||
so the operator can either download the raw text (paste into a
|
||
payer portal) or render the parsed fields directly. Per spec
|
||
section 3.4, nothing is persisted to the DB.
|
||
"""
|
||
try:
|
||
result, _ = _validate_eligibility_request(body)
|
||
except HTTPException:
|
||
raise
|
||
except (KeyError, TypeError, ValueError) as exc:
|
||
raise HTTPException(
|
||
status_code=400,
|
||
detail={"error": "Bad request", "detail": f"Malformed body: {exc}"},
|
||
) from exc
|
||
|
||
raw_270_text = serialize_270(result)
|
||
return {
|
||
"raw_270_text": raw_270_text,
|
||
"parsed": json.loads(result.model_dump_json()),
|
||
}
|
||
|
||
|
||
@app.post("/api/eligibility/parse-271")
|
||
async def post_eligibility_parse_271(
|
||
file: UploadFile = File(...),
|
||
) -> Any:
|
||
"""Parse a 271 eligibility response and return the structured summary.
|
||
|
||
Accepts the raw 271 text as a file upload (multipart/form-data),
|
||
mirrors the ``/api/parse-999`` contract. Per spec section 3.4 the
|
||
result is NOT persisted — the operator re-pastes the 271 each
|
||
time they need a fresh read.
|
||
|
||
The response body is a JSON object with three top-level keys:
|
||
``coverage_benefits``, ``subscriber``, and ``summary``. 400 is
|
||
returned on empty / undecodable / malformed EDI; 200 on success.
|
||
"""
|
||
raw = await file.read()
|
||
if not raw:
|
||
return JSONResponse(
|
||
status_code=400,
|
||
content={"error": "Empty file", "detail": "Uploaded file contained no bytes."},
|
||
)
|
||
try:
|
||
text = raw.decode("utf-8")
|
||
except UnicodeDecodeError as exc:
|
||
return JSONResponse(
|
||
status_code=400,
|
||
content={"error": "Encoding error", "detail": str(exc)},
|
||
)
|
||
|
||
try:
|
||
result = parse_271_text(text, input_file=file.filename or "")
|
||
except CycloneParseError as exc:
|
||
return JSONResponse(
|
||
status_code=400,
|
||
content={"error": "Parse error", "detail": str(exc)},
|
||
)
|
||
except Exception as exc: # pragma: no cover - safety net
|
||
log.exception("Unexpected parser failure on 271")
|
||
return JSONResponse(
|
||
status_code=500,
|
||
content={"error": "Internal server error", "detail": str(exc)},
|
||
)
|
||
|
||
return {
|
||
"coverage_benefits": [
|
||
json.loads(cb.model_dump_json()) for cb in result.coverage_benefits
|
||
],
|
||
"subscriber": json.loads(result.subscriber.model_dump_json()),
|
||
"summary": json.loads(result.summary.model_dump_json()),
|
||
"envelope": json.loads(result.envelope.model_dump_json()),
|
||
"information_source": json.loads(result.information_source.model_dump_json()),
|
||
"information_receiver": json.loads(result.information_receiver.model_dump_json()),
|
||
}
|
||
|
||
|
||
|
||
|
||
|
||
# --------------------------------------------------------------------------- #
|
||
# SP11: tamper-evident audit log (admin)
|
||
# --------------------------------------------------------------------------- #
|
||
|
||
|
||
@app.get("/api/admin/audit-log")
|
||
def list_audit_log_endpoint(
|
||
entity_type: str | None = Query(default=None),
|
||
entity_id: str | None = Query(default=None),
|
||
event_type: str | None = Query(default=None),
|
||
limit: int = Query(default=100, ge=1, le=1000),
|
||
) -> Any:
|
||
"""List audit-log rows, newest first, with optional filters.
|
||
|
||
Filters match the (entity_type, entity_id) pair (typical use:
|
||
"show me everything that happened to claim C-123") or a single
|
||
event_type (typical use: "show me all clearhouse.submitted
|
||
events today").
|
||
"""
|
||
with db.SessionLocal()() as s:
|
||
q = s.query(db.AuditLog)
|
||
if entity_type:
|
||
q = q.filter(db.AuditLog.entity_type == entity_type)
|
||
if entity_id:
|
||
q = q.filter(db.AuditLog.entity_id == entity_id)
|
||
if event_type:
|
||
q = q.filter(db.AuditLog.event_type == event_type)
|
||
rows = q.order_by(db.AuditLog.id.desc()).limit(limit).all()
|
||
return {
|
||
"total": len(rows),
|
||
"items": [
|
||
{
|
||
"id": r.id,
|
||
"event_type": r.event_type,
|
||
"entity_type": r.entity_type,
|
||
"entity_id": r.entity_id,
|
||
"actor": r.actor,
|
||
"payload": json.loads(r.payload_json) if r.payload_json else None,
|
||
"created_at": r.created_at.isoformat() if r.created_at else None,
|
||
"prev_hash": r.prev_hash,
|
||
"hash": r.hash,
|
||
}
|
||
for r in rows
|
||
],
|
||
}
|
||
|
||
|
||
@app.get("/api/admin/audit-log/verify")
|
||
def verify_audit_log_endpoint() -> Any:
|
||
"""Walk the audit-log chain and verify every row's hash.
|
||
|
||
Returns ``{"ok": true, "checked": N}`` for a clean chain, or
|
||
``{"ok": false, "checked": K, "first_bad_id": X, "reason": "..."}``
|
||
for a broken chain. This is the operator's "did anyone tamper?"
|
||
endpoint; run it on demand or via a nightly cron job.
|
||
"""
|
||
with db.SessionLocal()() as s:
|
||
result = verify_chain(s)
|
||
return {
|
||
"ok": result.ok,
|
||
"checked": result.checked,
|
||
"first_bad_id": result.first_bad_id,
|
||
"reason": result.reason,
|
||
}
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# SP15: SQLCipher key rotation
|
||
#
|
||
# Re-encrypts the DB in place with a fresh key, then updates the
|
||
# Keychain so subsequent connections open with the new key. This is
|
||
# a 1-time operation per rotation; for routine read/write the rest
|
||
# of the API is unchanged.
|
||
#
|
||
# Concurrency: the rotation holds a module-level lock so two
|
||
# concurrent requests can't race and end up with mismatched Keychain
|
||
# + DB. The lock is a simple threading.Lock; a process restart
|
||
# resets it (intentional — the operator's next start-up opens with
|
||
# whatever key is in the Keychain).
|
||
# ---------------------------------------------------------------------------
|
||
import threading as _threading
|
||
from cyclone import db_crypto as _db_crypto
|
||
from cyclone import secrets as _secrets
|
||
|
||
_db_rotate_lock = _threading.Lock()
|
||
|
||
|
||
@app.post("/api/admin/db/rotate-key")
|
||
def rotate_db_key_endpoint(body: dict | None = None) -> Any:
|
||
"""Generate a fresh DB key, re-encrypt the DB, update the Keychain.
|
||
|
||
Request body (optional):
|
||
actor: who initiated the rotation. Defaults to "operator".
|
||
reason: human-readable reason. Written to the audit log.
|
||
|
||
Returns:
|
||
``{ok, old_fingerprint, new_fingerprint, rotated_at, table_count}``
|
||
on success. On failure (DB not encrypted, rekey failed,
|
||
Keychain update failed) returns the same shape with
|
||
``ok=false`` and a ``reason``. HTTP 503 is returned if the
|
||
rekey fails or encryption is not enabled.
|
||
|
||
The Keychain write happens *after* the rekey succeeds. If the
|
||
Keychain write fails, the DB has the new key but the Keychain
|
||
still has the old one — the endpoint returns 503 with a
|
||
"keychain update failed" reason and the operator must restore
|
||
the old key manually (``cyclone db restore-key <old_key>``) to
|
||
avoid being locked out.
|
||
"""
|
||
body = body or {}
|
||
actor = body.get("actor") or "operator"
|
||
reason = body.get("reason") or ""
|
||
|
||
if not _db_crypto.is_encryption_enabled():
|
||
raise HTTPException(
|
||
status_code=400,
|
||
detail="encryption not enabled (sqlcipher3 missing or no Keychain key)",
|
||
)
|
||
|
||
# Acquire the lock; non-blocking so a stuck rotation doesn't
|
||
# silently hold up other requests.
|
||
if not _db_rotate_lock.acquire(blocking=False):
|
||
raise HTTPException(
|
||
status_code=409,
|
||
detail="another key rotation is in progress",
|
||
)
|
||
try:
|
||
url = db._resolve_url()
|
||
old_key = _db_crypto.get_db_key()
|
||
if not old_key:
|
||
raise HTTPException(
|
||
status_code=400,
|
||
detail="no DB key in Keychain; cannot rotate",
|
||
)
|
||
|
||
new_key = _db_crypto.generate_db_key()
|
||
result = _db_crypto.rotate_db_key(
|
||
url=url, old_key=old_key, new_key=new_key,
|
||
)
|
||
if not result.ok:
|
||
# Rekey failed. The DB still has the old key. The
|
||
# Keychain is unchanged. Caller should NOT retry with
|
||
# the same new key (it's lost); generate a fresh one.
|
||
log.error("SQLCipher rotate failed: %s", result.reason)
|
||
raise HTTPException(
|
||
status_code=503,
|
||
detail={
|
||
"ok": False,
|
||
"old_fingerprint": result.old_fingerprint,
|
||
"new_fingerprint": result.new_fingerprint,
|
||
"rotated_at": result.rotated_at,
|
||
"reason": result.reason,
|
||
},
|
||
)
|
||
|
||
# Rekey succeeded. Now update the Keychain. If this fails
|
||
# the DB is locked behind the new key — operator must
|
||
# restore the old key manually.
|
||
if not _secrets.set_secret(_db_crypto.KEYCHAIN_ACCOUNT, new_key):
|
||
log.error("Keychain update failed after successful rekey!")
|
||
raise HTTPException(
|
||
status_code=503,
|
||
detail={
|
||
"ok": False,
|
||
"old_fingerprint": result.old_fingerprint,
|
||
"new_fingerprint": result.new_fingerprint,
|
||
"rotated_at": result.rotated_at,
|
||
"reason": (
|
||
"rekey succeeded but Keychain update failed — "
|
||
"the DB is now encrypted with the new key but "
|
||
"the Keychain still has the old one. "
|
||
"Restore the old key to the Keychain to recover."
|
||
),
|
||
},
|
||
)
|
||
|
||
# Store the old key in the "previous" account for a grace
|
||
# period so the operator can roll back if they discover the
|
||
# new key is broken (e.g. the Keychain entry got truncated).
|
||
_secrets.set_secret(_db_crypto.KEYCHAIN_ACCOUNT_PREVIOUS, old_key)
|
||
|
||
# Rebuild the engine so subsequent connections use the new
|
||
# key. dispose_engine() closes every pooled connection that
|
||
# was using the old key; init_db() opens new ones with the
|
||
# new key from the (now-updated) Keychain.
|
||
db.reinit_engine()
|
||
|
||
# Audit log the rotation. We do this after the engine is
|
||
# rebuilt so the audit event is written with the new key —
|
||
# proving that the new key works for new writes.
|
||
try:
|
||
from cyclone.audit_log import append_event, AuditEvent
|
||
with db.SessionLocal()() as s:
|
||
append_event(s, AuditEvent(
|
||
event_type="db.key_rotated",
|
||
entity_type="database",
|
||
entity_id="cyclone.db",
|
||
actor=actor,
|
||
payload={
|
||
"old_fingerprint": result.old_fingerprint,
|
||
"new_fingerprint": result.new_fingerprint,
|
||
"table_count": result.table_count,
|
||
"reason": reason,
|
||
},
|
||
))
|
||
s.commit()
|
||
except Exception as exc: # noqa: BLE001
|
||
# Audit append is best-effort; rotation already succeeded.
|
||
log.warning("could not write audit event for rotation: %s", exc)
|
||
|
||
return {
|
||
"ok": True,
|
||
"old_fingerprint": result.old_fingerprint,
|
||
"new_fingerprint": result.new_fingerprint,
|
||
"rotated_at": result.rotated_at,
|
||
"table_count": result.table_count,
|
||
}
|
||
finally:
|
||
_db_rotate_lock.release()
|
||
|
||
|
||
|
||
|
||
|
||
@app.post("/api/admin/reload-config")
|
||
def reload_config():
|
||
"""Re-read ``config/payers.yaml`` and revalidate. Returns counts."""
|
||
from cyclone import payers as payer_loader
|
||
try:
|
||
configs = payer_loader.load_payer_configs()
|
||
except ValueError as e:
|
||
raise HTTPException(status_code=400, detail=str(e))
|
||
return {"ok": True, "loaded": len(configs), "errors": []}
|
||
|
||
|
||
__all__ = ["app"]
|