diff --git a/backend/src/cyclone/parsers/models.py b/backend/src/cyclone/parsers/models.py index 7a2e1a4..7c66e3a 100644 --- a/backend/src/cyclone/parsers/models.py +++ b/backend/src/cyclone/parsers/models.py @@ -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): diff --git a/backend/src/cyclone/parsers/parse_837.py b/backend/src/cyclone/parsers/parse_837.py index db0842e..e05c5b0 100644 --- a/backend/src/cyclone/parsers/parse_837.py +++ b/backend/src/cyclone/parsers/parse_837.py @@ -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) diff --git a/backend/src/cyclone/parsers/validator.py b/backend/src/cyclone/parsers/validator.py index 9227798..f0cfdc8 100644 --- a/backend/src/cyclone/parsers/validator.py +++ b/backend/src/cyclone/parsers/validator.py @@ -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, diff --git a/backend/tests/test_validator.py b/backend/tests/test_validator.py index f4de62a..24d52ca 100644 --- a/backend/tests/test_validator.py +++ b/backend/tests/test_validator.py @@ -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 + )