merge: SP33 co-txix-payer-fix into main
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -38,3 +38,7 @@ claims_output/
|
|||||||
# Brainstorm session artifacts (visual companion mockups, events, server state).
|
# Brainstorm session artifacts (visual companion mockups, events, server state).
|
||||||
# Skills under .superpowers/skills/ are committed project-scoped guidance.
|
# Skills under .superpowers/skills/ are committed project-scoped guidance.
|
||||||
.superpowers/brainstorm/
|
.superpowers/brainstorm/
|
||||||
|
|
||||||
|
# SP33+ scratch / production-data ingest. Generated artifacts live
|
||||||
|
# here only — the source EDI sits under docs/prodfiles/.
|
||||||
|
ingest/
|
||||||
|
|||||||
+373
-4
@@ -388,10 +388,6 @@ def backfill_rendering_npi(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
main()
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# SP17: `cyclone backup` subcommands
|
# SP17: `cyclone backup` subcommands
|
||||||
#
|
#
|
||||||
@@ -403,6 +399,375 @@ if __name__ == "__main__":
|
|||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# SP33: `cyclone backfill-999-rejections`
|
||||||
|
#
|
||||||
|
# One-shot replay of the cascade fix in `apply_999_rejections` for any
|
||||||
|
# 999 acks already in the DB. Used on the night of 2026-07-02 after
|
||||||
|
# Gainwell rejected the four dzinesco batches at the SET level
|
||||||
|
# ("2010BB NM109 must equal CO_TXIX") — the 999s were ingested but the
|
||||||
|
# pre-SP33 cascade bug didn't flip claim states, so the dashboard's
|
||||||
|
# "0/145 accepted" widget was lying.
|
||||||
|
#
|
||||||
|
# Idempotent: claims already in REJECTED are skipped (counted in
|
||||||
|
# `already_rejected`). Safe to re-run.
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
@main.command("backfill-999-rejections")
|
||||||
|
@click.option("--dry-run", is_flag=True, default=False,
|
||||||
|
help="Print the would-be state transitions without writing.")
|
||||||
|
@click.option("--actor", default="sp33-backfill",
|
||||||
|
show_default=True,
|
||||||
|
help="Audit-log actor tag for the claim.rejected events.")
|
||||||
|
def backfill_999_rejections(dry_run: bool, actor: str) -> None:
|
||||||
|
"""Replay existing 999 rejections onto already-linked claims (SP33).
|
||||||
|
|
||||||
|
Walks ``claim_acks`` joined with ``claims`` where the link row's
|
||||||
|
``set_accept_reject_code='R'`` (a SET-level rejection from the 999
|
||||||
|
envelope) and flips the matching claim to ``REJECTED`` — mirroring
|
||||||
|
what the (now-fixed) ``apply_999_rejections`` would have done at
|
||||||
|
ingest time.
|
||||||
|
|
||||||
|
Each flipped claim gets:
|
||||||
|
- ``state`` set to ``REJECTED``
|
||||||
|
- ``state_changed_at`` and ``rejected_at`` set to now
|
||||||
|
- ``rejection_reason`` filled with the 999 AK5 code + SCN
|
||||||
|
- ``payer_rejected_at`` / ``payer_rejected_reason`` /
|
||||||
|
``payer_rejected_status_code`` filled (the 999 SET-level reject
|
||||||
|
is also a payer-side reject for Inbox-lanes purposes)
|
||||||
|
- one ``claim.rejected`` audit-log event
|
||||||
|
|
||||||
|
Claims already in REJECTED are skipped (counted in the summary).
|
||||||
|
"""
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from sqlalchemy import select, func
|
||||||
|
|
||||||
|
from cyclone import db as db_mod
|
||||||
|
from cyclone.audit_log import AuditEvent, append_event
|
||||||
|
|
||||||
|
db_mod.init_db()
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
|
||||||
|
with db_mod.SessionLocal()() as session:
|
||||||
|
# Pull one representative R-coded link per claim_id (a single
|
||||||
|
# 999 may stamp many AK2 rows against the same claim, so we
|
||||||
|
# collapse via MIN(set_control_number) / MIN(ak2_index) to avoid
|
||||||
|
# firing 1 audit event per duplicate ack row).
|
||||||
|
#
|
||||||
|
# 36777 R-coded rows resolve to 339 unique claims (338 still in
|
||||||
|
# SUBMITTED + 1 already in REJECTED). Grouping in SQL keeps the
|
||||||
|
# in-Python loop small AND emits exactly 1 audit event per claim.
|
||||||
|
rows = session.execute(
|
||||||
|
select(
|
||||||
|
db_mod.Claim.id,
|
||||||
|
db_mod.Claim.state,
|
||||||
|
func.min(db_mod.ClaimAck.set_control_number).label("scn"),
|
||||||
|
func.min(db_mod.ClaimAck.ak2_index).label("ak2"),
|
||||||
|
func.min(db_mod.Ack.ack_code).label("ack_code"),
|
||||||
|
)
|
||||||
|
.join(db_mod.ClaimAck, db_mod.ClaimAck.claim_id == db_mod.Claim.id)
|
||||||
|
.join(db_mod.Ack, db_mod.Ack.id == db_mod.ClaimAck.ack_id)
|
||||||
|
.where(db_mod.ClaimAck.set_accept_reject_code == "R")
|
||||||
|
.where(db_mod.ClaimAck.claim_id.is_not(None))
|
||||||
|
.group_by(db_mod.Claim.id, db_mod.Claim.state)
|
||||||
|
).all()
|
||||||
|
|
||||||
|
matched = 0
|
||||||
|
already = 0
|
||||||
|
errors = 0
|
||||||
|
for (claim_id, current_state, scn, ak2_idx, ack_code) in rows:
|
||||||
|
if current_state == db_mod.ClaimState.REJECTED:
|
||||||
|
already += 1
|
||||||
|
continue
|
||||||
|
|
||||||
|
if dry_run:
|
||||||
|
matched += 1
|
||||||
|
continue
|
||||||
|
|
||||||
|
claim = session.get(db_mod.Claim, claim_id)
|
||||||
|
if claim is None:
|
||||||
|
errors += 1
|
||||||
|
continue
|
||||||
|
|
||||||
|
claim.state = db_mod.ClaimState.REJECTED
|
||||||
|
claim.state_changed_at = now
|
||||||
|
claim.rejected_at = now
|
||||||
|
claim.rejection_reason = (
|
||||||
|
f"999 AK5={ack_code or 'R'} SCN={scn} ak2={ak2_idx}"
|
||||||
|
)
|
||||||
|
# Mirror the 999 SET-level reject into the payer-rejected
|
||||||
|
# lane so the Inbox sees it as a Payer-Rejected claim too.
|
||||||
|
claim.payer_rejected_at = now
|
||||||
|
claim.payer_rejected_reason = f"999 SET-level reject at SCN={scn}"
|
||||||
|
claim.payer_rejected_status_code = "R"
|
||||||
|
|
||||||
|
append_event(session, AuditEvent(
|
||||||
|
event_type="claim.rejected",
|
||||||
|
entity_type="claim",
|
||||||
|
entity_id=claim_id,
|
||||||
|
payload={"source": "backfill-999-rejections",
|
||||||
|
"scn": scn, "ak2_index": ak2_idx,
|
||||||
|
"ack_code": ack_code},
|
||||||
|
actor=actor,
|
||||||
|
))
|
||||||
|
matched += 1
|
||||||
|
|
||||||
|
if not dry_run:
|
||||||
|
session.commit()
|
||||||
|
|
||||||
|
click.echo(
|
||||||
|
f"matched={matched} already_rejected={already} errors={errors} "
|
||||||
|
f"dry_run={dry_run}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# SP33: `cyclone resubmit-rejected-claims`
|
||||||
|
#
|
||||||
|
# Push the corrected single-claim 837 files to Gainwell's SFTP ToHPE
|
||||||
|
# dir so dzinesco can resubmit the batch. The byte-level
|
||||||
|
# SKCO0 -> CO_TXIX fix is assumed to have already been applied (see
|
||||||
|
# the SP33 plan §4.4 / `docs/ingest/corrected/`). This CLI just walks
|
||||||
|
# the corrected directory, validates each file via the parser, and
|
||||||
|
# uploads via the real SftpClient.
|
||||||
|
#
|
||||||
|
# Idempotent: a file already present on the remote with the same byte
|
||||||
|
# size is skipped (counted in `skipped`). Re-runnable after a partial
|
||||||
|
# failure without re-uploading files that landed.
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
@main.command("resubmit-rejected-claims")
|
||||||
|
@click.option("--ingest-dir", default="ingest/corrected",
|
||||||
|
show_default=True,
|
||||||
|
help="Root dir holding batch-*-claims/ subfolders of fixed .x12 files.")
|
||||||
|
@click.option("--actor", default="sp33-resubmit",
|
||||||
|
show_default=True,
|
||||||
|
help="Audit-log actor tag for the clearhouse.submitted events.")
|
||||||
|
@click.option("--validate/--no-validate", default=True,
|
||||||
|
help="Parse each file via parse_837 before upload (catches a bad fix).")
|
||||||
|
@click.option("--limit", type=int, default=None,
|
||||||
|
help="Stop after checking this many files (smoke-tests). Counts "
|
||||||
|
"all attempts, not just successful uploads.")
|
||||||
|
@click.option("--reconnect-every", type=int, default=50, show_default=True,
|
||||||
|
help="Reconnect SFTP every N uploads to avoid MOVEit's per-session cap.")
|
||||||
|
def resubmit_rejected_claims(
|
||||||
|
ingest_dir: str,
|
||||||
|
actor: str,
|
||||||
|
validate: bool,
|
||||||
|
limit: int | None,
|
||||||
|
reconnect_every: int,
|
||||||
|
) -> None:
|
||||||
|
"""Upload corrected 837 files to the Gainwell SFTP ToHPE dir (SP33).
|
||||||
|
|
||||||
|
Walks every ``batch-*-claims/*.x12`` under ``--ingest-dir`` (default
|
||||||
|
``./ingest/corrected``), validates each one through ``parse_837``,
|
||||||
|
and uploads via the seeded real-SFTP ``SftpClient``.
|
||||||
|
|
||||||
|
Idempotent: a file already present on the remote with the same
|
||||||
|
byte size is skipped (counted in ``skipped``). Reconnects every
|
||||||
|
``--reconnect-every`` uploads to avoid MOVEit's silent per-session
|
||||||
|
file cap (observed: ~200 puts/session before silent drops with no
|
||||||
|
exception — see SP33 root-cause notes).
|
||||||
|
|
||||||
|
Doesn't mutate claim state — claims stay in REJECTED until a 999
|
||||||
|
ACK confirms Gainwell accepted the resubmit. Emits one
|
||||||
|
``clearhouse.submitted`` audit event per successful upload.
|
||||||
|
"""
|
||||||
|
import time
|
||||||
|
|
||||||
|
from cyclone import db as db_mod
|
||||||
|
from cyclone.audit_log import AuditEvent, append_event
|
||||||
|
from cyclone.parsers.parse_837 import parse as parse_837_text
|
||||||
|
from cyclone.parsers.payer import PayerConfig
|
||||||
|
from cyclone.store import store as cycl_store
|
||||||
|
|
||||||
|
db_mod.init_db()
|
||||||
|
cycl_store.ensure_clearhouse_seeded()
|
||||||
|
clearhouse = cycl_store.get_clearhouse()
|
||||||
|
if clearhouse is None:
|
||||||
|
click.echo("No clearhouse seeded; cannot resolve SFTP block.", err=True)
|
||||||
|
sys.exit(2)
|
||||||
|
sftp_block = clearhouse.sftp_block
|
||||||
|
if sftp_block.stub:
|
||||||
|
click.echo("Clearhouse SFTP block is in stub mode; refusing to upload.", err=True)
|
||||||
|
sys.exit(2)
|
||||||
|
remote_root = sftp_block.paths["outbound"]
|
||||||
|
|
||||||
|
root = Path(ingest_dir).resolve()
|
||||||
|
if not root.exists():
|
||||||
|
click.echo(f"--ingest-dir does not exist: {root}", err=True)
|
||||||
|
sys.exit(2)
|
||||||
|
|
||||||
|
files: list[Path] = []
|
||||||
|
for batch_dir in sorted(root.glob("batch-*-claims")):
|
||||||
|
files.extend(sorted(p for p in batch_dir.glob("*.x12")
|
||||||
|
if not p.name.startswith("._")))
|
||||||
|
if not files:
|
||||||
|
click.echo(f"No .x12 files under {root}", err=True)
|
||||||
|
sys.exit(2)
|
||||||
|
|
||||||
|
click.echo(f"found {len(files)} files under {root}")
|
||||||
|
|
||||||
|
uploaded = 0
|
||||||
|
skipped = 0
|
||||||
|
failed = 0
|
||||||
|
validated = 0
|
||||||
|
payer_cfg = PayerConfig.co_medicaid()
|
||||||
|
start = time.monotonic()
|
||||||
|
last_progress = start
|
||||||
|
|
||||||
|
# One persistent paramiko session per batch, with periodic
|
||||||
|
# reconnect to dodge MOVEit's silent per-session file cap
|
||||||
|
# (~200 puts/session, no exception — see SP33 root-cause notes).
|
||||||
|
import paramiko
|
||||||
|
from cyclone.secrets import get_secret
|
||||||
|
|
||||||
|
def _open_session() -> tuple[paramiko.SSHClient, paramiko.SFTPClient]:
|
||||||
|
pw = get_secret(sftp_block.auth.get("password_keychain_account", ""))
|
||||||
|
ssh = paramiko.SSHClient()
|
||||||
|
ssh.set_missing_host_key_policy(paramiko.AutoAddPolicy())
|
||||||
|
ssh.connect(
|
||||||
|
sftp_block.host, port=sftp_block.port,
|
||||||
|
username=sftp_block.username, password=pw,
|
||||||
|
timeout=15, banner_timeout=15, auth_timeout=15,
|
||||||
|
)
|
||||||
|
return ssh, ssh.open_sftp()
|
||||||
|
|
||||||
|
def _close_session(ssh: paramiko.SSHClient | None) -> None:
|
||||||
|
if ssh is None:
|
||||||
|
return
|
||||||
|
try: ssh.close()
|
||||||
|
except Exception: pass
|
||||||
|
|
||||||
|
ssh: paramiko.SSHClient | None = None
|
||||||
|
sftp: paramiko.SFTPClient | None = None
|
||||||
|
|
||||||
|
for i, src in enumerate(files, 1):
|
||||||
|
if limit is not None and i > limit:
|
||||||
|
break
|
||||||
|
|
||||||
|
content = src.read_bytes()
|
||||||
|
|
||||||
|
# Validate: parse must succeed AND payer_id must match the
|
||||||
|
# companion-guide CO_TXIX (catches a bad byte-fix early).
|
||||||
|
if validate:
|
||||||
|
try:
|
||||||
|
parsed = parse_837_text(content.decode(), payer_cfg)
|
||||||
|
except Exception as exc: # noqa: BLE001
|
||||||
|
failed += 1
|
||||||
|
click.echo(f"PARSE FAIL {src.name}: {exc.__class__.__name__}: {exc}", err=True)
|
||||||
|
continue
|
||||||
|
mismatch = next(
|
||||||
|
(c for c in parsed.claims if c.payer.id != "CO_TXIX"), None
|
||||||
|
)
|
||||||
|
if mismatch is not None:
|
||||||
|
failed += 1
|
||||||
|
click.echo(
|
||||||
|
f"PAYER MISMATCH {src.name}: payer.id={mismatch.payer.id!r} "
|
||||||
|
f"(expected 'CO_TXIX')", err=True,
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
validated += 1
|
||||||
|
|
||||||
|
local_size = len(content)
|
||||||
|
remote_path = f"{remote_root}/{src.name}"
|
||||||
|
|
||||||
|
attempts = 0
|
||||||
|
ok = False
|
||||||
|
was_skipped = False
|
||||||
|
while attempts < 3:
|
||||||
|
attempts += 1
|
||||||
|
if ssh is None:
|
||||||
|
try:
|
||||||
|
ssh, sftp = _open_session()
|
||||||
|
except Exception as exc: # noqa: BLE001
|
||||||
|
click.echo(
|
||||||
|
f"SFTP CONNECT FAIL attempt {attempts}: "
|
||||||
|
f"{exc.__class__.__name__}: {exc}", err=True,
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
# Idempotency check.
|
||||||
|
try:
|
||||||
|
rs = sftp.stat(remote_path) # type: ignore[union-attr]
|
||||||
|
if rs.st_size == local_size:
|
||||||
|
skipped += 1
|
||||||
|
was_skipped = True
|
||||||
|
break
|
||||||
|
except IOError:
|
||||||
|
pass # not on remote yet
|
||||||
|
sftp.put(str(src), remote_path) # type: ignore[union-attr]
|
||||||
|
ok = True
|
||||||
|
break
|
||||||
|
except Exception as exc: # noqa: BLE001
|
||||||
|
# Connection died — drop the session and let the next
|
||||||
|
# attempt reopen.
|
||||||
|
_close_session(ssh)
|
||||||
|
ssh, sftp = None, None
|
||||||
|
if attempts >= 3:
|
||||||
|
click.echo(
|
||||||
|
f"UPLOAD FAIL {src.name}: "
|
||||||
|
f"{exc.__class__.__name__}: {exc}", err=True,
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
|
||||||
|
if not ok and not was_skipped:
|
||||||
|
failed += 1
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Audit + reconnect cadence apply only to real uploads.
|
||||||
|
if not was_skipped:
|
||||||
|
# Audit (best-effort; if the DB is unavailable we still
|
||||||
|
# keep the file on the wire).
|
||||||
|
try:
|
||||||
|
with db_mod.SessionLocal()() as session:
|
||||||
|
append_event(session, AuditEvent(
|
||||||
|
event_type="clearhouse.submitted",
|
||||||
|
entity_type="claim_file",
|
||||||
|
entity_id=src.name,
|
||||||
|
payload={"remote_path": remote_path,
|
||||||
|
"source": "resubmit-rejected-claims",
|
||||||
|
"size": local_size},
|
||||||
|
actor=actor,
|
||||||
|
))
|
||||||
|
session.commit()
|
||||||
|
except Exception as exc: # noqa: BLE001
|
||||||
|
click.echo(
|
||||||
|
f"audit-log write failed for {src.name}: "
|
||||||
|
f"{exc.__class__.__name__}: {exc}", err=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
uploaded += 1
|
||||||
|
|
||||||
|
# Reconnect periodically to dodge MOVEit's per-session cap.
|
||||||
|
if uploaded % reconnect_every == 0:
|
||||||
|
click.echo(f"reconnecting (after {uploaded} uploads)", err=True)
|
||||||
|
_close_session(ssh)
|
||||||
|
ssh, sftp = None, None
|
||||||
|
|
||||||
|
# Progress every 10s of wall-clock (or at end).
|
||||||
|
now = time.monotonic()
|
||||||
|
if now - last_progress >= 10 or i == len(files):
|
||||||
|
elapsed = now - start
|
||||||
|
rate = uploaded / elapsed if elapsed else 0
|
||||||
|
click.echo(
|
||||||
|
f"progress {i}/{len(files)} uploaded={uploaded} "
|
||||||
|
f"skipped={skipped} failed={failed} rate={rate:.2f}/s "
|
||||||
|
f"elapsed={elapsed:.1f}s", err=True,
|
||||||
|
)
|
||||||
|
last_progress = now
|
||||||
|
|
||||||
|
# Tear down the long-lived session if one is still open.
|
||||||
|
if ssh is not None:
|
||||||
|
_close_session(ssh)
|
||||||
|
|
||||||
|
elapsed = time.monotonic() - start
|
||||||
|
click.echo(
|
||||||
|
f"DONE uploaded={uploaded} skipped={skipped} failed={failed} "
|
||||||
|
f"validated={validated} files_total={len(files)} elapsed={elapsed:.1f}s"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@main.group()
|
@main.group()
|
||||||
def backup() -> None:
|
def backup() -> None:
|
||||||
"""Encrypted DB backup management (SP17)."""
|
"""Encrypted DB backup management (SP17)."""
|
||||||
@@ -824,3 +1189,7 @@ def pull_inbound(
|
|||||||
click.echo("download errors:", err=True)
|
click.echo("download errors:", err=True)
|
||||||
for e in summary["download_errors"]:
|
for e in summary["download_errors"]:
|
||||||
click.echo(f" {e}", err=True)
|
click.echo(f" {e}", err=True)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
|
|||||||
@@ -90,7 +90,9 @@ def handle(
|
|||||||
.first()
|
.first()
|
||||||
)
|
)
|
||||||
rejection_result = apply_999_rejections(
|
rejection_result = apply_999_rejections(
|
||||||
session, result, claim_lookup=_lookup,
|
session, result,
|
||||||
|
claim_lookup=_lookup,
|
||||||
|
batch_envelope_index=batch_index,
|
||||||
)
|
)
|
||||||
if rejection_result.matched:
|
if rejection_result.matched:
|
||||||
for cid in rejection_result.matched:
|
for cid in rejection_result.matched:
|
||||||
|
|||||||
@@ -33,42 +33,63 @@ def apply_999_rejections(
|
|||||||
parsed_999,
|
parsed_999,
|
||||||
*,
|
*,
|
||||||
claim_lookup: Callable[[str], Claim | None],
|
claim_lookup: Callable[[str], Claim | None],
|
||||||
|
batch_envelope_index: dict[str, list[str]] | None = None,
|
||||||
) -> Apply999Result:
|
) -> Apply999Result:
|
||||||
"""For each set response with code R or E, look up the matching claim and
|
"""For each set response with code R, E, or X, look up the matching claim and
|
||||||
move it to REJECTED. Idempotent on already-rejected claims.
|
move it to REJECTED. Idempotent on already-rejected claims.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
session: SQLAlchemy session.
|
session: SQLAlchemy session.
|
||||||
parsed_999: a ParseResult999 (or any object with .set_responses).
|
parsed_999: a ParseResult999 (or any object with .set_responses).
|
||||||
claim_lookup: callable from patient_control_number → Claim or None.
|
claim_lookup: callable from patient_control_number → Claim or None.
|
||||||
|
Legacy fallback; rarely hits when batch_envelope_index is present.
|
||||||
|
batch_envelope_index: SP33 — mapping from SET control_number (the 837
|
||||||
|
envelope's ST02) to list of Claim.id for the claims in that SET.
|
||||||
|
Mirrors the SP28 fix in apply_999_acceptances so SET-level
|
||||||
|
rejections correctly cascade across every claim under the SET.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Apply999Result with lists of matched claim ids and orphan PCNs.
|
Apply999Result with lists of matched claim ids and orphan PCNs.
|
||||||
"""
|
"""
|
||||||
result = Apply999Result()
|
result = Apply999Result()
|
||||||
now = datetime.now(timezone.utc)
|
now = datetime.now(timezone.utc)
|
||||||
|
index = batch_envelope_index or {}
|
||||||
|
|
||||||
for sr in parsed_999.set_responses:
|
for sr in parsed_999.set_responses:
|
||||||
code = sr.set_accept_reject.code
|
code = sr.set_accept_reject.code
|
||||||
if code not in ("R", "E", "X"):
|
if code not in ("R", "E", "X"):
|
||||||
continue
|
continue
|
||||||
|
|
||||||
claim = claim_lookup(sr.set_control_number)
|
# SP33: prefer batch_envelope_index (SCN -> [claim_id]) so a SET-level
|
||||||
if claim is None:
|
# rejection correctly flips every claim in the SET. Fall back to
|
||||||
result.orphans.append(sr.set_control_number)
|
# the legacy claim_lookup when the index is empty for this SCN.
|
||||||
continue
|
candidate_ids = index.get(sr.set_control_number, []) or []
|
||||||
|
claims_to_reject: list[Claim] = []
|
||||||
|
if candidate_ids:
|
||||||
|
claims_to_reject = (
|
||||||
|
session.query(Claim)
|
||||||
|
.filter(Claim.id.in_(candidate_ids))
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
legacy = claim_lookup(sr.set_control_number)
|
||||||
|
if legacy is not None:
|
||||||
|
claims_to_reject = [legacy]
|
||||||
|
else:
|
||||||
|
result.orphans.append(sr.set_control_number)
|
||||||
|
continue
|
||||||
|
|
||||||
if claim.state == ClaimState.REJECTED:
|
for claim in claims_to_reject:
|
||||||
# Idempotent: don't double-mutate.
|
if claim.state == ClaimState.REJECTED:
|
||||||
continue
|
# Idempotent: don't double-mutate.
|
||||||
|
continue
|
||||||
claim.state = ClaimState.REJECTED
|
claim.state = ClaimState.REJECTED
|
||||||
claim.state_changed_at = now
|
claim.state_changed_at = now
|
||||||
claim.rejected_at = now
|
claim.rejected_at = now
|
||||||
claim.rejection_reason = _build_reason(
|
claim.rejection_reason = _build_reason(
|
||||||
code, len(sr.segment_errors or [])
|
code, len(sr.segment_errors or [])
|
||||||
)
|
)
|
||||||
result.matched.append(claim.id)
|
result.matched.append(claim.id)
|
||||||
|
|
||||||
if result.matched or result.orphans:
|
if result.matched or result.orphans:
|
||||||
session.commit()
|
session.commit()
|
||||||
|
|||||||
@@ -66,8 +66,8 @@ class PayerConfig(BaseModel):
|
|||||||
# Lenient in v1 — see spec §9 R031.
|
# Lenient in v1 — see spec §9 R031.
|
||||||
require_ref_g1_for_adjustments=False,
|
require_ref_g1_for_adjustments=False,
|
||||||
allowed_bht06={"CH"},
|
allowed_bht06={"CH"},
|
||||||
payer_id="SKCO0",
|
payer_id="CO_TXIX",
|
||||||
payer_name="COHCPF",
|
payer_name="CO_TXIX",
|
||||||
no_patient_loop=True,
|
no_patient_loop=True,
|
||||||
encounter_claim_in_same_batch=False,
|
encounter_claim_in_same_batch=False,
|
||||||
allowed_facility_qualifiers={"B"},
|
allowed_facility_qualifiers={"B"},
|
||||||
|
|||||||
+2
-2
@@ -17,7 +17,7 @@ NM1*IL*1*Balliache*Marianela****MI*P060946~
|
|||||||
N3*1811 PAVILION DR APT 303~
|
N3*1811 PAVILION DR APT 303~
|
||||||
N4*Montrose*CO*814016072~
|
N4*Montrose*CO*814016072~
|
||||||
DMG*D8*19590223*F~
|
DMG*D8*19590223*F~
|
||||||
NM1*PR*2*COHCPF*****PI*SKCO0~
|
NM1*PR*2*CO_TXIX*****PI*CO_TXIX~
|
||||||
CLM*t991102984o1c1d*85.40***12:B:1*Y*A*Y*Y~
|
CLM*t991102984o1c1d*85.40***12:B:1*Y*A*Y*Y~
|
||||||
REF*G1*3173~
|
REF*G1*3173~
|
||||||
HI*ABK:R69~
|
HI*ABK:R69~
|
||||||
@@ -35,7 +35,7 @@ NM1*IL*1*Barella*Victoria****MI*H582447~
|
|||||||
N3*1900 Kellie DR~
|
N3*1900 Kellie DR~
|
||||||
N4*Montrose*CO*814019524~
|
N4*Montrose*CO*814019524~
|
||||||
DMG*D8*19570727*F~
|
DMG*D8*19570727*F~
|
||||||
NM1*PR*2*COHCPF*****PI*SKCO0~
|
NM1*PR*2*CO_TXIX*****PI*CO_TXIX~
|
||||||
CLM*t991102984o1c2d*155.76***12:B:1*Y*A*Y*Y~
|
CLM*t991102984o1c2d*155.76***12:B:1*Y*A*Y*Y~
|
||||||
REF*G1*3173~
|
REF*G1*3173~
|
||||||
HI*ABK:R69~
|
HI*ABK:R69~
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ NM1*IL*1*Balliache*Marianela****MI*P060946~
|
|||||||
N3*1811 PAVILION DR APT 303~
|
N3*1811 PAVILION DR APT 303~
|
||||||
N4*Montrose*CO*814016072~
|
N4*Montrose*CO*814016072~
|
||||||
DMG*D8*19590223*F~
|
DMG*D8*19590223*F~
|
||||||
NM1*PR*2*COHCPF*****PI*SKCO0~
|
NM1*PR*2*CO_TXIX*****PI*CO_TXIX~
|
||||||
CLM*t991102984o1c1d*85.40***12:B:1*Y*A*Y*Y~
|
CLM*t991102984o1c1d*85.40***12:B:1*Y*A*Y*Y~
|
||||||
REF*G1*3173~
|
REF*G1*3173~
|
||||||
HI*ABK:R69~
|
HI*ABK:R69~
|
||||||
@@ -36,7 +36,7 @@ NM1*IL*1*Barella*Victoria****MI*H582447~
|
|||||||
N3*1900 Kellie DR~
|
N3*1900 Kellie DR~
|
||||||
N4*Montrose*CO*814019524~
|
N4*Montrose*CO*814019524~
|
||||||
DMG*D8*19570727*F~
|
DMG*D8*19570727*F~
|
||||||
NM1*PR*2*COHCPF*****PI*SKCO0~
|
NM1*PR*2*CO_TXIX*****PI*CO_TXIX~
|
||||||
CLM*t991102984o1c2d*155.76***12:B:1*Y*A*Y*Y~
|
CLM*t991102984o1c2d*155.76***12:B:1*Y*A*Y*Y~
|
||||||
REF*G1*3173~
|
REF*G1*3173~
|
||||||
HI*ABK:R69~
|
HI*ABK:R69~
|
||||||
|
|||||||
+1
-1
@@ -17,7 +17,7 @@ NM1*IL*1*Doe*John****MI*ABC123~
|
|||||||
N3*456 Member St~
|
N3*456 Member St~
|
||||||
N4*Denver*CO*80203~
|
N4*Denver*CO*80203~
|
||||||
DMG*D8*19800101*M~
|
DMG*D8*19800101*M~
|
||||||
NM1*PR*2*COHCPF*****PI*SKCO0~
|
NM1*PR*2*CO_TXIX*****PI*CO_TXIX~
|
||||||
CLM*CLM001*100.00***12:B:1*Y*A*Y*Y~
|
CLM*CLM001*100.00***12:B:1*Y*A*Y*Y~
|
||||||
REF*G1*PA123~
|
REF*G1*PA123~
|
||||||
HI*ABK:Z00~
|
HI*ABK:Z00~
|
||||||
|
|||||||
@@ -0,0 +1,120 @@
|
|||||||
|
"""SP33: apply_999_rejections must use batch_envelope_index (mirrors
|
||||||
|
SP28's apply_999_acceptances fix) so SET-level 999 rejections cascade
|
||||||
|
to claim state transitions.
|
||||||
|
|
||||||
|
The pre-SP33 bug: ``claim_lookup(sr.set_control_number)`` passed the
|
||||||
|
SET control_number to a PCN-based lookup, so no claim was ever matched
|
||||||
|
and rejections silently died. SP33 threads the same
|
||||||
|
``batch_envelope_index`` that ``apply_999_acceptances`` already has.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from types import SimpleNamespace
|
||||||
|
from unittest.mock import MagicMock
|
||||||
|
|
||||||
|
from cyclone.db import ClaimState
|
||||||
|
from cyclone.inbox_state import apply_999_rejections
|
||||||
|
|
||||||
|
|
||||||
|
def _mk_set_response(code: str = "R", scn: str = "991102994"):
|
||||||
|
"""Build a stand-in for ``ParseResult999.set_responses[0]``."""
|
||||||
|
return SimpleNamespace(
|
||||||
|
set_accept_reject=SimpleNamespace(code=code),
|
||||||
|
set_control_number=scn,
|
||||||
|
segment_errors=[],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _mk_claim(cid: str = "claim-x", state=ClaimState.SUBMITTED):
|
||||||
|
c = MagicMock()
|
||||||
|
c.id = cid
|
||||||
|
c.state = state
|
||||||
|
return c
|
||||||
|
|
||||||
|
|
||||||
|
def test_set_level_rejection_cascades_via_envelope_index():
|
||||||
|
"""When the SET-level 999 has set_control_number=991102994 and the
|
||||||
|
batch_envelope_index maps it to a list of 3 claim_ids, all 3 should
|
||||||
|
transition to REJECTED.
|
||||||
|
"""
|
||||||
|
session = MagicMock()
|
||||||
|
claims = [_mk_claim(f"c-{i}") for i in range(3)]
|
||||||
|
session.query.return_value.filter.return_value.all.return_value = claims
|
||||||
|
|
||||||
|
parsed = SimpleNamespace(set_responses=[_mk_set_response(code="R", scn="991102994")])
|
||||||
|
index = {"991102994": [c.id for c in claims]}
|
||||||
|
|
||||||
|
result = apply_999_rejections(
|
||||||
|
session, parsed,
|
||||||
|
claim_lookup=lambda _pcn: None,
|
||||||
|
batch_envelope_index=index,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert sorted(result.matched) == sorted(c.id for c in claims)
|
||||||
|
assert result.orphans == []
|
||||||
|
session.commit.assert_called_once()
|
||||||
|
for c in claims:
|
||||||
|
# ClaimState.REJECTED is the lowercase 'rejected' string (the enum
|
||||||
|
# value stored in the DB column via SQLAlchemy Enum type).
|
||||||
|
assert c.state == ClaimState.REJECTED
|
||||||
|
|
||||||
|
|
||||||
|
def test_set_level_rejection_without_index_still_uses_legacy_lookup():
|
||||||
|
"""Backwards compat: when batch_envelope_index is None, the function
|
||||||
|
must fall back to claim_lookup (existing behavior preserved).
|
||||||
|
"""
|
||||||
|
session = MagicMock()
|
||||||
|
parsed = SimpleNamespace(set_responses=[_mk_set_response(code="R", scn="991102994")])
|
||||||
|
legacy_claim = _mk_claim("legacy-claim")
|
||||||
|
|
||||||
|
result = apply_999_rejections(
|
||||||
|
session, parsed,
|
||||||
|
claim_lookup=lambda scn: legacy_claim if scn == "991102994" else None,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result.matched == ["legacy-claim"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_no_envelope_index_no_match_becomes_orphan():
|
||||||
|
"""Empty index + empty legacy lookup => the SET becomes an orphan."""
|
||||||
|
session = MagicMock()
|
||||||
|
parsed = SimpleNamespace(set_responses=[_mk_set_response(code="R", scn="991102999")])
|
||||||
|
|
||||||
|
result = apply_999_rejections(
|
||||||
|
session, parsed,
|
||||||
|
claim_lookup=lambda _pcn: None,
|
||||||
|
batch_envelope_index={}, # empty index
|
||||||
|
)
|
||||||
|
assert result.matched == []
|
||||||
|
assert "991102999" in result.orphans
|
||||||
|
|
||||||
|
|
||||||
|
def test_accepted_999_does_not_reject():
|
||||||
|
"""An 'A' code must not transition any claim to REJECTED."""
|
||||||
|
session = MagicMock()
|
||||||
|
parsed = SimpleNamespace(set_responses=[_mk_set_response(code="A", scn="991102994")])
|
||||||
|
|
||||||
|
result = apply_999_rejections(
|
||||||
|
session, parsed,
|
||||||
|
claim_lookup=lambda _pcn: None,
|
||||||
|
batch_envelope_index={"991102994": ["claim-x"]},
|
||||||
|
)
|
||||||
|
assert result.matched == []
|
||||||
|
session.query.assert_not_called()
|
||||||
|
session.commit.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
|
def test_already_rejected_is_idempotent():
|
||||||
|
"""Pre-REJECTED claims in the index are left alone (no second commit)."""
|
||||||
|
session = MagicMock()
|
||||||
|
already = _mk_claim("already-rejected", state=ClaimState.REJECTED)
|
||||||
|
session.query.return_value.filter.return_value.all.return_value = [already]
|
||||||
|
|
||||||
|
parsed = SimpleNamespace(set_responses=[_mk_set_response(code="R", scn="991102994")])
|
||||||
|
result = apply_999_rejections(
|
||||||
|
session, parsed,
|
||||||
|
claim_lookup=lambda _pcn: None,
|
||||||
|
batch_envelope_index={"991102994": ["already-rejected"]},
|
||||||
|
)
|
||||||
|
assert result.matched == []
|
||||||
|
assert already.state == ClaimState.REJECTED # untouched
|
||||||
@@ -36,5 +36,5 @@ def test_subscriber_payer_reused_across_claims():
|
|||||||
result = parse(FIXTURE.read_text(), PayerConfig.co_medicaid())
|
result = parse(FIXTURE.read_text(), PayerConfig.co_medicaid())
|
||||||
assert result.claims[0].billing_provider.npi == "1881068062"
|
assert result.claims[0].billing_provider.npi == "1881068062"
|
||||||
assert result.claims[1].billing_provider.npi == "1881068062"
|
assert result.claims[1].billing_provider.npi == "1881068062"
|
||||||
assert result.claims[0].payer.id == "SKCO0"
|
assert result.claims[0].payer.id == "CO_TXIX"
|
||||||
assert result.claims[1].payer.id == "SKCO0"
|
assert result.claims[1].payer.id == "CO_TXIX"
|
||||||
|
|||||||
@@ -0,0 +1,33 @@
|
|||||||
|
"""SP33 regression test: ``PayerConfig.co_medicaid()`` emits CO_TXIX.
|
||||||
|
|
||||||
|
The HCPF 837P Companion Guide (June 2025 - Version 2.5) requires
|
||||||
|
``NM1*PR NM108=PI, NM109=CO_TXIX`` for the Payer Name loop (2010BB).
|
||||||
|
The in-code factory historically emitted ``SKCO0`` which Gainwell
|
||||||
|
rejects with ``IK3*NM1*17*2010*8``. This test pins the corrected value.
|
||||||
|
See ``docs/superpowers/specs/2026-07-02-cyclone-co-txix-payer-fix-design.md``.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
|
||||||
|
def test_co_medicaid_factory_emits_CO_TXIX():
|
||||||
|
"""Per HCPF 837P Companion Guide, NM1*PR NM109 must equal CO_TXIX."""
|
||||||
|
from cyclone.parsers.payer import PayerConfig
|
||||||
|
cfg = PayerConfig.co_medicaid()
|
||||||
|
assert cfg.payer_id == "CO_TXIX", (
|
||||||
|
f"co_medicaid().payer_id must be 'CO_TXIX' (HCPF guide), got {cfg.payer_id!r}"
|
||||||
|
)
|
||||||
|
assert cfg.payer_name == "CO_TXIX", (
|
||||||
|
f"co_medicaid().payer_name must be 'CO_TXIX' (matches docs/goodclaim.x12), got {cfg.payer_name!r}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_skco0_not_present_in_factory():
|
||||||
|
"""Hard guard: SKCO0 must never be emitted by the factory again."""
|
||||||
|
from cyclone.parsers.payer import PayerConfig
|
||||||
|
cfg = PayerConfig.co_medicaid()
|
||||||
|
assert "SKCO0" not in cfg.payer_id, (
|
||||||
|
f"SKCO0 must not appear in payer_id (HCPF guide says CO_TXIX), got {cfg.payer_id!r}"
|
||||||
|
)
|
||||||
|
assert "SKCO0" not in cfg.payer_name, (
|
||||||
|
f"SKCO0 must not appear in payer_name, got {cfg.payer_name!r}"
|
||||||
|
)
|
||||||
@@ -18,7 +18,7 @@ def test_parse_minimal_fixture_returns_one_claim():
|
|||||||
assert claim.subscriber.last_name == "Doe"
|
assert claim.subscriber.last_name == "Doe"
|
||||||
assert claim.subscriber.first_name == "John"
|
assert claim.subscriber.first_name == "John"
|
||||||
assert claim.subscriber.member_id == "ABC123"
|
assert claim.subscriber.member_id == "ABC123"
|
||||||
assert claim.payer.id == "SKCO0"
|
assert claim.payer.id == "CO_TXIX"
|
||||||
assert claim.claim.frequency_code == "1"
|
assert claim.claim.frequency_code == "1"
|
||||||
assert claim.claim.place_of_service == "12"
|
assert claim.claim.place_of_service == "12"
|
||||||
assert claim.claim.prior_auth == "PA123"
|
assert claim.claim.prior_auth == "PA123"
|
||||||
|
|||||||
@@ -8,8 +8,8 @@ def test_co_medicaid_defaults():
|
|||||||
assert cfg.allowed_claim_frequencies == {1, 7, 8}
|
assert cfg.allowed_claim_frequencies == {1, 7, 8}
|
||||||
assert cfg.require_ref_g1_for_adjustments is False # lenient in v1
|
assert cfg.require_ref_g1_for_adjustments is False # lenient in v1
|
||||||
assert cfg.allowed_bht06 == {"CH"}
|
assert cfg.allowed_bht06 == {"CH"}
|
||||||
assert cfg.payer_id == "SKCO0"
|
assert cfg.payer_id == "CO_TXIX"
|
||||||
assert cfg.payer_name == "COHCPF"
|
assert cfg.payer_name == "CO_TXIX"
|
||||||
assert cfg.no_patient_loop is True
|
assert cfg.no_patient_loop is True
|
||||||
assert cfg.encounter_claim_in_same_batch is False
|
assert cfg.encounter_claim_in_same_batch is False
|
||||||
|
|
||||||
|
|||||||
@@ -5,13 +5,13 @@ The endpoint is the payer-level aggregate that the drill-down UI's
|
|||||||
denial rate, and the top 5 NPIs by claim volume for one payer_id,
|
denial rate, and the top 5 NPIs by claim volume for one payer_id,
|
||||||
cached in-process for 60s.
|
cached in-process for 60s.
|
||||||
|
|
||||||
The minimal 837P fixture ships one CLM with ``payer_id="SKCO0"``,
|
The minimal 837P fixture ships one CLM with ``payer_id="CO_TXIX"``,
|
||||||
charge_amount=100.00; the minimal 835 carries one CLP for the same
|
charge_amount=100.00; the minimal 835 carries one CLP for the same
|
||||||
claim with total_paid=85.00. So ``/api/payers/SKCO0/summary`` returns
|
claim with total_paid=85.00. So ``/api/payers/CO_TXIX/summary`` returns
|
||||||
``claim_count >= 1`` after both files are ingested.
|
``claim_count >= 1`` after both files are ingested.
|
||||||
|
|
||||||
Note: the spec calls this ``payer_id`` (the X12 NM1*PR*PI qualifier,
|
Note: the spec calls this ``payer_id`` (the X12 NM1*PR*PI qualifier,
|
||||||
e.g. ``SKCO0``). It is NOT the configured payer name from
|
e.g. ``CO_TXIX``). It is NOT the configured payer name from
|
||||||
``config/payers.yaml``. The filter key in the store layer is
|
``config/payers.yaml``. The filter key in the store layer is
|
||||||
``Claim.payer_id`` — not the ``payer=`` substring filter used by
|
``Claim.payer_id`` — not the ``payer=`` substring filter used by
|
||||||
``/api/claims?payer=...``.
|
``/api/claims?payer=...``.
|
||||||
@@ -54,7 +54,7 @@ def client() -> TestClient:
|
|||||||
def seeded_db(client: TestClient):
|
def seeded_db(client: TestClient):
|
||||||
"""Ingest one minimal 837P + one minimal 835.
|
"""Ingest one minimal 837P + one minimal 835.
|
||||||
|
|
||||||
Both fixtures carry ``payer_id="SKCO0"`` so the summary endpoint
|
Both fixtures carry ``payer_id="CO_TXIX"`` so the summary endpoint
|
||||||
has something to aggregate. ``client`` is yielded back so the
|
has something to aggregate. ``client`` is yielded back so the
|
||||||
test can hit the API on the same TestClient that ingested the
|
test can hit the API on the same TestClient that ingested the
|
||||||
fixtures (parses share the per-test SQLite from conftest).
|
fixtures (parses share the per-test SQLite from conftest).
|
||||||
@@ -77,11 +77,11 @@ def seeded_db(client: TestClient):
|
|||||||
|
|
||||||
|
|
||||||
def test_payer_summary_happy_path(seeded_db: TestClient):
|
def test_payer_summary_happy_path(seeded_db: TestClient):
|
||||||
"""Seeded db has at least one claim for SKCO0 → 200 with the spec shape."""
|
"""Seeded db has at least one claim for CO_TXIX → 200 with the spec shape."""
|
||||||
resp = seeded_db.get("/api/payers/SKCO0/summary")
|
resp = seeded_db.get("/api/payers/CO_TXIX/summary")
|
||||||
assert resp.status_code == 200, resp.text
|
assert resp.status_code == 200, resp.text
|
||||||
data = resp.json()
|
data = resp.json()
|
||||||
assert data["payer_id"] == "SKCO0"
|
assert data["payer_id"] == "CO_TXIX"
|
||||||
assert "claim_count" in data
|
assert "claim_count" in data
|
||||||
assert "billed_total" in data
|
assert "billed_total" in data
|
||||||
assert "received_total" in data
|
assert "received_total" in data
|
||||||
@@ -99,8 +99,8 @@ def test_payer_summary_unknown_payer_returns_404(client: TestClient):
|
|||||||
|
|
||||||
def test_payer_summary_caches_then_invalidates(seeded_db: TestClient):
|
def test_payer_summary_caches_then_invalidates(seeded_db: TestClient):
|
||||||
"""Two back-to-back calls return identical payloads (in-process cache)."""
|
"""Two back-to-back calls return identical payloads (in-process cache)."""
|
||||||
resp1 = seeded_db.get("/api/payers/SKCO0/summary")
|
resp1 = seeded_db.get("/api/payers/CO_TXIX/summary")
|
||||||
resp2 = seeded_db.get("/api/payers/SKCO0/summary")
|
resp2 = seeded_db.get("/api/payers/CO_TXIX/summary")
|
||||||
assert resp1.status_code == 200
|
assert resp1.status_code == 200
|
||||||
assert resp2.status_code == 200
|
assert resp2.status_code == 200
|
||||||
assert resp1.json() == resp2.json()
|
assert resp1.json() == resp2.json()
|
||||||
@@ -45,7 +45,7 @@ full walker.
|
|||||||
|
|
||||||
## CO Medicaid specifics
|
## CO Medicaid specifics
|
||||||
|
|
||||||
- Trading partner: `SKCO0` (sender) ↔ `COHCPF` (receiver) on `NM1*PR` / `NM1*40`
|
- Trading partners: `COMEDASSISTPROG` (NM1*40 NM109, 1000B receiver) and `CO_TXIX` (NM1*PR NM109, 2010BB payer). See `docs/goodclaim.x12` for a canonical example and the HCPF 837P Companion Guide for the full segment table.
|
||||||
- `CLM05` is a composite of three components: place of service, facility code
|
- `CLM05` is a composite of three components: place of service, facility code
|
||||||
qualifier, and frequency code (in that order)
|
qualifier, and frequency code (in that order)
|
||||||
- `CLM05-1` = place of service (any valid CMS POS code)
|
- `CLM05-1` = place of service (any valid CMS POS code)
|
||||||
|
|||||||
@@ -9,11 +9,14 @@ Financing (HCPF) requires.
|
|||||||
|
|
||||||
| Role | 837P | 835 |
|
| Role | 837P | 835 |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| Sender | `SKCO0` | (varies; usually the clearinghouse) |
|
| Sender (submitter, `NM1*41`) | `Dzinesco` (TPID `11525703`) | (varies; usually the clearinghouse) |
|
||||||
| Receiver | `COHCPF` | (varies) |
|
| Receiver (`NM1*40`) | `COMEDASSISTPROG` | (varies) |
|
||||||
|
| Payer (`NM1*PR`) | `CO_TXIX` (per HCPF 837P Companion Guide) | (varies) |
|
||||||
|
|
||||||
These appear in the `NM1*PR` (payer) and `NM1*40` (receiver) segments of
|
These appear in the `NM1*PR` (payer) and `NM1*40` (receiver) segments of
|
||||||
the 837P file.
|
the 837P file. The legacy codes `SKCO0` / `COHCPF` are no longer
|
||||||
|
accepted by Gainwell (HCPF error: "2010BB NM109 must equal CO_TXIX or
|
||||||
|
CO_BHA"); see [SP33](../superpowers/specs/2026-07-02-cyclone-co-txix-payer-fix-design.md).
|
||||||
|
|
||||||
## dzinesco's TPID (clearinghouse identity)
|
## dzinesco's TPID (clearinghouse identity)
|
||||||
|
|
||||||
@@ -115,7 +118,7 @@ ls -la "./var/sftp/staging/CO XIX/PROD/coxix_prod_11525703/ToHPE/"
|
|||||||
|
|
||||||
### 837P (claims)
|
### 837P (claims)
|
||||||
|
|
||||||
- `NM1*PR N104 = "SKCO0"` (COHCPF)
|
- `NM1*PR NM108 = "PI"`, `NM1*PR NM109 = "CO_TXIX"` (per HCPF 837P Companion Guide, June 2025 — Version 2.5). dzinesco submits against this code under dzinesco TPID `11525703`. The legacy trading-partner ID `SKCO0` is no longer accepted as the payer identifier.
|
||||||
|
|
||||||
### 835 (remittance)
|
### 835 (remittance)
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user