plans->canUseQueueIntegration($organization) && (bool) data_get($organization->settings, 'queue_integration_enabled') && $this->queue->configured(); } /** * @return array */ public function provisionOrganization(Organization $organization): array { if (! $this->shouldProvision($organization)) { return (array) data_get($organization->settings, 'department_queue_provisioning', []); } $owner = (string) $organization->owner_ref; $branches = Branch::owned($owner) ->where('organization_id', $organization->id) ->where('is_active', true) ->orderBy('name') ->get(); $provisioning = (array) data_get($organization->settings, 'department_queue_provisioning', []); foreach (CareQueueContexts::departments() as $context => $meta) { $queues = is_array($provisioning[$context]['queues'] ?? null) ? $provisioning[$context]['queues'] : []; $byBranch = collect($queues)->keyBy(fn ($q) => (string) ($q['branch_id'] ?? '')); foreach ($branches as $branch) { $prior = $byBranch->get((string) $branch->id, []); $stub = $this->provisionBranchDepartment( $organization, $owner, $context, $meta, $branch, is_array($prior) ? $prior : [], ); $byBranch->put((string) $branch->id, $stub); } $provisioning[$context] = [ 'queues' => $byBranch->values()->all(), ]; } $settings = $organization->settings ?? []; $settings['department_queue_provisioning'] = $provisioning; $organization->update(['settings' => $settings]); return $provisioning; } /** * @param array $meta * @param array $prior * @return array */ protected function provisionBranchDepartment( Organization $organization, string $owner, string $context, array $meta, Branch $branch, array $prior, ): array { $stub = [ 'name' => $meta['name'], 'prefix' => $meta['prefix'], 'module' => $context, 'branch_id' => $branch->id, 'branch_name' => $branch->name, 'active' => true, 'routing_mode' => $meta['routing_mode'], 'queue_external_key' => CareQueueContexts::queueExternalKey($context, $branch->id), 'department_external_key' => CareQueueContexts::departmentExternalKey($context, $branch->id), 'queue_uuid' => $prior['queue_uuid'] ?? null, 'department_id' => $prior['department_id'] ?? null, 'counter_uuid' => $prior['counter_uuid'] ?? null, 'counter_external_key' => $prior['counter_external_key'] ?? CareQueueContexts::counterExternalKey($context, $branch->id), 'points' => is_array($prior['points'] ?? null) ? $prior['points'] : [], 'synced' => false, ]; try { $this->ensureBranchName($owner, $branch->name); $department = $this->queue->ensureDepartment($owner, [ 'name' => $meta['name'], 'type' => $meta['type'] ?? 'general', 'branch_name' => $branch->name, 'external_key' => $stub['department_external_key'], 'is_active' => true, ]); $stub['department_id'] = $department['id'] ?? $stub['department_id']; $queue = $this->queue->ensureQueue($owner, [ 'name' => $meta['name'], 'prefix' => $meta['prefix'], 'branch_name' => $branch->name, 'strategy' => 'fifo', 'external_key' => $stub['queue_external_key'], 'routing_mode' => $meta['routing_mode'], 'department_id' => $stub['department_id'], 'is_active' => true, ]); $stub['queue_uuid'] = $queue['uuid'] ?? $stub['queue_uuid']; $points = $this->buildPointsForContext($organization, $context, $branch, $stub); $syncedPoints = []; foreach ($points as $point) { try { $counter = $this->queue->ensureCounter($owner, [ 'name' => $point['name'], 'destination' => $point['destination'], 'staff_ref' => $point['staff_ref'], 'staff_display_name' => $point['staff_display_name'], 'branch_name' => $branch->name, 'department_id' => $stub['department_id'], 'queue_uuids' => array_values(array_filter([(string) $stub['queue_uuid']])), 'external_key' => $point['external_key'], 'is_active' => true, ]); $point['counter_uuid'] = $counter['uuid'] ?? null; $point['synced'] = ! empty($point['counter_uuid']); } catch (\Throwable $e) { Log::warning('care.queue_point_provision_failed', [ 'context' => $context, 'branch_id' => $branch->id, 'point' => $point['external_key'] ?? null, 'message' => $e->getMessage(), ]); $point['synced'] = false; } $syncedPoints[] = $point; } $stub['points'] = $syncedPoints; // Legacy single counter: first point (or dedicated default) for older callers. $first = collect($syncedPoints)->first(fn ($p) => ! empty($p['counter_uuid'])); if ($first) { $stub['counter_uuid'] = $first['counter_uuid']; $stub['counter_external_key'] = $first['external_key']; } $stub['synced'] = ! empty($stub['queue_uuid']) && collect($syncedPoints)->contains(fn ($p) => ! empty($p['synced'])); } catch (\Throwable $e) { Log::warning('care.queue_department_provision_failed', [ 'context' => $context, 'branch_id' => $branch->id, 'message' => $e->getMessage(), ]); } return $stub; } /** * @param array $stub * @return list> */ protected function buildPointsForContext( Organization $organization, string $context, Branch $branch, array $stub, ): array { $owner = (string) $organization->owner_ref; $priorByKey = collect($stub['points'] ?? [])->keyBy(fn ($p) => (string) ($p['external_key'] ?? '')); $points = match ($context) { CareQueueContexts::CONSULTATION => $this->practitionerPoints($organization, $branch, $priorByKey), CareQueueContexts::TRIAGE => $this->memberRolePoints( $organization, $branch, ['nurse'], $context, 'Nurse station', $priorByKey, ), CareQueueContexts::PHARMACY => $this->memberRolePoints( $organization, $branch, ['pharmacist'], $context, 'Pharmacy counter', $priorByKey, ), CareQueueContexts::LABORATORY, CareQueueContexts::IMAGING => $this->memberRolePoints( $organization, $branch, ['lab_technician'], $context, $context === CareQueueContexts::IMAGING ? 'Imaging room' : 'Lab desk', $priorByKey, ), CareQueueContexts::BILLING => $this->memberRolePoints( $organization, $branch, ['cashier'], $context, 'Cashier desk', $priorByKey, ), CareQueueContexts::RECEPTION => $this->memberRolePoints( $organization, $branch, ['receptionist'], $context, 'Reception', $priorByKey, ), default => [], }; // Always keep at least one default point so call-next / least-load have a target. if ($points === []) { $key = CareQueueContexts::pointExternalKey($context, $branch->id, 'default', 0); $prior = $priorByKey->get($key, []); $points[] = [ 'kind' => 'default', 'ref_id' => 0, 'name' => ($stub['name'] ?? ucfirst($context)).' desk', 'destination' => ($stub['name'] ?? ucfirst($context)).' desk', 'staff_ref' => null, 'staff_display_name' => null, 'external_key' => $key, 'counter_uuid' => $prior['counter_uuid'] ?? null, 'synced' => false, ]; } return $points; } /** * @param \Illuminate\Support\Collection> $priorByKey * @return list> */ protected function practitionerPoints(Organization $organization, Branch $branch, $priorByKey): array { $practitioners = Practitioner::owned($organization->owner_ref) ->where('organization_id', $organization->id) ->where('branch_id', $branch->id) ->where('is_active', true) ->orderBy('name') ->get(); $points = []; $roomIndex = 1; foreach ($practitioners as $practitioner) { $key = CareQueueContexts::pointExternalKey( CareQueueContexts::CONSULTATION, $branch->id, 'practitioner', $practitioner->id, ); $prior = $priorByKey->get($key, []); $room = trim((string) ($practitioner->room ?? '')); if ($room === '') { $room = 'Consultation Room '.$roomIndex; } $roomIndex++; $points[] = [ 'kind' => 'practitioner', 'ref_id' => $practitioner->id, 'name' => $practitioner->name, 'destination' => $room, 'staff_ref' => $practitioner->user_ref ?: ('practitioner:'.$practitioner->id), 'staff_display_name' => $practitioner->name, 'specialty' => $practitioner->specialty, 'external_key' => $key, 'counter_uuid' => $prior['counter_uuid'] ?? null, 'synced' => false, ]; } return $points; } /** * @param list $roles * @param \Illuminate\Support\Collection> $priorByKey * @return list> */ protected function memberRolePoints( Organization $organization, Branch $branch, array $roles, string $context, string $destinationPrefix, $priorByKey, ): array { $members = Member::owned($organization->owner_ref) ->where('organization_id', $organization->id) ->whereIn('role', $roles) ->where(function ($q) use ($branch) { $q->whereNull('branch_id')->orWhere('branch_id', $branch->id); }) ->orderBy('id') ->get(); $points = []; $index = 1; foreach ($members as $member) { $key = CareQueueContexts::pointExternalKey($context, $branch->id, 'member', $member->id); $prior = $priorByKey->get($key, []); $label = $destinationPrefix.' '.$index; $points[] = [ 'kind' => 'member', 'ref_id' => $member->id, 'name' => $label, 'destination' => $label, 'staff_ref' => $member->user_ref, 'staff_display_name' => null, 'external_key' => $key, 'counter_uuid' => $prior['counter_uuid'] ?? null, 'synced' => false, ]; $index++; } return $points; } protected function ensureBranchName(string $owner, string $branchName): void { try { $this->queue->ensureBranch($owner, ['name' => $branchName]); } catch (\Illuminate\Http\Client\RequestException $e) { if ($e->response?->status() !== 422) { throw $e; } } } /** * @return array{queue_uuid: ?string, counter_uuid: ?string, queue_external_key: string, counter_external_key: string, synced: bool, points: list>, routing_mode: string}|null */ public function resourcesFor( Organization $organization, string $context, int $branchId, ): ?array { if (CareQueueContexts::isSpecialty($context)) { $queues = data_get($organization->settings, "specialty_module_provisioning.{$context}.queues", []); } else { $queues = data_get($organization->settings, "department_queue_provisioning.{$context}.queues", []); } if (! is_array($queues)) { return null; } $match = collect($queues)->first( fn ($q) => (int) ($q['branch_id'] ?? 0) === $branchId && ($q['active'] ?? true) ); if (! $match) { return null; } $meta = CareQueueContexts::departments()[$context] ?? null; return [ 'queue_uuid' => $match['queue_uuid'] ?? null, 'counter_uuid' => $match['counter_uuid'] ?? null, 'queue_external_key' => (string) ($match['queue_external_key'] ?? CareQueueContexts::queueExternalKey($context, $branchId)), 'counter_external_key' => (string) ($match['counter_external_key'] ?? CareQueueContexts::counterExternalKey($context, $branchId)), 'synced' => (bool) ($match['synced'] ?? false), 'points' => is_array($match['points'] ?? null) ? $match['points'] : [], 'routing_mode' => (string) ($match['routing_mode'] ?? $meta['routing_mode'] ?? 'shared_pool'), ]; } /** * Resolve a service point for a practitioner (consultation) or member staff_ref. * * @return array|null */ public function pointForPractitioner(Organization $organization, int $branchId, int $practitionerId): ?array { $resources = $this->resourcesFor($organization, CareQueueContexts::CONSULTATION, $branchId); if (! $resources) { return null; } return collect($resources['points'])->first( fn ($p) => ($p['kind'] ?? '') === 'practitioner' && (int) ($p['ref_id'] ?? 0) === $practitionerId ); } /** * @return array|null */ public function pointForMember(Organization $organization, string $context, int $branchId, Member $member): ?array { $resources = $this->resourcesFor($organization, $context, $branchId); if (! $resources) { return null; } $byMember = collect($resources['points'])->first( fn ($p) => ($p['kind'] ?? '') === 'member' && (int) ($p['ref_id'] ?? 0) === $member->id ); if ($byMember) { return $byMember; } return collect($resources['points'])->first( fn ($p) => ($p['staff_ref'] ?? null) === $member->user_ref ); } /** * Deterministic least-load / first active point when Care has no explicit assignment. * * @return array|null */ public function defaultPoint(Organization $organization, string $context, int $branchId): ?array { $resources = $this->resourcesFor($organization, $context, $branchId); if (! $resources) { return null; } $points = collect($resources['points'])->filter(fn ($p) => ! empty($p['counter_uuid']) && ($p['synced'] ?? true)); if ($points->isEmpty()) { if (! empty($resources['counter_uuid'])) { return [ 'kind' => 'legacy', 'counter_uuid' => $resources['counter_uuid'], 'external_key' => $resources['counter_external_key'], 'destination' => null, 'staff_display_name' => null, ]; } return null; } return $points->sortBy('ref_id')->first(); } /** * @return array{queue_uuid: ?string, counter_uuid: ?string, queue_external_key: string, counter_external_key: string, synced: bool, points: list>, routing_mode: string}|null */ public function ensure( Organization $organization, string $context, int $branchId, ): ?array { $existing = $this->resourcesFor($organization, $context, $branchId); $expectedMode = CareQueueContexts::departments()[$context]['routing_mode'] ?? (CareQueueContexts::isSpecialty($context) ? 'assigned_only' : null); $modeMatches = ! $expectedMode || ($existing['routing_mode'] ?? null) === $expectedMode; if ($existing && ! empty($existing['queue_uuid']) && ( ! empty($existing['points']) || ! empty($existing['counter_uuid']) ) && $modeMatches) { return $existing; } if (! $this->shouldProvision($organization)) { return $existing; } if (CareQueueContexts::isSpecialty($context)) { return $existing; } if (! CareQueueContexts::isDepartment($context)) { return null; } $this->provisionOrganization($organization->fresh()); return $this->resourcesFor($organization->fresh(), $context, $branchId); } }