claimflow-api / app /workflow /state_machine.py
Minifigures's picture
feat: ClaimFlow API demo backend
ceea9e1 verified
Raw
History Blame Contribute Delete
5.05 kB
"""Workflow state machine, the single source of truth for claim transitions.
Every transition mutates the claim, records a Decision row, and appends a
hash-chained audit event in the caller's transaction (caller commits).
"""
from sqlalchemy.orm import Session
from app.claimguard import audit
from app.models import AuditEventType, Claim, ClaimAction, ClaimState, Decision, Role, User
TERMINAL_STATES = {ClaimState.APPROVED, ClaimState.REJECTED}
SYSTEM_ROLE = "system"
TRANSITIONS: dict[tuple[ClaimState, ClaimAction], tuple[ClaimState, frozenset[str]]] = {
(ClaimState.SUBMITTED, ClaimAction.IMAGING_COMPLETE): (
ClaimState.IMAGING_REVIEW,
frozenset({SYSTEM_ROLE}),
),
(ClaimState.IMAGING_REVIEW, ClaimAction.FORWARD): (
ClaimState.SPECIALIST_REVIEW,
frozenset({Role.IMAGING_SPECIALIST.value}),
),
(ClaimState.IMAGING_REVIEW, ClaimAction.RETURN_TO_CLAIMANT): (
ClaimState.RETURNED_TO_CLAIMANT,
frozenset({Role.IMAGING_SPECIALIST.value}),
),
(ClaimState.RETURNED_TO_CLAIMANT, ClaimAction.RESUBMIT): (
ClaimState.SUBMITTED,
frozenset({Role.CLAIMANT.value}),
),
(ClaimState.SPECIALIST_REVIEW, ClaimAction.SEND_TO_INSURER): (
ClaimState.ADJUDICATION,
frozenset({Role.MEDICAL_SPECIALIST.value}),
),
(ClaimState.SPECIALIST_REVIEW, ClaimAction.REQUEST_FURTHER_TESTING): (
ClaimState.PENDING_FURTHER_TESTING,
frozenset({Role.MEDICAL_SPECIALIST.value}),
),
(ClaimState.PENDING_FURTHER_TESTING, ClaimAction.RESUBMIT): (
ClaimState.SUBMITTED,
frozenset({Role.CLAIMANT.value}),
),
(ClaimState.ADJUDICATION, ClaimAction.APPROVE): (
ClaimState.APPROVED,
frozenset({Role.INSURANCE_AGENT.value}),
),
(ClaimState.ADJUDICATION, ClaimAction.REJECT): (
ClaimState.REJECTED,
frozenset({Role.INSURANCE_AGENT.value}),
),
}
class TransitionError(Exception):
"""A workflow action was not permitted; carries a human-readable reason."""
def __init__(self, reason: str) -> None:
super().__init__(reason)
self.reason = reason
def record_initial_submit(session: Session, claim: Claim, actor: User) -> Decision:
"""Record the SUBMIT decision for a freshly created claim already saved as SUBMITTED."""
if actor.role is not Role.CLAIMANT or actor.id != claim.claimant_id:
raise TransitionError("only the claim's claimant can submit it")
decision = Decision(
claim_id=claim.id,
actor_id=actor.id,
actor_role=actor.role.value,
action=ClaimAction.SUBMIT,
from_state=None,
to_state=ClaimState.SUBMITTED,
)
session.add(decision)
audit.append(
session,
AuditEventType.WORKFLOW_TRANSITION,
claim_id=claim.id,
actor_user_id=actor.id,
actor_role=actor.role.value,
payload={
"action": ClaimAction.SUBMIT.value,
"from": None,
"to": ClaimState.SUBMITTED.value,
},
)
session.flush()
return decision
def apply_transition(
session: Session,
claim: Claim,
action: ClaimAction,
*,
actor: User | None,
note: str | None = None,
) -> Decision:
if claim.state in TERMINAL_STATES:
raise TransitionError(
f"claim is in terminal state {claim.state.value}; no further actions are allowed"
)
transition = TRANSITIONS.get((claim.state, action))
if transition is None:
raise TransitionError(f"action {action.value} not allowed from state {claim.state.value}")
to_state, allowed_roles = transition
role_value = SYSTEM_ROLE if actor is None else actor.role.value
if role_value not in allowed_roles:
raise TransitionError(
f"role {role_value} may not perform {action.value} from state {claim.state.value}"
)
if actor is not None and role_value == Role.CLAIMANT.value and actor.id != claim.claimant_id:
raise TransitionError(f"only the claim's claimant may perform {action.value}")
from_state = claim.state
claim.state = to_state
decision = Decision(
claim_id=claim.id,
actor_id=actor.id if actor is not None else None,
actor_role=role_value,
action=action,
from_state=from_state,
to_state=to_state,
note=note,
)
session.add(decision)
audit.append(
session,
AuditEventType.WORKFLOW_TRANSITION,
claim_id=claim.id,
actor_user_id=actor.id if actor is not None else None,
actor_role=role_value,
payload={
"action": action.value,
"from": from_state.value,
"to": to_state.value,
"note": note,
},
)
session.flush()
return decision
def allowed_actions(state: ClaimState, role_value: str) -> set[ClaimAction]:
return {
action
for (from_state, action), (_, roles) in TRANSITIONS.items()
if from_state == state and role_value in roles
}