merge: ui/batch-diff

This commit is contained in:
Tyler
2026-06-20 17:30:30 -06:00
12 changed files with 2556 additions and 0 deletions
+52
View File
@@ -894,6 +894,58 @@ def get_reconciliation_unmatched() -> dict:
return store.list_unmatched(kind="both")
# --------------------------------------------------------------------------- #
# Side-by-side diff between two batches (SP3 P4 / T18)
# --------------------------------------------------------------------------- #
@app.get("/api/batch-diff")
def get_batch_diff(
a: str | None = Query(None),
b: str | None = Query(None),
) -> dict:
"""Return a side-by-side diff of two batches identified by id.
Query params: ``a=<batch_id>``, ``b=<batch_id>`` (both required).
Response body (snake_case keys, see :mod:`cyclone.batch_diff` for the
projector shapes):
- ``a`` / ``b`` — small metadata blocks (id, kind, parsedAt,
inputFilename, claimCount)
- ``added`` — claims present in B but not A
- ``removed`` — claims present in A but not B
- ``changed`` — claims present in both, with field deltas
- ``summary`` — precomputed counts
Errors:
- 400 — missing ``a`` or ``b``
- 404 — either batch id is unknown
Pure read endpoint — never mutates the store. Both 837P and 835
batches are accepted (mixed-kind diffs are valid: comparing the
submitted claims against the matching remittances).
"""
if not a or not b:
raise HTTPException(
status_code=400,
detail={"error": "Missing param", "detail": "Both ?a=<batch_id> and ?b=<batch_id> are required."},
)
try:
a_rec, b_rec = store.load_two_for_diff(a, b)
except LookupError as exc:
raise HTTPException(
status_code=404,
detail={"error": "Not found", "detail": str(exc)},
)
# Lazy import — keeps the module's import surface small until the
# endpoint is actually hit. Mirrors the same pattern used by other
# endpoint-local helpers (e.g. reconciler).
from cyclone.batch_diff import diff_batches_to_wire
return diff_batches_to_wire(a_rec, b_rec)
@app.post("/api/reconciliation/match")
def post_reconciliation_match(body: dict) -> dict:
"""Manually pair a Claim with a Remittance (operator override).
+401
View File
@@ -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
+25
View File
@@ -1240,6 +1240,31 @@ class CycloneStore:
rows = s.query(Batch).order_by(Batch.parsed_at.asc()).all()
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(
self,
*,
+611
View File
@@ -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
+2
View File
@@ -10,6 +10,7 @@ import { Upload } from "@/pages/Upload";
import { ReconciliationPage } from "@/pages/Reconciliation";
import { Acks } from "@/pages/Acks";
import { Batches } from "@/pages/Batches";
import { BatchDiff } from "@/pages/BatchDiff";
function NotFound() {
return (
@@ -34,6 +35,7 @@ export default function App() {
<Route path="reconciliation" element={<ReconciliationPage />} />
<Route path="acks" element={<Acks />} />
<Route path="batches" element={<Batches />} />
<Route path="batch-diff" element={<BatchDiff />} />
<Route path="*" element={<NotFound />} />
</Route>
</Routes>
+547
View File
@@ -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>
);
}
+17
View File
@@ -2,6 +2,7 @@ import { NavLink } from "react-router-dom";
import {
Activity,
CheckCircle2,
GitCompareArrows,
GitMerge,
Layers,
LayoutDashboard,
@@ -133,6 +134,22 @@ export function Sidebar() {
<span>Batches</span>
</NavLink>
</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>
</nav>
+35
View File
@@ -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,
});
}
+26
View File
@@ -23,6 +23,7 @@
import type {
Ack,
BatchDiff,
ClaimDetail,
ClaimOutput,
ClaimPayment,
@@ -508,6 +509,30 @@ async function getRemittance<T = unknown>(id: string): Promise<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>(
params: ListProvidersParams = {}
): Promise<PaginatedResponse<T>> {
@@ -672,6 +697,7 @@ export const api = {
parse999,
listBatches,
getBatch,
getBatchDiff,
listClaims,
getClaimDetail,
listRemittances,
+444
View File
@@ -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;
+320
View File
@@ -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>
);
}
+76
View File
@@ -560,3 +560,79 @@ export interface ClaimDetail {
matchedRemittance: ClaimDetailMatchedRemittance | null;
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;
}