"""Business rules and orchestration for repair workflows.""" from uuid import UUID import re from app.repairs.exceptions import ( ActiveRepairConflict, ControlBoardReplacementActorRequired, ControlBoardReplacementConflict, InvalidMacAddress, IncompatibleMinerLifecycle, InconsistentRepairState, RepairActiveMismatch, RepairActorRequired, RepairCompletionActorRequired, RepairMinerNotFound, RepairMinerNotInRepair, RepairNotActive, RepairNotFound, RepairPersistenceError, ) from app.repairs.models import ( BeginRepairRequest, ControlBoardReplacedPayload, ControlBoardReplacementRequest, ControlBoardReplacementResult, 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"], ) @staticmethod def normalize_optional_text(value: str | None) -> str | None: if value is None: return None normalized = str(value).strip() return normalized or None @staticmethod def normalize_mac_address(value: str) -> tuple[str, str]: raw = str(value).strip() if not raw or re.fullmatch(r"[0-9A-Fa-f:\-.\s]+", raw) is None: raise InvalidMacAddress() normalized = re.sub(r"[^0-9A-Fa-f]", "", raw).lower() if len(normalized) != 12: raise InvalidMacAddress() first_octet = int(normalized[:2], 16) if normalized == "000000000000": raise InvalidMacAddress("All-zero MAC addresses are not allowed") if normalized == "ffffffffffff": raise InvalidMacAddress("Broadcast MAC addresses are not allowed") if first_octet & 1: raise InvalidMacAddress("Multicast MAC addresses are not allowed") canonical = ":".join( normalized[index:index + 2].upper() for index in range(0, 12, 2) ) return canonical, normalized def replace_control_board( self, miner_id: UUID, request: ControlBoardReplacementRequest, *, actor_user_id: UUID | None ) -> ControlBoardReplacementResult: if actor_user_id is None: raise ControlBoardReplacementActorRequired() new_mac, new_mac_normalized = self.normalize_mac_address( request.new_mac_address ) new_serial = self.normalize_optional_text( request.new_control_board_serial ) board_model = self.normalize_optional_text(request.board_model) board_revision = self.normalize_optional_text(request.board_revision) firmware_version = self.normalize_optional_text(request.firmware_version) notes = self.normalize_note(request.notes) with self.repository.transaction() as repository: miner = repository.lock_miner_for_control_board_replacement(miner_id) if miner is None: raise RepairMinerNotFound(miner_id) if str(miner["lifecycle_status"]) != REPAIR_LIFECYCLE_STATUS: raise RepairMinerNotInRepair( miner_id, miner["lifecycle_status"] ) repair = repository.lock_active_repair_for_miner(miner_id) if repair is None: raise ControlBoardReplacementConflict( "Miner has no active repair" ) current_board = repository.lock_current_control_board(miner_id) if current_board is None: if not miner.get("current_mac") and not miner.get("control_board_sn"): raise ControlBoardReplacementConflict( "Miner has no current control-board identity to replace" ) current_board = repository.create_legacy_control_board(miner) old_mac = current_board.get("mac_address") or miner.get("current_mac") old_mac_canonical = None old_mac_normalized = None if old_mac: try: old_mac_canonical, old_mac_normalized = ( self.normalize_mac_address(str(old_mac)) ) except InvalidMacAddress: old_mac_canonical = str(old_mac) if old_mac_normalized == new_mac_normalized: raise ControlBoardReplacementConflict( "New MAC address is the same as the current MAC address" ) if old_mac_normalized is not None: repository.ensure_network_identity_history( miner_id, mac_address=old_mac_canonical, normalized_mac=old_mac_normalized ) repository.claim_mac_ownership( new_mac_normalized, miner_id, actor_user_id ) previous_board = repository.close_control_board( current_board["id"], repair_id=repair["id"], actor_user_id=actor_user_id, disposition=request.removed_board_disposition, ) installation = repository.create_control_board_installation( miner_id=miner_id, repair_id=repair["id"], mac_address=new_mac, normalized_mac=new_mac_normalized, board_serial=new_serial, board_model=board_model, board_revision=board_revision, firmware_version=firmware_version, replacement_reason=request.replacement_reason, notes=notes, actor_user_id=actor_user_id, ) repository.set_current_network_identity( miner_id, mac_address=new_mac, normalized_mac=new_mac_normalized ) repository.set_current_control_board( miner_id, mac_address=new_mac, board_serial=new_serial ) next_sequence = repository.get_next_event_sequence(repair["id"]) payload = ControlBoardReplacedPayload( old_mac_address=old_mac_canonical, new_mac_address=new_mac, old_control_board_serial=previous_board.get("board_serial"), new_control_board_serial=new_serial, old_board_model=previous_board.get("board_model"), new_board_model=board_model, old_board_revision=previous_board.get("board_revision"), new_board_revision=board_revision, new_firmware_version=firmware_version, removed_board_disposition=request.removed_board_disposition, replacement_reason=request.replacement_reason, notes=notes, ) event = repository.create_event( repair["id"], sequence=next_sequence, event_type="CONTROL_BOARD_REPLACED", created_by=actor_user_id, payload=payload.model_dump(mode="json", exclude_none=False), ) return ControlBoardReplacementResult( miner_id=miner_id, asset_id=miner.get("asset_tag"), repair_id=repair["id"], installation_id=installation["id"], event_id=event["id"], event_sequence=event["sequence"], current_mac=new_mac, control_board_serial=new_serial, replaced_at=event["created_at"], replaced_by=actor_user_id, ) 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 ], )