feat(sp6): apply_999_rejections state helper
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.
This commit is contained in:
@@ -195,9 +195,19 @@ class Claim(Base):
|
|||||||
state: Mapped[ClaimState] = mapped_column(
|
state: Mapped[ClaimState] = mapped_column(
|
||||||
Enum(ClaimState, native_enum=False), nullable=False, default=ClaimState.SUBMITTED
|
Enum(ClaimState, native_enum=False), nullable=False, default=ClaimState.SUBMITTED
|
||||||
)
|
)
|
||||||
|
state_changed_at: Mapped[Optional[datetime]] = mapped_column(
|
||||||
|
DateTime(timezone=True), nullable=True
|
||||||
|
)
|
||||||
state_before_reversal: Mapped[Optional[ClaimState]] = mapped_column(
|
state_before_reversal: Mapped[Optional[ClaimState]] = mapped_column(
|
||||||
Enum(ClaimState, native_enum=False), nullable=True
|
Enum(ClaimState, native_enum=False), nullable=True
|
||||||
)
|
)
|
||||||
|
rejection_reason: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
|
||||||
|
rejected_at: Mapped[Optional[datetime]] = mapped_column(
|
||||||
|
DateTime(timezone=True), nullable=True
|
||||||
|
)
|
||||||
|
resubmit_count: Mapped[int] = mapped_column(
|
||||||
|
Integer, nullable=False, default=0, server_default=text("0")
|
||||||
|
)
|
||||||
matched_remittance_id: Mapped[Optional[str]] = mapped_column(
|
matched_remittance_id: Mapped[Optional[str]] = mapped_column(
|
||||||
# ORM policy: SET NULL on remittance delete. The claim may outlive its
|
# ORM policy: SET NULL on remittance delete. The claim may outlive its
|
||||||
# remittance during reversal/reimport flows; without this, deleting a
|
# remittance during reversal/reimport flows; without this, deleting a
|
||||||
|
|||||||
@@ -0,0 +1,76 @@
|
|||||||
|
"""999 ACK → claim state transitions for the Inbox Rejected lane.
|
||||||
|
|
||||||
|
SP6 T3: apply_999_rejections.
|
||||||
|
For each set response with code R or E, look up the matching claim and
|
||||||
|
move it to REJECTED. Idempotent on already-rejected claims.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from typing import Callable
|
||||||
|
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from cyclone.db import Claim, ClaimState
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Apply999Result:
|
||||||
|
matched: list[str] = field(default_factory=list) # claim ids transitioned
|
||||||
|
orphans: list[str] = field(default_factory=list) # PCNs we couldn't resolve
|
||||||
|
|
||||||
|
|
||||||
|
def _build_reason(code: str, segment_error_count: int) -> str:
|
||||||
|
parts = [f"999 AK5 set-level {code}"]
|
||||||
|
if segment_error_count:
|
||||||
|
parts.append(f"{segment_error_count} segment error(s)")
|
||||||
|
return "; ".join(parts)
|
||||||
|
|
||||||
|
|
||||||
|
def apply_999_rejections(
|
||||||
|
session: Session,
|
||||||
|
parsed_999,
|
||||||
|
*,
|
||||||
|
claim_lookup: Callable[[str], Claim | None],
|
||||||
|
) -> Apply999Result:
|
||||||
|
"""For each set response with code R or E, look up the matching claim and
|
||||||
|
move it to REJECTED. Idempotent on already-rejected claims.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
session: SQLAlchemy session.
|
||||||
|
parsed_999: a ParseResult999 (or any object with .set_responses).
|
||||||
|
claim_lookup: callable from patient_control_number → Claim or None.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Apply999Result with lists of matched claim ids and orphan PCNs.
|
||||||
|
"""
|
||||||
|
result = Apply999Result()
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
|
||||||
|
for sr in parsed_999.set_responses:
|
||||||
|
code = sr.set_accept_reject.code
|
||||||
|
if code not in ("R", "E", "X"):
|
||||||
|
continue
|
||||||
|
|
||||||
|
claim = claim_lookup(sr.set_control_number)
|
||||||
|
if claim is None:
|
||||||
|
result.orphans.append(sr.set_control_number)
|
||||||
|
continue
|
||||||
|
|
||||||
|
if claim.state == ClaimState.REJECTED:
|
||||||
|
# Idempotent: don't double-mutate.
|
||||||
|
continue
|
||||||
|
|
||||||
|
claim.state = ClaimState.REJECTED
|
||||||
|
claim.state_changed_at = now
|
||||||
|
claim.rejected_at = now
|
||||||
|
claim.rejection_reason = _build_reason(
|
||||||
|
code, len(sr.segment_errors or [])
|
||||||
|
)
|
||||||
|
result.matched.append(claim.id)
|
||||||
|
|
||||||
|
if result.matched or result.orphans:
|
||||||
|
session.commit()
|
||||||
|
|
||||||
|
return result
|
||||||
@@ -0,0 +1,166 @@
|
|||||||
|
"""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
|
||||||
Reference in New Issue
Block a user