where('organization_id', $organizationId); } /** * @param array $filters */ public function list(string $ownerRef, int $organizationId, array $filters = [], ?int $branchId = null): LengthAwarePaginator { $query = $this->queryForOrganization($ownerRef, $organizationId) ->with(['patient', 'investigationType', 'practitioner', 'branch', 'result']) ->orderByDesc('created_at'); if ($branchId !== null) { $query->where('branch_id', $branchId); } if ($status = $filters['status'] ?? null) { $query->where('status', $status); } if ($patientId = $filters['patient_id'] ?? null) { $query->where('patient_id', $patientId); } return $query->paginate((int) ($filters['per_page'] ?? 20))->withQueryString(); } /** * @return Collection */ public function workQueue(string $ownerRef, int $branchId, ?string $status = null): Collection { $query = InvestigationRequest::owned($ownerRef) ->where('branch_id', $branchId) ->whereIn('status', InvestigationRequest::activeStatuses()) ->with(['patient', 'investigationType', 'assignedMember', 'result']) ->orderByRaw("CASE priority WHEN 'urgent' THEN 0 ELSE 1 END") ->orderBy('created_at'); if ($status) { $query->where('status', $status); } return $query->get(); } /** * @param array $typeIds * @return Collection */ public function requestFromConsultation( Consultation $consultation, string $ownerRef, array $typeIds, ?string $clinicalNotes = null, string $priority = 'routine', ?string $actorRef = null, ): Collection { $consultation->loadMissing('visit'); $created = new Collection; foreach ($typeIds as $typeId) { $type = InvestigationType::owned($ownerRef)->findOrFail($typeId); $request = InvestigationRequest::create([ 'owner_ref' => $ownerRef, 'organization_id' => $consultation->visit->organization_id, 'branch_id' => $consultation->visit->branch_id, 'visit_id' => $consultation->visit_id, 'consultation_id' => $consultation->id, 'patient_id' => $consultation->patient_id, 'investigation_type_id' => $type->id, 'practitioner_id' => $consultation->practitioner_id, 'status' => InvestigationRequest::STATUS_PENDING, 'priority' => $priority, 'clinical_notes' => $clinicalNotes, 'requested_by' => $actorRef, ]); AuditLogger::record( $ownerRef, 'investigation.requested', $consultation->visit->organization_id, $actorRef, InvestigationRequest::class, $request->id, ); $request = $request->load(['investigationType', 'patient']); $organization = Organization::query()->find($request->organization_id); if ($organization) { $this->queueBridge->issueForInvestigation($organization, $request); } $created->push($request->fresh(['investigationType', 'patient'])); } return $created; } public function collectSample( InvestigationRequest $request, string $ownerRef, ?string $barcode = null, ?string $actorRef = null, ): InvestigationRequest { $this->assertStatus($request, InvestigationRequest::STATUS_PENDING); $request->update([ 'status' => InvestigationRequest::STATUS_SAMPLE_COLLECTED, 'sample_barcode' => $barcode ?? $this->generateBarcode($request), 'sample_collected_at' => now(), 'sample_collected_by' => $actorRef, ]); AuditLogger::record($ownerRef, 'investigation.sample_collected', $request->organization_id, $actorRef, InvestigationRequest::class, $request->id); $request = $request->fresh(['patient', 'investigationType']); $organization = Organization::query()->find($request->organization_id); if ($organization) { $this->queueBridge->serve($organization, $request); } return $request; } public function startProcessing( InvestigationRequest $request, string $ownerRef, ?int $assignedMemberId = null, ?string $actorRef = null, ): InvestigationRequest { $this->assertStatus($request, InvestigationRequest::STATUS_SAMPLE_COLLECTED); $request->update([ 'status' => InvestigationRequest::STATUS_IN_PROGRESS, 'assigned_member_id' => $assignedMemberId, ]); InvestigationResult::firstOrCreate( ['investigation_request_id' => $request->id], ['owner_ref' => $ownerRef, 'entered_by' => $actorRef, 'status' => InvestigationResult::STATUS_DRAFT], ); AuditLogger::record($ownerRef, 'investigation.in_progress', $request->organization_id, $actorRef, InvestigationRequest::class, $request->id); return $request->fresh(['patient', 'investigationType', 'result']); } /** * @param array $data */ public function enterResults( InvestigationRequest $request, string $ownerRef, array $data, ?string $actorRef = null, ): InvestigationResult { if (! in_array($request->status, [InvestigationRequest::STATUS_IN_PROGRESS, InvestigationRequest::STATUS_AWAITING_REVIEW], true)) { throw new InvalidArgumentException('Results can only be entered when processing.'); } $result = InvestigationResult::firstOrCreate( ['investigation_request_id' => $request->id], ['owner_ref' => $ownerRef, 'entered_by' => $actorRef], ); $type = $request->investigationType; $isAbnormal = false; if (! empty($data['values']) && is_array($data['values'])) { $result->values()->delete(); foreach ($data['values'] as $row) { if (empty($row['parameter'])) { continue; } $abnormal = $this->isValueAbnormal( $row['value'] ?? null, $row['reference_low'] ?? $type->reference_low, $row['reference_high'] ?? $type->reference_high, ); $isAbnormal = $isAbnormal || $abnormal; InvestigationResultValue::create([ 'owner_ref' => $ownerRef, 'investigation_result_id' => $result->id, 'parameter' => $row['parameter'], 'value' => $row['value'] ?? null, 'unit' => $row['unit'] ?? $type->unit, 'reference_low' => $row['reference_low'] ?? $type->reference_low, 'reference_high' => $row['reference_high'] ?? $type->reference_high, 'reference_text' => $row['reference_text'] ?? $type->reference_text, 'is_abnormal' => $abnormal, ]); } } elseif (! empty($data['value'])) { $result->values()->delete(); $abnormal = $this->isValueAbnormal($data['value'], $type->reference_low, $type->reference_high); $isAbnormal = $abnormal; InvestigationResultValue::create([ 'owner_ref' => $ownerRef, 'investigation_result_id' => $result->id, 'parameter' => $type->name, 'value' => $data['value'], 'unit' => $type->unit, 'reference_low' => $type->reference_low, 'reference_high' => $type->reference_high, 'reference_text' => $type->reference_text, 'is_abnormal' => $abnormal, ]); } $result->update([ 'result_summary' => $data['result_summary'] ?? $result->result_summary, 'interpretation' => $data['interpretation'] ?? $result->interpretation, 'is_abnormal' => $isAbnormal, 'entered_by' => $actorRef ?? $result->entered_by, 'status' => InvestigationResult::STATUS_DRAFT, ]); if (! empty($data['attachments'])) { $this->storeAttachments($result, $ownerRef, $data['attachments'], $actorRef); } $request->update(['status' => InvestigationRequest::STATUS_AWAITING_REVIEW]); AuditLogger::record($ownerRef, 'investigation.results_entered', $request->organization_id, $actorRef, InvestigationResult::class, $result->id); return $result->fresh(['values', 'attachments']); } public function approve(InvestigationRequest $request, string $ownerRef, ?string $actorRef = null): InvestigationRequest { $this->assertStatus($request, InvestigationRequest::STATUS_AWAITING_REVIEW); $result = $request->result; abort_unless($result, 422, 'No results to approve.'); $result->update([ 'status' => InvestigationResult::STATUS_APPROVED, 'approved_by' => $actorRef, 'approved_at' => now(), ]); $request->update([ 'status' => InvestigationRequest::STATUS_COMPLETED, 'completed_at' => now(), 'approved_by' => $actorRef, 'approved_at' => now(), ]); AuditLogger::record($ownerRef, 'investigation.approved', $request->organization_id, $actorRef, InvestigationRequest::class, $request->id); $request = $request->fresh(['patient', 'investigationType', 'result.values', 'visit']); $organization = Organization::query()->find($request->organization_id); if ($organization) { $this->queueBridge->complete($organization, $request); if ($request->visit) { $this->workflowCompletion->complete( $organization, $request->visit, $this->queueBridge->contextForInvestigation($request), $ownerRef, $actorRef, ); } } return $request; } public function deliver(InvestigationRequest $request, string $ownerRef, ?string $actorRef = null): InvestigationRequest { $this->assertStatus($request, InvestigationRequest::STATUS_COMPLETED); $request->update([ 'status' => InvestigationRequest::STATUS_DELIVERED, 'delivered_at' => now(), ]); AuditLogger::record($ownerRef, 'investigation.delivered', $request->organization_id, $actorRef, InvestigationRequest::class, $request->id); return $request->fresh(['patient', 'investigationType', 'result']); } public function cancel(InvestigationRequest $request, string $ownerRef, ?string $actorRef = null): InvestigationRequest { if (in_array($request->status, [InvestigationRequest::STATUS_COMPLETED, InvestigationRequest::STATUS_DELIVERED], true)) { throw new InvalidArgumentException('Cannot cancel a completed investigation.'); } $request->update(['status' => InvestigationRequest::STATUS_CANCELLED]); AuditLogger::record($ownerRef, 'investigation.cancelled', $request->organization_id, $actorRef, InvestigationRequest::class, $request->id); return $request->fresh(); } /** * @param array $data */ public function createType(Organization $organization, string $ownerRef, array $data): InvestigationType { $type = InvestigationType::create([ 'owner_ref' => $ownerRef, 'organization_id' => $organization->id, 'name' => $data['name'], 'code' => $data['code'] ?? null, 'category' => $data['category'], 'description' => $data['description'] ?? null, 'unit' => $data['unit'] ?? null, 'reference_low' => $data['reference_low'] ?? null, 'reference_high' => $data['reference_high'] ?? null, 'reference_text' => $data['reference_text'] ?? null, 'price_minor' => (int) ($data['price_minor'] ?? 0), 'is_active' => (bool) ($data['is_active'] ?? true), ]); AuditLogger::record($ownerRef, 'investigation_type.created', $organization->id, null, InvestigationType::class, $type->id); return $type; } /** * @param array $data */ public function updateType(InvestigationType $type, string $ownerRef, array $data): InvestigationType { $type->update([ 'name' => $data['name'], 'code' => $data['code'] ?? null, 'category' => $data['category'], 'description' => $data['description'] ?? null, 'unit' => $data['unit'] ?? null, 'reference_low' => $data['reference_low'] ?? null, 'reference_high' => $data['reference_high'] ?? null, 'reference_text' => $data['reference_text'] ?? null, 'price_minor' => (int) ($data['price_minor'] ?? 0), 'is_active' => (bool) ($data['is_active'] ?? true), ]); AuditLogger::record($ownerRef, 'investigation_type.updated', $type->organization_id, null, InvestigationType::class, $type->id); return $type; } protected function assertStatus(InvestigationRequest $request, string ...$allowed): void { if (! in_array($request->status, $allowed, true)) { throw new InvalidArgumentException("Cannot transition from {$request->status}."); } } protected function isValueAbnormal(?string $value, mixed $low, mixed $high): bool { if ($value === null || $value === '') { return false; } if (! is_numeric($value)) { return false; } $numeric = (float) $value; if ($low !== null && $numeric < (float) $low) { return true; } if ($high !== null && $numeric > (float) $high) { return true; } return false; } protected function generateBarcode(InvestigationRequest $request): string { return 'LAB-'.str_pad((string) $request->id, 6, '0', STR_PAD_LEFT); } /** * @param array $files */ protected function storeAttachments(InvestigationResult $result, string $ownerRef, array $files, ?string $actorRef): void { foreach ($files as $file) { if (! $file instanceof UploadedFile) { continue; } $path = $file->store("care/investigations/{$result->id}/attachments", 'public'); InvestigationAttachment::create([ 'owner_ref' => $ownerRef, 'investigation_result_id' => $result->id, 'file_path' => $path, 'original_name' => $file->getClientOriginalName(), 'mime_type' => $file->getMimeType(), 'uploaded_by' => $actorRef, ]); } } }