feat(sp9): multi-payer, multi-NPI, SFTP stub for dzinesco/TOC
- providers table seeded with 3 NPIs (Montrose, Delta, Salida) - payers + payer_configs (per-tx config) join table - clearhouse singleton (dzinesco identity, SFTP block, filename block) - config/payers.yaml loader with Pydantic schema validation - cyclone/edi/filenames.py: HCPF X12 File Naming Standards helpers - cyclone/clearhouse/sftp.py: stub that copies to ./var/sftp/staging/... - cyclone/secrets.py: macOS Keychain wrapper via keyring - 11 new validation rules R200-R210 (CO MAP + HCPF naming) - 6 new API endpoints (/api/clearhouse/*, /api/config/*, /api/admin/reload-config) - migration 0007 - 72 new tests (646 total passing, was 574) See spec: docs/superpowers/specs/2026-06-20-cyclone-multi-payer-npi-sftp-design.md
This commit is contained in:
@@ -52,6 +52,7 @@ from cyclone.db import (
|
||||
from cyclone.parsers.models import ClaimOutput, ParseResult
|
||||
from cyclone.parsers.models_835 import ClaimPayment, ParseResult835
|
||||
from cyclone.parsers.payer import PayerConfig835
|
||||
from cyclone.providers import Clearhouse, Payer, Provider # SP9: ORM-row DTOs
|
||||
|
||||
|
||||
class AlreadyMatchedError(Exception):
|
||||
@@ -2082,6 +2083,256 @@ class CycloneStore:
|
||||
"deletedMatches": deleted_count,
|
||||
}
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# SP9: providers / payers / payer_configs / clearhouse
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def list_providers(self, *, is_active: bool | None = True) -> list[Provider]:
|
||||
"""List providers. ``is_active=None`` returns all."""
|
||||
from cyclone.db import Provider as ProviderORM
|
||||
from cyclone.providers import Provider
|
||||
with db.SessionLocal()() as s:
|
||||
q = s.query(ProviderORM)
|
||||
if is_active is not None:
|
||||
q = q.filter(ProviderORM.is_active == (1 if is_active else 0))
|
||||
rows = q.order_by(ProviderORM.label).all()
|
||||
return [Provider.model_validate(_provider_orm_to_dict(r)) for r in rows]
|
||||
|
||||
def get_provider(self, npi: str) -> Provider | None:
|
||||
from cyclone.db import Provider as ProviderORM
|
||||
from cyclone.providers import Provider
|
||||
with db.SessionLocal()() as s:
|
||||
row = s.get(ProviderORM, npi)
|
||||
return Provider.model_validate(_provider_orm_to_dict(row)) if row else None
|
||||
|
||||
def upsert_provider(self, provider: Provider) -> Provider:
|
||||
from cyclone.db import Provider as ProviderORM
|
||||
with db.SessionLocal()() as s:
|
||||
row = s.get(ProviderORM, provider.npi)
|
||||
now = utcnow().isoformat()
|
||||
if row is None:
|
||||
row = ProviderORM(
|
||||
npi=provider.npi, label=provider.label,
|
||||
legal_name=provider.legal_name, tax_id=provider.tax_id,
|
||||
taxonomy_code=provider.taxonomy_code,
|
||||
address_line1=provider.address_line1,
|
||||
address_line2=provider.address_line2,
|
||||
city=provider.city, state=provider.state, zip=provider.zip,
|
||||
is_active=1 if provider.is_active else 0,
|
||||
created_at=provider.created_at.isoformat(),
|
||||
updated_at=now,
|
||||
)
|
||||
s.add(row)
|
||||
else:
|
||||
row.label = provider.label
|
||||
row.legal_name = provider.legal_name
|
||||
row.tax_id = provider.tax_id
|
||||
row.taxonomy_code = provider.taxonomy_code
|
||||
row.address_line1 = provider.address_line1
|
||||
row.address_line2 = provider.address_line2
|
||||
row.city = provider.city
|
||||
row.state = provider.state
|
||||
row.zip = provider.zip
|
||||
row.is_active = 1 if provider.is_active else 0
|
||||
row.updated_at = now
|
||||
s.commit()
|
||||
return self.get_provider(provider.npi) # type: ignore[return-value]
|
||||
|
||||
def list_payers(self, *, is_active: bool | None = True) -> list[Payer]:
|
||||
from cyclone.db import Payer as PayerORM
|
||||
from cyclone.providers import Payer
|
||||
with db.SessionLocal()() as s:
|
||||
q = s.query(PayerORM)
|
||||
if is_active is not None:
|
||||
q = q.filter(PayerORM.is_active == (1 if is_active else 0))
|
||||
rows = q.order_by(PayerORM.payer_id).all()
|
||||
return [Payer.model_validate(_payer_orm_to_dict(r)) for r in rows]
|
||||
|
||||
def get_payer_config(self, payer_id: str, transaction_type: str) -> dict | None:
|
||||
from cyclone.db import PayerConfigORM
|
||||
with db.SessionLocal()() as s:
|
||||
row = s.get(PayerConfigORM, (payer_id, transaction_type))
|
||||
return dict(row.config_json) if row else None
|
||||
|
||||
def get_clearhouse(self) -> Clearhouse | None:
|
||||
from cyclone.db import ClearhouseORM
|
||||
from cyclone.providers import Clearhouse
|
||||
with db.SessionLocal()() as s:
|
||||
row = s.get(ClearhouseORM, 1)
|
||||
if row is None:
|
||||
return None
|
||||
return Clearhouse.model_validate({
|
||||
"id": 1,
|
||||
"name": row.name,
|
||||
"tpid": row.tpid,
|
||||
"submitter_name": row.submitter_name,
|
||||
"submitter_id_qual": row.submitter_id_qual,
|
||||
"submitter_contact_name": row.submitter_contact_name,
|
||||
"submitter_contact_email": row.submitter_contact_email,
|
||||
"filename_block": dict(row.filename_block_json),
|
||||
"sftp_block": dict(row.sftp_block_json),
|
||||
"updated_at": row.updated_at,
|
||||
})
|
||||
|
||||
def ensure_clearhouse_seeded(self) -> None:
|
||||
"""Insert the default clearhouse singleton + 3 providers + CO_TXIX payer
|
||||
if they don't exist. Idempotent. Called from the API lifespan."""
|
||||
from cyclone.db import ClearhouseORM, Payer as PayerORM, PayerConfigORM, Provider as ProviderORM
|
||||
from cyclone.providers import Clearhouse
|
||||
with db.SessionLocal()() as s:
|
||||
if s.get(ClearhouseORM, 1) is None:
|
||||
ch = Clearhouse(
|
||||
id=1,
|
||||
name="dzinesco",
|
||||
tpid="11525703",
|
||||
submitter_name="Dzinesco",
|
||||
submitter_id_qual="46",
|
||||
submitter_contact_name="Tyler Martinez",
|
||||
submitter_contact_email="tyler@dzinesco.com",
|
||||
filename_block={
|
||||
"tz": "America/Denver",
|
||||
"outbound_template": "{tpid}-{tx}-{ts_mt}-1of1.{ext}",
|
||||
"inbound_template": "TP{tpid}-{orig_tx}_M{tracking}-{ts}-1of1_{file_type}.x12",
|
||||
},
|
||||
sftp_block={
|
||||
"host": "mft.gainwelltechnologies.com",
|
||||
"port": 22,
|
||||
"username": "colorado-fts\\coxix_prod_11525703",
|
||||
"paths": {
|
||||
"outbound": "/CO XIX/PROD/coxix_prod_11525703/FromHPE",
|
||||
"inbound": "/CO XIX/PROD/coxix_prod_11525703/ToHPE",
|
||||
},
|
||||
"stub": True,
|
||||
"staging_dir": "./var/sftp/staging",
|
||||
"poll_seconds": 300,
|
||||
"auth": {"method": "keychain", "secret_ref": "sftp.gainwell.password"},
|
||||
},
|
||||
updated_at=utcnow(),
|
||||
)
|
||||
s.add(ClearhouseORM(
|
||||
id=1,
|
||||
name=ch.name,
|
||||
tpid=ch.tpid,
|
||||
submitter_name=ch.submitter_name,
|
||||
submitter_id_qual=ch.submitter_id_qual,
|
||||
submitter_contact_name=ch.submitter_contact_name,
|
||||
submitter_contact_email=ch.submitter_contact_email,
|
||||
filename_block_json=ch.filename_block.model_dump(),
|
||||
sftp_block_json=ch.sftp_block.model_dump(),
|
||||
updated_at=ch.updated_at.isoformat(),
|
||||
))
|
||||
|
||||
# Seed 3 providers (idempotent)
|
||||
from cyclone.providers import Provider
|
||||
now = utcnow().isoformat()
|
||||
for npi, label in [
|
||||
("1881068062", "Montrose"),
|
||||
("1851446637", "Delta"),
|
||||
("1467507269", "Salida"),
|
||||
]:
|
||||
if s.get(ProviderORM, npi) is None:
|
||||
s.add(ProviderORM(
|
||||
npi=npi,
|
||||
label=label,
|
||||
legal_name="TOC, Inc.",
|
||||
tax_id="721587149",
|
||||
taxonomy_code="251E00000X",
|
||||
address_line1="1100 East Main St",
|
||||
address_line2="Suite A",
|
||||
city="Montrose",
|
||||
state="CO",
|
||||
zip="814014063",
|
||||
is_active=1,
|
||||
created_at=now,
|
||||
updated_at=now,
|
||||
))
|
||||
|
||||
# Seed CO_TXIX payer (idempotent)
|
||||
if s.get(PayerORM, "CO_TXIX") is None:
|
||||
s.add(PayerORM(
|
||||
payer_id="CO_TXIX",
|
||||
name="Colorado Medical Assistance Program",
|
||||
receiver_name="COLORADO MEDICAL ASSISTANCE PROGRAM",
|
||||
receiver_id="COMEDASSISTPROG",
|
||||
is_active=1,
|
||||
created_at=now,
|
||||
updated_at=now,
|
||||
))
|
||||
# 837P config block
|
||||
s.add(PayerConfigORM(
|
||||
payer_id="CO_TXIX",
|
||||
transaction_type="837P",
|
||||
config_json={
|
||||
"submitter_name": "Dzinesco",
|
||||
"submitter_contact_name": "Tyler Martinez",
|
||||
"submitter_contact_email": "tyler@dzinesco.com",
|
||||
"receiver_name": "COLORADO MEDICAL ASSISTANCE PROGRAM",
|
||||
"receiver_id_qualifier": "46",
|
||||
"receiver_id": "COMEDASSISTPROG",
|
||||
"bht06_allowed": ["CH", "RP"],
|
||||
"bht06_default": "CH",
|
||||
"sbr09_default": "MC",
|
||||
"sbr09_allowed": ["MC", "16", "MA", "MB", "ZZ"],
|
||||
"payer_id_qualifier": "PI",
|
||||
"payer_id": "CO_TXIX",
|
||||
"pwk_supported": False,
|
||||
"cas_2320_group_allowed": False,
|
||||
"claim_type_codes": {"11": "Office", "12": "Home", "99": "Other"},
|
||||
},
|
||||
updated_at=now,
|
||||
))
|
||||
# 835 config block
|
||||
s.add(PayerConfigORM(
|
||||
payer_id="CO_TXIX",
|
||||
transaction_type="835",
|
||||
config_json={
|
||||
"expected_payer_tax_ids": [
|
||||
"81-1725341", "811725341", "84-0644739",
|
||||
"840644739", "1811725341",
|
||||
],
|
||||
"expected_payer_health_plan_id": "7912900843",
|
||||
"payer_name_pattern": "^CO_(TXIX|BHA)$",
|
||||
},
|
||||
updated_at=now,
|
||||
))
|
||||
|
||||
s.commit()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# SP9: ORM-to-Pydantic conversion helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _provider_orm_to_dict(row) -> dict:
|
||||
return {
|
||||
"npi": row.npi,
|
||||
"label": row.label,
|
||||
"legal_name": row.legal_name,
|
||||
"tax_id": row.tax_id,
|
||||
"taxonomy_code": row.taxonomy_code,
|
||||
"address_line1": row.address_line1,
|
||||
"address_line2": row.address_line2,
|
||||
"city": row.city,
|
||||
"state": row.state,
|
||||
"zip": row.zip,
|
||||
"is_active": bool(row.is_active),
|
||||
"created_at": row.created_at,
|
||||
"updated_at": row.updated_at,
|
||||
}
|
||||
|
||||
|
||||
def _payer_orm_to_dict(row) -> dict:
|
||||
return {
|
||||
"payer_id": row.payer_id,
|
||||
"name": row.name,
|
||||
"receiver_name": row.receiver_name,
|
||||
"receiver_id": row.receiver_id,
|
||||
"is_active": bool(row.is_active),
|
||||
"created_at": row.created_at,
|
||||
"updated_at": row.updated_at,
|
||||
}
|
||||
|
||||
|
||||
# Module-level singleton — same import path the old InMemoryStore used.
|
||||
store = CycloneStore()
|
||||
Reference in New Issue
Block a user