diff --git a/app/Services/Care/DemoTenantSeeder.php b/app/Services/Care/DemoTenantSeeder.php index 263b598..6608535 100644 --- a/app/Services/Care/DemoTenantSeeder.php +++ b/app/Services/Care/DemoTenantSeeder.php @@ -456,6 +456,8 @@ class DemoTenantSeeder private function seedSpecialtyModules(Organization $organization, string $ownerRef): void { try { + // Activates dentistry (depts + stubs). When queue_integration_enabled and + // QUEUE_API_* are configured, SpecialtyModuleService creates real Queue counters. app(SpecialtyModuleService::class)->activate($organization->fresh(), $ownerRef, 'dentistry'); } catch (\Throwable) { // Demo seed should not fail if Queue API is unreachable; settings flag remains. diff --git a/app/Services/Care/ServiceQueuePresenter.php b/app/Services/Care/ServiceQueuePresenter.php index 2389ab9..ea9ff7c 100644 --- a/app/Services/Care/ServiceQueuePresenter.php +++ b/app/Services/Care/ServiceQueuePresenter.php @@ -108,8 +108,13 @@ class ServiceQueuePresenter } } - $counters = $this->filterCounters($counters, $branchName, $context); + $stubCounterUuid = collect($stubs) + ->map(fn ($s) => (string) ($s['counter_uuid'] ?? '')) + ->first(fn ($uuid) => $uuid !== ''); + + $counters = $this->filterCounters($counters, $branchName, $context, $stubCounterUuid); $counterUuid = $preferredCounterUuid + ?: ($stubCounterUuid ?: '') ?: (string) ($counters[0]['uuid'] ?? ''); if ($counterUuid !== '' && ! collect($counters)->contains(fn ($c) => ($c['uuid'] ?? '') === $counterUuid)) { @@ -165,11 +170,21 @@ class ServiceQueuePresenter * @param list> $counters * @return list> */ - protected function filterCounters(array $counters, ?string $branchName, string $context): array - { - $keywords = $this->keywordsFor($context); + protected function filterCounters( + array $counters, + ?string $branchName, + string $context, + ?string $preferredUuid = null, + ): array { + $all = collect($counters); + $filtered = $all; - $filtered = collect($counters); + if ($preferredUuid) { + $linked = $all->first(fn ($c) => ($c['uuid'] ?? '') === $preferredUuid); + if ($linked) { + return [$linked]; + } + } if ($branchName) { $branchFiltered = $filtered->filter(function ($counter) use ($branchName) { @@ -182,11 +197,16 @@ class ServiceQueuePresenter } } + $keywords = $this->keywordsFor($context); if ($keywords !== []) { $keywordFiltered = $filtered->filter(function ($counter) use ($keywords) { + $queueNames = collect($counter['queues'] ?? [])->map(function ($q) { + return is_array($q) ? (string) ($q['name'] ?? '') : (string) $q; + })->implode(' '); + $haystack = strtolower(implode(' ', [ (string) ($counter['name'] ?? ''), - collect($counter['queues'] ?? [])->pluck('name')->implode(' '), + $queueNames, ])); foreach ($keywords as $keyword) { diff --git a/app/Services/Care/SpecialtyModuleService.php b/app/Services/Care/SpecialtyModuleService.php index 565155a..0869150 100644 --- a/app/Services/Care/SpecialtyModuleService.php +++ b/app/Services/Care/SpecialtyModuleService.php @@ -157,7 +157,23 @@ class SpecialtyModuleService $organization->update(['settings' => $settings]); if (data_get($organization->fresh()->settings, 'queue_integration_enabled') && $this->queue->configured()) { - $this->queue->syncSpecialtyQueueStubs($ownerRef, $queueStubs); + try { + $synced = $this->queue->syncSpecialtyQueueStubs($ownerRef, $queueStubs); + $settings = $organization->fresh()->settings ?? []; + $provisioning = is_array($settings['specialty_module_provisioning'] ?? null) + ? $settings['specialty_module_provisioning'] + : []; + $provisioning[$key] = array_merge($provisioning[$key] ?? [], [ + 'queues' => $synced, + ]); + $settings['specialty_module_provisioning'] = $provisioning; + $organization->update(['settings' => $settings]); + } catch (\Throwable $e) { + Log::warning('specialty_module.queue_sync_failed', [ + 'key' => $key, + 'message' => $e->getMessage(), + ]); + } } } @@ -185,8 +201,27 @@ class SpecialtyModuleService } $queues = is_array($record['queues'] ?? null) ? $record['queues'] : []; - foreach ($queues as $i => $queue) { - $queues[$i]['active'] = false; + + if ( + data_get($settings, 'queue_integration_enabled') + && $this->queue->configured() + && $queues !== [] + ) { + try { + $queues = $this->queue->deactivateSpecialtyQueueStubs($ownerRef, $queues); + } catch (\Throwable $e) { + Log::warning('specialty_module.queue_deactivate_failed', [ + 'key' => $key, + 'message' => $e->getMessage(), + ]); + foreach ($queues as $i => $queue) { + $queues[$i]['active'] = false; + } + } + } else { + foreach ($queues as $i => $queue) { + $queues[$i]['active'] = false; + } } $provisioning[$key] = array_merge($record, [ @@ -246,8 +281,8 @@ class SpecialtyModuleService } /** - * Care-side queue stubs (branch-aware). Full counter creation lives in Queue admin; - * stubs document intended queues and feed embedded panels / future API sync. + * Care-side queue stubs (branch-aware). When Queue integration is enabled, + * activate() creates/links real Queue queues + counters and stores UUIDs here. * * @param array $definition * @return list> @@ -263,8 +298,16 @@ class SpecialtyModuleService ->where('is_active', true) ->get(); + $existingByBranch = collect( + data_get($organization->settings, "specialty_module_provisioning.{$key}.queues", []) + )->keyBy(fn ($q) => (string) ($q['branch_id'] ?? '')); + $stubs = []; foreach ($branches as $branch) { + $prior = is_array($existingByBranch->get((string) $branch->id)) + ? $existingByBranch->get((string) $branch->id) + : []; + $stubs[] = [ 'module' => $key, 'branch_id' => $branch->id, @@ -273,6 +316,12 @@ class SpecialtyModuleService 'prefix' => (string) ($definition['queue_prefix'] ?? strtoupper(substr($key, 0, 3))), 'active' => true, 'synced' => false, + 'queue_uuid' => $prior['queue_uuid'] ?? null, + 'counter_uuid' => $prior['counter_uuid'] ?? null, + 'queue_external_key' => $prior['queue_external_key'] + ?? "care:specialty:{$key}:queue:{$branch->id}", + 'counter_external_key' => $prior['counter_external_key'] + ?? "care:specialty:{$key}:counter:{$branch->id}", ]; } diff --git a/app/Services/Queue/QueueClient.php b/app/Services/Queue/QueueClient.php index 3ba0398..985f697 100644 --- a/app/Services/Queue/QueueClient.php +++ b/app/Services/Queue/QueueClient.php @@ -4,6 +4,7 @@ namespace App\Services\Queue; use Illuminate\Http\Client\RequestException; use Illuminate\Support\Facades\Http; +use Illuminate\Support\Facades\Log; class QueueClient { @@ -100,6 +101,79 @@ class QueueClient return (array) ($response->json('data') ?? []); } + /** + * @return list> + */ + public function branches(string $owner): array + { + $response = $this->http($owner)->get($this->base().'/branches'); + $response->throw(); + + return (array) ($response->json('data') ?? []); + } + + /** + * @param array $payload + * @return array + */ + public function ensureBranch(string $owner, array $payload): array + { + $response = $this->http($owner)->post($this->base().'/branches', $payload); + $response->throw(); + + return (array) ($response->json('data') ?? []); + } + + /** + * @param array $payload + * @return array + */ + public function ensureQueue(string $owner, array $payload): array + { + $response = $this->http($owner)->post($this->base().'/queues', $payload); + $response->throw(); + + return (array) ($response->json('data') ?? []); + } + + /** + * @param array $payload + * @return array + */ + public function ensureCounter(string $owner, array $payload): array + { + $response = $this->http($owner)->post($this->base().'/counters', $payload); + $response->throw(); + + return (array) ($response->json('data') ?? []); + } + + /** + * Soft-deactivate a Queue counter without destroying history. + * + * @return array + */ + public function updateCounter(string $owner, string $counterUuid, array $payload): array + { + $response = $this->http($owner)->patch($this->base().'/counters/'.$counterUuid, $payload); + $response->throw(); + + return (array) ($response->json('data') ?? []); + } + + /** + * Soft-deactivate a Queue service queue without destroying history. + * + * @return array + */ + public function updateQueue(string $owner, string $queueUuid, array $payload): array + { + $response = $this->http($owner)->patch($this->base().'/queues/'.$queueUuid, $payload); + $response->throw(); + + return (array) ($response->json('data') ?? []); + } + /** * @return array */ @@ -124,8 +198,8 @@ class QueueClient } /** - * Best-effort specialty queue sync. Care integration API is read/console-only today, - * so stubs remain Care-side until Queue exposes create-queue for service tokens. + * Create or link real Queue queues/counters for Care specialty stubs. + * Idempotent via external_key (and Queue-side name+branch fallback). * * @param list> $stubs * @return list> @@ -137,20 +211,128 @@ class QueueClient } try { - $existing = collect($this->counters($owner)) - ->flatMap(fn ($c) => $c['queues'] ?? []) - ->map(fn ($q) => strtolower((string) ($q['name'] ?? ''))) - ->filter() - ->all(); + if (! $this->isLinked($owner)) { + return $stubs; + } } catch (\Throwable) { return $stubs; } foreach ($stubs as $i => $stub) { - $name = strtolower((string) ($stub['name'] ?? '')); - $stubs[$i]['synced'] = $name !== '' && in_array($name, $existing, true); + if (! ($stub['active'] ?? true)) { + continue; + } + + try { + $stubs[$i] = $this->ensureSpecialtyStub($owner, $stub); + } catch (\Throwable $e) { + Log::warning('queue.specialty_stub_sync_failed', [ + 'owner' => $owner, + 'stub' => $stub['name'] ?? null, + 'branch' => $stub['branch_name'] ?? null, + 'message' => $e->getMessage(), + ]); + $stubs[$i]['synced'] = false; + } } return $stubs; } + + /** + * Soft-deactivate Queue resources linked on Care stubs (leaves history intact). + * + * @param list> $stubs + * @return list> + */ + public function deactivateSpecialtyQueueStubs(string $owner, array $stubs): array + { + if (! $this->configured() || $stubs === []) { + return $stubs; + } + + foreach ($stubs as $i => $stub) { + try { + if (! empty($stub['counter_uuid'])) { + $this->updateCounter($owner, (string) $stub['counter_uuid'], ['is_active' => false]); + } + if (! empty($stub['queue_uuid'])) { + $this->updateQueue($owner, (string) $stub['queue_uuid'], ['is_active' => false]); + } + } catch (\Throwable $e) { + Log::warning('queue.specialty_stub_deactivate_failed', [ + 'owner' => $owner, + 'counter_uuid' => $stub['counter_uuid'] ?? null, + 'queue_uuid' => $stub['queue_uuid'] ?? null, + 'message' => $e->getMessage(), + ]); + } + + $stubs[$i]['active'] = false; + } + + return $stubs; + } + + /** + * @param array $stub + * @return array + */ + protected function ensureSpecialtyStub(string $owner, array $stub): array + { + $branchName = trim((string) ($stub['branch_name'] ?? '')); + $name = trim((string) ($stub['name'] ?? '')); + $prefix = trim((string) ($stub['prefix'] ?? 'A')); + $module = (string) ($stub['module'] ?? 'specialty'); + $careBranchId = (string) ($stub['branch_id'] ?? '0'); + + if ($branchName === '' || $name === '') { + $stub['synced'] = false; + + return $stub; + } + + $queueKey = (string) ($stub['queue_external_key'] ?? "care:specialty:{$module}:queue:{$careBranchId}"); + $counterKey = (string) ($stub['counter_external_key'] ?? "care:specialty:{$module}:counter:{$careBranchId}"); + + try { + $this->ensureBranch($owner, ['name' => $branchName]); + } catch (RequestException $e) { + // Free Queue plans may already be at branch cap — continue if branch already exists. + if ($e->response?->status() !== 422) { + throw $e; + } + $existing = collect($this->branches($owner))->first( + fn ($b) => strcasecmp((string) ($b['name'] ?? ''), $branchName) === 0 + ); + if (! $existing) { + throw $e; + } + } + + $queue = $this->ensureQueue($owner, [ + 'name' => $name, + 'prefix' => $prefix !== '' ? $prefix : 'A', + 'branch_name' => $branchName, + 'strategy' => 'fifo', + 'external_key' => $queueKey, + 'is_active' => true, + ]); + + $counter = $this->ensureCounter($owner, [ + 'name' => $name.' counter', + 'branch_name' => $branchName, + 'queue_uuids' => array_values(array_filter([(string) ($queue['uuid'] ?? '')])), + 'external_key' => $counterKey, + 'is_active' => true, + ]); + + $stub['queue_external_key'] = $queueKey; + $stub['counter_external_key'] = $counterKey; + $stub['queue_uuid'] = $queue['uuid'] ?? null; + $stub['counter_uuid'] = $counter['uuid'] ?? null; + $stub['synced'] = ! empty($stub['queue_uuid']) && ! empty($stub['counter_uuid']); + + return $stub; + } } diff --git a/tests/Feature/CareSpecialtyModulesTest.php b/tests/Feature/CareSpecialtyModulesTest.php index cc31d60..f237c38 100644 --- a/tests/Feature/CareSpecialtyModulesTest.php +++ b/tests/Feature/CareSpecialtyModulesTest.php @@ -85,6 +85,134 @@ class CareSpecialtyModulesTest extends TestCase $this->assertSame($this->branch->id, $stubs[0]['branch_id']); } + public function test_activating_with_queue_integration_creates_real_counters(): void + { + config([ + 'care.queue.api_url' => 'https://queue.test/api/v1', + 'care.queue.api_key' => 'care-test-key', + ]); + + $this->organization->update([ + 'settings' => array_merge($this->organization->settings ?? [], [ + 'queue_integration_enabled' => true, + ]), + ]); + + $queueUuid = 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa'; + $counterUuid = 'bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb'; + + Http::fake([ + 'https://queue.test/api/v1/integrations/status*' => Http::response([ + 'data' => [ + 'provisioned' => true, + 'integrations' => ['care' => true], + ], + ], 200), + 'https://queue.test/api/v1/branches*' => Http::sequence() + ->push(['data' => ['id' => 1, 'name' => 'Main', 'is_active' => true]], 200) + ->push(['data' => ['id' => 1, 'name' => 'Main', 'is_active' => true]], 200), + 'https://queue.test/api/v1/queues*' => Http::response([ + 'data' => [ + 'uuid' => $queueUuid, + 'name' => 'Dentistry', + 'prefix' => 'DEN', + 'external_key' => 'care:specialty:dentistry:queue:'.$this->branch->id, + ], + ], 201), + 'https://queue.test/api/v1/counters*' => Http::response([ + 'data' => [ + 'uuid' => $counterUuid, + 'name' => 'Dentistry counter', + 'branch' => 'Main', + 'queues' => [['uuid' => $queueUuid, 'name' => 'Dentistry']], + ], + ], 201), + ]); + + app(SpecialtyModuleService::class)->activate( + $this->organization->fresh(), + $this->owner->public_id, + 'dentistry', + ); + + $this->organization->refresh(); + $stub = data_get($this->organization->settings, 'specialty_module_provisioning.dentistry.queues.0'); + $this->assertTrue((bool) ($stub['synced'] ?? false)); + $this->assertSame($queueUuid, $stub['queue_uuid'] ?? null); + $this->assertSame($counterUuid, $stub['counter_uuid'] ?? null); + + Http::assertSent(function ($request) { + return $request->method() === 'POST' + && str_contains($request->url(), '/queues') + && ($request['external_key'] ?? null) === 'care:specialty:dentistry:queue:'.$this->branch->id; + }); + } + + public function test_reactivation_is_idempotent_for_queue_external_keys(): void + { + config([ + 'care.queue.api_url' => 'https://queue.test/api/v1', + 'care.queue.api_key' => 'care-test-key', + ]); + + $this->organization->update([ + 'settings' => array_merge($this->organization->settings ?? [], [ + 'queue_integration_enabled' => true, + 'specialty_module_provisioning' => [ + 'dentistry' => [ + 'active' => false, + 'queues' => [[ + 'module' => 'dentistry', + 'branch_id' => $this->branch->id, + 'branch_name' => 'Main', + 'name' => 'Dentistry', + 'prefix' => 'DEN', + 'active' => false, + 'synced' => true, + 'queue_uuid' => 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa', + 'counter_uuid' => 'bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb', + 'queue_external_key' => 'care:specialty:dentistry:queue:'.$this->branch->id, + 'counter_external_key' => 'care:specialty:dentistry:counter:'.$this->branch->id, + ]], + ], + ], + ]), + ]); + + Http::fake([ + 'https://queue.test/api/v1/integrations/status*' => Http::response([ + 'data' => ['provisioned' => true, 'integrations' => ['care' => true]], + ], 200), + 'https://queue.test/api/v1/branches*' => Http::response([ + 'data' => ['id' => 1, 'name' => 'Main', 'is_active' => true], + ], 200), + 'https://queue.test/api/v1/queues*' => Http::response([ + 'data' => [ + 'uuid' => 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa', + 'name' => 'Dentistry', + 'external_key' => 'care:specialty:dentistry:queue:'.$this->branch->id, + ], + ], 200), + 'https://queue.test/api/v1/counters*' => Http::response([ + 'data' => [ + 'uuid' => 'bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb', + 'name' => 'Dentistry counter', + ], + ], 200), + ]); + + app(SpecialtyModuleService::class)->activate( + $this->organization->fresh(), + $this->owner->public_id, + 'dentistry', + ); + + $this->organization->refresh(); + $stub = data_get($this->organization->settings, 'specialty_module_provisioning.dentistry.queues.0'); + $this->assertSame('care:specialty:dentistry:queue:'.$this->branch->id, $stub['queue_external_key']); + $this->assertSame('aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa', $stub['queue_uuid']); + } + public function test_deactivating_hides_department_without_deleting(): void { Http::fake();