queueBridge->contextForAppointment($organization, $appointment); } public function queryForOrganization(string $ownerRef, int $organizationId): Builder { return Appointment::owned($ownerRef)->where('organization_id', $organizationId); } /** * @param array $filters * @param list|null $practitionerIds When set, only appointments assigned to these desks. */ public function list( string $ownerRef, int $organizationId, array $filters = [], ?int $branchId = null, ?array $practitionerIds = null, ): LengthAwarePaginator { $query = $this->queryForOrganization($ownerRef, $organizationId) ->with(['patient', 'practitioner', 'branch', 'department']) ->orderByDesc('scheduled_at'); if ($branchId !== null) { $query->where('branch_id', $branchId); } if ($practitionerIds !== null) { if ($practitionerIds === []) { $query->whereRaw('1 = 0'); } else { $query->whereIn('practitioner_id', $practitionerIds); } } 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(); } /** * @param list|null $practitionerIds * @return Collection */ public function queue( string $ownerRef, int $branchId, ?int $practitionerId = null, bool $includeUnassigned = true, ?array $practitionerIds = null, ): Collection { if ($practitionerIds !== null) { return $this->queueForPractitioners($ownerRef, $practitionerIds, $branchId > 0 ? $branchId : null); } $this->repairDuplicateQueuePositions($ownerRef, $branchId); $query = Appointment::owned($ownerRef) ->where('branch_id', $branchId) ->whereIn('status', [Appointment::STATUS_WAITING, Appointment::STATUS_CHECKED_IN]) ->with(['patient', 'practitioner', 'visit', 'organization']) ->orderBy('queue_position') ->orderBy('waiting_at') ->orderBy('checked_in_at'); if ($practitionerId !== null) { $query->where(function (Builder $q) use ($practitionerId, $includeUnassigned) { $q->where('practitioner_id', $practitionerId); if ($includeUnassigned) { $q->orWhereNull('practitioner_id'); } }); } return $query->get() ->filter(function (Appointment $appointment) { $organization = $appointment->organization; if (! $organization) { return true; } return $this->workflowGate->canRelease( $organization, $appointment->visit, $this->queueBridge->contextForAppointment($organization, $appointment), ); }) ->values(); } /** * Doctor queue: only appointments assigned to their linked practitioner desk(s). * * @param list $practitionerIds * @return Collection */ public function queueForPractitioners(string $ownerRef, array $practitionerIds, ?int $branchId = null): Collection { if ($practitionerIds === []) { return new Collection; } $query = Appointment::owned($ownerRef) ->whereIn('practitioner_id', $practitionerIds) ->whereIn('status', [Appointment::STATUS_WAITING, Appointment::STATUS_CHECKED_IN]) ->when($branchId, fn ($q) => $q->where('branch_id', $branchId)) ->with(['patient', 'practitioner', 'visit', 'organization']) ->orderBy('queue_position') ->orderBy('waiting_at') ->orderBy('checked_in_at'); $practitioners = Practitioner::owned($ownerRef) ->whereIn('id', $practitionerIds) ->get(); $allowedSpecialtyContexts = []; $orgIds = $practitioners->pluck('organization_id')->filter()->unique()->all(); $orgsById = \App\Models\Organization::query() ->whereIn('id', $orgIds ?: [0]) ->get() ->keyBy('id'); foreach ($practitioners as $practitioner) { $org = $orgsById->get((int) $practitioner->organization_id); if (! $org) { continue; } $deskContext = $this->queueBridge->contextForPractitioner($org, $practitioner); if ($deskContext !== CareQueueContexts::CONSULTATION) { $allowedSpecialtyContexts[$deskContext] = true; } } $allowedSpecialtyContexts = array_keys($allowedSpecialtyContexts); return new Collection( $query->get() ->filter(function (Appointment $appointment) use ($allowedSpecialtyContexts) { $organization = $appointment->organization; if (! $organization) { return true; } $context = $this->queueBridge->contextForAppointment($organization, $appointment); // GPs see consultation only. Specialty desks see their own specialty waiters on Queue. if ($context !== CareQueueContexts::CONSULTATION && ! in_array($context, $allowedSpecialtyContexts, true)) { return false; } return $this->workflowGate->canRelease( $organization, $appointment->visit, $context, ); }) ->values() ->all() ); } /** * @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); $appointment = $appointment->load(['patient', 'practitioner', 'branch', 'visit']); $this->queueBridge->issueForAppointment($organization, $appointment); return $appointment->fresh(['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); $appointment = $appointment->fresh(['patient', 'practitioner', 'visit', 'organization']); $this->queueBridge->issueForAppointment($appointment->organization, $appointment); 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); $appointment = $appointment->fresh(['patient', 'practitioner', 'organization']); if ($appointment->organization) { $this->queueBridge->issueForAppointment($appointment->organization, $appointment); } 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->loadMissing(['organization', 'visit']); if ($appointment->organization && ! $this->workflowGate->canRelease( $appointment->organization, $appointment->visit, $this->queueBridge->contextForAppointment($appointment->organization, $appointment), )) { throw new InvalidArgumentException('This visit has not reached a financially cleared consultation stage.'); } $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); $appointment = $appointment->fresh(['patient', 'practitioner', 'visit', 'organization']); if ($appointment->organization) { $this->queueBridge->serve($appointment->organization, $appointment); } 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(), ]); $visit = $appointment->visit; $organization = $appointment->organization; $workflowEnabled = $visit && $organization && $this->workflowGate->enabled($organization); AuditLogger::record($ownerRef, 'appointment.completed', $appointment->organization_id, $actorRef, Appointment::class, $appointment->id); $appointment = $appointment->fresh(['patient', 'practitioner', 'visit', 'organization']); if ($appointment->organization) { $this->queueBridge->complete($appointment->organization, $appointment); } if ($workflowEnabled && $visit && $organization) { $this->workflowCompletion->complete( $organization, $visit, $this->queueBridge->contextForAppointment($organization, $appointment), $ownerRef, $actorRef, ); } elseif ($visit) { $this->visits->complete($visit, $ownerRef, $actorRef); } 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; } /** * Demo specialty seed used to set every module's waiter to position 1. * Repair duplicates in place so the patient queue shows 1..n. */ protected function repairDuplicateQueuePositions(string $ownerRef, int $branchId): void { $waiters = Appointment::owned($ownerRef) ->where('branch_id', $branchId) ->whereIn('status', [Appointment::STATUS_WAITING, Appointment::STATUS_CHECKED_IN]) ->orderByRaw('COALESCE(waiting_at, checked_in_at, scheduled_at) asc') ->orderBy('id') ->get(['id', 'queue_position']); if ($waiters->count() < 2) { return; } $positions = $waiters->pluck('queue_position'); if ($positions->filter(fn ($p) => $p !== null)->unique()->count() === $waiters->count()) { return; } foreach ($waiters as $index => $appointment) { $position = $index + 1; if ((int) $appointment->queue_position === $position) { continue; } Appointment::whereKey($appointment->id)->update(['queue_position' => $position]); } } protected function assertTransition(Appointment $appointment, string ...$allowedFrom): void { if (! in_array($appointment->status, $allowedFrom, true)) { throw new InvalidArgumentException("Cannot transition from {$appointment->status}."); } } }