feat: add repair parts tracking

This commit is contained in:
lussierj committed 2026-08-02 21:26:15 +00:00
1 parent 6e550c1c13
commit eab9646960
23 files changed
+2386 -4

No files matched your search

+35
View File
@@ -135,3 +135,38 @@ class MacOwnershipConflict(ControlBoardReplacementConflict):
super().__init__(
"MAC address has current or historical ownership on another miner"
)
class RepairPartNotFound(RepairError):
"""Raised when a nested repair-part UUID does not exist for a repair."""
def __init__(self, repair_part_id: object):
self.repair_part_id = repair_part_id
super().__init__(f"Repair part not found: {repair_part_id}")
class RepairPartComponentNotFound(RepairError):
"""Raised when a tracked component UUID does not exist."""
def __init__(self, component_id: object):
self.component_id = component_id
super().__init__(f"Component not found: {component_id}")
class RepairPartValidationError(RepairError):
"""Raised when repair-part input violates a domain rule."""
class RepairPartActorRequired(RepairError):
"""Raised when a repair-part action lacks an authenticated actor."""
def __init__(self):
super().__init__("An authenticated actor is required for repair-part actions")
class RepairPartAlreadyVoided(RepairConflict):
"""Raised when an immutable repair-part entry was already voided."""
def __init__(self, repair_part_id: object):
self.repair_part_id = repair_part_id
super().__init__(f"Repair part is already voided: {repair_part_id}")
+93 -1
View File
@@ -1,10 +1,12 @@
"""Typed contracts for the Repairs domain."""
from datetime import datetime
from decimal import Decimal
from typing import Any, Literal
from uuid import UUID
from pydantic import AwareDatetime, BaseModel, ConfigDict, Field
from pydantic import AwareDatetime, BaseModel, ConfigDict, Field, model_validator
from app.components.models import ComponentType
RepairReason = Literal[
@@ -33,6 +35,8 @@ RepairEventType = Literal[
"INITIAL_NOTE",
"REPAIR_COMPLETED",
"CONTROL_BOARD_REPLACED",
"REPAIR_PART_RECORDED",
"REPAIR_PART_VOIDED",
]
RepairEligibleLifecycle = Literal["active", "spare"]
RepairPostLifecycle = Literal["active", "spare"]
@@ -194,3 +198,91 @@ class RepairTimelineResponse(BaseModel):
data: RepairTimeline
meta: dict[str, object] = Field(default_factory=dict)
error: None = None
ConsumableUnit = Literal["each", "gram", "millilitre", "metre", "centimetre"]
RepairPartEntryKind = Literal["consumable", "tracked_component"]
class ConsumableInput(BaseModel):
model_config = ConfigDict(extra="forbid", str_strip_whitespace=True)
description: str = Field(min_length=1, max_length=255)
quantity: Decimal = Field(gt=0, max_digits=12, decimal_places=3)
unit: ConsumableUnit
@model_validator(mode="after")
def validate_each_quantity(self) -> "ConsumableInput":
if self.unit == "each" and self.quantity != self.quantity.to_integral_value():
raise ValueError("quantity for unit 'each' must be a whole number")
return self
class RepairPartRecordRequest(BaseModel):
model_config = ConfigDict(extra="forbid", str_strip_whitespace=True)
consumable: ConsumableInput | None = None
tracked_component_uuid: UUID | None = None
notes: str | None = Field(default=None, max_length=4000)
@model_validator(mode="after")
def validate_entry_shape(self) -> "RepairPartRecordRequest":
if (self.consumable is None) == (self.tracked_component_uuid is None):
raise ValueError(
"exactly one of consumable and tracked_component_uuid is required"
)
return self
class RepairPartVoidRequest(BaseModel):
model_config = ConfigDict(extra="forbid", str_strip_whitespace=True)
reason: str = Field(min_length=1, max_length=1000)
class RepairPartConsumable(BaseModel):
description: str
quantity: Decimal
unit: ConsumableUnit
class RepairPartComponentSnapshot(BaseModel):
component_type: ComponentType
asset_id: str | None = None
manufacturer: str | None = None
model: str | None = None
manufacturer_serial: str | None = None
class RepairPartDetail(BaseModel):
repair_part_id: UUID
repair_id: UUID
entry_kind: RepairPartEntryKind
consumable: RepairPartConsumable | None = None
component_id: UUID | None = None
component_snapshot: RepairPartComponentSnapshot | None = None
notes: str | None = None
recorded_by: UUID
recorded_at: AwareDatetime
recorded_event_id: UUID
voided_by: UUID | None = None
voided_at: AwareDatetime | None = None
void_reason: str | None = None
void_event_id: UUID | None = None
class RepairPartList(BaseModel):
repair_id: UUID
entries: list[RepairPartDetail] = Field(default_factory=list)
class RepairPartResponse(BaseModel):
data: RepairPartDetail
meta: dict[str, object] = Field(default_factory=dict)
error: None = None
class RepairPartListResponse(BaseModel):
data: RepairPartList
meta: dict[str, object] = Field(default_factory=dict)
error: None = None
+178
View File
@@ -597,6 +597,184 @@ class RepairRepository:
row = cur.fetchone()
return dict(row)
def lock_component_for_update(self, component_id: UUID) -> dict | None:
with self._connection_scope() as connection:
with connection.cursor(row_factory=dict_row) as cur:
cur.execute(
"""
SELECT
id,
component_type,
asset_id,
manufacturer,
model,
manufacturer_serial
FROM components
WHERE id = %s
FOR UPDATE
""",
(component_id,),
)
row = cur.fetchone()
return dict(row) if row else None
def create_repair_part(
self,
*,
repair_part_id: UUID,
repair_id: UUID,
consumable_description: str | None,
quantity: object | None,
unit: str | None,
component: Mapping[str, Any] | None,
notes: str | None,
recorded_event: Mapping[str, Any],
) -> dict:
with self._connection_scope(write=True) as connection:
with connection.cursor(row_factory=dict_row) as cur:
cur.execute(
"""
INSERT INTO repair_parts (
id,
repair_id,
consumable_description,
quantity,
unit,
component_id,
component_type_snapshot,
component_asset_id_snapshot,
component_manufacturer_snapshot,
component_model_snapshot,
component_manufacturer_serial_snapshot,
notes,
recorded_by,
recorded_at,
recorded_event_id
)
VALUES (
%s, %s, %s, %s, %s, %s, %s, %s,
%s, %s, %s, %s, %s, %s, %s
)
RETURNING *
""",
(
repair_part_id,
repair_id,
consumable_description,
quantity,
unit,
component.get("id") if component else None,
component.get("component_type") if component else None,
component.get("asset_id") if component else None,
component.get("manufacturer") if component else None,
component.get("model") if component else None,
component.get("manufacturer_serial") if component else None,
notes,
recorded_event["created_by"],
recorded_event["created_at"],
recorded_event["id"],
),
)
return dict(cur.fetchone())
def get_repair_part(
self,
repair_id: UUID,
repair_part_id: UUID,
) -> dict | None:
with self._connection_scope() as connection:
with connection.cursor(row_factory=dict_row) as cur:
cur.execute(
"""
SELECT part.*, event.sequence AS recorded_event_sequence
FROM repair_parts AS part
JOIN repair_events AS event
ON event.id = part.recorded_event_id
WHERE part.repair_id = %s AND part.id = %s
""",
(repair_id, repair_part_id),
)
row = cur.fetchone()
return dict(row) if row else None
def get_repair_parts(self, repair_id: UUID) -> list[dict] | None:
with self._connection_scope() as connection:
with connection.cursor(row_factory=dict_row) as cur:
cur.execute(
"""
SELECT
repair.id AS existing_repair_id,
part.*,
event.sequence AS recorded_event_sequence
FROM repairs AS repair
LEFT JOIN repair_parts AS part
ON part.repair_id = repair.id
LEFT JOIN repair_events AS event
ON event.id = part.recorded_event_id
WHERE repair.id = %s
ORDER BY event.sequence ASC, part.id ASC
""",
(repair_id,),
)
rows = [dict(row) for row in cur.fetchall()]
if not rows:
return None
return [row for row in rows if row.get("id") is not None]
def lock_repair_part_for_update(
self,
repair_id: UUID,
repair_part_id: UUID,
) -> dict | None:
with self._connection_scope() as connection:
with connection.cursor(row_factory=dict_row) as cur:
cur.execute(
"""
SELECT *
FROM repair_parts
WHERE repair_id = %s AND id = %s
FOR UPDATE
""",
(repair_id, repair_part_id),
)
row = cur.fetchone()
return dict(row) if row else None
def void_repair_part(
self,
repair_id: UUID,
repair_part_id: UUID,
*,
reason: str,
void_event: Mapping[str, Any],
) -> dict | None:
with self._connection_scope(write=True) as connection:
with connection.cursor(row_factory=dict_row) as cur:
cur.execute(
"""
UPDATE repair_parts
SET voided_by = %s,
voided_at = %s,
void_reason = %s,
void_event_id = %s
WHERE repair_id = %s
AND id = %s
AND voided_at IS NULL
RETURNING *
""",
(
void_event["created_by"],
void_event["created_at"],
reason,
void_event["id"],
repair_id,
repair_part_id,
),
)
row = cur.fetchone()
return dict(row) if row else None
def get_repair_detail(self, repair_id: UUID) -> dict | None:
with self._connection_scope() as connection:
with connection.cursor(row_factory=dict_row) as cur:
+111
View File
@@ -15,6 +15,10 @@ from app.repairs.exceptions import (
RepairConflict,
RepairMinerNotFound,
RepairNotFound,
RepairPartActorRequired,
RepairPartComponentNotFound,
RepairPartNotFound,
RepairPartValidationError,
)
from app.repairs.models import (
BeginRepairRequest,
@@ -24,6 +28,10 @@ from app.repairs.models import (
RepairCompleteRequest,
RepairDetailResponse,
RepairTimelineResponse,
RepairPartListResponse,
RepairPartRecordRequest,
RepairPartResponse,
RepairPartVoidRequest,
)
from app.repairs.service import RepairService
@@ -33,6 +41,7 @@ control_board_router = APIRouter(prefix="/api/miners", tags=["miners"])
require_repair_create = require_permission("repairs.create")
require_repair_read = require_permission("repairs.read")
require_repair_complete = require_permission("repairs.complete")
require_repair_parts_manage = require_permission("repairs.parts.manage")
require_control_board_replace = require_permission("miners.replace_control_board")
RepairServiceDependency = Annotated[
@@ -42,6 +51,7 @@ RepairServiceDependency = Annotated[
RepairCreateUser = Annotated[dict, Depends(require_repair_create)]
RepairReadUser = Annotated[dict, Depends(require_repair_read)]
RepairCompleteUser = Annotated[dict, Depends(require_repair_complete)]
RepairPartsManageUser = Annotated[dict, Depends(require_repair_parts_manage)]
ControlBoardReplaceUser = Annotated[dict, Depends(require_control_board_replace)]
@@ -149,6 +159,107 @@ def complete_repair(
return RepairDetailResponse(data=repair)
@router.get(
"/{repair_id}/parts",
response_model=RepairPartListResponse,
)
def list_repair_parts(
repair_id: UUID,
service: RepairServiceDependency,
current_user: RepairReadUser,
) -> RepairPartListResponse:
del current_user
try:
parts = service.list_repair_parts(repair_id)
except RepairNotFound as exc:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Repair not found",
) from exc
return RepairPartListResponse(data=parts)
@router.post(
"/{repair_id}/parts",
response_model=RepairPartResponse,
status_code=status.HTTP_201_CREATED,
)
def record_repair_part(
repair_id: UUID,
request: RepairPartRecordRequest,
service: RepairServiceDependency,
current_user: RepairPartsManageUser,
) -> RepairPartResponse:
try:
part = service.record_repair_part(
repair_id,
request,
actor_user_id=UUID(str(current_user["id"])),
)
except (RepairNotFound, RepairPartComponentNotFound) as exc:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=str(exc),
) from exc
except RepairMinerNotFound as exc:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail="Repair miner no longer exists",
) from exc
except RepairConflict as exc:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail=str(exc),
) from exc
except (RepairPartActorRequired, RepairPartValidationError) as exc:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail=str(exc),
) from exc
return RepairPartResponse(data=part)
@router.post(
"/{repair_id}/parts/{repair_part_id}/void",
response_model=RepairPartResponse,
)
def void_repair_part(
repair_id: UUID,
repair_part_id: UUID,
request: RepairPartVoidRequest,
service: RepairServiceDependency,
current_user: RepairPartsManageUser,
) -> RepairPartResponse:
try:
part = service.void_repair_part(
repair_id,
repair_part_id,
request,
actor_user_id=UUID(str(current_user["id"])),
)
except (RepairNotFound, RepairPartNotFound) as exc:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=str(exc),
) from exc
except RepairMinerNotFound as exc:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail="Repair miner no longer exists",
) from exc
except RepairConflict as exc:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail=str(exc),
) from exc
except (RepairPartActorRequired, RepairPartValidationError) as exc:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail=str(exc),
) from exc
return RepairPartResponse(data=part)
@router.get(
"/{repair_id}",
response_model=RepairDetailResponse,
+277 -1
View File
@@ -1,6 +1,7 @@
"""Business rules and orchestration for repair workflows."""
from uuid import UUID
from decimal import Decimal
from uuid import UUID, uuid4
import re
@@ -19,6 +20,11 @@ from app.repairs.exceptions import (
RepairNotActive,
RepairNotFound,
RepairPersistenceError,
RepairPartActorRequired,
RepairPartAlreadyVoided,
RepairPartComponentNotFound,
RepairPartNotFound,
RepairPartValidationError,
)
from app.repairs.models import (
BeginRepairRequest,
@@ -30,6 +36,12 @@ from app.repairs.models import (
RepairActor,
RepairCompleteRequest,
RepairCompletedPayload,
RepairPartComponentSnapshot,
RepairPartConsumable,
RepairPartDetail,
RepairPartList,
RepairPartRecordRequest,
RepairPartVoidRequest,
RepairDetail,
RepairTimeline,
RepairTimelineEvent,
@@ -378,6 +390,270 @@ class RepairService:
return self._map_repair_detail(detail)
@staticmethod
def _normalize_repair_part_text(
value: str | None,
*,
field_name: str,
max_length: int,
) -> str | None:
if value is None:
return None
normalized = str(value).strip()
if not normalized:
raise RepairPartValidationError(
f"{field_name} must not be blank when supplied"
)
if len(normalized) > max_length:
raise RepairPartValidationError(
f"{field_name} must not exceed {max_length} characters"
)
return normalized
@staticmethod
def _validate_consumable_quantity(quantity: Decimal, unit: str) -> Decimal:
normalized = Decimal(quantity)
if normalized <= 0:
raise RepairPartValidationError("quantity must be greater than zero")
significant = normalized.normalize()
fractional_places = max(-significant.as_tuple().exponent, 0)
if fractional_places > 3 or normalized >= Decimal("1000000000"):
raise RepairPartValidationError(
"quantity must fit NUMERIC(12,3)"
)
if unit == "each" and normalized != normalized.to_integral_value():
raise RepairPartValidationError(
"quantity for unit 'each' must be a whole number"
)
return normalized
@staticmethod
def _map_repair_part(part: dict) -> RepairPartDetail:
if part["consumable_description"] is not None:
consumable = RepairPartConsumable(
description=part["consumable_description"],
quantity=part["quantity"],
unit=part["unit"],
)
component_snapshot = None
entry_kind = "consumable"
else:
consumable = None
component_snapshot = RepairPartComponentSnapshot(
component_type=part["component_type_snapshot"],
asset_id=part["component_asset_id_snapshot"],
manufacturer=part["component_manufacturer_snapshot"],
model=part["component_model_snapshot"],
manufacturer_serial=(
part["component_manufacturer_serial_snapshot"]
),
)
entry_kind = "tracked_component"
return RepairPartDetail(
repair_part_id=part["id"],
repair_id=part["repair_id"],
entry_kind=entry_kind,
consumable=consumable,
component_id=part["component_id"],
component_snapshot=component_snapshot,
notes=part["notes"],
recorded_by=part["recorded_by"],
recorded_at=part["recorded_at"],
recorded_event_id=part["recorded_event_id"],
voided_by=part["voided_by"],
voided_at=part["voided_at"],
void_reason=part["void_reason"],
void_event_id=part["void_event_id"],
)
@staticmethod
def _lock_active_repair(
repository: RepairRepository,
repair_id: UUID,
) -> tuple[dict, dict]:
identity = repository.get_repair_identity(repair_id)
if identity is None:
raise RepairNotFound(repair_id)
miner_id = identity["miner_id"]
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)
return miner, repair
def record_repair_part(
self,
repair_id: UUID,
request: RepairPartRecordRequest,
*,
actor_user_id: UUID | None,
) -> RepairPartDetail:
if actor_user_id is None:
raise RepairPartActorRequired()
notes = self._normalize_repair_part_text(
request.notes,
field_name="notes",
max_length=4000,
)
description = None
quantity = None
unit = None
if request.consumable is not None:
description = self._normalize_repair_part_text(
request.consumable.description,
field_name="consumable.description",
max_length=255,
)
quantity = self._validate_consumable_quantity(
request.consumable.quantity,
request.consumable.unit,
)
unit = request.consumable.unit
with self.repository.transaction() as repository:
self._lock_active_repair(repository, repair_id)
component = None
if request.tracked_component_uuid is not None:
component = repository.lock_component_for_update(
request.tracked_component_uuid
)
if component is None:
raise RepairPartComponentNotFound(
request.tracked_component_uuid
)
repair_part_id = uuid4()
next_sequence = repository.get_next_event_sequence(repair_id)
if component is None:
payload = {
"repair_part_id": str(repair_part_id),
"entry_kind": "consumable",
"description": description,
"quantity": format(quantity, "f"),
"unit": unit,
"notes": notes,
}
else:
payload = {
"repair_part_id": str(repair_part_id),
"entry_kind": "tracked_component",
"component_id": str(component["id"]),
"component_type": component["component_type"],
"component_asset_id": component["asset_id"],
"component_manufacturer": component["manufacturer"],
"component_model": component["model"],
"component_manufacturer_serial": (
component["manufacturer_serial"]
),
"notes": notes,
}
event = repository.create_event(
repair_id,
sequence=next_sequence,
event_type="REPAIR_PART_RECORDED",
created_by=actor_user_id,
payload=payload,
)
repository.create_repair_part(
repair_part_id=repair_part_id,
repair_id=repair_id,
consumable_description=description,
quantity=quantity,
unit=unit,
component=component,
notes=notes,
recorded_event=event,
)
part = repository.get_repair_part(repair_id, repair_part_id)
if part is None:
raise RepairPersistenceError(
"Recorded repair part could not be read in its transaction"
)
return self._map_repair_part(part)
def list_repair_parts(self, repair_id: UUID) -> RepairPartList:
parts = self.repository.get_repair_parts(repair_id)
if parts is None:
raise RepairNotFound(repair_id)
return RepairPartList(
repair_id=repair_id,
entries=[self._map_repair_part(part) for part in parts],
)
def void_repair_part(
self,
repair_id: UUID,
repair_part_id: UUID,
request: RepairPartVoidRequest,
*,
actor_user_id: UUID | None,
) -> RepairPartDetail:
if actor_user_id is None:
raise RepairPartActorRequired()
reason = self._normalize_repair_part_text(
request.reason,
field_name="reason",
max_length=1000,
)
assert reason is not None
with self.repository.transaction() as repository:
self._lock_active_repair(repository, repair_id)
part = repository.lock_repair_part_for_update(
repair_id,
repair_part_id,
)
if part is None:
raise RepairPartNotFound(repair_part_id)
if part["voided_at"] is not None:
raise RepairPartAlreadyVoided(repair_part_id)
next_sequence = repository.get_next_event_sequence(repair_id)
event = repository.create_event(
repair_id,
sequence=next_sequence,
event_type="REPAIR_PART_VOIDED",
created_by=actor_user_id,
payload={
"repair_part_id": str(repair_part_id),
"reason": reason,
},
)
voided = repository.void_repair_part(
repair_id,
repair_part_id,
reason=reason,
void_event=event,
)
if voided is None:
raise RepairPartAlreadyVoided(repair_part_id)
part = repository.get_repair_part(repair_id, repair_part_id)
if part is None:
raise RepairPersistenceError(
"Voided repair part could not be read in its transaction"
)
return self._map_repair_part(part)
def get_repair(self, repair_id: UUID) -> RepairDetail:
repair = self.repository.get_repair_detail(repair_id)
if repair is None: