merge: ui/batch-diff
This commit is contained in:
@@ -894,6 +894,58 @@ def get_reconciliation_unmatched() -> dict:
|
|||||||
return store.list_unmatched(kind="both")
|
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")
|
@app.post("/api/reconciliation/match")
|
||||||
def post_reconciliation_match(body: dict) -> dict:
|
def post_reconciliation_match(body: dict) -> dict:
|
||||||
"""Manually pair a Claim with a Remittance (operator override).
|
"""Manually pair a Claim with a Remittance (operator override).
|
||||||
|
|||||||
@@ -0,0 +1,401 @@
|
|||||||
|
"""Side-by-side diff between two parsed batches.
|
||||||
|
|
||||||
|
Powers ``GET /api/batch-diff?a=<batch_id>&b=<batch_id>``. Given two
|
||||||
|
``BatchRecord`` objects (kind=837P or 835), project each contained
|
||||||
|
claim/remittance to a small ``BatchClaimSummary`` and split the union
|
||||||
|
into three buckets:
|
||||||
|
|
||||||
|
- ``added`` — in B, not in A (by claim_id / payer_claim_control_number)
|
||||||
|
- ``removed`` — in A, not in B
|
||||||
|
- ``changed`` — in both, with at least one differing field
|
||||||
|
|
||||||
|
The diff is purely value-based (no segment-level deep diff). The four
|
||||||
|
fields we surface as "changed" are:
|
||||||
|
|
||||||
|
- status (837P: validation-derived label, 835: CLP02 code)
|
||||||
|
- total_charge (Decimal → number; tolerance 1e-2 to ignore float jitter)
|
||||||
|
- service_date (ISO date; missing vs present counts as a change)
|
||||||
|
- cpt_codes (sorted list equality)
|
||||||
|
|
||||||
|
The module is a pure function on Pydantic models — no DB I/O — so the
|
||||||
|
API endpoint and tests can both call it without session management.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from decimal import Decimal
|
||||||
|
from typing import Any, Iterable
|
||||||
|
|
||||||
|
from cyclone.parsers.models import ClaimOutput, ValidationReport
|
||||||
|
from cyclone.parsers.models_835 import ClaimPayment, ParseResult835
|
||||||
|
from cyclone.store import BatchRecord, BatchRecord835, BatchRecord837, _claim_status_from_validation
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Projected types
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class BatchClaimSummary:
|
||||||
|
"""Small per-claim shape returned in the diff payload.
|
||||||
|
|
||||||
|
Kept tiny on purpose — the diff view shouldn't carry the full EDI
|
||||||
|
payload. Raw segments and diagnoses live on the per-batch detail
|
||||||
|
endpoint; this view is what the operator uses to spot the deltas
|
||||||
|
between A and B at a glance.
|
||||||
|
|
||||||
|
``service_date`` and ``cpt_codes`` are optional because 835
|
||||||
|
remittance rows don't carry the patient name; we surface empty
|
||||||
|
strings / empty lists rather than synthesizing values so the UI can
|
||||||
|
distinguish "no data" from "blank string".
|
||||||
|
"""
|
||||||
|
|
||||||
|
claim_id: str
|
||||||
|
patient_name: str
|
||||||
|
total_charge: float
|
||||||
|
service_date: str | None
|
||||||
|
status: str
|
||||||
|
cpt_codes: list[str]
|
||||||
|
|
||||||
|
def to_dict(self) -> dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"claim_id": self.claim_id,
|
||||||
|
"patient_name": self.patient_name,
|
||||||
|
"total_charge": self.total_charge,
|
||||||
|
"service_date": self.service_date,
|
||||||
|
"status": self.status,
|
||||||
|
"cpt_codes": list(self.cpt_codes),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class FieldDiff:
|
||||||
|
"""One changed field between two ``BatchClaimSummary`` rows.
|
||||||
|
|
||||||
|
``a`` and ``b`` are the field values from each side. We don't
|
||||||
|
attempt to coerce types — the frontend renders them verbatim — but
|
||||||
|
the comparator picks the field-specific projection (e.g. charge as
|
||||||
|
a number) so equality is stable.
|
||||||
|
"""
|
||||||
|
|
||||||
|
field: str
|
||||||
|
a: Any
|
||||||
|
b: Any
|
||||||
|
|
||||||
|
def to_dict(self) -> dict[str, Any]:
|
||||||
|
return {"field": self.field, "a": self.a, "b": self.b}
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class ChangedRow:
|
||||||
|
"""One claim present in both batches with at least one field delta.
|
||||||
|
|
||||||
|
Always carries both summaries (so the renderer can fall back to
|
||||||
|
a single side if a field is missing) plus the diff list (possibly
|
||||||
|
multiple fields changed for the same claim).
|
||||||
|
"""
|
||||||
|
|
||||||
|
a: BatchClaimSummary
|
||||||
|
b: BatchClaimSummary
|
||||||
|
diff: list[FieldDiff]
|
||||||
|
|
||||||
|
def to_dict(self) -> dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"a": self.a.to_dict(),
|
||||||
|
"b": self.b.to_dict(),
|
||||||
|
"diff": [d.to_dict() for d in self.diff],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class BatchDiffResult:
|
||||||
|
"""Full diff between two batches.
|
||||||
|
|
||||||
|
Mirrors the API contract documented in the task spec. Counts are
|
||||||
|
precomputed so the frontend doesn't have to derive them.
|
||||||
|
"""
|
||||||
|
|
||||||
|
a_meta: dict[str, Any]
|
||||||
|
b_meta: dict[str, Any]
|
||||||
|
added: list[BatchClaimSummary]
|
||||||
|
removed: list[BatchClaimSummary]
|
||||||
|
changed: list[ChangedRow]
|
||||||
|
|
||||||
|
@property
|
||||||
|
def summary(self) -> dict[str, int]:
|
||||||
|
added_count = len(self.added)
|
||||||
|
removed_count = len(self.removed)
|
||||||
|
changed_count = len(self.changed)
|
||||||
|
# ``unchanged`` are the claims present in BOTH batches that have
|
||||||
|
# no field differences. Derivation from A's total:
|
||||||
|
# A's total = unchanged + changed + removed
|
||||||
|
# so unchanged = A_total - changed - removed. Clamp at zero so
|
||||||
|
# a malformed batch pair can never surface a negative count.
|
||||||
|
a_total = len(self.a_meta.get("claim_ids", []) or [])
|
||||||
|
unchanged_count = max(0, a_total - changed_count - removed_count)
|
||||||
|
return {
|
||||||
|
"addedCount": added_count,
|
||||||
|
"removedCount": removed_count,
|
||||||
|
"changedCount": changed_count,
|
||||||
|
"unchangedCount": unchanged_count,
|
||||||
|
}
|
||||||
|
|
||||||
|
def to_dict(self) -> dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"a": self.a_meta,
|
||||||
|
"b": self.b_meta,
|
||||||
|
"added": [s.to_dict() for s in self.added],
|
||||||
|
"removed": [s.to_dict() for s in self.removed],
|
||||||
|
"changed": [c.to_dict() for c in self.changed],
|
||||||
|
"summary": self.summary,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Projection helpers
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def _summary_from_claim_837(claim: ClaimOutput) -> BatchClaimSummary:
|
||||||
|
"""Project a 837P ``ClaimOutput`` to the diff summary shape."""
|
||||||
|
# Patient name: same composition as to_ui_claim.
|
||||||
|
sub = claim.subscriber
|
||||||
|
patient_name = f"{sub.first_name} {sub.last_name}".strip()
|
||||||
|
# Total charge: coerce Decimal to float; 0.0 when None.
|
||||||
|
total_charge = float(claim.claim.total_charge or Decimal("0"))
|
||||||
|
# Earliest service date across the SV1 service lines (matches
|
||||||
|
# store._service_dates_from_claim convention).
|
||||||
|
service_dates: list = [
|
||||||
|
sl.service_date for sl in claim.service_lines if sl.service_date is not None
|
||||||
|
]
|
||||||
|
service_date: str | None = None
|
||||||
|
if service_dates:
|
||||||
|
earliest = min(service_dates)
|
||||||
|
# The pydantic model serializes dates to ISO strings via the
|
||||||
|
# @model_serializer; pull isoformat defensively for both
|
||||||
|
# date and datetime inputs (Pydantic coerces in either case).
|
||||||
|
service_date = earliest.isoformat() if hasattr(earliest, "isoformat") else str(earliest)
|
||||||
|
# CPT codes: collect the procedure.code across all SV1s, in line order.
|
||||||
|
cpt_codes = [
|
||||||
|
sl.procedure.code for sl in claim.service_lines if sl.procedure.code
|
||||||
|
]
|
||||||
|
return BatchClaimSummary(
|
||||||
|
claim_id=claim.claim_id,
|
||||||
|
patient_name=patient_name,
|
||||||
|
total_charge=total_charge,
|
||||||
|
service_date=service_date,
|
||||||
|
status=_claim_status_from_validation(claim),
|
||||||
|
cpt_codes=cpt_codes,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _summary_from_remit_835(cp: ClaimPayment) -> BatchClaimSummary:
|
||||||
|
"""Project an 835 ``ClaimPayment`` to the diff summary shape.
|
||||||
|
|
||||||
|
Patient name isn't carried on an ERA — we leave it blank rather
|
||||||
|
than synthesize. Total charge / paid amount aren't on the
|
||||||
|
``BatchClaimSummary`` contract; we expose the 835's ``total_charge``
|
||||||
|
on the BPR total so it lines up with the 837P's CLM01 amount.
|
||||||
|
"""
|
||||||
|
total_charge = float(cp.total_charge or Decimal("0"))
|
||||||
|
service_date: str | None = None
|
||||||
|
cpt_codes: list[str] = []
|
||||||
|
if cp.service_payments:
|
||||||
|
sp = cp.service_payments[0]
|
||||||
|
if sp.service_date is not None:
|
||||||
|
service_date = (
|
||||||
|
sp.service_date.isoformat()
|
||||||
|
if hasattr(sp.service_date, "isoformat")
|
||||||
|
else str(sp.service_date)
|
||||||
|
)
|
||||||
|
# CPT codes from every SVC segment in the CLP loop.
|
||||||
|
for svc in cp.service_payments:
|
||||||
|
if svc.procedure_code:
|
||||||
|
cpt_codes.append(svc.procedure_code)
|
||||||
|
# CLP02 status code as the primary status label; fall back to the
|
||||||
|
# human-readable label when one is bundled in the parser.
|
||||||
|
status = cp.status_label or cp.status_code or ""
|
||||||
|
return BatchClaimSummary(
|
||||||
|
# ``payer_claim_control_number`` is the ERA's natural id and the
|
||||||
|
# match key against another batch's 835 (matches on CLP01).
|
||||||
|
claim_id=cp.payer_claim_control_number,
|
||||||
|
patient_name="",
|
||||||
|
total_charge=total_charge,
|
||||||
|
service_date=service_date,
|
||||||
|
status=status,
|
||||||
|
cpt_codes=cpt_codes,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _summarize_record(rec: BatchRecord) -> list[BatchClaimSummary]:
|
||||||
|
"""Return the list of summaries for a batch — 837P or 835.
|
||||||
|
|
||||||
|
Dispatched by ``isinstance`` so callers don't need to branch on
|
||||||
|
``kind``. Returns an empty list when the record's ``result`` is
|
||||||
|
missing the expected claim array (defensive against partially-loaded
|
||||||
|
rows in tests).
|
||||||
|
"""
|
||||||
|
if isinstance(rec, BatchRecord837):
|
||||||
|
return [_summary_from_claim_837(c) for c in (rec.result.claims or [])]
|
||||||
|
if isinstance(rec, BatchRecord835):
|
||||||
|
return [_summary_from_remit_835(c) for c in (rec.result.claims or [])]
|
||||||
|
# Unknown record type — return empty so the diff still computes.
|
||||||
|
return []
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Field comparison
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def _eq_charge(a: float, b: float) -> bool:
|
||||||
|
"""Two floats are equal if they differ by less than a cent.
|
||||||
|
|
||||||
|
Decimal → float coercion can introduce sub-cent noise when the
|
||||||
|
source had more than 2 decimal places; tolerate that here so we
|
||||||
|
don't surface spurious charge deltas.
|
||||||
|
"""
|
||||||
|
return abs(a - b) < 0.01
|
||||||
|
|
||||||
|
|
||||||
|
def _diff_pair(
|
||||||
|
a: BatchClaimSummary,
|
||||||
|
b: BatchClaimSummary,
|
||||||
|
*,
|
||||||
|
compare_status: bool,
|
||||||
|
) -> list[FieldDiff]:
|
||||||
|
"""Return the list of changed fields between two same-id summaries.
|
||||||
|
|
||||||
|
Compares the four fields documented in the module docstring; an
|
||||||
|
unchanged field is simply not in the returned list.
|
||||||
|
|
||||||
|
``compare_status`` controls whether ``status`` enters the diff:
|
||||||
|
- True when both batches are the same kind (837P vs 837P, or
|
||||||
|
835 vs 835). 837P's validation-derived status (``submitted`` /
|
||||||
|
``pending`` / ``denied`` / ``draft``) and 835's CLP02 code
|
||||||
|
(``1`` / ``4`` / ``21`` / …) live in different namespaces, so
|
||||||
|
comparing across kinds surfaces spurious deltas the operator
|
||||||
|
can't act on. We skip the comparison when kinds differ.
|
||||||
|
"""
|
||||||
|
out: list[FieldDiff] = []
|
||||||
|
if compare_status and a.status != b.status:
|
||||||
|
out.append(FieldDiff("status", a.status, b.status))
|
||||||
|
if not _eq_charge(a.total_charge, b.total_charge):
|
||||||
|
out.append(FieldDiff("total_charge", a.total_charge, b.total_charge))
|
||||||
|
if a.service_date != b.service_date:
|
||||||
|
out.append(FieldDiff("service_date", a.service_date, b.service_date))
|
||||||
|
# CPT codes are compared as sorted, deduplicated lists so reordering
|
||||||
|
# between SV1 segments doesn't register as a diff.
|
||||||
|
a_codes = sorted(set(a.cpt_codes))
|
||||||
|
b_codes = sorted(set(b.cpt_codes))
|
||||||
|
if a_codes != b_codes:
|
||||||
|
out.append(FieldDiff("cpt_codes", a_codes, b_codes))
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def _meta(rec: BatchRecord, *, claim_ids: Iterable[str]) -> dict[str, Any]:
|
||||||
|
"""Build the ``a``/``b`` metadata block for the diff envelope.
|
||||||
|
|
||||||
|
The ``claim_ids`` list is passed in by the caller (the same source
|
||||||
|
used for the summaries) so the meta block is consistent with what
|
||||||
|
the diff actually saw.
|
||||||
|
"""
|
||||||
|
parsed_at = rec.parsed_at
|
||||||
|
parsed_iso = parsed_at.isoformat().replace("+00:00", "Z") if parsed_at else ""
|
||||||
|
return {
|
||||||
|
"id": rec.id,
|
||||||
|
"kind": rec.kind,
|
||||||
|
"parsedAt": parsed_iso,
|
||||||
|
"inputFilename": rec.input_filename,
|
||||||
|
"claimCount": len(list(claim_ids)),
|
||||||
|
# Internal-only — stripped from the wire response below. The
|
||||||
|
# API layer recomputes counts via to_dict, so this key is
|
||||||
|
# used solely for the ``summary.unchangedCount`` derivation.
|
||||||
|
"claim_ids": list(claim_ids),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Public API
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def diff_batches(
|
||||||
|
a: BatchRecord,
|
||||||
|
b: BatchRecord,
|
||||||
|
) -> BatchDiffResult:
|
||||||
|
"""Compute the side-by-side diff between ``a`` and ``b``.
|
||||||
|
|
||||||
|
Both records can be 837P, both 835, or one of each — we project
|
||||||
|
each to ``BatchClaimSummary`` independently and match by the
|
||||||
|
summary's ``claim_id`` field (837P ``CLM01``, 835 ``CLP01``). When
|
||||||
|
the kinds differ the result is still well-defined; it just means
|
||||||
|
you're comparing e.g. an 837P claim set against the matching
|
||||||
|
remittances — useful when the operator wants to see which claims
|
||||||
|
were paid vs. submitted.
|
||||||
|
|
||||||
|
Order preservation:
|
||||||
|
- ``added`` follows B's input order (matches the upload narrative).
|
||||||
|
- ``removed`` follows A's input order.
|
||||||
|
- ``changed`` follows B's input order (the "current state" side).
|
||||||
|
|
||||||
|
This mirrors git-diff conventions where the right-hand side drives
|
||||||
|
the row order on the page.
|
||||||
|
"""
|
||||||
|
a_summaries = _summarize_record(a)
|
||||||
|
b_summaries = _summarize_record(b)
|
||||||
|
a_by_id = {s.claim_id: s for s in a_summaries}
|
||||||
|
b_by_id = {s.claim_id: s for s in b_summaries}
|
||||||
|
|
||||||
|
# Status only carries comparable meaning when both batches share a
|
||||||
|
# kind (837P/837P or 835/835). Across kinds the namespaces differ
|
||||||
|
# (validation-derived vs CLP02) so we skip that field.
|
||||||
|
compare_status = a.kind == b.kind
|
||||||
|
|
||||||
|
added: list[BatchClaimSummary] = []
|
||||||
|
removed: list[BatchClaimSummary] = []
|
||||||
|
changed: list[ChangedRow] = []
|
||||||
|
|
||||||
|
# Walk B's order for added/changed (B drives row order).
|
||||||
|
for s in b_summaries:
|
||||||
|
if s.claim_id not in a_by_id:
|
||||||
|
added.append(s)
|
||||||
|
continue
|
||||||
|
other = a_by_id[s.claim_id]
|
||||||
|
diff = _diff_pair(other, s, compare_status=compare_status)
|
||||||
|
if diff:
|
||||||
|
changed.append(ChangedRow(a=other, b=s, diff=diff))
|
||||||
|
|
||||||
|
# Walk A's order for removed (anything in A but not in B).
|
||||||
|
for s in a_summaries:
|
||||||
|
if s.claim_id not in b_by_id:
|
||||||
|
removed.append(s)
|
||||||
|
|
||||||
|
a_meta = _meta(a, claim_ids=[s.claim_id for s in a_summaries])
|
||||||
|
b_meta = _meta(b, claim_ids=[s.claim_id for s in b_summaries])
|
||||||
|
return BatchDiffResult(
|
||||||
|
a_meta=a_meta,
|
||||||
|
b_meta=b_meta,
|
||||||
|
added=added,
|
||||||
|
removed=removed,
|
||||||
|
changed=changed,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def diff_batches_to_wire(
|
||||||
|
a: BatchRecord,
|
||||||
|
b: BatchRecord,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Convenience wrapper that returns the JSON-ready dict.
|
||||||
|
|
||||||
|
Strips the internal ``claim_ids`` key from the metadata blocks
|
||||||
|
before returning so the wire contract stays clean — the spec only
|
||||||
|
promises ``id, kind, parsedAt, inputFilename, claimCount`` per side.
|
||||||
|
"""
|
||||||
|
result = diff_batches(a, b)
|
||||||
|
wire = result.to_dict()
|
||||||
|
wire["a"].pop("claim_ids", None)
|
||||||
|
wire["b"].pop("claim_ids", None)
|
||||||
|
return wire
|
||||||
@@ -1240,6 +1240,31 @@ class CycloneStore:
|
|||||||
rows = s.query(Batch).order_by(Batch.parsed_at.asc()).all()
|
rows = s.query(Batch).order_by(Batch.parsed_at.asc()).all()
|
||||||
return [self._row_to_record(r) for r in rows]
|
return [self._row_to_record(r) for r in rows]
|
||||||
|
|
||||||
|
def load_two_for_diff(
|
||||||
|
self,
|
||||||
|
a_id: str,
|
||||||
|
b_id: str,
|
||||||
|
) -> tuple[BatchRecord, BatchRecord]:
|
||||||
|
"""Load two batches by id for the side-by-side diff view.
|
||||||
|
|
||||||
|
Returns ``(a, b)`` as ``BatchRecord`` objects. Raises
|
||||||
|
:class:`LookupError` when either id is missing — the API layer
|
||||||
|
catches it and maps it to ``404 Not Found`` (matching the
|
||||||
|
``GET /api/batches/{id}`` contract). The two loads happen in
|
||||||
|
independent sessions so a transient failure on one side can't
|
||||||
|
poison the other.
|
||||||
|
|
||||||
|
Used exclusively by :mod:`cyclone.batch_diff` via the
|
||||||
|
``/api/batch-diff`` endpoint.
|
||||||
|
"""
|
||||||
|
a = self.get(a_id)
|
||||||
|
if a is None:
|
||||||
|
raise LookupError(f"batch {a_id} not found")
|
||||||
|
b = self.get(b_id)
|
||||||
|
if b is None:
|
||||||
|
raise LookupError(f"batch {b_id} not found")
|
||||||
|
return a, b
|
||||||
|
|
||||||
def iter_claims(
|
def iter_claims(
|
||||||
self,
|
self,
|
||||||
*,
|
*,
|
||||||
|
|||||||
@@ -0,0 +1,611 @@
|
|||||||
|
"""Tests for ``cyclone.batch_diff`` and the ``GET /api/batch-diff`` endpoint.
|
||||||
|
|
||||||
|
Two layers:
|
||||||
|
|
||||||
|
1. Pure unit tests over :func:`cyclone.batch_diff.diff_batches` — no DB,
|
||||||
|
no HTTP. Construct ``BatchRecord`` values directly with the parser's
|
||||||
|
Pydantic models so the tests stay fast and deterministic.
|
||||||
|
|
||||||
|
2. Integration tests over the FastAPI endpoint — round-trip through
|
||||||
|
``TestClient`` with real 837P / 835 fixtures. The ``conftest.py``
|
||||||
|
fixture already gives every test a fresh SQLite database, so these
|
||||||
|
run independently without manual cleanup.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import date, datetime, timezone
|
||||||
|
from decimal import Decimal
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
from cyclone.api import app
|
||||||
|
from cyclone.batch_diff import (
|
||||||
|
BatchClaimSummary,
|
||||||
|
diff_batches,
|
||||||
|
diff_batches_to_wire,
|
||||||
|
)
|
||||||
|
from cyclone.parsers.models import (
|
||||||
|
Address,
|
||||||
|
BatchSummary,
|
||||||
|
BillingProvider,
|
||||||
|
ClaimHeader,
|
||||||
|
ClaimOutput,
|
||||||
|
Envelope,
|
||||||
|
Payer,
|
||||||
|
ParseResult,
|
||||||
|
Procedure,
|
||||||
|
ServiceLine,
|
||||||
|
Subscriber,
|
||||||
|
ValidationReport,
|
||||||
|
)
|
||||||
|
from cyclone.parsers.models_835 import (
|
||||||
|
ClaimPayment,
|
||||||
|
ParseResult835,
|
||||||
|
Payer835,
|
||||||
|
Payee835,
|
||||||
|
ReassociationTrace,
|
||||||
|
FinancialInfo,
|
||||||
|
ServicePayment,
|
||||||
|
)
|
||||||
|
from cyclone.store import BatchRecord, store as global_store
|
||||||
|
|
||||||
|
# 837P fixture shipped with the test suite — two claims, useful as a
|
||||||
|
# baseline for the integration tests below.
|
||||||
|
FIXTURE_837 = Path(__file__).parent / "fixtures" / "co_medicaid_837p.txt"
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Fixtures
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def _setup(tmp_path, monkeypatch):
|
||||||
|
"""Per-test isolated DB.
|
||||||
|
|
||||||
|
Same pattern used by ``test_store.py``. The unit tests below don't
|
||||||
|
touch the DB, but it's autouse'd so a test that flips to integration
|
||||||
|
mode doesn't accidentally inherit stale state.
|
||||||
|
"""
|
||||||
|
monkeypatch.setenv("CYCLONE_DB_URL", f"sqlite:///{tmp_path}/test.db")
|
||||||
|
from cyclone import db
|
||||||
|
db._reset_for_tests()
|
||||||
|
db.init_db()
|
||||||
|
yield
|
||||||
|
db._reset_for_tests()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def client() -> TestClient:
|
||||||
|
return TestClient(app)
|
||||||
|
|
||||||
|
|
||||||
|
def _validation(passed: bool = True) -> ValidationReport:
|
||||||
|
return ValidationReport(passed=passed, errors=[], warnings=[])
|
||||||
|
|
||||||
|
|
||||||
|
def _make_claim_837(
|
||||||
|
*,
|
||||||
|
claim_id: str = "CLM-1",
|
||||||
|
charge: str = "124.00",
|
||||||
|
patient_first: str = "Jane",
|
||||||
|
patient_last: str = "Doe",
|
||||||
|
frequency_code: str = "1",
|
||||||
|
service_date: date | None = None,
|
||||||
|
cpt_code: str = "99213",
|
||||||
|
validation: ValidationReport | None = None,
|
||||||
|
) -> ClaimOutput:
|
||||||
|
service_lines = []
|
||||||
|
if cpt_code or service_date is not None:
|
||||||
|
service_lines.append(
|
||||||
|
ServiceLine(
|
||||||
|
line_number=1,
|
||||||
|
procedure=Procedure(qualifier="HC", code=cpt_code, modifiers=[]),
|
||||||
|
charge=Decimal(charge),
|
||||||
|
units=Decimal("1"),
|
||||||
|
service_date=service_date,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return ClaimOutput(
|
||||||
|
claim_id=claim_id,
|
||||||
|
control_number="0001",
|
||||||
|
transaction_date=date(2026, 6, 19),
|
||||||
|
billing_provider=BillingProvider(name="Test Clinic", npi="1234567890"),
|
||||||
|
subscriber=Subscriber(
|
||||||
|
first_name=patient_first,
|
||||||
|
last_name=patient_last,
|
||||||
|
member_id=f"M-{claim_id}",
|
||||||
|
),
|
||||||
|
payer=Payer(name="Test Payer", id="P1"),
|
||||||
|
claim=ClaimHeader(
|
||||||
|
claim_id=claim_id,
|
||||||
|
total_charge=Decimal(charge),
|
||||||
|
frequency_code=frequency_code,
|
||||||
|
place_of_service="11",
|
||||||
|
),
|
||||||
|
diagnoses=[],
|
||||||
|
service_lines=service_lines,
|
||||||
|
validation=validation if validation is not None else _validation(),
|
||||||
|
raw_segments=[],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _make_record_837(claims: list[ClaimOutput], *, batch_id: str = "b-a") -> BatchRecord:
|
||||||
|
return BatchRecord(
|
||||||
|
id=batch_id,
|
||||||
|
kind="837p",
|
||||||
|
input_filename=f"{batch_id}.837",
|
||||||
|
parsed_at=datetime(2026, 6, 19, 12, 0, tzinfo=timezone.utc),
|
||||||
|
result=ParseResult(
|
||||||
|
envelope=Envelope(
|
||||||
|
sender_id="S", receiver_id="R", control_number="0001",
|
||||||
|
transaction_date=date(2026, 6, 19),
|
||||||
|
),
|
||||||
|
claims=claims,
|
||||||
|
summary=BatchSummary(
|
||||||
|
input_file=f"{batch_id}.837", control_number="0001",
|
||||||
|
transaction_date=date(2026, 6, 19),
|
||||||
|
total_claims=len(claims),
|
||||||
|
passed=len(claims), failed=0,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _make_remit_835(
|
||||||
|
*,
|
||||||
|
pccn: str = "R-1",
|
||||||
|
charge: str = "100.00",
|
||||||
|
status_code: str = "1",
|
||||||
|
service_date: date | None = None,
|
||||||
|
procedure_code: str = "99213",
|
||||||
|
) -> ClaimPayment:
|
||||||
|
service_payments = []
|
||||||
|
if procedure_code or service_date is not None:
|
||||||
|
service_payments.append(
|
||||||
|
ServicePayment(
|
||||||
|
line_number=1,
|
||||||
|
procedure_qualifier="HC",
|
||||||
|
procedure_code=procedure_code,
|
||||||
|
modifiers=[],
|
||||||
|
charge=Decimal(charge),
|
||||||
|
payment=Decimal("0"),
|
||||||
|
service_date=service_date,
|
||||||
|
adjustments=[],
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return ClaimPayment(
|
||||||
|
payer_claim_control_number=pccn,
|
||||||
|
status_code=status_code,
|
||||||
|
status_label=None,
|
||||||
|
total_charge=Decimal(charge),
|
||||||
|
total_paid=Decimal("0"),
|
||||||
|
service_payments=service_payments,
|
||||||
|
raw_segments=[],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _make_record_835(
|
||||||
|
remits: list[ClaimPayment], *, batch_id: str = "b-b",
|
||||||
|
) -> BatchRecord:
|
||||||
|
return BatchRecord(
|
||||||
|
id=batch_id,
|
||||||
|
kind="835",
|
||||||
|
input_filename=f"{batch_id}.835",
|
||||||
|
parsed_at=datetime(2026, 6, 19, 13, 0, tzinfo=timezone.utc),
|
||||||
|
result=ParseResult835(
|
||||||
|
envelope=Envelope(
|
||||||
|
sender_id="P", receiver_id="R", control_number="0002",
|
||||||
|
transaction_date=date(2026, 6, 19),
|
||||||
|
),
|
||||||
|
financial_info=FinancialInfo(
|
||||||
|
handling_code="C", paid_amount=Decimal("0"),
|
||||||
|
credit_debit_flag="C",
|
||||||
|
),
|
||||||
|
trace=ReassociationTrace(
|
||||||
|
trace_type_code="1", trace_number="1",
|
||||||
|
originating_company_id="P1",
|
||||||
|
),
|
||||||
|
payer=Payer835(name="Payer"),
|
||||||
|
payee=Payee835(name="Clinic", npi="1234567890"),
|
||||||
|
claims=remits,
|
||||||
|
summary=BatchSummary(
|
||||||
|
input_file=f"{batch_id}.835", control_number="0002",
|
||||||
|
transaction_date=date(2026, 6, 19),
|
||||||
|
total_claims=len(remits),
|
||||||
|
passed=len(remits), failed=0,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Pure unit tests — diff_batches
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_empty_batches_have_no_deltas():
|
||||||
|
a = _make_record_837([], batch_id="b-a")
|
||||||
|
b = _make_record_837([], batch_id="b-b")
|
||||||
|
result = diff_batches(a, b)
|
||||||
|
assert result.added == []
|
||||||
|
assert result.removed == []
|
||||||
|
assert result.changed == []
|
||||||
|
summary = result.summary
|
||||||
|
assert summary == {
|
||||||
|
"addedCount": 0, "removedCount": 0,
|
||||||
|
"changedCount": 0, "unchangedCount": 0,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_identical_batches_have_no_deltas():
|
||||||
|
claims = [
|
||||||
|
_make_claim_837(claim_id="CLM-1", charge="124.00", service_date=date(2026, 6, 1)),
|
||||||
|
_make_claim_837(claim_id="CLM-2", charge="88.50", service_date=date(2026, 6, 2)),
|
||||||
|
]
|
||||||
|
a = _make_record_837(claims, batch_id="b-a")
|
||||||
|
b = _make_record_837(claims, batch_id="b-b")
|
||||||
|
result = diff_batches(a, b)
|
||||||
|
assert result.added == []
|
||||||
|
assert result.removed == []
|
||||||
|
assert result.changed == []
|
||||||
|
assert result.summary["unchangedCount"] == 2
|
||||||
|
|
||||||
|
|
||||||
|
def test_claim_added_in_b():
|
||||||
|
a = _make_record_837([_make_claim_837(claim_id="CLM-1")], batch_id="b-a")
|
||||||
|
b = _make_record_837(
|
||||||
|
[_make_claim_837(claim_id="CLM-1"), _make_claim_837(claim_id="CLM-2")],
|
||||||
|
batch_id="b-b",
|
||||||
|
)
|
||||||
|
result = diff_batches(a, b)
|
||||||
|
assert [s.claim_id for s in result.added] == ["CLM-2"]
|
||||||
|
assert result.removed == []
|
||||||
|
assert result.changed == []
|
||||||
|
assert result.summary["addedCount"] == 1
|
||||||
|
assert result.summary["unchangedCount"] == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_claim_removed_from_a():
|
||||||
|
a = _make_record_837(
|
||||||
|
[_make_claim_837(claim_id="CLM-1"), _make_claim_837(claim_id="CLM-2")],
|
||||||
|
batch_id="b-a",
|
||||||
|
)
|
||||||
|
b = _make_record_837([_make_claim_837(claim_id="CLM-1")], batch_id="b-b")
|
||||||
|
result = diff_batches(a, b)
|
||||||
|
assert result.added == []
|
||||||
|
assert [s.claim_id for s in result.removed] == ["CLM-2"]
|
||||||
|
assert result.summary["removedCount"] == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_claim_charge_change_is_a_diff():
|
||||||
|
a = _make_record_837(
|
||||||
|
[_make_claim_837(claim_id="CLM-1", charge="100.00")], batch_id="b-a",
|
||||||
|
)
|
||||||
|
b = _make_record_837(
|
||||||
|
[_make_claim_837(claim_id="CLM-1", charge="125.00")], batch_id="b-b",
|
||||||
|
)
|
||||||
|
result = diff_batches(a, b)
|
||||||
|
assert result.added == [] and result.removed == []
|
||||||
|
assert len(result.changed) == 1
|
||||||
|
fields = [d.field for d in result.changed[0].diff]
|
||||||
|
assert "total_charge" in fields
|
||||||
|
charge_diff = next(d for d in result.changed[0].diff if d.field == "total_charge")
|
||||||
|
assert charge_diff.a == 100.00
|
||||||
|
assert charge_diff.b == 125.00
|
||||||
|
|
||||||
|
|
||||||
|
def test_sub_cent_charge_jitter_is_not_a_diff():
|
||||||
|
"""Two charges that differ by less than a cent register as equal.
|
||||||
|
|
||||||
|
Decimal → float can introduce sub-cent noise when the source had
|
||||||
|
more than 2 decimal places. Tolerance is 1e-2 to match the
|
||||||
|
production ``_eq_charge`` helper.
|
||||||
|
"""
|
||||||
|
a = _make_record_837(
|
||||||
|
[_make_claim_837(claim_id="CLM-1", charge="100.00")], batch_id="b-a",
|
||||||
|
)
|
||||||
|
# 100.004 rounds to 100.00 in the float projection but is a fresh
|
||||||
|
# value when summed — surface this as a delta only if the diff
|
||||||
|
# exceeds the 1e-2 tolerance.
|
||||||
|
b = _make_record_837(
|
||||||
|
[_make_claim_837(claim_id="CLM-1", charge="100.005")], batch_id="b-b",
|
||||||
|
)
|
||||||
|
result = diff_batches(a, b)
|
||||||
|
assert result.changed == [], (
|
||||||
|
"sub-cent float noise should not register as a delta"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_status_change_is_a_diff():
|
||||||
|
"""Validation-derived status flows through to the diff.
|
||||||
|
|
||||||
|
A claim that flipped from passing to failing should surface a
|
||||||
|
``status`` field diff between the two sides.
|
||||||
|
"""
|
||||||
|
passing = _make_claim_837(
|
||||||
|
claim_id="CLM-1", validation=_validation(passed=True),
|
||||||
|
)
|
||||||
|
failing = _make_claim_837(
|
||||||
|
claim_id="CLM-1", validation=ValidationReport(
|
||||||
|
passed=False,
|
||||||
|
errors=[],
|
||||||
|
warnings=[],
|
||||||
|
),
|
||||||
|
)
|
||||||
|
a = _make_record_837([passing], batch_id="b-a")
|
||||||
|
b = _make_record_837([failing], batch_id="b-b")
|
||||||
|
result = diff_batches(a, b)
|
||||||
|
assert len(result.changed) == 1
|
||||||
|
fields = [d.field for d in result.changed[0].diff]
|
||||||
|
assert "status" in fields
|
||||||
|
|
||||||
|
|
||||||
|
def test_service_date_change_is_a_diff():
|
||||||
|
a = _make_record_837(
|
||||||
|
[_make_claim_837(claim_id="CLM-1", service_date=date(2026, 6, 1))],
|
||||||
|
batch_id="b-a",
|
||||||
|
)
|
||||||
|
b = _make_record_837(
|
||||||
|
[_make_claim_837(claim_id="CLM-1", service_date=date(2026, 6, 2))],
|
||||||
|
batch_id="b-b",
|
||||||
|
)
|
||||||
|
result = diff_batches(a, b)
|
||||||
|
assert len(result.changed) == 1
|
||||||
|
fields = [d.field for d in result.changed[0].diff]
|
||||||
|
assert "service_date" in fields
|
||||||
|
|
||||||
|
|
||||||
|
def test_cpt_change_is_a_diff():
|
||||||
|
a = _make_record_837(
|
||||||
|
[_make_claim_837(claim_id="CLM-1", cpt_code="99213")], batch_id="b-a",
|
||||||
|
)
|
||||||
|
b = _make_record_837(
|
||||||
|
[_make_claim_837(claim_id="CLM-1", cpt_code="99214")], batch_id="b-b",
|
||||||
|
)
|
||||||
|
result = diff_batches(a, b)
|
||||||
|
assert len(result.changed) == 1
|
||||||
|
fields = [d.field for d in result.changed[0].diff]
|
||||||
|
assert "cpt_codes" in fields
|
||||||
|
|
||||||
|
|
||||||
|
def test_cpt_reorder_is_not_a_diff():
|
||||||
|
"""CPT codes compared as sorted, deduplicated sets.
|
||||||
|
|
||||||
|
Two claims with the same codes in different SV1 order should
|
||||||
|
not register as a diff — that would generate noise on every
|
||||||
|
service-line reordering.
|
||||||
|
"""
|
||||||
|
a = _make_record_837(
|
||||||
|
[_make_claim_837(claim_id="CLM-1", cpt_code="99213")], batch_id="b-a",
|
||||||
|
)
|
||||||
|
b = _make_record_837(
|
||||||
|
[_make_claim_837(claim_id="CLM-1", cpt_code="99213")], batch_id="b-b",
|
||||||
|
)
|
||||||
|
result = diff_batches(a, b)
|
||||||
|
assert result.changed == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_mixed_added_removed_changed():
|
||||||
|
a = _make_record_837(
|
||||||
|
[
|
||||||
|
_make_claim_837(claim_id="CLM-1", charge="100.00"),
|
||||||
|
_make_claim_837(claim_id="CLM-2", charge="200.00"),
|
||||||
|
_make_claim_837(claim_id="CLM-3", charge="300.00"),
|
||||||
|
],
|
||||||
|
batch_id="b-a",
|
||||||
|
)
|
||||||
|
b = _make_record_837(
|
||||||
|
[
|
||||||
|
_make_claim_837(claim_id="CLM-1", charge="150.00"), # changed
|
||||||
|
_make_claim_837(claim_id="CLM-3", charge="300.00"), # unchanged
|
||||||
|
_make_claim_837(claim_id="CLM-4", charge="400.00"), # added
|
||||||
|
],
|
||||||
|
batch_id="b-b",
|
||||||
|
)
|
||||||
|
result = diff_batches(a, b)
|
||||||
|
assert [s.claim_id for s in result.added] == ["CLM-4"]
|
||||||
|
assert [s.claim_id for s in result.removed] == ["CLM-2"]
|
||||||
|
assert [c.a.claim_id for c in result.changed] == ["CLM-1"]
|
||||||
|
summary = result.summary
|
||||||
|
assert summary["addedCount"] == 1
|
||||||
|
assert summary["removedCount"] == 1
|
||||||
|
assert summary["changedCount"] == 1
|
||||||
|
assert summary["unchangedCount"] == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_wire_dict_strips_internal_claim_ids():
|
||||||
|
a = _make_record_837(
|
||||||
|
[_make_claim_837(claim_id="CLM-1")], batch_id="b-a",
|
||||||
|
)
|
||||||
|
b = _make_record_837(
|
||||||
|
[_make_claim_837(claim_id="CLM-1"), _make_claim_837(claim_id="CLM-2")],
|
||||||
|
batch_id="b-b",
|
||||||
|
)
|
||||||
|
wire = diff_batches_to_wire(a, b)
|
||||||
|
assert "claim_ids" not in wire["a"]
|
||||||
|
assert "claim_ids" not in wire["b"]
|
||||||
|
# Public shape — every key documented in the spec.
|
||||||
|
assert set(wire.keys()) == {"a", "b", "added", "removed", "changed", "summary"}
|
||||||
|
assert set(wire["a"].keys()) == {
|
||||||
|
"id", "kind", "parsedAt", "inputFilename", "claimCount",
|
||||||
|
}
|
||||||
|
assert wire["a"]["claimCount"] == 1
|
||||||
|
assert wire["b"]["claimCount"] == 2
|
||||||
|
|
||||||
|
|
||||||
|
def test_mixed_kind_diff_matches_by_claim_id():
|
||||||
|
"""837P and 835 can be diffed; the matcher uses ``claim_id``.
|
||||||
|
|
||||||
|
The common case for a single kind pair is covered by the other
|
||||||
|
tests. Here we exercise the cross-kind path so an operator can
|
||||||
|
diff submitted claims against received remittances.
|
||||||
|
"""
|
||||||
|
a = _make_record_837(
|
||||||
|
[
|
||||||
|
_make_claim_837(claim_id="CLM-1", charge="100.00"),
|
||||||
|
_make_claim_837(claim_id="CLM-2", charge="200.00"),
|
||||||
|
],
|
||||||
|
batch_id="b-a",
|
||||||
|
)
|
||||||
|
b = _make_record_835(
|
||||||
|
[
|
||||||
|
_make_remit_835(pccn="CLM-1", charge="100.00", status_code="1"),
|
||||||
|
_make_remit_835(pccn="CLM-3", charge="300.00", status_code="1"),
|
||||||
|
],
|
||||||
|
batch_id="b-b",
|
||||||
|
)
|
||||||
|
result = diff_batches(a, b)
|
||||||
|
# CLM-1 in both, no field change → unchanged
|
||||||
|
# CLM-2 in A only → removed
|
||||||
|
# CLM-3 in B only → added
|
||||||
|
assert [s.claim_id for s in result.added] == ["CLM-3"]
|
||||||
|
assert [s.claim_id for s in result.removed] == ["CLM-2"]
|
||||||
|
assert result.changed == []
|
||||||
|
assert result.summary["unchangedCount"] == 1
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Integration tests — /api/batch-diff
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def _ingest_837(client: TestClient, text: str, filename: str = "x.837") -> str:
|
||||||
|
resp = client.post(
|
||||||
|
"/api/parse-837",
|
||||||
|
files={"file": (filename, text, "text/plain")},
|
||||||
|
headers={"Accept": "application/json"},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200, resp.text
|
||||||
|
return resp.json()["claims"][0]["claim_id"] # used for sanity
|
||||||
|
|
||||||
|
|
||||||
|
def test_endpoint_404_when_a_missing(client: TestClient):
|
||||||
|
"""Missing ``a`` → 404, never 500.
|
||||||
|
|
||||||
|
Mirrors the existing ``GET /api/batches/{id}`` 404 contract.
|
||||||
|
"""
|
||||||
|
resp = client.get("/api/batch-diff?a=ghost&b=also-ghost")
|
||||||
|
assert resp.status_code == 404
|
||||||
|
body = resp.json()
|
||||||
|
assert body["detail"]["error"] == "Not found"
|
||||||
|
assert "ghost" in body["detail"]["detail"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_endpoint_400_when_params_missing(client: TestClient):
|
||||||
|
"""Missing query params → 400 with a helpful detail message."""
|
||||||
|
resp = client.get("/api/batch-diff?a=only")
|
||||||
|
assert resp.status_code == 400
|
||||||
|
body = resp.json()
|
||||||
|
assert body["detail"]["error"] == "Missing param"
|
||||||
|
assert "b" in body["detail"]["detail"].lower()
|
||||||
|
|
||||||
|
|
||||||
|
def test_endpoint_returns_diff_between_two_ingested_batches(client: TestClient):
|
||||||
|
"""End-to-end: POST two 837 files, GET the diff, assert the shape.
|
||||||
|
|
||||||
|
The fixture file ships with 2 claims; we ingest it twice. The
|
||||||
|
diff should be empty (the two batches are identical) and the
|
||||||
|
unchangedCount should equal 2.
|
||||||
|
"""
|
||||||
|
text = FIXTURE_837.read_text()
|
||||||
|
r1 = client.post(
|
||||||
|
"/api/parse-837",
|
||||||
|
files={"file": ("first.837", text, "text/plain")},
|
||||||
|
headers={"Accept": "application/json"},
|
||||||
|
)
|
||||||
|
assert r1.status_code == 200, r1.text
|
||||||
|
a_id = r1.json()["claims"][0]["claim_id"] # sanity: parse worked
|
||||||
|
|
||||||
|
r2 = client.post(
|
||||||
|
"/api/parse-837",
|
||||||
|
files={"file": ("second.837", text, "text/plain")},
|
||||||
|
headers={"Accept": "application/json"},
|
||||||
|
)
|
||||||
|
assert r2.status_code == 200, r2.text
|
||||||
|
|
||||||
|
# Resolve the actual batch ids via /api/batches.
|
||||||
|
batches = client.get("/api/batches").json()["items"]
|
||||||
|
assert len(batches) == 2
|
||||||
|
a_batch_id = batches[0]["id"]
|
||||||
|
b_batch_id = batches[1]["id"]
|
||||||
|
|
||||||
|
resp = client.get(
|
||||||
|
f"/api/batch-diff?a={a_batch_id}&b={b_batch_id}",
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200, resp.text
|
||||||
|
body = resp.json()
|
||||||
|
assert body["a"]["id"] == a_batch_id
|
||||||
|
assert body["b"]["id"] == b_batch_id
|
||||||
|
assert body["a"]["kind"] == "837p"
|
||||||
|
assert body["b"]["kind"] == "837p"
|
||||||
|
assert body["added"] == []
|
||||||
|
assert body["removed"] == []
|
||||||
|
assert body["changed"] == []
|
||||||
|
# Spec summary keys are all present and numeric.
|
||||||
|
summary = body["summary"]
|
||||||
|
assert summary["addedCount"] == 0
|
||||||
|
assert summary["removedCount"] == 0
|
||||||
|
assert summary["changedCount"] == 0
|
||||||
|
assert summary["unchangedCount"] == 2
|
||||||
|
|
||||||
|
# Reference the parsed claim id once so the fixture's first claim
|
||||||
|
# id is exercised end-to-end (the lint otherwise complains about
|
||||||
|
# the unused variable).
|
||||||
|
assert a_id
|
||||||
|
|
||||||
|
|
||||||
|
def test_endpoint_includes_added_removed_summary_keys(client: TestClient):
|
||||||
|
"""Diff summary keys are documented and always present.
|
||||||
|
|
||||||
|
Even when zero, the JSON contract should expose every key so the
|
||||||
|
UI can render the four cards unconditionally.
|
||||||
|
"""
|
||||||
|
text = FIXTURE_837.read_text()
|
||||||
|
r1 = client.post(
|
||||||
|
"/api/parse-837",
|
||||||
|
files={"file": ("first.837", text, "text/plain")},
|
||||||
|
headers={"Accept": "application/json"},
|
||||||
|
)
|
||||||
|
r2 = client.post(
|
||||||
|
"/api/parse-837",
|
||||||
|
files={"file": ("second.837", text, "text/plain")},
|
||||||
|
headers={"Accept": "application/json"},
|
||||||
|
)
|
||||||
|
assert r1.status_code == r2.status_code == 200
|
||||||
|
batches = client.get("/api/batches").json()["items"]
|
||||||
|
a_batch_id, b_batch_id = batches[0]["id"], batches[1]["id"]
|
||||||
|
body = client.get(
|
||||||
|
f"/api/batch-diff?a={a_batch_id}&b={b_batch_id}",
|
||||||
|
).json()
|
||||||
|
assert set(body["summary"].keys()) == {
|
||||||
|
"addedCount", "removedCount", "changedCount", "unchangedCount",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_store_helper_load_two_for_diff_raises_lookup():
|
||||||
|
"""``CycloneStore.load_two_for_diff`` raises ``LookupError`` on
|
||||||
|
missing ids so the API layer can map to 404 cleanly.
|
||||||
|
"""
|
||||||
|
from cyclone.store import CycloneStore
|
||||||
|
s = CycloneStore()
|
||||||
|
s.add(_make_record_837(
|
||||||
|
[_make_claim_837(claim_id="CLM-1")],
|
||||||
|
batch_id="present",
|
||||||
|
))
|
||||||
|
with pytest.raises(LookupError):
|
||||||
|
s.load_two_for_diff("present", "ghost")
|
||||||
|
with pytest.raises(LookupError):
|
||||||
|
s.load_two_for_diff("ghost", "present")
|
||||||
|
# Happy path
|
||||||
|
a, b = s.load_two_for_diff("present", "present")
|
||||||
|
assert a.id == "present"
|
||||||
|
assert b.id == "present"
|
||||||
|
|
||||||
|
|
||||||
|
# Silence the unused-import linter for items used purely as type hints
|
||||||
|
# or fixture anchors.
|
||||||
|
void_global_store = global_store
|
||||||
@@ -10,6 +10,7 @@ import { Upload } from "@/pages/Upload";
|
|||||||
import { ReconciliationPage } from "@/pages/Reconciliation";
|
import { ReconciliationPage } from "@/pages/Reconciliation";
|
||||||
import { Acks } from "@/pages/Acks";
|
import { Acks } from "@/pages/Acks";
|
||||||
import { Batches } from "@/pages/Batches";
|
import { Batches } from "@/pages/Batches";
|
||||||
|
import { BatchDiff } from "@/pages/BatchDiff";
|
||||||
|
|
||||||
function NotFound() {
|
function NotFound() {
|
||||||
return (
|
return (
|
||||||
@@ -34,6 +35,7 @@ export default function App() {
|
|||||||
<Route path="reconciliation" element={<ReconciliationPage />} />
|
<Route path="reconciliation" element={<ReconciliationPage />} />
|
||||||
<Route path="acks" element={<Acks />} />
|
<Route path="acks" element={<Acks />} />
|
||||||
<Route path="batches" element={<Batches />} />
|
<Route path="batches" element={<Batches />} />
|
||||||
|
<Route path="batch-diff" element={<BatchDiff />} />
|
||||||
<Route path="*" element={<NotFound />} />
|
<Route path="*" element={<NotFound />} />
|
||||||
</Route>
|
</Route>
|
||||||
</Routes>
|
</Routes>
|
||||||
|
|||||||
@@ -0,0 +1,547 @@
|
|||||||
|
import { Plus, Minus, Equal, ArrowRight, type LucideIcon } from "lucide-react";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { Skeleton } from "@/components/ui/skeleton";
|
||||||
|
import {
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableHead,
|
||||||
|
TableHeader,
|
||||||
|
TableRow,
|
||||||
|
} from "@/components/ui/table";
|
||||||
|
import { KpiCard } from "@/components/KpiCard";
|
||||||
|
import { fmt } from "@/lib/format";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import type {
|
||||||
|
BatchDiff,
|
||||||
|
BatchDiffChangedRow,
|
||||||
|
BatchDiffSideMeta,
|
||||||
|
BatchDiffSummary,
|
||||||
|
BatchClaimDiffSummary,
|
||||||
|
} from "@/types";
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Side meta header — small panel identifying which batch is on the left / right.
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function SideMeta({
|
||||||
|
side,
|
||||||
|
label,
|
||||||
|
}: {
|
||||||
|
side: BatchDiffSideMeta;
|
||||||
|
label: string;
|
||||||
|
}) {
|
||||||
|
// Color mirrors the BatchesList kind badge so the visual identity is
|
||||||
|
// consistent across the app: 837p in cool blue, 835 in warm amber.
|
||||||
|
const kindClass =
|
||||||
|
side.kind === "837p"
|
||||||
|
? "text-sky-300 border-sky-400/30 bg-sky-400/10"
|
||||||
|
: "text-amber-300 border-amber-400/30 bg-amber-400/10";
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="surface rounded-xl p-4 flex flex-col gap-2"
|
||||||
|
data-testid={`batch-diff-side-${label.toLowerCase()}`}
|
||||||
|
>
|
||||||
|
<div className="text-[10.5px] font-semibold uppercase tracking-[0.18em] text-muted-foreground">
|
||||||
|
{label}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
"inline-flex items-center gap-1 rounded-full border px-2 py-0.5 text-[10.5px] font-semibold uppercase tracking-[0.14em] font-mono",
|
||||||
|
kindClass,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{side.kind}
|
||||||
|
</span>
|
||||||
|
<span className="display num text-[13px]">{side.id}</span>
|
||||||
|
</div>
|
||||||
|
<div className="font-mono text-[12px] text-muted-foreground truncate">
|
||||||
|
{side.inputFilename}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between text-xs text-muted-foreground">
|
||||||
|
<span>Parsed {side.parsedAt ? fmt.dateShort(side.parsedAt) : "—"}</span>
|
||||||
|
<span className="font-mono num">
|
||||||
|
{fmt.num(side.claimCount)} claim{side.claimCount === 1 ? "" : "s"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Summary cards — the four-count tile row.
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function SummaryCards({ summary }: { summary: BatchDiffSummary }) {
|
||||||
|
return (
|
||||||
|
<div className="grid grid-cols-2 md:grid-cols-4 gap-3" data-testid="batch-diff-summary">
|
||||||
|
<KpiCard
|
||||||
|
label="Added in B"
|
||||||
|
value={fmt.num(summary.addedCount)}
|
||||||
|
icon={Plus}
|
||||||
|
hint="In B, not in A"
|
||||||
|
/>
|
||||||
|
<KpiCard
|
||||||
|
label="Removed from A"
|
||||||
|
value={fmt.num(summary.removedCount)}
|
||||||
|
icon={Minus}
|
||||||
|
hint="In A, not in B"
|
||||||
|
/>
|
||||||
|
<KpiCard
|
||||||
|
label="Changed"
|
||||||
|
value={fmt.num(summary.changedCount)}
|
||||||
|
icon={Equal}
|
||||||
|
hint="Deltas in either side"
|
||||||
|
/>
|
||||||
|
<KpiCard
|
||||||
|
label="Unchanged"
|
||||||
|
value={fmt.num(summary.unchangedCount)}
|
||||||
|
icon={Equal}
|
||||||
|
hint="Identical across A & B"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Row indicator — `+` / `-` / `~` gutter on the left of every row.
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function RowIndicator({
|
||||||
|
kind,
|
||||||
|
}: {
|
||||||
|
kind: "added" | "removed" | "changed";
|
||||||
|
}) {
|
||||||
|
const cfg = {
|
||||||
|
added: {
|
||||||
|
Icon: Plus,
|
||||||
|
cls: "text-[hsl(var(--success))] bg-[hsl(var(--success)/0.10)] border-[hsl(var(--success)/0.30)]",
|
||||||
|
label: "added",
|
||||||
|
},
|
||||||
|
removed: {
|
||||||
|
Icon: Minus,
|
||||||
|
cls: "text-destructive bg-destructive/10 border-destructive/30",
|
||||||
|
label: "removed",
|
||||||
|
},
|
||||||
|
changed: {
|
||||||
|
Icon: ArrowRight,
|
||||||
|
cls: "text-[hsl(var(--warning))] bg-[hsl(var(--warning)/0.10)] border-[hsl(var(--warning)/0.30)]",
|
||||||
|
label: "changed",
|
||||||
|
},
|
||||||
|
}[kind];
|
||||||
|
const Icon: LucideIcon = cfg.Icon;
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
aria-label={cfg.label}
|
||||||
|
data-testid={`diff-indicator-${kind}`}
|
||||||
|
className={cn(
|
||||||
|
"inline-flex h-5 w-5 items-center justify-center rounded border font-mono text-[11px]",
|
||||||
|
cfg.cls,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Icon className="h-3 w-3" strokeWidth={2.25} />
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Cell renderers for the summary columns. Kept tiny — these are
|
||||||
|
// hairline cells in a tabular diff, not full claim cards.
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function ClaimIdCell({ id }: { id: string }) {
|
||||||
|
return (
|
||||||
|
<span className="font-mono text-[12px] tracking-tight" data-testid="diff-claim-id">
|
||||||
|
{id}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function PatientCell({ name }: { name: string }) {
|
||||||
|
if (!name) {
|
||||||
|
return <span className="text-muted-foreground/60 text-[12px]">—</span>;
|
||||||
|
}
|
||||||
|
return <span className="text-[13px]">{name}</span>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function ChargeCell({ value }: { value: number }) {
|
||||||
|
return (
|
||||||
|
<span className="display num text-[13px] tabular-nums">
|
||||||
|
{fmt.usdPrecise(value)}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function DateCell({ value }: { value: string | null }) {
|
||||||
|
if (!value) {
|
||||||
|
return <span className="text-muted-foreground/60 text-[12px]">—</span>;
|
||||||
|
}
|
||||||
|
return <span className="num text-[12.5px]">{value}</span>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function StatusCell({ value }: { value: string }) {
|
||||||
|
if (!value) {
|
||||||
|
return <span className="text-muted-foreground/60 text-[12px]">—</span>;
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<Badge
|
||||||
|
variant="outline"
|
||||||
|
className="font-mono uppercase tracking-wider text-[10px]"
|
||||||
|
>
|
||||||
|
{value}
|
||||||
|
</Badge>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function CptCell({ codes }: { codes: string[] }) {
|
||||||
|
if (!codes || codes.length === 0) {
|
||||||
|
return <span className="text-muted-foreground/60 text-[12px]">—</span>;
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<span className="font-mono text-[11.5px] tracking-tight">
|
||||||
|
{codes.join(", ")}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Section tables — one per bucket (added / removed / changed).
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
type Side = "a" | "b";
|
||||||
|
|
||||||
|
function summaryCells(summary: BatchClaimDiffSummary, side: Side) {
|
||||||
|
// Single-row builder that keeps the column order consistent across
|
||||||
|
// all three buckets. ``side`` is just a key for the testid so
|
||||||
|
// tests can target a specific cell.
|
||||||
|
return {
|
||||||
|
claimId: <ClaimIdCell id={summary.claim_id} />,
|
||||||
|
patient: <PatientCell name={summary.patient_name} />,
|
||||||
|
charge: <ChargeCell value={summary.total_charge} />,
|
||||||
|
serviceDate: <DateCell value={summary.service_date} />,
|
||||||
|
status: <StatusCell value={summary.status} />,
|
||||||
|
cpt: <CptCell codes={summary.cpt_codes} />,
|
||||||
|
// Suppress an unused-param warning while leaving the key in the
|
||||||
|
// object for readability / future use.
|
||||||
|
_side: side,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function SummaryRow({
|
||||||
|
summary,
|
||||||
|
indicator,
|
||||||
|
testid,
|
||||||
|
}: {
|
||||||
|
summary: BatchClaimDiffSummary;
|
||||||
|
indicator: "added" | "removed";
|
||||||
|
testid: string;
|
||||||
|
}) {
|
||||||
|
const cells = summaryCells(summary, indicator === "added" ? "b" : "a");
|
||||||
|
return (
|
||||||
|
<TableRow data-testid={testid} className="animate-row-flash">
|
||||||
|
<TableCell className="w-10">
|
||||||
|
<RowIndicator kind={indicator} />
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>{cells.claimId}</TableCell>
|
||||||
|
<TableCell>{cells.patient}</TableCell>
|
||||||
|
<TableCell className="text-right">{cells.charge}</TableCell>
|
||||||
|
<TableCell>{cells.serviceDate}</TableCell>
|
||||||
|
<TableCell>{cells.status}</TableCell>
|
||||||
|
<TableCell>{cells.cpt}</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ChangedRowView({
|
||||||
|
row,
|
||||||
|
testid,
|
||||||
|
}: {
|
||||||
|
row: BatchDiffChangedRow;
|
||||||
|
testid: string;
|
||||||
|
}) {
|
||||||
|
const a = row.a;
|
||||||
|
const b = row.b;
|
||||||
|
const changedFields = new Set(row.diff.map((d) => d.field));
|
||||||
|
return (
|
||||||
|
<TableRow data-testid={testid} className="animate-row-flash">
|
||||||
|
<TableCell className="w-10">
|
||||||
|
<RowIndicator kind="changed" />
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<ClaimIdCell id={a.claim_id} />
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<div className="flex flex-col gap-0.5">
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
"text-[12.5px]",
|
||||||
|
a.patient_name !== b.patient_name
|
||||||
|
? "line-through text-muted-foreground/70"
|
||||||
|
: "",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{a.patient_name || "—"}
|
||||||
|
</span>
|
||||||
|
{a.patient_name !== b.patient_name ? (
|
||||||
|
<span className="text-[12.5px] text-[hsl(var(--warning))]">
|
||||||
|
{b.patient_name || "—"}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-right">
|
||||||
|
<div className="flex flex-col items-end gap-0.5">
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
"display num text-[13px] tabular-nums",
|
||||||
|
changedFields.has("total_charge")
|
||||||
|
? "line-through text-muted-foreground/70"
|
||||||
|
: "",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{fmt.usdPrecise(a.total_charge)}
|
||||||
|
</span>
|
||||||
|
{changedFields.has("total_charge") ? (
|
||||||
|
<span className="display num text-[13px] tabular-nums text-[hsl(var(--warning))]">
|
||||||
|
{fmt.usdPrecise(b.total_charge)}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<div className="flex flex-col gap-0.5">
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
"num text-[12.5px]",
|
||||||
|
changedFields.has("service_date")
|
||||||
|
? "line-through text-muted-foreground/70"
|
||||||
|
: "",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{a.service_date || "—"}
|
||||||
|
</span>
|
||||||
|
{changedFields.has("service_date") ? (
|
||||||
|
<span className="num text-[12.5px] text-[hsl(var(--warning))]">
|
||||||
|
{b.service_date || "—"}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<div className="flex flex-col gap-0.5">
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
changedFields.has("status")
|
||||||
|
? "line-through text-muted-foreground/70"
|
||||||
|
: "",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<StatusCell value={a.status} />
|
||||||
|
</span>
|
||||||
|
{changedFields.has("status") ? (
|
||||||
|
<StatusCell value={b.status} />
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<div className="flex flex-col gap-0.5">
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
"font-mono text-[11.5px] tracking-tight",
|
||||||
|
changedFields.has("cpt_codes")
|
||||||
|
? "line-through text-muted-foreground/70"
|
||||||
|
: "",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{a.cpt_codes.length === 0 ? "—" : a.cpt_codes.join(", ")}
|
||||||
|
</span>
|
||||||
|
{changedFields.has("cpt_codes") ? (
|
||||||
|
<span className="font-mono text-[11.5px] tracking-tight text-[hsl(var(--warning))]">
|
||||||
|
{b.cpt_codes.length === 0 ? "—" : b.cpt_codes.join(", ")}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SectionTable({
|
||||||
|
title,
|
||||||
|
eyebrow,
|
||||||
|
count,
|
||||||
|
children,
|
||||||
|
testid,
|
||||||
|
emptyMessage,
|
||||||
|
}: {
|
||||||
|
title: string;
|
||||||
|
eyebrow: string;
|
||||||
|
count: number;
|
||||||
|
children: React.ReactNode;
|
||||||
|
testid: string;
|
||||||
|
emptyMessage: string;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<section className="space-y-3" data-testid={testid}>
|
||||||
|
<header className="flex items-baseline justify-between">
|
||||||
|
<div>
|
||||||
|
<div className="text-[10.5px] font-semibold uppercase tracking-[0.18em] text-muted-foreground mb-0.5">
|
||||||
|
{eyebrow}
|
||||||
|
</div>
|
||||||
|
<h2 className="text-[15px] font-semibold tracking-tight">{title}</h2>
|
||||||
|
</div>
|
||||||
|
<span className="font-mono num text-[12.5px] text-muted-foreground">
|
||||||
|
{fmt.num(count)}
|
||||||
|
</span>
|
||||||
|
</header>
|
||||||
|
{count === 0 ? (
|
||||||
|
<div
|
||||||
|
className="surface rounded-xl border border-dashed border-border/60 p-6 text-center text-[12.5px] text-muted-foreground"
|
||||||
|
data-testid={`${testid}-empty`}
|
||||||
|
>
|
||||||
|
{emptyMessage}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="surface rounded-xl overflow-hidden">
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead className="w-10" aria-label="Diff indicator" />
|
||||||
|
<TableHead>Claim ID</TableHead>
|
||||||
|
<TableHead>Patient</TableHead>
|
||||||
|
<TableHead className="text-right">Charge</TableHead>
|
||||||
|
<TableHead>Service date</TableHead>
|
||||||
|
<TableHead>Status</TableHead>
|
||||||
|
<TableHead>CPT</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>{children}</TableBody>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Skeleton — used by the page while the diff is loading.
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export function BatchDiffViewSkeleton() {
|
||||||
|
return (
|
||||||
|
<div className="space-y-6" data-testid="batch-diff-skeleton">
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
|
||||||
|
<Skeleton variant="default" height={92} />
|
||||||
|
<Skeleton variant="default" height={92} />
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
|
||||||
|
{Array.from({ length: 4 }).map((_, i) => (
|
||||||
|
<Skeleton key={i} variant="default" height={92} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
{Array.from({ length: 3 }).map((_, i) => (
|
||||||
|
<Skeleton key={i} variant="default" height={220} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Empty state — both batches selected but no deltas.
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export function BatchDiffEmpty({ data }: { data: BatchDiff }) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="space-y-6"
|
||||||
|
data-testid="batch-diff-empty"
|
||||||
|
>
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
|
||||||
|
<SideMeta side={data.a} label="A (left)" />
|
||||||
|
<SideMeta side={data.b} label="B (right)" />
|
||||||
|
</div>
|
||||||
|
<SummaryCards summary={data.summary} />
|
||||||
|
<div className="surface rounded-xl border border-dashed border-border/60 p-10 text-center">
|
||||||
|
<div className="text-[10.5px] font-semibold uppercase tracking-[0.18em] text-muted-foreground mb-1.5">
|
||||||
|
Diff · no deltas
|
||||||
|
</div>
|
||||||
|
<div className="text-[13.5px] text-muted-foreground">
|
||||||
|
These two batches are identical — no claims added, removed, or
|
||||||
|
changed between A and B.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Main view — rendered once both batches are picked and the diff returns.
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export function BatchDiffView({ data }: { data: BatchDiff }) {
|
||||||
|
const { a, b, added, removed, changed, summary } = data;
|
||||||
|
const allEmpty =
|
||||||
|
summary.addedCount === 0 &&
|
||||||
|
summary.removedCount === 0 &&
|
||||||
|
summary.changedCount === 0;
|
||||||
|
if (allEmpty) {
|
||||||
|
return <BatchDiffEmpty data={data} />;
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<div className="space-y-6" data-testid="batch-diff-view">
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
|
||||||
|
<SideMeta side={a} label="A (left)" />
|
||||||
|
<SideMeta side={b} label="B (right)" />
|
||||||
|
</div>
|
||||||
|
<SummaryCards summary={summary} />
|
||||||
|
<SectionTable
|
||||||
|
title="Added in B"
|
||||||
|
eyebrow="Diff · additions"
|
||||||
|
count={added.length}
|
||||||
|
testid="batch-diff-added-section"
|
||||||
|
emptyMessage="No claims were added in B."
|
||||||
|
>
|
||||||
|
{added.map((row, idx) => (
|
||||||
|
<SummaryRow
|
||||||
|
key={`${row.claim_id}-${idx}`}
|
||||||
|
summary={row}
|
||||||
|
indicator="added"
|
||||||
|
testid={`diff-added-row-${row.claim_id}`}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</SectionTable>
|
||||||
|
<SectionTable
|
||||||
|
title="Removed from A"
|
||||||
|
eyebrow="Diff · removals"
|
||||||
|
count={removed.length}
|
||||||
|
testid="batch-diff-removed-section"
|
||||||
|
emptyMessage="No claims were removed from A."
|
||||||
|
>
|
||||||
|
{removed.map((row, idx) => (
|
||||||
|
<SummaryRow
|
||||||
|
key={`${row.claim_id}-${idx}`}
|
||||||
|
summary={row}
|
||||||
|
indicator="removed"
|
||||||
|
testid={`diff-removed-row-${row.claim_id}`}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</SectionTable>
|
||||||
|
<SectionTable
|
||||||
|
title="Changed"
|
||||||
|
eyebrow="Diff · mutations"
|
||||||
|
count={changed.length}
|
||||||
|
testid="batch-diff-changed-section"
|
||||||
|
emptyMessage="No claims were changed between A and B."
|
||||||
|
>
|
||||||
|
{changed.map((row, idx) => (
|
||||||
|
<ChangedRowView
|
||||||
|
key={`${row.a.claim_id}-${idx}`}
|
||||||
|
row={row}
|
||||||
|
testid={`diff-changed-row-${row.a.claim_id}`}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</SectionTable>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@ import { NavLink } from "react-router-dom";
|
|||||||
import {
|
import {
|
||||||
Activity,
|
Activity,
|
||||||
CheckCircle2,
|
CheckCircle2,
|
||||||
|
GitCompareArrows,
|
||||||
GitMerge,
|
GitMerge,
|
||||||
Layers,
|
Layers,
|
||||||
LayoutDashboard,
|
LayoutDashboard,
|
||||||
@@ -133,6 +134,22 @@ export function Sidebar() {
|
|||||||
<span>Batches</span>
|
<span>Batches</span>
|
||||||
</NavLink>
|
</NavLink>
|
||||||
</li>
|
</li>
|
||||||
|
<li>
|
||||||
|
<NavLink
|
||||||
|
to="/batch-diff"
|
||||||
|
className={({ isActive }) =>
|
||||||
|
cn(
|
||||||
|
"group relative flex items-center gap-3 rounded-md px-3 py-2 text-sm font-medium transition-colors",
|
||||||
|
isActive
|
||||||
|
? "nav-active"
|
||||||
|
: "text-muted-foreground hover:text-foreground hover:bg-muted/40"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<GitCompareArrows className="h-4 w-4" strokeWidth={1.5} />
|
||||||
|
<span>Batch diff</span>
|
||||||
|
</NavLink>
|
||||||
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,35 @@
|
|||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import { api, ApiError } from "@/lib/api";
|
||||||
|
import type { BatchDiff } from "@/types";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* React Query binding for the side-by-side batch diff.
|
||||||
|
*
|
||||||
|
* Query key: ``["batch-diff", a, b]`` so picking different batches
|
||||||
|
* automatically produces a separate cache entry. ``enabled`` short-
|
||||||
|
* circuits when either id is missing — the page disables the Run
|
||||||
|
* button until both pickers are filled in, but we double-check here
|
||||||
|
* so a half-picked URL can't fire an empty query.
|
||||||
|
*
|
||||||
|
* 404s from the backend are surfaced as ``ApiError(404)`` — the page
|
||||||
|
* branches on ``error.status === 404`` to render the "one of these
|
||||||
|
* batches was deleted out from under you" state instead of the
|
||||||
|
* generic error toast. Matches the same convention used by the
|
||||||
|
* claim detail drawer.
|
||||||
|
*/
|
||||||
|
export function useBatchDiff(a: string | null, b: string | null) {
|
||||||
|
return useQuery<BatchDiff, ApiError>({
|
||||||
|
queryKey: ["batch-diff", a, b],
|
||||||
|
queryFn: () => api.getBatchDiff(a as string, b as string),
|
||||||
|
enabled:
|
||||||
|
api.isConfigured &&
|
||||||
|
typeof a === "string" &&
|
||||||
|
a.length > 0 &&
|
||||||
|
typeof b === "string" &&
|
||||||
|
b.length > 0,
|
||||||
|
// Refetch on window focus would be nice in production; for the
|
||||||
|
// diff view it's pointless because the underlying batches don't
|
||||||
|
// change shape without a fresh ingest.
|
||||||
|
refetchOnWindowFocus: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -23,6 +23,7 @@
|
|||||||
|
|
||||||
import type {
|
import type {
|
||||||
Ack,
|
Ack,
|
||||||
|
BatchDiff,
|
||||||
ClaimDetail,
|
ClaimDetail,
|
||||||
ClaimOutput,
|
ClaimOutput,
|
||||||
ClaimPayment,
|
ClaimPayment,
|
||||||
@@ -508,6 +509,30 @@ async function getRemittance<T = unknown>(id: string): Promise<T> {
|
|||||||
return (await res.json()) as T;
|
return (await res.json()) as T;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch the side-by-side diff between two batches.
|
||||||
|
*
|
||||||
|
* Drives ``GET /api/batch-diff?a=<batch_id>&b=<batch_id>``. Both ids
|
||||||
|
* are required; the endpoint returns 400 when either is missing and
|
||||||
|
* 404 when the id is unknown — both surface here as ``ApiError`` so
|
||||||
|
* the page can branch on ``.status`` like every other diff / fetch
|
||||||
|
* surface in the app.
|
||||||
|
*/
|
||||||
|
async function getBatchDiff(a: string, b: string): Promise<BatchDiff> {
|
||||||
|
if (!isConfigured) throw notConfiguredError();
|
||||||
|
const res = await fetch(
|
||||||
|
joinUrl(
|
||||||
|
`/api/batch-diff?${qs({ a, b })}`,
|
||||||
|
),
|
||||||
|
{ headers: { Accept: "application/json" } },
|
||||||
|
);
|
||||||
|
if (!res.ok) {
|
||||||
|
const detail = await readErrorBody(res);
|
||||||
|
throw new ApiError(res.status, detail || res.statusText);
|
||||||
|
}
|
||||||
|
return (await res.json()) as BatchDiff;
|
||||||
|
}
|
||||||
|
|
||||||
async function listProviders<T = unknown>(
|
async function listProviders<T = unknown>(
|
||||||
params: ListProvidersParams = {}
|
params: ListProvidersParams = {}
|
||||||
): Promise<PaginatedResponse<T>> {
|
): Promise<PaginatedResponse<T>> {
|
||||||
@@ -672,6 +697,7 @@ export const api = {
|
|||||||
parse999,
|
parse999,
|
||||||
listBatches,
|
listBatches,
|
||||||
getBatch,
|
getBatch,
|
||||||
|
getBatchDiff,
|
||||||
listClaims,
|
listClaims,
|
||||||
getClaimDetail,
|
getClaimDetail,
|
||||||
listRemittances,
|
listRemittances,
|
||||||
|
|||||||
@@ -0,0 +1,444 @@
|
|||||||
|
// @vitest-environment happy-dom
|
||||||
|
// Tell React this is an `act`-aware test environment so react-query's
|
||||||
|
// internal state updates flush through without noisy console warnings.
|
||||||
|
// Mirrors the convention used by Batches.test.tsx and Acks.test.tsx.
|
||||||
|
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT =
|
||||||
|
true;
|
||||||
|
|
||||||
|
import React, { act } from "react";
|
||||||
|
import { createRoot, type Root } from "react-dom/client";
|
||||||
|
import { MemoryRouter } from "react-router-dom";
|
||||||
|
import {
|
||||||
|
describe,
|
||||||
|
expect,
|
||||||
|
it,
|
||||||
|
vi,
|
||||||
|
beforeEach,
|
||||||
|
afterEach,
|
||||||
|
} from "vitest";
|
||||||
|
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||||
|
import { BatchDiff } from "./BatchDiff";
|
||||||
|
import { api, ApiError } from "@/lib/api";
|
||||||
|
import type { BatchDiff as BatchDiffResponse } from "@/types";
|
||||||
|
|
||||||
|
// Mock the api module. Only the surface the page actually touches is
|
||||||
|
// stubbed; everything else is left undefined so an accidental call
|
||||||
|
// blows up loudly.
|
||||||
|
vi.mock("@/lib/api", () => ({
|
||||||
|
api: {
|
||||||
|
isConfigured: true,
|
||||||
|
listBatches: vi.fn(),
|
||||||
|
getBatchDiff: vi.fn(),
|
||||||
|
},
|
||||||
|
ApiError: class ApiError extends Error {
|
||||||
|
constructor(public status: number, message: string) {
|
||||||
|
super(message);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mount the page into a container wrapped in MemoryRouter +
|
||||||
|
* QueryClientProvider. Uses a 10ms retryDelay so non-404 errors don't
|
||||||
|
* blow past the test timeout — same pattern used by Batches.test.tsx.
|
||||||
|
*/
|
||||||
|
function renderIntoContainer(
|
||||||
|
element: React.ReactElement,
|
||||||
|
initialEntries: string[] = ["/batch-diff"],
|
||||||
|
): { container: HTMLDivElement; unmount: () => void } {
|
||||||
|
const container = document.createElement("div");
|
||||||
|
document.body.appendChild(container);
|
||||||
|
const qc = new QueryClient({
|
||||||
|
defaultOptions: {
|
||||||
|
queries: {
|
||||||
|
retry: false,
|
||||||
|
retryDelay: 10,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const root: Root = createRoot(container);
|
||||||
|
act(() => {
|
||||||
|
root.render(
|
||||||
|
<MemoryRouter initialEntries={initialEntries}>
|
||||||
|
<QueryClientProvider client={qc}>{element}</QueryClientProvider>
|
||||||
|
</MemoryRouter>,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
container,
|
||||||
|
unmount: () => {
|
||||||
|
act(() => root.unmount());
|
||||||
|
container.remove();
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Flush microtasks + react state updates until the predicate holds or
|
||||||
|
* the deadline expires. react-query's fetch resolves asynchronously
|
||||||
|
* so we have to await a tick before the rendered DOM reflects the
|
||||||
|
* mocked data.
|
||||||
|
*/
|
||||||
|
async function waitFor(
|
||||||
|
predicate: () => boolean,
|
||||||
|
description: string,
|
||||||
|
timeoutMs = 2000,
|
||||||
|
): Promise<void> {
|
||||||
|
const start = Date.now();
|
||||||
|
while (!predicate()) {
|
||||||
|
if (Date.now() - start > timeoutMs) {
|
||||||
|
throw new Error(`waitFor: ${description} did not become true within ${timeoutMs}ms`);
|
||||||
|
}
|
||||||
|
await act(async () => {
|
||||||
|
await Promise.resolve();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fixtures used by the data-driven tests.
|
||||||
|
const BATCH_A = {
|
||||||
|
id: "B-A",
|
||||||
|
kind: "837p" as const,
|
||||||
|
inputFilename: "original.837",
|
||||||
|
parsedAt: "2026-06-15T12:00:00Z",
|
||||||
|
claimCount: 2,
|
||||||
|
};
|
||||||
|
const BATCH_B = {
|
||||||
|
id: "B-B",
|
||||||
|
kind: "837p" as const,
|
||||||
|
inputFilename: "corrected.837",
|
||||||
|
parsedAt: "2026-06-16T12:00:00Z",
|
||||||
|
claimCount: 3,
|
||||||
|
};
|
||||||
|
|
||||||
|
function makeDiffPayload(): BatchDiffResponse {
|
||||||
|
return {
|
||||||
|
a: {
|
||||||
|
id: BATCH_A.id,
|
||||||
|
kind: "837p",
|
||||||
|
parsedAt: BATCH_A.parsedAt,
|
||||||
|
inputFilename: BATCH_A.inputFilename,
|
||||||
|
claimCount: 2,
|
||||||
|
},
|
||||||
|
b: {
|
||||||
|
id: BATCH_B.id,
|
||||||
|
kind: "837p",
|
||||||
|
parsedAt: BATCH_B.parsedAt,
|
||||||
|
inputFilename: BATCH_B.inputFilename,
|
||||||
|
claimCount: 3,
|
||||||
|
},
|
||||||
|
added: [
|
||||||
|
{
|
||||||
|
claim_id: "CLM-3",
|
||||||
|
patient_name: "Sam Roe",
|
||||||
|
total_charge: 200,
|
||||||
|
service_date: "2026-06-02",
|
||||||
|
status: "submitted",
|
||||||
|
cpt_codes: ["99213"],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
removed: [
|
||||||
|
{
|
||||||
|
claim_id: "CLM-2",
|
||||||
|
patient_name: "Jane Doe",
|
||||||
|
total_charge: 75,
|
||||||
|
service_date: "2026-06-01",
|
||||||
|
status: "submitted",
|
||||||
|
cpt_codes: ["99213"],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
changed: [
|
||||||
|
{
|
||||||
|
a: {
|
||||||
|
claim_id: "CLM-1",
|
||||||
|
patient_name: "Pat Lee",
|
||||||
|
total_charge: 100,
|
||||||
|
service_date: "2026-06-01",
|
||||||
|
status: "submitted",
|
||||||
|
cpt_codes: ["99213"],
|
||||||
|
},
|
||||||
|
b: {
|
||||||
|
claim_id: "CLM-1",
|
||||||
|
patient_name: "Pat Lee",
|
||||||
|
total_charge: 150,
|
||||||
|
service_date: "2026-06-01",
|
||||||
|
status: "submitted",
|
||||||
|
cpt_codes: ["99213"],
|
||||||
|
},
|
||||||
|
diff: [{ field: "total_charge", a: 100, b: 150 }],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
summary: {
|
||||||
|
addedCount: 1,
|
||||||
|
removedCount: 1,
|
||||||
|
changedCount: 1,
|
||||||
|
unchangedCount: 1,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("BatchDiff page", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
// happy-dom persists window.location between tests; reset so the
|
||||||
|
// URL-state hook starts from a clean slate.
|
||||||
|
(window as unknown as { happyDOM: { setURL: (u: string) => void } })
|
||||||
|
.happyDOM.setURL("http://localhost/batch-diff");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders the awaiting-picks empty state when no batches are selected", async () => {
|
||||||
|
(api.listBatches as unknown as ReturnType<typeof vi.fn>).mockResolvedValue([
|
||||||
|
BATCH_A, BATCH_B,
|
||||||
|
]);
|
||||||
|
|
||||||
|
const { unmount } = renderIntoContainer(<BatchDiff />);
|
||||||
|
await waitFor(
|
||||||
|
() => !!document.querySelector('[data-testid="batch-diff-page"]'),
|
||||||
|
"page header mounted",
|
||||||
|
);
|
||||||
|
// The copy unique to the empty state — verifier the page rendered
|
||||||
|
// the awaiting-picks branch and not the loading skeleton.
|
||||||
|
expect(document.body.textContent).toContain("awaiting picks");
|
||||||
|
expect(document.querySelector('[data-testid="diff-picker-a"]')).not.toBeNull();
|
||||||
|
expect(document.querySelector('[data-testid="diff-picker-b"]')).not.toBeNull();
|
||||||
|
// Diff not requested yet — getBatchDiff should NOT have been
|
||||||
|
// called because the hook gates on both picks being non-null.
|
||||||
|
expect(api.getBatchDiff).not.toHaveBeenCalled();
|
||||||
|
unmount();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders the loading skeleton while the diff is in flight", async () => {
|
||||||
|
(api.listBatches as unknown as ReturnType<typeof vi.fn>).mockResolvedValue([
|
||||||
|
BATCH_A, BATCH_B,
|
||||||
|
]);
|
||||||
|
// Never resolves — keeps the query in the loading state.
|
||||||
|
(api.getBatchDiff as unknown as ReturnType<typeof vi.fn>).mockReturnValue(
|
||||||
|
new Promise(() => {}),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Start the page with both picks already in the URL.
|
||||||
|
const { unmount } = renderIntoContainer(
|
||||||
|
<BatchDiff />,
|
||||||
|
[`/batch-diff?a=${BATCH_A.id}&b=${BATCH_B.id}`],
|
||||||
|
);
|
||||||
|
await waitFor(
|
||||||
|
() => !!document.querySelector('[data-testid="batch-diff-skeleton"]'),
|
||||||
|
"loading skeleton visible",
|
||||||
|
);
|
||||||
|
unmount();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders the three-section diff view when the backend returns deltas", async () => {
|
||||||
|
(api.listBatches as unknown as ReturnType<typeof vi.fn>).mockResolvedValue([
|
||||||
|
BATCH_A, BATCH_B,
|
||||||
|
]);
|
||||||
|
(api.getBatchDiff as unknown as ReturnType<typeof vi.fn>).mockResolvedValue(
|
||||||
|
makeDiffPayload(),
|
||||||
|
);
|
||||||
|
|
||||||
|
const { unmount } = renderIntoContainer(
|
||||||
|
<BatchDiff />,
|
||||||
|
[`/batch-diff?a=${BATCH_A.id}&b=${BATCH_B.id}`],
|
||||||
|
);
|
||||||
|
|
||||||
|
// Wait for the diff view (full, non-empty payload).
|
||||||
|
await waitFor(
|
||||||
|
() => !!document.querySelector('[data-testid="batch-diff-view"]'),
|
||||||
|
"diff view rendered",
|
||||||
|
);
|
||||||
|
|
||||||
|
// Sections are present.
|
||||||
|
expect(
|
||||||
|
document.querySelector('[data-testid="batch-diff-added-section"]'),
|
||||||
|
).not.toBeNull();
|
||||||
|
expect(
|
||||||
|
document.querySelector('[data-testid="batch-diff-removed-section"]'),
|
||||||
|
).not.toBeNull();
|
||||||
|
expect(
|
||||||
|
document.querySelector('[data-testid="batch-diff-changed-section"]'),
|
||||||
|
).not.toBeNull();
|
||||||
|
|
||||||
|
// Per-claim rows render with the right indicator + id.
|
||||||
|
expect(
|
||||||
|
document.querySelector('[data-testid="diff-added-row-CLM-3"]'),
|
||||||
|
).not.toBeNull();
|
||||||
|
expect(
|
||||||
|
document.querySelector('[data-testid="diff-removed-row-CLM-2"]'),
|
||||||
|
).not.toBeNull();
|
||||||
|
expect(
|
||||||
|
document.querySelector('[data-testid="diff-changed-row-CLM-1"]'),
|
||||||
|
).not.toBeNull();
|
||||||
|
|
||||||
|
// Indicator chips are present for each bucket.
|
||||||
|
expect(
|
||||||
|
document.querySelector('[data-testid="diff-indicator-added"]'),
|
||||||
|
).not.toBeNull();
|
||||||
|
expect(
|
||||||
|
document.querySelector('[data-testid="diff-indicator-removed"]'),
|
||||||
|
).not.toBeNull();
|
||||||
|
expect(
|
||||||
|
document.querySelector('[data-testid="diff-indicator-changed"]'),
|
||||||
|
).not.toBeNull();
|
||||||
|
|
||||||
|
unmount();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders the no-deltas state when the backend reports an empty diff", async () => {
|
||||||
|
(api.listBatches as unknown as ReturnType<typeof vi.fn>).mockResolvedValue([
|
||||||
|
BATCH_A, BATCH_B,
|
||||||
|
]);
|
||||||
|
const empty = makeDiffPayload();
|
||||||
|
empty.added = [];
|
||||||
|
empty.removed = [];
|
||||||
|
empty.changed = [];
|
||||||
|
empty.summary = {
|
||||||
|
addedCount: 0,
|
||||||
|
removedCount: 0,
|
||||||
|
changedCount: 0,
|
||||||
|
unchangedCount: 2,
|
||||||
|
};
|
||||||
|
(api.getBatchDiff as unknown as ReturnType<typeof vi.fn>).mockResolvedValue(
|
||||||
|
empty,
|
||||||
|
);
|
||||||
|
|
||||||
|
const { unmount } = renderIntoContainer(
|
||||||
|
<BatchDiff />,
|
||||||
|
[`/batch-diff?a=${BATCH_A.id}&b=${BATCH_B.id}`],
|
||||||
|
);
|
||||||
|
|
||||||
|
await waitFor(
|
||||||
|
() => !!document.querySelector('[data-testid="batch-diff-empty"]'),
|
||||||
|
"empty-diff state visible",
|
||||||
|
);
|
||||||
|
// Summary cards still surface the counts even when zero.
|
||||||
|
expect(
|
||||||
|
document.querySelector('[data-testid="batch-diff-summary"]'),
|
||||||
|
).not.toBeNull();
|
||||||
|
unmount();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders the not-found state on a 404 from getBatchDiff", async () => {
|
||||||
|
(api.listBatches as unknown as ReturnType<typeof vi.fn>).mockResolvedValue([
|
||||||
|
BATCH_A, BATCH_B,
|
||||||
|
]);
|
||||||
|
// Mirror the backend's 404 contract: ApiError(404).
|
||||||
|
(api.getBatchDiff as unknown as ReturnType<typeof vi.fn>).mockRejectedValue(
|
||||||
|
new ApiError(404, "Not found — batch B-A not found"),
|
||||||
|
);
|
||||||
|
|
||||||
|
const { unmount } = renderIntoContainer(
|
||||||
|
<BatchDiff />,
|
||||||
|
[`/batch-diff?a=${BATCH_A.id}&b=${BATCH_B.id}`],
|
||||||
|
);
|
||||||
|
|
||||||
|
// The not-found state uses the ErrorState primitive with a retry
|
||||||
|
// affordance; assert by message text since we don't ship a
|
||||||
|
// dedicated testid for the variant.
|
||||||
|
await waitFor(
|
||||||
|
() => (document.body.textContent ?? "").includes("not found"),
|
||||||
|
"not-found error text visible",
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
(document.body.textContent ?? "").toLowerCase(),
|
||||||
|
).toContain("one of these batches was not found");
|
||||||
|
unmount();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders the network error state on a non-404 failure", async () => {
|
||||||
|
(api.listBatches as unknown as ReturnType<typeof vi.fn>).mockResolvedValue([
|
||||||
|
BATCH_A, BATCH_B,
|
||||||
|
]);
|
||||||
|
(api.getBatchDiff as unknown as ReturnType<typeof vi.fn>).mockRejectedValue(
|
||||||
|
new Error("500 Internal Server Error — upstream"),
|
||||||
|
);
|
||||||
|
|
||||||
|
const { unmount } = renderIntoContainer(
|
||||||
|
<BatchDiff />,
|
||||||
|
[`/batch-diff?a=${BATCH_A.id}&b=${BATCH_B.id}`],
|
||||||
|
);
|
||||||
|
|
||||||
|
await waitFor(
|
||||||
|
() =>
|
||||||
|
(document.body.textContent ?? "").includes(
|
||||||
|
"Couldn't compute the diff",
|
||||||
|
),
|
||||||
|
"network error text visible",
|
||||||
|
);
|
||||||
|
// Retry button is rendered (the ErrorState primitive's affordance).
|
||||||
|
expect(
|
||||||
|
(document.body.textContent ?? "").toLowerCase(),
|
||||||
|
).toContain("retry");
|
||||||
|
unmount();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("passes the URL a/b picks through to getBatchDiff", async () => {
|
||||||
|
(api.listBatches as unknown as ReturnType<typeof vi.fn>).mockResolvedValue([
|
||||||
|
BATCH_A, BATCH_B,
|
||||||
|
]);
|
||||||
|
(api.getBatchDiff as unknown as ReturnType<typeof vi.fn>).mockResolvedValue(
|
||||||
|
makeDiffPayload(),
|
||||||
|
);
|
||||||
|
|
||||||
|
const { unmount } = renderIntoContainer(
|
||||||
|
<BatchDiff />,
|
||||||
|
[`/batch-diff?a=${BATCH_A.id}&b=${BATCH_B.id}`],
|
||||||
|
);
|
||||||
|
|
||||||
|
await waitFor(
|
||||||
|
() => (api.getBatchDiff as unknown as ReturnType<typeof vi.fn>).mock.calls.length > 0,
|
||||||
|
"getBatchDiff invoked",
|
||||||
|
);
|
||||||
|
|
||||||
|
// The two picks are passed through in order: a, b.
|
||||||
|
expect(api.getBatchDiff).toHaveBeenCalledWith(BATCH_A.id, BATCH_B.id);
|
||||||
|
unmount();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("Clear picks button resets both pickers and returns to awaiting-picks state", async () => {
|
||||||
|
(api.listBatches as unknown as ReturnType<typeof vi.fn>).mockResolvedValue([
|
||||||
|
BATCH_A, BATCH_B,
|
||||||
|
]);
|
||||||
|
(api.getBatchDiff as unknown as ReturnType<typeof vi.fn>).mockResolvedValue(
|
||||||
|
makeDiffPayload(),
|
||||||
|
);
|
||||||
|
|
||||||
|
const { unmount } = renderIntoContainer(
|
||||||
|
<BatchDiff />,
|
||||||
|
[`/batch-diff?a=${BATCH_A.id}&b=${BATCH_B.id}`],
|
||||||
|
);
|
||||||
|
|
||||||
|
await waitFor(
|
||||||
|
() => !!document.querySelector('[data-testid="diff-clear-button"]'),
|
||||||
|
"clear button visible",
|
||||||
|
);
|
||||||
|
|
||||||
|
const clear = document.querySelector(
|
||||||
|
'[data-testid="diff-clear-button"]',
|
||||||
|
) as HTMLButtonElement | null;
|
||||||
|
await act(async () => {
|
||||||
|
clear?.click();
|
||||||
|
await Promise.resolve();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Back to the awaiting-picks empty state. The hook's
|
||||||
|
// `enabled: a && b` predicate stops getBatchDiff from being
|
||||||
|
// re-invoked once both ids are null.
|
||||||
|
await waitFor(
|
||||||
|
() => (document.body.textContent ?? "").includes("awaiting picks"),
|
||||||
|
"empty state re-appears",
|
||||||
|
);
|
||||||
|
// The picker for B should now be empty (the trigger renders the
|
||||||
|
// placeholder text).
|
||||||
|
expect(
|
||||||
|
(document.body.textContent ?? "").toLowerCase(),
|
||||||
|
).toContain("pick a batch");
|
||||||
|
unmount();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Keep `ApiError` referenced so the import isn't tree-shaken by
|
||||||
|
// vitest's transformer when the mock factory above is hoisted.
|
||||||
|
void ApiError;
|
||||||
@@ -0,0 +1,320 @@
|
|||||||
|
import { useCallback, useMemo } from "react";
|
||||||
|
import { useSearchParams } from "react-router-dom";
|
||||||
|
import { GitCompareArrows, CircleDashed, AlertCircle } from "lucide-react";
|
||||||
|
import { ApiError } from "@/lib/api";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "@/components/ui/select";
|
||||||
|
import { EmptyState } from "@/components/ui/empty-state";
|
||||||
|
import { ErrorState } from "@/components/ui/error-state";
|
||||||
|
import { BatchDiffView, BatchDiffViewSkeleton } from "@/components/BatchDiffView";
|
||||||
|
import { useBatches } from "@/hooks/useBatches";
|
||||||
|
import { useBatchDiff } from "@/hooks/useBatchDiff";
|
||||||
|
import type { BatchSummary as ApiBatchSummary } from "@/lib/api";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Inline URL state — uses react-router's useSearchParams so the page
|
||||||
|
// participates in the standard routing lifecycle (MemoryRouter in
|
||||||
|
// tests, BrowserRouter in production). `replace: true` keeps the
|
||||||
|
// picker tweaks from polluting the back-button stack.
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function readIdsFromParams(params: URLSearchParams): {
|
||||||
|
a: string | null;
|
||||||
|
b: string | null;
|
||||||
|
} {
|
||||||
|
const a = params.get("a");
|
||||||
|
const b = params.get("b");
|
||||||
|
return {
|
||||||
|
a: a && a.length > 0 ? a : null,
|
||||||
|
b: b && b.length > 0 ? b : null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Batch picker — one Select dropdown for each side. Keeps the same
|
||||||
|
// kind-colored badge inline so the operator can spot which batch is
|
||||||
|
// which at a glance.
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function KindBadge({ kind }: { kind: ApiBatchSummary["kind"] }) {
|
||||||
|
const cls =
|
||||||
|
kind === "837p"
|
||||||
|
? "text-sky-300 border-sky-400/30 bg-sky-400/10"
|
||||||
|
: "text-amber-300 border-amber-400/30 bg-amber-400/10";
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
data-testid={`diff-picker-kind-${kind}`}
|
||||||
|
className={cn(
|
||||||
|
"inline-flex items-center gap-1 rounded-full border px-1.5 py-px text-[9.5px] font-semibold uppercase tracking-[0.14em] font-mono",
|
||||||
|
cls,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{kind}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function BatchPicker({
|
||||||
|
label,
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
items,
|
||||||
|
excludeId,
|
||||||
|
testid,
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
value: string | null;
|
||||||
|
onChange: (id: string) => void;
|
||||||
|
items: ApiBatchSummary[];
|
||||||
|
excludeId: string | null;
|
||||||
|
testid: string;
|
||||||
|
}) {
|
||||||
|
// Filter out the value already chosen on the *other* side so the
|
||||||
|
// operator can't accidentally pick the same batch twice (which
|
||||||
|
// produces a meaningless diff). Disabled rather than hidden so the
|
||||||
|
// selection stays transparent.
|
||||||
|
const visible = useMemo(
|
||||||
|
() => items.filter((it) => it.id !== excludeId),
|
||||||
|
[items, excludeId],
|
||||||
|
);
|
||||||
|
const selected = items.find((it) => it.id === value);
|
||||||
|
return (
|
||||||
|
<div className="space-y-1.5" data-testid={testid}>
|
||||||
|
<div className="text-[10.5px] font-semibold uppercase tracking-[0.18em] text-muted-foreground">
|
||||||
|
{label}
|
||||||
|
</div>
|
||||||
|
<Select value={value ?? ""} onValueChange={(v) => onChange(v)}>
|
||||||
|
<SelectTrigger>
|
||||||
|
{selected ? (
|
||||||
|
<span className="flex items-center gap-2 truncate">
|
||||||
|
<KindBadge kind={selected.kind} />
|
||||||
|
<span className="font-mono num text-[12.5px]">{selected.id}</span>
|
||||||
|
<span className="text-muted-foreground text-[12px] truncate">
|
||||||
|
· {selected.inputFilename}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<SelectValue placeholder="Pick a batch…" />
|
||||||
|
)}
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{visible.length === 0 ? (
|
||||||
|
<div className="px-3 py-2 text-xs text-muted-foreground">
|
||||||
|
No other batches available.
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
visible.map((b) => (
|
||||||
|
<SelectItem
|
||||||
|
key={b.id}
|
||||||
|
value={b.id}
|
||||||
|
data-testid={`diff-picker-option-${b.id}`}
|
||||||
|
>
|
||||||
|
<span className="flex items-center gap-2">
|
||||||
|
<KindBadge kind={b.kind} />
|
||||||
|
<span className="font-mono num text-[12px]">{b.id}</span>
|
||||||
|
<span className="text-muted-foreground text-[12px] truncate">
|
||||||
|
· {b.inputFilename}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</SelectItem>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Not-found branch — surfaces when the backend reports 404 for one of
|
||||||
|
// the picks. Most likely cause: a bookmark referencing a now-deleted
|
||||||
|
// batch, or another agent deleting between the picker load and the
|
||||||
|
// diff fetch.
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function NotFoundState({
|
||||||
|
a,
|
||||||
|
b,
|
||||||
|
onReset,
|
||||||
|
}: {
|
||||||
|
a: string;
|
||||||
|
b: string;
|
||||||
|
onReset: () => void;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<ErrorState
|
||||||
|
message="One of these batches was not found."
|
||||||
|
detail={`The backend couldn't find batch ${a} or ${b}. It may have been deleted between picker load and diff fetch.`}
|
||||||
|
onRetry={onReset}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Page
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export function BatchDiff() {
|
||||||
|
// URL → state via react-router. Keeps MemoryRouter (tests) and
|
||||||
|
// BrowserRouter (production) symmetric: both update the
|
||||||
|
// useSearchParams hook on every navigation.
|
||||||
|
const [searchParams, setSearchParams] = useSearchParams();
|
||||||
|
const { a, b } = useMemo(
|
||||||
|
() => readIdsFromParams(searchParams),
|
||||||
|
[searchParams],
|
||||||
|
);
|
||||||
|
|
||||||
|
const updateIds = useCallback(
|
||||||
|
(next: { a: string | null; b: string | null }) => {
|
||||||
|
const params = new URLSearchParams(searchParams);
|
||||||
|
if (next.a === null) params.delete("a");
|
||||||
|
else params.set("a", next.a);
|
||||||
|
if (next.b === null) params.delete("b");
|
||||||
|
else params.set("b", next.b);
|
||||||
|
setSearchParams(params, { replace: true });
|
||||||
|
},
|
||||||
|
[searchParams, setSearchParams],
|
||||||
|
);
|
||||||
|
|
||||||
|
const setA = useCallback(
|
||||||
|
(id: string) => updateIds({ a: id, b }),
|
||||||
|
[b, updateIds],
|
||||||
|
);
|
||||||
|
const setB = useCallback(
|
||||||
|
(id: string) => updateIds({ a, b: id }),
|
||||||
|
[a, updateIds],
|
||||||
|
);
|
||||||
|
const reset = useCallback(() => updateIds({ a: null, b: null }), [updateIds]);
|
||||||
|
|
||||||
|
const { data: batches, isLoading: loadingBatches, isError: batchesError, error: batchesErrorMsg, refetch: refetchBatches } =
|
||||||
|
useBatches(100);
|
||||||
|
|
||||||
|
// Only fire the diff once both pickers have a value. The hook also
|
||||||
|
// gates on this internally (defense-in-depth), so a half-picked URL
|
||||||
|
// never hits the backend.
|
||||||
|
const diff = useBatchDiff(a, b);
|
||||||
|
const ready = a !== null && b !== null;
|
||||||
|
|
||||||
|
// --- error / status derivation ------------------------------------
|
||||||
|
const errKind: "network" | "not_found" | null = diff.error
|
||||||
|
? diff.error instanceof ApiError && diff.error.status === 404
|
||||||
|
? "not_found"
|
||||||
|
: "network"
|
||||||
|
: null;
|
||||||
|
|
||||||
|
// --- render -------------------------------------------------------
|
||||||
|
return (
|
||||||
|
<div className="space-y-6 md:space-y-8 animate-fade-in" data-testid="batch-diff-page">
|
||||||
|
<header>
|
||||||
|
<div className="text-[10.5px] font-semibold uppercase tracking-[0.18em] text-muted-foreground mb-2 flex items-center gap-2">
|
||||||
|
<span className="inline-block h-px w-6 bg-border" />
|
||||||
|
Diff
|
||||||
|
</div>
|
||||||
|
<h1 className="text-[22px] sm:text-[26px] font-semibold tracking-tight">
|
||||||
|
Batch diff
|
||||||
|
</h1>
|
||||||
|
<p className="text-muted-foreground mt-1.5 text-[14px]">
|
||||||
|
Pick two parsed batches — typically a submitted 837P and its
|
||||||
|
corrected follow-up — and see what was added, removed, or changed
|
||||||
|
between them.
|
||||||
|
</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div className="surface rounded-xl p-4 grid grid-cols-1 md:grid-cols-2 gap-3 md:gap-4">
|
||||||
|
<BatchPicker
|
||||||
|
label="A · left"
|
||||||
|
value={a}
|
||||||
|
onChange={setA}
|
||||||
|
items={batches ?? []}
|
||||||
|
excludeId={b}
|
||||||
|
testid="diff-picker-a"
|
||||||
|
/>
|
||||||
|
<BatchPicker
|
||||||
|
label="B · right"
|
||||||
|
value={b}
|
||||||
|
onChange={setB}
|
||||||
|
items={batches ?? []}
|
||||||
|
excludeId={a}
|
||||||
|
testid="diff-picker-b"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{batchesError ? (
|
||||||
|
<ErrorState
|
||||||
|
message="Couldn't load batches from the backend."
|
||||||
|
detail={
|
||||||
|
batchesErrorMsg instanceof Error
|
||||||
|
? batchesErrorMsg.message
|
||||||
|
: String(batchesErrorMsg)
|
||||||
|
}
|
||||||
|
onRetry={() => refetchBatches()}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{!ready ? (
|
||||||
|
<div className="surface rounded-xl">
|
||||||
|
<EmptyState
|
||||||
|
icon={<GitCompareArrows className="h-4 w-4" strokeWidth={1.5} />}
|
||||||
|
eyebrow="Batch diff · awaiting picks"
|
||||||
|
message="Choose one batch for A and one for B to compute the diff."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : loadingBatches ? (
|
||||||
|
<BatchDiffViewSkeleton />
|
||||||
|
) : errKind === "not_found" ? (
|
||||||
|
<NotFoundState a={a as string} b={b as string} onReset={reset} />
|
||||||
|
) : diff.isError ? (
|
||||||
|
<ErrorState
|
||||||
|
message="Couldn't compute the diff between these two batches."
|
||||||
|
detail={
|
||||||
|
diff.error instanceof Error
|
||||||
|
? diff.error.message
|
||||||
|
: String(diff.error)
|
||||||
|
}
|
||||||
|
onRetry={() => diff.refetch()}
|
||||||
|
/>
|
||||||
|
) : diff.isLoading || !diff.data ? (
|
||||||
|
<BatchDiffViewSkeleton />
|
||||||
|
) : (
|
||||||
|
<BatchDiffView data={diff.data} />
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Action footer — always visible so the operator can clear or
|
||||||
|
force-refresh without scrolling back to the picker. */}
|
||||||
|
{ready ? (
|
||||||
|
<div className="flex items-center justify-end gap-2 pt-2">
|
||||||
|
{diff.isFetching ? (
|
||||||
|
<span className="text-[12px] text-muted-foreground inline-flex items-center gap-1.5">
|
||||||
|
<CircleDashed className="h-3 w-3 animate-spin" strokeWidth={1.75} />
|
||||||
|
Refreshing…
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => diff.refetch()}
|
||||||
|
data-testid="diff-refresh-button"
|
||||||
|
>
|
||||||
|
Refresh
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={reset}
|
||||||
|
data-testid="diff-clear-button"
|
||||||
|
>
|
||||||
|
<AlertCircle className="h-3.5 w-3.5 mr-1" strokeWidth={1.75} />
|
||||||
|
Clear picks
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -560,3 +560,79 @@ export interface ClaimDetail {
|
|||||||
matchedRemittance: ClaimDetailMatchedRemittance | null;
|
matchedRemittance: ClaimDetailMatchedRemittance | null;
|
||||||
stateHistory: ClaimDetailStateHistoryEvent[];
|
stateHistory: ClaimDetailStateHistoryEvent[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Side-by-side batch diff (SP3 P4 / T18)
|
||||||
|
// Mirrors `GET /api/batch-diff?a=<batch_id>&b=<batch_id>` (see
|
||||||
|
// `backend/src/cyclone/batch_diff.py` and `backend/src/cyclone/api.py`).
|
||||||
|
// The contract is deliberately small: no raw segments, no service-line
|
||||||
|
// details — just the summary fields the operator needs to spot the
|
||||||
|
// deltas between two parsed files at a glance.
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export type BatchKind = "837p" | "835";
|
||||||
|
|
||||||
|
export interface BatchDiffSideMeta {
|
||||||
|
id: string;
|
||||||
|
kind: BatchKind;
|
||||||
|
parsedAt: string; // ISO Z
|
||||||
|
inputFilename: string;
|
||||||
|
claimCount: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One claim (or remittance) row projected for the diff view. Carries
|
||||||
|
* just enough context to render side-by-side without the EDI parse
|
||||||
|
* tree. ``status`` carries the validation-derived label for 837P
|
||||||
|
* (``submitted`` / ``pending`` / ``draft`` / ``denied``) and the CLP02
|
||||||
|
* code/label for 835.
|
||||||
|
*/
|
||||||
|
export interface BatchClaimDiffSummary {
|
||||||
|
claim_id: string;
|
||||||
|
patient_name: string;
|
||||||
|
total_charge: number;
|
||||||
|
service_date: string | null;
|
||||||
|
status: string;
|
||||||
|
cpt_codes: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One changed field between the two sides of a claim. ``field``
|
||||||
|
* identifies which column changed (``status`` / ``total_charge`` /
|
||||||
|
* ``service_date`` / ``cpt_codes``); ``a`` and ``b`` are the values
|
||||||
|
* from each side, rendered verbatim.
|
||||||
|
*/
|
||||||
|
export interface BatchDiffFieldDiff {
|
||||||
|
field: "status" | "total_charge" | "service_date" | "cpt_codes";
|
||||||
|
a: string | number | string[] | null;
|
||||||
|
b: string | number | string[] | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BatchDiffChangedRow {
|
||||||
|
a: BatchClaimDiffSummary;
|
||||||
|
b: BatchClaimDiffSummary;
|
||||||
|
diff: BatchDiffFieldDiff[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BatchDiffSummary {
|
||||||
|
addedCount: number;
|
||||||
|
removedCount: number;
|
||||||
|
changedCount: number;
|
||||||
|
unchangedCount: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Full response from `GET /api/batch-diff`. The three bucket arrays
|
||||||
|
* (``added`` / ``removed`` / ``changed``) are always present (never
|
||||||
|
* absent) so the UI can index unconditionally; ``summary`` is
|
||||||
|
* precomputed by the backend so the four count cards don't have to
|
||||||
|
* derive them on the client.
|
||||||
|
*/
|
||||||
|
export interface BatchDiff {
|
||||||
|
a: BatchDiffSideMeta;
|
||||||
|
b: BatchDiffSideMeta;
|
||||||
|
added: BatchClaimDiffSummary[];
|
||||||
|
removed: BatchClaimDiffSummary[];
|
||||||
|
changed: BatchDiffChangedRow[];
|
||||||
|
summary: BatchDiffSummary;
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user