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(
|
||||
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
|
||||
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user