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
@@ -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