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', 'practitioner', 'branch', 'department']) ->orderByDesc('scheduled_at'); if ($branchId !== null) { $query->where('branch_id', $branchId); } if ($status = $filters['status'] ?? null) { $query->where('status', $status); } if ($practitionerId = $filters['practitioner_id'] ?? null) { $query->where('practitioner_id', $practitionerId); } if ($date = $filters['date'] ?? null) { $query->whereDate('scheduled_at', Carbon::parse($date)->toDateString()); } if ($patientId = $filters['patient_id'] ?? null) { $query->where('patient_id', $patientId); } return $query->paginate((int) ($filters['per_page'] ?? 20))->withQueryString(); } /** * @return Collection */ public function queue(string $ownerRef, int $branchId, ?int $practitionerId = null): Collection { $query = Appointment::owned($ownerRef) ->where('branch_id', $branchId) ->whereIn('status', [Appointment::STATUS_WAITING, Appointment::STATUS_CHECKED_IN]) ->with(['patient', 'practitioner']) ->orderBy('queue_position') ->orderBy('waiting_at') ->orderBy('checked_in_at'); if ($practitionerId !== null) { $query->where(function (Builder $q) use ($practitionerId) { $q->where('practitioner_id', $practitionerId)->orWhereNull('practitioner_id'); }); } return $query->get(); } /** * @param array $data */ public function book(Organization $organization, string $ownerRef, array $data, ?string $actorRef = null): Appointment { $appointment = Appointment::create([ 'owner_ref' => $ownerRef, 'organization_id' => $organization->id, 'branch_id' => $data['branch_id'], 'patient_id' => $data['patient_id'], 'practitioner_id' => $data['practitioner_id'] ?? null, 'department_id' => $data['department_id'] ?? null, 'type' => Appointment::TYPE_SCHEDULED, 'status' => Appointment::STATUS_SCHEDULED, 'scheduled_at' => $data['scheduled_at'], 'reason' => $data['reason'] ?? null, 'notes' => $data['notes'] ?? null, 'created_by' => $actorRef, ]); AuditLogger::record($ownerRef, 'appointment.created', $organization->id, $actorRef, Appointment::class, $appointment->id); $appointment = $appointment->load(['patient', 'practitioner', 'branch']); $this->notifier->notifyAppointmentBooked($organization, $appointment); return $appointment; } /** * @param array $data */ public function walkIn(Organization $organization, string $ownerRef, array $data, ?string $actorRef = null): Appointment { $visit = $this->visits->checkIn( $organization, $ownerRef, Patient::findOrFail($data['patient_id']), (int) $data['branch_id'], $actorRef, ); $appointment = Appointment::create([ 'owner_ref' => $ownerRef, 'organization_id' => $organization->id, 'branch_id' => $data['branch_id'], 'patient_id' => $data['patient_id'], 'practitioner_id' => $data['practitioner_id'] ?? null, 'department_id' => $data['department_id'] ?? null, 'visit_id' => $visit->id, 'type' => Appointment::TYPE_WALK_IN, 'status' => Appointment::STATUS_WAITING, 'scheduled_at' => now(), 'checked_in_at' => now(), 'waiting_at' => now(), 'queue_position' => $this->nextQueuePosition($ownerRef, (int) $data['branch_id']), 'reason' => $data['reason'] ?? 'Walk-in', 'notes' => $data['notes'] ?? null, 'created_by' => $actorRef, ]); AuditLogger::record($ownerRef, 'appointment.walk_in', $organization->id, $actorRef, Appointment::class, $appointment->id); return $appointment->load(['patient', 'practitioner', 'branch', 'visit']); } public function checkIn(Appointment $appointment, string $ownerRef, ?string $actorRef = null): Appointment { $this->assertTransition($appointment, Appointment::STATUS_SCHEDULED); $visit = $this->visits->checkIn( $appointment->organization, $ownerRef, $appointment->patient, $appointment->branch_id, $actorRef, ); $appointment->update([ 'visit_id' => $visit->id, 'status' => Appointment::STATUS_WAITING, 'checked_in_at' => now(), 'waiting_at' => now(), 'queue_position' => $this->nextQueuePosition($ownerRef, $appointment->branch_id), ]); AuditLogger::record($ownerRef, 'appointment.checked_in', $appointment->organization_id, $actorRef, Appointment::class, $appointment->id); return $appointment->fresh(['patient', 'practitioner', 'visit']); } public function markWaiting(Appointment $appointment, string $ownerRef, ?string $actorRef = null): Appointment { if (! in_array($appointment->status, [Appointment::STATUS_CHECKED_IN, Appointment::STATUS_SCHEDULED], true)) { $this->assertTransition($appointment, Appointment::STATUS_CHECKED_IN); } $updates = [ 'status' => Appointment::STATUS_WAITING, 'waiting_at' => $appointment->waiting_at ?? now(), ]; if ($appointment->queue_position === null) { $updates['queue_position'] = $this->nextQueuePosition($ownerRef, $appointment->branch_id); } $appointment->update($updates); AuditLogger::record($ownerRef, 'appointment.waiting', $appointment->organization_id, $actorRef, Appointment::class, $appointment->id); return $appointment->fresh(['patient', 'practitioner']); } public function startConsultation(Appointment $appointment, string $ownerRef, ?int $practitionerId = null, ?string $actorRef = null): Appointment { $this->assertTransition($appointment, Appointment::STATUS_WAITING, Appointment::STATUS_CHECKED_IN); if ($appointment->visit_id === null) { $visit = $this->visits->checkIn( $appointment->organization, $ownerRef, $appointment->patient, $appointment->branch_id, $actorRef, ); $appointment->update(['visit_id' => $visit->id, 'checked_in_at' => now()]); } $appointment->visit->update(['status' => Visit::STATUS_IN_PROGRESS]); $appointment->update([ 'status' => Appointment::STATUS_IN_CONSULTATION, 'started_at' => now(), 'practitioner_id' => $practitionerId ?? $appointment->practitioner_id, ]); AuditLogger::record($ownerRef, 'appointment.in_consultation', $appointment->organization_id, $actorRef, Appointment::class, $appointment->id); return $appointment->fresh(['patient', 'practitioner', 'visit']); } public function complete(Appointment $appointment, string $ownerRef, ?string $actorRef = null): Appointment { $this->assertTransition($appointment, Appointment::STATUS_IN_CONSULTATION); $appointment->update([ 'status' => Appointment::STATUS_COMPLETED, 'completed_at' => now(), ]); if ($appointment->visit) { $this->visits->complete($appointment->visit, $ownerRef, $actorRef); } AuditLogger::record($ownerRef, 'appointment.completed', $appointment->organization_id, $actorRef, Appointment::class, $appointment->id); return $appointment->fresh(['patient', 'practitioner', 'visit']); } public function cancel(Appointment $appointment, string $ownerRef, ?string $actorRef = null): Appointment { if (in_array($appointment->status, [Appointment::STATUS_COMPLETED, Appointment::STATUS_CANCELLED], true)) { throw new InvalidArgumentException('Appointment cannot be cancelled.'); } $meetRoomUuid = filled($appointment->meet_room_uuid) ? (string) $appointment->meet_room_uuid : null; $appointment->update([ 'status' => Appointment::STATUS_CANCELLED, 'cancelled_at' => now(), ]); AuditLogger::record($ownerRef, 'appointment.cancelled', $appointment->organization_id, $actorRef, Appointment::class, $appointment->id); if ($meetRoomUuid !== null) { $this->cancelLinkedMeetRoom($appointment, $ownerRef, $meetRoomUuid, $actorRef); } return $appointment->fresh(['patient', 'practitioner']); } protected function cancelLinkedMeetRoom( Appointment $appointment, string $ownerRef, string $roomUuid, ?string $actorRef = null, ): void { try { MeetClient::for($ownerRef)->cancelRoom($roomUuid, $actorRef ?? $ownerRef); AuditLogger::record( $ownerRef, 'appointment.meet_cancelled', $appointment->organization_id, $actorRef, Appointment::class, $appointment->id, ['meet_room_uuid' => $roomUuid], ); } catch (\Throwable $e) { Log::warning('Care appointment cancel: could not cancel linked Meet room', [ 'appointment_id' => $appointment->id, 'meet_room_uuid' => $roomUuid, 'error' => $e->getMessage(), ]); } } public function markNoShow(Appointment $appointment, string $ownerRef, ?string $actorRef = null): Appointment { $this->assertTransition($appointment, Appointment::STATUS_SCHEDULED); $appointment->update(['status' => Appointment::STATUS_NO_SHOW]); AuditLogger::record($ownerRef, 'appointment.no_show', $appointment->organization_id, $actorRef, Appointment::class, $appointment->id); return $appointment->fresh(['patient', 'practitioner']); } public function callNext(string $ownerRef, int $branchId, ?int $practitionerId = null): ?Appointment { $next = $this->queue($ownerRef, $branchId, $practitionerId)->first(); return $next; } protected function nextQueuePosition(string $ownerRef, int $branchId): int { $max = Appointment::owned($ownerRef) ->where('branch_id', $branchId) ->whereIn('status', [Appointment::STATUS_WAITING, Appointment::STATUS_CHECKED_IN]) ->max('queue_position'); return ($max ?? 0) + 1; } protected function assertTransition(Appointment $appointment, string ...$allowedFrom): void { if (! in_array($appointment->status, $allowedFrom, true)) { throw new InvalidArgumentException("Cannot transition from {$appointment->status}."); } } }