"""Business rules and orchestration for repair workflows.""" from uuid import UUID from app.repairs.exceptions import ( ActiveRepairConflict, IncompatibleMinerLifecycle, InconsistentRepairState, RepairActiveMismatch, RepairActorRequired, RepairCompletionActorRequired, RepairMinerNotFound, RepairMinerNotInRepair, RepairNotActive, RepairNotFound, RepairPersistenceError, ) from app.repairs.models import ( BeginRepairRequest, BeginRepairResult, InitialRepairNotePayload, RepairActor, RepairCompleteRequest, RepairCompletedPayload, RepairDetail, RepairTimeline, RepairTimelineEvent, RepairStartedPayload, ) from app.repairs.repository import RepairRepository ELIGIBLE_LIFECYCLE_STATUSES = frozenset({"active", "spare"}) ACTIVE_REPAIR_STATUSES = frozenset( {"OPEN", "IN_PROGRESS", "WAITING_FOR_PARTS", "READY_FOR_TEST"} ) REPAIR_LIFECYCLE_STATUS = "in_repair" class RepairService: """Explicit operational repair workflows independent of FastAPI.""" def __init__(self, repository: RepairRepository): self.repository = repository @staticmethod def normalize_note(note: str | None) -> str | None: if note is None: return None normalized = str(note).strip() return normalized or None @staticmethod def _map_repair_detail(repair: dict) -> RepairDetail: closed_by = None if repair["closed_by_user_id"] is not None: closed_by = RepairActor( user_id=repair["closed_by_user_id"], display_name=repair["closed_by_display_name"], ) return RepairDetail( repair_id=repair["repair_id"], miner_id=repair["miner_id"], miner_asset_id=repair["miner_asset_id"], status=repair["status"], reason=repair["reason"], opened_at=repair["opened_at"], opened_by=RepairActor( user_id=repair["opened_by_user_id"], display_name=repair["opened_by_display_name"], ), closed_at=repair["closed_at"], closed_by=closed_by, miner_lifecycle=repair["miner_lifecycle"], latest_event_sequence=repair["latest_event_sequence"], ) def begin_repair( self, request: BeginRepairRequest, *, actor_user_id: UUID | None, ) -> BeginRepairResult: if actor_user_id is None: raise RepairActorRequired() note = self.normalize_note(request.notes) with self.repository.transaction() as repository: miner = repository.lock_miner_for_update(request.miner_id) if not miner: raise RepairMinerNotFound(request.miner_id) lifecycle_status = str(miner["lifecycle_status"]) active_repair = repository.get_active_repair(request.miner_id) if active_repair: raise ActiveRepairConflict(request.miner_id) if lifecycle_status == REPAIR_LIFECYCLE_STATUS: raise InconsistentRepairState(request.miner_id) if lifecycle_status not in ELIGIBLE_LIFECYCLE_STATUSES: raise IncompatibleMinerLifecycle( request.miner_id, lifecycle_status, ) repair = repository.create_repair( request.miner_id, request.reason, actor_user_id, ) started_payload = RepairStartedPayload( reason=request.reason, from_lifecycle=lifecycle_status, ) repository.create_event( repair["id"], sequence=1, event_type="REPAIR_STARTED", created_by=actor_user_id, payload=started_payload.model_dump(mode="json"), ) if note is not None: note_payload = InitialRepairNotePayload(note=note) repository.create_event( repair["id"], sequence=2, event_type="INITIAL_NOTE", created_by=actor_user_id, payload=note_payload.model_dump(mode="json"), ) repository.set_miner_lifecycle( request.miner_id, REPAIR_LIFECYCLE_STATUS, ) return BeginRepairResult( repair_id=repair["id"], miner_id=repair["miner_id"], status=repair["status"], reason=repair["reason"], opened_at=repair["opened_at"], opened_by=repair["opened_by"], ) def complete_repair( self, repair_id: UUID, request: RepairCompleteRequest, *, actor_user_id: UUID | None, ) -> RepairDetail: if actor_user_id is None: raise RepairCompletionActorRequired() completion_summary = self.normalize_note(request.completion_summary) with self.repository.transaction() as repository: repair_identity = repository.get_repair_identity(repair_id) if repair_identity is None: raise RepairNotFound(repair_id) miner_id = repair_identity["miner_id"] # Begin Repair also locks the miner first. Keeping this order makes # competing lifecycle workflows serialize on the durable asset. miner = repository.lock_miner_for_update(miner_id) if miner is None: raise RepairMinerNotFound(miner_id) repair = repository.lock_repair_for_update(repair_id) if repair is None: raise RepairNotFound(repair_id) if repair["miner_id"] != miner_id: raise RepairActiveMismatch(repair_id, miner_id) if repair["status"] not in ACTIVE_REPAIR_STATUSES: raise RepairNotActive(repair_id, repair["status"]) active_repair = repository.get_active_repair(miner_id) if active_repair is None or active_repair["id"] != repair_id: raise RepairActiveMismatch(repair_id, miner_id) lifecycle_status = str(miner["lifecycle_status"]) if lifecycle_status != REPAIR_LIFECYCLE_STATUS: raise RepairMinerNotInRepair(miner_id, lifecycle_status) next_sequence = repository.get_next_event_sequence(repair_id) pre_repair_lifecycle = repository.get_pre_repair_lifecycle( repair_id ) completed = repository.complete_repair( repair_id, completed_by=actor_user_id, completion_summary=completion_summary, resulting_lifecycle=request.target_lifecycle, ) if completed is None: raise RepairNotActive(repair_id, repair["status"]) repository.set_miner_lifecycle( miner_id, request.target_lifecycle, ) payload = RepairCompletedPayload( resulting_miner_lifecycle=request.target_lifecycle, pre_repair_lifecycle=pre_repair_lifecycle, completion_summary=completion_summary, ) repository.create_event( repair_id, sequence=next_sequence, event_type="REPAIR_COMPLETED", created_by=actor_user_id, payload=payload.model_dump(mode="json", exclude_none=True), ) detail = repository.get_repair_detail(repair_id) if detail is None: raise RepairPersistenceError( "Completed repair could not be read in its transaction" ) return self._map_repair_detail(detail) def get_repair(self, repair_id: UUID) -> RepairDetail: repair = self.repository.get_repair_detail(repair_id) if repair is None: raise RepairNotFound(repair_id) return self._map_repair_detail(repair) def get_repair_timeline(self, repair_id: UUID) -> RepairTimeline: events = self.repository.get_repair_events(repair_id) if events is None: raise RepairNotFound(repair_id) return RepairTimeline( repair_id=repair_id, events=[ RepairTimelineEvent( event_id=event["event_id"], sequence=event["sequence"], event_type=event["event_type"], actor=RepairActor( user_id=event["actor_user_id"], display_name=event["actor_display_name"], ), occurred_at=event["occurred_at"], payload=event["payload"], ) for event in events ], )