Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 3f672d5db3 |
+343
@@ -0,0 +1,343 @@
|
|||||||
|
# Drop `UNIQUE(batch_id, patient_control_number)` on Claims + Robust 409 UX
|
||||||
|
|
||||||
|
**Date:** 2026-06-21
|
||||||
|
**Branch:** `main`
|
||||||
|
**Status:** Draft (brainstorming approved, awaiting writing-plans)
|
||||||
|
**Scope:** Backend schema + minimal frontend error handling for collision 409.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Why this exists
|
||||||
|
|
||||||
|
Today, every multi-claim 837P file in `docs/prodfiles/837p-from-axiscare/` and
|
||||||
|
`docs/prodfiles/FromHPE/` returns **HTTP 409** on upload. The cause:
|
||||||
|
|
||||||
|
* The `claims` table has `UNIQUE(batch_id, patient_control_number)` declared
|
||||||
|
inline in `CREATE TABLE` (auto-index `sqlite_autoindex_claims_2`).
|
||||||
|
* `store._claim_837_row` populates `patient_control_number` from
|
||||||
|
`claim.subscriber.member_id` (the subscriber's insurance member id),
|
||||||
|
not from the CLM01 segment value.
|
||||||
|
* A real 837P submission routinely contains many `CLM*` segments per
|
||||||
|
subscriber (a member seeing multiple providers on the same day). All
|
||||||
|
those rows share the same `member_id` → same `patient_control_number`.
|
||||||
|
Within one batch, the constraint fires on claim #2.
|
||||||
|
|
||||||
|
Migration `0003_drop_claims_remits_unique_constraints.sql` already exists
|
||||||
|
with the right intent but is **buggy**: it does
|
||||||
|
`DROP INDEX IF EXISTS uq_claims_batch_pcn`, but the constraint is inline,
|
||||||
|
not a named index — so the `DROP INDEX` is a no-op and the constraint
|
||||||
|
survives.
|
||||||
|
|
||||||
|
The 409 error message ("duplicate CLM01 control numbers") is also
|
||||||
|
misleading because the column stores `member_id`, not CLM01.
|
||||||
|
|
||||||
|
This SP fixes both: drops the constraint properly via SQLite table
|
||||||
|
recreation, and gives the upload page a structured error panel for the
|
||||||
|
collision case so the operator can find the existing batch in one click.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Operator surface
|
||||||
|
|
||||||
|
| Surface | Change |
|
||||||
|
|---|---|
|
||||||
|
| DB | New migration `0013_drop_claims_unique_constraint.sql`; idempotent. |
|
||||||
|
| Python | `store.find_existing_batch_for_claim(claim_id)` new helper. |
|
||||||
|
| API | `POST /api/parse-837` and `/api/parse-835` 409 responses gain `existing_batch_id`. |
|
||||||
|
| UI | `/upload` page renders an inline error panel for 409 with a link to the existing batch. |
|
||||||
|
| Tests | New migration test, store helper test, API test, component test. |
|
||||||
|
|
||||||
|
No CLI / settings changes.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Schema migration
|
||||||
|
|
||||||
|
`backend/src/cyclone/migrations/0013_drop_claims_unique_constraint.sql`:
|
||||||
|
|
||||||
|
```sql
|
||||||
|
-- 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.
|
||||||
|
|
||||||
|
PRAGMA foreign_keys=OFF;
|
||||||
|
BEGIN;
|
||||||
|
|
||||||
|
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;
|
||||||
|
|
||||||
|
COMMIT;
|
||||||
|
PRAGMA foreign_keys=ON;
|
||||||
|
```
|
||||||
|
|
||||||
|
**Note on the `db.py` ORM model:** `backend/src/cyclone/db.py` declares
|
||||||
|
the `Claim` ORM model with `UNIQUE(batch_id, patient_control_number)` in
|
||||||
|
`__table_args__`. After this migration the DB no longer enforces that
|
||||||
|
constraint, so the ORM declaration becomes aspirational. We leave it in
|
||||||
|
place as documentation (and as a guard if the DB ever gets rebuilt from
|
||||||
|
ORM on a fresh schema). No code reads through it; the dedup happens via
|
||||||
|
PK on `claims.id` in `store.add`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Store helper
|
||||||
|
|
||||||
|
New function in `backend/src/cyclone/store.py`:
|
||||||
|
|
||||||
|
```python
|
||||||
|
def find_existing_batch_for_claim(claim_id: str) -> str | None:
|
||||||
|
"""Return the batch_id of the first batch containing this CLM01, or None."""
|
||||||
|
from cyclone import db
|
||||||
|
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(payer_claim_control_number: str) -> str | None:
|
||||||
|
"""Return the batch_id of the first batch containing this CLP01, or None."""
|
||||||
|
from cyclone import db
|
||||||
|
from cyclone.db import Remittance
|
||||||
|
with db.SessionLocal()() as s:
|
||||||
|
row = s.execute(
|
||||||
|
select(Remittance.batch_id)
|
||||||
|
.where(Remittance.id == payer_claim_control_number)
|
||||||
|
.limit(1)
|
||||||
|
).first()
|
||||||
|
return row[0] if row else None
|
||||||
|
```
|
||||||
|
|
||||||
|
Both pure reads; no transaction management needed.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. API change
|
||||||
|
|
||||||
|
`backend/src/cyclone/api.py`:
|
||||||
|
|
||||||
|
### 837 path (line 394-415)
|
||||||
|
|
||||||
|
```python
|
||||||
|
except IntegrityError as exc:
|
||||||
|
existing_batch_id = store.find_existing_batch_for_claim(...)
|
||||||
|
body = {
|
||||||
|
"error": "Duplicate claim",
|
||||||
|
"detail": "...",
|
||||||
|
"batch_id": rec.id,
|
||||||
|
}
|
||||||
|
if existing_batch_id:
|
||||||
|
body["existing_batch_id"] = existing_batch_id
|
||||||
|
log.warning("Duplicate claim while persisting batch %s: %s", rec.id, exc)
|
||||||
|
return JSONResponse(status_code=409, content=body)
|
||||||
|
```
|
||||||
|
|
||||||
|
The `...` argument is the first `claim.claim_id` from `result.claims`
|
||||||
|
where the collision occurred. We pick the first one because the DB raises
|
||||||
|
on the second insert; iterating through the parser's claim list to find
|
||||||
|
which CLM01 already existed is cheap.
|
||||||
|
|
||||||
|
### 835 path (line 588-602)
|
||||||
|
|
||||||
|
Same pattern with `find_existing_batch_for_remit` and the existing remit's
|
||||||
|
`payer_claim_control_number`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Frontend change
|
||||||
|
|
||||||
|
### `src/lib/api.ts`
|
||||||
|
|
||||||
|
`ApiError` carries an optional `existingBatchId`:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
export class ApiError extends Error {
|
||||||
|
constructor(
|
||||||
|
public status: number,
|
||||||
|
message: string,
|
||||||
|
public existingBatchId: string | null = null,
|
||||||
|
) {
|
||||||
|
super(message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`readErrorBody()` parses JSON and, when status ≥ 400, attempts to extract
|
||||||
|
`existing_batch_id` and pass it through. `parse837`/`parse835` throw
|
||||||
|
`new ApiError(res.status, detail, existingBatchId)`.
|
||||||
|
|
||||||
|
### `src/pages/Upload.tsx`
|
||||||
|
|
||||||
|
New local state:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
type UploadError = {
|
||||||
|
kind: "duplicate";
|
||||||
|
existingBatchId: string | null;
|
||||||
|
filename: string;
|
||||||
|
};
|
||||||
|
const [uploadError, setUploadError] = useState<UploadError | null>(null);
|
||||||
|
```
|
||||||
|
|
||||||
|
On mutation error:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof ApiError && err.status === 409) {
|
||||||
|
setUploadError({
|
||||||
|
kind: "duplicate",
|
||||||
|
existingBatchId: err.existingBatchId,
|
||||||
|
filename: file.name,
|
||||||
|
});
|
||||||
|
toast.error("Duplicate claim — file not ingested");
|
||||||
|
} else {
|
||||||
|
toast.error(err instanceof Error ? err.message : "Failed to parse file");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Inline error panel JSX (renders above streaming results when
|
||||||
|
`uploadError` is set):
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
{uploadError ? (
|
||||||
|
<div className="error-panel">
|
||||||
|
<Badge variant="destructive">409</Badge>
|
||||||
|
<div className="error-title">Duplicate claim — file not ingested</div>
|
||||||
|
<div className="error-detail">
|
||||||
|
{filename} collides with an existing record.
|
||||||
|
{existingBatchId
|
||||||
|
? " Open the existing batch to compare, or pick a different file."
|
||||||
|
: " Pick a different file."}
|
||||||
|
</div>
|
||||||
|
<div className="error-actions">
|
||||||
|
{existingBatchId ? (
|
||||||
|
<Button onClick={() => navigate(`/batches/${existingBatchId}`)}>
|
||||||
|
Open existing batch →
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
<Button variant="ghost" onClick={() => { pickFile(null); }}>
|
||||||
|
Pick a different file
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
```
|
||||||
|
|
||||||
|
Styling matches the existing paper-toned surface (slate/parchment) per
|
||||||
|
the hybrid dark/paper treatment used elsewhere in the app.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Files changed
|
||||||
|
|
||||||
|
* `backend/src/cyclone/migrations/0013_drop_claims_unique_constraint.sql` — new
|
||||||
|
* `backend/src/cyclone/store.py` — `find_existing_batch_for_claim`,
|
||||||
|
`find_existing_batch_for_remit`
|
||||||
|
* `backend/src/cyclone/api.py` — both 409 handlers add
|
||||||
|
`existing_batch_id` lookup
|
||||||
|
* `src/lib/api.ts` — `ApiError.existingBatchId`
|
||||||
|
* `src/pages/Upload.tsx` — error state + inline panel
|
||||||
|
* `src/pages/Upload.test.tsx` — new test file
|
||||||
|
* `backend/tests/test_api_parse_persists.py` — new test cases
|
||||||
|
* `backend/tests/test_db_migrate.py` — new migration test
|
||||||
|
* `backend/tests/test_store.py` — new helper tests
|
||||||
|
|
||||||
|
No new dependencies. No config changes.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. Out of scope
|
||||||
|
|
||||||
|
* A "Replace existing batch" destructive action — requires
|
||||||
|
`DELETE /api/batches/{id}` and reconciliation cascade handling; deferred.
|
||||||
|
* General 4xx error UI for other statuses (empty file, parse error,
|
||||||
|
validation errors). Those already surface in toasts and the streaming
|
||||||
|
view; a future SP can generalize the inline panel pattern.
|
||||||
|
* Renaming `patient_control_number` to `subscriber_member_id` to match
|
||||||
|
what it actually stores. Out of scope for this fix; tracked separately
|
||||||
|
if it becomes a source of confusion.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. Risk
|
||||||
|
|
||||||
|
* **Migration irreversibility**: SQLite has no `DROP CONSTRAINT`; the
|
||||||
|
recreation is destructive to schema (but not to data — `INSERT INTO
|
||||||
|
claims_new SELECT * FROM claims` preserves every row). If the
|
||||||
|
migration fails mid-way, the transaction rolls back. The
|
||||||
|
`PRAGMA foreign_keys=OFF` block is required because SQLite otherwise
|
||||||
|
can't drop and rename a table that other tables reference.
|
||||||
|
* **Loss of uniqueness**: after the migration, two claims in one batch
|
||||||
|
*can* share a `patient_control_number`. This is the intended behavior.
|
||||||
|
Claim identity is still unique via `claims.id` (CLM01, PK) and the
|
||||||
|
existing dedup check in `store.add` (`s.get(Claim, claim.claim_id)`).
|
||||||
|
* **`existing_batch_id` race**: the helper runs after the failed
|
||||||
|
`store.add` transaction has rolled back. Between rollback and helper
|
||||||
|
call, another writer could delete the colliding claim. Result: 409
|
||||||
|
fires with no `existing_batch_id`; UI shows panel without link.
|
||||||
|
Acceptable — the panel still explains the collision and offers
|
||||||
|
"Pick a different file."
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. Test plan
|
||||||
|
|
||||||
|
| Test | Asserts |
|
||||||
|
|---|---|
|
||||||
|
| `test_db_migrate.py::test_drop_claims_unique_constraint` | After running migrations from v12, `user_version=13`, no `*claims*unique*` index exists, two rows with same `(batch_id, patient_control_number)` insert cleanly. |
|
||||||
|
| `test_db_migrate.py::test_migration_idempotent` | Running migrations on a v13 DB is a no-op. |
|
||||||
|
| `test_store.py::test_find_existing_batch_for_claim` | Helper returns None for unknown, returns batch_id for known, deterministic on duplicates. |
|
||||||
|
| `test_api_parse_persists.py::test_409_response_includes_existing_batch_id` | Upload duplicate; assert body has `existing_batch_id` pointing to the right batch. |
|
||||||
|
| `test_api_parse_persists.py::test_multi_claim_batch_with_duplicate_member_id_succeeds` | Upload a file with duplicate `member_id` claims; assert 200, all claims persisted. |
|
||||||
|
| `Upload.test.tsx::test_error_panel_renders_on_409_with_link` | Mock `useParse` to throw `ApiError(409, ..., existingBatchId)`; assert panel visible, link present. |
|
||||||
|
| `Upload.test.tsx::test_error_panel_renders_on_409_without_link` | Mock 409 with `existingBatchId: null`; assert panel visible, no link. |
|
||||||
|
| `Upload.test.tsx::test_no_panel_on_non_409` | Mock 400; assert panel absent, only toast. |
|
||||||
|
| `Upload.test.tsx::test_pick_different_clears_error` | Click button; assert `pickFile(null)` called and `errorState` cleared. |
|
||||||
Reference in New Issue
Block a user