Compare commits
14 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 22720168c4 | |||
| 1c0d855b8e | |||
| 8db5db7610 | |||
| 786ead8c94 | |||
| 6c773c1159 | |||
| 5c7e9b6168 | |||
| ac87ed4908 | |||
| 4a8ce1a524 | |||
| 5053a1ea8e | |||
| 33fa899217 | |||
| 6fdbceefc2 | |||
| 7290cac643 | |||
| 5e8c7b11ea | |||
| 9c0cec8f0c |
@@ -7,4 +7,3 @@ __pycache__/
|
|||||||
venv/
|
venv/
|
||||||
build/
|
build/
|
||||||
dist/
|
dist/
|
||||||
backend/.venv
|
|
||||||
|
|||||||
@@ -1093,12 +1093,8 @@ def inbox_match_candidate(remit_id: str, body: dict):
|
|||||||
if not claim_id:
|
if not claim_id:
|
||||||
raise HTTPException(400, "claim_id required")
|
raise HTTPException(400, "claim_id required")
|
||||||
with db.SessionLocal()() as s:
|
with db.SessionLocal()() as s:
|
||||||
# Migration 0014: composite PKs. The URL takes only id (not
|
claim = s.get(Claim, claim_id)
|
||||||
# batch_id); look up by id alone, first match wins. Cross-batch
|
remit = s.get(Remittance, remit_id)
|
||||||
# duplicates are handled at ingest time by parse-decide-workflow;
|
|
||||||
# this manual-match endpoint inherits the legacy "id alone" API.
|
|
||||||
claim = s.query(Claim).filter(Claim.id == claim_id).first()
|
|
||||||
remit = s.query(Remittance).filter(Remittance.id == remit_id).first()
|
|
||||||
if claim is None or remit is None:
|
if claim is None or remit is None:
|
||||||
raise HTTPException(404, "claim or remit not found")
|
raise HTTPException(404, "claim or remit not found")
|
||||||
if claim.matched_remittance_id and claim.matched_remittance_id != remit_id:
|
if claim.matched_remittance_id and claim.matched_remittance_id != remit_id:
|
||||||
@@ -1114,11 +1110,6 @@ def inbox_match_candidate(remit_id: str, body: dict):
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
claim.matched_remittance_id = remit_id
|
claim.matched_remittance_id = remit_id
|
||||||
# Migration 0014: composite back-reference to remittances.
|
|
||||||
# matched_remittance_batch_id is the batch side; the SQL-level FK
|
|
||||||
# to remittances(batch_id, id) can't be declared in SQLite (no
|
|
||||||
# ALTER CONSTRAINT) so the application keeps both columns in sync.
|
|
||||||
claim.matched_remittance_batch_id = remit.batch_id
|
|
||||||
remit.claim_id = claim_id
|
remit.claim_id = claim_id
|
||||||
s.commit()
|
s.commit()
|
||||||
return {"ok": True, "claim_id": claim_id, "remit_id": remit_id}
|
return {"ok": True, "claim_id": claim_id, "remit_id": remit_id}
|
||||||
@@ -1168,8 +1159,7 @@ def inbox_acknowledge_payer_rejected(body: dict):
|
|||||||
not_found = 0
|
not_found = 0
|
||||||
not_rejected = 0
|
not_rejected = 0
|
||||||
for cid in claim_ids:
|
for cid in claim_ids:
|
||||||
# Migration 0014: composite PK; URL takes only claim_id.
|
claim = session.get(Claim, cid)
|
||||||
claim = session.query(Claim).filter(Claim.id == cid).first()
|
|
||||||
if claim is None:
|
if claim is None:
|
||||||
not_found += 1
|
not_found += 1
|
||||||
continue
|
continue
|
||||||
@@ -1239,8 +1229,7 @@ def inbox_resubmit_rejected(
|
|||||||
accepted_with_rows: list[tuple[str, "Claim"]] = []
|
accepted_with_rows: list[tuple[str, "Claim"]] = []
|
||||||
with db.SessionLocal()() as s:
|
with db.SessionLocal()() as s:
|
||||||
for cid in ids:
|
for cid in ids:
|
||||||
# Migration 0014: composite PK; URL takes only claim_id.
|
c = s.get(Claim, cid)
|
||||||
c = s.query(Claim).filter(Claim.id == cid).first()
|
|
||||||
if c is None:
|
if c is None:
|
||||||
continue
|
continue
|
||||||
if c.state != ClaimState.REJECTED:
|
if c.state != ClaimState.REJECTED:
|
||||||
@@ -1532,8 +1521,7 @@ def serialize_claim_as_837(claim_id: str):
|
|||||||
issue, not a transient failure).
|
issue, not a transient failure).
|
||||||
"""
|
"""
|
||||||
with db.SessionLocal()() as s:
|
with db.SessionLocal()() as s:
|
||||||
# Migration 0014: composite PK; URL takes only claim_id.
|
row = s.get(Claim, claim_id)
|
||||||
row = s.query(Claim).filter(Claim.id == claim_id).first()
|
|
||||||
if row is None:
|
if row is None:
|
||||||
return JSONResponse(
|
return JSONResponse(
|
||||||
{"error": "Not found", "detail": f"Claim {claim_id} not found"},
|
{"error": "Not found", "detail": f"Claim {claim_id} not found"},
|
||||||
@@ -1596,8 +1584,7 @@ def get_claim_line_reconciliation(claim_id: str) -> dict:
|
|||||||
from decimal import Decimal
|
from decimal import Decimal
|
||||||
|
|
||||||
with db.SessionLocal()() as s:
|
with db.SessionLocal()() as s:
|
||||||
# Migration 0014: composite PK; URL takes only claim_id.
|
claim = s.get(db.Claim, claim_id)
|
||||||
claim = s.query(db.Claim).filter(db.Claim.id == claim_id).first()
|
|
||||||
if claim is None:
|
if claim is None:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=404,
|
status_code=404,
|
||||||
@@ -2376,8 +2363,7 @@ def _serialize_claim_for_submit(claim_id: str) -> str:
|
|||||||
from cyclone.parsers.serialize_837 import serialize_837
|
from cyclone.parsers.serialize_837 import serialize_837
|
||||||
from cyclone import db
|
from cyclone import db
|
||||||
with db.SessionLocal()() as s:
|
with db.SessionLocal()() as s:
|
||||||
# Migration 0014: composite PK; URL takes only claim_id.
|
row = s.get(db.Claim, claim_id)
|
||||||
row = s.query(db.Claim).filter(db.Claim.id == claim_id).first()
|
|
||||||
if row is None:
|
if row is None:
|
||||||
raise ValueError(f"claim {claim_id!r} not found")
|
raise ValueError(f"claim {claim_id!r} not found")
|
||||||
# Re-parse the stored raw_json to get a ClaimOutput
|
# Re-parse the stored raw_json to get a ClaimOutput
|
||||||
|
|||||||
+22
-215
@@ -34,7 +34,6 @@ from sqlalchemy import (
|
|||||||
)
|
)
|
||||||
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, relationship, sessionmaker
|
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, relationship, sessionmaker
|
||||||
from sqlalchemy.types import TypeDecorator
|
from sqlalchemy.types import TypeDecorator
|
||||||
from sqlalchemy.schema import PrimaryKeyConstraint
|
|
||||||
|
|
||||||
|
|
||||||
DEFAULT_DB_PATH = Path.home() / ".local" / "share" / "cyclone" / "cyclone.db"
|
DEFAULT_DB_PATH = Path.home() / ".local" / "share" / "cyclone" / "cyclone.db"
|
||||||
@@ -240,15 +239,9 @@ class Batch(Base):
|
|||||||
class Claim(Base):
|
class Claim(Base):
|
||||||
__tablename__ = "claims"
|
__tablename__ = "claims"
|
||||||
|
|
||||||
# Migration 0014: composite PRIMARY KEY (batch_id, id). Enables
|
id: Mapped[str] = mapped_column(String(64), primary_key=True)
|
||||||
# resubmits (same CLM01 in different batches) and makes the
|
|
||||||
# pre-flight dedup workflow exercisable. The composite PK is declared
|
|
||||||
# explicitly in __table_args__ below so the column order matches the
|
|
||||||
# migration (`PRIMARY KEY (batch_id, id)`).
|
|
||||||
id: Mapped[str] = mapped_column(String(64))
|
|
||||||
batch_id: Mapped[str] = mapped_column(
|
batch_id: Mapped[str] = mapped_column(
|
||||||
String(32),
|
String(32), ForeignKey("batches.id", ondelete="CASCADE"), nullable=False
|
||||||
ForeignKey("batches.id", ondelete="CASCADE"),
|
|
||||||
)
|
)
|
||||||
patient_control_number: Mapped[str] = mapped_column(String(64), nullable=False)
|
patient_control_number: Mapped[str] = mapped_column(String(64), nullable=False)
|
||||||
service_date_from: Mapped[Optional[date]] = mapped_column(Date, nullable=True)
|
service_date_from: Mapped[Optional[date]] = mapped_column(Date, nullable=True)
|
||||||
@@ -297,38 +290,29 @@ class Claim(Base):
|
|||||||
resubmit_count: Mapped[int] = mapped_column(
|
resubmit_count: Mapped[int] = mapped_column(
|
||||||
Integer, nullable=False, default=0, server_default=text("0")
|
Integer, nullable=False, default=0, server_default=text("0")
|
||||||
)
|
)
|
||||||
# Back-reference to remittances. The composite pointer is split into
|
|
||||||
# matched_remittance_id + matched_remittance_batch_id. Migration 0014
|
|
||||||
# drops the SQL-level FK (composite FK to remittances(batch_id, id)
|
|
||||||
# cannot be declared as inline REFERENCES in SQLite; no ALTER CONSTRAINT).
|
|
||||||
# App-layer invariants in store.manual_match / manual_unmatch / dedup
|
|
||||||
# keep these consistent. matched_remittance_id points at
|
|
||||||
# remittances.id (a non-PK column under composite PK) — SQLAlchemy
|
|
||||||
# accepts this because FKs reference any column, not just PKs.
|
|
||||||
matched_remittance_id: Mapped[Optional[str]] = mapped_column(
|
matched_remittance_id: Mapped[Optional[str]] = mapped_column(
|
||||||
|
# ORM policy: SET NULL on remittance delete. The claim may outlive its
|
||||||
|
# remittance during reversal/reimport flows; without this, deleting a
|
||||||
|
# remittance with matched claims raises IntegrityError.
|
||||||
|
# Note: 0001_initial.sql predates this policy (no ON DELETE clause).
|
||||||
|
# SQLite ignores FK direction by default unless PRAGMA foreign_keys=ON,
|
||||||
|
# so the inconsistency is benign there; the ORM is the source of truth
|
||||||
|
# for PostgreSQL/test environments with FK enforcement. Amendment to the
|
||||||
|
# migration is deferred to post-SQLite rollout.
|
||||||
String(64),
|
String(64),
|
||||||
ForeignKey("remittances.id", ondelete="SET NULL"),
|
ForeignKey("remittances.id", ondelete="SET NULL"),
|
||||||
nullable=True,
|
nullable=True,
|
||||||
)
|
)
|
||||||
matched_remittance_batch_id: Mapped[Optional[str]] = mapped_column(
|
|
||||||
String(32),
|
|
||||||
nullable=True,
|
|
||||||
)
|
|
||||||
raw_json: Mapped[Optional[dict]] = mapped_column(JSONText, nullable=True)
|
raw_json: Mapped[Optional[dict]] = mapped_column(JSONText, nullable=True)
|
||||||
|
|
||||||
batch: Mapped["Batch"] = relationship(back_populates="claims")
|
batch: Mapped["Batch"] = relationship(back_populates="claims")
|
||||||
|
|
||||||
__table_args__ = (
|
__table_args__ = (
|
||||||
# Migration 0014: explicit composite PK column order (batch_id, id).
|
|
||||||
# SQLAlchemy's default composite-PK assembly is by declaration order,
|
|
||||||
# but `id` is declared first for readability; we override here so
|
|
||||||
# ``s.get(Claim, (batch_id, id))`` looks up by the right key order.
|
|
||||||
PrimaryKeyConstraint("batch_id", "id", name="pk_claims"),
|
|
||||||
# NOTE: no (batch_id, patient_control_number) unique constraint.
|
# NOTE: no (batch_id, patient_control_number) unique constraint.
|
||||||
# X12 837P allows any number of CLM segments inside one 2000B
|
# X12 837P allows any number of CLM segments inside one 2000B
|
||||||
# subscriber loop, so a single member routinely has multiple
|
# subscriber loop, so a single member routinely has multiple
|
||||||
# claims in a single submission batch. Claim identity is provided
|
# claims in a single submission batch. Claim identity is provided
|
||||||
# by the composite primary key (batch_id, id).
|
# by ``id`` (= CLM01 claim_id), which is the primary key.
|
||||||
Index("ix_claims_state", "state"),
|
Index("ix_claims_state", "state"),
|
||||||
Index("ix_claims_patient_control_number", "patient_control_number"),
|
Index("ix_claims_patient_control_number", "patient_control_number"),
|
||||||
Index("ix_claims_service_date_from", "service_date_from"),
|
Index("ix_claims_service_date_from", "service_date_from"),
|
||||||
@@ -338,19 +322,17 @@ class Claim(Base):
|
|||||||
class Remittance(Base):
|
class Remittance(Base):
|
||||||
__tablename__ = "remittances"
|
__tablename__ = "remittances"
|
||||||
|
|
||||||
# Migration 0014: composite PRIMARY KEY (batch_id, id). Same rationale
|
id: Mapped[str] = mapped_column(String(64), primary_key=True)
|
||||||
# as Claim: enables resubmits (same CLP01 in different batches). The
|
|
||||||
# composite PK is declared explicitly in __table_args__ below so the
|
|
||||||
# column order matches the migration.
|
|
||||||
id: Mapped[str] = mapped_column(String(64))
|
|
||||||
batch_id: Mapped[str] = mapped_column(
|
batch_id: Mapped[str] = mapped_column(
|
||||||
String(32),
|
String(32), ForeignKey("batches.id", ondelete="CASCADE"), nullable=False
|
||||||
ForeignKey("batches.id", ondelete="CASCADE"),
|
|
||||||
)
|
)
|
||||||
payer_claim_control_number: Mapped[str] = mapped_column(String(64), nullable=False)
|
payer_claim_control_number: Mapped[str] = mapped_column(String(64), nullable=False)
|
||||||
# Forward FK from Remittance → Claim. As with Claim.matched_remittance_id,
|
# ORM policy: no ON DELETE (forward FK from Remittance → Claim). A claim
|
||||||
# migration 0014 drops the SQL-level FK (cannot declare composite FK
|
# is unlikely to be deleted in normal flow (audit trail); if it ever is,
|
||||||
# inline in SQLite). App-layer invariants keep these consistent.
|
# the application layer (T7 reconciliation) must clear this FK. The
|
||||||
|
# migration predates this rationale; amendment deferred to post-SQLite
|
||||||
|
# rollout. SQLite without `PRAGMA foreign_keys=ON` does not enforce,
|
||||||
|
# so the divergence is benign in this engine.
|
||||||
claim_id: Mapped[Optional[str]] = mapped_column(
|
claim_id: Mapped[Optional[str]] = mapped_column(
|
||||||
String(64), ForeignKey("claims.id"), nullable=True
|
String(64), ForeignKey("claims.id"), nullable=True
|
||||||
)
|
)
|
||||||
@@ -370,24 +352,14 @@ class Remittance(Base):
|
|||||||
|
|
||||||
batch: Mapped["Batch"] = relationship(back_populates="remittances")
|
batch: Mapped["Batch"] = relationship(back_populates="remittances")
|
||||||
cas_adjustments: Mapped[list["CasAdjustment"]] = relationship(
|
cas_adjustments: Mapped[list["CasAdjustment"]] = relationship(
|
||||||
back_populates="remittance",
|
back_populates="remittance", cascade="all, delete-orphan"
|
||||||
cascade="all, delete-orphan",
|
|
||||||
# Migration 0014: CasAdjustment has TWO FKs to remittances
|
|
||||||
# (remittance_id and remittance_batch_id). Tell SQLAlchemy to use
|
|
||||||
# remittance_id for the relationship (the batch_id side is just a
|
|
||||||
# denormalized copy used for the composite FK constraint).
|
|
||||||
foreign_keys="CasAdjustment.remittance_id",
|
|
||||||
)
|
)
|
||||||
|
|
||||||
__table_args__ = (
|
__table_args__ = (
|
||||||
# Migration 0014: explicit composite PK column order (batch_id, id).
|
|
||||||
# See Claim.__table_args__ for the rationale — same as for Claim.
|
|
||||||
PrimaryKeyConstraint("batch_id", "id", name="pk_remittances"),
|
|
||||||
# NOTE: no (batch_id, payer_claim_control_number) unique constraint.
|
# NOTE: no (batch_id, payer_claim_control_number) unique constraint.
|
||||||
# An 835 ERA can contain multiple CLP segments that share a
|
# An 835 ERA can contain multiple CLP segments that share a
|
||||||
# payer_claim_control_number (e.g. reversals re-referencing the
|
# payer_claim_control_number (e.g. reversals re-referencing the
|
||||||
# original PCN). Remittance identity is provided by the composite
|
# original PCN). Remittance identity is provided by ``id``.
|
||||||
# PK (batch_id, id).
|
|
||||||
Index("ix_remittances_claim_id", "claim_id"),
|
Index("ix_remittances_claim_id", "claim_id"),
|
||||||
Index("ix_remittances_payer_claim_control_number", "payer_claim_control_number"),
|
Index("ix_remittances_payer_claim_control_number", "payer_claim_control_number"),
|
||||||
Index("ix_remittances_status_code", "status_code"),
|
Index("ix_remittances_status_code", "status_code"),
|
||||||
@@ -401,15 +373,6 @@ class CasAdjustment(Base):
|
|||||||
remittance_id: Mapped[str] = mapped_column(
|
remittance_id: Mapped[str] = mapped_column(
|
||||||
String(64), ForeignKey("remittances.id", ondelete="CASCADE"), nullable=False
|
String(64), ForeignKey("remittances.id", ondelete="CASCADE"), nullable=False
|
||||||
)
|
)
|
||||||
# Migration 0014: remittances.id is no longer a single-column PK; we add
|
|
||||||
# the batch side of the composite FK to make the constraint declarative
|
|
||||||
# again. The SQL migration declares it as a real composite FK; here we
|
|
||||||
# use a plain column reference because the application always queries
|
|
||||||
# via remittance_id alone (the batch_id is denormalized for FK
|
|
||||||
# enforcement only — same value for every row with a given remittance_id).
|
|
||||||
remittance_batch_id: Mapped[str] = mapped_column(
|
|
||||||
String(32), ForeignKey("remittances.batch_id"), nullable=False
|
|
||||||
)
|
|
||||||
group_code: Mapped[str] = mapped_column(String(4), nullable=False)
|
group_code: Mapped[str] = mapped_column(String(4), nullable=False)
|
||||||
reason_code: Mapped[str] = mapped_column(String(8), nullable=False)
|
reason_code: Mapped[str] = mapped_column(String(8), nullable=False)
|
||||||
amount: Mapped[Decimal] = mapped_column(Numeric(12, 2), nullable=False)
|
amount: Mapped[Decimal] = mapped_column(Numeric(12, 2), nullable=False)
|
||||||
@@ -420,12 +383,7 @@ class CasAdjustment(Base):
|
|||||||
nullable=True,
|
nullable=True,
|
||||||
)
|
)
|
||||||
|
|
||||||
remittance: Mapped["Remittance"] = relationship(
|
remittance: Mapped["Remittance"] = relationship(back_populates="cas_adjustments")
|
||||||
back_populates="cas_adjustments",
|
|
||||||
# See Remittance.cas_adjustments for the rationale: disambiguate
|
|
||||||
# between the two FKs from CasAdjustment to remittances.
|
|
||||||
foreign_keys="CasAdjustment.remittance_id",
|
|
||||||
)
|
|
||||||
|
|
||||||
__table_args__ = (
|
__table_args__ = (
|
||||||
Index("ix_cas_adjustments_remittance_id", "remittance_id"),
|
Index("ix_cas_adjustments_remittance_id", "remittance_id"),
|
||||||
@@ -447,10 +405,6 @@ class ServiceLinePayment(Base):
|
|||||||
remittance_id: Mapped[str] = mapped_column(
|
remittance_id: Mapped[str] = mapped_column(
|
||||||
String(64), ForeignKey("remittances.id", ondelete="CASCADE"), nullable=False
|
String(64), ForeignKey("remittances.id", ondelete="CASCADE"), nullable=False
|
||||||
)
|
)
|
||||||
# Migration 0014: see CasAdjustment.remittance_batch_id for the same rationale.
|
|
||||||
remittance_batch_id: Mapped[str] = mapped_column(
|
|
||||||
String(32), ForeignKey("remittances.batch_id"), nullable=False
|
|
||||||
)
|
|
||||||
line_number: Mapped[int] = mapped_column(Integer, nullable=False)
|
line_number: Mapped[int] = mapped_column(Integer, nullable=False)
|
||||||
procedure_qualifier: Mapped[str] = mapped_column(String(4), nullable=False)
|
procedure_qualifier: Mapped[str] = mapped_column(String(4), nullable=False)
|
||||||
procedure_code: Mapped[str] = mapped_column(String(16), nullable=False)
|
procedure_code: Mapped[str] = mapped_column(String(16), nullable=False)
|
||||||
@@ -499,10 +453,6 @@ class LineReconciliation(Base):
|
|||||||
claim_id: Mapped[str] = mapped_column(
|
claim_id: Mapped[str] = mapped_column(
|
||||||
String(64), ForeignKey("claims.id", ondelete="CASCADE"), nullable=False
|
String(64), ForeignKey("claims.id", ondelete="CASCADE"), nullable=False
|
||||||
)
|
)
|
||||||
# Migration 0014: see CasAdjustment.remittance_batch_id for the same rationale.
|
|
||||||
batch_id: Mapped[str] = mapped_column(
|
|
||||||
String(32), ForeignKey("claims.batch_id"), nullable=False
|
|
||||||
)
|
|
||||||
claim_service_line_number: Mapped[Optional[int]] = mapped_column(
|
claim_service_line_number: Mapped[Optional[int]] = mapped_column(
|
||||||
Integer, nullable=True
|
Integer, nullable=True
|
||||||
)
|
)
|
||||||
@@ -541,17 +491,9 @@ class Match(Base):
|
|||||||
String(64), ForeignKey("claims.id", ondelete="CASCADE"),
|
String(64), ForeignKey("claims.id", ondelete="CASCADE"),
|
||||||
nullable=False,
|
nullable=False,
|
||||||
)
|
)
|
||||||
# Migration 0014: batch side of the composite FK to claims.
|
|
||||||
batch_id: Mapped[str] = mapped_column(
|
|
||||||
String(32), ForeignKey("claims.batch_id"), nullable=False
|
|
||||||
)
|
|
||||||
remittance_id: Mapped[str] = mapped_column(
|
remittance_id: Mapped[str] = mapped_column(
|
||||||
String(64), ForeignKey("remittances.id", ondelete="CASCADE"), nullable=False
|
String(64), ForeignKey("remittances.id", ondelete="CASCADE"), nullable=False
|
||||||
)
|
)
|
||||||
# Migration 0014: batch side of the composite FK to remittances.
|
|
||||||
remittance_batch_id: Mapped[str] = mapped_column(
|
|
||||||
String(32), ForeignKey("remittances.batch_id"), nullable=False
|
|
||||||
)
|
|
||||||
strategy: Mapped[str] = mapped_column(String(16), nullable=False)
|
strategy: Mapped[str] = mapped_column(String(16), nullable=False)
|
||||||
matched_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
|
matched_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
|
||||||
prior_claim_state: Mapped[Optional[ClaimState]] = mapped_column(
|
prior_claim_state: Mapped[Optional[ClaimState]] = mapped_column(
|
||||||
@@ -894,138 +836,3 @@ class ClearhouseORM(Base):
|
|||||||
filename_block_json: Mapped[dict] = mapped_column(JSONText, nullable=False)
|
filename_block_json: Mapped[dict] = mapped_column(JSONText, nullable=False)
|
||||||
sftp_block_json: Mapped[dict] = mapped_column(JSONText, nullable=False)
|
sftp_block_json: Mapped[dict] = mapped_column(JSONText, nullable=False)
|
||||||
updated_at: Mapped[str] = mapped_column(String(32), nullable=False)
|
updated_at: Mapped[str] = mapped_column(String(32), nullable=False)
|
||||||
|
|
||||||
|
|
||||||
# =============================================================================
|
|
||||||
# Migration 0014: ORM `before_insert` events auto-populate the batch side
|
|
||||||
# of composite FKs.
|
|
||||||
# =============================================================================
|
|
||||||
#
|
|
||||||
# **What.** The four listeners below (``_match_before_insert``,
|
|
||||||
# ``_cas_before_insert``, ``_slp_before_insert``, ``_lr_before_insert``)
|
|
||||||
# run on every INSERT of a ``Match`` / ``CasAdjustment`` /
|
|
||||||
# ``ServiceLinePayment`` / ``LineReconciliation`` row. They look up the
|
|
||||||
# parent ``Claim`` / ``Remittance`` and copy its ``batch_id`` (and
|
|
||||||
# ``remittance_batch_id``) into the new row's composite-FK column.
|
|
||||||
#
|
|
||||||
# **Why.** After migration 0014, every child table has a composite FK to
|
|
||||||
# its parent: ``(batch_id, claim_id)`` for Match / LineReconciliation and
|
|
||||||
# ``(remittance_batch_id, remittance_id)`` for CasAdjustment /
|
|
||||||
# ServiceLinePayment. Both sides are ``NOT NULL`` at the SQL level, so
|
|
||||||
# every insert must supply ``batch_id``. Application code that creates
|
|
||||||
# these rows usually has the parent ``claim_id`` / ``remittance_id`` in
|
|
||||||
# scope (often as a string) but does not always have ``batch_id``
|
|
||||||
# readily available — the batch is created elsewhere in the same ingest
|
|
||||||
# transaction, or the parent is fetched by id alone. Threading
|
|
||||||
# ``batch_id`` through every call site would be error-prone. The events
|
|
||||||
# solve this transparently: caller passes the parent id as before, and
|
|
||||||
# the event fills in the batch side.
|
|
||||||
#
|
|
||||||
# **Lookup order** (preferred-to-fallback, to minimize round-trips):
|
|
||||||
# 1. ``session.new`` — pending parents added in this transaction
|
|
||||||
# (covers the common ingest/match flow where parent + child are
|
|
||||||
# added in the same session).
|
|
||||||
# 2. ``session.identity_map`` — already-persisted parents loaded
|
|
||||||
# earlier in this session.
|
|
||||||
# 3. ``session.execute(select(...))`` — DB query as a last resort.
|
|
||||||
# ``autoflush`` is OFF on our ``SessionLocal``, so this won't
|
|
||||||
# trigger an unintended flush of ``session.new``.
|
|
||||||
#
|
|
||||||
# **When it fails.** If the parent is in none of the above (e.g. the
|
|
||||||
# caller passed a parent id that was never added to the session and is
|
|
||||||
# not in the DB), the column stays NULL and the INSERT fails with
|
|
||||||
# ``IntegrityError: NOT NULL constraint failed: <table>.batch_id`` (or
|
|
||||||
# ``remittance_batch_id``). The error message is misleading — it looks
|
|
||||||
# like the column was forgotten, not that the parent id was wrong.
|
|
||||||
# Workarounds:
|
|
||||||
# * If the parent exists in another session, ``session.add(parent)``
|
|
||||||
# first (or use ``session.merge(parent)``) so the lookup finds it
|
|
||||||
# in ``session.new`` / ``session.identity_map``.
|
|
||||||
# * If you have the parent object in scope and know its ``batch_id``,
|
|
||||||
# set ``child.batch_id = ...`` explicitly — the event only fills
|
|
||||||
# in when the column is None, so explicit values win.
|
|
||||||
# * If the parent is on a different connection entirely, populate the
|
|
||||||
# column by hand before ``session.add(child)``.
|
|
||||||
#
|
|
||||||
# **Idempotency.** The events only act when ``target.batch_id is None``
|
|
||||||
# (or ``remittance_batch_id``). Application code may explicitly set
|
|
||||||
# either column — e.g., a bulk-loader that already has batch_id in hand
|
|
||||||
# skips the lookup.
|
|
||||||
#
|
|
||||||
# **Scope.** ``propagate=True`` so subclasses of these mappers (in
|
|
||||||
# tests, mainly) inherit the listeners. Production code does not use
|
|
||||||
# mapper inheritance for these tables.
|
|
||||||
|
|
||||||
from sqlalchemy import event as _sa_event # noqa: E402
|
|
||||||
from sqlalchemy.orm import Session as _Session # noqa: E402
|
|
||||||
|
|
||||||
|
|
||||||
def _resolve_claim_batch_id(session: "_Session", claim_id: "str | None") -> "str | None":
|
|
||||||
"""Look up a Claim's batch_id, preferring session-cached state to SQL."""
|
|
||||||
if claim_id is None:
|
|
||||||
return None
|
|
||||||
for obj in session.new:
|
|
||||||
if isinstance(obj, Claim) and obj.id == claim_id:
|
|
||||||
return obj.batch_id
|
|
||||||
for obj in session.identity_map.values():
|
|
||||||
if isinstance(obj, Claim) and obj.id == claim_id:
|
|
||||||
return obj.batch_id
|
|
||||||
from sqlalchemy import select as _select
|
|
||||||
return session.execute(
|
|
||||||
_select(Claim.batch_id).where(Claim.id == claim_id)
|
|
||||||
).scalar_one_or_none()
|
|
||||||
|
|
||||||
|
|
||||||
def _resolve_remit_batch_id(session: "_Session", remittance_id: "str | None") -> "str | None":
|
|
||||||
"""Look up a Remittance's batch_id, preferring session-cached state to SQL."""
|
|
||||||
if remittance_id is None:
|
|
||||||
return None
|
|
||||||
for obj in session.new:
|
|
||||||
if isinstance(obj, Remittance) and obj.id == remittance_id:
|
|
||||||
return obj.batch_id
|
|
||||||
for obj in session.identity_map.values():
|
|
||||||
if isinstance(obj, Remittance) and obj.id == remittance_id:
|
|
||||||
return obj.batch_id
|
|
||||||
from sqlalchemy import select as _select
|
|
||||||
return session.execute(
|
|
||||||
_select(Remittance.batch_id).where(Remittance.id == remittance_id)
|
|
||||||
).scalar_one_or_none()
|
|
||||||
|
|
||||||
|
|
||||||
@_sa_event.listens_for(Match, "before_insert", propagate=True)
|
|
||||||
def _match_before_insert(mapper, connection, target):
|
|
||||||
if target.batch_id is None or target.remittance_batch_id is None:
|
|
||||||
session = _Session.object_session(target)
|
|
||||||
if session is None:
|
|
||||||
return
|
|
||||||
if target.batch_id is None:
|
|
||||||
target.batch_id = _resolve_claim_batch_id(session, target.claim_id)
|
|
||||||
if target.remittance_batch_id is None:
|
|
||||||
target.remittance_batch_id = _resolve_remit_batch_id(session, target.remittance_id)
|
|
||||||
|
|
||||||
|
|
||||||
@_sa_event.listens_for(CasAdjustment, "before_insert", propagate=True)
|
|
||||||
def _cas_before_insert(mapper, connection, target):
|
|
||||||
if target.remittance_batch_id is None:
|
|
||||||
session = _Session.object_session(target)
|
|
||||||
if session is None:
|
|
||||||
return
|
|
||||||
target.remittance_batch_id = _resolve_remit_batch_id(session, target.remittance_id)
|
|
||||||
|
|
||||||
|
|
||||||
@_sa_event.listens_for(ServiceLinePayment, "before_insert", propagate=True)
|
|
||||||
def _slp_before_insert(mapper, connection, target):
|
|
||||||
if target.remittance_batch_id is None:
|
|
||||||
session = _Session.object_session(target)
|
|
||||||
if session is None:
|
|
||||||
return
|
|
||||||
target.remittance_batch_id = _resolve_remit_batch_id(session, target.remittance_id)
|
|
||||||
|
|
||||||
|
|
||||||
@_sa_event.listens_for(LineReconciliation, "before_insert", propagate=True)
|
|
||||||
def _lr_before_insert(mapper, connection, target):
|
|
||||||
if target.batch_id is None:
|
|
||||||
session = _Session.object_session(target)
|
|
||||||
if session is None:
|
|
||||||
return
|
|
||||||
target.batch_id = _resolve_claim_batch_id(session, target.claim_id)
|
|
||||||
|
|||||||
@@ -47,58 +47,31 @@ def _strip_comments(sql: str) -> str:
|
|||||||
return "\n".join(lines)
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
|
||||||
def _apply_migrations_until(engine: sa.Engine, target_version: int | None) -> None:
|
def run(engine: sa.Engine) -> None:
|
||||||
"""Apply migrations up to ``target_version`` (inclusive).
|
"""Apply any pending migrations. Idempotent."""
|
||||||
|
migrations_dir = MIGRATIONS_DIR
|
||||||
|
if not migrations_dir.exists():
|
||||||
|
return # no migrations directory yet — fresh checkout, no-op
|
||||||
|
|
||||||
|
migration_files = sorted(migrations_dir.glob("*.sql"))
|
||||||
|
if not migration_files:
|
||||||
|
return
|
||||||
|
|
||||||
If ``target_version`` is ``None``, apply every pending migration. If a
|
|
||||||
migration's version is greater than ``target_version``, stop iterating.
|
|
||||||
Idempotent: migrations whose version is <= the current ``PRAGMA user_version``
|
|
||||||
are skipped.
|
|
||||||
"""
|
|
||||||
with engine.begin() as conn:
|
with engine.begin() as conn:
|
||||||
current = conn.exec_driver_sql("PRAGMA user_version").scalar() or 0
|
current = conn.exec_driver_sql("PRAGMA user_version").scalar() or 0
|
||||||
|
|
||||||
for path in sorted(MIGRATIONS_DIR.glob("*.sql")):
|
for path in migration_files:
|
||||||
sql = path.read_text()
|
sql = path.read_text()
|
||||||
version = _migration_version(sql, path.name)
|
version = _migration_version(sql, path.name)
|
||||||
if version <= current:
|
if version <= current:
|
||||||
continue
|
continue
|
||||||
if target_version is not None and version > target_version:
|
|
||||||
break
|
|
||||||
|
|
||||||
statements_sql = _strip_comments(sql)
|
statements_sql = _strip_comments(sql)
|
||||||
statements = [s.strip() for s in statements_sql.split(";") if s.strip()]
|
statements = [s.strip() for s in statements_sql.split(";") if s.strip()]
|
||||||
|
|
||||||
with engine.begin() as conn:
|
with engine.begin() as conn:
|
||||||
for stmt in statements:
|
for stmt in statements:
|
||||||
conn.exec_driver_sql(stmt)
|
conn.exec_driver_sql(stmt)
|
||||||
conn.exec_driver_sql(f"PRAGMA user_version = {version}")
|
conn.exec_driver_sql(f"PRAGMA user_version = {version}")
|
||||||
|
|
||||||
current = version
|
current = version
|
||||||
|
|
||||||
|
|
||||||
def run(engine: sa.Engine) -> None:
|
|
||||||
"""Apply any pending migrations. Idempotent."""
|
|
||||||
if not MIGRATIONS_DIR.exists():
|
|
||||||
return # no migrations directory yet — fresh checkout, no-op
|
|
||||||
if not list(MIGRATIONS_DIR.glob("*.sql")):
|
|
||||||
return
|
|
||||||
_apply_migrations_until(engine, None)
|
|
||||||
|
|
||||||
|
|
||||||
def migrate_to_version(engine: sa.Engine, target_version: int) -> None:
|
|
||||||
"""Apply migrations up to and including ``target_version``. Idempotent.
|
|
||||||
|
|
||||||
Reads the current ``PRAGMA user_version`` and applies any migrations
|
|
||||||
whose version is in the range ``(current, target_version]``. Stops
|
|
||||||
before applying migrations with version > ``target_version`` so the
|
|
||||||
caller can seed data between N and N+1.
|
|
||||||
|
|
||||||
Useful for tests that need to seed data after migrations ``0001..N``
|
|
||||||
have applied but before ``N+1`` runs — exercise a migration against
|
|
||||||
real, pre-existing rows rather than only verifying that post-migration
|
|
||||||
inserts round-trip.
|
|
||||||
|
|
||||||
Calling with ``target_version`` <= the current version is a no-op.
|
|
||||||
"""
|
|
||||||
if not MIGRATIONS_DIR.exists():
|
|
||||||
return
|
|
||||||
_apply_migrations_until(engine, target_version)
|
|
||||||
@@ -1,59 +0,0 @@
|
|||||||
-- version: 13
|
|
||||||
-- Drop the inline UNIQUE(batch_id, patient_control_number) on claims.
|
|
||||||
-- Migration 0003 attempted DROP INDEX IF EXISTS uq_claims_batch_pcn but
|
|
||||||
-- the constraint is inline in CREATE TABLE, so the drop was a no-op.
|
|
||||||
-- The only way to remove an inline UNIQUE in SQLite is table recreation.
|
|
||||||
--
|
|
||||||
-- X12 837P allows any number of CLM segments per 2000B subscriber loop;
|
|
||||||
-- claim identity is provided by the primary key (claims.id = CLM01).
|
|
||||||
-- The remittances table had a parallel constraint already removed in 0003
|
|
||||||
-- (because that one WAS a named index), so this migration only touches
|
|
||||||
-- claims.
|
|
||||||
--
|
|
||||||
-- The migration runner (db_migrate.py) wraps each .sql in an implicit
|
|
||||||
-- transaction via engine.begin(), so we MUST NOT use BEGIN/COMMIT.
|
|
||||||
-- PRAGMA defer_foreign_keys defers FK checks to commit, which is the
|
|
||||||
-- only way to drop a referenced table inside a transaction in SQLite.
|
|
||||||
|
|
||||||
PRAGMA defer_foreign_keys = ON;
|
|
||||||
|
|
||||||
CREATE TABLE claims_new (
|
|
||||||
id TEXT PRIMARY KEY,
|
|
||||||
batch_id TEXT NOT NULL REFERENCES batches(id) ON DELETE CASCADE,
|
|
||||||
patient_control_number TEXT NOT NULL,
|
|
||||||
service_date_from DATE,
|
|
||||||
service_date_to DATE,
|
|
||||||
charge_amount NUMERIC(12, 2) NOT NULL DEFAULT 0,
|
|
||||||
provider_npi TEXT,
|
|
||||||
payer_id TEXT,
|
|
||||||
state TEXT NOT NULL DEFAULT 'submitted',
|
|
||||||
state_before_reversal TEXT,
|
|
||||||
matched_remittance_id TEXT REFERENCES remittances(id),
|
|
||||||
raw_json TEXT,
|
|
||||||
rejection_reason TEXT,
|
|
||||||
rejected_at TIMESTAMP,
|
|
||||||
resubmit_count INTEGER NOT NULL DEFAULT 0,
|
|
||||||
state_changed_at TIMESTAMP,
|
|
||||||
payer_rejected_at TEXT,
|
|
||||||
payer_rejected_reason TEXT,
|
|
||||||
payer_rejected_status_code TEXT,
|
|
||||||
payer_rejected_by_277ca_id TEXT,
|
|
||||||
payer_rejected_acknowledged_at TEXT,
|
|
||||||
payer_rejected_acknowledged_actor TEXT
|
|
||||||
-- NO UNIQUE (batch_id, patient_control_number) — removed.
|
|
||||||
);
|
|
||||||
|
|
||||||
INSERT INTO claims_new SELECT * FROM claims;
|
|
||||||
DROP TABLE claims;
|
|
||||||
ALTER TABLE claims_new RENAME TO claims;
|
|
||||||
|
|
||||||
-- Recreate secondary indexes (same names, same columns as initial schema
|
|
||||||
-- plus later migrations).
|
|
||||||
CREATE INDEX ix_claims_state ON claims(state);
|
|
||||||
CREATE INDEX ix_claims_patient_control_number ON claims(patient_control_number);
|
|
||||||
CREATE INDEX ix_claims_service_date_from ON claims(service_date_from);
|
|
||||||
CREATE INDEX ix_claims_state_changed_at ON claims(state, state_changed_at);
|
|
||||||
CREATE INDEX idx_claims_payer_rejected_at ON claims(payer_rejected_at);
|
|
||||||
CREATE INDEX idx_claims_payer_rejected_unack
|
|
||||||
ON claims(payer_rejected_at)
|
|
||||||
WHERE payer_rejected_acknowledged_at IS NULL;
|
|
||||||
@@ -1,253 +0,0 @@
|
|||||||
-- version: 14
|
|
||||||
-- Relax PRIMARY KEYs on `claims` and `remittances` from single-column (id)
|
|
||||||
-- to composite (batch_id, id). Enables resubmits (same CLM01 / CLP01 in
|
|
||||||
-- different batches) and makes the pre-flight dedup workflow exercisable.
|
|
||||||
--
|
|
||||||
-- Strategy (mirrors 0013): table recreation with PRAGMA
|
|
||||||
-- defer_foreign_keys. We must recreate every table that has an FK pointing
|
|
||||||
-- at `claims(id)` or `remittances(id)` so that FK constraints can be
|
|
||||||
-- updated to point at the new composite PK.
|
|
||||||
--
|
|
||||||
-- FKs that need updating (verified via pragma_foreign_key_list on the
|
|
||||||
-- pre-migration schema):
|
|
||||||
-- - remittances.claim_id -> claims(id) becomes -> (batch_id, id)
|
|
||||||
-- - claims.matched_remittance_id -> remittances(id) becomes -> (batch_id, id)
|
|
||||||
-- - matches.claim_id -> claims(id) ON DELETE CASCADE becomes -> (batch_id, id)
|
|
||||||
-- - matches.remittance_id -> remittances(id) ON DELETE CASCADE becomes -> (batch_id, id)
|
|
||||||
-- - cas_adjustments.remittance_id -> remittances(id) ON DELETE CASCADE becomes -> (batch_id, id)
|
|
||||||
-- - service_line_payments.remittance_id -> remittances(id) ON DELETE CASCADE becomes -> (batch_id, id)
|
|
||||||
-- - line_reconciliations.claim_id -> claims(id) ON DELETE CASCADE becomes -> (batch_id, id)
|
|
||||||
--
|
|
||||||
-- The cross-table FKs (remittances.claim_id, claims.matched_remittance_id)
|
|
||||||
-- can NOT be SQL-enforced with composite PKs because SQLite has no
|
|
||||||
-- ALTER TABLE ADD CONSTRAINT and DROP/ADD CONSTRAINT for an existing FK.
|
|
||||||
-- We keep them as plain TEXT columns (no REFERENCES clause) and rely on
|
|
||||||
-- application-layer invariants (store.manual_match / manual_unmatch,
|
|
||||||
-- dedup.preflight_*) to keep them consistent. The application code already
|
|
||||||
-- owns those paths; this is a deliberate trade-off documented in 0001's
|
|
||||||
-- comments ("amendment deferred to post-SQLite rollout").
|
|
||||||
--
|
|
||||||
-- The single-column FKs from claims/remittances DOWN to other tables
|
|
||||||
-- (matches, cas_adjustments, service_line_payments, line_reconciliations)
|
|
||||||
-- MUST be updated to composite FKs because those tables are recreated with
|
|
||||||
-- composite FK columns. We add a `batch_id` (or `remittance_batch_id`)
|
|
||||||
-- column to each and JOIN against the parent table to populate it during
|
|
||||||
-- the migration.
|
|
||||||
--
|
|
||||||
-- Why PRAGMA defer_foreign_keys (and not foreign_keys=OFF): the migration
|
|
||||||
-- runner wraps each .sql file in `engine.begin()` (a transaction). Inside a
|
|
||||||
-- transaction, `PRAGMA defer_foreign_keys = ON` defers FK checks to COMMIT,
|
|
||||||
-- which is the only safe way to drop a referenced table. `PRAGMA
|
|
||||||
-- foreign_keys` cannot be changed inside a transaction in SQLite, so the
|
|
||||||
-- defer_foreign_keys approach is what works inside the runner's transaction.
|
|
||||||
|
|
||||||
PRAGMA defer_foreign_keys = ON;
|
|
||||||
|
|
||||||
-- ============================================================================
|
|
||||||
-- Step 1: recreate `remittances` with composite PK (batch_id, id).
|
|
||||||
-- ============================================================================
|
|
||||||
-- Column shape: every column from 0001_initial.sql + claim_level_adjustment_amount
|
|
||||||
-- (added by 0006_line_reconciliation.sql). The `claim_id` column is kept
|
|
||||||
-- without an SQL-level FK — composite-FK to claims is enforced at the
|
|
||||||
-- application layer (see plan).
|
|
||||||
CREATE TABLE remittances_new (
|
|
||||||
id TEXT NOT NULL,
|
|
||||||
batch_id TEXT NOT NULL REFERENCES batches(id) ON DELETE CASCADE,
|
|
||||||
payer_claim_control_number TEXT NOT NULL,
|
|
||||||
claim_id TEXT,
|
|
||||||
status_code TEXT NOT NULL,
|
|
||||||
status_label TEXT,
|
|
||||||
total_charge NUMERIC(12, 2) NOT NULL DEFAULT 0,
|
|
||||||
total_paid NUMERIC(12, 2) NOT NULL DEFAULT 0,
|
|
||||||
patient_responsibility NUMERIC(12, 2),
|
|
||||||
adjustment_amount NUMERIC(12, 2) NOT NULL DEFAULT 0,
|
|
||||||
claim_level_adjustment_amount NUMERIC(12, 2) NOT NULL DEFAULT 0,
|
|
||||||
received_at DATETIME NOT NULL,
|
|
||||||
service_date DATE,
|
|
||||||
is_reversal INTEGER NOT NULL DEFAULT 0,
|
|
||||||
raw_json TEXT,
|
|
||||||
PRIMARY KEY (batch_id, id)
|
|
||||||
);
|
|
||||||
INSERT INTO remittances_new
|
|
||||||
SELECT id, batch_id, payer_claim_control_number, claim_id, status_code,
|
|
||||||
status_label, total_charge, total_paid, patient_responsibility,
|
|
||||||
adjustment_amount, claim_level_adjustment_amount, received_at,
|
|
||||||
service_date, is_reversal, raw_json
|
|
||||||
FROM remittances;
|
|
||||||
DROP TABLE remittances;
|
|
||||||
ALTER TABLE remittances_new RENAME TO remittances;
|
|
||||||
CREATE INDEX ix_remittances_claim_id ON remittances(claim_id);
|
|
||||||
CREATE INDEX ix_remittances_payer_claim_control_number ON remittances(payer_claim_control_number);
|
|
||||||
CREATE INDEX ix_remittances_status_code ON remittances(status_code);
|
|
||||||
|
|
||||||
-- ============================================================================
|
|
||||||
-- Step 2: recreate `claims` with composite PK (batch_id, id).
|
|
||||||
-- ============================================================================
|
|
||||||
-- Column shape: every column from 0013_drop_claims_unique_constraint.sql
|
|
||||||
-- (which itself consolidated 0001 + 0004 + 0008 + 0010), plus a new
|
|
||||||
-- `matched_remittance_batch_id` column. The back-reference to remittances
|
|
||||||
-- becomes a 2-column pointer (matched_remittance_id + matched_remittance_batch_id)
|
|
||||||
-- but the SQL-level FK is dropped (no ALTER CONSTRAINT in SQLite); see plan.
|
|
||||||
--
|
|
||||||
-- Column order MUST match the 0013 column order exactly so that
|
|
||||||
-- `INSERT INTO claims_new SELECT ... FROM claims` populates the right
|
|
||||||
-- columns. The trailing NULL fills the new matched_remittance_batch_id.
|
|
||||||
CREATE TABLE claims_new (
|
|
||||||
id TEXT NOT NULL,
|
|
||||||
batch_id TEXT NOT NULL REFERENCES batches(id) ON DELETE CASCADE,
|
|
||||||
patient_control_number TEXT NOT NULL,
|
|
||||||
service_date_from DATE,
|
|
||||||
service_date_to DATE,
|
|
||||||
charge_amount NUMERIC(12, 2) NOT NULL DEFAULT 0,
|
|
||||||
provider_npi TEXT,
|
|
||||||
payer_id TEXT,
|
|
||||||
state TEXT NOT NULL DEFAULT 'submitted',
|
|
||||||
state_before_reversal TEXT,
|
|
||||||
-- matched_remittance_id: kept without an SQL-level FK (composite
|
|
||||||
-- back-reference requires the application layer; see plan header).
|
|
||||||
matched_remittance_id TEXT,
|
|
||||||
raw_json TEXT,
|
|
||||||
rejection_reason TEXT,
|
|
||||||
rejected_at TIMESTAMP,
|
|
||||||
resubmit_count INTEGER NOT NULL DEFAULT 0,
|
|
||||||
state_changed_at TIMESTAMP,
|
|
||||||
payer_rejected_at TEXT,
|
|
||||||
payer_rejected_reason TEXT,
|
|
||||||
payer_rejected_status_code TEXT,
|
|
||||||
payer_rejected_by_277ca_id TEXT,
|
|
||||||
payer_rejected_acknowledged_at TEXT,
|
|
||||||
payer_rejected_acknowledged_actor TEXT,
|
|
||||||
-- matched_remittance_batch_id: NEW. Always NULL on migration because
|
|
||||||
-- the pre-0014 schema only stored matched_remittance_id (single column).
|
|
||||||
-- App code populates it on manual_match / match_auto going forward.
|
|
||||||
matched_remittance_batch_id TEXT,
|
|
||||||
PRIMARY KEY (batch_id, id)
|
|
||||||
);
|
|
||||||
INSERT INTO claims_new
|
|
||||||
SELECT id, batch_id, patient_control_number, service_date_from,
|
|
||||||
service_date_to, charge_amount, provider_npi, payer_id, state,
|
|
||||||
state_before_reversal, matched_remittance_id, raw_json,
|
|
||||||
rejection_reason, rejected_at, resubmit_count, state_changed_at,
|
|
||||||
payer_rejected_at, payer_rejected_reason,
|
|
||||||
payer_rejected_status_code, payer_rejected_by_277ca_id,
|
|
||||||
payer_rejected_acknowledged_at, payer_rejected_acknowledged_actor,
|
|
||||||
NULL
|
|
||||||
FROM claims;
|
|
||||||
DROP TABLE claims;
|
|
||||||
ALTER TABLE claims_new RENAME TO claims;
|
|
||||||
CREATE INDEX ix_claims_state ON claims(state);
|
|
||||||
CREATE INDEX ix_claims_patient_control_number ON claims(patient_control_number);
|
|
||||||
CREATE INDEX ix_claims_service_date_from ON claims(service_date_from);
|
|
||||||
CREATE INDEX ix_claims_state_changed_at ON claims(state, state_changed_at);
|
|
||||||
CREATE INDEX idx_claims_payer_rejected_at ON claims(payer_rejected_at);
|
|
||||||
CREATE INDEX idx_claims_payer_rejected_unack
|
|
||||||
ON claims(payer_rejected_at)
|
|
||||||
WHERE payer_rejected_acknowledged_at IS NULL;
|
|
||||||
|
|
||||||
-- ============================================================================
|
|
||||||
-- Step 3: recreate `matches`, `cas_adjustments`, `service_line_payments`,
|
|
||||||
-- `line_reconciliations` so their FKs point at the new composite PKs.
|
|
||||||
--
|
|
||||||
-- At this point the OLD `claims` and `remittances` tables are gone (dropped
|
|
||||||
-- in Steps 1 and 2) — only the recreated (composite-PK) versions exist.
|
|
||||||
-- The JOIN below reads the NEW parents, which have identical data
|
|
||||||
-- (same row count, same id values, same batch_id values); we only need
|
|
||||||
-- `batch_id` from each parent to populate the composite-FK column.
|
|
||||||
-- ============================================================================
|
|
||||||
|
|
||||||
CREATE TABLE matches_new (
|
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
||||||
claim_id TEXT NOT NULL,
|
|
||||||
batch_id TEXT NOT NULL,
|
|
||||||
remittance_id TEXT NOT NULL,
|
|
||||||
remittance_batch_id TEXT NOT NULL,
|
|
||||||
strategy TEXT NOT NULL,
|
|
||||||
matched_at DATETIME NOT NULL,
|
|
||||||
prior_claim_state TEXT,
|
|
||||||
is_reversal INTEGER NOT NULL DEFAULT 0,
|
|
||||||
FOREIGN KEY (batch_id, claim_id) REFERENCES claims(batch_id, id) ON DELETE CASCADE,
|
|
||||||
FOREIGN KEY (remittance_batch_id, remittance_id) REFERENCES remittances(batch_id, id) ON DELETE CASCADE
|
|
||||||
);
|
|
||||||
INSERT INTO matches_new
|
|
||||||
SELECT m.id, m.claim_id, c.batch_id, m.remittance_id, r.batch_id,
|
|
||||||
m.strategy, m.matched_at, m.prior_claim_state, m.is_reversal
|
|
||||||
FROM matches m
|
|
||||||
JOIN claims c ON c.id = m.claim_id
|
|
||||||
JOIN remittances r ON r.id = m.remittance_id;
|
|
||||||
DROP TABLE matches;
|
|
||||||
ALTER TABLE matches_new RENAME TO matches;
|
|
||||||
CREATE INDEX ix_matches_claim_id ON matches(claim_id);
|
|
||||||
CREATE INDEX ix_matches_remittance_id ON matches(remittance_id);
|
|
||||||
CREATE INDEX ix_matches_matched_at ON matches(matched_at);
|
|
||||||
|
|
||||||
CREATE TABLE cas_adjustments_new (
|
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
||||||
remittance_id TEXT NOT NULL,
|
|
||||||
remittance_batch_id TEXT NOT NULL,
|
|
||||||
group_code TEXT NOT NULL,
|
|
||||||
reason_code TEXT NOT NULL,
|
|
||||||
amount NUMERIC(12, 2) NOT NULL,
|
|
||||||
quantity NUMERIC(10, 2),
|
|
||||||
service_line_payment_id INTEGER REFERENCES service_line_payments(id) ON DELETE SET NULL,
|
|
||||||
FOREIGN KEY (remittance_batch_id, remittance_id) REFERENCES remittances(batch_id, id) ON DELETE CASCADE
|
|
||||||
);
|
|
||||||
INSERT INTO cas_adjustments_new
|
|
||||||
SELECT ca.id, ca.remittance_id, r.batch_id, ca.group_code, ca.reason_code,
|
|
||||||
ca.amount, ca.quantity, ca.service_line_payment_id
|
|
||||||
FROM cas_adjustments ca
|
|
||||||
JOIN remittances r ON r.id = ca.remittance_id;
|
|
||||||
DROP TABLE cas_adjustments;
|
|
||||||
ALTER TABLE cas_adjustments_new RENAME TO cas_adjustments;
|
|
||||||
CREATE INDEX ix_cas_adjustments_remittance_id ON cas_adjustments(remittance_id);
|
|
||||||
CREATE INDEX ix_cas_adjustments_service_line_payment_id ON cas_adjustments(service_line_payment_id);
|
|
||||||
|
|
||||||
CREATE TABLE service_line_payments_new (
|
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
||||||
remittance_id TEXT NOT NULL,
|
|
||||||
remittance_batch_id TEXT NOT NULL,
|
|
||||||
line_number INTEGER NOT NULL,
|
|
||||||
procedure_qualifier VARCHAR(4) NOT NULL,
|
|
||||||
procedure_code VARCHAR(16) NOT NULL,
|
|
||||||
modifiers_json TEXT NOT NULL DEFAULT '[]',
|
|
||||||
charge NUMERIC(12, 2) NOT NULL,
|
|
||||||
payment NUMERIC(12, 2) NOT NULL,
|
|
||||||
units NUMERIC(10, 2),
|
|
||||||
unit_type VARCHAR(8),
|
|
||||||
service_date DATE,
|
|
||||||
ref_benefit_plan VARCHAR(64),
|
|
||||||
superseded_by_id INTEGER REFERENCES service_line_payments(id) ON DELETE SET NULL,
|
|
||||||
FOREIGN KEY (remittance_batch_id, remittance_id) REFERENCES remittances(batch_id, id) ON DELETE CASCADE
|
|
||||||
);
|
|
||||||
INSERT INTO service_line_payments_new
|
|
||||||
SELECT slp.id, slp.remittance_id, r.batch_id, slp.line_number,
|
|
||||||
slp.procedure_qualifier, slp.procedure_code, slp.modifiers_json,
|
|
||||||
slp.charge, slp.payment, slp.units, slp.unit_type, slp.service_date,
|
|
||||||
slp.ref_benefit_plan, slp.superseded_by_id
|
|
||||||
FROM service_line_payments slp
|
|
||||||
JOIN remittances r ON r.id = slp.remittance_id;
|
|
||||||
DROP TABLE service_line_payments;
|
|
||||||
ALTER TABLE service_line_payments_new RENAME TO service_line_payments;
|
|
||||||
CREATE INDEX ix_service_line_payments_remittance_id ON service_line_payments(remittance_id);
|
|
||||||
CREATE INDEX ix_service_line_payments_procedure_code_date ON service_line_payments(procedure_code, service_date);
|
|
||||||
|
|
||||||
CREATE TABLE line_reconciliations_new (
|
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
||||||
claim_id VARCHAR(64) NOT NULL,
|
|
||||||
batch_id VARCHAR(64) NOT NULL,
|
|
||||||
claim_service_line_number INTEGER,
|
|
||||||
service_line_payment_id INTEGER REFERENCES service_line_payments(id) ON DELETE SET NULL,
|
|
||||||
status VARCHAR(16) NOT NULL,
|
|
||||||
match_score INTEGER,
|
|
||||||
reconciled_at TIMESTAMP NOT NULL,
|
|
||||||
FOREIGN KEY (batch_id, claim_id) REFERENCES claims(batch_id, id) ON DELETE CASCADE
|
|
||||||
);
|
|
||||||
INSERT INTO line_reconciliations_new
|
|
||||||
SELECT lr.id, lr.claim_id, c.batch_id, lr.claim_service_line_number,
|
|
||||||
lr.service_line_payment_id, lr.status, lr.match_score, lr.reconciled_at
|
|
||||||
FROM line_reconciliations lr
|
|
||||||
JOIN claims c ON c.id = lr.claim_id;
|
|
||||||
DROP TABLE line_reconciliations;
|
|
||||||
ALTER TABLE line_reconciliations_new RENAME TO line_reconciliations;
|
|
||||||
CREATE INDEX ix_line_reconciliations_claim_id ON line_reconciliations(claim_id);
|
|
||||||
CREATE INDEX ix_line_reconciliations_claim_service_line_number ON line_reconciliations(claim_service_line_number);
|
|
||||||
CREATE INDEX ix_line_reconciliations_service_line_payment_id ON line_reconciliations(service_line_payment_id);
|
|
||||||
@@ -287,9 +287,6 @@ def run(session, batch_id: str) -> ReconcileResult:
|
|||||||
else:
|
else:
|
||||||
m.claim.state = ClaimState.REVERSED
|
m.claim.state = ClaimState.REVERSED
|
||||||
m.claim.matched_remittance_id = m.remittance.id
|
m.claim.matched_remittance_id = m.remittance.id
|
||||||
# Migration 0014: composite back-reference to remittances — both
|
|
||||||
# sides needed for get_claim_detail to JOIN back to remittances.
|
|
||||||
m.claim.matched_remittance_batch_id = m.remittance.batch_id
|
|
||||||
# Symmetric FK: also set Remittance.claim_id so list_unmatched (which
|
# Symmetric FK: also set Remittance.claim_id so list_unmatched (which
|
||||||
# filters on Remittance.claim_id IS NULL) correctly drops auto-matched
|
# filters on Remittance.claim_id IS NULL) correctly drops auto-matched
|
||||||
# remits from the orphan bucket. Manual matches do this too.
|
# remits from the orphan bucket. Manual matches do this too.
|
||||||
|
|||||||
@@ -64,40 +64,6 @@ class AlreadyMatchedError(Exception):
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
|
|
||||||
def find_existing_batch_for_claim(claim_id: str) -> str | None:
|
|
||||||
"""Return the batch_id of the first batch containing this claim id, or None.
|
|
||||||
|
|
||||||
Pure read; opens a short-lived session. Used by the 837 409 handler to
|
|
||||||
surface which prior batch already holds the same CLM01.
|
|
||||||
"""
|
|
||||||
from sqlalchemy import select
|
|
||||||
|
|
||||||
from cyclone.db import Claim
|
|
||||||
|
|
||||||
with db.SessionLocal()() as s:
|
|
||||||
row = s.execute(
|
|
||||||
select(Claim.batch_id).where(Claim.id == claim_id).limit(1)
|
|
||||||
).first()
|
|
||||||
return row[0] if row else None
|
|
||||||
|
|
||||||
|
|
||||||
def find_existing_batch_for_remit(remit_id: str) -> str | None:
|
|
||||||
"""Return the batch_id of the first batch containing this remit id, or None.
|
|
||||||
|
|
||||||
Pure read; opens a short-lived session. Used by the 835 409 handler.
|
|
||||||
`remit_id` is the PK on `remittances.id` (= payer_claim_control_number = CLP01).
|
|
||||||
"""
|
|
||||||
from sqlalchemy import select
|
|
||||||
|
|
||||||
from cyclone.db import Remittance
|
|
||||||
|
|
||||||
with db.SessionLocal()() as s:
|
|
||||||
row = s.execute(
|
|
||||||
select(Remittance.batch_id).where(Remittance.id == remit_id).limit(1)
|
|
||||||
).first()
|
|
||||||
return row[0] if row else None
|
|
||||||
|
|
||||||
|
|
||||||
class NotMatchedError(Exception):
|
class NotMatchedError(Exception):
|
||||||
"""Raised by ``CycloneStore.manual_unmatch`` when the claim has no match.
|
"""Raised by ``CycloneStore.manual_unmatch`` when the claim has no match.
|
||||||
|
|
||||||
@@ -986,10 +952,7 @@ class CycloneStore:
|
|||||||
if isinstance(record, BatchRecord837):
|
if isinstance(record, BatchRecord837):
|
||||||
result: ParseResult = record.result
|
result: ParseResult = record.result
|
||||||
for claim in result.claims:
|
for claim in result.claims:
|
||||||
# Migration 0014: claims PK is composite (batch_id, id).
|
if s.get(Claim, claim.claim_id) is not None:
|
||||||
# Duplicate-check is per-batch now — same CLM01 in a
|
|
||||||
# DIFFERENT batch is allowed (resubmits / retry).
|
|
||||||
if s.get(Claim, (record.id, claim.claim_id)) is not None:
|
|
||||||
log.warning(
|
log.warning(
|
||||||
"add: claim %s already exists; skipping (batch=%s)",
|
"add: claim %s already exists; skipping (batch=%s)",
|
||||||
claim.claim_id, record.id,
|
claim.claim_id, record.id,
|
||||||
@@ -1015,9 +978,7 @@ class CycloneStore:
|
|||||||
result835: ParseResult835 = record.result
|
result835: ParseResult835 = record.result
|
||||||
payer_name = result835.payer.name
|
payer_name = result835.payer.name
|
||||||
for cp in result835.claims:
|
for cp in result835.claims:
|
||||||
# Migration 0014: remittances PK is composite. Per-batch
|
if s.get(Remittance, cp.payer_claim_control_number) is not None:
|
||||||
# duplicate-check; cross-batch CLP01 is allowed.
|
|
||||||
if s.get(Remittance, (record.id, cp.payer_claim_control_number)) is not None:
|
|
||||||
log.warning(
|
log.warning(
|
||||||
"add: remittance %s already exists; skipping (batch=%s)",
|
"add: remittance %s already exists; skipping (batch=%s)",
|
||||||
cp.payer_claim_control_number, record.id,
|
cp.payer_claim_control_number, record.id,
|
||||||
@@ -1101,11 +1062,7 @@ class CycloneStore:
|
|||||||
try:
|
try:
|
||||||
with db.SessionLocal()() as s:
|
with db.SessionLocal()() as s:
|
||||||
for cid in claim_ids:
|
for cid in claim_ids:
|
||||||
# Migration 0014: composite PK. The event log
|
row = s.get(Claim, cid)
|
||||||
# (ActivityEvent.claim_id) only stores the id, not
|
|
||||||
# batch_id, so look up by id alone within this batch
|
|
||||||
# context (record.id).
|
|
||||||
row = s.get(Claim, (record.id, cid))
|
|
||||||
if row is None:
|
if row is None:
|
||||||
continue
|
continue
|
||||||
ui = to_ui_claim_from_orm(
|
ui = to_ui_claim_from_orm(
|
||||||
@@ -1114,7 +1071,7 @@ class CycloneStore:
|
|||||||
)
|
)
|
||||||
self._sync_publish(event_bus, "claim_written", ui)
|
self._sync_publish(event_bus, "claim_written", ui)
|
||||||
for rid in remit_ids:
|
for rid in remit_ids:
|
||||||
row = s.get(Remittance, (record.id, rid))
|
row = s.get(Remittance, rid)
|
||||||
if row is None:
|
if row is None:
|
||||||
continue
|
continue
|
||||||
ui = to_ui_remittance_from_orm(
|
ui = to_ui_remittance_from_orm(
|
||||||
@@ -1261,12 +1218,7 @@ class CycloneStore:
|
|||||||
per-line payments + adjustments without a second fetch.
|
per-line payments + adjustments without a second fetch.
|
||||||
"""
|
"""
|
||||||
with db.SessionLocal()() as s:
|
with db.SessionLocal()() as s:
|
||||||
# Migration 0014: composite PK. The public store.get_remittance
|
row = s.get(Remittance, remittance_id)
|
||||||
# API takes only remittance_id (callers may not know batch_id),
|
|
||||||
# so we look up by id alone. If duplicates exist across
|
|
||||||
# batches (resubmits), we return the first match — the same
|
|
||||||
# semantics the old single-PK s.get() provided.
|
|
||||||
row = s.query(Remittance).filter(Remittance.id == remittance_id).first()
|
|
||||||
if row is None:
|
if row is None:
|
||||||
return None
|
return None
|
||||||
cas_rows = (
|
cas_rows = (
|
||||||
@@ -1338,9 +1290,7 @@ class CycloneStore:
|
|||||||
from cyclone import db as _db
|
from cyclone import db as _db
|
||||||
|
|
||||||
with _db.SessionLocal()() as s:
|
with _db.SessionLocal()() as s:
|
||||||
# Migration 0014: see get_remittance above — public API takes
|
row = s.get(Claim, claim_id)
|
||||||
# only claim_id; look up by id alone, return first match.
|
|
||||||
row = s.query(Claim).filter(Claim.id == claim_id).first()
|
|
||||||
if row is None:
|
if row is None:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
@@ -1380,12 +1330,7 @@ class CycloneStore:
|
|||||||
]
|
]
|
||||||
|
|
||||||
if row.matched_remittance_id is not None:
|
if row.matched_remittance_id is not None:
|
||||||
# Migration 0014: composite PK; matched_remittance_batch_id
|
remit = s.get(Remittance, row.matched_remittance_id)
|
||||||
# is the batch side of the back-reference.
|
|
||||||
remit = s.get(
|
|
||||||
Remittance,
|
|
||||||
(row.matched_remittance_batch_id, row.matched_remittance_id),
|
|
||||||
)
|
|
||||||
if remit is not None:
|
if remit is not None:
|
||||||
status = (
|
status = (
|
||||||
"reconciled"
|
"reconciled"
|
||||||
@@ -2038,12 +1983,7 @@ class CycloneStore:
|
|||||||
from cyclone import reconcile as _reconcile
|
from cyclone import reconcile as _reconcile
|
||||||
|
|
||||||
with db.SessionLocal()() as s:
|
with db.SessionLocal()() as s:
|
||||||
# Migration 0014: public API takes only claim_id / remit_id.
|
claim = s.get(Claim, claim_id)
|
||||||
# Look up by id alone — if duplicates exist across batches,
|
|
||||||
# the first match wins (the manual_match workflow is per-batch
|
|
||||||
# so the user knows which batch they mean; cross-batch
|
|
||||||
# collisions are handled at ingest time by parse-decide-workflow).
|
|
||||||
claim = s.query(Claim).filter(Claim.id == claim_id).first()
|
|
||||||
if claim is None:
|
if claim is None:
|
||||||
raise LookupError(f"claim {claim_id} not found")
|
raise LookupError(f"claim {claim_id} not found")
|
||||||
if claim.matched_remittance_id is not None:
|
if claim.matched_remittance_id is not None:
|
||||||
@@ -2052,7 +1992,7 @@ class CycloneStore:
|
|||||||
f"{claim.matched_remittance_id}"
|
f"{claim.matched_remittance_id}"
|
||||||
)
|
)
|
||||||
|
|
||||||
remit = s.query(Remittance).filter(Remittance.id == remit_id).first()
|
remit = s.get(Remittance, remit_id)
|
||||||
if remit is None:
|
if remit is None:
|
||||||
raise LookupError(f"remittance {remit_id} not found")
|
raise LookupError(f"remittance {remit_id} not found")
|
||||||
|
|
||||||
@@ -2091,12 +2031,6 @@ class CycloneStore:
|
|||||||
))
|
))
|
||||||
claim.state = new_state
|
claim.state = new_state
|
||||||
claim.matched_remittance_id = remit_id
|
claim.matched_remittance_id = remit_id
|
||||||
# Migration 0014: composite back-reference to remittances. The
|
|
||||||
# claim side needs both id and batch_id (we drop the SQL-level
|
|
||||||
# cross-table FK because SQLite has no ALTER CONSTRAINT;
|
|
||||||
# matched_remittance_id alone is not enough to look up the
|
|
||||||
# remittance under composite PK).
|
|
||||||
claim.matched_remittance_batch_id = remit.batch_id
|
|
||||||
# Symmetric FK update — see list_unmatched docstring for why
|
# Symmetric FK update — see list_unmatched docstring for why
|
||||||
# we don't rely on T10 to do this.
|
# we don't rely on T10 to do this.
|
||||||
remit.claim_id = claim_id
|
remit.claim_id = claim_id
|
||||||
@@ -2166,9 +2100,7 @@ class CycloneStore:
|
|||||||
6. Return ``{"claim": <ui>, "deletedMatches": <count>}``.
|
6. Return ``{"claim": <ui>, "deletedMatches": <count>}``.
|
||||||
"""
|
"""
|
||||||
with db.SessionLocal()() as s:
|
with db.SessionLocal()() as s:
|
||||||
# Migration 0014: see manual_match — public API takes only
|
claim = s.get(Claim, claim_id)
|
||||||
# claim_id; look up by id alone, first match wins.
|
|
||||||
claim = s.query(Claim).filter(Claim.id == claim_id).first()
|
|
||||||
if claim is None:
|
if claim is None:
|
||||||
raise LookupError(f"claim {claim_id} not found")
|
raise LookupError(f"claim {claim_id} not found")
|
||||||
if claim.matched_remittance_id is None:
|
if claim.matched_remittance_id is None:
|
||||||
@@ -2202,20 +2134,12 @@ class CycloneStore:
|
|||||||
|
|
||||||
claim.state = restored_state
|
claim.state = restored_state
|
||||||
claim.matched_remittance_id = None
|
claim.matched_remittance_id = None
|
||||||
# Migration 0014: clear the batch side of the composite
|
|
||||||
# back-reference too. Both columns must move together.
|
|
||||||
claim.matched_remittance_batch_id = None
|
|
||||||
# Clear the symmetric FK on the remittance so list_unmatched
|
# Clear the symmetric FK on the remittance so list_unmatched
|
||||||
# surfaces the pair again. The remittance may have been
|
# surfaces the pair again. The remittance may have been
|
||||||
# deleted between the match and this call — guard with a
|
# deleted between the match and this call — guard with a
|
||||||
# get() so we don't blow up on a stale FK.
|
# get() so we don't blow up on a stale FK.
|
||||||
if latest is not None:
|
if latest is not None:
|
||||||
# Migration 0014: composite PK — latest is a Match row
|
paired_remit = s.get(Remittance, latest.remittance_id)
|
||||||
# which has remittance_batch_id (the batch side of the FK).
|
|
||||||
paired_remit = s.get(
|
|
||||||
Remittance,
|
|
||||||
(latest.remittance_batch_id, latest.remittance_id),
|
|
||||||
)
|
|
||||||
if paired_remit is not None:
|
if paired_remit is not None:
|
||||||
paired_remit.claim_id = None
|
paired_remit.claim_id = None
|
||||||
|
|
||||||
|
|||||||
@@ -68,8 +68,7 @@ def test_999_set_rejection_moves_claim_to_rejected_state(client: TestClient):
|
|||||||
assert r.status_code == 200, r.text
|
assert r.status_code == 200, r.text
|
||||||
|
|
||||||
with db.SessionLocal()() as s:
|
with db.SessionLocal()() as s:
|
||||||
# Migration 0014: composite PK (batch_id, id).
|
c = s.get(Claim, "CLP-1")
|
||||||
c = s.get(Claim, ("B-1", "CLP-1"))
|
|
||||||
assert c.state == ClaimState.REJECTED
|
assert c.state == ClaimState.REJECTED
|
||||||
assert c.rejected_at is not None
|
assert c.rejected_at is not None
|
||||||
assert "999" in (c.rejection_reason or "")
|
assert "999" in (c.rejection_reason or "")
|
||||||
|
|||||||
@@ -51,21 +51,19 @@ def test_migration_0002_creates_acks_table():
|
|||||||
|
|
||||||
def test_migration_latest_idempotent_on_fresh_db():
|
def test_migration_latest_idempotent_on_fresh_db():
|
||||||
"""Re-running the migration on the same DB must be a no-op (PRAGMA
|
"""Re-running the migration on the same DB must be a no-op (PRAGMA
|
||||||
user_version already at the latest version — currently 14 after
|
user_version already at the latest version — currently 12 after
|
||||||
0004-0006 line_reconciliation, 0005 ta1_acks, SP9's 0007
|
0004-0006 line_reconciliation, 0005 ta1_acks, SP9's 0007
|
||||||
providers/payers/clearhouse, SP10's 0008 payer_rejected,
|
providers/payers/clearhouse, SP10's 0008 payer_rejected,
|
||||||
SP11's 0009 audit_log, SP14's 0010 payer_rejected_acknowledged,
|
SP11's 0009 audit_log, SP14's 0010 payer_rejected_acknowledged,
|
||||||
SP16's 0011 processed_inbound_files, SP17's 0012 db_backups,
|
SP16's 0011 processed_inbound_files, SP17's 0012 db_backups)."""
|
||||||
SP19's 0013 drop_claims_unique_constraint, SP20's 0014
|
|
||||||
relax_claims_remits_pk)."""
|
|
||||||
with db.engine().begin() as c:
|
with db.engine().begin() as c:
|
||||||
v1 = c.exec_driver_sql("PRAGMA user_version").scalar() or 0
|
v1 = c.exec_driver_sql("PRAGMA user_version").scalar() or 0
|
||||||
assert v1 == 14
|
assert v1 == 12
|
||||||
# A second run should not raise and should not bump the version.
|
# A second run should not raise and should not bump the version.
|
||||||
db_migrate.run(db.engine())
|
db_migrate.run(db.engine())
|
||||||
with db.engine().begin() as c:
|
with db.engine().begin() as c:
|
||||||
v2 = c.exec_driver_sql("PRAGMA user_version").scalar() or 0
|
v2 = c.exec_driver_sql("PRAGMA user_version").scalar() or 0
|
||||||
assert v2 == 14
|
assert v2 == 12
|
||||||
|
|
||||||
|
|
||||||
def test_add_ack_persists_row():
|
def test_add_ack_persists_row():
|
||||||
|
|||||||
@@ -98,10 +98,8 @@ class TestParse277CAEndpointHappyPath:
|
|||||||
files={"file": ("minimal_277ca.txt", text, "text/plain")},
|
files={"file": ("minimal_277ca.txt", text, "text/plain")},
|
||||||
)
|
)
|
||||||
with db.SessionLocal()() as s:
|
with db.SessionLocal()() as s:
|
||||||
# Migration 0014: composite PK (batch_id, id). The _seed_claim
|
c1 = s.get(Claim, "c1")
|
||||||
# helper uses batch_id="BATCH-1".
|
c2 = s.get(Claim, "c2")
|
||||||
c1 = s.get(Claim, ("BATCH-1", "c1"))
|
|
||||||
c2 = s.get(Claim, ("BATCH-1", "c2"))
|
|
||||||
assert c1.payer_rejected_at is None
|
assert c1.payer_rejected_at is None
|
||||||
assert c2.payer_rejected_at is not None
|
assert c2.payer_rejected_at is not None
|
||||||
assert c2.payer_rejected_status_code == "A6"
|
assert c2.payer_rejected_status_code == "A6"
|
||||||
|
|||||||
@@ -357,60 +357,3 @@ def test_prodfile_cross_pipeline_reconciles(client: TestClient):
|
|||||||
assert claim_rows >= len(unique_837_pcns), (
|
assert claim_rows >= len(unique_837_pcns), (
|
||||||
f"claim_rows {claim_rows} < unique_837_pcns {len(unique_837_pcns)}"
|
f"claim_rows {claim_rows} < unique_837_pcns {len(unique_837_pcns)}"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def test_409_response_includes_existing_batch_id_for_837(client: TestClient) -> None:
|
|
||||||
"""When a CLM01 already exists in a prior batch, the 409 body has existing_batch_id."""
|
|
||||||
from datetime import datetime, timezone
|
|
||||||
|
|
||||||
from cyclone import db as _db
|
|
||||||
from cyclone.db import Batch, Claim
|
|
||||||
|
|
||||||
# Seed a prior batch with claim CLM-X.
|
|
||||||
with _db.SessionLocal()() as s:
|
|
||||||
s.add(Batch(
|
|
||||||
id="PRIOR", kind="837p", input_filename="prior.txt",
|
|
||||||
parsed_at=datetime(2026, 1, 1, tzinfo=timezone.utc),
|
|
||||||
raw_result_json={},
|
|
||||||
))
|
|
||||||
s.add(Claim(id="CLM-X", batch_id="PRIOR", patient_control_number="M"))
|
|
||||||
s.commit()
|
|
||||||
|
|
||||||
# Build a file with two CLM* segments both using CLM-X (forces PK collision).
|
|
||||||
text = (
|
|
||||||
"ISA*00* *00* *ZZ*SUBMITTERID *ZZ*RECEIVERID "
|
|
||||||
"*240101*1200*^*00501*000000001*0*P*:~\n"
|
|
||||||
"GS*HC*SUBMITTERID*RECEIVERID*20240101*1200*1*X*005010X222A1~\n"
|
|
||||||
"ST*837*0001*005010X222A1~\n"
|
|
||||||
"BHT*0019*00*1*20240101*1200*CH~\n"
|
|
||||||
"NM1*41*2*SUBMITTER*****46*SUBMITTERID~\n"
|
|
||||||
"PER*IC*CONTACT*TE*5555555555~\n"
|
|
||||||
"NM1*40*2*RECEIVER*****46*RECEIVERID~\n"
|
|
||||||
"HL*1**20*1~\n"
|
|
||||||
"NM1*85*2*BILLING*****XX*1881068062~\n"
|
|
||||||
"N3*123 MAIN*\nN4*DENVER*CO*80202~\n"
|
|
||||||
"REF*EI*123456789~\n"
|
|
||||||
"HL*2*1*22*0~\n"
|
|
||||||
"SBR*P*18*******CI~\n"
|
|
||||||
"NM1*IL*1*DOE*JOHN****MI*M~\n"
|
|
||||||
"N3*456 ELM*\nN4*DENVER*CO*80202~\n"
|
|
||||||
"DMG*D8*19700101*M~\n"
|
|
||||||
"NM1*PR*2*MEDICAID*****PI*MCD~\n"
|
|
||||||
"CLM*CLM-X*100***11:B:1*Y*A*Y*Y~\n"
|
|
||||||
"LX*1~\nSV1*HC:99213*100*UN*1***1~\n"
|
|
||||||
"DTP*472*D8*20240101~\n"
|
|
||||||
"CLM*CLM-X*100***11:B:1*Y*A*Y*Y~\n"
|
|
||||||
"LX*2~\nSV1*HC:99213*100*UN*1***1~\n"
|
|
||||||
"DTP*472*D8*20240101~\n"
|
|
||||||
"SE*30*0001~\n"
|
|
||||||
"GE*1*1~\n"
|
|
||||||
"IEA*1*000000001~\n"
|
|
||||||
)
|
|
||||||
resp = client.post(
|
|
||||||
"/api/parse-837",
|
|
||||||
files={"file": ("dup.txt", text, "text/plain")},
|
|
||||||
headers={"Accept": "application/json"},
|
|
||||||
)
|
|
||||||
assert resp.status_code == 409, resp.text
|
|
||||||
body = resp.json()
|
|
||||||
assert body.get("existing_batch_id") == "PRIOR"
|
|
||||||
|
|||||||
@@ -70,9 +70,7 @@ class TestApply277CARejectionsHappyPath:
|
|||||||
assert outcome.matched == ["c1"]
|
assert outcome.matched == ["c1"]
|
||||||
assert outcome.orphans == []
|
assert outcome.orphans == []
|
||||||
with db.SessionLocal()() as s:
|
with db.SessionLocal()() as s:
|
||||||
# Migration 0014: composite PK; only one claim with id "c1"
|
c = s.get(Claim, "c1")
|
||||||
# exists in this test, so filter-by-id returns it.
|
|
||||||
c = s.query(Claim).filter(Claim.id == "c1").first()
|
|
||||||
assert c.payer_rejected_at is not None
|
assert c.payer_rejected_at is not None
|
||||||
assert c.payer_rejected_status_code == "A6"
|
assert c.payer_rejected_status_code == "A6"
|
||||||
assert "A6" in (c.payer_rejected_reason or "")
|
assert "A6" in (c.payer_rejected_reason or "")
|
||||||
@@ -118,16 +116,14 @@ class TestApply277CARejectionsIdempotent:
|
|||||||
return s.query(Claim).filter_by(patient_control_number="CLAIM001").first()
|
return s.query(Claim).filter_by(patient_control_number="CLAIM001").first()
|
||||||
outcome1 = apply_277ca_rejections(s, result, claim_lookup=_lookup, two77ca_id="ACK-1")
|
outcome1 = apply_277ca_rejections(s, result, claim_lookup=_lookup, two77ca_id="ACK-1")
|
||||||
assert outcome1.matched == ["c1"]
|
assert outcome1.matched == ["c1"]
|
||||||
# Migration 0014: composite PK — see test_rejected_status_stamps_matching_claim.
|
original_reason = s.get(Claim, "c1").payer_rejected_reason
|
||||||
original_reason = s.query(Claim).filter(Claim.id == "c1").first().payer_rejected_reason
|
original_at = s.get(Claim, "c1").payer_rejected_at
|
||||||
original_at = s.query(Claim).filter(Claim.id == "c1").first().payer_rejected_at
|
|
||||||
# Run again with same code.
|
# Run again with same code.
|
||||||
outcome2 = apply_277ca_rejections(s, result, claim_lookup=_lookup, two77ca_id="ACK-1")
|
outcome2 = apply_277ca_rejections(s, result, claim_lookup=_lookup, two77ca_id="ACK-1")
|
||||||
assert outcome2.matched == []
|
assert outcome2.matched == []
|
||||||
assert outcome2.already_rejected == ["c1"]
|
assert outcome2.already_rejected == ["c1"]
|
||||||
with db.SessionLocal()() as s:
|
with db.SessionLocal()() as s:
|
||||||
# Migration 0014: see above — composite PK.
|
c = s.get(Claim, "c1")
|
||||||
c = s.query(Claim).filter(Claim.id == "c1").first()
|
|
||||||
# Reason and timestamp unchanged.
|
# Reason and timestamp unchanged.
|
||||||
assert c.payer_rejected_reason == original_reason
|
assert c.payer_rejected_reason == original_reason
|
||||||
assert c.payer_rejected_at == original_at
|
assert c.payer_rejected_at == original_at
|
||||||
@@ -147,8 +143,7 @@ class TestApply277CAOnlyRejectsRejected:
|
|||||||
outcome = apply_277ca_rejections(s, result, claim_lookup=_lookup, two77ca_id="ACK-1")
|
outcome = apply_277ca_rejections(s, result, claim_lookup=_lookup, two77ca_id="ACK-1")
|
||||||
assert outcome.matched == []
|
assert outcome.matched == []
|
||||||
with db.SessionLocal()() as s:
|
with db.SessionLocal()() as s:
|
||||||
# Migration 0014: composite PK.
|
c = s.get(Claim, "c1")
|
||||||
c = s.query(Claim).filter(Claim.id == "c1").first()
|
|
||||||
assert c.payer_rejected_at is None
|
assert c.payer_rejected_at is None
|
||||||
|
|
||||||
|
|
||||||
@@ -188,12 +183,9 @@ class TestApply277CAMultipleStatuses:
|
|||||||
outcome = apply_277ca_rejections(s, result, claim_lookup=_lookup, two77ca_id="ACK-1")
|
outcome = apply_277ca_rejections(s, result, claim_lookup=_lookup, two77ca_id="ACK-1")
|
||||||
assert outcome.matched == ["c2"]
|
assert outcome.matched == ["c2"]
|
||||||
with db.SessionLocal()() as s:
|
with db.SessionLocal()() as s:
|
||||||
# Migration 0014: composite PK — filter-by-id returns the
|
assert s.get(Claim, "c1").payer_rejected_at is None
|
||||||
# single claim with that id (no cross-batch duplicates in
|
assert s.get(Claim, "c2").payer_rejected_at is not None
|
||||||
# this test fixture).
|
assert s.get(Claim, "c3").payer_rejected_at is None
|
||||||
assert s.query(Claim).filter(Claim.id == "c1").first().payer_rejected_at is None
|
|
||||||
assert s.query(Claim).filter(Claim.id == "c2").first().payer_rejected_at is not None
|
|
||||||
assert s.query(Claim).filter(Claim.id == "c3").first().payer_rejected_at is None
|
|
||||||
|
|
||||||
|
|
||||||
# --------------------------------------------------------------------------- #
|
# --------------------------------------------------------------------------- #
|
||||||
|
|||||||
@@ -2,7 +2,6 @@
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from datetime import datetime, timezone
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
@@ -114,543 +113,3 @@ def test_run_ignores_non_sql_files(
|
|||||||
).all()
|
).all()
|
||||||
assert len(rows) == 0
|
assert len(rows) == 0
|
||||||
assert _user_version(engine) == 1
|
assert _user_version(engine) == 1
|
||||||
|
|
||||||
|
|
||||||
def test_drop_claims_unique_constraint_migration(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
|
||||||
"""Migration 0013 must drop the inline UNIQUE(batch_id, patient_control_number)
|
|
||||||
on claims by recreating the table. Idempotent and preserves data.
|
|
||||||
|
|
||||||
We copy the real 0013 file (so this test exercises the real migration, not
|
|
||||||
a synthetic one) and stub 0001-0012 with a synthetic 0001 that has the
|
|
||||||
historical inline UNIQUE. If 0013 doesn't exist as a real file, the runner
|
|
||||||
will leave user_version at 1 and the test will fail.
|
|
||||||
"""
|
|
||||||
monkeypatch.setattr(db_migrate, "MIGRATIONS_DIR", tmp_path)
|
|
||||||
|
|
||||||
# Synthetic 0001: claims with INLINE UNIQUE(batch_id, patient_control_number).
|
|
||||||
# The schema mirrors the real 0001 column shape so 0013's
|
|
||||||
# ``INSERT INTO claims_new SELECT * FROM claims`` works against this fixture.
|
|
||||||
(tmp_path / "0001_initial.sql").write_text(
|
|
||||||
"-- version: 1\n"
|
|
||||||
"CREATE TABLE batches (id TEXT PRIMARY KEY, kind TEXT NOT NULL, "
|
|
||||||
"input_filename TEXT NOT NULL, parsed_at DATETIME NOT NULL);\n"
|
|
||||||
"CREATE TABLE claims ("
|
|
||||||
"id TEXT PRIMARY KEY, batch_id TEXT NOT NULL REFERENCES batches(id) ON DELETE CASCADE,"
|
|
||||||
"patient_control_number TEXT NOT NULL, service_date_from DATE, service_date_to DATE,"
|
|
||||||
"charge_amount NUMERIC(12, 2) NOT NULL DEFAULT 0, provider_npi TEXT, payer_id TEXT,"
|
|
||||||
"state TEXT NOT NULL DEFAULT 'submitted', state_before_reversal TEXT,"
|
|
||||||
"matched_remittance_id TEXT REFERENCES remittances(id), raw_json TEXT,"
|
|
||||||
"rejection_reason TEXT, rejected_at TIMESTAMP, resubmit_count INTEGER NOT NULL DEFAULT 0,"
|
|
||||||
"state_changed_at TIMESTAMP, payer_rejected_at TEXT, payer_rejected_reason TEXT,"
|
|
||||||
"payer_rejected_status_code TEXT, payer_rejected_by_277ca_id TEXT,"
|
|
||||||
"payer_rejected_acknowledged_at TEXT, payer_rejected_acknowledged_actor TEXT,"
|
|
||||||
"UNIQUE (batch_id, patient_control_number));\n"
|
|
||||||
)
|
|
||||||
|
|
||||||
# Copy the REAL 0013 from the source tree. If it's missing, the test fails
|
|
||||||
# because user_version will stay at 1 and we'll never get to assert it == 13.
|
|
||||||
real_migrations = Path(db_migrate.__file__).parent / "migrations"
|
|
||||||
real_0013 = real_migrations / "0013_drop_claims_unique_constraint.sql"
|
|
||||||
assert real_0013.exists(), (
|
|
||||||
f"Real migration 0013 missing at {real_0013} — this test is meant to "
|
|
||||||
f"exercise the real migration, not a synthetic one."
|
|
||||||
)
|
|
||||||
(tmp_path / "0013_drop_claims_unique_constraint.sql").write_text(real_0013.read_text())
|
|
||||||
|
|
||||||
engine = _fresh_engine(tmp_path)
|
|
||||||
db_migrate.run(engine)
|
|
||||||
assert _user_version(engine) == 13, (
|
|
||||||
"0013 did not apply; user_version did not reach 13. Either the migration "
|
|
||||||
"file is missing, or it has a syntax error."
|
|
||||||
)
|
|
||||||
|
|
||||||
# Before-0013 assertion: with the inline UNIQUE, two claims sharing
|
|
||||||
# (batch_id, patient_control_number) on the same batch must fail. The
|
|
||||||
# recreation must remove that constraint, so this insert must now succeed.
|
|
||||||
with engine.begin() as c:
|
|
||||||
c.exec_driver_sql(
|
|
||||||
"INSERT INTO batches(id, kind, input_filename, parsed_at) "
|
|
||||||
"VALUES ('b1', '837p', 'x.txt', '2026-01-01')"
|
|
||||||
)
|
|
||||||
c.exec_driver_sql(
|
|
||||||
"INSERT INTO claims(id, batch_id, patient_control_number) VALUES ('c1', 'b1', 'M')"
|
|
||||||
)
|
|
||||||
c.exec_driver_sql(
|
|
||||||
"INSERT INTO claims(id, batch_id, patient_control_number) VALUES ('c2', 'b1', 'M')"
|
|
||||||
)
|
|
||||||
ids = [r[0] for r in c.exec_driver_sql("SELECT id FROM claims ORDER BY id").all()]
|
|
||||||
assert ids == ["c1", "c2"], (
|
|
||||||
"Both claims with same (batch_id, patient_control_number) must persist "
|
|
||||||
"after 0013 — the UNIQUE(batch_id, patient_control_number) is gone."
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def test_migration_0013_is_idempotent(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
|
||||||
"""Re-running db_migrate.run on a v13 DB is a no-op."""
|
|
||||||
monkeypatch.setattr(db_migrate, "MIGRATIONS_DIR", tmp_path)
|
|
||||||
|
|
||||||
# Use synthetic 0001 + copy the real 0013 so we exercise the real file.
|
|
||||||
(tmp_path / "0001_initial.sql").write_text(
|
|
||||||
"-- version: 1\n"
|
|
||||||
"CREATE TABLE batches (id TEXT PRIMARY KEY, kind TEXT NOT NULL, "
|
|
||||||
"input_filename TEXT NOT NULL, parsed_at DATETIME NOT NULL);\n"
|
|
||||||
"CREATE TABLE claims ("
|
|
||||||
"id TEXT PRIMARY KEY, batch_id TEXT NOT NULL REFERENCES batches(id) ON DELETE CASCADE,"
|
|
||||||
"patient_control_number TEXT NOT NULL, service_date_from DATE, service_date_to DATE,"
|
|
||||||
"charge_amount NUMERIC(12, 2) NOT NULL DEFAULT 0, provider_npi TEXT, payer_id TEXT,"
|
|
||||||
"state TEXT NOT NULL DEFAULT 'submitted', state_before_reversal TEXT,"
|
|
||||||
"matched_remittance_id TEXT REFERENCES remittances(id), raw_json TEXT,"
|
|
||||||
"rejection_reason TEXT, rejected_at TIMESTAMP, resubmit_count INTEGER NOT NULL DEFAULT 0,"
|
|
||||||
"state_changed_at TIMESTAMP, payer_rejected_at TEXT, payer_rejected_reason TEXT,"
|
|
||||||
"payer_rejected_status_code TEXT, payer_rejected_by_277ca_id TEXT,"
|
|
||||||
"payer_rejected_acknowledged_at TEXT, payer_rejected_acknowledged_actor TEXT,"
|
|
||||||
"UNIQUE (batch_id, patient_control_number));\n"
|
|
||||||
)
|
|
||||||
real_migrations = Path(db_migrate.__file__).parent / "migrations"
|
|
||||||
real_0013 = real_migrations / "0013_drop_claims_unique_constraint.sql"
|
|
||||||
assert real_0013.exists(), "Real 0013 missing — test cannot exercise it."
|
|
||||||
(tmp_path / "0013_drop_claims_unique_constraint.sql").write_text(real_0013.read_text())
|
|
||||||
|
|
||||||
engine = _fresh_engine(tmp_path)
|
|
||||||
db_migrate.run(engine) # applies both
|
|
||||||
version_after_first = _user_version(engine)
|
|
||||||
assert version_after_first == 13
|
|
||||||
db_migrate.run(engine) # second call: no-op
|
|
||||||
assert _user_version(engine) == version_after_first
|
|
||||||
|
|
||||||
|
|
||||||
# =============================================================================
|
|
||||||
# Migration 0014: relax claims/remittances PK to composite (batch_id, id)
|
|
||||||
# =============================================================================
|
|
||||||
|
|
||||||
|
|
||||||
# Synthetic 0001 with the FULL post-0013 column shape, so 0014's
|
|
||||||
# INSERT...SELECT statements find every column they expect on the source
|
|
||||||
# tables. Mirrors the shape after 0001 + 0004 + 0006 + 0008 + 0010 + 0013.
|
|
||||||
# The tables and columns here are exactly what 0014 reads from; we do NOT
|
|
||||||
# add migrations 0002..0012 because they're orthogonal to the FK chain
|
|
||||||
# 0014 walks (no claims/remittances FKs to acks, audit_log, etc).
|
|
||||||
_0014_SYNTHETIC_0001 = (
|
|
||||||
"-- version: 1\n"
|
|
||||||
"CREATE TABLE batches ("
|
|
||||||
"id TEXT PRIMARY KEY, kind TEXT NOT NULL, input_filename TEXT NOT NULL,"
|
|
||||||
"parsed_at DATETIME NOT NULL, totals_json TEXT, validation_json TEXT,"
|
|
||||||
"raw_result_json TEXT);\n"
|
|
||||||
"CREATE TABLE claims ("
|
|
||||||
"id TEXT PRIMARY KEY, batch_id TEXT NOT NULL REFERENCES batches(id) ON DELETE CASCADE,"
|
|
||||||
"patient_control_number TEXT NOT NULL, service_date_from DATE, service_date_to DATE,"
|
|
||||||
"charge_amount NUMERIC(12, 2) NOT NULL DEFAULT 0, provider_npi TEXT, payer_id TEXT,"
|
|
||||||
"state TEXT NOT NULL DEFAULT 'submitted', state_before_reversal TEXT,"
|
|
||||||
"matched_remittance_id TEXT REFERENCES remittances(id), raw_json TEXT,"
|
|
||||||
"rejection_reason TEXT, rejected_at TIMESTAMP,"
|
|
||||||
"resubmit_count INTEGER NOT NULL DEFAULT 0, state_changed_at TIMESTAMP,"
|
|
||||||
"payer_rejected_at TEXT, payer_rejected_reason TEXT,"
|
|
||||||
"payer_rejected_status_code TEXT, payer_rejected_by_277ca_id TEXT,"
|
|
||||||
"payer_rejected_acknowledged_at TEXT, payer_rejected_acknowledged_actor TEXT);\n"
|
|
||||||
"CREATE TABLE remittances ("
|
|
||||||
"id TEXT PRIMARY KEY, batch_id TEXT NOT NULL REFERENCES batches(id) ON DELETE CASCADE,"
|
|
||||||
"payer_claim_control_number TEXT NOT NULL, claim_id TEXT REFERENCES claims(id),"
|
|
||||||
"status_code TEXT NOT NULL, status_label TEXT,"
|
|
||||||
"total_charge NUMERIC(12, 2) NOT NULL DEFAULT 0,"
|
|
||||||
"total_paid NUMERIC(12, 2) NOT NULL DEFAULT 0,"
|
|
||||||
"patient_responsibility NUMERIC(12, 2),"
|
|
||||||
"adjustment_amount NUMERIC(12, 2) NOT NULL DEFAULT 0,"
|
|
||||||
"claim_level_adjustment_amount NUMERIC(12, 2) NOT NULL DEFAULT 0,"
|
|
||||||
"received_at DATETIME NOT NULL, service_date DATE,"
|
|
||||||
"is_reversal INTEGER NOT NULL DEFAULT 0, raw_json TEXT);\n"
|
|
||||||
"CREATE TABLE matches ("
|
|
||||||
"id INTEGER PRIMARY KEY AUTOINCREMENT,"
|
|
||||||
"claim_id TEXT NOT NULL REFERENCES claims(id) ON DELETE CASCADE,"
|
|
||||||
"remittance_id TEXT NOT NULL REFERENCES remittances(id) ON DELETE CASCADE,"
|
|
||||||
"strategy TEXT NOT NULL, matched_at DATETIME NOT NULL,"
|
|
||||||
"prior_claim_state TEXT, is_reversal INTEGER NOT NULL DEFAULT 0);\n"
|
|
||||||
"CREATE TABLE cas_adjustments ("
|
|
||||||
"id INTEGER PRIMARY KEY AUTOINCREMENT,"
|
|
||||||
"remittance_id TEXT NOT NULL REFERENCES remittances(id) ON DELETE CASCADE,"
|
|
||||||
"group_code TEXT NOT NULL, reason_code TEXT NOT NULL,"
|
|
||||||
"amount NUMERIC(12, 2) NOT NULL, quantity NUMERIC(10, 2),"
|
|
||||||
"service_line_payment_id INTEGER REFERENCES service_line_payments(id) ON DELETE SET NULL);\n"
|
|
||||||
"CREATE TABLE service_line_payments ("
|
|
||||||
"id INTEGER PRIMARY KEY AUTOINCREMENT,"
|
|
||||||
"remittance_id VARCHAR(64) NOT NULL REFERENCES remittances(id) ON DELETE CASCADE,"
|
|
||||||
"line_number INTEGER NOT NULL, procedure_qualifier VARCHAR(4) NOT NULL,"
|
|
||||||
"procedure_code VARCHAR(16) NOT NULL,"
|
|
||||||
"modifiers_json TEXT NOT NULL DEFAULT '[]',"
|
|
||||||
"charge NUMERIC(12, 2) NOT NULL, payment NUMERIC(12, 2) NOT NULL,"
|
|
||||||
"units NUMERIC(10, 2), unit_type VARCHAR(8), service_date DATE,"
|
|
||||||
"ref_benefit_plan VARCHAR(64),"
|
|
||||||
"superseded_by_id INTEGER REFERENCES service_line_payments(id) ON DELETE SET NULL);\n"
|
|
||||||
"CREATE TABLE line_reconciliations ("
|
|
||||||
"id INTEGER PRIMARY KEY AUTOINCREMENT,"
|
|
||||||
"claim_id VARCHAR(64) NOT NULL REFERENCES claims(id) ON DELETE CASCADE,"
|
|
||||||
"claim_service_line_number INTEGER,"
|
|
||||||
"service_line_payment_id INTEGER REFERENCES service_line_payments(id) ON DELETE SET NULL,"
|
|
||||||
"status VARCHAR(16) NOT NULL, match_score INTEGER, reconciled_at TIMESTAMP NOT NULL);\n"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _apply_0014_only(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> sa.Engine:
|
|
||||||
"""Apply just 0014 (with a synthetic 0001 that has the post-0013 column
|
|
||||||
shape) on a fresh engine. Returns the engine with user_version=14.
|
|
||||||
"""
|
|
||||||
monkeypatch.setattr(db_migrate, "MIGRATIONS_DIR", tmp_path)
|
|
||||||
(tmp_path / "0001_initial.sql").write_text(_0014_SYNTHETIC_0001)
|
|
||||||
|
|
||||||
real_migrations = Path(db_migrate.__file__).parent / "migrations"
|
|
||||||
real_0014 = real_migrations / "0014_relax_claims_remits_pk.sql"
|
|
||||||
assert real_0014.exists(), (
|
|
||||||
f"Real migration 0014 missing at {real_0014} — these tests are "
|
|
||||||
f"meant to exercise the real migration, not a synthetic one."
|
|
||||||
)
|
|
||||||
(tmp_path / "0014_relax_claims_remits_pk.sql").write_text(real_0014.read_text())
|
|
||||||
|
|
||||||
engine = _fresh_engine(tmp_path)
|
|
||||||
db_migrate.run(engine)
|
|
||||||
assert _user_version(engine) == 14, (
|
|
||||||
"0014 did not apply; user_version did not reach 14. Either the "
|
|
||||||
"migration file is missing, or it has a syntax error."
|
|
||||||
)
|
|
||||||
return engine
|
|
||||||
|
|
||||||
|
|
||||||
def test_migration_0014_relaxes_claims_pk_to_composite(
|
|
||||||
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
|
||||||
) -> None:
|
|
||||||
"""Migration 0014 changes claims PK from single-column id to (batch_id, id).
|
|
||||||
|
|
||||||
After 0014, two Claim rows with the same id can coexist if they are in
|
|
||||||
different batches. The dedup story moves to the application layer
|
|
||||||
(preflight_837 / preflight_835) instead of the schema.
|
|
||||||
"""
|
|
||||||
engine = _apply_0014_only(tmp_path, monkeypatch)
|
|
||||||
|
|
||||||
# After 0014: same id in two different batches must coexist.
|
|
||||||
with engine.begin() as c:
|
|
||||||
c.exec_driver_sql(
|
|
||||||
"INSERT INTO batches(id, kind, input_filename, parsed_at) "
|
|
||||||
"VALUES ('B1', '837p', 'b1.txt', '2026-01-01')"
|
|
||||||
)
|
|
||||||
c.exec_driver_sql(
|
|
||||||
"INSERT INTO batches(id, kind, input_filename, parsed_at) "
|
|
||||||
"VALUES ('B2', '837p', 'b2.txt', '2026-01-02')"
|
|
||||||
)
|
|
||||||
c.exec_driver_sql(
|
|
||||||
"INSERT INTO claims(id, batch_id, patient_control_number, "
|
|
||||||
"state) VALUES ('CLM-A', 'B1', 'M1', 'submitted')"
|
|
||||||
)
|
|
||||||
c.exec_driver_sql(
|
|
||||||
"INSERT INTO claims(id, batch_id, patient_control_number, "
|
|
||||||
"state) VALUES ('CLM-A', 'B2', 'M1', 'submitted')"
|
|
||||||
)
|
|
||||||
rows = c.exec_driver_sql(
|
|
||||||
"SELECT id, batch_id FROM claims WHERE id='CLM-A' ORDER BY batch_id"
|
|
||||||
).all()
|
|
||||||
assert rows == [("CLM-A", "B1"), ("CLM-A", "B2")], (
|
|
||||||
"After 0014, the same claims.id in two different batches must "
|
|
||||||
"coexist. Composite PK is (batch_id, id)."
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def test_migration_0014_relaxes_remittances_pk_to_composite(
|
|
||||||
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
|
||||||
) -> None:
|
|
||||||
"""Same shape for remittances: same CLP01 can exist in two batches."""
|
|
||||||
engine = _apply_0014_only(tmp_path, monkeypatch)
|
|
||||||
|
|
||||||
with engine.begin() as c:
|
|
||||||
c.exec_driver_sql(
|
|
||||||
"INSERT INTO batches(id, kind, input_filename, parsed_at) "
|
|
||||||
"VALUES ('B1', '835', 'b1.txt', '2026-01-01')"
|
|
||||||
)
|
|
||||||
c.exec_driver_sql(
|
|
||||||
"INSERT INTO batches(id, kind, input_filename, parsed_at) "
|
|
||||||
"VALUES ('B2', '835', 'b2.txt', '2026-01-02')"
|
|
||||||
)
|
|
||||||
c.exec_driver_sql(
|
|
||||||
"INSERT INTO remittances(id, batch_id, payer_claim_control_number, "
|
|
||||||
"status_code, received_at) "
|
|
||||||
"VALUES ('CLP-A', 'B1', 'CLP-A', '1', '2026-01-01')"
|
|
||||||
)
|
|
||||||
c.exec_driver_sql(
|
|
||||||
"INSERT INTO remittances(id, batch_id, payer_claim_control_number, "
|
|
||||||
"status_code, received_at) "
|
|
||||||
"VALUES ('CLP-A', 'B2', 'CLP-A', '1', '2026-01-02')"
|
|
||||||
)
|
|
||||||
rows = c.exec_driver_sql(
|
|
||||||
"SELECT id, batch_id FROM remittances WHERE id='CLP-A' ORDER BY batch_id"
|
|
||||||
).all()
|
|
||||||
assert rows == [("CLP-A", "B1"), ("CLP-A", "B2")], (
|
|
||||||
"After 0014, the same remittances.id in two different batches must "
|
|
||||||
"coexist. Composite PK is (batch_id, id)."
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def test_migration_0014_preserves_existing_data(tmp_path, monkeypatch):
|
|
||||||
"""Data inserted at v=13 must survive the v=14 table recreation.
|
|
||||||
|
|
||||||
0014 uses ``INSERT INTO claims_new SELECT * FROM claims`` (and the
|
|
||||||
remittance-side equivalent) to populate the recreated tables. Any rows
|
|
||||||
present before 0014 must still be there after 0014 finishes — that is
|
|
||||||
the contract that lets the schema change ship without data loss.
|
|
||||||
|
|
||||||
This test exercises the real 0014 against real, pre-existing rows:
|
|
||||||
1. Apply 0001-0013 via ``migrate_to_version``.
|
|
||||||
2. Seed a claim and a remittance in v=13 (raw SQL — the ORM model
|
|
||||||
declares ``matched_remittance_batch_id`` which doesn't exist at v=13).
|
|
||||||
3. Apply 0014 via ``migrate_to_version``.
|
|
||||||
4. Verify the seeded rows survive, with the same id and batch_id.
|
|
||||||
5. Verify the SAME id can be inserted in a DIFFERENT batch after 0014
|
|
||||||
(proving the composite PK took effect — a single-column PK would
|
|
||||||
have rejected the second insert).
|
|
||||||
|
|
||||||
Uses a fresh ``sa.Engine`` (not the module-global ``db.engine()``)
|
|
||||||
because the conftest autouse fixture has already initialized the
|
|
||||||
module engine at v=14 — we need a clean engine that starts at v=0
|
|
||||||
so ``migrate_to_version(13)`` actually applies 0001..0013.
|
|
||||||
"""
|
|
||||||
from sqlalchemy.orm import sessionmaker
|
|
||||||
|
|
||||||
engine = _fresh_engine(tmp_path)
|
|
||||||
SessionLocal = sessionmaker(bind=engine, autoflush=False, autocommit=False, expire_on_commit=False)
|
|
||||||
|
|
||||||
from cyclone.db_migrate import migrate_to_version
|
|
||||||
from cyclone.db import Batch, Claim
|
|
||||||
|
|
||||||
# Apply 0001-0013 only.
|
|
||||||
migrate_to_version(engine, 13)
|
|
||||||
assert _user_version(engine) == 13
|
|
||||||
|
|
||||||
# Seed a claim and a remittance in v=13. We use raw SQL here because
|
|
||||||
# the ORM Claim/Remittance models declare post-0014 columns
|
|
||||||
# (``matched_remittance_batch_id``) that don't exist at v=13. The
|
|
||||||
# migration's data-preservation contract is independent of how the
|
|
||||||
# data got there — raw SQL inserts count as "data that needs to
|
|
||||||
# survive the migration" just like ORM inserts do.
|
|
||||||
with engine.begin() as c:
|
|
||||||
c.exec_driver_sql(
|
|
||||||
"INSERT INTO batches(id, kind, input_filename, parsed_at) "
|
|
||||||
"VALUES ('B-V13', '837p', 'x.txt', '2026-01-01')"
|
|
||||||
)
|
|
||||||
c.exec_driver_sql(
|
|
||||||
"INSERT INTO claims(id, batch_id, patient_control_number, "
|
|
||||||
"state, state_changed_at) VALUES "
|
|
||||||
"('CLM-PRESERVED', 'B-V13', 'M', 'submitted', '2026-01-01')"
|
|
||||||
)
|
|
||||||
c.exec_driver_sql(
|
|
||||||
"INSERT INTO batches(id, kind, input_filename, parsed_at) "
|
|
||||||
"VALUES ('B-V13R', '835', 'x.txt', '2026-01-02')"
|
|
||||||
)
|
|
||||||
c.exec_driver_sql(
|
|
||||||
"INSERT INTO remittances(id, batch_id, payer_claim_control_number, "
|
|
||||||
"status_code, received_at) VALUES "
|
|
||||||
"('CLP-PRESERVED', 'B-V13R', 'CLP-PRESERVED', '1', '2026-01-02')"
|
|
||||||
)
|
|
||||||
|
|
||||||
# Apply 0014. Data must survive.
|
|
||||||
migrate_to_version(engine, 14)
|
|
||||||
assert _user_version(engine) == 14
|
|
||||||
|
|
||||||
with engine.connect() as c:
|
|
||||||
claim = c.exec_driver_sql(
|
|
||||||
"SELECT id, batch_id, patient_control_number "
|
|
||||||
"FROM claims WHERE id='CLM-PRESERVED'"
|
|
||||||
).first()
|
|
||||||
remit = c.exec_driver_sql(
|
|
||||||
"SELECT id, batch_id, payer_claim_control_number "
|
|
||||||
"FROM remittances WHERE id='CLP-PRESERVED'"
|
|
||||||
).first()
|
|
||||||
assert claim == ("CLM-PRESERVED", "B-V13", "M")
|
|
||||||
assert remit == ("CLP-PRESERVED", "B-V13R", "CLP-PRESERVED")
|
|
||||||
|
|
||||||
# Confirm that AFTER v=14, the same id can be inserted in a different
|
|
||||||
# batch via the ORM (now that the composite PK exists, this is the
|
|
||||||
# canonical insert path).
|
|
||||||
with SessionLocal() as s:
|
|
||||||
s.add(Batch(id="B-V14", kind="837p", input_filename="y.txt",
|
|
||||||
parsed_at=datetime(2026, 1, 3, tzinfo=timezone.utc),
|
|
||||||
raw_result_json={}))
|
|
||||||
s.add(Claim(id="CLM-PRESERVED", batch_id="B-V14",
|
|
||||||
patient_control_number="M2",
|
|
||||||
state_changed_at=datetime(2026, 1, 3, tzinfo=timezone.utc)))
|
|
||||||
s.commit()
|
|
||||||
|
|
||||||
with engine.connect() as c:
|
|
||||||
rows = c.exec_driver_sql(
|
|
||||||
"SELECT batch_id FROM claims WHERE id='CLM-PRESERVED' ORDER BY batch_id"
|
|
||||||
).all()
|
|
||||||
assert [r[0] for r in rows] == ["B-V13", "B-V14"]
|
|
||||||
|
|
||||||
|
|
||||||
def test_migration_0014_preserves_joint_fk_rows(tmp_path, monkeypatch):
|
|
||||||
"""Migration 0014 also recreates the JOIN-FK tables (matches,
|
|
||||||
cas_adjustments, service_line_payments, line_reconciliations). Each of
|
|
||||||
those tables has a composite FK to claims/remittances whose ``batch_id``
|
|
||||||
side gets populated by JOIN in the migration. Insert representative
|
|
||||||
rows at v=13 and verify they survive 0014 with the new batch_id column
|
|
||||||
populated.
|
|
||||||
|
|
||||||
This is the complement to ``test_migration_0014_preserves_existing_data``
|
|
||||||
(which covers the claims/remittances tables) and exercises the
|
|
||||||
``INSERT INTO ..._new SELECT ... JOIN parent`` statements that the
|
|
||||||
simpler test cannot.
|
|
||||||
|
|
||||||
Like the simpler test, we seed at v=13 via raw SQL — the ORM model
|
|
||||||
declares ``batch_id`` on the JOIN-FK tables but that column is only
|
|
||||||
added by 0014 itself, so an ORM insert at v=13 would fail.
|
|
||||||
"""
|
|
||||||
from sqlalchemy.orm import sessionmaker
|
|
||||||
|
|
||||||
engine = _fresh_engine(tmp_path)
|
|
||||||
SessionLocal = sessionmaker(bind=engine, autoflush=False, autocommit=False, expire_on_commit=False)
|
|
||||||
|
|
||||||
from cyclone.db_migrate import migrate_to_version
|
|
||||||
|
|
||||||
migrate_to_version(engine, 13)
|
|
||||||
|
|
||||||
# Seed parent rows + one row per JOIN-FK child table (raw SQL at v=13).
|
|
||||||
with engine.begin() as c:
|
|
||||||
c.exec_driver_sql(
|
|
||||||
"INSERT INTO batches(id, kind, input_filename, parsed_at) "
|
|
||||||
"VALUES ('B-JOIN', '837p', 'j.txt', '2026-01-01')"
|
|
||||||
)
|
|
||||||
c.exec_driver_sql(
|
|
||||||
"INSERT INTO claims(id, batch_id, patient_control_number, "
|
|
||||||
"state, state_changed_at) VALUES "
|
|
||||||
"('CLM-J', 'B-JOIN', 'M', 'submitted', '2026-01-01')"
|
|
||||||
)
|
|
||||||
c.exec_driver_sql(
|
|
||||||
"INSERT INTO batches(id, kind, input_filename, parsed_at) "
|
|
||||||
"VALUES ('B-JOIN-R', '835', 'jr.txt', '2026-01-02')"
|
|
||||||
)
|
|
||||||
c.exec_driver_sql(
|
|
||||||
"INSERT INTO remittances(id, batch_id, payer_claim_control_number, "
|
|
||||||
"status_code, received_at) VALUES "
|
|
||||||
"('CLP-J', 'B-JOIN-R', 'CLP-J', '1', '2026-01-02')"
|
|
||||||
)
|
|
||||||
# Seed the four JOIN-FK child rows. At v=13 these tables only have
|
|
||||||
# the parent-id columns (no batch_id side yet) — 0014 adds the
|
|
||||||
# batch_id columns and JOINs against the parents to populate them.
|
|
||||||
c.exec_driver_sql(
|
|
||||||
"INSERT INTO service_line_payments("
|
|
||||||
"remittance_id, line_number, procedure_qualifier, procedure_code, "
|
|
||||||
"modifiers_json, charge, payment) VALUES "
|
|
||||||
"('CLP-J', 1, 'HC', '99213', '[]', 100.00, 80.00)"
|
|
||||||
)
|
|
||||||
c.exec_driver_sql(
|
|
||||||
"INSERT INTO cas_adjustments("
|
|
||||||
"remittance_id, group_code, reason_code, amount) VALUES "
|
|
||||||
"('CLP-J', 'CO', '45', 20.00)"
|
|
||||||
)
|
|
||||||
c.exec_driver_sql(
|
|
||||||
"INSERT INTO line_reconciliations("
|
|
||||||
"claim_id, status, match_score, reconciled_at) VALUES "
|
|
||||||
"('CLM-J', 'matched', 95, '2026-01-02')"
|
|
||||||
)
|
|
||||||
c.exec_driver_sql(
|
|
||||||
"INSERT INTO matches("
|
|
||||||
"claim_id, remittance_id, strategy, matched_at) VALUES "
|
|
||||||
"('CLM-J', 'CLP-J', 'exact', '2026-01-02')"
|
|
||||||
)
|
|
||||||
|
|
||||||
# Apply 0014 — the JOIN-FK tables get the batch_id column populated by
|
|
||||||
# the migration's INSERT...SELECT...JOIN statements.
|
|
||||||
migrate_to_version(engine, 14)
|
|
||||||
|
|
||||||
with engine.connect() as c:
|
|
||||||
rows = c.exec_driver_sql(
|
|
||||||
"SELECT remittance_id, remittance_batch_id "
|
|
||||||
"FROM service_line_payments WHERE remittance_id='CLP-J'"
|
|
||||||
).all()
|
|
||||||
assert rows == [("CLP-J", "B-JOIN-R")], (
|
|
||||||
"service_line_payments.remittance_batch_id must be populated "
|
|
||||||
"by 0014's JOIN against remittances; got " + repr(rows)
|
|
||||||
)
|
|
||||||
rows = c.exec_driver_sql(
|
|
||||||
"SELECT remittance_id, remittance_batch_id "
|
|
||||||
"FROM cas_adjustments WHERE remittance_id='CLP-J'"
|
|
||||||
).all()
|
|
||||||
assert rows == [("CLP-J", "B-JOIN-R")], (
|
|
||||||
"cas_adjustments.remittance_batch_id must be populated by 0014; got "
|
|
||||||
+ repr(rows)
|
|
||||||
)
|
|
||||||
rows = c.exec_driver_sql(
|
|
||||||
"SELECT claim_id, batch_id FROM line_reconciliations WHERE claim_id='CLM-J'"
|
|
||||||
).all()
|
|
||||||
assert rows == [("CLM-J", "B-JOIN")], (
|
|
||||||
"line_reconciliations.batch_id must be populated by 0014; got "
|
|
||||||
+ repr(rows)
|
|
||||||
)
|
|
||||||
rows = c.exec_driver_sql(
|
|
||||||
"SELECT claim_id, batch_id, remittance_id, remittance_batch_id "
|
|
||||||
"FROM matches WHERE claim_id='CLM-J'"
|
|
||||||
).all()
|
|
||||||
assert rows == [("CLM-J", "B-JOIN", "CLP-J", "B-JOIN-R")], (
|
|
||||||
"matches batch_id + remittance_batch_id must be populated by 0014; "
|
|
||||||
"got " + repr(rows)
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
# =============================================================================
|
|
||||||
# migrate_to_version: edge-case behavior
|
|
||||||
# =============================================================================
|
|
||||||
# These tests use a fresh ``sa.Engine`` (not ``db.engine()``) because the
|
|
||||||
# autouse conftest fixture has already initialized the module engine at the
|
|
||||||
# latest version. We need a clean engine that starts at ``user_version=0``
|
|
||||||
# so that ``migrate_to_version(N)`` actually exercises the apply / no-op /
|
|
||||||
# beyond-max branches.
|
|
||||||
|
|
||||||
|
|
||||||
def test_migrate_to_version_idempotent(tmp_path, monkeypatch):
|
|
||||||
"""Calling migrate_to_version(engine, N) twice is equivalent to calling once."""
|
|
||||||
from cyclone.db_migrate import migrate_to_version
|
|
||||||
|
|
||||||
engine = _fresh_engine(tmp_path)
|
|
||||||
migrate_to_version(engine, 5)
|
|
||||||
with engine.begin() as conn:
|
|
||||||
v1 = conn.exec_driver_sql("PRAGMA user_version").scalar()
|
|
||||||
migrate_to_version(engine, 5)
|
|
||||||
with engine.begin() as conn:
|
|
||||||
v2 = conn.exec_driver_sql("PRAGMA user_version").scalar()
|
|
||||||
assert v1 == v2 == 5
|
|
||||||
|
|
||||||
|
|
||||||
def test_migrate_to_version_lower_than_current_is_noop(tmp_path, monkeypatch):
|
|
||||||
"""migrate_to_version(engine, 3) after migrate_to_version(engine, 13) is a no-op."""
|
|
||||||
from cyclone.db_migrate import migrate_to_version
|
|
||||||
|
|
||||||
engine = _fresh_engine(tmp_path)
|
|
||||||
migrate_to_version(engine, 13)
|
|
||||||
with engine.begin() as conn:
|
|
||||||
v_after_13 = conn.exec_driver_sql("PRAGMA user_version").scalar()
|
|
||||||
migrate_to_version(engine, 3) # lower, no-op
|
|
||||||
with engine.begin() as conn:
|
|
||||||
v_after_3 = conn.exec_driver_sql("PRAGMA user_version").scalar()
|
|
||||||
assert v_after_13 == v_after_3 == 13
|
|
||||||
|
|
||||||
|
|
||||||
def test_migrate_to_version_zero_on_fresh_db(tmp_path, monkeypatch):
|
|
||||||
"""migrate_to_version(engine, 0) on a fresh DB is a no-op."""
|
|
||||||
from cyclone.db_migrate import migrate_to_version
|
|
||||||
|
|
||||||
engine = _fresh_engine(tmp_path)
|
|
||||||
migrate_to_version(engine, 0)
|
|
||||||
with engine.begin() as conn:
|
|
||||||
v = conn.exec_driver_sql("PRAGMA user_version").scalar()
|
|
||||||
assert v == 0
|
|
||||||
|
|
||||||
|
|
||||||
def test_migrate_to_version_beyond_max_applies_all(tmp_path, monkeypatch):
|
|
||||||
"""migrate_to_version(engine, 999) applies every migration (equivalent to run)."""
|
|
||||||
import re
|
|
||||||
from cyclone.db_migrate import MIGRATIONS_DIR, migrate_to_version
|
|
||||||
|
|
||||||
engine = _fresh_engine(tmp_path)
|
|
||||||
migrate_to_version(engine, 999)
|
|
||||||
with engine.begin() as conn:
|
|
||||||
v = conn.exec_driver_sql("PRAGMA user_version").scalar()
|
|
||||||
# Should equal the highest version present in the migrations directory.
|
|
||||||
max_version = max(
|
|
||||||
int(re.match(r"^(\d+)", p.name).group(1))
|
|
||||||
for p in MIGRATIONS_DIR.glob("*.sql")
|
|
||||||
)
|
|
||||||
assert v == max_version
|
|
||||||
@@ -72,9 +72,8 @@ def test_create_and_query_claim_with_default_state():
|
|||||||
s.add(c)
|
s.add(c)
|
||||||
s.commit()
|
s.commit()
|
||||||
|
|
||||||
# Migration 0014: composite PK (batch_id, id). PK lookups use tuples.
|
|
||||||
with db.SessionLocal()() as s:
|
with db.SessionLocal()() as s:
|
||||||
loaded = s.get(Claim, ("b1", "CLM-1"))
|
loaded = s.get(Claim, "CLM-1")
|
||||||
assert loaded is not None
|
assert loaded is not None
|
||||||
assert loaded.state == ClaimState.SUBMITTED
|
assert loaded.state == ClaimState.SUBMITTED
|
||||||
assert loaded.charge_amount == Decimal("124.00")
|
assert loaded.charge_amount == Decimal("124.00")
|
||||||
@@ -100,7 +99,7 @@ def test_claim_uses_db_default_when_state_omitted():
|
|||||||
s.commit()
|
s.commit()
|
||||||
|
|
||||||
with db.SessionLocal()() as s:
|
with db.SessionLocal()() as s:
|
||||||
loaded = s.get(Claim, ("b1", "CLM-1"))
|
loaded = s.get(Claim, "CLM-1")
|
||||||
assert loaded is not None
|
assert loaded is not None
|
||||||
assert loaded.state == ClaimState.SUBMITTED
|
assert loaded.state == ClaimState.SUBMITTED
|
||||||
|
|
||||||
@@ -124,7 +123,7 @@ def test_state_before_reversal_round_trips():
|
|||||||
s.commit()
|
s.commit()
|
||||||
|
|
||||||
with db.SessionLocal()() as s:
|
with db.SessionLocal()() as s:
|
||||||
loaded = s.get(Claim, ("b1", "CLM-1"))
|
loaded = s.get(Claim, "CLM-1")
|
||||||
assert loaded is not None
|
assert loaded is not None
|
||||||
assert loaded.state_before_reversal == ClaimState.PAID
|
assert loaded.state_before_reversal == ClaimState.PAID
|
||||||
|
|
||||||
@@ -155,8 +154,7 @@ def test_batch_cascade_delete_drops_claims():
|
|||||||
|
|
||||||
with db.SessionLocal()() as s:
|
with db.SessionLocal()() as s:
|
||||||
assert s.get(Batch, "b1") is None
|
assert s.get(Batch, "b1") is None
|
||||||
# Composite PK lookup — both batch_id AND id are required.
|
assert s.get(Claim, "CLM-1") is None
|
||||||
assert s.get(Claim, ("b1", "CLM-1")) is None
|
|
||||||
|
|
||||||
|
|
||||||
def test_create_and_query_remittance():
|
def test_create_and_query_remittance():
|
||||||
@@ -180,7 +178,7 @@ def test_create_and_query_remittance():
|
|||||||
s.commit()
|
s.commit()
|
||||||
|
|
||||||
with db.SessionLocal()() as s:
|
with db.SessionLocal()() as s:
|
||||||
loaded = s.get(Remittance, ("b1", "CLP-1"))
|
loaded = s.get(Remittance, "CLP-1")
|
||||||
assert loaded is not None
|
assert loaded is not None
|
||||||
assert loaded.status_code == "1"
|
assert loaded.status_code == "1"
|
||||||
assert loaded.total_paid == Decimal("124.00")
|
assert loaded.total_paid == Decimal("124.00")
|
||||||
@@ -203,14 +201,10 @@ def test_cas_adjustment_aggregation():
|
|||||||
)
|
)
|
||||||
s.add(r)
|
s.add(r)
|
||||||
s.flush()
|
s.flush()
|
||||||
# Migration 0014: remittance_batch_id is required (NOT NULL) on
|
|
||||||
# CasAdjustment; it mirrors remittance_id's batch.
|
|
||||||
s.add_all([
|
s.add_all([
|
||||||
CasAdjustment(remittance_id="CLP-1", remittance_batch_id="b1",
|
CasAdjustment(remittance_id="CLP-1", group_code="CO", reason_code="45",
|
||||||
group_code="CO", reason_code="45",
|
|
||||||
amount=Decimal("30.00")),
|
amount=Decimal("30.00")),
|
||||||
CasAdjustment(remittance_id="CLP-1", remittance_batch_id="b1",
|
CasAdjustment(remittance_id="CLP-1", group_code="PR", reason_code="1",
|
||||||
group_code="PR", reason_code="1",
|
|
||||||
amount=Decimal("32.00")),
|
amount=Decimal("32.00")),
|
||||||
])
|
])
|
||||||
s.commit()
|
s.commit()
|
||||||
@@ -241,16 +235,15 @@ def test_remittance_raw_json_round_trips_dict():
|
|||||||
s.commit()
|
s.commit()
|
||||||
|
|
||||||
with db.SessionLocal()() as s:
|
with db.SessionLocal()() as s:
|
||||||
loaded = s.get(Remittance, ("b1", "CLP-1"))
|
loaded = s.get(Remittance, "CLP-1")
|
||||||
assert loaded.raw_json == {"x12_segments": ["CLP", "NM1", "SVC"], "version": "005010X221A1"}
|
assert loaded.raw_json == {"x12_segments": ["CLP", "NM1", "SVC"], "version": "005010X221A1"}
|
||||||
|
|
||||||
|
|
||||||
def test_remittance_allows_duplicate_pcn_in_same_batch():
|
def test_remittance_allows_duplicate_pcn_in_same_batch():
|
||||||
"""An 835 ERA can carry multiple CLP segments with the same
|
"""An 835 ERA can carry multiple CLP segments with the same
|
||||||
payer_claim_control_number (e.g. reversals re-referencing the
|
payer_claim_control_number (e.g. reversals re-referencing the
|
||||||
original PCN). Remittance identity is by the composite PK
|
original PCN). Remittance identity is by ``id`` (CLP01), not by
|
||||||
(batch_id, id) — id (CLP01) distinguishes rows in the same batch.
|
(batch_id, PCN). This test documents the absence of the constraint
|
||||||
This test documents the absence of the (batch_id, PCN) constraint
|
|
||||||
removed in migration 0003.
|
removed in migration 0003.
|
||||||
"""
|
"""
|
||||||
with db.SessionLocal()() as s:
|
with db.SessionLocal()() as s:
|
||||||
@@ -293,13 +286,12 @@ def test_remittance_cascade_delete_drops_cas_adjustments():
|
|||||||
)
|
)
|
||||||
s.add(r)
|
s.add(r)
|
||||||
s.flush()
|
s.flush()
|
||||||
s.add(CasAdjustment(remittance_id="CLP-1", remittance_batch_id="b1",
|
s.add(CasAdjustment(remittance_id="CLP-1", group_code="CO",
|
||||||
group_code="CO", reason_code="45",
|
reason_code="45", amount=Decimal("30.00")))
|
||||||
amount=Decimal("30.00")))
|
|
||||||
s.commit()
|
s.commit()
|
||||||
|
|
||||||
with db.SessionLocal()() as s:
|
with db.SessionLocal()() as s:
|
||||||
r = s.get(Remittance, ("b1", "CLP-1"))
|
r = s.get(Remittance, "CLP-1")
|
||||||
s.delete(r)
|
s.delete(r)
|
||||||
s.commit()
|
s.commit()
|
||||||
|
|
||||||
@@ -326,10 +318,8 @@ def test_create_match_and_activity_event():
|
|||||||
status_code="1", total_charge=Decimal("124.00"), total_paid=Decimal("124.00"),
|
status_code="1", total_charge=Decimal("124.00"), total_paid=Decimal("124.00"),
|
||||||
received_at=datetime(2026, 6, 19, tzinfo=timezone.utc),
|
received_at=datetime(2026, 6, 19, tzinfo=timezone.utc),
|
||||||
))
|
))
|
||||||
# Migration 0014: Match needs batch_id + remittance_batch_id (NOT NULL).
|
|
||||||
m = Match(
|
m = Match(
|
||||||
claim_id="CLM-1", batch_id="b1",
|
claim_id="CLM-1", remittance_id="CLP-1",
|
||||||
remittance_id="CLP-1", remittance_batch_id="b1",
|
|
||||||
strategy="auto", is_reversal=False,
|
strategy="auto", is_reversal=False,
|
||||||
matched_at=datetime(2026, 6, 19, 13, 0, tzinfo=timezone.utc),
|
matched_at=datetime(2026, 6, 19, 13, 0, tzinfo=timezone.utc),
|
||||||
)
|
)
|
||||||
@@ -371,13 +361,9 @@ def test_match_multiple_rows_per_claim():
|
|||||||
status_code="1", received_at=datetime.now(timezone.utc)))
|
status_code="1", received_at=datetime.now(timezone.utc)))
|
||||||
s.add(Remittance(id="CLP-2", batch_id="b1", payer_claim_control_number="y",
|
s.add(Remittance(id="CLP-2", batch_id="b1", payer_claim_control_number="y",
|
||||||
status_code="1", received_at=datetime.now(timezone.utc)))
|
status_code="1", received_at=datetime.now(timezone.utc)))
|
||||||
# Migration 0014: batch_id + remittance_batch_id required on Match.
|
s.add(Match(claim_id="CLM-1", remittance_id="CLP-1", strategy="auto",
|
||||||
s.add(Match(claim_id="CLM-1", batch_id="b1",
|
matched_at=datetime.now(timezone.utc)))
|
||||||
remittance_id="CLP-1", remittance_batch_id="b1",
|
s.add(Match(claim_id="CLM-1", remittance_id="CLP-2", strategy="manual",
|
||||||
strategy="auto", matched_at=datetime.now(timezone.utc)))
|
|
||||||
s.add(Match(claim_id="CLM-1", batch_id="b1",
|
|
||||||
remittance_id="CLP-2", remittance_batch_id="b1",
|
|
||||||
strategy="manual",
|
|
||||||
matched_at=datetime.now(timezone.utc),
|
matched_at=datetime.now(timezone.utc),
|
||||||
is_reversal=True, prior_claim_state=ClaimState.PAID))
|
is_reversal=True, prior_claim_state=ClaimState.PAID))
|
||||||
# No IntegrityError — two Match rows per claim are now allowed.
|
# No IntegrityError — two Match rows per claim are now allowed.
|
||||||
|
|||||||
@@ -75,9 +75,7 @@ def test_match_endpoint_links_remit_to_claim(client: TestClient):
|
|||||||
assert r.status_code == 200, r.text
|
assert r.status_code == 200, r.text
|
||||||
|
|
||||||
with db.SessionLocal()() as s:
|
with db.SessionLocal()() as s:
|
||||||
# Migration 0014: composite PK — filter-by-id returns the single
|
c = s.get(Claim, "C1")
|
||||||
# claim with this id (no cross-batch duplicates in this fixture).
|
|
||||||
c = s.query(Claim).filter(Claim.id == "C1").first()
|
|
||||||
assert c.matched_remittance_id == "R1"
|
assert c.matched_remittance_id == "R1"
|
||||||
|
|
||||||
|
|
||||||
@@ -121,8 +119,7 @@ def test_resubmit_moves_rejected_to_submitted_and_increments_count(client: TestC
|
|||||||
assert r.status_code == 200, r.text
|
assert r.status_code == 200, r.text
|
||||||
|
|
||||||
with db.SessionLocal()() as s:
|
with db.SessionLocal()() as s:
|
||||||
# Migration 0014: composite PK (batch_id, id).
|
c = s.get(Claim, "C1")
|
||||||
c = s.get(Claim, ("B-1", "C1"))
|
|
||||||
assert c.state == ClaimState.SUBMITTED
|
assert c.state == ClaimState.SUBMITTED
|
||||||
assert c.rejection_reason is None
|
assert c.rejection_reason is None
|
||||||
assert c.resubmit_count == 3
|
assert c.resubmit_count == 3
|
||||||
@@ -178,9 +175,7 @@ def _seed_rejected_claim_with_raw_837(client: TestClient) -> str:
|
|||||||
real_id = r.json()["claims"][0]["claim_id"]
|
real_id = r.json()["claims"][0]["claim_id"]
|
||||||
_seed_batch()
|
_seed_batch()
|
||||||
with db.SessionLocal()() as s:
|
with db.SessionLocal()() as s:
|
||||||
# Migration 0014: filter-by-id since the parse-837 batch id is
|
c = s.get(Claim, real_id)
|
||||||
# a fresh UUID we don't know here.
|
|
||||||
c = s.query(Claim).filter(Claim.id == real_id).first()
|
|
||||||
assert c is not None
|
assert c is not None
|
||||||
c.state = ClaimState.REJECTED
|
c.state = ClaimState.REJECTED
|
||||||
c.rejection_reason = "test fixture"
|
c.rejection_reason = "test fixture"
|
||||||
@@ -221,8 +216,7 @@ def test_resubmit_with_download_returns_zip_with_one_x12_per_accepted_claim(clie
|
|||||||
|
|
||||||
# Side effect: claim actually moved to SUBMITTED.
|
# Side effect: claim actually moved to SUBMITTED.
|
||||||
with db.SessionLocal()() as s:
|
with db.SessionLocal()() as s:
|
||||||
# Migration 0014: filter-by-id (batch_id is a fresh UUID).
|
c = s.get(Claim, real_id)
|
||||||
c = s.query(Claim).filter(Claim.id == real_id).first()
|
|
||||||
assert c.state == ClaimState.SUBMITTED
|
assert c.state == ClaimState.SUBMITTED
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -116,20 +116,17 @@ def _seed_pair_with_two_lines():
|
|||||||
reconcile_run(s, bid_835)
|
reconcile_run(s, bid_835)
|
||||||
s.commit()
|
s.commit()
|
||||||
|
|
||||||
return claim_id, bid_837
|
return claim_id
|
||||||
|
|
||||||
|
|
||||||
def test_done_today_lane_includes_matched_remittance_line_counts(client):
|
def test_done_today_lane_includes_matched_remittance_line_counts(client):
|
||||||
claim_id, bid_837 = _seed_pair_with_two_lines()
|
claim_id = _seed_pair_with_two_lines()
|
||||||
# Backfill state_changed_at so the claim lands in the done_today lane
|
# Backfill state_changed_at so the claim lands in the done_today lane
|
||||||
# (reconcile.run() doesn't write it; the API surface is exercised by
|
# (reconcile.run() doesn't write it; the API surface is exercised by
|
||||||
# the existing manual-match flow which sets it).
|
# the existing manual-match flow which sets it).
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
with db.SessionLocal()() as s:
|
with db.SessionLocal()() as s:
|
||||||
# Migration 0014: composite PK (batch_id, id). Use filter-by-id
|
cl = s.get(db.Claim, claim_id)
|
||||||
# since we don't have a single canonical batch_id (the test seeds
|
|
||||||
# both 837 and 835 batches under fresh UUIDs).
|
|
||||||
cl = s.query(db.Claim).filter(db.Claim.id == claim_id).first()
|
|
||||||
cl.state_changed_at = datetime.now(timezone.utc)
|
cl.state_changed_at = datetime.now(timezone.utc)
|
||||||
s.commit()
|
s.commit()
|
||||||
|
|
||||||
|
|||||||
@@ -131,11 +131,9 @@ def test_done_today_includes_recent_terminal_states():
|
|||||||
# Force C1's state_changed_at to be recent, C2 to be old.
|
# Force C1's state_changed_at to be recent, C2 to be old.
|
||||||
now = datetime.now(timezone.utc)
|
now = datetime.now(timezone.utc)
|
||||||
with db.SessionLocal()() as s:
|
with db.SessionLocal()() as s:
|
||||||
# Migration 0014: composite PK — filter-by-id returns the single
|
c1 = s.get(Claim, "C1")
|
||||||
# claim with that id (no cross-batch duplicates in this test).
|
|
||||||
c1 = s.query(Claim).filter(Claim.id == "C1").first()
|
|
||||||
c1.state_changed_at = now - timedelta(hours=2)
|
c1.state_changed_at = now - timedelta(hours=2)
|
||||||
c2 = s.query(Claim).filter(Claim.id == "C2").first()
|
c2 = s.get(Claim, "C2")
|
||||||
c2.state_changed_at = now - timedelta(hours=30)
|
c2.state_changed_at = now - timedelta(hours=30)
|
||||||
s.commit()
|
s.commit()
|
||||||
|
|
||||||
|
|||||||
@@ -111,9 +111,7 @@ def test_rejection_moves_submitted_claim_to_rejected_state():
|
|||||||
assert result.matched == ["CLP-1"]
|
assert result.matched == ["CLP-1"]
|
||||||
assert result.orphans == []
|
assert result.orphans == []
|
||||||
with db.SessionLocal()() as s:
|
with db.SessionLocal()() as s:
|
||||||
# Migration 0014: composite PK — claim is in batch "B-1"; use
|
c = s.get(Claim, "CLP-1")
|
||||||
# filter-by-id since the test has only one claim with this id.
|
|
||||||
c = s.query(Claim).filter(Claim.id == "CLP-1").first()
|
|
||||||
assert c.state == ClaimState.REJECTED
|
assert c.state == ClaimState.REJECTED
|
||||||
assert c.rejected_at is not None
|
assert c.rejected_at is not None
|
||||||
assert "999" in (c.rejection_reason or "")
|
assert "999" in (c.rejection_reason or "")
|
||||||
@@ -132,8 +130,7 @@ def test_accepted_set_leaves_claim_alone():
|
|||||||
|
|
||||||
assert result.matched == []
|
assert result.matched == []
|
||||||
with db.SessionLocal()() as s:
|
with db.SessionLocal()() as s:
|
||||||
# Migration 0014: composite PK — see test_rejection_moves...
|
c = s.get(Claim, "CLP-1")
|
||||||
c = s.query(Claim).filter(Claim.id == "CLP-1").first()
|
|
||||||
assert c.state == ClaimState.SUBMITTED
|
assert c.state == ClaimState.SUBMITTED
|
||||||
assert c.rejected_at is None
|
assert c.rejected_at is None
|
||||||
assert c.rejection_reason is None
|
assert c.rejection_reason is None
|
||||||
@@ -165,6 +162,5 @@ def test_already_rejected_claim_is_idempotent():
|
|||||||
|
|
||||||
assert result.matched == [] # no-op
|
assert result.matched == [] # no-op
|
||||||
with db.SessionLocal()() as s:
|
with db.SessionLocal()() as s:
|
||||||
# Migration 0014: composite PK — see test_rejection_moves...
|
c = s.get(Claim, "CLP-1")
|
||||||
c = s.query(Claim).filter(Claim.id == "CLP-1").first()
|
|
||||||
assert c.state == ClaimState.REJECTED
|
assert c.state == ClaimState.REJECTED
|
||||||
|
|||||||
@@ -67,9 +67,7 @@ def test_acknowledge_marks_unacknowledged_claim():
|
|||||||
assert body["not_rejected"] == 0
|
assert body["not_rejected"] == 0
|
||||||
|
|
||||||
with db.SessionLocal()() as session:
|
with db.SessionLocal()() as session:
|
||||||
# Migration 0014: composite PK — filter-by-id returns the single
|
c = session.get(Claim, "C1")
|
||||||
# claim with this id (no cross-batch duplicates in this fixture).
|
|
||||||
c = session.query(Claim).filter(Claim.id == "C1").first()
|
|
||||||
assert c.payer_rejected_acknowledged_at is not None
|
assert c.payer_rejected_acknowledged_at is not None
|
||||||
assert c.payer_rejected_acknowledged_actor == "operator"
|
assert c.payer_rejected_acknowledged_actor == "operator"
|
||||||
# Original fields stay intact for audit.
|
# Original fields stay intact for audit.
|
||||||
|
|||||||
@@ -271,9 +271,7 @@ def test_run_matches_and_updates_state(fixture_835):
|
|||||||
assert result.unmatched_claims == 0
|
assert result.unmatched_claims == 0
|
||||||
|
|
||||||
with db.SessionLocal()() as s:
|
with db.SessionLocal()() as s:
|
||||||
# Migration 0014: claims PK is composite (batch_id, id). The test
|
claim = s.get(Claim, "CLM-1")
|
||||||
# only has one claim with id "CLM-1" so filter-by-id returns it.
|
|
||||||
claim = s.query(Claim).filter(Claim.id == "CLM-1").first()
|
|
||||||
assert claim.state == ClaimState.PAID
|
assert claim.state == ClaimState.PAID
|
||||||
assert claim.matched_remittance_id == "CLP-1:b1xxxxxx"
|
assert claim.matched_remittance_id == "CLP-1:b1xxxxxx"
|
||||||
|
|
||||||
@@ -305,32 +303,13 @@ def test_run_reversal_flips_paid_to_reversed(fixture_835):
|
|||||||
# (9 days, outside window) which produced no match.
|
# (9 days, outside window) which produced no match.
|
||||||
_make_claim(s, "CLM-1", "PCN-A", "100.00", date(2026, 6, 5),
|
_make_claim(s, "CLM-1", "PCN-A", "100.00", date(2026, 6, 5),
|
||||||
state=ClaimState.PAID)
|
state=ClaimState.PAID)
|
||||||
# Prior Match exists from an earlier reconcile — represented by a
|
# Match row already exists from prior reconcile.
|
||||||
# Match row pointing at a Remittance that already happened. Migration
|
|
||||||
# 0014: composite FK requires the parent Remittance row to exist, so
|
|
||||||
# we add a prior batch to host it (separate batch so reconcile.run
|
|
||||||
# below only sees CLP-REV in batch "b1"). The prior Match row is just
|
|
||||||
# a hint that the claim was paid earlier — we don't care about the
|
|
||||||
# prior Match's batch membership here.
|
|
||||||
_make_batch(s, batch_id="b-PRIOR", kind="835")
|
|
||||||
s.add(Remittance(
|
|
||||||
id="CLP-OLD:bPRIORxxxx", batch_id="b-PRIOR",
|
|
||||||
payer_claim_control_number="PCN-A",
|
|
||||||
status_code="1", total_charge=Decimal("100.00"),
|
|
||||||
total_paid=Decimal("100.00"),
|
|
||||||
received_at=datetime(2026, 6, 1, tzinfo=timezone.utc),
|
|
||||||
service_date=date(2026, 6, 1),
|
|
||||||
))
|
|
||||||
s.flush()
|
|
||||||
s.add(Match(
|
s.add(Match(
|
||||||
claim_id="CLM-1", batch_id="b1",
|
claim_id="CLM-1", remittance_id="CLP-OLD:b1xxxxxx",
|
||||||
remittance_id="CLP-OLD:bPRIORxxxx",
|
|
||||||
remittance_batch_id="b-PRIOR",
|
|
||||||
strategy="auto", matched_at=datetime.now(timezone.utc),
|
strategy="auto", matched_at=datetime.now(timezone.utc),
|
||||||
is_reversal=False,
|
is_reversal=False,
|
||||||
))
|
))
|
||||||
s.flush()
|
s.flush()
|
||||||
# The reversal hits batch "b1" — the one reconcile.run() targets.
|
|
||||||
_make_remit(s, "CLP-REV", "PCN-A", "22", "100.00", "-100.00",
|
_make_remit(s, "CLP-REV", "PCN-A", "22", "100.00", "-100.00",
|
||||||
date(2026, 6, 10), is_reversal=True)
|
date(2026, 6, 10), is_reversal=True)
|
||||||
s.commit()
|
s.commit()
|
||||||
@@ -342,9 +321,7 @@ def test_run_reversal_flips_paid_to_reversed(fixture_835):
|
|||||||
|
|
||||||
assert result.matched == 1
|
assert result.matched == 1
|
||||||
with db.SessionLocal()() as s:
|
with db.SessionLocal()() as s:
|
||||||
# Migration 0014: see test_run_matches_and_updates_state — use
|
claim = s.get(Claim, "CLM-1")
|
||||||
# filter-by-id since the test has one claim with id "CLM-1".
|
|
||||||
claim = s.query(Claim).filter(Claim.id == "CLM-1").first()
|
|
||||||
assert claim.state == ClaimState.REVERSED
|
assert claim.state == ClaimState.REVERSED
|
||||||
# Match rows: original + reversal.
|
# Match rows: original + reversal.
|
||||||
matches = s.query(Match).filter(Match.claim_id == "CLM-1").all()
|
matches = s.query(Match).filter(Match.claim_id == "CLM-1").all()
|
||||||
|
|||||||
@@ -201,58 +201,3 @@ def test_persistence_across_session():
|
|||||||
loaded = s2.get_batch("b-x")
|
loaded = s2.get_batch("b-x")
|
||||||
assert loaded is not None
|
assert loaded is not None
|
||||||
assert loaded["kind"] == "837p"
|
assert loaded["kind"] == "837p"
|
||||||
|
|
||||||
|
|
||||||
def test_find_existing_batch_for_claim_returns_none_for_unknown():
|
|
||||||
"""Unknown claim_id -> None."""
|
|
||||||
from cyclone import store as store_mod # noqa: F401
|
|
||||||
assert store_mod.find_existing_batch_for_claim("nope") is None
|
|
||||||
|
|
||||||
|
|
||||||
def test_find_existing_batch_for_claim_returns_batch_id():
|
|
||||||
"""Known claim_id -> batch_id of the holding batch."""
|
|
||||||
from cyclone import db as _db
|
|
||||||
from cyclone.db import Batch, Claim
|
|
||||||
from datetime import datetime, timezone
|
|
||||||
|
|
||||||
with _db.SessionLocal()() as s:
|
|
||||||
s.add(Batch(
|
|
||||||
id="B1", kind="837p", input_filename="x.txt",
|
|
||||||
parsed_at=datetime(2026, 1, 1, tzinfo=timezone.utc),
|
|
||||||
raw_result_json={},
|
|
||||||
))
|
|
||||||
s.add(Claim(id="CLM-A", batch_id="B1", patient_control_number="M1"))
|
|
||||||
s.commit()
|
|
||||||
|
|
||||||
from cyclone import store as store_mod # noqa: F401
|
|
||||||
assert store_mod.find_existing_batch_for_claim("CLM-A") == "B1"
|
|
||||||
|
|
||||||
|
|
||||||
def test_find_existing_batch_for_remit_returns_none_for_unknown():
|
|
||||||
"""Unknown remit id -> None."""
|
|
||||||
from cyclone import store as store_mod # noqa: F401
|
|
||||||
assert store_mod.find_existing_batch_for_remit("nope") is None
|
|
||||||
|
|
||||||
|
|
||||||
def test_find_existing_batch_for_remit_returns_batch_id():
|
|
||||||
"""Known remit id -> batch_id of the holding batch."""
|
|
||||||
from cyclone import db as _db
|
|
||||||
from cyclone.db import Batch, Remittance
|
|
||||||
from datetime import datetime, timezone
|
|
||||||
|
|
||||||
with _db.SessionLocal()() as s:
|
|
||||||
s.add(Batch(
|
|
||||||
id="B2", kind="835", input_filename="y.txt",
|
|
||||||
parsed_at=datetime(2026, 1, 1, tzinfo=timezone.utc),
|
|
||||||
raw_result_json={},
|
|
||||||
))
|
|
||||||
s.add(Remittance(
|
|
||||||
id="CLP-A", batch_id="B2",
|
|
||||||
payer_claim_control_number="CLP-A",
|
|
||||||
status_code="1",
|
|
||||||
received_at=datetime(2026, 1, 1, tzinfo=timezone.utc),
|
|
||||||
))
|
|
||||||
s.commit()
|
|
||||||
|
|
||||||
from cyclone import store as store_mod # noqa: F401
|
|
||||||
assert store_mod.find_existing_batch_for_remit("CLP-A") == "B2"
|
|
||||||
@@ -221,8 +221,7 @@ def test_manual_match_pairs_claim_with_orphan_remit():
|
|||||||
# Match row was persisted with strategy="manual".
|
# Match row was persisted with strategy="manual".
|
||||||
with db.SessionLocal()() as session:
|
with db.SessionLocal()() as session:
|
||||||
from cyclone.db import Claim, Match
|
from cyclone.db import Claim, Match
|
||||||
# Migration 0014: composite PK — claim ingested under batch "b-837".
|
claim_row = session.get(Claim, claim_id)
|
||||||
claim_row = session.get(Claim, ("b-837", claim_id))
|
|
||||||
assert claim_row is not None
|
assert claim_row is not None
|
||||||
assert claim_row.matched_remittance_id == remit_id
|
assert claim_row.matched_remittance_id == remit_id
|
||||||
match_rows = (
|
match_rows = (
|
||||||
@@ -418,8 +417,7 @@ def test_manual_match_populates_line_reconciliation_rows():
|
|||||||
assert unmatched.service_line_payment_id is None
|
assert unmatched.service_line_payment_id is None
|
||||||
|
|
||||||
# Remittance aggregates were recomputed (0 CAS in this test).
|
# Remittance aggregates were recomputed (0 CAS in this test).
|
||||||
# Migration 0014: composite PK — remit ingested under batch "b-835-sp7".
|
r = session.get(Remittance, remit_id)
|
||||||
r = session.get(Remittance, ("b-835-sp7", remit_id))
|
|
||||||
assert r is not None
|
assert r is not None
|
||||||
assert r.adjustment_amount == Decimal("0")
|
assert r.adjustment_amount == Decimal("0")
|
||||||
assert r.claim_level_adjustment_amount == Decimal("0")
|
assert r.claim_level_adjustment_amount == Decimal("0")
|
||||||
@@ -585,8 +583,7 @@ def test_manual_match_idempotent_line_reconciliation():
|
|||||||
|
|
||||||
# CLP-level CAS aggregate now reflects the inserted row.
|
# CLP-level CAS aggregate now reflects the inserted row.
|
||||||
from cyclone.db import Remittance
|
from cyclone.db import Remittance
|
||||||
# Migration 0014: composite PK — remit ingested under batch "b-835-idemp".
|
r = session.get(Remittance, remit_id)
|
||||||
r = session.get(Remittance, ("b-835-idemp", remit_id))
|
|
||||||
assert r is not None
|
assert r is not None
|
||||||
assert r.adjustment_amount == Decimal("20.00")
|
assert r.adjustment_amount == Decimal("20.00")
|
||||||
assert r.claim_level_adjustment_amount == Decimal("20.00")
|
assert r.claim_level_adjustment_amount == Decimal("20.00")
|
||||||
@@ -388,22 +388,6 @@ class Claim(...):
|
|||||||
|
|
||||||
SQLAlchemy 2.x syntax: use `mapped_column(..., primary_key=True)` for both columns and SQLAlchemy infers the composite. Or use `__table_args__ = (PrimaryKeyConstraint(...),)`.
|
SQLAlchemy 2.x syntax: use `mapped_column(..., primary_key=True)` for both columns and SQLAlchemy infers the composite. Or use `__table_args__ = (PrimaryKeyConstraint(...),)`.
|
||||||
|
|
||||||
In addition to the column changes above, four SQLAlchemy ORM
|
|
||||||
`before_insert` event listeners were added at the bottom of `db.py`
|
|
||||||
(see the `# Migration 0014: ORM `before_insert` events auto-populate the
|
|
||||||
batch side of composite FKs` block). They auto-populate the ``batch_id``
|
|
||||||
/ ``remittance_batch_id`` column on ``Match`` / ``CasAdjustment`` /
|
|
||||||
``ServiceLinePayment`` / ``LineReconciliation`` rows by looking up the
|
|
||||||
parent ``Claim`` / ``Remittance`` in the session. This is necessary
|
|
||||||
because the composite FK columns are ``NOT NULL`` and most call sites
|
|
||||||
only have the parent's id (not its batch_id) in scope — threading
|
|
||||||
``batch_id`` through every ingest/match path would be error-prone. The
|
|
||||||
event block in `db.py` has a docstring explaining what / why / when it
|
|
||||||
fails (parent id not findable in the session or DB → misleading
|
|
||||||
``NOT NULL constraint failed`` IntegrityError on INSERT). Application
|
|
||||||
code may still explicitly set the batch side — the events only fill in
|
|
||||||
``None`` columns.
|
|
||||||
|
|
||||||
- [ ] **Step 6: Run migration tests, expect PASS**
|
- [ ] **Step 6: Run migration tests, expect PASS**
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -432,21 +416,7 @@ PYTHONPATH=/Users/openclaw/dev/cyclone/.worktrees/claims-unique-fix/backend/src
|
|||||||
.venv/bin/python3.13 -m pytest --no-header -q
|
.venv/bin/python3.13 -m pytest --no-header -q
|
||||||
```
|
```
|
||||||
|
|
||||||
Expected: all pass. Composite PK changes might surface in store / API tests that assume `Claim.id` is unique. Fix those tests by using `(batch_id, id)` lookups where needed.
|
Expected: all pass. Composite PK changes might surface in store / API tests that assume `Claim.id` is unique. Fix those tests by using `(batch_id, id)` lookups where needed. **Do not modify production code to make old tests pass**; update the tests.
|
||||||
|
|
||||||
**Note on production-code changes during this step:** the original plan
|
|
||||||
text said "Do not modify production code to make old tests pass;
|
|
||||||
update the tests." In practice some production code DID need to change
|
|
||||||
because composite-PK semantics make single-id lookups ambiguous — the
|
|
||||||
same `Claim.id` can now exist in multiple batches, so `s.get(Claim, X)`
|
|
||||||
returns at most one row (whichever the DB picks first) and is no longer
|
|
||||||
the right way to find a specific claim. Call sites that did
|
|
||||||
`s.get(Claim, X)` were updated to filter by `(batch_id, id)` (e.g.
|
|
||||||
`s.query(Claim).filter(Claim.batch_id == b, Claim.id == X).first()`).
|
|
||||||
This is a forced consequence of the schema change, not test-driven
|
|
||||||
product churn. The corrected rule is: **do not modify production code
|
|
||||||
to make old tests pass UNLESS the schema change forces it; update the
|
|
||||||
tests for everything else.**
|
|
||||||
|
|
||||||
- [ ] **Step 9: Commit**
|
- [ ] **Step 9: Commit**
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,798 @@
|
|||||||
|
# Parse → Detect → Decide: 837P/835 Upload Workflow
|
||||||
|
|
||||||
|
**Date:** 2026-06-21
|
||||||
|
**Branch:** `claims-unique-fix` (worktree)
|
||||||
|
**Status:** Draft (brainstorming approved, awaiting writing-plans)
|
||||||
|
**Supersedes:** `2026-06-21-cyclone-claims-unique-constraint-and-409-ux-design.md` (kept for the migration 0013 + store helper sections, which still apply).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Why this exists
|
||||||
|
|
||||||
|
Today's upload flow is "parse → validate → persist" in a single call.
|
||||||
|
When a claim's CLM01 collides with a prior batch, the persist raises
|
||||||
|
`IntegrityError`, the transaction rolls back, and the API returns 409
|
||||||
|
with **no parse result, no list of colliding claims, and no way to act**.
|
||||||
|
The user sees only an error message and a `batch_id` that doesn't exist.
|
||||||
|
|
||||||
|
This is wrong: the parse already happened. The user should see what was
|
||||||
|
parsed, see which claims collide with which prior batches, and decide
|
||||||
|
what to do (force-insert, delete the prior batch, or pick a different
|
||||||
|
file). The 409 response body today is too thin to make that decision.
|
||||||
|
|
||||||
|
The root cause of the 409s is a schema bug — see
|
||||||
|
`2026-06-21-cyclone-claims-unique-constraint-and-409-ux-design.md` §1.
|
||||||
|
Migration 0013 drops the `UNIQUE(batch_id, patient_control_number)` inline
|
||||||
|
constraint that 0003 was supposed to drop. After 0013 lands, **multi-claim
|
||||||
|
837P files where many CLM segments share a subscriber's `member_id` will
|
||||||
|
ingest cleanly for the first time**.
|
||||||
|
|
||||||
|
But 0013 alone is not enough. The current schema has `claims.id` and
|
||||||
|
`remittances.id` as single-column PRIMARY KEYs, which means the same
|
||||||
|
CLM01 cannot exist in two different batches. That makes "cross-batch
|
||||||
|
CLM01 collisions" impossible to express in the data — but it also makes
|
||||||
|
resubmits impossible, and it makes the 409-with-collision-summary workflow
|
||||||
|
this SP describes unreachable. **Migration 0014 (added as Task 1.3 to the
|
||||||
|
plan) relaxes the PKs to composite `(batch_id, id)`.** After 0014 lands,
|
||||||
|
real resubmits are representable, the pre-flight 409 path actually fires,
|
||||||
|
and the workflow defined below is exercisable end-to-end against real data.
|
||||||
|
|
||||||
|
This SP defines the workflow for both classes of collision:
|
||||||
|
1. Multi-claim files with shared `member_id` (no longer a 409 after 0013).
|
||||||
|
2. Files where one or more CLM01s exist in a prior batch (a 409 after 0014; this SP defines the UX for it).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Operator surface
|
||||||
|
|
||||||
|
| Surface | Change |
|
||||||
|
|---|---|
|
||||||
|
| Backend | Pre-flight dedup check in `parse_837` and `parse_835`. New `?force=true` query param. New `DELETE /api/batches/{id}` endpoint. 409 body shape changes. |
|
||||||
|
| Frontend | `Upload.tsx` panel renders the full parse result + collision summary, with actions: "Force insert (skip dups)", "Open prior batch", "Delete prior batch and retry", "Pick a different file". |
|
||||||
|
| Tests | Migration + store helpers (already done in `claims-unique-fix`). New tests for: pre-flight dedup, force-insert, within-file dup, race 409, DELETE endpoint, frontend panel. |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. The workflow
|
||||||
|
|
||||||
|
### 3.1 No collision (the happy path)
|
||||||
|
|
||||||
|
```
|
||||||
|
User → POST /api/parse-837 (file)
|
||||||
|
← 200 + ParseResult + batch_id
|
||||||
|
Batch persisted. UI shows parsed claims and links to the new batch.
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3.2 Collision (the new path)
|
||||||
|
|
||||||
|
```
|
||||||
|
User → POST /api/parse-837 (file)
|
||||||
|
← 409 + {
|
||||||
|
error: "Duplicate claim",
|
||||||
|
detail: "...",
|
||||||
|
existing_batch_id: "B123", # most-recent prior batch with a colliding CLM01
|
||||||
|
collisions: {
|
||||||
|
colliding_claim_ids: ["A", "B"],
|
||||||
|
total_collisions: 2,
|
||||||
|
total_claims: 141, # claims in the file
|
||||||
|
new_claims_after_skip: 139, # claims that WOULD be inserted on force
|
||||||
|
},
|
||||||
|
parse_result: { ... full ParseResult ... },
|
||||||
|
}
|
||||||
|
User sees the parse result in the panel.
|
||||||
|
User can:
|
||||||
|
- Click "Force insert (skip 2 dups)" → POST /api/parse-837?force=true (same file)
|
||||||
|
← 200 + ParseResult + { skipped_claim_ids: ["A", "B"], inserted: 139 }
|
||||||
|
- Click "Open prior batch" → navigate to /batches/B123
|
||||||
|
- Click "Delete prior batch" → DELETE /api/batches/B123, then click "Re-upload"
|
||||||
|
- Click "Pick a different file" → clear the upload state
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3.3 Force-insert after collision
|
||||||
|
|
||||||
|
`force=true` skips the pre-flight check. The store's existing per-row
|
||||||
|
`s.get(Claim, claim_id)` dedup still skips colliding rows silently, so
|
||||||
|
the new batch persists with only the non-colliding claims. The response
|
||||||
|
body includes `skipped_claim_ids` so the UI can show what was skipped.
|
||||||
|
|
||||||
|
`force=true` does NOT bypass the parser. If the file fails validation
|
||||||
|
(missing diagnosis, malformed segment), the response is still 422.
|
||||||
|
|
||||||
|
### 3.4 Race condition (pre-flight clean, persist fails)
|
||||||
|
|
||||||
|
If a pre-flight dedup check finds no collisions, but a concurrent process
|
||||||
|
ingests a colliding CLM01 between the check and the persist, the persist
|
||||||
|
will still raise `IntegrityError`. The handler catches it and returns
|
||||||
|
**the same 409 shape as the pre-flight collision** with
|
||||||
|
`existing_batch_id` set to the racing batch and `detail` mentioning
|
||||||
|
"another process ingested this between the check and the persist —
|
||||||
|
re-upload to retry". The user re-runs the same flow.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Within-file duplicates
|
||||||
|
|
||||||
|
If the file itself has the same CLM01 twice (a malformed file, not a
|
||||||
|
cross-batch collision), the pre-flight check catches it the same way:
|
||||||
|
it returns 409 with `existing_batch_id: null` and `detail: "CLM01 A
|
||||||
|
appears twice in this file"`. The user can only force-insert (which
|
||||||
|
skips the second instance). They can't "delete the prior batch" because
|
||||||
|
there isn't one — it's a bad file.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. The 409 body shape
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"error": "Duplicate claim",
|
||||||
|
"detail": "This file (or one previously ingested with the same claim control number) collides with an existing record. 2 of 141 claims collide with batch B123.",
|
||||||
|
"batch_id": null,
|
||||||
|
"existing_batch_id": "B123",
|
||||||
|
"collisions": {
|
||||||
|
"colliding_claim_ids": ["A", "B"],
|
||||||
|
"total_collisions": 2,
|
||||||
|
"total_claims": 141,
|
||||||
|
"new_claims_after_skip": 139
|
||||||
|
},
|
||||||
|
"parse_result": { ... full ParseResult ... }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Field semantics:
|
||||||
|
- `error`: short tag for the UI ("Duplicate claim", "Duplicate remittance", "Within-file duplicate CLM01").
|
||||||
|
- `detail`: human-readable, mentions the count and the existing batch when known.
|
||||||
|
- `batch_id`: always `null` on 409 (the insert rolled back).
|
||||||
|
- `existing_batch_id`: the most-recent prior batch that contains a colliding CLM01, or `null` if (a) the collision is within-file, or (b) the colliding claim has since been deleted (race).
|
||||||
|
- `collisions.colliding_claim_ids`: subset of `parse_result.claims[].claim_id` that collides.
|
||||||
|
- `collisions.total_claims`: count from `parse_result.summary.total_claims`.
|
||||||
|
- `collisions.new_claims_after_skip`: `total_claims - total_collisions`.
|
||||||
|
- `parse_result`: the full `ParseResult` (same shape as a 200 response body). The UI uses this to render the parsed claims list.
|
||||||
|
|
||||||
|
The 200 body on `force=true` adds `skipped_claim_ids: ["A", "B"]` at the top level so the UI can show a "skipped" badge per claim.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. `DELETE /api/batches/{id}`
|
||||||
|
|
||||||
|
New endpoint. Cascades through `ON DELETE CASCADE` FKs:
|
||||||
|
|
||||||
|
```
|
||||||
|
batches ─┬─ claims ─┬─ matches
|
||||||
|
│ ├─ activity_events (claim_id)
|
||||||
|
│ └─ line_reconciliations
|
||||||
|
├─ remittances ─┬─ cas_adjustments
|
||||||
|
│ ├─ service_line_payments
|
||||||
|
│ └─ activity_events (remittance_id)
|
||||||
|
└─ activity_events (batch_id only)
|
||||||
|
```
|
||||||
|
|
||||||
|
FKs already declare `ON DELETE CASCADE` in the migrations, so the
|
||||||
|
SQLite engine handles the cascade. The endpoint just needs to
|
||||||
|
`session.delete(batch_row)` and commit.
|
||||||
|
|
||||||
|
The endpoint:
|
||||||
|
- `204 No Content` on success.
|
||||||
|
- `404 Not Found` if the batch doesn't exist.
|
||||||
|
- `409 Conflict` if the batch has any claims in a non-`submitted` state
|
||||||
|
(e.g., `paid`, `reversed`, `denied`). Forces the user to first
|
||||||
|
unreconcile — same as the existing 409 pattern for `manual_match` /
|
||||||
|
`manual_unmatch` (see `store.py:AlreadyMatchedError`).
|
||||||
|
|
||||||
|
A `batch_deleted` activity event is recorded before the delete so the
|
||||||
|
audit log has a tombstone. The event's `batch_id` will be `null` after
|
||||||
|
the cascade (the FK is to `batches.id` with no `ON DELETE` clause
|
||||||
|
specified in any migration; verify in `migrations/0001_initial.sql` —
|
||||||
|
the spec says we preserve audit history). If the FK is `ON DELETE
|
||||||
|
CASCADE`, we record the event AFTER the cascade with `batch_id` set to
|
||||||
|
the deleted id and rely on the cascade to remove it (acceptable, or we
|
||||||
|
use a no-cascade FK and keep the tombstone). **Open question resolved
|
||||||
|
during implementation by reading the actual FK clauses.**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Backend implementation
|
||||||
|
|
||||||
|
### 7.1 New dedup helper
|
||||||
|
|
||||||
|
`backend/src/cyclone/store.py` (already added in `claims-unique-fix`):
|
||||||
|
|
||||||
|
```python
|
||||||
|
def find_existing_batch_for_claim(claim_id: str) -> str | None:
|
||||||
|
"""Return the batch_id of the first batch containing this claim id, or None.
|
||||||
|
|
||||||
|
Pure read; opens a short-lived session. Used by the 837 409 handler to
|
||||||
|
surface which prior batch already holds the same CLM01.
|
||||||
|
|
||||||
|
Returns the most-recent batch (ORDER BY parsed_at DESC LIMIT 1) so the
|
||||||
|
UI links to the most likely "where did the dup come from" answer.
|
||||||
|
"""
|
||||||
|
from sqlalchemy import select
|
||||||
|
from cyclone.db import Claim
|
||||||
|
with db.SessionLocal()() as s:
|
||||||
|
row = s.execute(
|
||||||
|
select(Claim.batch_id)
|
||||||
|
.where(Claim.id == claim_id)
|
||||||
|
.order_by(Claim.state_changed_at.desc()) # most-recent touch
|
||||||
|
.limit(1)
|
||||||
|
).first()
|
||||||
|
return row[0] if row else None
|
||||||
|
|
||||||
|
|
||||||
|
def find_existing_batch_for_remit(remit_id: str) -> str | None:
|
||||||
|
"""Same shape as find_existing_batch_for_claim but for remittances."""
|
||||||
|
from sqlalchemy import select
|
||||||
|
from cyclone.db import Remittance
|
||||||
|
with db.SessionLocal()() as s:
|
||||||
|
row = s.execute(
|
||||||
|
select(Remittance.batch_id)
|
||||||
|
.where(Remittance.id == remit_id)
|
||||||
|
.order_by(Remittance.received_at.desc())
|
||||||
|
.limit(1)
|
||||||
|
).first()
|
||||||
|
return row[0] if row else None
|
||||||
|
```
|
||||||
|
|
||||||
|
The current `claims-unique-fix` implementation uses
|
||||||
|
`select(Claim.batch_id).where(Claim.id == claim_id).limit(1)` without
|
||||||
|
`ORDER BY`. We replace it with the ordered version to satisfy
|
||||||
|
"return the most-recent colliding batch".
|
||||||
|
|
||||||
|
### 7.2 New pre-flight dedup check
|
||||||
|
|
||||||
|
`backend/src/cyclone/dedup.py` (new file, single responsibility):
|
||||||
|
|
||||||
|
```python
|
||||||
|
"""Pre-flight dedup for parsed 837P/835 batches.
|
||||||
|
|
||||||
|
Splits the parsed result into "would-insert" and "would-skip" sets by
|
||||||
|
querying the DB for any claim_id / payer_claim_control_number already
|
||||||
|
present. Also detects within-file duplicates by counting claim_id
|
||||||
|
frequencies.
|
||||||
|
|
||||||
|
Used by the parse-837 and parse-835 endpoints between validation and
|
||||||
|
persist, so the user can see the parse result + collision summary
|
||||||
|
before any DB write.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from collections import Counter
|
||||||
|
from dataclasses import dataclass
|
||||||
|
|
||||||
|
from sqlalchemy import select
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from cyclone import db
|
||||||
|
from cyclone.db import Claim, Remittance
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class CollisionReport:
|
||||||
|
"""What the parse endpoint needs to render a 409 response."""
|
||||||
|
colliding_claim_ids: list[str] # CLM01s (837) or CLP01s (835)
|
||||||
|
existing_batch_id: str | None # most-recent prior batch with a collision, or None
|
||||||
|
within_file_duplicate_ids: list[str] # CLM01s appearing twice in this file (subset of colliding_claim_ids)
|
||||||
|
total_claims: int
|
||||||
|
|
||||||
|
|
||||||
|
def preflight_837(result, session: Session | None = None) -> CollisionReport:
|
||||||
|
"""Detect 837 collisions: within-file dupes + cross-batch CLM01 dupes."""
|
||||||
|
claim_ids = [c.claim_id for c in result.claims]
|
||||||
|
counts = Counter(claim_ids)
|
||||||
|
within_file_duplicate_ids = sorted(
|
||||||
|
cid for cid, n in counts.items() if n > 1
|
||||||
|
)
|
||||||
|
seen: set[str] = set(claim_ids)
|
||||||
|
if not seen:
|
||||||
|
return CollisionReport(
|
||||||
|
colliding_claim_ids=[],
|
||||||
|
existing_batch_id=None,
|
||||||
|
within_file_duplicate_ids=[],
|
||||||
|
total_claims=0,
|
||||||
|
)
|
||||||
|
|
||||||
|
own_session = session is None
|
||||||
|
if own_session:
|
||||||
|
session = db.SessionLocal()()
|
||||||
|
try:
|
||||||
|
rows = session.execute(
|
||||||
|
select(Claim.id, Claim.batch_id)
|
||||||
|
.where(Claim.id.in_(seen))
|
||||||
|
.order_by(Claim.state_changed_at.desc())
|
||||||
|
).all()
|
||||||
|
finally:
|
||||||
|
if own_session:
|
||||||
|
session.close()
|
||||||
|
|
||||||
|
db_collisions = {cid: bid for cid, bid in rows}
|
||||||
|
colliding = sorted(cid for cid in seen if cid in db_collisions)
|
||||||
|
existing_batch_id = next(iter(db_collisions.values()), None) if db_collisions else None
|
||||||
|
return CollisionReport(
|
||||||
|
colliding_claim_ids=colliding,
|
||||||
|
existing_batch_id=existing_batch_id,
|
||||||
|
within_file_duplicate_ids=within_file_duplicate_ids,
|
||||||
|
total_claims=len(claim_ids),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def preflight_835(result, session: Session | None = None) -> CollisionReport:
|
||||||
|
"""Same shape for 835 remittances. Payer claim control number = CLP01 = remittance.id."""
|
||||||
|
pcns = [c.payer_claim_control_number for c in result.claims]
|
||||||
|
counts = Counter(pcns)
|
||||||
|
within_file_duplicate_ids = sorted(p for p, n in counts.items() if n > 1)
|
||||||
|
seen = set(pcns)
|
||||||
|
if not seen:
|
||||||
|
return CollisionReport(
|
||||||
|
colliding_claim_ids=[],
|
||||||
|
existing_batch_id=None,
|
||||||
|
within_file_duplicate_ids=[],
|
||||||
|
total_claims=0,
|
||||||
|
)
|
||||||
|
|
||||||
|
own_session = session is None
|
||||||
|
if own_session:
|
||||||
|
session = db.SessionLocal()()
|
||||||
|
try:
|
||||||
|
rows = session.execute(
|
||||||
|
select(Remittance.id, Remittance.batch_id)
|
||||||
|
.where(Remittance.id.in_(seen))
|
||||||
|
.order_by(Remittance.received_at.desc())
|
||||||
|
).all()
|
||||||
|
finally:
|
||||||
|
if own_session:
|
||||||
|
session.close()
|
||||||
|
|
||||||
|
db_collisions = {pcn: bid for pcn, bid in rows}
|
||||||
|
colliding = sorted(pcn for pcn in seen if pcn in db_collisions)
|
||||||
|
existing_batch_id = next(iter(db_collisions.values()), None) if db_collisions else None
|
||||||
|
return CollisionReport(
|
||||||
|
colliding_claim_ids=colliding,
|
||||||
|
existing_batch_id=existing_batch_id,
|
||||||
|
within_file_duplicate_ids=within_file_duplicate_ids,
|
||||||
|
total_claims=len(pcns),
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 7.3 Modified `parse_837` endpoint
|
||||||
|
|
||||||
|
```python
|
||||||
|
@app.post("/api/parse-837")
|
||||||
|
async def parse_837(
|
||||||
|
request: Request,
|
||||||
|
file: UploadFile = File(...),
|
||||||
|
payer: str = Query("co_medicaid"),
|
||||||
|
include_raw_segments: bool = Query(True),
|
||||||
|
strict: bool = Query(False),
|
||||||
|
ack: bool = Query(False),
|
||||||
|
force: bool = Query(False), # NEW
|
||||||
|
) -> Any:
|
||||||
|
# ... existing parse + validate ...
|
||||||
|
|
||||||
|
if _has_claim_validation_errors(result):
|
||||||
|
return JSONResponse(status_code=422, content=json.loads(result.model_dump_json()))
|
||||||
|
|
||||||
|
# NEW: pre-flight dedup check
|
||||||
|
if not force and result.claims:
|
||||||
|
report = dedup.preflight_837(result)
|
||||||
|
if report.colliding_claim_ids or report.within_file_duplicate_ids:
|
||||||
|
return _build_409_response(
|
||||||
|
result=result,
|
||||||
|
report=report,
|
||||||
|
error="Duplicate claim",
|
||||||
|
kind="cross_batch" if report.existing_batch_id else "within_file",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Persist (existing path). On IntegrityError (race), same 409 shape.
|
||||||
|
rec = BatchRecord(
|
||||||
|
id=uuid.uuid4().hex,
|
||||||
|
kind="837p",
|
||||||
|
input_filename=file.filename or "upload.txt",
|
||||||
|
parsed_at=utcnow(),
|
||||||
|
result=result,
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
store.add(rec, event_bus=request.app.state.event_bus)
|
||||||
|
except IntegrityError as exc:
|
||||||
|
# Race: pre-flight said clean, but persist hit a PK. Re-run pre-flight
|
||||||
|
# so the 409 body has the same shape.
|
||||||
|
report = dedup.preflight_837(result)
|
||||||
|
return _build_409_response(
|
||||||
|
result=result,
|
||||||
|
report=report,
|
||||||
|
error="Duplicate claim (race condition)",
|
||||||
|
kind="race",
|
||||||
|
)
|
||||||
|
|
||||||
|
# ... existing response ...
|
||||||
|
if _client_wants_json(request):
|
||||||
|
body = json.loads(result.model_dump_json())
|
||||||
|
if ack:
|
||||||
|
ack_body = _build_and_persist_ack(rec.id)
|
||||||
|
if ack_body is not None:
|
||||||
|
body["ack"] = ack_body
|
||||||
|
# If force=true, the store.add silently skipped some claims.
|
||||||
|
# Surface what was skipped so the UI can show a "skipped" badge.
|
||||||
|
if force:
|
||||||
|
body["skipped_claim_ids"] = sorted({
|
||||||
|
c.claim_id for c in result.claims
|
||||||
|
if _claim_skipped(c.claim_id, rec.id)
|
||||||
|
})
|
||||||
|
return JSONResponse(content=body)
|
||||||
|
# ... streaming response ...
|
||||||
|
```
|
||||||
|
|
||||||
|
Where:
|
||||||
|
|
||||||
|
```python
|
||||||
|
def _build_409_response(
|
||||||
|
result, report, error: str, kind: str
|
||||||
|
) -> JSONResponse:
|
||||||
|
"""Build the standard 409 body for any dedup failure."""
|
||||||
|
if kind == "cross_batch":
|
||||||
|
detail = (
|
||||||
|
f"{len(report.colliding_claim_ids)} of {report.total_claims} "
|
||||||
|
f"claims collide with prior batch {report.existing_batch_id}. "
|
||||||
|
f"Force-insert to skip the duplicates, or delete the prior batch."
|
||||||
|
)
|
||||||
|
elif kind == "within_file":
|
||||||
|
detail = (
|
||||||
|
f"CLM01(s) {', '.join(report.within_file_duplicate_ids)} appear "
|
||||||
|
f"twice in this file. Force-insert will keep the first occurrence "
|
||||||
|
f"and skip the rest."
|
||||||
|
)
|
||||||
|
else: # race
|
||||||
|
detail = (
|
||||||
|
f"Another process ingested a colliding batch between the check "
|
||||||
|
f"and the persist. Re-upload to retry with the latest state."
|
||||||
|
)
|
||||||
|
body = {
|
||||||
|
"error": error,
|
||||||
|
"detail": detail,
|
||||||
|
"batch_id": None,
|
||||||
|
"existing_batch_id": report.existing_batch_id,
|
||||||
|
"collisions": {
|
||||||
|
"colliding_claim_ids": report.colliding_claim_ids,
|
||||||
|
"total_collisions": len(report.colliding_claim_ids),
|
||||||
|
"total_claims": report.total_claims,
|
||||||
|
"new_claims_after_skip": report.total_claims - len(report.colliding_claim_ids),
|
||||||
|
},
|
||||||
|
"parse_result": json.loads(result.model_dump_json()),
|
||||||
|
}
|
||||||
|
return JSONResponse(status_code=409, content=body)
|
||||||
|
```
|
||||||
|
|
||||||
|
`force=true` does NOT bypass validation (still 422 for bad data). It
|
||||||
|
only bypasses the pre-flight dedup. The `store.add` dedup still skips
|
||||||
|
colliding claims silently, but the response surfaces the skip list.
|
||||||
|
|
||||||
|
### 7.4 Modified `parse_835` endpoint
|
||||||
|
|
||||||
|
Same pattern, with `dedup.preflight_835` and the 835 parse result. Not
|
||||||
|
shown in detail; the structure mirrors 837.
|
||||||
|
|
||||||
|
### 7.5 New `DELETE /api/batches/{id}`
|
||||||
|
|
||||||
|
```python
|
||||||
|
@app.delete("/api/batches/{batch_id}")
|
||||||
|
def delete_batch(batch_id: str) -> dict:
|
||||||
|
"""Hard-delete a batch and all its child rows.
|
||||||
|
|
||||||
|
Returns 204 on success, 404 if missing, 409 if the batch has any
|
||||||
|
claims/remits in a non-`submitted` state (must unreconcile first).
|
||||||
|
"""
|
||||||
|
from cyclone import db
|
||||||
|
with db.SessionLocal()() as s:
|
||||||
|
batch = s.get(db.Batch, batch_id)
|
||||||
|
if batch is None:
|
||||||
|
raise HTTPException(404, f"Batch {batch_id} not found")
|
||||||
|
# Refuse if any claim/remittance is past 'submitted' state
|
||||||
|
non_submitted = s.execute(
|
||||||
|
select(db.Claim.id)
|
||||||
|
.where(db.Claim.batch_id == batch_id)
|
||||||
|
.where(db.Claim.state != "submitted")
|
||||||
|
.limit(1)
|
||||||
|
).first()
|
||||||
|
if non_submitted is not None:
|
||||||
|
raise HTTPException(
|
||||||
|
409,
|
||||||
|
f"Batch {batch_id} has claims in non-submitted state; "
|
||||||
|
f"unreconcile first before deleting.",
|
||||||
|
)
|
||||||
|
# Record tombstone activity event before the cascade
|
||||||
|
s.add(db.ActivityEvent(
|
||||||
|
ts=utcnow(),
|
||||||
|
kind="batch_deleted",
|
||||||
|
batch_id=batch_id,
|
||||||
|
payload_json={"message": f"Batch {batch_id} deleted"},
|
||||||
|
))
|
||||||
|
s.flush()
|
||||||
|
s.delete(batch)
|
||||||
|
s.commit()
|
||||||
|
return {"ok": True, "batch_id": batch_id}
|
||||||
|
```
|
||||||
|
|
||||||
|
The FKs in the schema (`migrations/0001_initial.sql` and later) declare
|
||||||
|
`ON DELETE CASCADE` on `claims.batch_id`, `remittances.batch_id`, etc.
|
||||||
|
SQLite handles the cascade at the engine level. We verify this assumption
|
||||||
|
in the implementation test by deleting a batch with child rows and
|
||||||
|
asserting the child rows are gone.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. Frontend
|
||||||
|
|
||||||
|
### 8.1 `src/lib/api.ts`
|
||||||
|
|
||||||
|
`ApiError` carries more collision data:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
export class ApiError extends Error {
|
||||||
|
constructor(
|
||||||
|
public status: number,
|
||||||
|
message: string,
|
||||||
|
public existingBatchId: string | null = null,
|
||||||
|
public collisions: CollisionSummary | null = null,
|
||||||
|
public parseResult: unknown = null,
|
||||||
|
) {
|
||||||
|
super(message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export type CollisionSummary = {
|
||||||
|
colliding_claim_ids: string[];
|
||||||
|
total_collisions: number;
|
||||||
|
total_claims: number;
|
||||||
|
new_claims_after_skip: number;
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
`parse837` adds `?force=true` to the URL when called for the
|
||||||
|
"force-insert" action:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
export async function parse837(
|
||||||
|
file: File,
|
||||||
|
options: { onProgress?: (p: number) => void; force?: boolean } = {},
|
||||||
|
): Promise<ParseResult> {
|
||||||
|
const url = `${base}/api/parse-837${options.force ? "?force=true" : ""}`;
|
||||||
|
// ... existing fetch + body parse ...
|
||||||
|
if (!res.ok) {
|
||||||
|
const { message, existingBatchId, collisions, parseResult } = await readErrorBody(res);
|
||||||
|
throw new ApiError(res.status, message, existingBatchId, collisions, parseResult);
|
||||||
|
}
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 8.2 `src/pages/Upload.tsx`
|
||||||
|
|
||||||
|
New state:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
type UploadError = {
|
||||||
|
kind: "duplicate";
|
||||||
|
existingBatchId: string | null;
|
||||||
|
collisions: CollisionSummary;
|
||||||
|
parseResult: ParseResult;
|
||||||
|
filename: string;
|
||||||
|
};
|
||||||
|
const [uploadError, setUploadError] = useState<UploadError | null>(null);
|
||||||
|
const [forceInserting, setForceInserting] = useState(false);
|
||||||
|
```
|
||||||
|
|
||||||
|
Panel JSX (above the streaming results):
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
{uploadError ? (
|
||||||
|
<div
|
||||||
|
role="alert"
|
||||||
|
className="rounded-md border border-destructive/40 bg-destructive/5 p-4 mx-auto max-w-3xl"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="inline-flex items-center rounded-md bg-destructive px-2 py-0.5 text-xs font-semibold text-destructive-foreground">
|
||||||
|
409
|
||||||
|
</span>
|
||||||
|
<span className="font-semibold">
|
||||||
|
{uploadError.collisions.total_collisions} of {uploadError.collisions.total_claims} claims
|
||||||
|
collide
|
||||||
|
{uploadError.existingBatchId
|
||||||
|
? ` with batch ${uploadError.existingBatchId}`
|
||||||
|
: " within this file"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="mt-2 text-sm text-muted-foreground">
|
||||||
|
File <span className="font-mono">{uploadError.filename}</span> would persist
|
||||||
|
{" "}{uploadError.collisions.new_claims_after_skip} of {uploadError.collisions.total_claims} claims.
|
||||||
|
Colliding CLM01s: {uploadError.collisions.colliding_claim_ids.join(", ")}.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="mt-3 flex flex-wrap gap-2">
|
||||||
|
<Button
|
||||||
|
disabled={forceInserting}
|
||||||
|
onClick={async () => {
|
||||||
|
setForceInserting(true);
|
||||||
|
try {
|
||||||
|
// re-call with force=true; the response will be 200 + skipped_claim_ids
|
||||||
|
const result = await parse837(file, { onProgress: () => {}, force: true });
|
||||||
|
setParseResult(result);
|
||||||
|
setUploadError(null);
|
||||||
|
toast.success(
|
||||||
|
`Force-inserted: ${result.summary.total_claims - (result.skipped_claim_ids?.length ?? 0)} of ${result.summary.total_claims} claims (skipped ${result.skipped_claim_ids?.length ?? 0} dups)`,
|
||||||
|
);
|
||||||
|
} catch (err) {
|
||||||
|
toast.error(err instanceof Error ? err.message : "Force-insert failed");
|
||||||
|
} finally {
|
||||||
|
setForceInserting(false);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Force insert (skip {uploadError.collisions.total_collisions} dups)
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
{uploadError.existingBatchId ? (
|
||||||
|
<>
|
||||||
|
<Button variant="outline" onClick={() => navigate(`/batches/${uploadError.existingBatchId}`)}>
|
||||||
|
Open prior batch
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={async () => {
|
||||||
|
if (!confirm(`Delete batch ${uploadError.existingBatchId}? This cannot be undone.`)) return;
|
||||||
|
await deleteBatch(uploadError.existingBatchId);
|
||||||
|
toast.success(`Deleted ${uploadError.existingBatchId}`);
|
||||||
|
setUploadError(null);
|
||||||
|
pickFile(null);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Delete prior batch
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<Button variant="ghost" onClick={() => { setUploadError(null); pickFile(null); }}>
|
||||||
|
Pick a different file
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* The full parse result is rendered below so the user can see what was parsed. */}
|
||||||
|
<details className="mt-3 text-sm">
|
||||||
|
<summary>Show parsed claims ({uploadError.parseResult.claims.length})</summary>
|
||||||
|
<pre className="mt-2 max-h-64 overflow-auto rounded bg-muted p-2 text-xs">
|
||||||
|
{JSON.stringify(uploadError.parseResult.summary, null, 2)}
|
||||||
|
</pre>
|
||||||
|
</details>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. Database
|
||||||
|
|
||||||
|
Migration 0013 already exists on the `claims-unique-fix` worktree. It
|
||||||
|
drops the `UNIQUE(batch_id, patient_control_number)` inline constraint.
|
||||||
|
After it runs:
|
||||||
|
|
||||||
|
- The 409 fires only on actual CLM01 collisions (not the `member_id`
|
||||||
|
dedup that was over-constraining before).
|
||||||
|
- Multi-claim 837P files with shared `member_id` ingest cleanly for
|
||||||
|
the first time.
|
||||||
|
|
||||||
|
Migration 0014 (added as Task 1.3 in the plan) further relaxes the schema:
|
||||||
|
it changes the PKs on `claims` and `remittances` from single-column
|
||||||
|
(`id`) to composite (`batch_id`, `id`). This is what allows resubmits and
|
||||||
|
makes the workflow in §3 reachable.
|
||||||
|
|
||||||
|
No new tables. No new columns. The DELETE endpoint relies on existing
|
||||||
|
`ON DELETE CASCADE` FKs.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. Files changed
|
||||||
|
|
||||||
|
| File | Change |
|
||||||
|
|---|---|
|
||||||
|
| `backend/src/cyclone/migrations/0013_drop_claims_unique_constraint.sql` | new (DONE on `claims-unique-fix`) |
|
||||||
|
| `backend/src/cyclone/migrations/0014_relax_claims_remits_pk.sql` | new: composite PK `(batch_id, id)` on `claims` and `remittances`; updates FKs |
|
||||||
|
| `backend/src/cyclone/store.py` | new `find_existing_batch_for_claim` / `find_existing_batch_for_remit` (DONE) + new `delete_batch` method |
|
||||||
|
| `backend/src/cyclone/dedup.py` | new file: pre-flight `preflight_837` / `preflight_835` + `CollisionReport` dataclass |
|
||||||
|
| `backend/src/cyclone/api.py` | 837/835 endpoints: pre-flight check, force param, new 409 body, race handler, new DELETE endpoint |
|
||||||
|
| `src/lib/api.ts` | `ApiError` adds `collisions` + `parseResult`; `parse837`/`parse835` accept `force`; new `deleteBatch` |
|
||||||
|
| `src/pages/Upload.tsx` | new `UploadError` state, error panel JSX, force-insert handler, delete-prior handler |
|
||||||
|
| `src/pages/Upload.test.tsx` | new tests (4 cases from §11) |
|
||||||
|
| `backend/tests/test_db_migrate.py` | 0013 idempotency + UNIQUE-dropped tests (DONE); 0014 composite-PK + FK-cascade tests |
|
||||||
|
| `backend/tests/test_store.py` | `find_existing_batch_for_claim`/`remit` tests (DONE) |
|
||||||
|
| `backend/tests/test_dedup.py` | new tests for `preflight_837` / `preflight_835` (§11) |
|
||||||
|
| `backend/tests/test_api_parse_persists.py` | new tests: pre-flight 409, force-insert, within-file 409, race 409, DELETE endpoint (§11) |
|
||||||
|
|
||||||
|
No new dependencies. No config changes.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 11. Test plan
|
||||||
|
|
||||||
|
### Backend (pytest)
|
||||||
|
|
||||||
|
| Test | File | Asserts |
|
||||||
|
|---|---|---|
|
||||||
|
| `test_preflight_837_finds_no_collisions_on_empty_db` | `test_dedup.py` | empty DB → empty `colliding_claim_ids`, no `existing_batch_id` |
|
||||||
|
| `test_preflight_837_finds_cross_batch_collision` | `test_dedup.py` | pre-seed a claim; pre-flight returns that claim_id in `colliding_claim_ids` and the seeded batch in `existing_batch_id` |
|
||||||
|
| `test_preflight_837_finds_within_file_duplicate` | `test_dedup.py` | parsed result has the same CLM01 twice; pre-flight returns it in both `colliding_claim_ids` and `within_file_duplicate_ids` |
|
||||||
|
| `test_preflight_837_returns_most_recent_batch_id` | `test_dedup.py` | pre-seed 3 batches with the same CLM01 at different times; pre-flight returns the most-recent batch_id |
|
||||||
|
| `test_preflight_835_mirrors_837` | `test_dedup.py` | same shape for remittances |
|
||||||
|
| `test_parse_837_409_includes_parse_result_and_collisions` | `test_api_parse_persists.py` | pre-seed a claim; upload a file with a colliding CLM01; assert 409 with `parse_result`, `collisions.colliding_claim_ids`, `existing_batch_id` |
|
||||||
|
| `test_parse_837_409_within_file_duplicate_has_null_batch_id` | `test_api_parse_persists.py` | upload a file with the same CLM01 twice; assert 409 with `existing_batch_id: null` and `within_file_duplicate_ids` populated |
|
||||||
|
| `test_parse_837_force_true_persists_non_colliding_claims` | `test_api_parse_persists.py` | pre-seed a claim; upload a file with 3 claims, 1 colliding; assert 200 with `skipped_claim_ids: [colliding_id]`, the 2 non-colliding claims persist |
|
||||||
|
| `test_parse_837_force_true_does_not_bypass_validation` | `test_api_parse_persists.py` | a file that fails validation still returns 422 with `force=true` |
|
||||||
|
| `test_parse_837_race_409_uses_same_body_shape` | `test_api_parse_persists.py` | mock `store.add` to raise IntegrityError; assert 409 body has the same shape as the pre-flight 409 |
|
||||||
|
| `test_delete_batch_cascades_to_claims` | `test_api_parse_persists.py` | persist a batch with 2 claims; DELETE; assert batch and both claims are gone |
|
||||||
|
| `test_delete_batch_404_on_unknown` | `test_api_parse_persists.py` | DELETE /api/batches/does-not-exist → 404 |
|
||||||
|
| `test_delete_batch_409_on_reconciled_claims` | `test_api_parse_persists.py` | persist a batch, mark a claim state='paid'; DELETE → 409 |
|
||||||
|
| `test_parse_837_after_delete_succeeds` | `test_api_parse_persists.py` | pre-seed a colliding claim; DELETE that batch; re-upload the same file; assert 200 |
|
||||||
|
|
||||||
|
### Frontend (vitest)
|
||||||
|
|
||||||
|
| Test | File | Asserts |
|
||||||
|
|---|---|---|
|
||||||
|
| `test_error_panel_renders_on_409_with_collisions` | `Upload.test.tsx` | mock `parse837` to throw `ApiError(409, ..., PRIOR, collisions, parseResult)`; assert panel visible with all collision data |
|
||||||
|
| `test_force_insert_button_re_calls_with_force_true` | `Upload.test.tsx` | user clicks "Force insert"; assert `parse837` is called with `{ force: true }` |
|
||||||
|
| `test_delete_prior_button_calls_deleteBatch` | `Upload.test.tsx` | user clicks "Delete prior batch"; assert `deleteBatch(existingBatchId)` is called |
|
||||||
|
| `test_pick_different_clears_error` | `Upload.test.tsx` | user clicks "Pick a different file"; assert `uploadError` is cleared and file picker is reset |
|
||||||
|
| `test_no_panel_on_non_409` | `Upload.test.tsx` | 400 error; assert panel absent |
|
||||||
|
| `test_within_file_duplicate_omits_prior_batch_actions` | `Upload.test.tsx` | 409 with `existingBatchId: null`; assert "Open prior batch" and "Delete prior batch" buttons are absent |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 12. Out of scope
|
||||||
|
|
||||||
|
* Batch editing (update claim state, edit claim fields). Future SP.
|
||||||
|
* Cross-batch dedup REPORT (a "find all CLM01s in batches B1+B2+B3"
|
||||||
|
query). Future SP.
|
||||||
|
* Migration reversibility for 0013 — the recreation preserves data but
|
||||||
|
not schema history. Acceptable since 0013 just drops an inline
|
||||||
|
constraint; recreating the constraint would be a separate migration.
|
||||||
|
* Audit event for force-insert skips. The user explicitly chose to
|
||||||
|
skip silently; we honor that.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 13. Risk
|
||||||
|
|
||||||
|
* **Pre-flight check race**: between the check and the persist, a
|
||||||
|
concurrent process could ingest a colliding claim. The persist would
|
||||||
|
then raise `IntegrityError`; the handler returns the same 409 shape
|
||||||
|
with `detail` mentioning the race. The user re-runs. Acceptable.
|
||||||
|
* **DELETE on a large batch**: cascade through `claims`, `remittances`,
|
||||||
|
`matches`, `line_reconciliations`, `activity_events`. SQLite handles
|
||||||
|
the cascade in a single transaction; a 140-claim batch deletes in
|
||||||
|
<100ms. The endpoint refuses if any claim is past `submitted` state.
|
||||||
|
* **`force=true` silent skip**: the user clicks "Force insert" and
|
||||||
|
the response says "X of Y claims persisted, Z skipped". They
|
||||||
|
acknowledged this in the panel before clicking. No undo.
|
||||||
|
* **Within-file duplicates and force-insert**: the user can force-insert
|
||||||
|
a file with the same CLM01 twice. The first instance persists, the
|
||||||
|
second is silently skipped. This is intentional — within-file dupes
|
||||||
|
are usually a typo, and the user has explicitly asked to proceed.
|
||||||
|
* **`existing_batch_id` may be stale**: the helper returns the
|
||||||
|
most-recent batch by `state_changed_at` (or `received_at` for 835).
|
||||||
|
The user clicks "Open prior batch" and the batch may have been
|
||||||
|
deleted in the meantime. The BatchesList page already handles 404
|
||||||
|
gracefully.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 14. Rollout
|
||||||
|
|
||||||
|
1. **Schema**: migration 0013 applies on next `cyclone` startup.
|
||||||
|
Idempotent and reversible only by rebuilding the `claims` table
|
||||||
|
(acceptable; production data preserved by the INSERT...SELECT).
|
||||||
|
2. **Backend API**: new `force` param + new 409 body shape + new
|
||||||
|
DELETE endpoint. Existing clients that don't pass `force` see the
|
||||||
|
same behavior as before for collision-free files. Collision cases
|
||||||
|
now get a richer 409 body that includes `parse_result`; clients
|
||||||
|
that ignore the new fields keep working.
|
||||||
|
3. **Frontend**: `Upload.tsx` panel replaces the toast on 409. Users
|
||||||
|
who don't read the panel still see the toast and the 409 message
|
||||||
|
in the streaming view.
|
||||||
|
4. **No data migration**: nothing to migrate. 0013 is structural only.
|
||||||
@@ -0,0 +1,192 @@
|
|||||||
|
// @vitest-environment happy-dom
|
||||||
|
// AckDrawer wires `useAckDetail` (TanStack Query) and renders a Radix
|
||||||
|
// Dialog portal — both need an act-aware, DOM-backed environment or
|
||||||
|
// React logs warnings and the portal can't mount.
|
||||||
|
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
||||||
|
|
||||||
|
import { afterEach, describe, it, expect, vi } from "vitest";
|
||||||
|
import { cleanup, render } from "@testing-library/react";
|
||||||
|
import { ApiError } from "@/lib/api";
|
||||||
|
import { AckDrawer } from "@/components/AckDrawer";
|
||||||
|
import type { Ack } from "@/types";
|
||||||
|
|
||||||
|
// Mock the hook BEFORE the import above is resolved (vitest hoists
|
||||||
|
// `vi.mock` to the top of the file regardless of where it appears
|
||||||
|
// syntactically). Mocking the hook directly — rather than mocking
|
||||||
|
// `api.getAck` — lets each test pin the hook's exact return shape
|
||||||
|
// without standing up a real `QueryClient`.
|
||||||
|
const { useAckDetail } = vi.hoisted(() => ({
|
||||||
|
useAckDetail: vi.fn(),
|
||||||
|
}));
|
||||||
|
vi.mock("@/hooks/useAckDetail", () => ({
|
||||||
|
useAckDetail,
|
||||||
|
}));
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Minimal valid `Ack` fixture — every required key present so the
|
||||||
|
* component typechecks. The wire shape extends with `raw_999_text`
|
||||||
|
* (and `rawJson`), per `useAckDetail`'s `AckDetail` type — populated
|
||||||
|
* when the backend serves it; absent on older rows.
|
||||||
|
*/
|
||||||
|
const SAMPLE_ACK: Ack & { raw_999_text: string } = {
|
||||||
|
id: 42,
|
||||||
|
sourceBatchId: "b-uuid-1",
|
||||||
|
acceptedCount: 3,
|
||||||
|
rejectedCount: 1,
|
||||||
|
receivedCount: 4,
|
||||||
|
ackCode: "P",
|
||||||
|
parsedAt: "2026-06-20T12:00:00Z",
|
||||||
|
raw_999_text: "ISA*...*~\nGS*...*~\nST*999*0001~",
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Configure the mocked hook's return value for a single test. The
|
||||||
|
* `refetch` default is a fresh `vi.fn()` — tests that need to assert
|
||||||
|
* on it can override via `overrides.refetch`.
|
||||||
|
*/
|
||||||
|
function mockDetail(
|
||||||
|
overrides: Partial<{
|
||||||
|
data: (Ack & { raw_999_text?: string }) | null;
|
||||||
|
isLoading: boolean;
|
||||||
|
isError: boolean;
|
||||||
|
error: Error | null;
|
||||||
|
refetch: () => void;
|
||||||
|
}> = {}
|
||||||
|
) {
|
||||||
|
useAckDetail.mockReturnValue({
|
||||||
|
data: null,
|
||||||
|
isLoading: false,
|
||||||
|
isError: false,
|
||||||
|
error: null,
|
||||||
|
refetch: vi.fn(),
|
||||||
|
...overrides,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// happy-dom keeps `document.body` between tests; without cleanup,
|
||||||
|
// `screen.getByText(...)` would find nodes from earlier renders.
|
||||||
|
afterEach(() => {
|
||||||
|
cleanup();
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("AckDrawer", () => {
|
||||||
|
it("test_renders_nothing_when_ackId_is_null", () => {
|
||||||
|
mockDetail({ data: null });
|
||||||
|
render(<AckDrawer ackId={null} onClose={() => {}} />);
|
||||||
|
|
||||||
|
// No ack content should be in the document when the drawer is
|
||||||
|
// closed — Radix's Dialog gates the portal on `open`.
|
||||||
|
expect(document.body.textContent).not.toContain("b-uuid-1");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("test_calls_useAckDetail_with_ackId", () => {
|
||||||
|
mockDetail({ data: SAMPLE_ACK });
|
||||||
|
render(<AckDrawer ackId="42" onClose={() => {}} />);
|
||||||
|
|
||||||
|
expect(useAckDetail).toHaveBeenCalledWith("42");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("test_renders_ack_summary_on_success", () => {
|
||||||
|
mockDetail({ data: SAMPLE_ACK });
|
||||||
|
render(<AckDrawer ackId="42" onClose={() => {}} />);
|
||||||
|
|
||||||
|
// Header shows the source batch id as the title.
|
||||||
|
expect(document.body.textContent).toContain("b-uuid-1");
|
||||||
|
// Counts (3 accepted, 1 rejected, 4 received) are surfaced as
|
||||||
|
// StatTile values.
|
||||||
|
expect(document.body.textContent).toContain("3");
|
||||||
|
expect(document.body.textContent).toContain("1");
|
||||||
|
expect(document.body.textContent).toContain("4");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("test_renders_ack_code_pill_with_human_label", () => {
|
||||||
|
mockDetail({ data: { ...SAMPLE_ACK, ackCode: "P" } });
|
||||||
|
render(<AckDrawer ackId="42" onClose={() => {}} />);
|
||||||
|
|
||||||
|
// The pill renders a human label, not just the bare code letter.
|
||||||
|
expect(document.body.textContent).toContain("Partially accepted");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("test_renders_skeleton_while_loading", () => {
|
||||||
|
mockDetail({ isLoading: true });
|
||||||
|
render(<AckDrawer ackId="42" onClose={() => {}} />);
|
||||||
|
|
||||||
|
// The `Skeleton` primitive sets `aria-busy="true"` — a stable
|
||||||
|
// hook for the loading state.
|
||||||
|
expect(document.querySelectorAll('[aria-busy="true"]').length).toBeGreaterThan(0);
|
||||||
|
// And the ack id should NOT have leaked in yet.
|
||||||
|
expect(document.body.textContent).not.toContain("b-uuid-1");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("test_renders_not_found_error_on_404", () => {
|
||||||
|
mockDetail({
|
||||||
|
isError: true,
|
||||||
|
error: new ApiError(404, "Ack ghost not found"),
|
||||||
|
});
|
||||||
|
render(<AckDrawer ackId="9999" onClose={() => {}} />);
|
||||||
|
|
||||||
|
const errEl = document.querySelector('[data-testid="ack-drawer-error-not_found"]');
|
||||||
|
expect(errEl).not.toBeNull();
|
||||||
|
// Body should mention "doesn't exist" — the not_found COPY key.
|
||||||
|
expect(errEl?.textContent).toContain("doesn't exist");
|
||||||
|
// And the retry button should NOT be present (not_found has no
|
||||||
|
// retry affordance — retrying a 404 won't help).
|
||||||
|
expect(document.querySelector('[data-testid="error-retry"]')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("test_renders_network_error_with_retry", () => {
|
||||||
|
mockDetail({ isError: true, error: new Error("network down") });
|
||||||
|
render(<AckDrawer ackId="42" onClose={() => {}} />);
|
||||||
|
|
||||||
|
const errEl = document.querySelector('[data-testid="ack-drawer-error-network"]');
|
||||||
|
expect(errEl).not.toBeNull();
|
||||||
|
expect(errEl?.textContent).toContain("Couldn't reach the server");
|
||||||
|
|
||||||
|
// Network variant shows a Retry button.
|
||||||
|
const retryBtn = document.querySelector(
|
||||||
|
'[data-testid="error-retry"]'
|
||||||
|
) as HTMLButtonElement | null;
|
||||||
|
expect(retryBtn).not.toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("test_close_button_calls_onClose", () => {
|
||||||
|
const onClose = vi.fn<() => void>();
|
||||||
|
mockDetail({ isError: true, error: new Error("network down") });
|
||||||
|
render(<AckDrawer ackId="42" onClose={onClose} />);
|
||||||
|
|
||||||
|
const closeBtn = document.querySelector(
|
||||||
|
'[data-testid="error-close"]'
|
||||||
|
) as HTMLButtonElement | null;
|
||||||
|
expect(closeBtn).not.toBeNull();
|
||||||
|
closeBtn!.click();
|
||||||
|
expect(onClose).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("test_renders_download_button_when_raw_999_text_present", () => {
|
||||||
|
mockDetail({ data: SAMPLE_ACK });
|
||||||
|
render(<AckDrawer ackId="42" onClose={() => {}} />);
|
||||||
|
|
||||||
|
// The header action slot populates with the Download 999 button
|
||||||
|
// only when the ack detail carries raw_999_text.
|
||||||
|
const dlBtn = document.querySelector('[data-testid="ack-drawer-download"]');
|
||||||
|
expect(dlBtn).not.toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("test_omits_download_button_when_raw_999_text_absent", () => {
|
||||||
|
const data: Ack = {
|
||||||
|
id: 42,
|
||||||
|
sourceBatchId: "b-uuid-1",
|
||||||
|
acceptedCount: 3,
|
||||||
|
rejectedCount: 1,
|
||||||
|
receivedCount: 4,
|
||||||
|
ackCode: "A",
|
||||||
|
parsedAt: "2026-06-20T12:00:00Z",
|
||||||
|
};
|
||||||
|
mockDetail({ data });
|
||||||
|
render(<AckDrawer ackId="42" onClose={() => {}} />);
|
||||||
|
|
||||||
|
// No raw_999_text → no Download button.
|
||||||
|
expect(document.querySelector('[data-testid="ack-drawer-download"]')).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,324 @@
|
|||||||
|
import { useCallback, useState } from "react";
|
||||||
|
import { Download } from "lucide-react";
|
||||||
|
import { Dialog, DialogContent } from "@/components/ui/dialog";
|
||||||
|
import { ApiError } from "@/lib/api";
|
||||||
|
import { DrillDrawerHeader } from "@/components/drill/DrillDrawerHeader";
|
||||||
|
import { Skeleton } from "@/components/ui/skeleton";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import { useAckDetail, type AckDetail } from "@/hooks/useAckDetail";
|
||||||
|
import { SegmentStatusList } from "./SegmentStatusList";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
/**
|
||||||
|
* Currently-open ack id (string), or `null` when the drawer is
|
||||||
|
* closed. The URL stores ids as strings so deep links round-trip
|
||||||
|
* cleanly; the hook does the `Number()` coercion before calling
|
||||||
|
* `api.getAck`.
|
||||||
|
*/
|
||||||
|
ackId: string | null;
|
||||||
|
/** Fired when the user dismisses the drawer (X button, Escape, etc.). */
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Roll a hex code into a "kind" so the drawer's error branch can
|
||||||
|
* pick the right copy. Mirrors `ProviderDrawer`'s `errorKind`
|
||||||
|
* computation.
|
||||||
|
*/
|
||||||
|
type ErrorKind = "not_found" | "network";
|
||||||
|
|
||||||
|
function AckDrawerError({
|
||||||
|
kind,
|
||||||
|
onRetry,
|
||||||
|
onClose,
|
||||||
|
}: {
|
||||||
|
kind: ErrorKind;
|
||||||
|
onRetry?: () => void;
|
||||||
|
onClose: () => void;
|
||||||
|
}) {
|
||||||
|
const COPY = {
|
||||||
|
not_found: {
|
||||||
|
eyebrow: "NOT FOUND",
|
||||||
|
message: "This 999 ACK doesn't exist or has been removed.",
|
||||||
|
},
|
||||||
|
network: {
|
||||||
|
eyebrow: "CONNECTION",
|
||||||
|
message:
|
||||||
|
"Couldn't reach the server. Check your connection and try again.",
|
||||||
|
},
|
||||||
|
} as const;
|
||||||
|
const { eyebrow, message } = COPY[kind];
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="flex h-full flex-col"
|
||||||
|
role="alert"
|
||||||
|
data-testid={`ack-drawer-error-${kind}`}
|
||||||
|
>
|
||||||
|
<DrillDrawerHeader eyebrow="999 ACK" title="—" onClose={onClose} />
|
||||||
|
<div className="flex flex-1 flex-col items-start gap-4 px-6 py-6">
|
||||||
|
<span className="text-[10.5px] font-semibold uppercase tracking-[0.18em] text-destructive">
|
||||||
|
{eyebrow}
|
||||||
|
</span>
|
||||||
|
<p className="text-sm text-muted-foreground max-w-sm">{message}</p>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{kind === "network" && onRetry ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onRetry}
|
||||||
|
data-testid="error-retry"
|
||||||
|
className="rounded-md border px-2.5 py-1 text-[12.5px] font-medium hover:bg-muted"
|
||||||
|
>
|
||||||
|
Retry
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onClose}
|
||||||
|
data-testid="error-close"
|
||||||
|
className="rounded-md border px-2.5 py-1 text-[12.5px] font-medium hover:bg-muted"
|
||||||
|
>
|
||||||
|
Close
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Inline ack code pill — same color palette as `AcksPage`
|
||||||
|
* (`Acks.tsx`) so the badge reads the same in both surfaces.
|
||||||
|
*/
|
||||||
|
function AckCodePill({ code }: { code: AckDetail["ackCode"] }) {
|
||||||
|
const cfg =
|
||||||
|
code === "A"
|
||||||
|
? {
|
||||||
|
fg: "hsl(152 64% 30%)",
|
||||||
|
bg: "hsl(152 50% 88%)",
|
||||||
|
border: "hsl(152 64% 38% / 0.30)",
|
||||||
|
label: "Accepted",
|
||||||
|
}
|
||||||
|
: code === "R"
|
||||||
|
? {
|
||||||
|
fg: "hsl(358 70% 36%)",
|
||||||
|
bg: "hsl(358 70% 92%)",
|
||||||
|
border: "hsl(358 70% 50% / 0.30)",
|
||||||
|
label: "Rejected",
|
||||||
|
}
|
||||||
|
: code === "P"
|
||||||
|
? {
|
||||||
|
fg: "hsl(36 92% 30%)",
|
||||||
|
bg: "hsl(36 82% 88%)",
|
||||||
|
border: "hsl(36 92% 50% / 0.30)",
|
||||||
|
label: "Partially accepted",
|
||||||
|
}
|
||||||
|
: {
|
||||||
|
fg: "hsl(36 92% 30%)",
|
||||||
|
bg: "hsl(36 82% 88%)",
|
||||||
|
border: "hsl(36 92% 50% / 0.30)",
|
||||||
|
label: "Accepted w/ errors",
|
||||||
|
};
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className="inline-flex items-center rounded-sm border px-2 py-0.5 mono text-[10.5px] font-semibold uppercase tracking-[0.14em]"
|
||||||
|
style={{ color: cfg.fg, backgroundColor: cfg.bg, borderColor: cfg.border }}
|
||||||
|
>
|
||||||
|
{cfg.label}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function StatTile({
|
||||||
|
label,
|
||||||
|
value,
|
||||||
|
tone,
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
value: number | string;
|
||||||
|
tone: "success" | "destructive" | "ink";
|
||||||
|
}) {
|
||||||
|
const color =
|
||||||
|
tone === "success"
|
||||||
|
? "hsl(152 64% 30%)"
|
||||||
|
: tone === "destructive"
|
||||||
|
? "hsl(358 70% 36%)"
|
||||||
|
: "hsl(var(--foreground))";
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="rounded-lg border px-3.5 py-3"
|
||||||
|
style={{
|
||||||
|
backgroundColor: "hsl(var(--card) / 0.4)",
|
||||||
|
borderColor: "hsl(var(--border) / 0.5)",
|
||||||
|
}}
|
||||||
|
data-testid="ack-stat"
|
||||||
|
>
|
||||||
|
<div className="text-[10px] font-semibold uppercase tracking-[0.18em] text-muted-foreground">
|
||||||
|
{label}
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className="display mono tabular-nums mt-1"
|
||||||
|
style={{ color, fontSize: 22, lineHeight: 1.1 }}
|
||||||
|
>
|
||||||
|
{value}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 999 ACK drill-down drawer (SP21 Phase 5 Task 5.2).
|
||||||
|
*
|
||||||
|
* Mirror of `ProviderDrawer` — same right-anchored side-panel shell,
|
||||||
|
* same `errorKind` + `useAckDetail` shape (404 vs network), same
|
||||||
|
* skeleton-first loading state. The body is slim: rolled-up counts
|
||||||
|
* at the top, the ack code pill, the source batch id, and a
|
||||||
|
* per-segment status list. The header carries a "Download 999"
|
||||||
|
* action so the user can grab the original X12 file from the drawer
|
||||||
|
* without a second round-trip to `/api/acks/{id}/raw`.
|
||||||
|
*
|
||||||
|
* Layout mirrors `ProviderDrawer`/`ClaimDrawer`: Radix Dialog
|
||||||
|
* repositioned to the right edge as a fixed-height side panel, with
|
||||||
|
* the shared `DrillDrawerHeader` on top and a scrollable body below.
|
||||||
|
*
|
||||||
|
* Error branching:
|
||||||
|
* - `ApiError(404)` → "not_found" (no retry, the ack is gone)
|
||||||
|
* - anything else → "network" (retry available)
|
||||||
|
*/
|
||||||
|
export function AckDrawer({ ackId, onClose }: Props) {
|
||||||
|
const { data, isLoading, isError, error, refetch } = useAckDetail(ackId);
|
||||||
|
|
||||||
|
const errorKind: ErrorKind | null = isError
|
||||||
|
? error instanceof ApiError && error.status === 404
|
||||||
|
? "not_found"
|
||||||
|
: "network"
|
||||||
|
: null;
|
||||||
|
|
||||||
|
// Download affordance for the regenerated 999 X12 text. Lives in
|
||||||
|
// the drawer (not the page) so a deep-linked user can grab the file
|
||||||
|
// without bouncing back to /acks. `raw_999_text` may be empty for
|
||||||
|
// older rows without the field — we silently no-op rather than
|
||||||
|
// surfacing an error toast (the spec calls this a "low stakes"
|
||||||
|
// affordance).
|
||||||
|
const [downloading, setDownloading] = useState(false);
|
||||||
|
const onDownload = useCallback(async () => {
|
||||||
|
if (!data) return;
|
||||||
|
const raw =
|
||||||
|
(data as AckDetail & { raw_999_text?: string }).raw_999_text ?? "";
|
||||||
|
if (!raw || downloading) return;
|
||||||
|
setDownloading(true);
|
||||||
|
try {
|
||||||
|
const blob = new Blob([raw], { type: "text/plain" });
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const a = document.createElement("a");
|
||||||
|
a.href = url;
|
||||||
|
a.download = `ack-${data.sourceBatchId}.999`;
|
||||||
|
a.click();
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
} finally {
|
||||||
|
setDownloading(false);
|
||||||
|
}
|
||||||
|
}, [data, downloading]);
|
||||||
|
|
||||||
|
const downloadAction =
|
||||||
|
data && (data as AckDetail & { raw_999_text?: string }).raw_999_text ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onDownload}
|
||||||
|
disabled={downloading}
|
||||||
|
aria-label="Download 999 file"
|
||||||
|
title="Download 999 file"
|
||||||
|
data-testid="ack-drawer-download"
|
||||||
|
className={cn(
|
||||||
|
"inline-flex items-center gap-1.5 rounded-md border px-2 py-1 text-[11px] font-medium uppercase tracking-[0.14em] mono transition-colors",
|
||||||
|
downloading && "opacity-50 cursor-not-allowed",
|
||||||
|
)}
|
||||||
|
style={{
|
||||||
|
borderColor: "hsl(var(--border) / 0.7)",
|
||||||
|
color: "hsl(var(--foreground))",
|
||||||
|
backgroundColor: "hsl(var(--card) / 0.5)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Download className="h-3.5 w-3.5" strokeWidth={1.75} aria-hidden />
|
||||||
|
999
|
||||||
|
</button>
|
||||||
|
) : null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={ackId !== null} onOpenChange={(o) => { if (!o) onClose(); }}>
|
||||||
|
<DialogContent
|
||||||
|
className="fixed right-0 top-0 flex h-full w-full max-w-2xl flex-col translate-x-0 translate-y-0 rounded-none border-l border-border bg-card p-0"
|
||||||
|
aria-describedby={undefined}
|
||||||
|
data-testid="ack-drawer"
|
||||||
|
>
|
||||||
|
{errorKind ? (
|
||||||
|
<AckDrawerError
|
||||||
|
kind={errorKind}
|
||||||
|
onRetry={() => {
|
||||||
|
void refetch();
|
||||||
|
}}
|
||||||
|
onClose={onClose}
|
||||||
|
/>
|
||||||
|
) : isLoading || !data ? (
|
||||||
|
<div className="flex h-full flex-col overflow-y-auto">
|
||||||
|
<DrillDrawerHeader
|
||||||
|
eyebrow="999 ACK"
|
||||||
|
title="Loading…"
|
||||||
|
onClose={onClose}
|
||||||
|
/>
|
||||||
|
<div className="space-y-2 p-6">
|
||||||
|
<Skeleton variant="row" />
|
||||||
|
<Skeleton variant="row" />
|
||||||
|
<Skeleton variant="row" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div
|
||||||
|
className="flex h-full flex-col overflow-y-auto"
|
||||||
|
data-testid="ack-drawer-content"
|
||||||
|
>
|
||||||
|
<DrillDrawerHeader
|
||||||
|
eyebrow="999 ACK"
|
||||||
|
title={data.sourceBatchId}
|
||||||
|
onClose={onClose}
|
||||||
|
action={downloadAction}
|
||||||
|
/>
|
||||||
|
<div className="flex flex-col divide-y divide-border/40">
|
||||||
|
<section
|
||||||
|
className="flex flex-col gap-4 px-6 py-4"
|
||||||
|
data-testid="ack-summary"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-3 flex-wrap">
|
||||||
|
<AckCodePill code={data.ackCode} />
|
||||||
|
<span className="mono text-[12px] text-muted-foreground">
|
||||||
|
ID {data.id}
|
||||||
|
</span>
|
||||||
|
<span className="mono text-[12px] text-muted-foreground">
|
||||||
|
· {data.parsedAt ? data.parsedAt.slice(0, 10) : "—"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-3 gap-3">
|
||||||
|
<StatTile
|
||||||
|
label="Accepted"
|
||||||
|
value={data.acceptedCount}
|
||||||
|
tone="success"
|
||||||
|
/>
|
||||||
|
<StatTile
|
||||||
|
label="Rejected"
|
||||||
|
value={data.rejectedCount}
|
||||||
|
tone="destructive"
|
||||||
|
/>
|
||||||
|
<StatTile
|
||||||
|
label="Received"
|
||||||
|
value={data.receivedCount}
|
||||||
|
tone="ink"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
<SegmentStatusList segments={[]} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,150 @@
|
|||||||
|
import { CheckCircle2, AlertTriangle, Minus } from "lucide-react";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One 999 ACK segment status row. The 999 spec labels each transaction
|
||||||
|
* set with a 3-char code:
|
||||||
|
*
|
||||||
|
* "A" = Accepted
|
||||||
|
* "E" = Accepted, but errors are noted (one or more segments had
|
||||||
|
* errors; the transaction set as a whole was accepted)
|
||||||
|
* "R" = Rejected
|
||||||
|
* "P" = Partially accepted (mixed — some segments accepted, some
|
||||||
|
* not; a degraded success the operator must investigate)
|
||||||
|
*
|
||||||
|
* The wire shape's `rawJson` (set by the parser, see backend
|
||||||
|
* `cyclone.parsers.parsers_999`) carries an array of segment rows.
|
||||||
|
* Until that array is parsed into a stable UI shape, we render the
|
||||||
|
* rolled-up counts on the ack row (`acceptedCount` / `rejectedCount`)
|
||||||
|
* and a placeholder explaining how to read it.
|
||||||
|
*/
|
||||||
|
interface SegmentRow {
|
||||||
|
/** Loop / segment reference like "ST*999*0001" or "AK2*HC*0001". */
|
||||||
|
reference?: string;
|
||||||
|
/** "A" | "E" | "R" | "P". */
|
||||||
|
status: "A" | "E" | "R" | "P";
|
||||||
|
/** Free-form note from the parser (e.g. "SVC*HC:9450 missing"). */
|
||||||
|
note?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
segments: SegmentRow[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pill for one segment status. Color mirrors the badge palette used on
|
||||||
|
* the Acks page (`Acks.tsx`) so a status reads the same in both
|
||||||
|
* surfaces.
|
||||||
|
*/
|
||||||
|
function StatusPill({ status }: { status: SegmentRow["status"] }) {
|
||||||
|
const cfg = {
|
||||||
|
A: {
|
||||||
|
fg: "hsl(152 64% 30%)",
|
||||||
|
bg: "hsl(152 50% 88%)",
|
||||||
|
border: "hsl(152 64% 38% / 0.30)",
|
||||||
|
label: "Accepted",
|
||||||
|
Icon: CheckCircle2,
|
||||||
|
},
|
||||||
|
E: {
|
||||||
|
fg: "hsl(36 92% 30%)",
|
||||||
|
bg: "hsl(36 82% 88%)",
|
||||||
|
border: "hsl(36 92% 50% / 0.30)",
|
||||||
|
label: "Accepted w/ errors",
|
||||||
|
Icon: AlertTriangle,
|
||||||
|
},
|
||||||
|
R: {
|
||||||
|
fg: "hsl(358 70% 36%)",
|
||||||
|
bg: "hsl(358 70% 92%)",
|
||||||
|
border: "hsl(358 70% 50% / 0.30)",
|
||||||
|
label: "Rejected",
|
||||||
|
Icon: AlertTriangle,
|
||||||
|
},
|
||||||
|
P: {
|
||||||
|
fg: "hsl(36 92% 30%)",
|
||||||
|
bg: "hsl(36 82% 88%)",
|
||||||
|
border: "hsl(36 92% 50% / 0.30)",
|
||||||
|
label: "Partially accepted",
|
||||||
|
Icon: Minus,
|
||||||
|
},
|
||||||
|
}[status];
|
||||||
|
|
||||||
|
const Icon = cfg.Icon;
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className="inline-flex items-center gap-1.5 rounded-sm border px-2 py-0.5 mono text-[10.5px] font-semibold uppercase tracking-[0.14em]"
|
||||||
|
style={{ color: cfg.fg, backgroundColor: cfg.bg, borderColor: cfg.border }}
|
||||||
|
>
|
||||||
|
<Icon className="h-3 w-3" strokeWidth={1.75} aria-hidden />
|
||||||
|
{cfg.label}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Per-segment status list inside the AckDrawer body (SP21 Phase 5
|
||||||
|
* Task 5.2). Renders one row per parsed 999 segment, each with the
|
||||||
|
* segment reference (when present), the parsed status code, and the
|
||||||
|
* parser's note (when present). An empty list renders a small
|
||||||
|
* explanatory note so the drawer doesn't look broken on old acks
|
||||||
|
* without the per-segment slice.
|
||||||
|
*/
|
||||||
|
export function SegmentStatusList({ segments }: Props) {
|
||||||
|
return (
|
||||||
|
<section
|
||||||
|
className="flex flex-col gap-3 px-6 py-4"
|
||||||
|
data-testid="ack-segment-list"
|
||||||
|
>
|
||||||
|
<div className="flex items-baseline justify-between gap-3">
|
||||||
|
<span className="text-[10.5px] font-semibold uppercase tracking-[0.18em] text-muted-foreground">
|
||||||
|
Segment status
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
className="mono text-[10.5px] text-muted-foreground"
|
||||||
|
data-testid="ack-segment-count"
|
||||||
|
>
|
||||||
|
{segments.length} segment{segments.length === 1 ? "" : "s"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{segments.length === 0 ? (
|
||||||
|
<p
|
||||||
|
className="text-[12.5px] text-muted-foreground"
|
||||||
|
data-testid="ack-segment-empty"
|
||||||
|
>
|
||||||
|
No per-segment breakdown for this ack — the rolled-up accepted
|
||||||
|
/ rejected counts above are the only signal.
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<ul className="flex flex-col divide-y divide-border/40 border-y border-border/30">
|
||||||
|
{segments.map((seg, idx) => (
|
||||||
|
<li
|
||||||
|
key={`${seg.reference ?? idx}`}
|
||||||
|
className="flex items-start gap-3 py-2.5"
|
||||||
|
data-testid="ack-segment-row"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className="mono text-[12px] tabular-nums text-muted-foreground shrink-0"
|
||||||
|
style={{ minWidth: 32 }}
|
||||||
|
>
|
||||||
|
{idx + 1}
|
||||||
|
</span>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
|
<StatusPill status={seg.status} />
|
||||||
|
{seg.reference ? (
|
||||||
|
<span className="mono text-[12px] truncate">
|
||||||
|
{seg.reference}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
{seg.note ? (
|
||||||
|
<p className="text-[12.5px] text-muted-foreground mt-1 leading-snug">
|
||||||
|
{seg.note}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
// Barrel export for the AckDrawer module (SP21 Phase 5 Task 5.2).
|
||||||
|
|
||||||
|
export { AckDrawer } from "./AckDrawer";
|
||||||
|
export { SegmentStatusList } from "./SegmentStatusList";
|
||||||
@@ -5,6 +5,7 @@ import {
|
|||||||
Plus,
|
Plus,
|
||||||
type LucideIcon,
|
type LucideIcon,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
|
import { useNavigate } from "react-router-dom";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { Skeleton } from "@/components/ui/skeleton";
|
import { Skeleton } from "@/components/ui/skeleton";
|
||||||
import {
|
import {
|
||||||
@@ -15,6 +16,7 @@ import {
|
|||||||
TableHeader,
|
TableHeader,
|
||||||
TableRow,
|
TableRow,
|
||||||
} from "@/components/ui/table";
|
} from "@/components/ui/table";
|
||||||
|
import { DrillableCell } from "@/components/drill/DrillableCell";
|
||||||
import { fmt } from "@/lib/format";
|
import { fmt } from "@/lib/format";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import type {
|
import type {
|
||||||
@@ -263,13 +265,28 @@ function RowIndicator({
|
|||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
function ClaimIdCell({ id }: { id: string }) {
|
function ClaimIdCell({ id }: { id: string }) {
|
||||||
|
// SP21 Phase 5 Task 5.6: each claim id is drillable to
|
||||||
|
// /claims?claim=ID so the operator can jump straight from a
|
||||||
|
// "Removed from A" or "Changed" row into the ClaimDrawer.
|
||||||
|
// DrillableCell handles e.stopPropagation internally — important
|
||||||
|
// if these rows ever get a row-level onClick. For removed claims
|
||||||
|
// that no longer exist in the DB, the ClaimDrawer's 404 state
|
||||||
|
// takes over (verified in Phase 2 testing).
|
||||||
|
const navigate = useNavigate();
|
||||||
return (
|
return (
|
||||||
|
<DrillableCell
|
||||||
|
onClick={() =>
|
||||||
|
navigate(`/claims?claim=${encodeURIComponent(id)}`)
|
||||||
|
}
|
||||||
|
ariaLabel={`View claim ${id} in detail`}
|
||||||
|
>
|
||||||
<span
|
<span
|
||||||
className="font-mono text-[12px] tracking-tight"
|
className="font-mono text-[12px] tracking-tight"
|
||||||
data-testid="diff-claim-id"
|
data-testid="diff-claim-id"
|
||||||
>
|
>
|
||||||
{id}
|
{id}
|
||||||
</span>
|
</span>
|
||||||
|
</DrillableCell>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import { createRoot, type Root } from "react-dom/client";
|
|||||||
import { describe, expect, it, vi, beforeEach, afterEach } from "vitest";
|
import { describe, expect, it, vi, beforeEach, afterEach } from "vitest";
|
||||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||||
import { ClaimDrawer } from "./ClaimDrawer";
|
import { ClaimDrawer } from "./ClaimDrawer";
|
||||||
|
import { DrillStackProvider } from "@/components/drill/DrillStackProvider";
|
||||||
import { api, ApiError } from "@/lib/api";
|
import { api, ApiError } from "@/lib/api";
|
||||||
import type { ClaimDetail } from "@/types";
|
import type { ClaimDetail } from "@/types";
|
||||||
|
|
||||||
@@ -162,14 +163,22 @@ function renderDrawer(
|
|||||||
React.createElement(
|
React.createElement(
|
||||||
QueryClientProvider,
|
QueryClientProvider,
|
||||||
{ client: qc },
|
{ client: qc },
|
||||||
|
// SP21 Phase 5 Task 5.8: PartiesGrid (mounted by ClaimDrawer)
|
||||||
|
// calls useDrillStack(). Wrap the drawer in DrillStackProvider
|
||||||
|
// so the hook has a context. (The provider is also mounted at
|
||||||
|
// the App root in production.)
|
||||||
|
React.createElement(
|
||||||
|
DrillStackProvider,
|
||||||
|
null,
|
||||||
React.createElement(ClaimDrawer, {
|
React.createElement(ClaimDrawer, {
|
||||||
claimId: props.claimId,
|
claimId: props.claimId,
|
||||||
claims,
|
claims,
|
||||||
onClose,
|
onClose,
|
||||||
onNavigate,
|
onNavigate,
|
||||||
onToggleHelp,
|
onToggleHelp,
|
||||||
})
|
}),
|
||||||
)
|
),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -131,14 +131,17 @@ describe("ClaimDrawerHeader", () => {
|
|||||||
it("test_renders_claim_id_label_and_value", () => {
|
it("test_renders_claim_id_label_and_value", () => {
|
||||||
const { container, unmount } = renderHeader({ id: "CLM-42" });
|
const { container, unmount } = renderHeader({ id: "CLM-42" });
|
||||||
|
|
||||||
// The eyebrow label "Claim" is rendered as uppercase text.
|
// The eyebrow label "Claim" is rendered as uppercase text by
|
||||||
|
// DrillDrawerHeader.
|
||||||
const text = (container.textContent ?? "").toLowerCase();
|
const text = (container.textContent ?? "").toLowerCase();
|
||||||
expect(text).toContain("claim");
|
expect(text).toContain("claim");
|
||||||
|
|
||||||
// The claim id sits in a node tagged with data-testid="header-id".
|
// SP21 Phase 5 Task 5.10: the claim id is now the title passed
|
||||||
const idEl = container.querySelector('[data-testid="header-id"]');
|
// to DrillDrawerHeader, which renders it inside an <h2>. Find
|
||||||
expect(idEl).not.toBeNull();
|
// the h2 and assert its text matches the claim id.
|
||||||
expect(idEl?.textContent).toBe("CLM-42");
|
const titleEl = container.querySelector("h2");
|
||||||
|
expect(titleEl).not.toBeNull();
|
||||||
|
expect(titleEl?.textContent).toBe("CLM-42");
|
||||||
|
|
||||||
unmount();
|
unmount();
|
||||||
});
|
});
|
||||||
@@ -212,8 +215,11 @@ describe("ClaimDrawerHeader", () => {
|
|||||||
const onClose = vi.fn();
|
const onClose = vi.fn();
|
||||||
const { container, unmount } = renderHeader({}, onClose);
|
const { container, unmount } = renderHeader({}, onClose);
|
||||||
|
|
||||||
|
// SP21 Phase 5 Task 5.10: the close button is now rendered by
|
||||||
|
// DrillDrawerHeader (no `data-testid`); find it via its
|
||||||
|
// accessible name instead.
|
||||||
const closeBtn = container.querySelector(
|
const closeBtn = container.querySelector(
|
||||||
'[data-testid="header-close"]'
|
'button[aria-label="Close drawer"]'
|
||||||
) as HTMLButtonElement | null;
|
) as HTMLButtonElement | null;
|
||||||
expect(closeBtn).not.toBeNull();
|
expect(closeBtn).not.toBeNull();
|
||||||
expect(onClose).not.toHaveBeenCalled();
|
expect(onClose).not.toHaveBeenCalled();
|
||||||
@@ -226,18 +232,25 @@ describe("ClaimDrawerHeader", () => {
|
|||||||
unmount();
|
unmount();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("test_uses_modern_palette_surface", () => {
|
it("test_uses_shared_drilldrawerheader_shell", () => {
|
||||||
// The drawer header anchors itself on the light surface palette
|
// SP21 Phase 5 Task 5.10: the header is now a thin wrapper
|
||||||
// token (matches ClaimDrawerSkeleton / ClaimDrawerError). The root
|
// around DrillDrawerHeader — verify the wrapper is present and
|
||||||
// <header> element is tagged with data-testid="claim-drawer-header"
|
// that the underlying shell is the shared one (an h2 with the
|
||||||
// so we can sniff its className without coupling to the badge
|
// expected Tailwind treatment). The "Claim" eyebrow + claim id
|
||||||
// or close-button wrappers.
|
// title prove the shell rendered.
|
||||||
const { container, unmount } = renderHeader({});
|
const { container, unmount } = renderHeader({ id: "CLM-42" });
|
||||||
|
|
||||||
const root = container.querySelector('[data-testid="claim-drawer-header"]');
|
const root = container.querySelector('[data-testid="claim-drawer-header"]');
|
||||||
expect(root).not.toBeNull();
|
expect(root).not.toBeNull();
|
||||||
const cls = root?.className ?? "";
|
|
||||||
expect(cls).toContain("bg-[color:var(--m-surface)]");
|
// The h2 is DrillDrawerHeader's title slot.
|
||||||
|
const titleEl = root?.querySelector("h2");
|
||||||
|
expect(titleEl).not.toBeNull();
|
||||||
|
expect(titleEl?.textContent).toBe("CLM-42");
|
||||||
|
// The className pattern DrillDrawerHeader uses for the title.
|
||||||
|
const cls = titleEl?.className ?? "";
|
||||||
|
expect(cls).toContain("text-[18px]");
|
||||||
|
expect(cls).toContain("font-semibold");
|
||||||
|
|
||||||
unmount();
|
unmount();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { Download, X } from "lucide-react";
|
import { Download } from "lucide-react";
|
||||||
import { Badge, type BadgeProps } from "@/components/ui/badge";
|
import { Badge, type BadgeProps } from "@/components/ui/badge";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { DrillDrawerHeader } from "@/components/drill/DrillDrawerHeader";
|
||||||
import { api } from "@/lib/api";
|
import { api } from "@/lib/api";
|
||||||
import { downloadTextFile } from "@/lib/download";
|
import { downloadTextFile } from "@/lib/download";
|
||||||
import { fmt } from "@/lib/format";
|
import { fmt } from "@/lib/format";
|
||||||
import { cn } from "@/lib/utils";
|
|
||||||
import type { ClaimDetail } from "@/types";
|
import type { ClaimDetail } from "@/types";
|
||||||
|
|
||||||
type ClaimDrawerHeaderProps = {
|
type ClaimDrawerHeaderProps = {
|
||||||
@@ -47,12 +47,19 @@ function badgeVariantFor(state: string): BadgeProps["variant"] {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Header band for the claim detail drawer (SP4).
|
* Header band for the claim detail drawer (SP4 → refactored SP21
|
||||||
|
* Phase 5 Task 5.10).
|
||||||
*
|
*
|
||||||
* Top-left: instrument-style "Claim" eyebrow + large mono ID.
|
* The shell is now the shared `DrillDrawerHeader` (same as
|
||||||
* Top-right: state badge + total billed amount + close button. The
|
* `ProviderDrawer` / `AckDrawer`) — eyebrow + title on the left,
|
||||||
* badge color encodes state so the user can read the drawer's status
|
* close button on the right. The right-side `action` slot carries
|
||||||
* at a glance without scrolling.
|
* the state badge, total billed amount, and the "Download 837"
|
||||||
|
* button, all of which used to live in a custom <header> block.
|
||||||
|
*
|
||||||
|
* Top-left: instrument-style "Claim" eyebrow + the claim ID.
|
||||||
|
* Top-right: state badge + total billed amount + download button.
|
||||||
|
* The badge color encodes state so the user can read the drawer's
|
||||||
|
* status at a glance without scrolling.
|
||||||
*/
|
*/
|
||||||
export function ClaimDrawerHeader({
|
export function ClaimDrawerHeader({
|
||||||
claim,
|
claim,
|
||||||
@@ -80,31 +87,12 @@ export function ClaimDrawerHeader({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
// The action slot is rendered by DrillDrawerHeader to the left of
|
||||||
<header
|
// the close button. Group the three action pieces (badge, amount,
|
||||||
className={cn(
|
// download) in a single flex row so they read as a unit.
|
||||||
"flex items-start justify-between gap-4 px-6 py-5",
|
const action = (
|
||||||
"border-b border-[color:var(--m-border-heavy)]/40",
|
<div className="flex items-center gap-2" data-testid="claim-header-actions">
|
||||||
"bg-[color:var(--m-surface)]"
|
<div className="flex items-center gap-2">
|
||||||
)}
|
|
||||||
data-testid="claim-drawer-header"
|
|
||||||
>
|
|
||||||
{/* Left: eyebrow + mono claim ID */}
|
|
||||||
<div className="flex flex-col gap-1 min-w-0">
|
|
||||||
<span className="eyebrow text-[color:var(--m-ink-tertiary)]">
|
|
||||||
Claim
|
|
||||||
</span>
|
|
||||||
<span
|
|
||||||
data-testid="header-id"
|
|
||||||
className="mono text-2xl font-semibold tracking-tight text-[color:var(--m-ink-primary)]"
|
|
||||||
>
|
|
||||||
{claim.id}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Right: state badge + total amount + download + close */}
|
|
||||||
<div className="flex items-start gap-2">
|
|
||||||
<div className="flex flex-col items-end gap-1">
|
|
||||||
<Badge
|
<Badge
|
||||||
variant={badgeVariantFor(claim.state)}
|
variant={badgeVariantFor(claim.state)}
|
||||||
data-testid="header-state"
|
data-testid="header-state"
|
||||||
@@ -114,7 +102,7 @@ export function ClaimDrawerHeader({
|
|||||||
</Badge>
|
</Badge>
|
||||||
<span
|
<span
|
||||||
data-testid="header-amount"
|
data-testid="header-amount"
|
||||||
className="mono text-lg tabular-nums text-[color:var(--m-ink-primary)]"
|
className="mono text-sm tabular-nums text-muted-foreground"
|
||||||
>
|
>
|
||||||
{fmt.usdPrecise(claim.billedAmount)}
|
{fmt.usdPrecise(claim.billedAmount)}
|
||||||
</span>
|
</span>
|
||||||
@@ -130,16 +118,17 @@ export function ClaimDrawerHeader({
|
|||||||
>
|
>
|
||||||
<Download className="h-4 w-4" strokeWidth={1.75} />
|
<Download className="h-4 w-4" strokeWidth={1.75} />
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="icon"
|
|
||||||
onClick={onClose}
|
|
||||||
aria-label="Close drawer"
|
|
||||||
data-testid="header-close"
|
|
||||||
>
|
|
||||||
<X className="h-4 w-4" strokeWidth={1.75} />
|
|
||||||
</Button>
|
|
||||||
</div>
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<header data-testid="claim-drawer-header">
|
||||||
|
<DrillDrawerHeader
|
||||||
|
eyebrow="Claim"
|
||||||
|
title={claim.id}
|
||||||
|
onClose={onClose}
|
||||||
|
action={action}
|
||||||
|
/>
|
||||||
</header>
|
</header>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,13 +6,23 @@
|
|||||||
|
|
||||||
import React, { act } from "react";
|
import React, { act } from "react";
|
||||||
import { createRoot, type Root } from "react-dom/client";
|
import { createRoot, type Root } from "react-dom/client";
|
||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it, vi, beforeEach } from "vitest";
|
||||||
import { PartiesGrid } from "./PartiesGrid";
|
import { PartiesGrid } from "./PartiesGrid";
|
||||||
|
import { DrillStackProvider } from "@/components/drill/DrillStackProvider";
|
||||||
import type {
|
import type {
|
||||||
ClaimDetailAddress,
|
ClaimDetailAddress,
|
||||||
ClaimDetailParties,
|
ClaimDetailParties,
|
||||||
} from "@/types";
|
} from "@/types";
|
||||||
|
|
||||||
|
// Mock the api module so PayerPeekContent's usePayerSummary call
|
||||||
|
// doesn't make a real network request. The spy is set per-test in
|
||||||
|
// beforeEach.
|
||||||
|
vi.mock("@/hooks/usePayerSummary", () => ({
|
||||||
|
usePayerSummary: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
import { usePayerSummary } from "@/hooks/usePayerSummary";
|
||||||
|
|
||||||
function renderIntoContainer(element: React.ReactElement): {
|
function renderIntoContainer(element: React.ReactElement): {
|
||||||
container: HTMLDivElement;
|
container: HTMLDivElement;
|
||||||
unmount: () => void;
|
unmount: () => void;
|
||||||
@@ -20,8 +30,11 @@ function renderIntoContainer(element: React.ReactElement): {
|
|||||||
const container = document.createElement("div");
|
const container = document.createElement("div");
|
||||||
document.body.appendChild(container);
|
document.body.appendChild(container);
|
||||||
const root: Root = createRoot(container);
|
const root: Root = createRoot(container);
|
||||||
|
// SP21 Phase 5 Task 5.8: PartiesGrid now uses useDrillStack() to
|
||||||
|
// open payer peeks. Wrap every render in a DrillStackProvider so
|
||||||
|
// the hook has a context to read from.
|
||||||
act(() => {
|
act(() => {
|
||||||
root.render(element);
|
root.render(<DrillStackProvider>{element}</DrillStackProvider>);
|
||||||
});
|
});
|
||||||
return {
|
return {
|
||||||
container,
|
container,
|
||||||
@@ -279,4 +292,112 @@ describe("PartiesGrid", () => {
|
|||||||
|
|
||||||
unmount();
|
unmount();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("SP21 Task 5.8: payer name is drillable (renders as a button)", () => {
|
||||||
|
// The payer card's name now wraps in a button with the
|
||||||
|
// `drillable` affordance + `data-testid="party-payer-name-drill"`.
|
||||||
|
// The other two cards (billing-provider, subscriber) keep the
|
||||||
|
// name as a plain div.
|
||||||
|
const { container, unmount } = renderIntoContainer(
|
||||||
|
<PartiesGrid parties={makeParties()} />
|
||||||
|
);
|
||||||
|
|
||||||
|
const payerNameBtn = container.querySelector(
|
||||||
|
'[data-testid="party-payer-name-drill"]',
|
||||||
|
);
|
||||||
|
expect(payerNameBtn).not.toBeNull();
|
||||||
|
expect(payerNameBtn?.textContent).toContain("Aetna");
|
||||||
|
|
||||||
|
// The billing-provider and subscriber names stay plain divs.
|
||||||
|
expect(
|
||||||
|
container.querySelector(
|
||||||
|
'[data-testid="party-billing-provider-name-drill"]',
|
||||||
|
),
|
||||||
|
).toBeNull();
|
||||||
|
expect(
|
||||||
|
container.querySelector(
|
||||||
|
'[data-testid="party-subscriber-name-drill"]',
|
||||||
|
),
|
||||||
|
).toBeNull();
|
||||||
|
|
||||||
|
unmount();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("SP21 Task 5.8: clicking the payer name opens the PeekModal", async () => {
|
||||||
|
// Mock the payer summary fetch so PayerPeekContent resolves
|
||||||
|
// without a real network call. The peek modal renders
|
||||||
|
// regardless of fetch state (loading skeleton → content).
|
||||||
|
(usePayerSummary as unknown as ReturnType<typeof vi.fn>).mockReturnValue({
|
||||||
|
data: null,
|
||||||
|
isLoading: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
const { container, unmount } = renderIntoContainer(
|
||||||
|
<PartiesGrid parties={makeParties()} />
|
||||||
|
);
|
||||||
|
|
||||||
|
const payerNameBtn = container.querySelector(
|
||||||
|
'[data-testid="party-payer-name-drill"]',
|
||||||
|
) as HTMLButtonElement | null;
|
||||||
|
expect(payerNameBtn).not.toBeNull();
|
||||||
|
await act(async () => {
|
||||||
|
payerNameBtn!.click();
|
||||||
|
await Promise.resolve();
|
||||||
|
});
|
||||||
|
|
||||||
|
// The PeekModal is a Radix Dialog that portals into document.body.
|
||||||
|
// Look for the [role="dialog"] (modal) with our eyebrow "Payer".
|
||||||
|
const dialogs = document.body.querySelectorAll('[role="dialog"]');
|
||||||
|
// One dialog is the modal itself (the peek). PartiesGrid doesn't
|
||||||
|
// open a drawer, so we expect exactly one.
|
||||||
|
expect(dialogs.length).toBeGreaterThanOrEqual(1);
|
||||||
|
const peekDialog = Array.from(dialogs).find((d) =>
|
||||||
|
d.textContent?.includes("Payer"),
|
||||||
|
);
|
||||||
|
expect(peekDialog).not.toBeNull();
|
||||||
|
expect(peekDialog?.textContent).toContain("Aetna");
|
||||||
|
|
||||||
|
unmount();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("SP21 Task 5.8: peek uses the X12 payer id, not the human name", () => {
|
||||||
|
// The PayerPeekContent is given the X12 payer id from
|
||||||
|
// `payer.id`. Verify the click flow passes the right id by
|
||||||
|
// checking usePayerSummary was called with "PAYER01".
|
||||||
|
const mockUsePayerSummary = vi.fn().mockReturnValue({
|
||||||
|
data: null,
|
||||||
|
isLoading: true,
|
||||||
|
});
|
||||||
|
(usePayerSummary as unknown as ReturnType<typeof vi.fn>).mockImplementation(
|
||||||
|
mockUsePayerSummary,
|
||||||
|
);
|
||||||
|
|
||||||
|
const { container, unmount } = renderIntoContainer(
|
||||||
|
<PartiesGrid parties={makeParties()} />
|
||||||
|
);
|
||||||
|
|
||||||
|
// No peek yet → no usePayerSummary call.
|
||||||
|
expect(mockUsePayerSummary).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
const payerNameBtn = container.querySelector(
|
||||||
|
'[data-testid="party-payer-name-drill"]',
|
||||||
|
) as HTMLButtonElement | null;
|
||||||
|
act(() => {
|
||||||
|
payerNameBtn!.click();
|
||||||
|
});
|
||||||
|
|
||||||
|
// After the click, PayerPeekContent mounts and calls
|
||||||
|
// usePayerSummary("PAYER01") — NOT the human name "Aetna".
|
||||||
|
expect(mockUsePayerSummary).toHaveBeenCalledWith("PAYER01");
|
||||||
|
|
||||||
|
unmount();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
// Reset the mock between tests so per-test implementations stick.
|
||||||
|
(usePayerSummary as unknown as ReturnType<typeof vi.fn>).mockReturnValue({
|
||||||
|
data: null,
|
||||||
|
isLoading: true,
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,4 +1,7 @@
|
|||||||
import type { ClaimDetail, ClaimDetailAddress } from "@/types";
|
import type { ClaimDetail, ClaimDetailAddress } from "@/types";
|
||||||
|
import { useDrillStack } from "@/components/drill/DrillStackProvider";
|
||||||
|
import { PeekModal } from "@/components/drill/PeekModal";
|
||||||
|
import { PayerPeekContent } from "@/components/drill/PayerPeekContent";
|
||||||
|
|
||||||
type PartiesGridProps = {
|
type PartiesGridProps = {
|
||||||
parties: ClaimDetail["parties"];
|
parties: ClaimDetail["parties"];
|
||||||
@@ -48,12 +51,14 @@ function PartyCard({
|
|||||||
name,
|
name,
|
||||||
identity,
|
identity,
|
||||||
address,
|
address,
|
||||||
|
onNameClick,
|
||||||
}: {
|
}: {
|
||||||
testId: string;
|
testId: string;
|
||||||
label: string;
|
label: string;
|
||||||
name: string;
|
name: string;
|
||||||
identity: React.ReactNode;
|
identity: React.ReactNode;
|
||||||
address?: AddressLike;
|
address?: AddressLike;
|
||||||
|
onNameClick?: () => void;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
@@ -63,9 +68,21 @@ function PartyCard({
|
|||||||
<span className="eyebrow text-[color:var(--m-ink-tertiary)]">
|
<span className="eyebrow text-[color:var(--m-ink-tertiary)]">
|
||||||
{label}
|
{label}
|
||||||
</span>
|
</span>
|
||||||
|
{onNameClick ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onNameClick}
|
||||||
|
data-testid={`${testId}-name-drill`}
|
||||||
|
aria-label={`Drill into ${label.toLowerCase()} ${name}`}
|
||||||
|
className="display text-lg text-[color:var(--m-ink-primary)] text-left cursor-pointer drillable rounded-sm px-0 -mx-0"
|
||||||
|
>
|
||||||
|
{name}
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
<div className="display text-lg text-[color:var(--m-ink-primary)]">
|
<div className="display text-lg text-[color:var(--m-ink-primary)]">
|
||||||
{name}
|
{name}
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
<div
|
<div
|
||||||
className="mono text-[12.5px] text-[color:var(--m-ink-secondary)] tabular-nums"
|
className="mono text-[12.5px] text-[color:var(--m-ink-secondary)] tabular-nums"
|
||||||
>
|
>
|
||||||
@@ -88,6 +105,13 @@ function PartyCard({
|
|||||||
*/
|
*/
|
||||||
export function PartiesGrid({ parties }: PartiesGridProps) {
|
export function PartiesGrid({ parties }: PartiesGridProps) {
|
||||||
const { billingProvider, subscriber, payer } = parties;
|
const { billingProvider, subscriber, payer } = parties;
|
||||||
|
// SP21 Phase 5 Task 5.8: payer name opens a PeekModal on top of
|
||||||
|
// the drawer — the peek stack (DrillStackProvider) holds at most
|
||||||
|
// one peek at a time, so opening payer-peek replaces any earlier
|
||||||
|
// peek. The X12 payer id (`payer.id`) is what the peek endpoint
|
||||||
|
// expects, NOT the human name.
|
||||||
|
const { stack, openPeek, closeTop } = useDrillStack();
|
||||||
|
const topPeek = stack[stack.length - 1];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section
|
<section
|
||||||
@@ -142,8 +166,31 @@ export function PartiesGrid({ parties }: PartiesGridProps) {
|
|||||||
<div>ID {payer.id}</div>
|
<div>ID {payer.id}</div>
|
||||||
</>
|
</>
|
||||||
}
|
}
|
||||||
|
// SP21 Phase 5 Task 5.8: payer name is drillable. Clicking
|
||||||
|
// pushes a payer peek onto the drill stack; the PeekModal
|
||||||
|
// at the bottom of this section renders when the top of
|
||||||
|
// the stack is "payer". The payer id used here is the X12
|
||||||
|
// payer_id (e.g. "SKCO0") — verified in ClaimDetailPayer
|
||||||
|
// type — which is what usePayerSummary expects.
|
||||||
|
onNameClick={() => openPeek({ kind: "payer", payerId: payer.id })}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* SP21 Phase 5 Task 5.8: payer peek. Mounted at the bottom
|
||||||
|
of PartiesGrid so the peek sits on top of the drawer (Radix
|
||||||
|
Dialog portals). Closing the peek pops the stack. Only one
|
||||||
|
peek renders at a time (the stack caps at 1) — when the
|
||||||
|
user opens a payer peek, any earlier peek is replaced. */}
|
||||||
|
{topPeek?.kind === "payer" ? (
|
||||||
|
<PeekModal
|
||||||
|
open
|
||||||
|
onClose={closeTop}
|
||||||
|
eyebrow="Payer"
|
||||||
|
title={payer.name}
|
||||||
|
>
|
||||||
|
<PayerPeekContent payerId={topPeek.payerId} />
|
||||||
|
</PeekModal>
|
||||||
|
) : null}
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,8 +6,9 @@
|
|||||||
|
|
||||||
import React, { act } from "react";
|
import React, { act } from "react";
|
||||||
import { createRoot, type Root } from "react-dom/client";
|
import { createRoot, type Root } from "react-dom/client";
|
||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it, beforeEach } from "vitest";
|
||||||
import { ValidationPanel } from "./ValidationPanel";
|
import { ValidationPanel } from "./ValidationPanel";
|
||||||
|
import { DrillStackProvider } from "@/components/drill/DrillStackProvider";
|
||||||
import type { ClaimDetailValidation, ClaimDetailValidationIssue } from "@/types";
|
import type { ClaimDetailValidation, ClaimDetailValidationIssue } from "@/types";
|
||||||
|
|
||||||
function renderIntoContainer(element: React.ReactElement): {
|
function renderIntoContainer(element: React.ReactElement): {
|
||||||
@@ -17,8 +18,11 @@ function renderIntoContainer(element: React.ReactElement): {
|
|||||||
const container = document.createElement("div");
|
const container = document.createElement("div");
|
||||||
document.body.appendChild(container);
|
document.body.appendChild(container);
|
||||||
const root: Root = createRoot(container);
|
const root: Root = createRoot(container);
|
||||||
|
// SP21 Phase 5 Task 5.9: ValidationPanel now uses useDrillStack()
|
||||||
|
// to open validation-rule peeks. Wrap every render in a
|
||||||
|
// DrillStackProvider so the hook has a context to read from.
|
||||||
act(() => {
|
act(() => {
|
||||||
root.render(element);
|
root.render(<DrillStackProvider>{element}</DrillStackProvider>);
|
||||||
});
|
});
|
||||||
return {
|
return {
|
||||||
container,
|
container,
|
||||||
@@ -244,4 +248,129 @@ describe("ValidationPanel", () => {
|
|||||||
|
|
||||||
unmount();
|
unmount();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("SP21 Task 5.9: rule code is drillable (renders as a button)", () => {
|
||||||
|
// The rule code in each IssueGroup now wraps in a button with
|
||||||
|
// data-testid="...-rule-drill". The other rule codes also drill
|
||||||
|
// the same way.
|
||||||
|
const { container, unmount } = renderIntoContainer(
|
||||||
|
<ValidationPanel
|
||||||
|
validation={makeValidation({
|
||||||
|
passed: false,
|
||||||
|
errors: [
|
||||||
|
makeIssue({ rule: "R050_diagnosis_present" }),
|
||||||
|
],
|
||||||
|
warnings: [
|
||||||
|
makeIssue({
|
||||||
|
rule: "R200_units_recommended",
|
||||||
|
severity: "warning",
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
})}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
const errorRuleDrill = container.querySelector(
|
||||||
|
'[data-testid="validation-errors-rule-drill"]',
|
||||||
|
);
|
||||||
|
const warningRuleDrill = container.querySelector(
|
||||||
|
'[data-testid="validation-warnings-rule-drill"]',
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(errorRuleDrill).not.toBeNull();
|
||||||
|
expect(errorRuleDrill?.tagName).toBe("BUTTON");
|
||||||
|
expect(errorRuleDrill?.textContent).toContain("R050_diagnosis_present");
|
||||||
|
|
||||||
|
expect(warningRuleDrill).not.toBeNull();
|
||||||
|
expect(warningRuleDrill?.tagName).toBe("BUTTON");
|
||||||
|
expect(warningRuleDrill?.textContent).toContain("R200_units_recommended");
|
||||||
|
|
||||||
|
unmount();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("SP21 Task 5.9: clicking the rule code opens the PeekModal", async () => {
|
||||||
|
// Clicking the rule code in the errors sub-section pushes a
|
||||||
|
// `{ kind: "rule", rule }` peek onto the drill stack. The peek
|
||||||
|
// is a Radix Dialog portal with the eyebrow "Validation rule"
|
||||||
|
// and the rule code as the title.
|
||||||
|
const { container, unmount } = renderIntoContainer(
|
||||||
|
<ValidationPanel
|
||||||
|
validation={makeValidation({
|
||||||
|
passed: false,
|
||||||
|
errors: [
|
||||||
|
makeIssue({ rule: "R050_diagnosis_present" }),
|
||||||
|
],
|
||||||
|
})}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
const ruleBtn = container.querySelector(
|
||||||
|
'[data-testid="validation-errors-rule-drill"]',
|
||||||
|
) as HTMLButtonElement | null;
|
||||||
|
expect(ruleBtn).not.toBeNull();
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
ruleBtn!.click();
|
||||||
|
await Promise.resolve();
|
||||||
|
});
|
||||||
|
|
||||||
|
// The PeekModal portals to document.body as a Radix dialog. Find
|
||||||
|
// the dialog with the "Validation rule" eyebrow.
|
||||||
|
const dialogs = document.body.querySelectorAll('[role="dialog"]');
|
||||||
|
const peekDialog = Array.from(dialogs).find((d) =>
|
||||||
|
d.textContent?.includes("Validation rule"),
|
||||||
|
);
|
||||||
|
expect(peekDialog).not.toBeNull();
|
||||||
|
// Title is the rule code.
|
||||||
|
expect(peekDialog?.textContent).toContain("R050_diagnosis_present");
|
||||||
|
// Body includes the catalog description for R050.
|
||||||
|
expect(peekDialog?.textContent).toContain("Diagnosis pointer present");
|
||||||
|
|
||||||
|
unmount();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("SP21 Task 5.9: unknown rule still opens the peek (fallback note)", async () => {
|
||||||
|
// Rules not in the catalog still open the peek — operators
|
||||||
|
// should be able to correlate unknown rule codes to whatever
|
||||||
|
// they were just looking at. The peek shows an "Unknown rule"
|
||||||
|
// note instead of the catalog text.
|
||||||
|
const { container, unmount } = renderIntoContainer(
|
||||||
|
<ValidationPanel
|
||||||
|
validation={makeValidation({
|
||||||
|
passed: false,
|
||||||
|
errors: [
|
||||||
|
makeIssue({ rule: "R999_totally_made_up" }),
|
||||||
|
],
|
||||||
|
})}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
const ruleBtn = container.querySelector(
|
||||||
|
'[data-testid="validation-errors-rule-drill"]',
|
||||||
|
) as HTMLButtonElement | null;
|
||||||
|
expect(ruleBtn).not.toBeNull();
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
ruleBtn!.click();
|
||||||
|
await Promise.resolve();
|
||||||
|
});
|
||||||
|
|
||||||
|
const dialogs = document.body.querySelectorAll('[role="dialog"]');
|
||||||
|
const peekDialog = Array.from(dialogs).find((d) =>
|
||||||
|
d.textContent?.includes("Validation rule"),
|
||||||
|
);
|
||||||
|
expect(peekDialog).not.toBeNull();
|
||||||
|
expect(peekDialog?.textContent).toContain("R999_totally_made_up");
|
||||||
|
expect(peekDialog?.textContent).toContain("Unknown rule");
|
||||||
|
|
||||||
|
unmount();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
// The PeekModal portals to document.body as a Radix dialog. Wipe
|
||||||
|
// any leftover dialogs between tests so the "find dialog by
|
||||||
|
// eyebrow" assertions in the rule-drill tests don't see stale
|
||||||
|
// portals from a prior test.
|
||||||
|
document.body.querySelectorAll('[role="dialog"]').forEach((d) => d.remove());
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,4 +1,7 @@
|
|||||||
import { AlertCircle, AlertTriangle, CheckCircle2 } from "lucide-react";
|
import { AlertCircle, AlertTriangle, CheckCircle2 } from "lucide-react";
|
||||||
|
import { useDrillStack } from "@/components/drill/DrillStackProvider";
|
||||||
|
import { PeekModal } from "@/components/drill/PeekModal";
|
||||||
|
import { ValidationRulePeekContent } from "@/components/drill/ValidationRulePeekContent";
|
||||||
import type { ClaimDetail } from "@/types";
|
import type { ClaimDetail } from "@/types";
|
||||||
|
|
||||||
type ValidationPanelProps = {
|
type ValidationPanelProps = {
|
||||||
@@ -28,6 +31,13 @@ function groupByRule(issues: IssueList): Array<[string, IssueList]> {
|
|||||||
/**
|
/**
|
||||||
* One rule-group block: header with rule code + count chip, followed by
|
* One rule-group block: header with rule code + count chip, followed by
|
||||||
* the list of messages underneath.
|
* the list of messages underneath.
|
||||||
|
*
|
||||||
|
* SP21 Phase 5 Task 5.9: the rule code is now drillable — clicking it
|
||||||
|
* opens the validation-rule peek on top of the drawer (via the drill
|
||||||
|
* stack). The peek renders ValidationRulePeekContent for the rule
|
||||||
|
* code, falling back to a "unknown rule" note when the catalog has
|
||||||
|
* no entry. Unknown rules still render the peek so operators can
|
||||||
|
* correlate the code to whatever they were just looking at.
|
||||||
*/
|
*/
|
||||||
function IssueGroup({
|
function IssueGroup({
|
||||||
rule,
|
rule,
|
||||||
@@ -43,15 +53,20 @@ function IssueGroup({
|
|||||||
testId === "validation-errors"
|
testId === "validation-errors"
|
||||||
? "text-[color:var(--m-error)]"
|
? "text-[color:var(--m-error)]"
|
||||||
: "text-[color:var(--m-warning)]";
|
: "text-[color:var(--m-warning)]";
|
||||||
|
const { openPeek } = useDrillStack();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-1.5">
|
<div className="flex flex-col gap-1.5">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<span
|
<button
|
||||||
className="mono text-[12px] font-semibold tracking-tight text-[color:var(--m-ink-primary)]"
|
type="button"
|
||||||
|
onClick={() => openPeek({ kind: "rule", rule })}
|
||||||
|
data-testid={`${testId}-rule-drill`}
|
||||||
|
aria-label={`Drill into rule ${rule}`}
|
||||||
|
className="mono text-[12px] font-semibold tracking-tight text-[color:var(--m-ink-primary)] cursor-pointer drillable rounded-sm px-0 -mx-0"
|
||||||
>
|
>
|
||||||
{rule}
|
{rule}
|
||||||
</span>
|
</button>
|
||||||
<span
|
<span
|
||||||
className="inline-flex items-center rounded-full bg-[color:var(--m-ink-tertiary)]/15 px-1.5 py-0.5 text-[10px] font-medium text-[color:var(--m-ink-secondary)] tabular-nums"
|
className="inline-flex items-center rounded-full bg-[color:var(--m-ink-tertiary)]/15 px-1.5 py-0.5 text-[10px] font-medium text-[color:var(--m-ink-secondary)] tabular-nums"
|
||||||
data-testid={`${testId}-count`}
|
data-testid={`${testId}-count`}
|
||||||
@@ -93,6 +108,11 @@ function IssueGroup({
|
|||||||
*/
|
*/
|
||||||
export function ValidationPanel({ validation }: ValidationPanelProps) {
|
export function ValidationPanel({ validation }: ValidationPanelProps) {
|
||||||
const allPassed = validation.passed && validation.warnings.length === 0;
|
const allPassed = validation.passed && validation.warnings.length === 0;
|
||||||
|
// SP21 Phase 5 Task 5.9: peek stack for rule drill. The drill
|
||||||
|
// provider is mounted at the App root; we only read the top entry
|
||||||
|
// here so the peek renders regardless of which section pushed it.
|
||||||
|
const { stack, closeTop } = useDrillStack();
|
||||||
|
const topPeek = stack[stack.length - 1];
|
||||||
|
|
||||||
if (allPassed) {
|
if (allPassed) {
|
||||||
return (
|
return (
|
||||||
@@ -185,6 +205,22 @@ export function ValidationPanel({ validation }: ValidationPanelProps) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
|
{/* SP21 Phase 5 Task 5.9: validation-rule peek. Mounted at the
|
||||||
|
bottom of the panel so the peek (a Radix Dialog portal) sits
|
||||||
|
on top of the drawer. Only renders when the top of the
|
||||||
|
drill stack is a rule peek — payer peek (from PartiesGrid)
|
||||||
|
wins when it's on top because the stack caps at 1 entry. */}
|
||||||
|
{topPeek?.kind === "rule" ? (
|
||||||
|
<PeekModal
|
||||||
|
open
|
||||||
|
onClose={closeTop}
|
||||||
|
eyebrow="Validation rule"
|
||||||
|
title={topPeek.rule}
|
||||||
|
>
|
||||||
|
<ValidationRulePeekContent rule={topPeek.rule} />
|
||||||
|
</PeekModal>
|
||||||
|
) : null}
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,19 @@
|
|||||||
|
import type { ReactNode } from "react";
|
||||||
import { X } from "lucide-react";
|
import { X } from "lucide-react";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
eyebrow: string;
|
eyebrow: string;
|
||||||
title: string;
|
title: string;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
|
/**
|
||||||
|
* Optional slot for a right-side action (e.g. "Download 999" on the
|
||||||
|
* AckDrawer, "Download 837" on the ClaimDrawer — added in SP21
|
||||||
|
* Phase 5 Task 5.2/5.10). Rendered to the left of the close
|
||||||
|
* button with a small visual gap. Anything goes — a button, a
|
||||||
|
* status pill, an icon link. Default `null` so existing callers
|
||||||
|
* (ProviderDrawer) render unchanged.
|
||||||
|
*/
|
||||||
|
action?: ReactNode;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -15,7 +25,7 @@ interface Props {
|
|||||||
* the app (see ``.eyebrow`` in ``src/index.css`` and the
|
* the app (see ``.eyebrow`` in ``src/index.css`` and the
|
||||||
* ``DrillDrawerHeader`` usage in ``ClaimDrawerHeader``).
|
* ``DrillDrawerHeader`` usage in ``ClaimDrawerHeader``).
|
||||||
*/
|
*/
|
||||||
export function DrillDrawerHeader({ eyebrow, title, onClose }: Props) {
|
export function DrillDrawerHeader({ eyebrow, title, onClose, action }: Props) {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-between border-b border-border/30 px-6 py-4">
|
<div className="flex items-center justify-between border-b border-border/30 px-6 py-4">
|
||||||
<div>
|
<div>
|
||||||
@@ -24,6 +34,8 @@ export function DrillDrawerHeader({ eyebrow, title, onClose }: Props) {
|
|||||||
</div>
|
</div>
|
||||||
<h2 className="text-[18px] font-semibold tracking-tight mt-0.5">{title}</h2>
|
<h2 className="text-[18px] font-semibold tracking-tight mt-0.5">{title}</h2>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{action}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
@@ -33,5 +45,6 @@ export function DrillDrawerHeader({ eyebrow, title, onClose }: Props) {
|
|||||||
<X className="h-4 w-4" aria-hidden />
|
<X className="h-4 w-4" aria-hidden />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,134 @@
|
|||||||
|
interface Props {
|
||||||
|
/** The rule code (e.g. "R050_diagnosis_present" or just "R050"). */
|
||||||
|
rule: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface RuleDoc {
|
||||||
|
/** Short human title (e.g. "Diagnosis pointer present"). */
|
||||||
|
title: string;
|
||||||
|
/** Plain-English description of what the rule checks. */
|
||||||
|
description: string;
|
||||||
|
/** Why the rule matters — operator-facing rationale. */
|
||||||
|
whyItMatters: string;
|
||||||
|
/** How to fix — short, actionable. */
|
||||||
|
howToFix: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* SP21 Phase 5 Task 5.9: rule catalog used by ValidationRulePeekContent.
|
||||||
|
*
|
||||||
|
* The catalog is intentionally small — it covers the rules we actually
|
||||||
|
* emit today (R050_diagnosis_present, R200_units_recommended). For
|
||||||
|
* anything not in the catalog the peek still renders (with an "Unknown
|
||||||
|
* rule" note) — operators should still be able to open the peek for
|
||||||
|
* any rule code so they can see the originating message verbatim.
|
||||||
|
*
|
||||||
|
* Adding a new entry here is the source-of-truth for the rule's
|
||||||
|
* documentation. The ValidationPanel wires the peek by rule code; if
|
||||||
|
* we add new rules later (Phase 6+), add a new entry here.
|
||||||
|
*/
|
||||||
|
const RULE_CATALOG: Record<string, RuleDoc> = {
|
||||||
|
R050_diagnosis_present: {
|
||||||
|
title: "Diagnosis pointer present",
|
||||||
|
description:
|
||||||
|
"Each service line must point to at least one diagnosis code in the claim header (the HL segment's HI element). A missing pointer makes the line unprocessable on the payer side.",
|
||||||
|
whyItMatters:
|
||||||
|
"Payers reject claims with missing diagnosis pointers at the 999 stage, which would otherwise re-trigger the 999 rejection loop. Catching it here gives the operator a chance to attach the dx before submission.",
|
||||||
|
howToFix:
|
||||||
|
"Open the claim's Service Lines table and attach the relevant diagnosis code (e.g. E11.9) to the line. The pointer is the line's diagnosis pointer list.",
|
||||||
|
},
|
||||||
|
R200_units_recommended: {
|
||||||
|
title: "Service line units recommended",
|
||||||
|
description:
|
||||||
|
"Service lines that represent timed procedures (anesthesia, critical care, psychotherapy time-based codes) should carry an explicit units value. Defaulting to 1 is acceptable for most codes but flagged here for review.",
|
||||||
|
whyItMatters:
|
||||||
|
"Timed codes without units get under-reimbursed — payers default to 1 unit when the field is blank, even when the procedure took 45 minutes. The warning exists so an operator can verify the units are correct before submission.",
|
||||||
|
howToFix:
|
||||||
|
"Confirm the units value on the service line matches the documented encounter time. If the code is not time-based, no action is required.",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Peek body for a validation rule — opens on top of the ClaimDrawer
|
||||||
|
* via PeekModal when the operator clicks a rule code in the
|
||||||
|
* ValidationPanel. The body shows the rule's title, description, why
|
||||||
|
* it matters, and how to fix it.
|
||||||
|
*
|
||||||
|
* Unknown rules (codes not in the catalog) render a small "Unknown
|
||||||
|
* rule — see originating message" note rather than blowing up. The
|
||||||
|
* peek still renders so the operator can correlate the rule code to
|
||||||
|
* whatever they were just looking at.
|
||||||
|
*
|
||||||
|
* No fetch — the catalog is static and bundled. (A future phase
|
||||||
|
* could swap this for a backend-served catalog if rules become
|
||||||
|
* user-extensible.)
|
||||||
|
*/
|
||||||
|
export function ValidationRulePeekContent({ rule }: Props) {
|
||||||
|
// Normalize: the rule code in the validation payload is the full
|
||||||
|
// form (`R050_diagnosis_present`), but a future backend response
|
||||||
|
// might use the short form (`R050`). Look up both.
|
||||||
|
const doc =
|
||||||
|
RULE_CATALOG[rule] ??
|
||||||
|
RULE_CATALOG[rule.split("_")[0] ?? ""] ??
|
||||||
|
null;
|
||||||
|
|
||||||
|
if (!doc) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="display text-[14px] text-foreground">
|
||||||
|
{rule}
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className="text-[12.5px]"
|
||||||
|
style={{ color: "hsl(var(--muted-foreground))" }}
|
||||||
|
>
|
||||||
|
Unknown rule. The originating message is the authoritative
|
||||||
|
description — this peek is a no-op for undocumented rule codes.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="display text-[15px] text-foreground">
|
||||||
|
{doc.title}
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className="mono text-[11px]"
|
||||||
|
style={{ color: "hsl(var(--muted-foreground))" }}
|
||||||
|
>
|
||||||
|
{rule}
|
||||||
|
</div>
|
||||||
|
<div className="text-[13px] leading-relaxed text-[color:var(--m-ink-primary)]">
|
||||||
|
{doc.description}
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1.5 pt-1">
|
||||||
|
<Section heading="Why it matters">{doc.whyItMatters}</Section>
|
||||||
|
<Section heading="How to fix">{doc.howToFix}</Section>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Section({
|
||||||
|
heading,
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
heading: string;
|
||||||
|
children: React.ReactNode;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div
|
||||||
|
className="mono text-[10px] uppercase tracking-[0.18em] font-semibold mb-0.5"
|
||||||
|
style={{ color: "hsl(var(--muted-foreground))" }}
|
||||||
|
>
|
||||||
|
{heading}
|
||||||
|
</div>
|
||||||
|
<div className="text-[12.5px] leading-relaxed text-[color:var(--m-ink-secondary)]">
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import { api, ApiError } from "@/lib/api";
|
||||||
|
import type { Ack } from "@/types";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* UI-facing ack detail shape returned by `GET /api/acks/{id}`.
|
||||||
|
*
|
||||||
|
* Extends the base `Ack` shape with the `raw_999_text` field that
|
||||||
|
* `api.getAck` populates on top of the canonical row. The download
|
||||||
|
* button inside `AckDrawer` reads this string to hand the user the
|
||||||
|
* regenerated X12 file.
|
||||||
|
*/
|
||||||
|
export interface AckDetail extends Ack {
|
||||||
|
/**
|
||||||
|
* Full regenerated 999 X12 text. The backend re-emits the parsed
|
||||||
|
* transaction set so a user can grab the original file from the
|
||||||
|
* drawer without a second round-trip to `/api/acks/{id}/raw`.
|
||||||
|
*/
|
||||||
|
raw_999_text?: string;
|
||||||
|
/**
|
||||||
|
* Raw JSON envelope captured by the parser (the same dict the
|
||||||
|
* parser wrote into `raw_json`). Optional so older rows without
|
||||||
|
* it still typecheck.
|
||||||
|
*/
|
||||||
|
rawJson?: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Per-ack detail drawer query (AckDrawer · SP21 Phase 5 Task 5.2).
|
||||||
|
*
|
||||||
|
* Twin of `useProviderDetail` and `useClaimDetail` — same return
|
||||||
|
* shape, same retry semantics, no in-memory fallback (the spec §5.2
|
||||||
|
* calls out that ACKs are backend-only; `useAcks` has no sample-data
|
||||||
|
* path so there's nothing to fall back on).
|
||||||
|
*
|
||||||
|
* Returns `{ data, isLoading, isError, error, refetch }`:
|
||||||
|
* - `ackId === null` (drawer closed): the query is disabled and the
|
||||||
|
* hook short-circuits to the empty drawer state so a closed drawer
|
||||||
|
* doesn't burn a network request or a TanStack cache slot.
|
||||||
|
* - `ackId` is set: fetches `GET /api/acks/{id}` via `api.getAck`.
|
||||||
|
* Cached 60 s — the underlying ack rows don't change after
|
||||||
|
* parse-time.
|
||||||
|
* - On 404: the hook's retry predicate short-circuits (no retries)
|
||||||
|
* so the drawer's not-found state appears immediately rather than
|
||||||
|
* being masked by three back-to-back retry attempts.
|
||||||
|
*
|
||||||
|
* The drawer accepts `ackId: string | null` (the URL keeps the id as
|
||||||
|
* a string for clean deep-link round-tripping) and does the
|
||||||
|
* `Number()` coercion here, matching how `useProviderDetail`
|
||||||
|
* (`string`) and `useRemitDetail` (`string`) already work.
|
||||||
|
*/
|
||||||
|
export function useAckDetail(ackId: string | null): {
|
||||||
|
data: AckDetail | null;
|
||||||
|
isLoading: boolean;
|
||||||
|
isError: boolean;
|
||||||
|
error: Error | null;
|
||||||
|
refetch: () => void;
|
||||||
|
} {
|
||||||
|
const q = useQuery<AckDetail>({
|
||||||
|
queryKey: ["ack-detail", ackId],
|
||||||
|
queryFn: () => api.getAck(Number(ackId)),
|
||||||
|
enabled: ackId !== null,
|
||||||
|
staleTime: 60 * 1000,
|
||||||
|
retry: (failureCount, error) => {
|
||||||
|
if (error instanceof ApiError && error.status === 404) return false;
|
||||||
|
return failureCount < 3;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (ackId === null) {
|
||||||
|
return {
|
||||||
|
data: null,
|
||||||
|
isLoading: false,
|
||||||
|
isError: false,
|
||||||
|
error: null,
|
||||||
|
refetch: () => {},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
data: q.data ?? null,
|
||||||
|
isLoading: q.isLoading,
|
||||||
|
isError: q.isError,
|
||||||
|
error: q.error,
|
||||||
|
refetch: () => {
|
||||||
|
void q.refetch();
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,219 @@
|
|||||||
|
// @vitest-environment happy-dom
|
||||||
|
// Mirror the IS_REACT_ACT_ENVIRONMENT setup from useProviderDrawerUrlState.test.ts
|
||||||
|
// so React doesn't log act() warnings about the createRoot render/unmount
|
||||||
|
// and the popstate-driven state updates.
|
||||||
|
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
||||||
|
|
||||||
|
import React, { act } from "react";
|
||||||
|
import { createRoot, type Root } from "react-dom/client";
|
||||||
|
import { describe, expect, it, vi, beforeEach, afterEach } from "vitest";
|
||||||
|
import { useAckDrawerUrlState } from "./useAckDrawerUrlState";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Minimal renderHook shim — same pattern as the other hook tests.
|
||||||
|
*/
|
||||||
|
function renderHook<TResult>(setup: () => TResult): {
|
||||||
|
result: { current: TResult | undefined };
|
||||||
|
unmount: () => void;
|
||||||
|
} {
|
||||||
|
const result: { current: TResult | undefined } = { current: undefined };
|
||||||
|
const container = document.createElement("div");
|
||||||
|
document.body.appendChild(container);
|
||||||
|
|
||||||
|
function Probe() {
|
||||||
|
result.current = setup();
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const root: Root = createRoot(container);
|
||||||
|
act(() => {
|
||||||
|
root.render(React.createElement(Probe));
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
result,
|
||||||
|
unmount: () => {
|
||||||
|
act(() => root.unmount());
|
||||||
|
container.remove();
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Point happy-dom's URL at a known value. happy-dom v20 doesn't expose a
|
||||||
|
* writable `window.location.search`, but `window.happyDOM.setURL` updates
|
||||||
|
* the URL the window reports without triggering a navigation — exactly
|
||||||
|
* what we want for mounting the hook at `/acks?ack=42` etc.
|
||||||
|
*/
|
||||||
|
function setLocation(url: string): void {
|
||||||
|
(window as unknown as { happyDOM: { setURL: (u: string) => void } }).happyDOM.setURL(url);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("useAckDrawerUrlState", () => {
|
||||||
|
type PushState = (state: unknown, unused: string, url?: string | URL | null) => void;
|
||||||
|
let pushStateMock: ReturnType<typeof vi.fn<PushState>>;
|
||||||
|
let replaceStateMock: ReturnType<typeof vi.fn<PushState>>;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
pushStateMock = vi.fn();
|
||||||
|
replaceStateMock = vi.fn();
|
||||||
|
|
||||||
|
vi.stubGlobal("history", {
|
||||||
|
pushState: pushStateMock,
|
||||||
|
replaceState: replaceStateMock,
|
||||||
|
state: null,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.unstubAllGlobals();
|
||||||
|
setLocation("http://localhost/");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reads the ?ack= param from window.location.search on mount", () => {
|
||||||
|
setLocation("http://localhost/acks?ack=42");
|
||||||
|
|
||||||
|
const { result, unmount } = renderHook(() => useAckDrawerUrlState());
|
||||||
|
|
||||||
|
expect(result.current?.ackId).toBe("42");
|
||||||
|
expect(typeof result.current?.open).toBe("function");
|
||||||
|
expect(typeof result.current?.close).toBe("function");
|
||||||
|
expect(typeof result.current?.setAckId).toBe("function");
|
||||||
|
|
||||||
|
unmount();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns null ackId when no ?ack= param is set", () => {
|
||||||
|
setLocation("http://localhost/acks");
|
||||||
|
|
||||||
|
const { result, unmount } = renderHook(() => useAckDrawerUrlState());
|
||||||
|
|
||||||
|
expect(result.current?.ackId).toBeNull();
|
||||||
|
|
||||||
|
unmount();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns null ackId when ?ack= is present but empty", () => {
|
||||||
|
setLocation("http://localhost/acks?ack=");
|
||||||
|
|
||||||
|
const { result, unmount } = renderHook(() => useAckDrawerUrlState());
|
||||||
|
|
||||||
|
expect(result.current?.ackId).toBeNull();
|
||||||
|
|
||||||
|
unmount();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("open(id) pushes a new history entry containing ?ack=ID", () => {
|
||||||
|
setLocation("http://localhost/acks");
|
||||||
|
|
||||||
|
const { result, unmount } = renderHook(() => useAckDrawerUrlState());
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
result.current?.open("42");
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(pushStateMock).toHaveBeenCalledTimes(1);
|
||||||
|
expect(replaceStateMock).not.toHaveBeenCalled();
|
||||||
|
const urlArg = pushStateMock.mock.calls[0][2] as string;
|
||||||
|
expect(urlArg).toContain("?ack=42");
|
||||||
|
|
||||||
|
unmount();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("setAckId(id) replaces the current history entry (no new entry) and does NOT pushState", () => {
|
||||||
|
setLocation("http://localhost/acks?ack=42");
|
||||||
|
|
||||||
|
const { result, unmount } = renderHook(() => useAckDrawerUrlState());
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
result.current?.setAckId("43");
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(replaceStateMock).toHaveBeenCalledTimes(1);
|
||||||
|
expect(pushStateMock).not.toHaveBeenCalled();
|
||||||
|
const urlArg = replaceStateMock.mock.calls[0][2] as string;
|
||||||
|
expect(urlArg).toContain("?ack=43");
|
||||||
|
|
||||||
|
unmount();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("open() and close() preserve other query params (only ?ack= is touched)", () => {
|
||||||
|
setLocation("http://localhost/acks?sort=date&ack=42");
|
||||||
|
|
||||||
|
const { result, unmount } = renderHook(() => useAckDrawerUrlState());
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
result.current?.open("43");
|
||||||
|
});
|
||||||
|
const openUrl = pushStateMock.mock.calls[0][2] as string;
|
||||||
|
expect(openUrl).toContain("sort=date");
|
||||||
|
expect(openUrl).toMatch(/[?&]ack=43/);
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
result.current?.close();
|
||||||
|
});
|
||||||
|
const closeUrl = pushStateMock.mock.calls[1][2] as string;
|
||||||
|
expect(closeUrl).toContain("sort=date");
|
||||||
|
expect(closeUrl).not.toContain("ack=");
|
||||||
|
|
||||||
|
unmount();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("close() pushes a new history entry with the ?ack= param stripped", () => {
|
||||||
|
setLocation("http://localhost/acks?ack=42");
|
||||||
|
|
||||||
|
const { result, unmount } = renderHook(() => useAckDrawerUrlState());
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
result.current?.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(pushStateMock).toHaveBeenCalledTimes(1);
|
||||||
|
expect(replaceStateMock).not.toHaveBeenCalled();
|
||||||
|
const urlArg = pushStateMock.mock.calls[0][2] as string;
|
||||||
|
expect(urlArg).not.toContain("?ack=");
|
||||||
|
expect(result.current?.ackId).toBeNull();
|
||||||
|
|
||||||
|
unmount();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("updates ackId in response to popstate (browser back/forward)", async () => {
|
||||||
|
setLocation("http://localhost/acks?ack=42");
|
||||||
|
|
||||||
|
const { result, unmount } = renderHook(() => useAckDrawerUrlState());
|
||||||
|
|
||||||
|
expect(result.current?.ackId).toBe("42");
|
||||||
|
|
||||||
|
setLocation("http://localhost/acks?ack=43");
|
||||||
|
await act(async () => {
|
||||||
|
window.dispatchEvent(new PopStateEvent("popstate"));
|
||||||
|
await Promise.resolve();
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.current?.ackId).toBe("43");
|
||||||
|
|
||||||
|
unmount();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not collide with the existing ?claim=, ?remit=, or ?provider= params (orthogonal keys)", () => {
|
||||||
|
// The acks drawer is independent of the claim/remit/provider
|
||||||
|
// drawers — opening an ack must leave the others intact so a user
|
||||||
|
// can deep-link to multiple states simultaneously (though the UI
|
||||||
|
// currently only shows one drawer at a time, the URL params don't
|
||||||
|
// know that).
|
||||||
|
setLocation("http://localhost/?claim=CLM-1&remit=REM-1&provider=1881068062");
|
||||||
|
|
||||||
|
const { result, unmount } = renderHook(() => useAckDrawerUrlState());
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
result.current?.open("42");
|
||||||
|
});
|
||||||
|
const openUrl = pushStateMock.mock.calls[0][2] as string;
|
||||||
|
expect(openUrl).toContain("claim=CLM-1");
|
||||||
|
expect(openUrl).toContain("remit=REM-1");
|
||||||
|
expect(openUrl).toContain("provider=1881068062");
|
||||||
|
expect(openUrl).toMatch(/[?&]ack=42/);
|
||||||
|
|
||||||
|
unmount();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,105 @@
|
|||||||
|
import { useCallback, useEffect, useState } from "react";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Read the current `?ack=…` query param off `window.location.search`.
|
||||||
|
* Returns `null` when the param is absent or empty.
|
||||||
|
*
|
||||||
|
* `URLSearchParams` is the standard, locale-free way to parse query
|
||||||
|
* strings in the browser. Using it (rather than hand-rolled string
|
||||||
|
* slicing) means we correctly handle multiple params and percent-encoded
|
||||||
|
* characters in ack ids without surprises.
|
||||||
|
*
|
||||||
|
* Param name is `?ack=` — chosen to mirror the existing `?provider=…`,
|
||||||
|
* `?claim=…`, and `?remit=…` drilldown conventions (one-word token,
|
||||||
|
* alphabetical brevity, no collision with the existing `Acks` table
|
||||||
|
* columns). The id value is treated as a string in the URL so deep
|
||||||
|
* links (`/acks?ack=42`) round-trip identically across the app, even
|
||||||
|
* though the backend `Ack.id` is a numeric — `AckDrawer` does the
|
||||||
|
* `Number()` coercion when calling `api.getAck`.
|
||||||
|
*/
|
||||||
|
function readAckId(): string | null {
|
||||||
|
const params = new URLSearchParams(window.location.search);
|
||||||
|
const value = params.get("ack");
|
||||||
|
return value === "" ? null : value;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build the URL we want to push/replace into history.
|
||||||
|
*
|
||||||
|
* - `ackId === null` → drop the `?ack=` param, preserving any
|
||||||
|
* other params (e.g. `?page=2&ack=…` keeps `page=2`).
|
||||||
|
* - `ackId !== null` → set the param to the new id, also preserving
|
||||||
|
* any other params.
|
||||||
|
*
|
||||||
|
* We return `pathname + search + hash` (a relative URL) rather than the
|
||||||
|
* full href — `history.pushState` accepts a relative URL and rewriting
|
||||||
|
* only the relative form keeps the document's origin stable.
|
||||||
|
*/
|
||||||
|
function buildUrl(ackId: string | null): string {
|
||||||
|
const url = new URL(window.location.href);
|
||||||
|
if (ackId === null) {
|
||||||
|
url.searchParams.delete("ack");
|
||||||
|
} else {
|
||||||
|
url.searchParams.set("ack", ackId);
|
||||||
|
}
|
||||||
|
return url.pathname + url.search + url.hash;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Per-ack detail drawer URL state (AckDrawer).
|
||||||
|
*
|
||||||
|
* Mirrors `useProviderDrawerUrlState` / `useRemitDrawerUrlState` but
|
||||||
|
* for the 999-ACK drawer — reads `?ack=` from the URL on mount and
|
||||||
|
* keeps the value in sync with history as the drawer is opened and
|
||||||
|
* closed.
|
||||||
|
*
|
||||||
|
* - `ackId`: the id parsed from the URL (or `null` when the param
|
||||||
|
* is absent). React state so consumers re-render on changes.
|
||||||
|
* - `open(id)`: pushes a NEW history entry with `?ack={id}` — so the
|
||||||
|
* browser Back button returns to the previous page (e.g. the
|
||||||
|
* acks list) and not just to the previously-open ack.
|
||||||
|
* - `setAckId(id)`: REPLACES the current history entry — kept for
|
||||||
|
* symmetry with the other drawer hooks so a future j/k nav
|
||||||
|
* handler doesn't have to special-case ack ids.
|
||||||
|
* - `close()`: pushes a NEW entry that strips the param, so Back
|
||||||
|
* from the closed drawer returns to whatever page the user was
|
||||||
|
* on before opening the drawer.
|
||||||
|
*
|
||||||
|
* The hook subscribes to `popstate` so that browser Back/Forward
|
||||||
|
* (which fire popstate rather than our own pushState) propagate into
|
||||||
|
* the React state. Without this, hitting Back would change the URL
|
||||||
|
* but leave the drawer open on the stale id.
|
||||||
|
*/
|
||||||
|
export function useAckDrawerUrlState(): {
|
||||||
|
ackId: string | null;
|
||||||
|
open: (id: string) => void;
|
||||||
|
close: () => void;
|
||||||
|
setAckId: (id: string) => void;
|
||||||
|
} {
|
||||||
|
const [ackId, setAckIdState] = useState<string | null>(() => readAckId());
|
||||||
|
|
||||||
|
const open = useCallback((id: string) => {
|
||||||
|
window.history.pushState(null, "", buildUrl(id));
|
||||||
|
setAckIdState(id);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const setAckId = useCallback((id: string) => {
|
||||||
|
window.history.replaceState(null, "", buildUrl(id));
|
||||||
|
setAckIdState(id);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const close = useCallback(() => {
|
||||||
|
window.history.pushState(null, "", buildUrl(null));
|
||||||
|
setAckIdState(null);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const onPopState = () => {
|
||||||
|
setAckIdState(readAckId());
|
||||||
|
};
|
||||||
|
window.addEventListener("popstate", onPopState);
|
||||||
|
return () => window.removeEventListener("popstate", onPopState);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return { ackId, open, close, setAckId };
|
||||||
|
}
|
||||||
+121
-2
@@ -9,13 +9,17 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
|||||||
import { Acks } from "./Acks";
|
import { Acks } from "./Acks";
|
||||||
import { api } from "@/lib/api";
|
import { api } from "@/lib/api";
|
||||||
|
|
||||||
vi.mock("@/lib/api", () => ({
|
vi.mock("@/lib/api", async (importOriginal) => {
|
||||||
|
const actual = await importOriginal();
|
||||||
|
return {
|
||||||
|
...actual,
|
||||||
api: {
|
api: {
|
||||||
isConfigured: true,
|
isConfigured: true,
|
||||||
listAcks: vi.fn(),
|
listAcks: vi.fn(),
|
||||||
getAck: vi.fn(),
|
getAck: vi.fn(),
|
||||||
},
|
},
|
||||||
}));
|
};
|
||||||
|
});
|
||||||
|
|
||||||
function renderIntoContainer(element: React.ReactElement): {
|
function renderIntoContainer(element: React.ReactElement): {
|
||||||
container: HTMLDivElement;
|
container: HTMLDivElement;
|
||||||
@@ -105,6 +109,10 @@ function hasExactlyOneSelectedRow(): boolean {
|
|||||||
describe("Acks", () => {
|
describe("Acks", () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
|
// Reset URL state between tests so a previous `?ack=` doesn't leak.
|
||||||
|
(window as unknown as { happyDOM: { setURL: (u: string) => void } }).happyDOM.setURL(
|
||||||
|
"http://localhost/acks"
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("renders a single ack row with counts and ack code", async () => {
|
it("renders a single ack row with counts and ack code", async () => {
|
||||||
@@ -448,4 +456,115 @@ describe("Acks", () => {
|
|||||||
|
|
||||||
unmount();
|
unmount();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("test_clicking_a_row_opens_the_ack_drawer", async () => {
|
||||||
|
// SP21 Phase 5 Task 5.3: clicking an acks row drills into the
|
||||||
|
// matching ack via `?ack=ID` URL state. The AckDrawer mounts
|
||||||
|
// but the actual content depends on `useAckDetail` — we don't
|
||||||
|
// need to verify drawer internals here, just that the URL got
|
||||||
|
// pushed and the drawer portal opens.
|
||||||
|
(api.listAcks as unknown as ReturnType<typeof vi.fn>).mockResolvedValue({
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
id: 42,
|
||||||
|
sourceBatchId: "b-uuid-1",
|
||||||
|
acceptedCount: 3,
|
||||||
|
rejectedCount: 1,
|
||||||
|
receivedCount: 4,
|
||||||
|
ackCode: "P",
|
||||||
|
parsedAt: "2026-06-20T12:00:00Z",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
total: 1,
|
||||||
|
returned: 1,
|
||||||
|
has_more: false,
|
||||||
|
});
|
||||||
|
// Stub the per-ack fetch so `useAckDetail` resolves cleanly
|
||||||
|
// (avoids TanStack Query's "Query data cannot be undefined"
|
||||||
|
// warning). We only assert on the drawer's presence, so the
|
||||||
|
// shape doesn't need to be precise.
|
||||||
|
(api.getAck as unknown as ReturnType<typeof vi.fn>).mockResolvedValue({
|
||||||
|
id: 42,
|
||||||
|
sourceBatchId: "b-uuid-1",
|
||||||
|
acceptedCount: 3,
|
||||||
|
rejectedCount: 1,
|
||||||
|
receivedCount: 4,
|
||||||
|
ackCode: "P",
|
||||||
|
parsedAt: "2026-06-20T12:00:00Z",
|
||||||
|
raw_999_text: "ISA*~\n",
|
||||||
|
});
|
||||||
|
|
||||||
|
const { unmount } = renderIntoContainer(React.createElement(Acks));
|
||||||
|
await waitForText("b-uuid-1");
|
||||||
|
|
||||||
|
// No drawer yet.
|
||||||
|
expect(
|
||||||
|
document.body.querySelector('[data-testid="ack-drawer"]')
|
||||||
|
).toBeNull();
|
||||||
|
|
||||||
|
// Click the row.
|
||||||
|
const row = rowAt(0);
|
||||||
|
expect(row).not.toBeNull();
|
||||||
|
await act(async () => {
|
||||||
|
row!.click();
|
||||||
|
await Promise.resolve();
|
||||||
|
});
|
||||||
|
await settle(
|
||||||
|
() => document.body.querySelector('[data-testid="ack-drawer"]') !== null
|
||||||
|
);
|
||||||
|
|
||||||
|
// The drawer is now in the DOM.
|
||||||
|
expect(
|
||||||
|
document.body.querySelector('[data-testid="ack-drawer"]')
|
||||||
|
).not.toBeNull();
|
||||||
|
|
||||||
|
unmount();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("test_deep_link_with_ack_param_opens_drawer_on_mount", async () => {
|
||||||
|
// /acks?ack=42 deep link → drawer opens on mount without a click.
|
||||||
|
(api.listAcks as unknown as ReturnType<typeof vi.fn>).mockResolvedValue({
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
id: 42,
|
||||||
|
sourceBatchId: "b-uuid-1",
|
||||||
|
acceptedCount: 3,
|
||||||
|
rejectedCount: 1,
|
||||||
|
receivedCount: 4,
|
||||||
|
ackCode: "P",
|
||||||
|
parsedAt: "2026-06-20T12:00:00Z",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
total: 1,
|
||||||
|
returned: 1,
|
||||||
|
has_more: false,
|
||||||
|
});
|
||||||
|
(api.getAck as unknown as ReturnType<typeof vi.fn>).mockResolvedValue({
|
||||||
|
id: 42,
|
||||||
|
sourceBatchId: "b-uuid-1",
|
||||||
|
acceptedCount: 3,
|
||||||
|
rejectedCount: 1,
|
||||||
|
receivedCount: 4,
|
||||||
|
ackCode: "P",
|
||||||
|
parsedAt: "2026-06-20T12:00:00Z",
|
||||||
|
raw_999_text: "ISA*~\n",
|
||||||
|
});
|
||||||
|
|
||||||
|
(window as unknown as { happyDOM: { setURL: (u: string) => void } }).happyDOM.setURL(
|
||||||
|
"http://localhost/acks?ack=42"
|
||||||
|
);
|
||||||
|
|
||||||
|
const { unmount } = renderIntoContainer(React.createElement(Acks));
|
||||||
|
await waitForText("b-uuid-1");
|
||||||
|
await settle(
|
||||||
|
() => document.body.querySelector('[data-testid="ack-drawer"]') !== null
|
||||||
|
);
|
||||||
|
|
||||||
|
// The drawer is in the DOM on first render.
|
||||||
|
expect(
|
||||||
|
document.body.querySelector('[data-testid="ack-drawer"]')
|
||||||
|
).not.toBeNull();
|
||||||
|
|
||||||
|
unmount();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
+22
-1
@@ -12,6 +12,8 @@ import { Skeleton } from "@/components/ui/skeleton";
|
|||||||
import { EmptyState } from "@/components/ui/empty-state";
|
import { EmptyState } from "@/components/ui/empty-state";
|
||||||
import { ErrorState } from "@/components/ui/error-state";
|
import { ErrorState } from "@/components/ui/error-state";
|
||||||
import { KeyboardCheatsheet } from "@/components/KeyboardCheatsheet";
|
import { KeyboardCheatsheet } from "@/components/KeyboardCheatsheet";
|
||||||
|
import { AckDrawer } from "@/components/AckDrawer";
|
||||||
|
import { useAckDrawerUrlState } from "@/hooks/useAckDrawerUrlState";
|
||||||
import { useAcks } from "@/hooks/useAcks";
|
import { useAcks } from "@/hooks/useAcks";
|
||||||
import { useRowKeyboard } from "@/hooks/useRowKeyboard";
|
import { useRowKeyboard } from "@/hooks/useRowKeyboard";
|
||||||
import { api } from "@/lib/api";
|
import { api } from "@/lib/api";
|
||||||
@@ -91,7 +93,14 @@ function DownloadButton({ id, sourceBatchId }: { id: number; sourceBatchId: stri
|
|||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={onClick}
|
onClick={(e) => {
|
||||||
|
// The row's onClick drills into AckDrawer; this button is a
|
||||||
|
// nested control and we want the click to NOT bubble into the
|
||||||
|
// row's onClick handler (matches the precedent set by
|
||||||
|
// DrillableCell onClick in `src/components/drill/DrillableCell.tsx:39`).
|
||||||
|
e.stopPropagation();
|
||||||
|
onClick();
|
||||||
|
}}
|
||||||
disabled={busy}
|
disabled={busy}
|
||||||
className={cn(
|
className={cn(
|
||||||
"inline-flex items-center gap-1.5 rounded-sm border px-2 py-1 mono text-[10.5px] uppercase tracking-[0.14em] font-semibold transition-colors",
|
"inline-flex items-center gap-1.5 rounded-sm border px-2 py-1 mono text-[10.5px] uppercase tracking-[0.14em] font-semibold transition-colors",
|
||||||
@@ -113,6 +122,10 @@ function DownloadButton({ id, sourceBatchId }: { id: number; sourceBatchId: stri
|
|||||||
export function Acks() {
|
export function Acks() {
|
||||||
const { data, isLoading, isError, error, refetch } = useAcks({ limit: 100 });
|
const { data, isLoading, isError, error, refetch } = useAcks({ limit: 100 });
|
||||||
const items = data?.items ?? [];
|
const items = data?.items ?? [];
|
||||||
|
// SP21 Phase 5 Task 5.3: drill-down from an acks row into
|
||||||
|
// AckDrawer. The hook reads `?ack=` off `window.location.search`
|
||||||
|
// so deep links restore the open ack on reload.
|
||||||
|
const { ackId, open, close } = useAckDrawerUrlState();
|
||||||
|
|
||||||
const [selectedIndex, setSelectedIndex] = useState<number | null>(null);
|
const [selectedIndex, setSelectedIndex] = useState<number | null>(null);
|
||||||
const [helpOpen, setHelpOpen] = useState(false);
|
const [helpOpen, setHelpOpen] = useState(false);
|
||||||
@@ -172,6 +185,12 @@ export function Acks() {
|
|||||||
open={helpOpen}
|
open={helpOpen}
|
||||||
onClose={() => setHelpOpen(false)}
|
onClose={() => setHelpOpen(false)}
|
||||||
/>
|
/>
|
||||||
|
{/* SP21 Phase 5 Task 5.3: AckDrawer mount. Row click drills
|
||||||
|
into the matching ack; the drawer portals into document.body
|
||||||
|
(Radix Dialog), so the surrounding paper plane stays put
|
||||||
|
while the drawer is open. Deep links via /acks?ack=ID
|
||||||
|
restore the open ack on reload. */}
|
||||||
|
<AckDrawer ackId={ackId} onClose={close} />
|
||||||
<div className="space-y-0">
|
<div className="space-y-0">
|
||||||
{/* =================================================================
|
{/* =================================================================
|
||||||
HERO — DARK EDITORIAL HEADER
|
HERO — DARK EDITORIAL HEADER
|
||||||
@@ -650,7 +669,9 @@ export function Acks() {
|
|||||||
data-row-index={idx}
|
data-row-index={idx}
|
||||||
data-state={isSelected ? "selected" : undefined}
|
data-state={isSelected ? "selected" : undefined}
|
||||||
aria-selected={isSelected}
|
aria-selected={isSelected}
|
||||||
|
onClick={() => open(String(a.id))}
|
||||||
className={cn(
|
className={cn(
|
||||||
|
"cursor-pointer",
|
||||||
isSelected && [
|
isSelected && [
|
||||||
"ring-1 ring-inset",
|
"ring-1 ring-inset",
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -481,8 +481,184 @@ describe("BatchDiff page", () => {
|
|||||||
).toContain("pick a batch");
|
).toContain("pick a batch");
|
||||||
unmount();
|
unmount();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("SP21 Task 5.6: clicking an added-claim id navigates to /claims?claim=ID", async () => {
|
||||||
|
// Each ClaimIdCell wraps its id text with DrillableCell, whose
|
||||||
|
// onClick navigates to /claims?claim=ID. The MemoryRouter gives
|
||||||
|
// us a router context so the navigation completes; we observe
|
||||||
|
// it via the rendered pathname on a LocationTracker spy.
|
||||||
|
(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 captured: { pathname: string; search: string } = {
|
||||||
|
pathname: "/batch-diff",
|
||||||
|
search: "",
|
||||||
|
};
|
||||||
|
const Tracker = () => {
|
||||||
|
const loc = useLocationSafe();
|
||||||
|
React.useEffect(() => {
|
||||||
|
captured.pathname = loc.pathname;
|
||||||
|
captured.search = loc.search;
|
||||||
|
}, [loc.pathname, loc.search]);
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const { unmount } = renderIntoContainer(
|
||||||
|
<>
|
||||||
|
<Tracker />
|
||||||
|
<BatchDiff />
|
||||||
|
</>,
|
||||||
|
[`/batch-diff?a=${BATCH_A.id}&b=${BATCH_B.id}`],
|
||||||
|
);
|
||||||
|
|
||||||
|
await waitFor(
|
||||||
|
() => !!document.querySelector('[data-testid="diff-added-row-CLM-3"]'),
|
||||||
|
"added row rendered",
|
||||||
|
);
|
||||||
|
|
||||||
|
const cell = document.querySelector(
|
||||||
|
'[data-testid="diff-added-row-CLM-3"] [data-testid="diff-claim-id"]',
|
||||||
|
);
|
||||||
|
expect(cell).not.toBeNull();
|
||||||
|
// DrillableCell wraps the id text in its own <button>; click the
|
||||||
|
// nearest button ancestor so the drillable onClick fires.
|
||||||
|
const drillBtn =
|
||||||
|
(cell?.closest("button") as HTMLButtonElement | null) ?? null;
|
||||||
|
expect(drillBtn).not.toBeNull();
|
||||||
|
await act(async () => {
|
||||||
|
drillBtn!.click();
|
||||||
|
await Promise.resolve();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
await waitFor(
|
||||||
|
() => captured.pathname === "/claims" && captured.search === "?claim=CLM-3",
|
||||||
|
"navigation to /claims?claim=CLM-3",
|
||||||
|
);
|
||||||
|
unmount();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("SP21 Task 5.6: clicking a removed-claim id still navigates (ClaimDrawer handles 404)", async () => {
|
||||||
|
// The diff can list claim ids that no longer exist in the DB
|
||||||
|
// (Removed from A means "was in A, not in B" — the claim may
|
||||||
|
// still exist or may have been purged). Either way, clicking
|
||||||
|
// the id drills to /claims?claim=ID; the ClaimDrawer's 404
|
||||||
|
// state (Phase 2) handles the missing-claim case. This test
|
||||||
|
// only verifies the click path, not the drawer's 404 surface.
|
||||||
|
(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 captured: { pathname: string; search: string } = {
|
||||||
|
pathname: "/batch-diff",
|
||||||
|
search: "",
|
||||||
|
};
|
||||||
|
const Tracker = () => {
|
||||||
|
const loc = useLocationSafe();
|
||||||
|
React.useEffect(() => {
|
||||||
|
captured.pathname = loc.pathname;
|
||||||
|
captured.search = loc.search;
|
||||||
|
}, [loc.pathname, loc.search]);
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const { unmount } = renderIntoContainer(
|
||||||
|
<>
|
||||||
|
<Tracker />
|
||||||
|
<BatchDiff />
|
||||||
|
</>,
|
||||||
|
[`/batch-diff?a=${BATCH_A.id}&b=${BATCH_B.id}`],
|
||||||
|
);
|
||||||
|
|
||||||
|
await waitFor(
|
||||||
|
() => !!document.querySelector('[data-testid="diff-removed-row-CLM-2"]'),
|
||||||
|
"removed row rendered",
|
||||||
|
);
|
||||||
|
|
||||||
|
const cell = document.querySelector(
|
||||||
|
'[data-testid="diff-removed-row-CLM-2"] [data-testid="diff-claim-id"]',
|
||||||
|
);
|
||||||
|
const drillBtn =
|
||||||
|
(cell?.closest("button") as HTMLButtonElement | null) ?? null;
|
||||||
|
expect(drillBtn).not.toBeNull();
|
||||||
|
await act(async () => {
|
||||||
|
drillBtn!.click();
|
||||||
|
await Promise.resolve();
|
||||||
|
});
|
||||||
|
|
||||||
|
await waitFor(
|
||||||
|
() => captured.pathname === "/claims" && captured.search === "?claim=CLM-2",
|
||||||
|
"navigation to /claims?claim=CLM-2",
|
||||||
|
);
|
||||||
|
unmount();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("SP21 Task 5.6: clicking a changed-claim id navigates to /claims?claim=ID", async () => {
|
||||||
|
// The Changed row uses the A-side id (per existing test
|
||||||
|
// assertions). Drill should fire on that id.
|
||||||
|
(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 captured: { pathname: string; search: string } = {
|
||||||
|
pathname: "/batch-diff",
|
||||||
|
search: "",
|
||||||
|
};
|
||||||
|
const Tracker = () => {
|
||||||
|
const loc = useLocationSafe();
|
||||||
|
React.useEffect(() => {
|
||||||
|
captured.pathname = loc.pathname;
|
||||||
|
captured.search = loc.search;
|
||||||
|
}, [loc.pathname, loc.search]);
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const { unmount } = renderIntoContainer(
|
||||||
|
<>
|
||||||
|
<Tracker />
|
||||||
|
<BatchDiff />
|
||||||
|
</>,
|
||||||
|
[`/batch-diff?a=${BATCH_A.id}&b=${BATCH_B.id}`],
|
||||||
|
);
|
||||||
|
|
||||||
|
await waitFor(
|
||||||
|
() => !!document.querySelector('[data-testid="diff-changed-row-CLM-1"]'),
|
||||||
|
"changed row rendered",
|
||||||
|
);
|
||||||
|
|
||||||
|
const cell = document.querySelector(
|
||||||
|
'[data-testid="diff-changed-row-CLM-1"] [data-testid="diff-claim-id"]',
|
||||||
|
);
|
||||||
|
const drillBtn =
|
||||||
|
(cell?.closest("button") as HTMLButtonElement | null) ?? null;
|
||||||
|
expect(drillBtn).not.toBeNull();
|
||||||
|
await act(async () => {
|
||||||
|
drillBtn!.click();
|
||||||
|
await Promise.resolve();
|
||||||
|
});
|
||||||
|
|
||||||
|
await waitFor(
|
||||||
|
() => captured.pathname === "/claims" && captured.search === "?claim=CLM-1",
|
||||||
|
"navigation to /claims?claim=CLM-1",
|
||||||
|
);
|
||||||
|
unmount();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// SP21 Phase 5 Task 5.6: react-router-dom's `useLocation` hook, used
|
||||||
|
// by the LocationTracker test helper below. Aliased to keep the import
|
||||||
|
// block tidy alongside React + the page import.
|
||||||
|
import { useLocation as useLocationSafe } from "react-router-dom";
|
||||||
|
|
||||||
// Keep `ApiError` referenced so the import isn't tree-shaken by
|
// Keep `ApiError` referenced so the import isn't tree-shaken by
|
||||||
// vitest's transformer when the mock factory above is hoisted.
|
// vitest's transformer when the mock factory above is hoisted.
|
||||||
void ApiError;
|
void ApiError;
|
||||||
|
|||||||
+31
-14
@@ -10,6 +10,7 @@ import { describe, expect, it, vi, beforeEach, afterEach } from "vitest";
|
|||||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||||
import { MemoryRouter } from "react-router-dom";
|
import { MemoryRouter } from "react-router-dom";
|
||||||
import { Claims } from "./Claims";
|
import { Claims } from "./Claims";
|
||||||
|
import { DrillStackProvider } from "@/components/drill/DrillStackProvider";
|
||||||
import { api } from "@/lib/api";
|
import { api } from "@/lib/api";
|
||||||
import { useTailStore } from "@/store/tail-store";
|
import { useTailStore } from "@/store/tail-store";
|
||||||
import type { Claim, ClaimDetail } from "@/types";
|
import type { Claim, ClaimDetail } from "@/types";
|
||||||
@@ -194,9 +195,18 @@ function renderClaimsAt(initialEntries: string[]): {
|
|||||||
React.createElement(
|
React.createElement(
|
||||||
MemoryRouter,
|
MemoryRouter,
|
||||||
{ initialEntries },
|
{ initialEntries },
|
||||||
|
// SP21 Phase 5 Task 5.8/5.9: the ClaimDrawer mounted by
|
||||||
|
// Claims now uses useDrillStack() in PartiesGrid +
|
||||||
|
// ValidationPanel. Wrap in a DrillStackProvider so the
|
||||||
|
// hook has a context (the provider is also mounted at
|
||||||
|
// the App root in production).
|
||||||
|
React.createElement(
|
||||||
|
DrillStackProvider,
|
||||||
|
null,
|
||||||
React.createElement(Claims),
|
React.createElement(Claims),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
return {
|
return {
|
||||||
@@ -313,17 +323,19 @@ describe("Claims page drawer wiring", () => {
|
|||||||
document.body.querySelector('[data-testid="claim-drawer"]')
|
document.body.querySelector('[data-testid="claim-drawer"]')
|
||||||
).not.toBeNull();
|
).not.toBeNull();
|
||||||
|
|
||||||
// Header shows the claim id — proves the id propagated end-to-end
|
// SP21 Phase 5 Task 5.10: the claim id is now the title of the
|
||||||
// (URL → useDrawerUrlState → ClaimDrawer prop → useClaimDetail →
|
// shared DrillDrawerHeader (rendered as an <h2>). Find the h2
|
||||||
// header render), not just that some drawer is mounted.
|
// inside the drawer and assert its text matches the claim id —
|
||||||
await settle(
|
// proves the id propagated end-to-end (URL → useDrawerUrlState
|
||||||
() =>
|
// → ClaimDrawer prop → useClaimDetail → header render), not
|
||||||
document.body.querySelector('[data-testid="header-id"]')?.textContent ===
|
// just that some drawer is mounted.
|
||||||
"CLM-1"
|
await settle(() => {
|
||||||
);
|
const drawer = document.body.querySelector('[data-testid="claim-drawer"]');
|
||||||
expect(
|
const h2 = drawer?.querySelector("h2");
|
||||||
document.body.querySelector('[data-testid="header-id"]')?.textContent
|
return h2?.textContent === "CLM-1";
|
||||||
).toBe("CLM-1");
|
});
|
||||||
|
const drawer = document.body.querySelector('[data-testid="claim-drawer"]');
|
||||||
|
expect(drawer?.querySelector("h2")?.textContent).toBe("CLM-1");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("test_escape_closes_the_drawer", async () => {
|
it("test_escape_closes_the_drawer", async () => {
|
||||||
@@ -360,9 +372,14 @@ describe("Claims page drawer wiring", () => {
|
|||||||
|
|
||||||
// Wait for the header to render — the close button only mounts once
|
// Wait for the header to render — the close button only mounts once
|
||||||
// `useClaimDetail` resolves with a real ClaimDetail (the skeleton /
|
// `useClaimDetail` resolves with a real ClaimDetail (the skeleton /
|
||||||
// error states don't render the header).
|
// error states don't render the header). SP21 Phase 5 Task 5.10:
|
||||||
|
// the close button is now inside the shared DrillDrawerHeader;
|
||||||
|
// find it via its accessible name.
|
||||||
await settle(
|
await settle(
|
||||||
() => document.body.querySelector('[data-testid="header-close"]') !== null
|
() =>
|
||||||
|
document.body.querySelector(
|
||||||
|
'[data-testid="claim-drawer"] button[aria-label="Close drawer"]'
|
||||||
|
) !== null
|
||||||
);
|
);
|
||||||
|
|
||||||
// URL currently carries the claim.
|
// URL currently carries the claim.
|
||||||
@@ -372,7 +389,7 @@ describe("Claims page drawer wiring", () => {
|
|||||||
// onClose → useDrawerUrlState.close(), which pushState's a URL with
|
// onClose → useDrawerUrlState.close(), which pushState's a URL with
|
||||||
// the ?claim= param stripped.
|
// the ?claim= param stripped.
|
||||||
const closeBtn = document.body.querySelector(
|
const closeBtn = document.body.querySelector(
|
||||||
'[data-testid="header-close"]'
|
'[data-testid="claim-drawer"] button[aria-label="Close drawer"]'
|
||||||
) as HTMLButtonElement | null;
|
) as HTMLButtonElement | null;
|
||||||
expect(closeBtn).not.toBeNull();
|
expect(closeBtn).not.toBeNull();
|
||||||
await act(async () => {
|
await act(async () => {
|
||||||
|
|||||||
+189
-2
@@ -1,7 +1,8 @@
|
|||||||
// @vitest-environment happy-dom
|
// @vitest-environment happy-dom
|
||||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||||
import { act, cleanup, fireEvent, render, waitFor } from "@testing-library/react";
|
import { act, cleanup, fireEvent, render, waitFor } from "@testing-library/react";
|
||||||
import { MemoryRouter } from "react-router-dom";
|
import { useEffect, useState } from "react";
|
||||||
|
import { MemoryRouter, useLocation, useNavigate } from "react-router-dom";
|
||||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||||
import Inbox from "./Inbox";
|
import Inbox from "./Inbox";
|
||||||
import * as inboxApi from "@/lib/inbox-api";
|
import * as inboxApi from "@/lib/inbox-api";
|
||||||
@@ -12,17 +13,46 @@ import * as downloadModule from "@/lib/download";
|
|||||||
// a Router context. We use a fresh QueryClient per test so the
|
// a Router context. We use a fresh QueryClient per test so the
|
||||||
// drawer's per-remit query doesn't leak cache between cases, and a
|
// drawer's per-remit query doesn't leak cache between cases, and a
|
||||||
// MemoryRouter so `useNavigate` has a router to push into.
|
// MemoryRouter so `useNavigate` has a router to push into.
|
||||||
|
//
|
||||||
|
// Phase 5 Task 5.4: MemoryRouter doesn't update window.location, so
|
||||||
|
// the navigation assertion uses a small LocationTracker component
|
||||||
|
// mounted under the same router that records the current pathname +
|
||||||
|
// search after each render. Tests then read `tracker.last` instead
|
||||||
|
// of `window.location`.
|
||||||
|
function LocationTracker({ on }: { on: (pathname: string, search: string) => void }) {
|
||||||
|
const loc = useLocation();
|
||||||
|
useEffect(() => {
|
||||||
|
on(loc.pathname, loc.search);
|
||||||
|
}, [loc.pathname, loc.search, on]);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
function renderInbox() {
|
function renderInbox() {
|
||||||
const qc = new QueryClient({
|
const qc = new QueryClient({
|
||||||
defaultOptions: { queries: { retry: false, retryDelay: 0 } },
|
defaultOptions: { queries: { retry: false, retryDelay: 0 } },
|
||||||
});
|
});
|
||||||
return render(
|
// Capture the last location seen so tests can assert on navigation
|
||||||
|
// without poking at window.location (MemoryRouter doesn't touch it).
|
||||||
|
const captured = { pathname: "/inbox", search: "" };
|
||||||
|
const view = render(
|
||||||
<MemoryRouter initialEntries={["/inbox"]}>
|
<MemoryRouter initialEntries={["/inbox"]}>
|
||||||
<QueryClientProvider client={qc}>
|
<QueryClientProvider client={qc}>
|
||||||
|
<LocationTracker
|
||||||
|
on={(pathname, search) => {
|
||||||
|
captured.pathname = pathname;
|
||||||
|
captured.search = search;
|
||||||
|
}}
|
||||||
|
/>
|
||||||
<Inbox />
|
<Inbox />
|
||||||
</QueryClientProvider>
|
</QueryClientProvider>
|
||||||
</MemoryRouter>,
|
</MemoryRouter>,
|
||||||
);
|
);
|
||||||
|
// Attach the tracker so individual tests can read it. Note: the
|
||||||
|
// captured value updates asynchronously after navigation, but since
|
||||||
|
// LocationTracker runs in the same render pass as the navigate,
|
||||||
|
// tests should `waitFor` it.
|
||||||
|
(view as unknown as { tracker: typeof captured }).tracker = captured;
|
||||||
|
return view as ReturnType<typeof render> & { tracker: typeof captured };
|
||||||
}
|
}
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
@@ -355,4 +385,161 @@ describe("Inbox page", () => {
|
|||||||
).not.toBeNull();
|
).not.toBeNull();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("SP21 Task 5.4: clicking a rejected claim row navigates to /claims?claim=ID", async () => {
|
||||||
|
// Task 5.4 wires the rejected lane's onRowClick to navigate to
|
||||||
|
// the ClaimDrawer via `?claim=ID` on the /claims route. The
|
||||||
|
// MemoryRouter (initial /inbox) lets us observe the URL change
|
||||||
|
// via the LocationTracker helper mounted in the render harness.
|
||||||
|
vi.stubGlobal(
|
||||||
|
"fetch",
|
||||||
|
vi.fn().mockResolvedValue({
|
||||||
|
ok: true,
|
||||||
|
json: async () => ({
|
||||||
|
rejected: [
|
||||||
|
{
|
||||||
|
id: "REJ1",
|
||||||
|
kind: "claim",
|
||||||
|
payer_claim_control_number: "REJ1",
|
||||||
|
charge_amount: 175,
|
||||||
|
payer_id: "P1",
|
||||||
|
provider_npi: "1234567890",
|
||||||
|
state: "rejected",
|
||||||
|
rejection_reason: "999 reject",
|
||||||
|
service_date: null,
|
||||||
|
score: null,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
payer_rejected: [],
|
||||||
|
candidates: [],
|
||||||
|
unmatched: [],
|
||||||
|
done_today: [],
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const view = renderInbox();
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(view.container.textContent).toContain("REJ1");
|
||||||
|
});
|
||||||
|
|
||||||
|
// Click the rejected row. The InboxRow renders the claim id as
|
||||||
|
// its primary text cell; clicking that row bubbles up to the
|
||||||
|
// Lane's onRowClick handler we wired in Task 5.4.
|
||||||
|
const cell = Array.from(view.container.querySelectorAll("td")).find(
|
||||||
|
(td) => td.textContent === "REJ1",
|
||||||
|
);
|
||||||
|
const row = cell?.closest("tr");
|
||||||
|
expect(row).toBeTruthy();
|
||||||
|
await act(async () => {
|
||||||
|
fireEvent.click(row as HTMLElement);
|
||||||
|
});
|
||||||
|
|
||||||
|
// MemoryRouter navigates to /claims?claim=REJ1 — verify the
|
||||||
|
// tracker observed the new pathname + search.
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(view.tracker.pathname).toBe("/claims");
|
||||||
|
expect(view.tracker.search).toBe("?claim=REJ1");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("SP21 Task 5.4: clicking a done_today claim row navigates to /claims?claim=ID", async () => {
|
||||||
|
// done_today rows are claim-shaped — same drill pattern as the
|
||||||
|
// rejected lane. Verifies that the wiring covers the trailing
|
||||||
|
// "shipped today" lane too, not just the error lanes.
|
||||||
|
vi.stubGlobal(
|
||||||
|
"fetch",
|
||||||
|
vi.fn().mockResolvedValue({
|
||||||
|
ok: true,
|
||||||
|
json: async () => ({
|
||||||
|
rejected: [],
|
||||||
|
payer_rejected: [],
|
||||||
|
candidates: [],
|
||||||
|
unmatched: [],
|
||||||
|
done_today: [
|
||||||
|
{
|
||||||
|
id: "DONE1",
|
||||||
|
kind: "claim",
|
||||||
|
patient_control_number: "DONE1",
|
||||||
|
charge_amount: 88,
|
||||||
|
payer_id: "P1",
|
||||||
|
provider_npi: "1234567890",
|
||||||
|
state: "submitted",
|
||||||
|
service_date_from: "2026-06-21",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const view = renderInbox();
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(view.container.textContent).toContain("DONE1");
|
||||||
|
});
|
||||||
|
|
||||||
|
const cell = Array.from(view.container.querySelectorAll("td")).find(
|
||||||
|
(td) => td.textContent === "DONE1",
|
||||||
|
);
|
||||||
|
const row = cell?.closest("tr");
|
||||||
|
expect(row).toBeTruthy();
|
||||||
|
await act(async () => {
|
||||||
|
fireEvent.click(row as HTMLElement);
|
||||||
|
});
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(view.tracker.pathname).toBe("/claims");
|
||||||
|
expect(view.tracker.search).toBe("?claim=DONE1");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("SP21 Task 5.4: clicking the row checkbox does not bubble to onRowClick", async () => {
|
||||||
|
// Per the plan's §self-review #5, the Lane's RowCheckbox already
|
||||||
|
// calls e.stopPropagation() — verify that's still the case by
|
||||||
|
// clicking the checkbox and confirming the URL didn't change to
|
||||||
|
// /claims. (If stopPropagation regressed, the row click handler
|
||||||
|
// would fire and navigate.)
|
||||||
|
vi.stubGlobal(
|
||||||
|
"fetch",
|
||||||
|
vi.fn().mockResolvedValue({
|
||||||
|
ok: true,
|
||||||
|
json: async () => ({
|
||||||
|
rejected: [
|
||||||
|
{
|
||||||
|
id: "REJ1",
|
||||||
|
kind: "claim",
|
||||||
|
payer_claim_control_number: "REJ1",
|
||||||
|
charge_amount: 175,
|
||||||
|
payer_id: "P1",
|
||||||
|
provider_npi: "1234567890",
|
||||||
|
state: "rejected",
|
||||||
|
rejection_reason: "999 reject",
|
||||||
|
service_date: null,
|
||||||
|
score: null,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
payer_rejected: [],
|
||||||
|
candidates: [],
|
||||||
|
unmatched: [],
|
||||||
|
done_today: [],
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const view = renderInbox();
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(view.container.textContent).toContain("REJ1");
|
||||||
|
});
|
||||||
|
|
||||||
|
const checkbox = view.container.querySelector(
|
||||||
|
'input[type="checkbox"][aria-label="Select REJ1"]',
|
||||||
|
) as HTMLInputElement;
|
||||||
|
expect(checkbox).toBeTruthy();
|
||||||
|
await act(async () => {
|
||||||
|
checkbox.click();
|
||||||
|
});
|
||||||
|
|
||||||
|
// URL should still be /inbox — clicking the checkbox selected
|
||||||
|
// the row but did not drill into the claim drawer.
|
||||||
|
expect(view.tracker.pathname).toBe("/inbox");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
+27
-3
@@ -295,7 +295,16 @@ export default function Inbox() {
|
|||||||
name="REJECTED"
|
name="REJECTED"
|
||||||
accent="oxblood"
|
accent="oxblood"
|
||||||
rows={lanes.rejected}
|
rows={lanes.rejected}
|
||||||
onRowClick={() => {}}
|
// SP21 Phase 5 Task 5.4: rejected claims drill into the
|
||||||
|
// ClaimDrawer. All rows here are claims (kind === "claim"),
|
||||||
|
// so the branch is straightforward — the type union still
|
||||||
|
// requires the defensive check, but a row click on a claim
|
||||||
|
// here is always a claim drill.
|
||||||
|
onRowClick={(row) => {
|
||||||
|
if (row.kind === "claim") {
|
||||||
|
navigate(`/claims?claim=${encodeURIComponent(row.id)}`);
|
||||||
|
}
|
||||||
|
}}
|
||||||
onSelectionChange={(ids) => setLaneSelected("rejected", ids)}
|
onSelectionChange={(ids) => setLaneSelected("rejected", ids)}
|
||||||
/>
|
/>
|
||||||
{/*
|
{/*
|
||||||
@@ -308,7 +317,13 @@ export default function Inbox() {
|
|||||||
name="PAYER REJECTED"
|
name="PAYER REJECTED"
|
||||||
accent="oxblood"
|
accent="oxblood"
|
||||||
rows={lanes.payer_rejected}
|
rows={lanes.payer_rejected}
|
||||||
onRowClick={() => {}}
|
// SP21 Phase 5 Task 5.4: payer-rejected claims also drill
|
||||||
|
// into the ClaimDrawer — same shape as the rejected lane.
|
||||||
|
onRowClick={(row) => {
|
||||||
|
if (row.kind === "claim") {
|
||||||
|
navigate(`/claims?claim=${encodeURIComponent(row.id)}`);
|
||||||
|
}
|
||||||
|
}}
|
||||||
onSelectionChange={(ids) => setLaneSelected("payer_rejected", ids)}
|
onSelectionChange={(ids) => setLaneSelected("payer_rejected", ids)}
|
||||||
/>
|
/>
|
||||||
<Lane
|
<Lane
|
||||||
@@ -344,7 +359,16 @@ export default function Inbox() {
|
|||||||
name="DONE"
|
name="DONE"
|
||||||
accent="muted"
|
accent="muted"
|
||||||
rows={lanes.done_today}
|
rows={lanes.done_today}
|
||||||
onRowClick={() => {}}
|
// SP21 Phase 5 Task 5.4: done_today rows are also claim
|
||||||
|
// drills — same as rejected / payer_rejected. The drawer
|
||||||
|
// surfaces the claim's current state + recent history,
|
||||||
|
// which is exactly what an operator wants when reviewing
|
||||||
|
// "what got shipped today".
|
||||||
|
onRowClick={(row) => {
|
||||||
|
if (row.kind === "claim") {
|
||||||
|
navigate(`/claims?claim=${encodeURIComponent(row.id)}`);
|
||||||
|
}
|
||||||
|
}}
|
||||||
onSelectionChange={(ids) => setLaneSelected("done_today", ids)}
|
onSelectionChange={(ids) => setLaneSelected("done_today", ids)}
|
||||||
/>
|
/>
|
||||||
</main>
|
</main>
|
||||||
|
|||||||
@@ -4,9 +4,10 @@
|
|||||||
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT =
|
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT =
|
||||||
true;
|
true;
|
||||||
|
|
||||||
import React, { act } from "react";
|
import React, { act, useEffect } from "react";
|
||||||
import { createRoot, type Root } from "react-dom/client";
|
import { createRoot, type Root } from "react-dom/client";
|
||||||
import { describe, expect, it, vi, beforeEach } from "vitest";
|
import { describe, expect, it, vi, beforeEach, afterEach } from "vitest";
|
||||||
|
import { MemoryRouter, useLocation } from "react-router-dom";
|
||||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||||
import { ReconciliationPage } from "./Reconciliation";
|
import { ReconciliationPage } from "./Reconciliation";
|
||||||
import { api } from "@/lib/api";
|
import { api } from "@/lib/api";
|
||||||
@@ -28,6 +29,23 @@ vi.mock("@/lib/api", () => ({
|
|||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tracks the current MemoryRouter location so tests can assert on
|
||||||
|
* navigation without poking at window.location (MemoryRouter doesn't
|
||||||
|
* touch it). Mounted as a sibling under the same router.
|
||||||
|
*/
|
||||||
|
function LocationTracker({
|
||||||
|
on,
|
||||||
|
}: {
|
||||||
|
on: (pathname: string, search: string) => void;
|
||||||
|
}) {
|
||||||
|
const loc = useLocation();
|
||||||
|
useEffect(() => {
|
||||||
|
on(loc.pathname, loc.search);
|
||||||
|
}, [loc.pathname, loc.search, on]);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Minimal `render` helper using react-dom/client + act(). Mirrors the
|
* Minimal `render` helper using react-dom/client + act(). Mirrors the
|
||||||
* `renderHook` helper in `useReconciliation.test.ts` — see that file's
|
* `renderHook` helper in `useReconciliation.test.ts` — see that file's
|
||||||
@@ -35,22 +53,50 @@ vi.mock("@/lib/api", () => ({
|
|||||||
* yet, and adding one just for these tests would inflate the dev-deps
|
* yet, and adding one just for these tests would inflate the dev-deps
|
||||||
* tree). Returns the rendered container so tests can assert against
|
* tree). Returns the rendered container so tests can assert against
|
||||||
* the live DOM via `container.textContent`.
|
* the live DOM via `container.textContent`.
|
||||||
|
*
|
||||||
|
* SP21 Phase 5 Task 5.5: optionally wraps with a MemoryRouter so the
|
||||||
|
* `useNavigate()` call (claim drill to /claims?claim=ID) has a router
|
||||||
|
* context. Without this, `useNavigate()` would throw in tests that
|
||||||
|
* trigger a claim-card click. Tests that don't need a router can keep
|
||||||
|
* using the default (no router) path.
|
||||||
*/
|
*/
|
||||||
function renderIntoContainer(element: React.ReactElement): {
|
function renderIntoContainer(
|
||||||
|
element: React.ReactElement,
|
||||||
|
options: { withRouter?: boolean; initialEntries?: string[] } = {},
|
||||||
|
): {
|
||||||
container: HTMLDivElement;
|
container: HTMLDivElement;
|
||||||
unmount: () => void;
|
unmount: () => void;
|
||||||
|
tracker?: { pathname: string; search: string };
|
||||||
} {
|
} {
|
||||||
const container = document.createElement("div");
|
const container = document.createElement("div");
|
||||||
document.body.appendChild(container);
|
document.body.appendChild(container);
|
||||||
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||||
|
|
||||||
|
let tracker: { pathname: string; search: string } | undefined;
|
||||||
|
const track = (pathname: string, search: string) => {
|
||||||
|
if (!tracker) tracker = { pathname, search };
|
||||||
|
tracker.pathname = pathname;
|
||||||
|
tracker.search = search;
|
||||||
|
};
|
||||||
|
|
||||||
|
const inner = options.withRouter
|
||||||
|
? React.createElement(
|
||||||
|
MemoryRouter,
|
||||||
|
{
|
||||||
|
initialEntries: options.initialEntries ?? ["/reconciliation"],
|
||||||
|
},
|
||||||
|
React.createElement(LocationTracker, { on: track }),
|
||||||
|
element,
|
||||||
|
)
|
||||||
|
: element;
|
||||||
|
|
||||||
const root: Root = createRoot(container);
|
const root: Root = createRoot(container);
|
||||||
act(() => {
|
act(() => {
|
||||||
root.render(
|
root.render(
|
||||||
React.createElement(
|
React.createElement(
|
||||||
QueryClientProvider,
|
QueryClientProvider,
|
||||||
{ client: qc },
|
{ client: qc },
|
||||||
element
|
inner
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
@@ -61,6 +107,7 @@ function renderIntoContainer(element: React.ReactElement): {
|
|||||||
act(() => root.unmount());
|
act(() => root.unmount());
|
||||||
container.remove();
|
container.remove();
|
||||||
},
|
},
|
||||||
|
tracker,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -94,6 +141,33 @@ describe("ReconciliationPage", () => {
|
|||||||
(
|
(
|
||||||
api.getRemittance as unknown as ReturnType<typeof vi.fn>
|
api.getRemittance as unknown as ReturnType<typeof vi.fn>
|
||||||
).mockReturnValue(new Promise(() => {}));
|
).mockReturnValue(new Promise(() => {}));
|
||||||
|
// SP21 Phase 5 Task 5.5: reset window.location to a clean
|
||||||
|
// /reconciliation URL so `useRemitDrawerUrlState`'s mount-time
|
||||||
|
// read doesn't see a `?remit=REM-DRILL` from the previous
|
||||||
|
// test's `open()` history push. Without this, a test that
|
||||||
|
// asserts the drawer isn't open still finds the drawer
|
||||||
|
// mounted on initial render (driven by history state, not by
|
||||||
|
// a click).
|
||||||
|
(window as unknown as { happyDOM: { setURL: (u: string) => void } })
|
||||||
|
.happyDOM.setURL("http://localhost/reconciliation");
|
||||||
|
// SP21 Phase 5 Task 5.5: the previous test may have left a Radix
|
||||||
|
// portal in document.body. Clear them before each test so a
|
||||||
|
// "drawer not in DOM" assertion isn't polluted by a stale portal.
|
||||||
|
document.body
|
||||||
|
.querySelectorAll('[role="dialog"]')
|
||||||
|
.forEach((node) => node.remove());
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
// SP21 Phase 5 Task 5.5: the RemitDrawer portals into
|
||||||
|
// document.body. Each test renders + unmounts, but happy-dom's
|
||||||
|
// document.body persists across tests within the file, so we
|
||||||
|
// explicitly remove any lingering Radix portals here. Without
|
||||||
|
// this, a test that asserts `drawer not in DOM` would still
|
||||||
|
// find a leftover portal from the previous test.
|
||||||
|
document.body
|
||||||
|
.querySelectorAll('[role="dialog"]')
|
||||||
|
.forEach((node) => node.remove());
|
||||||
});
|
});
|
||||||
|
|
||||||
it("SP21 Task 4.6: deep-link ?remit=ID opens the RemitDrawer on mount", async () => {
|
it("SP21 Task 4.6: deep-link ?remit=ID opens the RemitDrawer on mount", async () => {
|
||||||
@@ -138,6 +212,7 @@ describe("ReconciliationPage", () => {
|
|||||||
|
|
||||||
const { unmount } = renderIntoContainer(
|
const { unmount } = renderIntoContainer(
|
||||||
React.createElement(ReconciliationPage),
|
React.createElement(ReconciliationPage),
|
||||||
|
{ withRouter: true },
|
||||||
);
|
);
|
||||||
|
|
||||||
// Wait for the loaded two-column view (the "Pair them." headline
|
// Wait for the loaded two-column view (the "Pair them." headline
|
||||||
@@ -188,7 +263,8 @@ describe("ReconciliationPage", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const { unmount } = renderIntoContainer(
|
const { unmount } = renderIntoContainer(
|
||||||
React.createElement(ReconciliationPage)
|
React.createElement(ReconciliationPage),
|
||||||
|
{ withRouter: true },
|
||||||
);
|
);
|
||||||
await waitForText("CLM-1");
|
await waitForText("CLM-1");
|
||||||
|
|
||||||
@@ -206,7 +282,8 @@ describe("ReconciliationPage", () => {
|
|||||||
).mockResolvedValue({ claims: [], remittances: [] });
|
).mockResolvedValue({ claims: [], remittances: [] });
|
||||||
|
|
||||||
const { unmount } = renderIntoContainer(
|
const { unmount } = renderIntoContainer(
|
||||||
React.createElement(ReconciliationPage)
|
React.createElement(ReconciliationPage),
|
||||||
|
{ withRouter: true },
|
||||||
);
|
);
|
||||||
await waitForText("nothing pending");
|
await waitForText("nothing pending");
|
||||||
|
|
||||||
@@ -216,4 +293,213 @@ describe("ReconciliationPage", () => {
|
|||||||
expect(document.body.textContent).not.toContain("Unmatched claims (");
|
expect(document.body.textContent).not.toContain("Unmatched claims (");
|
||||||
unmount();
|
unmount();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("SP21 Task 5.5: clicking the claim card body drills to /claims?claim=ID", async () => {
|
||||||
|
// Task 5.5 splits the gesture: clicking the card body drills into
|
||||||
|
// the ClaimDrawer via /claims?claim=ID, while a separate
|
||||||
|
// "Select for match" button toggles the row's selection. The
|
||||||
|
// card is no longer a single <button>; the body region is a
|
||||||
|
// button with its own onClick that calls navigate().
|
||||||
|
(
|
||||||
|
api.listUnmatched as unknown as ReturnType<typeof vi.fn>
|
||||||
|
).mockResolvedValue({
|
||||||
|
claims: [
|
||||||
|
{
|
||||||
|
id: "CLM-DRILL",
|
||||||
|
patientName: "Patient Drill",
|
||||||
|
billedAmount: 250,
|
||||||
|
providerNpi: "1234567890",
|
||||||
|
serviceDate: "2026-06-19",
|
||||||
|
payerId: "P1",
|
||||||
|
state: "submitted",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
remittances: [],
|
||||||
|
});
|
||||||
|
|
||||||
|
const { unmount, tracker } = renderIntoContainer(
|
||||||
|
React.createElement(ReconciliationPage),
|
||||||
|
{ withRouter: true, initialEntries: ["/reconciliation"] },
|
||||||
|
);
|
||||||
|
await waitForText("CLM-DRILL");
|
||||||
|
|
||||||
|
// Click the claim card body (the body region is the inner <button>
|
||||||
|
// that contains the id). It's not the "Select for match" toggle
|
||||||
|
// — the body region has aria-label="View claim CLM-DRILL in detail".
|
||||||
|
const bodyBtn = document.body.querySelector(
|
||||||
|
'button[aria-label="View claim CLM-DRILL in detail"]',
|
||||||
|
) as HTMLButtonElement | null;
|
||||||
|
expect(bodyBtn).not.toBeNull();
|
||||||
|
await act(async () => {
|
||||||
|
bodyBtn!.click();
|
||||||
|
await Promise.resolve();
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(tracker?.pathname).toBe("/claims");
|
||||||
|
expect(tracker?.search).toBe("?claim=CLM-DRILL");
|
||||||
|
unmount();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("SP21 Task 5.5: clicking the 'Select for match' button toggles selection without drilling", async () => {
|
||||||
|
// The select button calls e.stopPropagation() before setSelectedClaim,
|
||||||
|
// so the row body click handler doesn't fire. Verify the URL stays
|
||||||
|
// on /reconciliation and the "Match selected" button becomes enabled
|
||||||
|
// (or at least that selection state advances).
|
||||||
|
(
|
||||||
|
api.listUnmatched as unknown as ReturnType<typeof vi.fn>
|
||||||
|
).mockResolvedValue({
|
||||||
|
claims: [
|
||||||
|
{
|
||||||
|
id: "CLM-SEL",
|
||||||
|
patientName: "Patient Select",
|
||||||
|
billedAmount: 175,
|
||||||
|
providerNpi: "1234567890",
|
||||||
|
serviceDate: "2026-06-19",
|
||||||
|
payerId: "P1",
|
||||||
|
state: "submitted",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
remittances: [
|
||||||
|
{
|
||||||
|
id: "REM-SEL",
|
||||||
|
payerClaimControlNumber: "PCN-SEL",
|
||||||
|
status: "received",
|
||||||
|
paidAmount: 175,
|
||||||
|
adjustmentAmount: 0,
|
||||||
|
receivedDate: "2026-06-19",
|
||||||
|
isReversal: false,
|
||||||
|
totalCharge: 175,
|
||||||
|
serviceDate: "2026-06-19",
|
||||||
|
batchId: "b1",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
const { unmount, tracker } = renderIntoContainer(
|
||||||
|
React.createElement(ReconciliationPage),
|
||||||
|
{ withRouter: true },
|
||||||
|
);
|
||||||
|
await waitForText("CLM-SEL");
|
||||||
|
|
||||||
|
// Click the claim "Select for match" toggle. It has
|
||||||
|
// data-testid="recon-claim-select-CLM-SEL" and should NOT drill.
|
||||||
|
const selectBtn = document.body.querySelector(
|
||||||
|
'[data-testid="recon-claim-select-CLM-SEL"]',
|
||||||
|
) as HTMLButtonElement | null;
|
||||||
|
expect(selectBtn).not.toBeNull();
|
||||||
|
expect(selectBtn!.textContent).toContain("Select for match");
|
||||||
|
await act(async () => {
|
||||||
|
selectBtn!.click();
|
||||||
|
await Promise.resolve();
|
||||||
|
});
|
||||||
|
|
||||||
|
// URL stays on /reconciliation — selection didn't drill.
|
||||||
|
expect(tracker?.pathname).toBe("/reconciliation");
|
||||||
|
// The toggle button now reads "Unselect" (clicked once).
|
||||||
|
const updatedBtn = document.body.querySelector(
|
||||||
|
'[data-testid="recon-claim-select-CLM-SEL"]',
|
||||||
|
) as HTMLButtonElement | null;
|
||||||
|
expect(updatedBtn?.textContent).toContain("Unselect");
|
||||||
|
unmount();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("SP21 Task 5.5: clicking the remit card body opens the RemitDrawer", async () => {
|
||||||
|
// Task 5.5 also restructures the remits column — the body is no
|
||||||
|
// longer role="button" with a select-onClick handler. The body
|
||||||
|
// drills into the RemitDrawer via open(r.id); selection moves to
|
||||||
|
// a dedicated "Select for match" toggle.
|
||||||
|
(
|
||||||
|
api.listUnmatched as unknown as ReturnType<typeof vi.fn>
|
||||||
|
).mockResolvedValue({
|
||||||
|
claims: [],
|
||||||
|
remittances: [
|
||||||
|
{
|
||||||
|
id: "REM-DRILL",
|
||||||
|
payerClaimControlNumber: "PCN-DRILL",
|
||||||
|
status: "received",
|
||||||
|
paidAmount: 100,
|
||||||
|
adjustmentAmount: 0,
|
||||||
|
receivedDate: "2026-06-19",
|
||||||
|
isReversal: false,
|
||||||
|
totalCharge: 100,
|
||||||
|
serviceDate: "2026-06-19",
|
||||||
|
batchId: "b1",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
const { unmount } = renderIntoContainer(
|
||||||
|
React.createElement(ReconciliationPage),
|
||||||
|
{ withRouter: true },
|
||||||
|
);
|
||||||
|
await waitForText("PCN-DRILL");
|
||||||
|
|
||||||
|
// Click the remit card body (inner <button> with aria-label).
|
||||||
|
const bodyBtn = document.body.querySelector(
|
||||||
|
'button[aria-label="View remittance PCN-DRILL in detail"]',
|
||||||
|
) as HTMLButtonElement | null;
|
||||||
|
expect(bodyBtn).not.toBeNull();
|
||||||
|
await act(async () => {
|
||||||
|
bodyBtn!.click();
|
||||||
|
await Promise.resolve();
|
||||||
|
});
|
||||||
|
|
||||||
|
// RemitDrawer should be mounted in document.body.
|
||||||
|
expect(
|
||||||
|
document.body.querySelector('[data-testid="remit-drawer"]'),
|
||||||
|
).not.toBeNull();
|
||||||
|
unmount();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("SP21 Task 5.5: clicking the remit 'Select for match' button toggles selection without opening drawer", async () => {
|
||||||
|
// The remit select toggle has e.stopPropagation() — clicking it
|
||||||
|
// shouldn't open the RemitDrawer (which used to be a side effect
|
||||||
|
// when the outer card was role="button" with onClick=select).
|
||||||
|
(
|
||||||
|
api.listUnmatched as unknown as ReturnType<typeof vi.fn>
|
||||||
|
).mockResolvedValue({
|
||||||
|
claims: [],
|
||||||
|
remittances: [
|
||||||
|
{
|
||||||
|
id: "REM-SEL",
|
||||||
|
payerClaimControlNumber: "PCN-SEL2",
|
||||||
|
status: "received",
|
||||||
|
paidAmount: 100,
|
||||||
|
adjustmentAmount: 0,
|
||||||
|
receivedDate: "2026-06-19",
|
||||||
|
isReversal: false,
|
||||||
|
totalCharge: 100,
|
||||||
|
serviceDate: "2026-06-19",
|
||||||
|
batchId: "b1",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
const { unmount } = renderIntoContainer(
|
||||||
|
React.createElement(ReconciliationPage),
|
||||||
|
{ withRouter: true },
|
||||||
|
);
|
||||||
|
await waitForText("PCN-SEL2");
|
||||||
|
|
||||||
|
const selectBtn = document.body.querySelector(
|
||||||
|
'[data-testid="recon-remit-select-REM-SEL"]',
|
||||||
|
) as HTMLButtonElement | null;
|
||||||
|
expect(selectBtn).not.toBeNull();
|
||||||
|
expect(selectBtn!.textContent).toContain("Select for match");
|
||||||
|
await act(async () => {
|
||||||
|
selectBtn!.click();
|
||||||
|
await Promise.resolve();
|
||||||
|
});
|
||||||
|
|
||||||
|
// No drawer.
|
||||||
|
expect(
|
||||||
|
document.body.querySelector('[data-testid="remit-drawer"]'),
|
||||||
|
).toBeNull();
|
||||||
|
// Toggle flipped.
|
||||||
|
const updated = document.body.querySelector(
|
||||||
|
'[data-testid="recon-remit-select-REM-SEL"]',
|
||||||
|
) as HTMLButtonElement | null;
|
||||||
|
expect(updated?.textContent).toContain("Unselect");
|
||||||
|
unmount();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
+107
-30
@@ -5,6 +5,7 @@ import {
|
|||||||
GitMerge,
|
GitMerge,
|
||||||
X,
|
X,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
|
import { useNavigate } from "react-router-dom";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { useReconciliation } from "@/hooks/useReconciliation";
|
import { useReconciliation } from "@/hooks/useReconciliation";
|
||||||
import { ApiError } from "@/lib/api";
|
import { ApiError } from "@/lib/api";
|
||||||
@@ -12,7 +13,6 @@ import { Skeleton } from "@/components/ui/skeleton";
|
|||||||
import { ErrorState } from "@/components/ui/error-state";
|
import { ErrorState } from "@/components/ui/error-state";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { RemitDrawer } from "@/components/RemitDrawer";
|
import { RemitDrawer } from "@/components/RemitDrawer";
|
||||||
import { DrillableCell } from "@/components/drill/DrillableCell";
|
|
||||||
import { useRemitDrawerUrlState } from "@/hooks/useRemitDrawerUrlState";
|
import { useRemitDrawerUrlState } from "@/hooks/useRemitDrawerUrlState";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
@@ -34,6 +34,11 @@ export function ReconciliationPage() {
|
|||||||
// links land with the drawer open. Selection state stays local
|
// links land with the drawer open. Selection state stays local
|
||||||
// — the drill is a separate gesture from the match selection.
|
// — the drill is a separate gesture from the match selection.
|
||||||
const { remitId, open, close } = useRemitDrawerUrlState();
|
const { remitId, open, close } = useRemitDrawerUrlState();
|
||||||
|
// SP21 Phase 5 Task 5.5: claim drill from the card body navigates
|
||||||
|
// to /claims?claim=ID so the ClaimDrawer mounts in-place on the
|
||||||
|
// /claims route. Selection for matching stays local — the drill
|
||||||
|
// gesture is separate from the match selection gesture.
|
||||||
|
const navigate = useNavigate();
|
||||||
const [selectedClaim, setSelectedClaim] = useState<string | null>(null);
|
const [selectedClaim, setSelectedClaim] = useState<string | null>(null);
|
||||||
const [selectedRemit, setSelectedRemit] = useState<string | null>(null);
|
const [selectedRemit, setSelectedRemit] = useState<string | null>(null);
|
||||||
|
|
||||||
@@ -658,17 +663,40 @@ export function ReconciliationPage() {
|
|||||||
{claims.map((c) => {
|
{claims.map((c) => {
|
||||||
const active = selectedClaim === c.id;
|
const active = selectedClaim === c.id;
|
||||||
return (
|
return (
|
||||||
<button
|
// SP21 Phase 5 Task 5.5: card body drills into the
|
||||||
|
// ClaimDrawer; selection for matching lives on a
|
||||||
|
// dedicated button inside the card. The card is no
|
||||||
|
// longer a single <button> — it's a <div> with a
|
||||||
|
// clickable body region (cursor-pointer) and a
|
||||||
|
// separate "Select for match" toggle. We split the
|
||||||
|
// gestures so the operator can review the claim
|
||||||
|
// without accidentally queuing it for pairing.
|
||||||
|
<div
|
||||||
key={c.id}
|
key={c.id}
|
||||||
type="button"
|
data-testid="recon-claim-card"
|
||||||
onClick={() => setSelectedClaim(c.id)}
|
|
||||||
aria-pressed={active}
|
|
||||||
className={cn(
|
className={cn(
|
||||||
"w-full text-left p-3.5 min-h-[44px] rounded-md border transition-colors",
|
"w-full text-left p-3.5 min-h-[44px] rounded-md border transition-colors",
|
||||||
active
|
active
|
||||||
? "border-[hsl(212_100%_45%)] bg-[hsl(212_85%_95%)] ring-1 ring-inset ring-[hsl(212_100%_45%_/_0.30)]"
|
? "border-[hsl(212_100%_45%)] bg-[hsl(212_85%_95%)] ring-1 ring-inset ring-[hsl(212_100%_45%_/_0.30)]"
|
||||||
: "border-[hsl(30_14%_14%/_0.14)] bg-[hsl(36_22%_98%)] hover:bg-[hsl(36_22%_94%)] hover:border-[hsl(30_14%_14%/_0.25)]"
|
: "border-[hsl(30_14%_14%/_0.14)] bg-[hsl(36_22%_98%)] hover:bg-[hsl(36_22%_94%)] hover:border-[hsl(30_14%_14%/_0.25)]"
|
||||||
)}
|
)}
|
||||||
|
>
|
||||||
|
{/* Body — drillable into /claims?claim=ID. We
|
||||||
|
use a button (not an <a>) because we're
|
||||||
|
staying on the SPA; the ClaimDrawer mounts
|
||||||
|
when Claims.tsx reads ?claim= off the URL.
|
||||||
|
e.stopPropagation in DrillableCell prevents
|
||||||
|
the click from reaching the select button
|
||||||
|
below. */}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() =>
|
||||||
|
navigate(
|
||||||
|
`/claims?claim=${encodeURIComponent(c.id)}`,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
aria-label={`View claim ${c.id} in detail`}
|
||||||
|
className="w-full text-left cursor-pointer rounded-sm p-0 m-0 border-0 bg-transparent"
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
className="display mono text-[13.5px]"
|
className="display mono text-[13.5px]"
|
||||||
@@ -691,6 +719,33 @@ export function ReconciliationPage() {
|
|||||||
{c.providerNpi ?? "—"}
|
{c.providerNpi ?? "—"}
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
|
{/* Selection toggle — separate gesture so a drill
|
||||||
|
doesn't auto-select the row for matching. */}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
setSelectedClaim(active ? null : c.id);
|
||||||
|
}}
|
||||||
|
aria-pressed={active}
|
||||||
|
data-testid={`recon-claim-select-${c.id}`}
|
||||||
|
className={cn(
|
||||||
|
"mt-2 inline-flex items-center gap-1.5 px-2.5 py-1 rounded-sm text-[10.5px] mono uppercase tracking-[0.16em] font-semibold transition-colors",
|
||||||
|
active
|
||||||
|
? "bg-[hsl(212_100%_45%)] text-white"
|
||||||
|
: "bg-[hsl(36_22%_92%)] text-[hsl(var(--surface-ink-2))] hover:bg-[hsl(212_85%_92%)]",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{active ? (
|
||||||
|
<>
|
||||||
|
<X className="h-3 w-3" strokeWidth={2.5} />
|
||||||
|
Unselect
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>Select for match</>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</PairColumn>
|
</PairColumn>
|
||||||
@@ -743,44 +798,39 @@ export function ReconciliationPage() {
|
|||||||
{remittances.map((r) => {
|
{remittances.map((r) => {
|
||||||
const active = selectedRemit === r.id;
|
const active = selectedRemit === r.id;
|
||||||
return (
|
return (
|
||||||
|
// SP21 Phase 5 Task 5.5: card body drills into the
|
||||||
|
// RemitDrawer; selection for matching lives on a
|
||||||
|
// dedicated button. Same gesture-split as the
|
||||||
|
// claims column — body = drill, button = select.
|
||||||
<div
|
<div
|
||||||
key={r.id}
|
key={r.id}
|
||||||
role="button"
|
data-testid="recon-remit-card"
|
||||||
tabIndex={0}
|
|
||||||
aria-pressed={active}
|
|
||||||
onClick={() => setSelectedRemit(r.id)}
|
|
||||||
onKeyDown={(e) => {
|
|
||||||
if (e.key === "Enter" || e.key === " ") {
|
|
||||||
e.preventDefault();
|
|
||||||
setSelectedRemit(r.id);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
className={cn(
|
className={cn(
|
||||||
"w-full text-left p-3.5 min-h-[44px] rounded-md border transition-colors",
|
"w-full text-left p-3.5 min-h-[44px] rounded-md border transition-colors",
|
||||||
active
|
active
|
||||||
? "border-[hsl(36_92%_50%)] bg-[hsl(36_82%_92%)] ring-1 ring-inset ring-[hsl(36_92%_50%_/_0.30)]"
|
? "border-[hsl(36_92%_50%)] bg-[hsl(36_82%_92%)] ring-1 ring-inset ring-[hsl(36_92%_50%_/_0.30)]"
|
||||||
: "border-[hsl(30_14%_14%/_0.14)] bg-[hsl(36_22%_98%)] hover:bg-[hsl(36_22%_94%)] hover:border-[hsl(30_14%_14%/_0.25)]"
|
: "border-[hsl(30_14%_14%/_0.14)] bg-[hsl(36_22%_98%)] hover:bg-[hsl(36_22%_94%)] hover:border-[hsl(30_14%_14%/_0.25)]"
|
||||||
)}
|
)}
|
||||||
|
>
|
||||||
|
{/* Body — drillable. The whole body is one
|
||||||
|
<button>; no nested DrillableCell (a <button>
|
||||||
|
inside a <button> is invalid HTML and warns
|
||||||
|
in the console). The PCN still gets the
|
||||||
|
chevron affordance via the `drillable` class
|
||||||
|
added directly to the inner span. */}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => open(r.id)}
|
||||||
|
aria-label={`View remittance ${r.payerClaimControlNumber} in detail`}
|
||||||
|
className="w-full text-left cursor-pointer rounded-sm p-0 m-0 border-0 bg-transparent"
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
className="mono text-[13.5px] flex items-center gap-2"
|
className="mono text-[13.5px] flex items-center gap-2"
|
||||||
style={{ color: "hsl(var(--surface-ink))" }}
|
style={{ color: "hsl(var(--surface-ink))" }}
|
||||||
>
|
>
|
||||||
{/* DrillableCell wraps the PCN text — clicking
|
<span className="display drillable inline-flex items-center gap-0 rounded-sm">
|
||||||
the text drills into the RemitDrawer; the
|
{r.payerClaimControlNumber}
|
||||||
surrounding div onClick (which selects the
|
</span>
|
||||||
row for the match action) is suppressed
|
|
||||||
because DrillableCell calls
|
|
||||||
e.stopPropagation() in its onClick. The
|
|
||||||
DrillableCell is its own <button>, so the
|
|
||||||
outer row had to move off <button> to
|
|
||||||
avoid invalid nested-button HTML. */}
|
|
||||||
<DrillableCell
|
|
||||||
onClick={() => open(r.id)}
|
|
||||||
ariaLabel={`View remittance ${r.payerClaimControlNumber}`}
|
|
||||||
>
|
|
||||||
<span className="display">{r.payerClaimControlNumber}</span>
|
|
||||||
</DrillableCell>
|
|
||||||
{r.isReversal ? (
|
{r.isReversal ? (
|
||||||
<span
|
<span
|
||||||
className="text-[10px] uppercase tracking-[0.18em] mono font-semibold"
|
className="text-[10px] uppercase tracking-[0.18em] mono font-semibold"
|
||||||
@@ -797,6 +847,33 @@ export function ReconciliationPage() {
|
|||||||
Status {r.status} · ${r.paidAmount.toFixed(2)} paid · $
|
Status {r.status} · ${r.paidAmount.toFixed(2)} paid · $
|
||||||
{r.adjustmentAmount.toFixed(2)} adj
|
{r.adjustmentAmount.toFixed(2)} adj
|
||||||
</div>
|
</div>
|
||||||
|
</button>
|
||||||
|
{/* Selection toggle — separate gesture from
|
||||||
|
drill. Same pattern as the claims column. */}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
setSelectedRemit(active ? null : r.id);
|
||||||
|
}}
|
||||||
|
aria-pressed={active}
|
||||||
|
data-testid={`recon-remit-select-${r.id}`}
|
||||||
|
className={cn(
|
||||||
|
"mt-2 inline-flex items-center gap-1.5 px-2.5 py-1 rounded-sm text-[10.5px] mono uppercase tracking-[0.16em] font-semibold transition-colors",
|
||||||
|
active
|
||||||
|
? "bg-[hsl(36_92%_50%)] text-[hsl(30_14%_14%)]"
|
||||||
|
: "bg-[hsl(36_22%_92%)] text-[hsl(var(--surface-ink-2))] hover:bg-[hsl(36_82%_88%)]",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{active ? (
|
||||||
|
<>
|
||||||
|
<X className="h-3 w-3" strokeWidth={2.5} />
|
||||||
|
Unselect
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>Select for match</>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|||||||
@@ -0,0 +1,270 @@
|
|||||||
|
// @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.
|
||||||
|
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT =
|
||||||
|
true;
|
||||||
|
|
||||||
|
import React, { act, useEffect } from "react";
|
||||||
|
import { describe, expect, it, vi, beforeEach, afterEach } from "vitest";
|
||||||
|
import { MemoryRouter, useLocation } from "react-router-dom";
|
||||||
|
import { createRoot, type Root } from "react-dom/client";
|
||||||
|
import { ClaimCard837, ClaimCard835 } from "./Upload";
|
||||||
|
import { useAppStore } from "@/store";
|
||||||
|
import type { ClaimOutput, ClaimPayment, ParsedBatch } from "@/types";
|
||||||
|
|
||||||
|
// Fixtures — kept tiny, just enough to exercise the drill logic.
|
||||||
|
const CLAIM_837: ClaimOutput = {
|
||||||
|
claim_id: "CLM-PERSISTED",
|
||||||
|
subscriber: {
|
||||||
|
first_name: "Jane",
|
||||||
|
last_name: "Doe",
|
||||||
|
member_id: "MEM-1",
|
||||||
|
},
|
||||||
|
payer: { name: "Test Payer", id: "P1" },
|
||||||
|
billing_provider: { npi: "1234567890" },
|
||||||
|
claim: {
|
||||||
|
total_charge: 100,
|
||||||
|
place_of_service: "11",
|
||||||
|
frequency_code: "1",
|
||||||
|
prior_auth: null,
|
||||||
|
},
|
||||||
|
service_lines: [
|
||||||
|
{
|
||||||
|
line_number: 1,
|
||||||
|
procedure: { qualifier: "HC", code: "99213", modifiers: [] },
|
||||||
|
charge: "100",
|
||||||
|
units: "1",
|
||||||
|
unit_type: "UN",
|
||||||
|
service_date: "2026-06-01",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
diagnoses: [{ qualifier: "ABK", code: "J20.9" }],
|
||||||
|
validation: { passed: true, errors: [], warnings: [] },
|
||||||
|
};
|
||||||
|
|
||||||
|
const CLAIM_835: ClaimPayment = {
|
||||||
|
payer_claim_control_number: "PCN-PERSISTED",
|
||||||
|
status_code: "1",
|
||||||
|
status_label: "Processed as Primary",
|
||||||
|
claim_filing_indicator: "CI",
|
||||||
|
facility_type: "11",
|
||||||
|
frequency_code: "1",
|
||||||
|
total_charge: "100",
|
||||||
|
total_paid: "80",
|
||||||
|
patient_responsibility: "20",
|
||||||
|
service_payments: [
|
||||||
|
{
|
||||||
|
line_number: 1,
|
||||||
|
procedure_qualifier: "HC",
|
||||||
|
procedure_code: "99213",
|
||||||
|
modifiers: [],
|
||||||
|
service_date: "2026-06-01",
|
||||||
|
units: "1",
|
||||||
|
unit_type: "UN",
|
||||||
|
charge: "100",
|
||||||
|
payment: "80",
|
||||||
|
adjustments: [],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
original_claim_id: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Mount the card inside a MemoryRouter so the useNavigate call has
|
||||||
|
// a router context (without this, clicking the drill link would
|
||||||
|
// throw "useNavigate may be used only in a Router").
|
||||||
|
function renderCard(
|
||||||
|
element: React.ReactElement,
|
||||||
|
initialEntries: string[] = ["/upload"],
|
||||||
|
): {
|
||||||
|
container: HTMLDivElement;
|
||||||
|
unmount: () => void;
|
||||||
|
tracker: { pathname: string; search: string };
|
||||||
|
} {
|
||||||
|
const container = document.createElement("div");
|
||||||
|
document.body.appendChild(container);
|
||||||
|
const tracker = { pathname: "/upload", search: "" };
|
||||||
|
const Tracker = () => {
|
||||||
|
const loc = useLocation();
|
||||||
|
useEffect(() => {
|
||||||
|
tracker.pathname = loc.pathname;
|
||||||
|
tracker.search = loc.search;
|
||||||
|
}, [loc.pathname, loc.search]);
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
const root: Root = createRoot(container);
|
||||||
|
act(() => {
|
||||||
|
root.render(
|
||||||
|
React.createElement(
|
||||||
|
MemoryRouter,
|
||||||
|
{ initialEntries },
|
||||||
|
React.createElement(Tracker, null),
|
||||||
|
element,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
container,
|
||||||
|
unmount: () => {
|
||||||
|
act(() => root.unmount());
|
||||||
|
container.remove();
|
||||||
|
},
|
||||||
|
tracker,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("ClaimCard837 / ClaimCard835 drill link (SP21 Phase 5 Task 5.7)", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
// Reset parsedBatches to empty by default — individual tests
|
||||||
|
// populate it as needed.
|
||||||
|
useAppStore.setState({ parsedBatches: [] });
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
useAppStore.setState({ parsedBatches: [] });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ClaimCard837: renders the drill link when the claim_id is in a persisted batch", async () => {
|
||||||
|
// Pre-populate the store with a parsed batch that contains this
|
||||||
|
// claim id, then expand the card and verify the link is present.
|
||||||
|
const persisted: ParsedBatch = {
|
||||||
|
id: "batch-1",
|
||||||
|
kind: "837p",
|
||||||
|
inputFilename: "test.837",
|
||||||
|
parsedAt: "2026-06-21T12:00:00Z",
|
||||||
|
claimCount: 1,
|
||||||
|
passed: 1,
|
||||||
|
failed: 0,
|
||||||
|
claimIds: ["CLM-PERSISTED"],
|
||||||
|
summary: { total_claims: 1, passed: 1, failed: 0 },
|
||||||
|
};
|
||||||
|
useAppStore.setState({ parsedBatches: [persisted] });
|
||||||
|
|
||||||
|
const { container, unmount } = renderCard(
|
||||||
|
React.createElement(ClaimCard837, { claim: CLAIM_837 }),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Expand the card.
|
||||||
|
const header = container.querySelector("button[aria-expanded]");
|
||||||
|
await act(async () => {
|
||||||
|
(header as HTMLButtonElement).click();
|
||||||
|
await Promise.resolve();
|
||||||
|
});
|
||||||
|
|
||||||
|
// The drill link should now be visible.
|
||||||
|
const link = container.querySelector('[data-testid="upload-claim-drill"]');
|
||||||
|
expect(link).not.toBeNull();
|
||||||
|
expect(link?.textContent).toContain("See claim in detail");
|
||||||
|
unmount();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ClaimCard837: does NOT render the drill link when the claim_id is not persisted", async () => {
|
||||||
|
// Streaming-only claim — the parsedBatches slice is empty, so
|
||||||
|
// the link must be absent (clicking would 404 ClaimDrawer).
|
||||||
|
const { container, unmount } = renderCard(
|
||||||
|
React.createElement(ClaimCard837, { claim: CLAIM_837 }),
|
||||||
|
);
|
||||||
|
|
||||||
|
const header = container.querySelector("button[aria-expanded]");
|
||||||
|
await act(async () => {
|
||||||
|
(header as HTMLButtonElement).click();
|
||||||
|
await Promise.resolve();
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(
|
||||||
|
container.querySelector('[data-testid="upload-claim-drill"]'),
|
||||||
|
).toBeNull();
|
||||||
|
unmount();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ClaimCard837: clicking the drill link navigates to /claims?claim=ID", async () => {
|
||||||
|
const persisted: ParsedBatch = {
|
||||||
|
id: "batch-1",
|
||||||
|
kind: "837p",
|
||||||
|
inputFilename: "test.837",
|
||||||
|
parsedAt: "2026-06-21T12:00:00Z",
|
||||||
|
claimCount: 1,
|
||||||
|
passed: 1,
|
||||||
|
failed: 0,
|
||||||
|
claimIds: ["CLM-PERSISTED"],
|
||||||
|
summary: { total_claims: 1, passed: 1, failed: 0 },
|
||||||
|
};
|
||||||
|
useAppStore.setState({ parsedBatches: [persisted] });
|
||||||
|
|
||||||
|
const { container, unmount, tracker } = renderCard(
|
||||||
|
React.createElement(ClaimCard837, { claim: CLAIM_837 }),
|
||||||
|
);
|
||||||
|
|
||||||
|
const header = container.querySelector("button[aria-expanded]");
|
||||||
|
await act(async () => {
|
||||||
|
(header as HTMLButtonElement).click();
|
||||||
|
await Promise.resolve();
|
||||||
|
});
|
||||||
|
|
||||||
|
const link = container.querySelector(
|
||||||
|
'[data-testid="upload-claim-drill"]',
|
||||||
|
) as HTMLButtonElement;
|
||||||
|
await act(async () => {
|
||||||
|
link.click();
|
||||||
|
await Promise.resolve();
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(tracker.pathname).toBe("/claims");
|
||||||
|
expect(tracker.search).toBe("?claim=CLM-PERSISTED");
|
||||||
|
unmount();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ClaimCard835: renders the drill link when the PCN is persisted", async () => {
|
||||||
|
const persisted: ParsedBatch = {
|
||||||
|
id: "batch-1",
|
||||||
|
kind: "835",
|
||||||
|
inputFilename: "test.835",
|
||||||
|
parsedAt: "2026-06-21T12:00:00Z",
|
||||||
|
claimCount: 1,
|
||||||
|
passed: 1,
|
||||||
|
failed: 0,
|
||||||
|
claimIds: ["PCN-PERSISTED"],
|
||||||
|
summary: { total_claims: 1, passed: 1, failed: 0 },
|
||||||
|
};
|
||||||
|
useAppStore.setState({ parsedBatches: [persisted] });
|
||||||
|
|
||||||
|
const { container, unmount, tracker } = renderCard(
|
||||||
|
React.createElement(ClaimCard835, { claim: CLAIM_835 }),
|
||||||
|
);
|
||||||
|
|
||||||
|
const header = container.querySelector("button[aria-expanded]");
|
||||||
|
await act(async () => {
|
||||||
|
(header as HTMLButtonElement).click();
|
||||||
|
await Promise.resolve();
|
||||||
|
});
|
||||||
|
|
||||||
|
const link = container.querySelector('[data-testid="upload-remit-drill"]');
|
||||||
|
expect(link).not.toBeNull();
|
||||||
|
await act(async () => {
|
||||||
|
(link as HTMLButtonElement).click();
|
||||||
|
await Promise.resolve();
|
||||||
|
});
|
||||||
|
|
||||||
|
// 835 cards drill to /remittances?remit=PCN (not /claims), since
|
||||||
|
// the RemitDrawer is the right surface for the payment side.
|
||||||
|
expect(tracker.pathname).toBe("/remittances");
|
||||||
|
expect(tracker.search).toBe("?remit=PCN-PERSISTED");
|
||||||
|
unmount();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ClaimCard835: does NOT render the drill link when the PCN is not persisted", async () => {
|
||||||
|
const { container, unmount } = renderCard(
|
||||||
|
React.createElement(ClaimCard835, { claim: CLAIM_835 }),
|
||||||
|
);
|
||||||
|
|
||||||
|
const header = container.querySelector("button[aria-expanded]");
|
||||||
|
await act(async () => {
|
||||||
|
(header as HTMLButtonElement).click();
|
||||||
|
await Promise.resolve();
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(
|
||||||
|
container.querySelector('[data-testid="upload-remit-drill"]'),
|
||||||
|
).toBeNull();
|
||||||
|
unmount();
|
||||||
|
});
|
||||||
|
});
|
||||||
+75
-2
@@ -1,4 +1,5 @@
|
|||||||
import { useMemo, useRef, useState } from "react";
|
import { useMemo, useRef, useState } from "react";
|
||||||
|
import { useNavigate } from "react-router-dom";
|
||||||
import {
|
import {
|
||||||
AlertTriangle,
|
AlertTriangle,
|
||||||
ArrowRight,
|
ArrowRight,
|
||||||
@@ -138,10 +139,22 @@ function StatPill({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function ClaimCard837({ claim }: { claim: ClaimOutput }) {
|
export function ClaimCard837({ claim }: { claim: ClaimOutput }) {
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
const passed = claim.validation.passed;
|
const passed = claim.validation.passed;
|
||||||
const hasWarnings = claim.validation.warnings.length > 0;
|
const hasWarnings = claim.validation.warnings.length > 0;
|
||||||
|
// SP21 Phase 5 Task 5.7: a "See claim in detail →" link drills to
|
||||||
|
// /claims?claim=ID — but ONLY when this streamed claim_id has
|
||||||
|
// actually been persisted to a parsed batch. The Upload page
|
||||||
|
// streams claims as the parser emits them; until the user clicks
|
||||||
|
// "Save batch" the claim isn't visible to ClaimDrawer.
|
||||||
|
const parsedBatches = useAppStore((s) => s.parsedBatches);
|
||||||
|
const persistedClaimIds = useMemo(
|
||||||
|
() => new Set(parsedBatches.flatMap((b) => b.claimIds)),
|
||||||
|
[parsedBatches],
|
||||||
|
);
|
||||||
|
const canDrill = persistedClaimIds.has(claim.claim_id);
|
||||||
|
const navigate = useNavigate();
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className="rounded-lg overflow-hidden border"
|
className="rounded-lg overflow-hidden border"
|
||||||
@@ -331,6 +344,31 @@ function ClaimCard837({ claim }: { claim: ClaimOutput }) {
|
|||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
|
{/* SP21 Phase 5 Task 5.7: drill to the persisted claim. Only
|
||||||
|
renders when this streamed claim_id has been saved into
|
||||||
|
a parsed batch (so ClaimDrawer can find it). Before
|
||||||
|
"Save batch" the claim is streaming-only and the link
|
||||||
|
would 404. */}
|
||||||
|
{canDrill ? (
|
||||||
|
<div className="pt-1">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() =>
|
||||||
|
navigate(
|
||||||
|
`/claims?claim=${encodeURIComponent(claim.claim_id)}`,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
data-testid="upload-claim-drill"
|
||||||
|
aria-label={`See claim ${claim.claim_id} in detail`}
|
||||||
|
className="inline-flex items-center gap-1.5 text-[12px] mono font-semibold cursor-pointer rounded-sm px-1 -mx-1 hover:underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1"
|
||||||
|
style={{ color: "hsl(var(--surface-ink))" }}
|
||||||
|
>
|
||||||
|
See claim in detail
|
||||||
|
<ArrowRight className="h-3 w-3" strokeWidth={2} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
@@ -385,9 +423,19 @@ function ServiceLine837Row({ line }: { line: ServiceLine }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function ClaimCard835({ claim }: { claim: ClaimPayment }) {
|
export function ClaimCard835({ claim }: { claim: ClaimPayment }) {
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
const passed = claim.service_payments.length > 0;
|
const passed = claim.service_payments.length > 0;
|
||||||
|
// SP21 Phase 5 Task 5.7: same drill gate as ClaimCard837. Only show
|
||||||
|
// "See claim in detail" once the claim_id is in the persisted
|
||||||
|
// batches (otherwise ClaimDrawer would 404).
|
||||||
|
const parsedBatches = useAppStore((s) => s.parsedBatches);
|
||||||
|
const persistedClaimIds = useMemo(
|
||||||
|
() => new Set(parsedBatches.flatMap((b) => b.claimIds)),
|
||||||
|
[parsedBatches],
|
||||||
|
);
|
||||||
|
const canDrill = persistedClaimIds.has(claim.payer_claim_control_number);
|
||||||
|
const navigate = useNavigate();
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className="rounded-lg overflow-hidden border"
|
className="rounded-lg overflow-hidden border"
|
||||||
@@ -518,6 +566,31 @@ function ClaimCard835({ claim }: { claim: ClaimPayment }) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
|
{/* SP21 Phase 5 Task 5.7: drill to the persisted remit. The
|
||||||
|
835 cards have no separate "claim" (they're the payment
|
||||||
|
side), so the link goes to /remittances?remit=PCN which
|
||||||
|
opens the RemitDrawer for this payment. Same persisted-
|
||||||
|
gate: only show when the PCN is in a saved batch. */}
|
||||||
|
{canDrill ? (
|
||||||
|
<div className="pt-1">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() =>
|
||||||
|
navigate(
|
||||||
|
`/remittances?remit=${encodeURIComponent(claim.payer_claim_control_number)}`,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
data-testid="upload-remit-drill"
|
||||||
|
aria-label={`See remittance ${claim.payer_claim_control_number} in detail`}
|
||||||
|
className="inline-flex items-center gap-1.5 text-[12px] mono font-semibold cursor-pointer rounded-sm px-1 -mx-1 hover:underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1"
|
||||||
|
style={{ color: "hsl(var(--surface-ink))" }}
|
||||||
|
>
|
||||||
|
See claim in detail
|
||||||
|
<ArrowRight className="h-3 w-3" strokeWidth={2} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user