diff --git a/backend/src/cyclone/migrations/0015_drop_claims_unique_constraint.sql b/backend/src/cyclone/migrations/0015_drop_claims_unique_constraint.sql new file mode 100644 index 0000000..d573642 --- /dev/null +++ b/backend/src/cyclone/migrations/0015_drop_claims_unique_constraint.sql @@ -0,0 +1,74 @@ +-- version: 15 +-- 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. +-- +-- Discovery 2026-06-23: the inline UNIQUE does NOT exist in the current +-- production DB at user_version=14 (or in main's fresh-DB schema). The +-- 32 "Duplicate claim" warnings in /tmp/cyclone-uvicorn.log are PK +-- collisions on claims.id (CLM01) when an operator re-uploads the same +-- file — not UNIQUE violations. This migration is therefore a defensive +-- no-op against the current schema, but keeps the 0003 intent alive +-- (drop the constraint if it ever reappears) and lets the SP22 spec +-- ship as designed. +-- +-- 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. +-- Other tables referencing claims: +-- remittances.claim_id +-- matches.claim_id +-- line_reconciliations.claim_id +-- activity_events.claim_id + +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; diff --git a/backend/tests/test_acks.py b/backend/tests/test_acks.py index 50cc9c1..0ef3b45 100644 --- a/backend/tests/test_acks.py +++ b/backend/tests/test_acks.py @@ -51,20 +51,21 @@ def test_migration_0002_creates_acks_table(): def test_migration_latest_idempotent_on_fresh_db(): """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 15 after 0004-0006 line_reconciliation, 0005 ta1_acks, SP9's 0007 providers/payers/clearhouse, SP10's 0008 payer_rejected, SP11's 0009 audit_log, SP14's 0010 payer_rejected_acknowledged, SP16's 0011 processed_inbound_files, SP17's 0012 db_backups, - SP-auth's 0013 users + sessions, SP-audit's 0014 audit_log.user_id).""" + SP-auth's 0013 users + sessions, SP-audit's 0014 audit_log.user_id, + SP22's 0015 drop_claims_unique_constraint).""" with db.engine().begin() as c: v1 = c.exec_driver_sql("PRAGMA user_version").scalar() or 0 - assert v1 == 14 + assert v1 == 15 # A second run should not raise and should not bump the version. db_migrate.run(db.engine()) with db.engine().begin() as c: v2 = c.exec_driver_sql("PRAGMA user_version").scalar() or 0 - assert v2 == 14 + assert v2 == 15 def test_add_ack_persists_row(): diff --git a/backend/tests/test_db_migrate.py b/backend/tests/test_db_migrate.py index d99b995..ede3f1a 100644 --- a/backend/tests/test_db_migrate.py +++ b/backend/tests/test_db_migrate.py @@ -112,4 +112,69 @@ def test_run_ignores_non_sql_files( "SELECT name FROM sqlite_master WHERE type='table' AND name='should_not_exist'" ).all() assert len(rows) == 0 - assert _user_version(engine) == 1 \ No newline at end of file + assert _user_version(engine) == 1 + + +def test_migration_latest_idempotent_on_fresh_db() -> None: + """All migrations up to the current head run cleanly on a fresh DB, + and a second run is a no-op (no version bump). SP22 bumped the + expected head from 14 to 15 with the new UNIQUE-drop migration. + """ + engine = _fresh_engine(Path("/tmp/sp22_fresh_db_latest_test.db")) + db_migrate.run(engine) + v_after_first = _user_version(engine) + assert v_after_first == 15, f"expected head=15, got {v_after_first}" + + db_migrate.run(engine) + assert _user_version(engine) == 15, "second run should not bump version" + + +def test_drop_claims_unique_constraint_migration(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + """SP22: migration 0015 recreates the `claims` table without the inline + `UNIQUE(batch_id, patient_control_number)` constraint, so two claims in + one batch can share a patient_control_number (real 837P multi-claim + subscriber loops do this). + + Discovery (2026-06-23): the inline UNIQUE does not exist in the current + production DB or in main's fresh-DB schema, so this migration is a + defensive no-op against the current state. The test still proves + migration correctness: (a) all migrations up to v15 run cleanly, + (b) two rows with the same (batch_id, patient_control_number) can + be inserted after the migration (proving no UNIQUE was re-introduced + by the table recreation). + """ + # Real migrations dir so the test exercises the actual 0015 file. + monkeypatch.setattr( + db_migrate, + "MIGRATIONS_DIR", + Path(__file__).parent.parent / "src" / "cyclone" / "migrations", + ) + engine = _fresh_engine(tmp_path) + + db_migrate.run(engine) + assert _user_version(engine) == 15, f"expected head=15, got {_user_version(engine)}" + + # Two claims in one batch with the same patient_control_number + # must be insertable. If 0015's table recreation re-introduced a + # UNIQUE(batch_id, patient_control_number), this would raise + # IntegrityError. (The test also implicitly asserts the FK from + # claims to batches still works after the recreation.) + with engine.begin() as conn: + conn.exec_driver_sql( + "INSERT INTO batches (id) VALUES ('B1')" + ) + conn.exec_driver_sql( + "INSERT INTO claims (id, batch_id, patient_control_number, charge_amount) " + "VALUES ('CLM-1', 'B1', 'SAME-PCN', 100)" + ) + conn.exec_driver_sql( + "INSERT INTO claims (id, batch_id, patient_control_number, charge_amount) " + "VALUES ('CLM-2', 'B1', 'SAME-PCN', 200)" + ) + rows = conn.exec_driver_sql( + "SELECT id, charge_amount FROM claims " + "WHERE patient_control_number='SAME-PCN' ORDER BY id" + ).all() + assert [r[0] for r in rows] == ["CLM-1", "CLM-2"] + assert [float(r[1]) for r in rows] == [100.0, 200.0] +