78ed75a31d
T3 — moves claims to ClaimState.REJECTED on 999 AK5 R/E/X. - New module: src/cyclone/inbox_state.py - New tests: tests/test_inbox_state.py (4 cases) - Adds ORM mappings for rejection_reason / rejected_at / resubmit_count / state_changed_at on Claim (db.py) — these were added to the schema by migration 0004 but not yet exposed to the ORM.
167 lines
5.1 KiB
Python
167 lines
5.1 KiB
Python
"""TDD: apply_999_rejections moves claims to REJECTED on 999 AK5 R/E.
|
|
|
|
Plan: docs/superpowers/plans/2026-06-20-cyclone-workflow-automation.md
|
|
Task 3 (T3).
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
from datetime import date, datetime, timezone
|
|
|
|
import pytest
|
|
|
|
from cyclone import db
|
|
from cyclone.db import Batch, Claim, ClaimState
|
|
from cyclone.inbox_state import apply_999_rejections
|
|
from cyclone.parsers.models import BatchSummary, Envelope
|
|
from cyclone.parsers.models_999 import (
|
|
AcknowledgmentHeader,
|
|
FunctionalGroupAck,
|
|
ParseResult999,
|
|
SetAcceptReject,
|
|
SetFunctionalGroupResponse,
|
|
)
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def _setup(tmp_path, monkeypatch):
|
|
monkeypatch.setenv("CYCLONE_DB_URL", f"sqlite:///{tmp_path}/inbox.db")
|
|
db._reset_for_tests()
|
|
db.init_db()
|
|
|
|
|
|
def _seed_batch() -> None:
|
|
with db.SessionLocal()() as s:
|
|
s.add(Batch(
|
|
id="B-1", kind="837p", input_filename="x.txt",
|
|
parsed_at=datetime.now(timezone.utc),
|
|
totals_json={"total_claims": 1},
|
|
validation_json={"passed": True, "warnings": [], "errors": []},
|
|
raw_result_json={"_": "stub"},
|
|
))
|
|
s.commit()
|
|
|
|
|
|
def _add_claim(*, claim_id: str, pcn: str, state: ClaimState = ClaimState.SUBMITTED) -> None:
|
|
_seed_batch()
|
|
with db.SessionLocal()() as s:
|
|
s.add(Claim(
|
|
id=claim_id, batch_id="B-1",
|
|
patient_control_number=pcn, state=state,
|
|
))
|
|
s.commit()
|
|
|
|
|
|
def _make_999(*, claim_refs: list[tuple[str, str]]) -> ParseResult999:
|
|
"""claim_refs: list of (set_control_number, accept_code)."""
|
|
set_responses = [
|
|
SetFunctionalGroupResponse(
|
|
ak2=AcknowledgmentHeader(
|
|
functional_id_code="837", group_control_number=ref,
|
|
),
|
|
set_control_number=ref,
|
|
transaction_set_identifier="837",
|
|
segment_errors=[],
|
|
set_accept_reject=SetAcceptReject(code=code), # type: ignore[arg-type]
|
|
)
|
|
for ref, code in claim_refs
|
|
]
|
|
return ParseResult999(
|
|
envelope=Envelope(
|
|
sender_id="RECEIVER", receiver_id="SUBMITTER",
|
|
control_number="000000001", transaction_date=date(2024, 1, 1),
|
|
implementation_guide="005010X231A1",
|
|
),
|
|
functional_group_acks=[
|
|
FunctionalGroupAck(
|
|
ak1=AcknowledgmentHeader(functional_id_code="HC", group_control_number="0001"),
|
|
received_count=len(set_responses), accepted_count=0,
|
|
rejected_count=len(set_responses), ack_code="R",
|
|
),
|
|
],
|
|
set_responses=set_responses,
|
|
summary=BatchSummary(
|
|
input_file="x.txt", total_claims=len(set_responses),
|
|
passed=0, failed=len(set_responses),
|
|
),
|
|
)
|
|
|
|
|
|
def _lookup_factory(session):
|
|
"""Build a claim_lookup closure that uses the given session.
|
|
|
|
The session must be the same one passed to apply_999_rejections so
|
|
that the returned Claim is attached to that session's transaction.
|
|
"""
|
|
def _lookup(pcn: str):
|
|
return session.query(Claim).filter_by(patient_control_number=pcn).first()
|
|
return _lookup
|
|
|
|
|
|
def test_rejection_moves_submitted_claim_to_rejected_state():
|
|
_add_claim(claim_id="CLP-1", pcn="PCN-001")
|
|
|
|
with db.SessionLocal()() as s:
|
|
result = apply_999_rejections(
|
|
s,
|
|
_make_999(claim_refs=[("PCN-001", "R")]),
|
|
claim_lookup=_lookup_factory(s),
|
|
)
|
|
s.commit()
|
|
|
|
assert result.matched == ["CLP-1"]
|
|
assert result.orphans == []
|
|
with db.SessionLocal()() as s:
|
|
c = s.get(Claim, "CLP-1")
|
|
assert c.state == ClaimState.REJECTED
|
|
assert c.rejected_at is not None
|
|
assert "999" in (c.rejection_reason or "")
|
|
|
|
|
|
def test_accepted_set_leaves_claim_alone():
|
|
_add_claim(claim_id="CLP-1", pcn="PCN-002")
|
|
|
|
with db.SessionLocal()() as s:
|
|
result = apply_999_rejections(
|
|
s,
|
|
_make_999(claim_refs=[("PCN-002", "A")]),
|
|
claim_lookup=_lookup_factory(s),
|
|
)
|
|
s.commit()
|
|
|
|
assert result.matched == []
|
|
with db.SessionLocal()() as s:
|
|
c = s.get(Claim, "CLP-1")
|
|
assert c.state == ClaimState.SUBMITTED
|
|
assert c.rejected_at is None
|
|
assert c.rejection_reason is None
|
|
|
|
|
|
def test_unknown_pcn_recorded_as_orphan_not_error():
|
|
with db.SessionLocal()() as s:
|
|
result = apply_999_rejections(
|
|
s,
|
|
_make_999(claim_refs=[("PCN-NONEXISTENT", "R")]),
|
|
claim_lookup=_lookup_factory(s),
|
|
)
|
|
s.commit()
|
|
|
|
assert result.matched == []
|
|
assert result.orphans == ["PCN-NONEXISTENT"]
|
|
|
|
|
|
def test_already_rejected_claim_is_idempotent():
|
|
_add_claim(claim_id="CLP-1", pcn="PCN-003", state=ClaimState.REJECTED)
|
|
|
|
with db.SessionLocal()() as s:
|
|
result = apply_999_rejections(
|
|
s,
|
|
_make_999(claim_refs=[("PCN-003", "R")]),
|
|
claim_lookup=_lookup_factory(s),
|
|
)
|
|
s.commit()
|
|
|
|
assert result.matched == [] # no-op
|
|
with db.SessionLocal()() as s:
|
|
c = s.get(Claim, "CLP-1")
|
|
assert c.state == ClaimState.REJECTED
|