plans->canUseQueueIntegration($organization) && (bool) data_get($organization->settings, 'queue_integration_enabled'); } /** * @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 { $queue = CareServiceQueue::query()->updateOrCreate( [ 'organization_id' => $organization->id, 'branch_id' => $branch->id, 'context' => $context, ], [ 'owner_ref' => $owner, 'name' => $meta['name'], 'prefix' => $meta['prefix'], 'routing_mode' => $meta['routing_mode'], 'external_key' => $stub['queue_external_key'], 'is_active' => true, ], ); $stub['queue_uuid'] = $queue->uuid; $stub['department_id'] = null; $points = $this->buildPointsForContext($organization, $context, $branch, $stub); $syncedPoints = []; $seenExternalKeys = []; foreach ($points as $point) { $externalKey = (string) ($point['external_key'] ?? ''); if ($externalKey === '') { continue; } $seenExternalKeys[] = $externalKey; $row = CareServicePoint::query()->updateOrCreate( [ 'service_queue_id' => $queue->id, 'external_key' => $externalKey, ], [ 'owner_ref' => $owner, 'organization_id' => $organization->id, 'name' => (string) ($point['name'] ?? $point['destination'] ?? 'Desk'), 'destination' => $point['destination'] ?? null, 'kind' => (string) ($point['kind'] ?? 'default'), 'ref_id' => isset($point['ref_id']) ? (int) $point['ref_id'] : null, 'staff_ref' => $point['staff_ref'] ?? null, 'staff_display_name' => $point['staff_display_name'] ?? null, 'is_active' => true, ], ); $point['counter_uuid'] = $row->uuid; $point['synced'] = true; $syncedPoints[] = $point; } CareServicePoint::query() ->where('service_queue_id', $queue->id) ->when( $seenExternalKeys !== [], fn ($q) => $q->whereNotIn('external_key', $seenExternalKeys), fn ($q) => $q->whereRaw('1 = 1'), ) ->update(['is_active' => false]); $stub['points'] = $syncedPoints; $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)->isNotEmpty(); } catch (\Throwable $e) { Log::warning('care.native_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 { $priorByKey = collect($stub['points'] ?? [])->keyBy(fn ($p) => (string) ($p['external_key'] ?? '')); $points = match (true) { $context === CareQueueContexts::CONSULTATION => $this->practitionerPoints( $organization, $branch, CareQueueContexts::CONSULTATION, $priorByKey, ), CareQueueContexts::isSpecialty($context) => array_values(array_merge( $this->practitionerPoints( $organization, $branch, $context, $priorByKey, data_get($organization->settings, "specialty_module_provisioning.{$context}.department_ids", []), ), $this->specialtyStagePoints($organization, $branch, $context, $priorByKey), )), $context === CareQueueContexts::TRIAGE => $this->memberRolePoints( $organization, $branch, ['nurse'], $context, 'Nurse station', $priorByKey, ), $context === CareQueueContexts::PHARMACY => $this->memberRolePoints( $organization, $branch, ['pharmacist'], $context, 'Pharmacy counter', $priorByKey, ), in_array($context, [CareQueueContexts::LABORATORY, CareQueueContexts::IMAGING], true) => $this->memberRolePoints( $organization, $branch, ['lab_technician'], $context, $context === CareQueueContexts::IMAGING ? 'Imaging room' : 'Lab desk', $priorByKey, ), $context === CareQueueContexts::BILLING => $this->memberRolePoints( $organization, $branch, ['cashier'], $context, 'Cashier desk', $priorByKey, ), $context === CareQueueContexts::RECEPTION => $this->memberRolePoints( $organization, $branch, ['receptionist'], $context, 'Reception', $priorByKey, ), default => [], }; 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; } /** * Fixed stage points for specialty modules that define distinct queue_point values * (e.g. dentistry waiting / chair / recovery). * * @param \Illuminate\Support\Collection> $priorByKey * @return list> */ protected function specialtyStagePoints( Organization $organization, Branch $branch, string $context, $priorByKey, ): array { $stages = app(SpecialtyShellService::class)->stages($context); $hasQueuePoints = collect($stages)->contains(fn ($s) => is_string($s['queue_point'] ?? null) && $s['queue_point'] !== ''); if (! $hasQueuePoints) { return []; } $seen = []; $points = []; foreach ($stages as $stage) { $queuePoint = $stage['queue_point'] ?? null; if (! is_string($queuePoint) || $queuePoint === '' || isset($seen[$queuePoint])) { continue; } $seen[$queuePoint] = true; $key = CareQueueContexts::pointExternalKey($context, $branch->id, 'stage', $queuePoint); $prior = $priorByKey->get($key, []); $label = (string) ($stage['label'] ?? ucfirst($queuePoint)); $points[] = [ 'kind' => 'stage', 'stage' => $queuePoint, 'ref_id' => 0, 'name' => $label, 'destination' => $label, 'staff_ref' => null, 'staff_display_name' => null, 'external_key' => $key, 'counter_uuid' => $prior['counter_uuid'] ?? null, 'synced' => false, ]; } return $points; } /** * @param list|null $departmentIds * @param \Illuminate\Support\Collection> $priorByKey * @return list> */ protected function practitionerPoints( Organization $organization, Branch $branch, string $context, $priorByKey, ?array $departmentIds = null, ): array { $practitioners = Practitioner::owned($organization->owner_ref) ->where('organization_id', $organization->id) ->where('branch_id', $branch->id) ->where('is_active', true) ->when( is_array($departmentIds) && $departmentIds !== [], fn ($q) => $q->whereIn('department_id', array_map('intval', $departmentIds)), ) ->orderBy('name') ->get(); $points = []; $roomIndex = 1; $roomPrefix = CareQueueContexts::isSpecialty($context) ? 'Bay' : 'Consultation Room'; foreach ($practitioners as $practitioner) { $key = CareQueueContexts::pointExternalKey( $context, $branch->id, 'practitioner', $practitioner->id, ); $prior = $priorByKey->get($key, []); $room = trim((string) ($practitioner->room ?? '')); if ($room === '') { $room = $roomPrefix.' '.$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; } /** * @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 on a queue context. * * When a doctor is linked after queues were first provisioned, ensure() may * early-return with a stale points list — refresh provisioning on miss so * Call next can still reach board waiters assigned to that desk. * * @return array|null */ public function pointForPractitioner( Organization $organization, int $branchId, int $practitionerId, string $context = CareQueueContexts::CONSULTATION, ): ?array { $point = $this->findPractitionerPoint($organization, $context, $branchId, $practitionerId); if ($point && ! empty($point['counter_uuid'])) { return $point; } if (! $this->shouldProvision($organization)) { return $this->practitionerPointFromDb($organization, $context, $branchId, $practitionerId); } if (CareQueueContexts::isSpecialty($context)) { $this->provisionSpecialtyContextBranch($organization->fresh() ?? $organization, $context, $branchId); } elseif (CareQueueContexts::isDepartment($context)) { $this->provisionContextBranch($organization->fresh() ?? $organization, $context, $branchId); } else { return $this->practitionerPointFromDb($organization, $context, $branchId, $practitionerId); } $organization = $organization->fresh() ?? $organization; $point = $this->findPractitionerPoint($organization, $context, $branchId, $practitionerId); if ($point && ! empty($point['counter_uuid'])) { return $point; } return $this->practitionerPointFromDb($organization, $context, $branchId, $practitionerId); } /** * @return array|null */ protected function findPractitionerPoint( Organization $organization, string $context, int $branchId, int $practitionerId, ): ?array { $resources = $this->resourcesFor($organization, $context, $branchId); if (! $resources) { return null; } return collect($resources['points'] ?? [])->first( fn ($p) => ($p['kind'] ?? '') === 'practitioner' && (int) ($p['ref_id'] ?? 0) === $practitionerId ); } /** * @return array|null */ protected function practitionerPointFromDb( Organization $organization, string $context, int $branchId, int $practitionerId, ): ?array { $row = CareServicePoint::query() ->where('organization_id', $organization->id) ->where('kind', 'practitioner') ->where('ref_id', $practitionerId) ->where('is_active', true) ->whereHas( 'serviceQueue', fn ($q) => $q ->where('organization_id', $organization->id) ->where('context', $context) ->where('branch_id', $branchId) ->where('is_active', true), ) ->first(); if (! $row) { return null; } return [ 'kind' => 'practitioner', 'ref_id' => $practitionerId, 'name' => $row->name, 'destination' => $row->destination, 'staff_ref' => $row->staff_ref, 'staff_display_name' => $row->staff_display_name, 'external_key' => $row->external_key, 'counter_uuid' => $row->uuid, 'synced' => true, ]; } /** * @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(); } /** * Ensure Queue resources exist for one context + branch only. * Never fans out across every department × branch (that hung demo queues). * * @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) { if (! CareQueueContexts::isSpecialty($context) || ! empty($existing['points'])) { return $existing; } } if (! $this->shouldProvision($organization)) { return $existing; } if (CareQueueContexts::isSpecialty($context)) { return $this->provisionSpecialtyContextBranch($organization->fresh(), $context, $branchId) ?? $existing; } if (! CareQueueContexts::isDepartment($context)) { return null; } $this->provisionContextBranch($organization->fresh(), $context, $branchId); return $this->resourcesFor($organization->fresh(), $context, $branchId); } /** * Provision a single specialty queue for one branch (idempotent). * * @return array|null */ public function provisionSpecialtyContextBranch(Organization $organization, string $context, int $branchId): ?array { if (! CareQueueContexts::isSpecialty($context) || ! $this->shouldProvision($organization)) { return $this->resourcesFor($organization, $context, $branchId); } $definition = config("care.specialty_modules.{$context}"); if (! is_array($definition)) { return null; } $branch = Branch::owned((string) $organization->owner_ref) ->where('organization_id', $organization->id) ->whereKey($branchId) ->first(); if (! $branch) { return null; } $meta = [ 'name' => (string) ($definition['queue_name'] ?? $definition['label'] ?? $context), 'prefix' => (string) ($definition['queue_prefix'] ?? strtoupper(substr($context, 0, 3))), 'routing_mode' => 'assigned_only', 'type' => 'consultation', ]; $provisioning = (array) data_get($organization->settings, 'specialty_module_provisioning', []); $record = is_array($provisioning[$context] ?? null) ? $provisioning[$context] : []; $queues = is_array($record['queues'] ?? null) ? $record['queues'] : []; $byBranch = collect($queues)->keyBy(fn ($q) => (string) ($q['branch_id'] ?? '')); $prior = $byBranch->get((string) $branch->id, []); if (! is_array($prior)) { $prior = []; } $prior['module'] = $context; $stub = $this->provisionBranchDepartment( $organization, (string) $organization->owner_ref, $context, $meta, $branch, $prior, ); $byBranch->put((string) $branch->id, $stub); $provisioning[$context] = array_merge($record, [ 'active' => true, 'queues' => $byBranch->values()->all(), ]); $settings = $organization->settings ?? []; $settings['specialty_module_provisioning'] = $provisioning; $organization->update(['settings' => $settings]); return $stub; } /** * Provision a single department queue for one branch (idempotent). */ public function provisionContextBranch(Organization $organization, string $context, int $branchId): ?array { if (! $this->shouldProvision($organization)) { return $this->resourcesFor($organization, $context, $branchId); } $meta = CareQueueContexts::departments()[$context] ?? null; if ($meta === null) { return null; } $branch = Branch::owned((string) $organization->owner_ref) ->where('organization_id', $organization->id) ->whereKey($branchId) ->first(); if (! $branch) { return null; } $provisioning = (array) data_get($organization->settings, 'department_queue_provisioning', []); $queues = is_array($provisioning[$context]['queues'] ?? null) ? $provisioning[$context]['queues'] : []; $byBranch = collect($queues)->keyBy(fn ($q) => (string) ($q['branch_id'] ?? '')); $prior = $byBranch->get((string) $branch->id, []); $stub = $this->provisionBranchDepartment( $organization, (string) $organization->owner_ref, $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 $stub; } }