feat(parsers): R034 REF*G1 enforcement + R035 BHT06 transaction type code (SP3 P1)
- R034 _r034_ref_g1_required: when payer opts in (require_ref_g1_for_adjustments),
emit error if frequency_code is 7/8 and no REF*G1 segment appears in raw_segments.
CO Medicaid stays lenient in v1 (gate is False by default).
- R035 _r035_bht06_allowed: BHT06 transaction type code must be in cfg.allowed_bht06.
Skipped silently when transaction_type_code is None.
- Add transaction_type_code (str|None) to Envelope and ClaimOutput.
- parse_837: read BHT06 (seg[6]) in _build_envelope; mirror onto each ClaimOutput
via the existing model_copy(update={...}) call site.
- 9 new tests in test_validator.py: 5 R034 (incl. lenient no-op) + 4 R035.
213 passed, 1 skipped (prodfile corpus present-conditional).
This commit is contained in:
@@ -109,6 +109,8 @@ class Envelope(_Base):
|
||||
transaction_date: date
|
||||
transaction_time: str | None = None
|
||||
implementation_guide: str | None = None
|
||||
# SP3 P1 T2: BHT06 transaction type code (was: transaction_set_purpose_code, which is BHT02).
|
||||
transaction_type_code: str | None = None
|
||||
|
||||
|
||||
class BatchSummary(_Base):
|
||||
@@ -135,6 +137,9 @@ class ClaimOutput(_Base):
|
||||
service_lines: list[ServiceLine] = Field(default_factory=list)
|
||||
validation: ValidationReport
|
||||
raw_segments: list[list[str]] = Field(default_factory=list)
|
||||
# SP3 P1 T2: BHT06 transaction type code (was: transaction_set_purpose_code, which is BHT02).
|
||||
# Mirrors envelope.transaction_type_code per parser convention.
|
||||
transaction_type_code: str | None = None
|
||||
|
||||
|
||||
class ParseResult(_Base):
|
||||
|
||||
@@ -77,6 +77,9 @@ def _build_envelope(segments: list[list[str]], input_file: str = "") -> tuple[En
|
||||
envelope = envelope.model_copy(update={"transaction_date": _parse_isa_date(seg[4])})
|
||||
if len(seg) > 5:
|
||||
envelope = envelope.model_copy(update={"transaction_time": seg[5]})
|
||||
# SP3 P1 T2: BHT06 = transaction type code (CH/RP/etc).
|
||||
if len(seg) > 6:
|
||||
envelope = envelope.model_copy(update={"transaction_type_code": seg[6]})
|
||||
except (IndexError, ValueError) as exc:
|
||||
log.warning("Could not parse BHT date: %s", exc)
|
||||
elif seg[0] == "ST" and envelope is not None:
|
||||
@@ -359,6 +362,9 @@ def parse(text: str, payer_config: PayerConfig, input_file: str = "") -> ParseRe
|
||||
"billing_provider": provider,
|
||||
"subscriber": subscriber,
|
||||
"payer": payer,
|
||||
# SP3 P1 T2: mirror BHT06 transaction type code onto each claim
|
||||
# so per-claim validators (R035) can check it without re-reading the envelope.
|
||||
"transaction_type_code": envelope.transaction_type_code,
|
||||
})
|
||||
# Run validation
|
||||
report = validate(claim, payer_config)
|
||||
|
||||
@@ -57,6 +57,55 @@ def _r031_ref_g1_optional(claim: ClaimOutput, _: PayerConfig) -> Iterable[Valida
|
||||
return ()
|
||||
|
||||
|
||||
def _r034_ref_g1_required(claim: ClaimOutput, cfg: PayerConfig) -> Iterable[ValidationIssue]:
|
||||
"""REF*G1 is required on adjustment claims (frequency 7/8) when the payer opts in.
|
||||
|
||||
Gated on :attr:`PayerConfig.require_ref_g1_for_adjustments` — when the
|
||||
payer keeps the lenient v1 default (``False``), this rule is a no-op even
|
||||
for frequency 7/8 claims. Strict payers (e.g. CO Medicaid once it flips
|
||||
in a future version) get the error so they can surface the missing segment
|
||||
in their validation reports.
|
||||
"""
|
||||
if not cfg.require_ref_g1_for_adjustments:
|
||||
return
|
||||
freq = claim.claim.frequency_code
|
||||
if freq not in {"7", "8"}:
|
||||
return
|
||||
has_ref_g1 = any(
|
||||
len(seg) >= 2 and seg[0] == "REF" and seg[1] == "G1"
|
||||
for seg in claim.raw_segments
|
||||
)
|
||||
if not has_ref_g1:
|
||||
yield ValidationIssue(
|
||||
rule="R034_ref_g1_required",
|
||||
severity="error",
|
||||
message=(
|
||||
f"Adjustment claim (frequency={freq}) for payer {cfg.name} requires REF*G1 "
|
||||
"segment with prior authorization number"
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def _r035_bht06_allowed(claim: ClaimOutput, cfg: PayerConfig) -> Iterable[ValidationIssue]:
|
||||
"""BHT06 (transaction type code, e.g. CH/RP) must be in the payer's allow-list.
|
||||
|
||||
Skipped silently when ``transaction_type_code`` is missing — older fixtures
|
||||
and EDI streams without a BHT06 element should not produce a spurious error.
|
||||
"""
|
||||
code = claim.transaction_type_code
|
||||
if not code:
|
||||
return
|
||||
if code not in cfg.allowed_bht06:
|
||||
yield ValidationIssue(
|
||||
rule="R035_bht06_allowed",
|
||||
severity="error",
|
||||
message=(
|
||||
f"BHT06 transaction type code {code!r} not in {sorted(cfg.allowed_bht06)} "
|
||||
f"for payer {cfg.name}"
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def _r032_clm05_2_facility_qualifier(claim: ClaimOutput, cfg: PayerConfig) -> Iterable[ValidationIssue]:
|
||||
"""CLM05-2 Facility Code Qualifier must be recognized for the payer.
|
||||
|
||||
@@ -126,6 +175,8 @@ _RULES: list[Rule] = [
|
||||
_r020_npi_format,
|
||||
_r030_frequency_allowed,
|
||||
_r031_ref_g1_optional,
|
||||
_r034_ref_g1_required,
|
||||
_r035_bht06_allowed,
|
||||
_r032_clm05_2_facility_qualifier,
|
||||
_r033_clm05_1_place_of_service_code,
|
||||
_r050_diagnosis_present,
|
||||
|
||||
@@ -193,3 +193,123 @@ def test_r033_errors_with_invalid_pos_code():
|
||||
report = validate(claim, cfg)
|
||||
assert any(i.rule == "R033_clm05_1_place_of_service_code" and i.severity == "error" for i in report.errors)
|
||||
assert report.passed is False
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# R034 — REF*G1 enforcement (SP3 Phase 1)
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
def _strict_cfg() -> PayerConfig:
|
||||
"""Strict cfg that mirrors the CO Medicaid defaults but turns on R034 enforcement.
|
||||
|
||||
We don't modify :meth:`PayerConfig.co_medicaid` — the lenient default stays
|
||||
in v1. This local fixture lets us exercise the strict path without leaking
|
||||
config changes across the suite.
|
||||
"""
|
||||
return PayerConfig(
|
||||
name="StrictTest",
|
||||
require_ref_g1_for_adjustments=True,
|
||||
allowed_bht06={"CH"},
|
||||
payer_id="X",
|
||||
)
|
||||
|
||||
|
||||
def test_r034_ref_g1_required_freq_7_no_ref_g1_errors():
|
||||
cfg = _strict_cfg()
|
||||
claim = _build_claim()
|
||||
claim.claim.frequency_code = "7"
|
||||
claim.raw_segments = []
|
||||
report = validate(claim, cfg)
|
||||
assert any(
|
||||
i.rule == "R034_ref_g1_required" and i.severity == "error" for i in report.errors
|
||||
)
|
||||
assert report.passed is False
|
||||
|
||||
|
||||
def test_r034_ref_g1_required_freq_8_no_ref_g1_errors():
|
||||
cfg = _strict_cfg()
|
||||
claim = _build_claim()
|
||||
claim.claim.frequency_code = "8"
|
||||
claim.raw_segments = []
|
||||
report = validate(claim, cfg)
|
||||
assert any(
|
||||
i.rule == "R034_ref_g1_required" and i.severity == "error" for i in report.errors
|
||||
)
|
||||
assert report.passed is False
|
||||
|
||||
|
||||
def test_r034_ref_g1_required_freq_7_with_ref_g1_passes():
|
||||
cfg = _strict_cfg()
|
||||
claim = _build_claim()
|
||||
claim.claim.frequency_code = "7"
|
||||
claim.raw_segments = [["REF", "G1", "12345"]]
|
||||
report = validate(claim, cfg)
|
||||
assert not any(
|
||||
i.rule == "R034_ref_g1_required" for i in report.errors + report.warnings
|
||||
)
|
||||
|
||||
|
||||
def test_r034_ref_g1_required_freq_1_no_ref_g1_passes():
|
||||
cfg = _strict_cfg()
|
||||
claim = _build_claim()
|
||||
claim.claim.frequency_code = "1"
|
||||
claim.raw_segments = []
|
||||
report = validate(claim, cfg)
|
||||
assert not any(
|
||||
i.rule == "R034_ref_g1_required" for i in report.errors + report.warnings
|
||||
)
|
||||
|
||||
|
||||
def test_r034_ref_g1_lenient_cfg_never_errors():
|
||||
cfg = PayerConfig.co_medicaid() # require_ref_g1_for_adjustments=False (lenient v1)
|
||||
claim = _build_claim()
|
||||
claim.claim.frequency_code = "7"
|
||||
claim.raw_segments = []
|
||||
report = validate(claim, cfg)
|
||||
assert not any(
|
||||
i.rule == "R034_ref_g1_required" for i in report.errors + report.warnings
|
||||
)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# R035 — BHT06 transaction type code (SP3 Phase 1)
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
def test_r035_bht06_allowed_ch_passes():
|
||||
cfg = PayerConfig.co_medicaid()
|
||||
claim = _build_claim(transaction_type_code="CH")
|
||||
report = validate(claim, cfg)
|
||||
assert not any(
|
||||
i.rule == "R035_bht06_allowed" for i in report.errors + report.warnings
|
||||
)
|
||||
|
||||
|
||||
def test_r035_bht06_allowed_rp_errors_for_co_medicaid():
|
||||
cfg = PayerConfig.co_medicaid()
|
||||
claim = _build_claim(transaction_type_code="RP")
|
||||
report = validate(claim, cfg)
|
||||
assert any(
|
||||
i.rule == "R035_bht06_allowed" and i.severity == "error" for i in report.errors
|
||||
)
|
||||
assert report.passed is False
|
||||
|
||||
|
||||
def test_r035_bht06_missing_skips():
|
||||
cfg = PayerConfig.co_medicaid()
|
||||
claim = _build_claim(transaction_type_code=None)
|
||||
assert claim.transaction_type_code is None
|
||||
report = validate(claim, cfg)
|
||||
assert not any(
|
||||
i.rule == "R035_bht06_allowed" for i in report.errors + report.warnings
|
||||
)
|
||||
|
||||
|
||||
def test_r035_bht06_rp_allowed_for_generic_837p():
|
||||
cfg = PayerConfig.generic_837p() # allows {"CH", "RP"}
|
||||
claim = _build_claim(transaction_type_code="RP")
|
||||
report = validate(claim, cfg)
|
||||
assert not any(
|
||||
i.rule == "R035_bht06_allowed" for i in report.errors + report.warnings
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user