feat: add return to service workflow

This commit is contained in:
lussierj committed 2026-07-29 19:02:23 +00:00
1 parent 72ad19a071
commit f2b736b08e
19 files changed
+2883 -46

No files matched your search

+115 -24
View File
@@ -6,15 +6,22 @@ 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,
@@ -24,6 +31,9 @@ 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"
@@ -40,6 +50,32 @@ class RepairService:
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,
@@ -107,34 +143,89 @@ class RepairService:
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)
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"],
)
return self._map_repair_detail(repair)
def get_repair_timeline(self, repair_id: UUID) -> RepairTimeline:
events = self.repository.get_repair_events(repair_id)