feat(sp41): claim-id dedup at SFTP pre-flight (30-day window, configurable)
This commit is contained in:
@@ -0,0 +1,15 @@
|
|||||||
|
-- version: 22
|
||||||
|
-- SP41: claim-id dedup at SFTP pre-flight.
|
||||||
|
--
|
||||||
|
-- The 30-day dedup window is enforced by cyclone.store.submission_dedup.
|
||||||
|
-- This table records (claim_id, submitted_at) for every push that passed
|
||||||
|
-- the pre-flight check. Past the window, the prior record is ignored
|
||||||
|
-- (the guard lets through re-submits older than DEFAULT_WINDOW_DAYS).
|
||||||
|
|
||||||
|
CREATE TABLE submission_dedup (
|
||||||
|
claim_id TEXT PRIMARY KEY,
|
||||||
|
submitted_at DATETIME NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX submission_dedup_submitted_at_idx
|
||||||
|
ON submission_dedup (submitted_at);
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
"""Claim-id dedup at the SFTP pre-flight stage.
|
||||||
|
|
||||||
|
Schema: a ``submission_dedup`` table records (claim_id, submitted_at). A
|
||||||
|
re-submission of the same claim_id within ``DEFAULT_WINDOW_DAYS`` (30)
|
||||||
|
is rejected with ``DuplicateClaimError``. Past the window, the guard
|
||||||
|
lets it through — the operator may be retrying a 999-rejected file
|
||||||
|
from >30 days ago, which is a legitimate rebuild path.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
|
||||||
|
from sqlalchemy import Column, DateTime, String, create_engine, insert, select
|
||||||
|
from sqlalchemy.orm import declarative_base
|
||||||
|
|
||||||
|
DEFAULT_WINDOW_DAYS = 30
|
||||||
|
|
||||||
|
_Base = declarative_base()
|
||||||
|
|
||||||
|
|
||||||
|
class _SubmissionRecord(_Base):
|
||||||
|
__tablename__ = "submission_dedup"
|
||||||
|
claim_id = Column(String(64), primary_key=True)
|
||||||
|
submitted_at = Column(DateTime, nullable=False, index=True)
|
||||||
|
|
||||||
|
|
||||||
|
class DuplicateClaimError(Exception):
|
||||||
|
def __init__(self, claim_id: str, original_submission_at: datetime):
|
||||||
|
super().__init__(
|
||||||
|
f"claim_id {claim_id!r} was already submitted at "
|
||||||
|
f"{original_submission_at.isoformat()}; within {DEFAULT_WINDOW_DAYS}-day window"
|
||||||
|
)
|
||||||
|
self.claim_id = claim_id
|
||||||
|
self.original_submission_at = original_submission_at
|
||||||
|
|
||||||
|
|
||||||
|
def _engine(db_url: str):
|
||||||
|
return create_engine(db_url, future=True)
|
||||||
|
|
||||||
|
|
||||||
|
def init_submission_dedup(db_url: str) -> None:
|
||||||
|
"""Create the table if it doesn't exist. Idempotent."""
|
||||||
|
_Base.metadata.create_all(_engine(db_url))
|
||||||
|
|
||||||
|
|
||||||
|
def record_submission(claim_id: str, submitted_at: datetime, db_url: str) -> None:
|
||||||
|
init_submission_dedup(db_url)
|
||||||
|
eng = _engine(db_url)
|
||||||
|
with eng.begin() as conn:
|
||||||
|
conn.execute(insert(_SubmissionRecord).values(
|
||||||
|
claim_id=claim_id, submitted_at=submitted_at,
|
||||||
|
).prefix_with("OR REPLACE"))
|
||||||
|
|
||||||
|
|
||||||
|
def check_duplicate(
|
||||||
|
claim_id: str,
|
||||||
|
db_url: str,
|
||||||
|
now: datetime | None = None,
|
||||||
|
window_days: int = DEFAULT_WINDOW_DAYS,
|
||||||
|
) -> None:
|
||||||
|
"""Raise DuplicateClaimError if claim_id was submitted within the window."""
|
||||||
|
init_submission_dedup(db_url)
|
||||||
|
if now is None:
|
||||||
|
now = datetime.utcnow()
|
||||||
|
cutoff = now - timedelta(days=window_days)
|
||||||
|
eng = _engine(db_url)
|
||||||
|
with eng.connect() as conn:
|
||||||
|
stmt = select(_SubmissionRecord).where(
|
||||||
|
_SubmissionRecord.claim_id == claim_id,
|
||||||
|
_SubmissionRecord.submitted_at >= cutoff,
|
||||||
|
)
|
||||||
|
row = conn.execute(stmt).first()
|
||||||
|
if row is not None:
|
||||||
|
raise DuplicateClaimError(claim_id, row.submitted_at)
|
||||||
@@ -51,7 +51,7 @@ 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 21 after
|
user_version already at the latest version — currently 22 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,
|
||||||
@@ -62,16 +62,16 @@ def test_migration_latest_idempotent_on_fresh_db():
|
|||||||
claim.patient_control_number backfill UPDATE, SP28's 0018
|
claim.patient_control_number backfill UPDATE, SP28's 0018
|
||||||
claim_acks join table, SP32's 0019
|
claim_acks join table, SP32's 0019
|
||||||
rendering_provider_npi + service_provider_npi, SP37's 0020
|
rendering_provider_npi + service_provider_npi, SP37's 0020
|
||||||
transaction_set_control_number, and SP39's 0021 resubmissions
|
transaction_set_control_number, SP39's 0021 resubmissions
|
||||||
audit table)."""
|
audit table, and SP41's 0022 submission_dedup)."""
|
||||||
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 == 21
|
assert v1 == 22
|
||||||
# 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 == 21
|
assert v2 == 22
|
||||||
|
|
||||||
|
|
||||||
def test_add_ack_persists_row():
|
def test_add_ack_persists_row():
|
||||||
|
|||||||
@@ -128,14 +128,15 @@ def test_migration_latest_idempotent_on_fresh_db(tmp_path: Path) -> None:
|
|||||||
service_provider_npi on claims and remittances.
|
service_provider_npi on claims and remittances.
|
||||||
SP37 bumped it to 20 with batch transaction_set_control_number.
|
SP37 bumped it to 20 with batch transaction_set_control_number.
|
||||||
SP39 bumped it to 21 with the resubmissions audit table.
|
SP39 bumped it to 21 with the resubmissions audit table.
|
||||||
|
SP41 bumped it to 22 with submission_dedup.
|
||||||
"""
|
"""
|
||||||
engine = _fresh_engine(tmp_path)
|
engine = _fresh_engine(tmp_path)
|
||||||
db_migrate.run(engine)
|
db_migrate.run(engine)
|
||||||
v_after_first = _user_version(engine)
|
v_after_first = _user_version(engine)
|
||||||
assert v_after_first == 21, f"expected head=21, got {v_after_first}"
|
assert v_after_first == 22, f"expected head=22, got {v_after_first}"
|
||||||
|
|
||||||
db_migrate.run(engine)
|
db_migrate.run(engine)
|
||||||
assert _user_version(engine) == 21, "second run should not bump version"
|
assert _user_version(engine) == 22, "second run should not bump version"
|
||||||
|
|
||||||
|
|
||||||
def test_drop_claims_unique_constraint_migration(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
def test_drop_claims_unique_constraint_migration(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
@@ -161,7 +162,7 @@ def test_drop_claims_unique_constraint_migration(tmp_path: Path, monkeypatch: py
|
|||||||
engine = _fresh_engine(tmp_path)
|
engine = _fresh_engine(tmp_path)
|
||||||
|
|
||||||
db_migrate.run(engine)
|
db_migrate.run(engine)
|
||||||
assert _user_version(engine) == 21, f"expected head=21, got {_user_version(engine)}"
|
assert _user_version(engine) == 22, f"expected head=22, got {_user_version(engine)}"
|
||||||
|
|
||||||
# Two claims in one batch with the same patient_control_number
|
# Two claims in one batch with the same patient_control_number
|
||||||
# must be insertable. If 0015's table recreation re-introduced a
|
# must be insertable. If 0015's table recreation re-introduced a
|
||||||
|
|||||||
@@ -102,10 +102,10 @@ def migrated_engine(tmp_path: Path, monkeypatch: pytest.MonkeyPatch):
|
|||||||
engine = _fresh_engine(tmp_path / "mig0020.db")
|
engine = _fresh_engine(tmp_path / "mig0020.db")
|
||||||
db_migrate.run(engine)
|
db_migrate.run(engine)
|
||||||
|
|
||||||
# Confirm head is 21 (every migration applied, including SP39's 0021).
|
# Confirm head is 22 (every migration applied, including SP41's 0022).
|
||||||
with engine.connect() as conn:
|
with engine.connect() as conn:
|
||||||
v = conn.exec_driver_sql("PRAGMA user_version").scalar() or 0
|
v = conn.exec_driver_sql("PRAGMA user_version").scalar() or 0
|
||||||
assert v == 21, f"expected migration head=21, got {v}"
|
assert v == 22, f"expected migration head=22, got {v}"
|
||||||
|
|
||||||
yield engine
|
yield engine
|
||||||
engine.dispose()
|
engine.dispose()
|
||||||
|
|||||||
@@ -0,0 +1,38 @@
|
|||||||
|
"""Claim-id dedup at the SFTP pre-flight stage."""
|
||||||
|
from datetime import datetime
|
||||||
|
from cyclone.store.submission_dedup import (
|
||||||
|
DuplicateClaimError,
|
||||||
|
record_submission,
|
||||||
|
check_duplicate,
|
||||||
|
DEFAULT_WINDOW_DAYS,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_check_duplicate_returns_none_for_first_submission(tmp_path):
|
||||||
|
db = tmp_path / "test.db"
|
||||||
|
assert check_duplicate("ORIG-001", db_url=f"sqlite:///{db}") is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_check_duplicate_raises_within_window(tmp_path):
|
||||||
|
db = tmp_path / "test.db"
|
||||||
|
record_submission("ORIG-002", submitted_at=datetime(2026, 6, 1), db_url=f"sqlite:///{db}")
|
||||||
|
try:
|
||||||
|
check_duplicate("ORIG-002", db_url=f"sqlite:///{db}",
|
||||||
|
now=datetime(2026, 6, 15))
|
||||||
|
except DuplicateClaimError as e:
|
||||||
|
assert e.claim_id == "ORIG-002"
|
||||||
|
assert e.original_submission_at == datetime(2026, 6, 1)
|
||||||
|
else:
|
||||||
|
raise AssertionError("expected DuplicateClaimError")
|
||||||
|
|
||||||
|
|
||||||
|
def test_check_duplicate_passes_after_window(tmp_path):
|
||||||
|
db = tmp_path / "test.db"
|
||||||
|
record_submission("ORIG-003", submitted_at=datetime(2026, 1, 1), db_url=f"sqlite:///{db}")
|
||||||
|
# 100 days later — past the 30-day default window
|
||||||
|
assert check_duplicate("ORIG-003", db_url=f"sqlite:///{db}",
|
||||||
|
now=datetime(2026, 4, 11)) is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_default_window_is_30_days():
|
||||||
|
assert DEFAULT_WINDOW_DAYS == 30
|
||||||
Reference in New Issue
Block a user