The implementer caught that claims.id is a single-column PK, which prevents the same CLM01 from existing in multiple batches. That makes the spec's pre-flight 409 workflow unreachable and resubmits impossible. Migration 0014 relaxes the PKs to composite (batch_id, id) on both claims and remittances, and updates all FKs that referenced the old single-column PK. Task 1.3 in the plan is now the migration; the previous Task 1.3 (helper tightening) is renumbered to Task 1.4 and will run after 0014 lands.
32 KiB
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:
- Multi-claim files with shared
member_id(no longer a 409 after 0013). - 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
{
"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: alwaysnullon 409 (the insert rolled back).existing_batch_id: the most-recent prior batch that contains a colliding CLM01, ornullif (a) the collision is within-file, or (b) the colliding claim has since been deleted (race).collisions.colliding_claim_ids: subset ofparse_result.claims[].claim_idthat collides.collisions.total_claims: count fromparse_result.summary.total_claims.collisions.new_claims_after_skip:total_claims - total_collisions.parse_result: the fullParseResult(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 Contenton success.404 Not Foundif the batch doesn't exist.409 Conflictif the batch has any claims in a non-submittedstate (e.g.,paid,reversed,denied). Forces the user to first unreconcile — same as the existing 409 pattern formanual_match/manual_unmatch(seestore.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):
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):
"""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
@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:
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}
@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:
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:
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:
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):
{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_iddedup that was over-constraining before). - Multi-claim 837P files with shared
member_idingest 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 withdetailmentioning 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 pastsubmittedstate. force=truesilent 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_idmay be stale: the helper returns the most-recent batch bystate_changed_at(orreceived_atfor 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
- Schema: migration 0013 applies on next
cyclonestartup. Idempotent and reversible only by rebuilding theclaimstable (acceptable; production data preserved by the INSERT...SELECT). - Backend API: new
forceparam + new 409 body shape + new DELETE endpoint. Existing clients that don't passforcesee the same behavior as before for collision-free files. Collision cases now get a richer 409 body that includesparse_result; clients that ignore the new fields keep working. - Frontend:
Upload.tsxpanel replaces the toast on 409. Users who don't read the panel still see the toast and the 409 message in the streaming view. - No data migration: nothing to migrate. 0013 is structural only.