feat: add repair parts tracking
This commit is contained in:
1 parent
6e550c1c13
commit
eab9646960
23 files changed
+2386
-4
No files matched your search
@@ -0,0 +1 @@
|
||||
"""Minimal durable component registry for repair-part references."""
|
||||
@@ -0,0 +1,8 @@
|
||||
"""Dependency construction for the Components domain."""
|
||||
|
||||
from app.components.repository import ComponentRepository
|
||||
from app.components.service import ComponentService
|
||||
|
||||
|
||||
def get_component_service() -> ComponentService:
|
||||
return ComponentService(ComponentRepository())
|
||||
@@ -0,0 +1,32 @@
|
||||
"""Domain exceptions for the minimal component registry."""
|
||||
|
||||
|
||||
class ComponentError(Exception):
|
||||
"""Base exception for component operations."""
|
||||
|
||||
|
||||
class ComponentNotFound(ComponentError):
|
||||
"""Raised when a 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 DuplicateComponentAssetID(ComponentError):
|
||||
"""Raised when a normalized component Asset ID already exists."""
|
||||
|
||||
def __init__(self, asset_id: str):
|
||||
self.asset_id = asset_id
|
||||
super().__init__(f"Component Asset ID already exists: {asset_id}")
|
||||
|
||||
|
||||
class ComponentValidationError(ComponentError):
|
||||
"""Raised when component metadata violates a domain rule."""
|
||||
|
||||
|
||||
class ComponentActorRequired(ComponentError):
|
||||
"""Raised when component creation lacks an authenticated actor."""
|
||||
|
||||
def __init__(self):
|
||||
super().__init__("An authenticated actor is required to create a component")
|
||||
@@ -0,0 +1,36 @@
|
||||
"""Typed contracts for the minimal component registry."""
|
||||
|
||||
from typing import Literal
|
||||
from uuid import UUID
|
||||
|
||||
from pydantic import AwareDatetime, BaseModel, ConfigDict, Field
|
||||
|
||||
|
||||
ComponentType = Literal["control_board", "hashboard", "power_supply", "fan"]
|
||||
|
||||
|
||||
class ComponentCreateRequest(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid", str_strip_whitespace=True)
|
||||
|
||||
component_type: ComponentType
|
||||
asset_id: str | None = Field(default=None, max_length=255)
|
||||
manufacturer: str | None = Field(default=None, max_length=255)
|
||||
model: str | None = Field(default=None, max_length=255)
|
||||
manufacturer_serial: str | None = Field(default=None, max_length=255)
|
||||
|
||||
|
||||
class ComponentDetail(BaseModel):
|
||||
component_id: UUID
|
||||
component_type: ComponentType
|
||||
asset_id: str | None = None
|
||||
manufacturer: str | None = None
|
||||
model: str | None = None
|
||||
manufacturer_serial: str | None = None
|
||||
created_at: AwareDatetime
|
||||
created_by: UUID
|
||||
|
||||
|
||||
class ComponentResponse(BaseModel):
|
||||
data: ComponentDetail
|
||||
meta: dict[str, object] = Field(default_factory=dict)
|
||||
error: None = None
|
||||
@@ -0,0 +1,143 @@
|
||||
"""PostgreSQL persistence for the minimal component registry."""
|
||||
|
||||
from collections.abc import Callable, Iterator
|
||||
from contextlib import contextmanager
|
||||
from uuid import UUID
|
||||
|
||||
import psycopg
|
||||
from psycopg.errors import UniqueViolation
|
||||
from psycopg.rows import dict_row
|
||||
|
||||
from app.components.exceptions import DuplicateComponentAssetID
|
||||
from app.config import settings
|
||||
|
||||
|
||||
ConnectionFactory = Callable[[], psycopg.Connection]
|
||||
COMPONENT_ASSET_ID_CONSTRAINT = "uq_components_normalized_asset_id"
|
||||
|
||||
|
||||
def default_connection_factory() -> psycopg.Connection:
|
||||
return psycopg.connect(
|
||||
host=settings.postgres_host,
|
||||
port=settings.postgres_port,
|
||||
dbname=settings.postgres_db,
|
||||
user=settings.postgres_user,
|
||||
password=settings.postgres_password,
|
||||
connect_timeout=5,
|
||||
)
|
||||
|
||||
|
||||
class ComponentRepository:
|
||||
"""Persistence boundary for minimal component identity."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
connection_factory: ConnectionFactory | None = None,
|
||||
*,
|
||||
connection: psycopg.Connection | None = None,
|
||||
):
|
||||
self._connection_factory = connection_factory or default_connection_factory
|
||||
self._connection = connection
|
||||
|
||||
@contextmanager
|
||||
def transaction(self) -> Iterator["ComponentRepository"]:
|
||||
if self._connection is not None:
|
||||
yield self
|
||||
return
|
||||
|
||||
connection = self._connection_factory()
|
||||
repository = ComponentRepository(
|
||||
self._connection_factory,
|
||||
connection=connection,
|
||||
)
|
||||
try:
|
||||
yield repository
|
||||
connection.commit()
|
||||
except UniqueViolation as exc:
|
||||
connection.rollback()
|
||||
if exc.diag.constraint_name == COMPONENT_ASSET_ID_CONSTRAINT:
|
||||
raise DuplicateComponentAssetID("normalized Asset ID") from exc
|
||||
raise
|
||||
except Exception:
|
||||
connection.rollback()
|
||||
raise
|
||||
finally:
|
||||
connection.close()
|
||||
|
||||
@contextmanager
|
||||
def _connection_scope(self) -> Iterator[psycopg.Connection]:
|
||||
if self._connection is not None:
|
||||
yield self._connection
|
||||
return
|
||||
|
||||
connection = self._connection_factory()
|
||||
try:
|
||||
yield connection
|
||||
finally:
|
||||
connection.close()
|
||||
|
||||
def create_component(
|
||||
self,
|
||||
*,
|
||||
component_type: str,
|
||||
asset_id: str | None,
|
||||
manufacturer: str | None,
|
||||
model: str | None,
|
||||
manufacturer_serial: str | None,
|
||||
created_by: UUID,
|
||||
) -> dict:
|
||||
with self._connection_scope() as connection:
|
||||
with connection.cursor(row_factory=dict_row) as cur:
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO components (
|
||||
component_type,
|
||||
asset_id,
|
||||
manufacturer,
|
||||
model,
|
||||
manufacturer_serial,
|
||||
created_by
|
||||
)
|
||||
VALUES (%s, %s, %s, %s, %s, %s)
|
||||
RETURNING
|
||||
id AS component_id,
|
||||
component_type,
|
||||
asset_id,
|
||||
manufacturer,
|
||||
model,
|
||||
manufacturer_serial,
|
||||
created_at,
|
||||
created_by
|
||||
""",
|
||||
(
|
||||
component_type,
|
||||
asset_id,
|
||||
manufacturer,
|
||||
model,
|
||||
manufacturer_serial,
|
||||
created_by,
|
||||
),
|
||||
)
|
||||
return dict(cur.fetchone())
|
||||
|
||||
def get_component(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 AS component_id,
|
||||
component_type,
|
||||
asset_id,
|
||||
manufacturer,
|
||||
model,
|
||||
manufacturer_serial,
|
||||
created_at,
|
||||
created_by
|
||||
FROM components
|
||||
WHERE id = %s
|
||||
""",
|
||||
(component_id,),
|
||||
)
|
||||
row = cur.fetchone()
|
||||
return dict(row) if row else None
|
||||
@@ -0,0 +1,80 @@
|
||||
"""HTTP mapping for the minimal component registry."""
|
||||
|
||||
from typing import Annotated
|
||||
from uuid import UUID
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
|
||||
from app.components.dependencies import get_component_service
|
||||
from app.components.exceptions import (
|
||||
ComponentActorRequired,
|
||||
ComponentNotFound,
|
||||
ComponentValidationError,
|
||||
DuplicateComponentAssetID,
|
||||
)
|
||||
from app.components.models import (
|
||||
ComponentCreateRequest,
|
||||
ComponentResponse,
|
||||
)
|
||||
from app.components.service import ComponentService
|
||||
from app.identity import require_permission
|
||||
|
||||
|
||||
router = APIRouter(prefix="/api/components", tags=["components"])
|
||||
require_component_read = require_permission("components.read")
|
||||
require_component_create = require_permission("components.create")
|
||||
|
||||
ComponentServiceDependency = Annotated[
|
||||
ComponentService,
|
||||
Depends(get_component_service),
|
||||
]
|
||||
ComponentReadUser = Annotated[dict, Depends(require_component_read)]
|
||||
ComponentCreateUser = Annotated[dict, Depends(require_component_create)]
|
||||
|
||||
|
||||
@router.post(
|
||||
"",
|
||||
response_model=ComponentResponse,
|
||||
status_code=status.HTTP_201_CREATED,
|
||||
)
|
||||
def create_component(
|
||||
request: ComponentCreateRequest,
|
||||
service: ComponentServiceDependency,
|
||||
current_user: ComponentCreateUser,
|
||||
) -> ComponentResponse:
|
||||
try:
|
||||
component = service.create_component(
|
||||
request,
|
||||
actor_user_id=UUID(str(current_user["id"])),
|
||||
)
|
||||
except DuplicateComponentAssetID as exc:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
detail=str(exc),
|
||||
) from exc
|
||||
except (ComponentActorRequired, ComponentValidationError) as exc:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||
detail=str(exc),
|
||||
) from exc
|
||||
return ComponentResponse(data=component)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/{component_id}",
|
||||
response_model=ComponentResponse,
|
||||
)
|
||||
def get_component(
|
||||
component_id: UUID,
|
||||
service: ComponentServiceDependency,
|
||||
current_user: ComponentReadUser,
|
||||
) -> ComponentResponse:
|
||||
del current_user
|
||||
try:
|
||||
component = service.get_component(component_id)
|
||||
except ComponentNotFound as exc:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Component not found",
|
||||
) from exc
|
||||
return ComponentResponse(data=component)
|
||||
@@ -0,0 +1,75 @@
|
||||
"""Business rules for the minimal component registry."""
|
||||
|
||||
from uuid import UUID
|
||||
|
||||
from app.components.exceptions import (
|
||||
ComponentActorRequired,
|
||||
ComponentNotFound,
|
||||
ComponentValidationError,
|
||||
DuplicateComponentAssetID,
|
||||
)
|
||||
from app.components.models import ComponentCreateRequest, ComponentDetail
|
||||
from app.components.repository import ComponentRepository
|
||||
|
||||
|
||||
class ComponentService:
|
||||
"""Minimal component workflows independent of FastAPI."""
|
||||
|
||||
def __init__(self, repository: ComponentRepository):
|
||||
self.repository = repository
|
||||
|
||||
@staticmethod
|
||||
def _normalize_optional_text(value: str | None, field_name: str) -> str | None:
|
||||
if value is None:
|
||||
return None
|
||||
normalized = str(value).strip()
|
||||
if not normalized:
|
||||
raise ComponentValidationError(
|
||||
f"{field_name} must not be blank when supplied"
|
||||
)
|
||||
if len(normalized) > 255:
|
||||
raise ComponentValidationError(
|
||||
f"{field_name} must not exceed 255 characters"
|
||||
)
|
||||
return normalized
|
||||
|
||||
@staticmethod
|
||||
def _map_component(component: dict) -> ComponentDetail:
|
||||
return ComponentDetail(**component)
|
||||
|
||||
def create_component(
|
||||
self,
|
||||
request: ComponentCreateRequest,
|
||||
*,
|
||||
actor_user_id: UUID | None,
|
||||
) -> ComponentDetail:
|
||||
if actor_user_id is None:
|
||||
raise ComponentActorRequired()
|
||||
|
||||
asset_id = self._normalize_optional_text(request.asset_id, "asset_id")
|
||||
values = {
|
||||
"manufacturer": self._normalize_optional_text(
|
||||
request.manufacturer, "manufacturer"
|
||||
),
|
||||
"model": self._normalize_optional_text(request.model, "model"),
|
||||
"manufacturer_serial": self._normalize_optional_text(
|
||||
request.manufacturer_serial, "manufacturer_serial"
|
||||
),
|
||||
}
|
||||
try:
|
||||
with self.repository.transaction() as repository:
|
||||
component = repository.create_component(
|
||||
component_type=request.component_type,
|
||||
asset_id=asset_id,
|
||||
created_by=actor_user_id,
|
||||
**values,
|
||||
)
|
||||
except DuplicateComponentAssetID as exc:
|
||||
raise DuplicateComponentAssetID(asset_id or "") from exc
|
||||
return self._map_component(component)
|
||||
|
||||
def get_component(self, component_id: UUID) -> ComponentDetail:
|
||||
component = self.repository.get_component(component_id)
|
||||
if component is None:
|
||||
raise ComponentNotFound(component_id)
|
||||
return self._map_component(component)
|
||||
@@ -691,7 +691,32 @@ def repair_completion_request(path: str) -> bool:
|
||||
)
|
||||
|
||||
|
||||
def repair_parts_request(path: str) -> bool:
|
||||
parts = path.split("/")
|
||||
return (
|
||||
len(parts) == 5
|
||||
and parts[:3] == ["", "api", "repairs"]
|
||||
and parts[4] == "parts"
|
||||
) or (
|
||||
len(parts) == 7
|
||||
and parts[:3] == ["", "api", "repairs"]
|
||||
and parts[4] == "parts"
|
||||
and parts[6] == "void"
|
||||
)
|
||||
|
||||
|
||||
def component_request(path: str) -> bool:
|
||||
parts = path.split("/")
|
||||
return path == "/api/components" or (
|
||||
len(parts) == 4 and parts[:3] == ["", "api", "components"]
|
||||
)
|
||||
|
||||
|
||||
def required_api_permission(method: str, path: str) -> str | None:
|
||||
if component_request(path):
|
||||
return "components.create" if method == "POST" else "components.read"
|
||||
if repair_parts_request(path):
|
||||
return "repairs.read" if method == "GET" else "repairs.parts.manage"
|
||||
if path.startswith("/api/admin/"):
|
||||
return "users.manage"
|
||||
if method == "POST" and path.endswith("/change-mac") and path.startswith("/api/miners/"):
|
||||
|
||||
@@ -19,6 +19,7 @@ from app.identity import (
|
||||
router as identity_router,
|
||||
)
|
||||
from app.inventory.router import router as inventory_router
|
||||
from app.components.router import router as components_router
|
||||
from app.repairs.exceptions import (
|
||||
ControlBoardReplacementActorRequired,
|
||||
InvalidMacAddress,
|
||||
@@ -41,6 +42,7 @@ from app.repairs.router import (
|
||||
app = FastAPI(title=settings.app_name)
|
||||
app.include_router(identity_router)
|
||||
app.include_router(inventory_router)
|
||||
app.include_router(components_router)
|
||||
install_identity_middleware(app)
|
||||
|
||||
|
||||
|
||||
@@ -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}")
|
||||
@@ -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
|
||||
@@ -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:
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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:
|
||||
|
||||
Reference in new issue
Block a user