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:
Tyler
2026-06-20 18:25:25 -06:00
parent 1b1534d8d2
commit 78ed75a31d
3 changed files with 252 additions and 0 deletions
+10
View File
@@ -195,9 +195,19 @@ class Claim(Base):
state: Mapped[ClaimState] = mapped_column(
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(
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(
# ORM policy: SET NULL on remittance delete. The claim may outlive its
# remittance during reversal/reimport flows; without this, deleting a
+76
View File
@@ -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