diff --git a/.cursor/rules/specialty-modules.mdc b/.cursor/rules/specialty-modules.mdc index d8abd3c..7b29ab5 100644 --- a/.cursor/rules/specialty-modules.mdc +++ b/.cursor/rules/specialty-modules.mdc @@ -12,7 +12,8 @@ Full standard: `docs/specialty-module-design-standard.md`. - Specialty modules are **native Care extensions**, not standalone apps. - Design **workflow first** (stages, queues, payment checkpoints, completion), then screens. -- Reuse Care engines: Workflow, Queue, Billing, Appointments, Documents, Permissions, Reports. +- Reuse Care engines: Workflow, **Care Queue Engine (in-app)**, Billing, Appointments, Documents, Permissions, Reports. +- Do **not** depend on Ladill Queue for Care or specialty queues (see `docs/care-queue-engine-and-specialty-plan.md`). - Use **identical primary actions** (Call Next, Start Consultation, Order Test, Prescribe, Generate Invoice, Discharge, …). - Prefer extending **`Visit`** as the clinical episode unit before inventing a separate Episode model. - Contribute to one **patient timeline** — do not create parallel charts. @@ -20,7 +21,7 @@ Full standard: `docs/specialty-module-design-standard.md`. ## Current reality -Today specialties are a thin layer: catalog activate → departments/queue stubs → generic `specialty/show` waiting list. Do **not** build deep per-specialty UIs until the shared specialty shell exists (KPI dashboard, patient header, left nav Overview/Visits/History/Module Tabs, billing catalog hooks). +Today specialties are a thin layer and clinical call-next still adapts to Ladill Queue via `QueueClient`. Program direction: replace that with the Care Queue Engine, then build the shared specialty shell on it. Do **not** deepen per-specialty UIs on the remote Queue adapter. ## When adding or deepening a module diff --git a/.env.example b/.env.example index 271ed92..84d32f1 100644 --- a/.env.example +++ b/.env.example @@ -26,7 +26,10 @@ SESSION_LIFETIME=1440 CACHE_STORE=redis QUEUE_CONNECTION=database -# Ladill Queue service API (in-app service queue console) +# Care Queue Engine: native (in-app, default) | remote (legacy Ladill Queue HTTP) +CARE_QUEUE_DRIVER=native + +# Legacy Ladill Queue API (only when CARE_QUEUE_DRIVER=remote) QUEUE_API_URL=https://queue.ladill.com/api/v1 QUEUE_API_KEY_CARE= diff --git a/app/Http/Controllers/Care/SettingsController.php b/app/Http/Controllers/Care/SettingsController.php index 9401980..023c94d 100644 --- a/app/Http/Controllers/Care/SettingsController.php +++ b/app/Http/Controllers/Care/SettingsController.php @@ -144,7 +144,7 @@ class SettingsController extends Controller $settings[AppointmentPatientNotifier::SETTING_VIDEO_SMS] = $request->boolean('notify_video_scheduled_sms'); $settings[AppointmentPatientNotifier::SETTING_VIDEO_EMAIL] = $request->boolean('notify_video_scheduled_email'); - if ($wantsQueue && $queue->configured()) { + if ($wantsQueue && config('care.queue.driver', 'native') !== 'native' && $queue->configured()) { $branch = Branch::owned($owner)->where('organization_id', $organization->id)->orderBy('name')->first(); try { $queue->enable($owner, $organization->name, $branch?->name); @@ -186,9 +186,9 @@ class SettingsController extends Controller } } - // Queue department sync + waiting-ticket backfill can take minutes over HTTP. - // Never block the settings response on it — run after the redirect goes out. - if ($wantsQueue && $queue->configured()) { + // Queue department sync + waiting-ticket backfill — run after the redirect. + $queueReady = config('care.queue.driver', 'native') === 'native' || $queue->configured(); + if ($wantsQueue && $queueReady) { $organizationId = (int) $organization->id; $backfillTickets = $queueJustEnabled; dispatch(function () use ($organizationId, $owner, $backfillTickets) { diff --git a/app/Models/CareQueueTicket.php b/app/Models/CareQueueTicket.php new file mode 100644 index 0000000..39c1059 --- /dev/null +++ b/app/Models/CareQueueTicket.php @@ -0,0 +1,70 @@ + 'array', + 'called_at' => 'datetime', + 'serving_at' => 'datetime', + 'completed_at' => 'datetime', + 'care_entity_id' => 'integer', + 'visit_id' => 'integer', + ]; + } + + protected static function booted(): void + { + static::creating(function (CareQueueTicket $ticket) { + if (! $ticket->uuid) { + $ticket->uuid = (string) Str::uuid(); + } + }); + } + + public function organization(): BelongsTo + { + return $this->belongsTo(Organization::class, 'organization_id'); + } + + public function serviceQueue(): BelongsTo + { + return $this->belongsTo(CareServiceQueue::class, 'service_queue_id'); + } + + public function servicePoint(): BelongsTo + { + return $this->belongsTo(CareServicePoint::class, 'service_point_id'); + } +} diff --git a/app/Models/CareServicePoint.php b/app/Models/CareServicePoint.php new file mode 100644 index 0000000..84f3fdc --- /dev/null +++ b/app/Models/CareServicePoint.php @@ -0,0 +1,54 @@ + 'boolean', + 'ref_id' => 'integer', + ]; + } + + protected static function booted(): void + { + static::creating(function (CareServicePoint $point) { + if (! $point->uuid) { + $point->uuid = (string) Str::uuid(); + } + }); + } + + public function organization(): BelongsTo + { + return $this->belongsTo(Organization::class, 'organization_id'); + } + + public function serviceQueue(): BelongsTo + { + return $this->belongsTo(CareServiceQueue::class, 'service_queue_id'); + } + + public function tickets(): HasMany + { + return $this->hasMany(CareQueueTicket::class, 'service_point_id'); + } +} diff --git a/app/Models/CareServiceQueue.php b/app/Models/CareServiceQueue.php new file mode 100644 index 0000000..0dbf05d --- /dev/null +++ b/app/Models/CareServiceQueue.php @@ -0,0 +1,58 @@ + 'boolean', + 'next_number' => 'integer', + ]; + } + + protected static function booted(): void + { + static::creating(function (CareServiceQueue $queue) { + if (! $queue->uuid) { + $queue->uuid = (string) Str::uuid(); + } + }); + } + + public function organization(): BelongsTo + { + return $this->belongsTo(Organization::class, 'organization_id'); + } + + public function branch(): BelongsTo + { + return $this->belongsTo(Branch::class, 'branch_id'); + } + + public function points(): HasMany + { + return $this->hasMany(CareServicePoint::class, 'service_queue_id'); + } + + public function tickets(): HasMany + { + return $this->hasMany(CareQueueTicket::class, 'service_queue_id'); + } +} diff --git a/app/Services/Care/CareQueueBridge.php b/app/Services/Care/CareQueueBridge.php index 749a114..d266d40 100644 --- a/app/Services/Care/CareQueueBridge.php +++ b/app/Services/Care/CareQueueBridge.php @@ -17,24 +17,35 @@ use Illuminate\Http\Client\RequestException; use Illuminate\Support\Facades\Log; /** - * Bridges Care workflow entities to Ladill Queue tickets for department - * service points (not a single shared consultation counter per branch). + * Bridges Care workflow entities to service-queue tickets (native Care Queue + * Engine by default; optional legacy Ladill Queue HTTP when driver=remote). */ class CareQueueBridge { public function __construct( protected QueueClient $queue, + protected CareQueueEngine $engine, protected CareQueueProvisioner $provisioner, protected PlanService $plans, protected SpecialtyModuleService $specialties, protected WorkflowQueueGate $workflowGate, ) {} + public function usesNative(): bool + { + return config('care.queue.driver', 'native') === 'native'; + } + public function isEnabled(Organization $organization): bool { - return $this->plans->canUseQueueIntegration($organization) - && (bool) data_get($organization->settings, 'queue_integration_enabled') - && $this->queue->configured(); + $enabled = $this->plans->canUseQueueIntegration($organization) + && (bool) data_get($organization->settings, 'queue_integration_enabled'); + + if (! $enabled) { + return false; + } + + return $this->usesNative() || $this->queue->configured(); } public function contextForAppointment(Organization $organization, Appointment $appointment): string @@ -339,16 +350,15 @@ class CareQueueBridge return ['ticket' => null, 'entity' => null, 'resources' => $resources]; } - $owner = (string) $organization->owner_ref; $callResources = [ 'queue_uuid' => $resources['queue_uuid'], 'counter_uuid' => $point['counter_uuid'], ]; - $ticket = $this->attemptCallNext($owner, $callResources); + $ticket = $this->attemptCallNext($organization, $callResources); if (! $ticket) { $this->syncMissingTickets($organization, $context, $branchId, 25); - $ticket = $this->attemptCallNext($owner, $callResources); + $ticket = $this->attemptCallNext($organization, $callResources); } if (! $ticket) { @@ -367,11 +377,19 @@ class CareQueueBridge * @param array{queue_uuid?: ?string, counter_uuid?: ?string} $resources * @return array|null */ - protected function attemptCallNext(string $owner, array $resources): ?array + protected function attemptCallNext(Organization $organization, array $resources): ?array { try { + if ($this->usesNative()) { + return $this->engine->callNext( + $organization, + (string) $resources['queue_uuid'], + (string) $resources['counter_uuid'], + ); + } + return $this->queue->callNext( - $owner, + (string) $organization->owner_ref, (string) $resources['queue_uuid'], (string) $resources['counter_uuid'], ); @@ -380,6 +398,12 @@ class CareQueueBridge 'message' => $e->getMessage(), ]); + return null; + } catch (\Throwable $e) { + Log::warning('care.queue_call_next_failed', [ + 'message' => $e->getMessage(), + ]); + return null; } } @@ -573,6 +597,10 @@ class CareQueueBridge } try { + if ($this->usesNative()) { + return $this->engine->issueTicket($organization, $payload); + } + return $this->queue->issueTicket($owner, $payload); } catch (\Throwable $e) { Log::warning('care.queue_issue_failed', [ @@ -622,9 +650,15 @@ class CareQueueBridge } try { - $ticket = $this->queue->ticketAction((string) $organization->owner_ref, $uuid, [ - 'action' => $action, - ]); + if ($this->usesNative()) { + $ticket = $this->engine->ticketAction($organization, $uuid, [ + 'action' => $action, + ]); + } else { + $ticket = $this->queue->ticketAction((string) $organization->owner_ref, $uuid, [ + 'action' => $action, + ]); + } $this->applyTicket($entity, $ticket); return $ticket; diff --git a/app/Services/Care/CareQueueEngine.php b/app/Services/Care/CareQueueEngine.php new file mode 100644 index 0000000..15d1054 --- /dev/null +++ b/app/Services/Care/CareQueueEngine.php @@ -0,0 +1,266 @@ +, + * assigned_counter_id?: ?string + * } $payload + * @return array + */ + public function issueTicket(Organization $organization, array $payload): array + { + $queueUuid = (string) ($payload['service_queue_id'] ?? ''); + if ($queueUuid === '') { + throw new InvalidArgumentException('service_queue_id is required.'); + } + + return DB::transaction(function () use ($organization, $payload, $queueUuid) { + /** @var CareServiceQueue|null $queue */ + $queue = CareServiceQueue::query() + ->where('organization_id', $organization->id) + ->where('uuid', $queueUuid) + ->lockForUpdate() + ->first(); + + if (! $queue || ! $queue->is_active) { + throw new InvalidArgumentException('Service queue not found.'); + } + + $point = null; + $counterUuid = (string) ($payload['assigned_counter_id'] ?? ''); + if ($counterUuid !== '') { + $point = CareServicePoint::query() + ->where('organization_id', $organization->id) + ->where('service_queue_id', $queue->id) + ->where('uuid', $counterUuid) + ->where('is_active', true) + ->first(); + if (! $point) { + throw new InvalidArgumentException('Service point not found.'); + } + } + + $seq = (int) $queue->next_number; + $queue->forceFill(['next_number' => $seq + 1])->save(); + $ticketNumber = $queue->prefix.str_pad((string) $seq, 3, '0', STR_PAD_LEFT); + + $metadata = is_array($payload['metadata'] ?? null) ? $payload['metadata'] : []; + + $ticket = CareQueueTicket::create([ + 'owner_ref' => $organization->owner_ref, + 'organization_id' => $organization->id, + 'service_queue_id' => $queue->id, + 'service_point_id' => $point?->id, + 'ticket_number' => $ticketNumber, + 'status' => CareQueueTicket::STATUS_WAITING, + 'priority' => (string) ($payload['priority'] ?? 'walk_in'), + 'source' => (string) ($payload['source'] ?? 'api'), + 'customer_name' => $payload['customer_name'] ?? null, + 'customer_phone' => $payload['customer_phone'] ?? null, + 'care_entity' => $metadata['care_entity'] ?? null, + 'care_entity_uuid' => $metadata['care_entity_uuid'] ?? null, + 'care_entity_id' => isset($metadata['care_entity_id']) ? (int) $metadata['care_entity_id'] : null, + 'visit_id' => isset($metadata['visit_id']) ? (int) $metadata['visit_id'] : null, + 'metadata' => $metadata, + ]); + + $ticket->setRelation('servicePoint', $point); + $ticket->setRelation('serviceQueue', $queue); + + return $this->toApiArray($ticket); + }); + } + + /** + * @return array|null + */ + public function callNext(Organization $organization, string $queueUuid, string $counterUuid): ?array + { + return DB::transaction(function () use ($organization, $queueUuid, $counterUuid) { + /** @var CareServiceQueue|null $queue */ + $queue = CareServiceQueue::query() + ->where('organization_id', $organization->id) + ->where('uuid', $queueUuid) + ->lockForUpdate() + ->first(); + + if (! $queue || ! $queue->is_active) { + return null; + } + + /** @var CareServicePoint|null $point */ + $point = CareServicePoint::query() + ->where('organization_id', $organization->id) + ->where('service_queue_id', $queue->id) + ->where('uuid', $counterUuid) + ->where('is_active', true) + ->first(); + + if (! $point) { + return null; + } + + $waiting = CareQueueTicket::query() + ->where('service_queue_id', $queue->id) + ->where('status', CareQueueTicket::STATUS_WAITING) + ->when( + $queue->routing_mode === 'assigned_only', + fn ($q) => $q->where('service_point_id', $point->id), + fn ($q) => $q->where(function ($inner) use ($point) { + $inner->whereNull('service_point_id') + ->orWhere('service_point_id', $point->id); + }), + ) + ->orderByRaw("CASE priority WHEN 'appointment' THEN 0 WHEN 'walk_in' THEN 1 ELSE 2 END") + ->orderBy('id') + ->lockForUpdate() + ->first(); + + if (! $waiting) { + return null; + } + + $waiting->forceFill([ + 'service_point_id' => $point->id, + 'status' => CareQueueTicket::STATUS_CALLED, + 'called_at' => now(), + ])->save(); + + $waiting->setRelation('servicePoint', $point); + $waiting->setRelation('serviceQueue', $queue); + + return $this->toApiArray($waiting); + }); + } + + /** + * @param array{action: string} $payload + * @return array + */ + public function ticketAction(Organization $organization, string $ticketUuid, array $payload): array + { + $action = (string) ($payload['action'] ?? ''); + + return DB::transaction(function () use ($organization, $ticketUuid, $action) { + /** @var CareQueueTicket|null $ticket */ + $ticket = CareQueueTicket::query() + ->where('organization_id', $organization->id) + ->where('uuid', $ticketUuid) + ->lockForUpdate() + ->first(); + + if (! $ticket) { + throw new InvalidArgumentException('Ticket not found.'); + } + + $ticket->loadMissing('servicePoint', 'serviceQueue'); + + match ($action) { + 'start' => $this->markServing($ticket), + 'complete' => $this->markCompleted($ticket), + 'recall' => $this->markCalled($ticket), + 'skip' => $this->markSkipped($ticket), + 'cancel' => $this->markCancelled($ticket), + default => throw new InvalidArgumentException("Unknown ticket action [{$action}]."), + }; + + return $this->toApiArray($ticket->fresh(['servicePoint', 'serviceQueue'])); + }); + } + + /** + * @return array + */ + public function toApiArray(CareQueueTicket $ticket): array + { + $point = $ticket->servicePoint; + + return [ + 'uuid' => $ticket->uuid, + 'ticket_number' => $ticket->ticket_number, + 'status' => $ticket->status, + 'priority' => $ticket->priority, + 'source' => $ticket->source, + 'customer_name' => $ticket->customer_name, + 'customer_phone' => $ticket->customer_phone, + 'metadata' => $ticket->metadata ?? [], + 'assigned_counter' => $point ? [ + 'uuid' => $point->uuid, + 'name' => $point->name, + 'staff_display_name' => $point->staff_display_name, + 'destination' => $point->destination, + ] : null, + 'counter' => $point ? [ + 'uuid' => $point->uuid, + 'name' => $point->name, + ] : null, + 'destination' => $point?->destination, + 'staff_display_name' => $point?->staff_display_name, + 'called_at' => $ticket->called_at?->toIso8601String(), + 'serving_at' => $ticket->serving_at?->toIso8601String(), + 'completed_at' => $ticket->completed_at?->toIso8601String(), + ]; + } + + protected function markServing(CareQueueTicket $ticket): void + { + $ticket->forceFill([ + 'status' => CareQueueTicket::STATUS_SERVING, + 'serving_at' => $ticket->serving_at ?? now(), + 'called_at' => $ticket->called_at ?? now(), + ])->save(); + } + + protected function markCompleted(CareQueueTicket $ticket): void + { + $ticket->forceFill([ + 'status' => CareQueueTicket::STATUS_COMPLETED, + 'completed_at' => now(), + 'serving_at' => $ticket->serving_at ?? now(), + ])->save(); + } + + protected function markCalled(CareQueueTicket $ticket): void + { + $ticket->forceFill([ + 'status' => CareQueueTicket::STATUS_CALLED, + 'called_at' => now(), + ])->save(); + } + + protected function markSkipped(CareQueueTicket $ticket): void + { + $ticket->forceFill([ + 'status' => CareQueueTicket::STATUS_SKIPPED, + 'completed_at' => now(), + ])->save(); + } + + protected function markCancelled(CareQueueTicket $ticket): void + { + $ticket->forceFill([ + 'status' => CareQueueTicket::STATUS_CANCELLED, + 'completed_at' => now(), + ])->save(); + } +} diff --git a/app/Services/Care/CareQueueProvisioner.php b/app/Services/Care/CareQueueProvisioner.php index de5b89e..127a050 100644 --- a/app/Services/Care/CareQueueProvisioner.php +++ b/app/Services/Care/CareQueueProvisioner.php @@ -3,6 +3,8 @@ namespace App\Services\Care; use App\Models\Branch; +use App\Models\CareServicePoint; +use App\Models\CareServiceQueue; use App\Models\Member; use App\Models\Organization; use App\Models\Practitioner; @@ -10,13 +12,9 @@ use App\Services\Queue\QueueClient; use Illuminate\Support\Facades\Log; /** - * Idempotently provisions Ladill Queue departments, queues, and service points - * (counters) from Care staff / branch configuration. - * - * Ladill Queue v2 industry packages define the Healthcare template in Queue Core. - * When Care integration is enabled, Queue skips materializing healthcare stages - * and this provisioner remains the source of truth for clinical service points - * (per-doctor rooms, pharmacy desks, lab desks, cashiers). + * Idempotently provisions service queues and service points from Care staff / + * branch configuration. Native driver writes Care Queue Engine tables; remote + * driver syncs to Ladill Queue over HTTP (legacy cutover). */ class CareQueueProvisioner { @@ -25,11 +23,21 @@ class CareQueueProvisioner protected PlanService $plans, ) {} + public function usesNative(): bool + { + return config('care.queue.driver', 'native') === 'native'; + } + public function shouldProvision(Organization $organization): bool { - return $this->plans->canUseQueueIntegration($organization) - && (bool) data_get($organization->settings, 'queue_integration_enabled') - && $this->queue->configured(); + $enabled = $this->plans->canUseQueueIntegration($organization) + && (bool) data_get($organization->settings, 'queue_integration_enabled'); + + if (! $enabled) { + return false; + } + + return $this->usesNative() || $this->queue->configured(); } /** @@ -113,6 +121,10 @@ class CareQueueProvisioner 'synced' => false, ]; + if ($this->usesNative()) { + return $this->provisionBranchDepartmentNative($organization, $owner, $context, $meta, $branch, $stub); + } + try { $this->ensureBranchName($owner, $branch->name); @@ -185,6 +197,101 @@ class CareQueueProvisioner return $stub; } + /** + * @param array $meta + * @param array $stub + * @return array + */ + protected function provisionBranchDepartmentNative( + Organization $organization, + string $owner, + string $context, + array $meta, + Branch $branch, + array $stub, + ): array { + 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; + } + + // Soft-deactivate points no longer in the staff roster. + 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> diff --git a/app/Services/Care/DemoTenantSeeder.php b/app/Services/Care/DemoTenantSeeder.php index 5f55d22..3216acb 100644 --- a/app/Services/Care/DemoTenantSeeder.php +++ b/app/Services/Care/DemoTenantSeeder.php @@ -734,12 +734,13 @@ class DemoTenantSeeder private function seedSpecialtyModules(Organization $organization, string $ownerRef): void { - // Pause Queue HTTP during per-module activate so Enterprise demos (many - // branches × specialties) do not spend minutes on remote timeouts. + // Remote Queue HTTP during per-module activate can hang Enterprise demos. + // Native Care Queue Engine is local — keep integration on so desks provision. $organization = $organization->fresh(); $settings = $organization->settings ?? []; $queueEnabled = (bool) data_get($settings, 'queue_integration_enabled'); - if ($queueEnabled) { + $native = config('care.queue.driver', 'native') === 'native'; + if ($queueEnabled && ! $native) { data_set($settings, 'queue_integration_enabled', false); $organization->update(['settings' => $settings]); } @@ -753,12 +754,18 @@ class DemoTenantSeeder } } - if ($queueEnabled) { + if ($queueEnabled && ! $native) { $organization = $organization->fresh(); $settings = $organization->settings ?? []; data_set($settings, 'queue_integration_enabled', true); $organization->update(['settings' => $settings]); // Skip CareQueueProvisioner here — same Queue HTTP storm as ticket sync. + } elseif ($queueEnabled && $native) { + try { + app(CareQueueProvisioner::class)->provisionOrganization($organization->fresh()); + } catch (\Throwable) { + // Demo seed continues; pages lazy-provision on use. + } } } diff --git a/app/Services/Care/ServiceQueuePresenter.php b/app/Services/Care/ServiceQueuePresenter.php index ea9ff7c..4318640 100644 --- a/app/Services/Care/ServiceQueuePresenter.php +++ b/app/Services/Care/ServiceQueuePresenter.php @@ -65,6 +65,26 @@ class ServiceQueuePresenter $stubs = $this->stubsForContext($organization, $context, $branchScope); + if (config('care.queue.driver', 'native') === 'native') { + $counters = $this->nativeCountersFromStubs($stubs, $context); + $stubCounterUuid = collect($stubs) + ->map(fn ($s) => (string) ($s['counter_uuid'] ?? '')) + ->first(fn ($uuid) => $uuid !== ''); + $counterUuid = $preferredCounterUuid + ?: ($stubCounterUuid ?: '') + ?: (string) ($counters[0]['uuid'] ?? ''); + + return [ + 'enabled' => true, + 'counters' => $counters, + 'counter_uuid' => $counterUuid !== '' ? $counterUuid : null, + 'state' => null, + 'stubs' => $stubs, + 'error' => null, + 'context' => $context, + ]; + } + if (! $this->queue->configured()) { return [ 'enabled' => true, @@ -227,6 +247,44 @@ class ServiceQueuePresenter return $filtered->values()->all(); } + /** + * @param list> $stubs + * @return list> + */ + protected function nativeCountersFromStubs(array $stubs, string $context): array + { + $counters = []; + foreach ($stubs as $stub) { + $branchName = $stub['branch_name'] ?? null; + $queueName = $stub['name'] ?? $context; + $points = is_array($stub['points'] ?? null) ? $stub['points'] : []; + + foreach ($points as $point) { + $uuid = (string) ($point['counter_uuid'] ?? ''); + if ($uuid === '') { + continue; + } + $counters[] = [ + 'uuid' => $uuid, + 'name' => (string) ($point['name'] ?? $point['destination'] ?? 'Desk'), + 'branch' => $branchName, + 'queues' => [['name' => $queueName]], + ]; + } + + if ($points === [] && ! empty($stub['counter_uuid'])) { + $counters[] = [ + 'uuid' => (string) $stub['counter_uuid'], + 'name' => (string) $queueName, + 'branch' => $branchName, + 'queues' => [['name' => $queueName]], + ]; + } + } + + return $counters; + } + /** * @return list */ diff --git a/app/Services/Care/SpecialtyModuleService.php b/app/Services/Care/SpecialtyModuleService.php index e479398..df7856f 100644 --- a/app/Services/Care/SpecialtyModuleService.php +++ b/app/Services/Care/SpecialtyModuleService.php @@ -320,23 +320,43 @@ class SpecialtyModuleService $organization->update(['settings' => $settings]); - if (data_get($organization->fresh()->settings, 'queue_integration_enabled') && $this->queue->configured()) { - 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(), - ]); + $fresh = $organization->fresh(); + if (data_get($fresh?->settings, 'queue_integration_enabled')) { + if (config('care.queue.driver', 'native') === 'native') { + $provisioner = app(CareQueueProvisioner::class); + $branches = Branch::owned($ownerRef) + ->where('organization_id', $organization->id) + ->where('is_active', true) + ->pluck('id'); + foreach ($branches as $branchId) { + try { + $provisioner->ensure($fresh, $key, (int) $branchId); + } catch (\Throwable $e) { + Log::warning('specialty_module.native_queue_provision_failed', [ + 'key' => $key, + 'branch_id' => $branchId, + 'message' => $e->getMessage(), + ]); + } + } + } elseif ($this->queue->configured()) { + try { + $synced = $this->queue->syncSpecialtyQueueStubs($ownerRef, $queueStubs); + $settings = $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(), + ]); + } } } } @@ -372,6 +392,7 @@ class SpecialtyModuleService if ( data_get($settings, 'queue_integration_enabled') + && config('care.queue.driver', 'native') !== 'native' && $this->queue->configured() && $queues !== [] ) { diff --git a/config/care.php b/config/care.php index 00b33b0..6bb073a 100644 --- a/config/care.php +++ b/config/care.php @@ -394,6 +394,8 @@ return [ ], 'queue' => [ + // native = Care Queue Engine (in-app). remote = Ladill Queue HTTP (legacy cutover). + 'driver' => env('CARE_QUEUE_DRIVER', 'native'), 'api_url' => env('QUEUE_API_URL', 'https://queue.ladill.com/api/v1'), 'api_key' => env('QUEUE_API_KEY_CARE'), ], diff --git a/database/migrations/2026_07_18_100000_create_care_queue_engine_tables.php b/database/migrations/2026_07_18_100000_create_care_queue_engine_tables.php new file mode 100644 index 0000000..d9584e7 --- /dev/null +++ b/database/migrations/2026_07_18_100000_create_care_queue_engine_tables.php @@ -0,0 +1,82 @@ +id(); + $table->uuid('uuid')->unique(); + $table->string('owner_ref', 64)->index(); + $table->foreignId('organization_id')->constrained('care_organizations')->cascadeOnDelete(); + $table->foreignId('branch_id')->constrained('care_branches')->cascadeOnDelete(); + $table->string('context', 64); + $table->string('name'); + $table->string('prefix', 8); + $table->string('routing_mode', 32)->default('shared_pool'); + $table->string('external_key')->nullable()->index(); + $table->unsignedInteger('next_number')->default(1); + $table->boolean('is_active')->default(true); + $table->timestamps(); + + $table->unique(['organization_id', 'branch_id', 'context']); + }); + + Schema::create('care_service_points', function (Blueprint $table) { + $table->id(); + $table->uuid('uuid')->unique(); + $table->string('owner_ref', 64)->index(); + $table->foreignId('organization_id')->constrained('care_organizations')->cascadeOnDelete(); + $table->foreignId('service_queue_id')->constrained('care_service_queues')->cascadeOnDelete(); + $table->string('name'); + $table->string('destination')->nullable(); + $table->string('kind', 32)->default('default'); + $table->unsignedBigInteger('ref_id')->nullable(); + $table->string('staff_ref')->nullable(); + $table->string('staff_display_name')->nullable(); + $table->string('external_key')->nullable()->index(); + $table->boolean('is_active')->default(true); + $table->timestamps(); + + $table->unique(['service_queue_id', 'external_key']); + }); + + Schema::create('care_queue_tickets', function (Blueprint $table) { + $table->id(); + $table->uuid('uuid')->unique(); + $table->string('owner_ref', 64)->index(); + $table->foreignId('organization_id')->constrained('care_organizations')->cascadeOnDelete(); + $table->foreignId('service_queue_id')->constrained('care_service_queues')->cascadeOnDelete(); + $table->foreignId('service_point_id')->nullable()->constrained('care_service_points')->nullOnDelete(); + $table->string('ticket_number', 32); + $table->string('status', 32)->default('waiting')->index(); + $table->string('priority', 32)->default('walk_in'); + $table->string('source', 32)->default('api'); + $table->string('customer_name')->nullable(); + $table->string('customer_phone')->nullable(); + $table->string('care_entity', 32)->nullable(); + $table->string('care_entity_uuid', 36)->nullable()->index(); + $table->unsignedBigInteger('care_entity_id')->nullable(); + $table->unsignedBigInteger('visit_id')->nullable(); + $table->json('metadata')->nullable(); + $table->timestamp('called_at')->nullable(); + $table->timestamp('serving_at')->nullable(); + $table->timestamp('completed_at')->nullable(); + $table->timestamps(); + + $table->index(['service_queue_id', 'status']); + $table->index(['service_point_id', 'status']); + }); + } + + public function down(): void + { + Schema::dropIfExists('care_queue_tickets'); + Schema::dropIfExists('care_service_points'); + Schema::dropIfExists('care_service_queues'); + } +}; diff --git a/docs/care-queue-engine-and-specialty-plan.md b/docs/care-queue-engine-and-specialty-plan.md new file mode 100644 index 0000000..6becc9e --- /dev/null +++ b/docs/care-queue-engine-and-specialty-plan.md @@ -0,0 +1,288 @@ +# Care Queue Engine + Specialty Modules — Unified Plan + +**Status:** Phase 1 in progress +**Date:** 2026-07-18 +**Repos:** ladill-care (primary), ladill-queue (healthcare removal) +**Related:** `docs/specialty-module-design-standard.md` + +--- + +## Phase 1 notes (implementation) + +- Config: `CARE_QUEUE_DRIVER=native` (default) | `remote` (legacy Ladill Queue HTTP) +- Tables: `care_service_queues`, `care_service_points`, `care_queue_tickets` +- Service: `App\Services\Care\CareQueueEngine` +- Bridge / provisioner route to native when driver is `native`; PHPUnit sets `remote` so legacy Http::fake tests keep working +- Exit criterion: Care Pro demo issues / call-next / complete with `QUEUE_API_*` unset + +--- + +## 1. Decision + +| Decision | Choice | +| --- | --- | +| Care waiting lines / call-next / service points | **Built into Ladill Care** as a first-party **Care Queue Engine** | +| Ladill Queue dependency for Care | **Removed** — Care never requires Queue API keys or Pro “Queue integration” | +| Healthcare in Ladill Queue | **Removed** — Queue becomes general-purpose multi-industry QMS only | +| Specialty modules | Built **on top of** Care Queue Engine + Visit/workflow/billing — not on Ladill Queue | + +**Product framing** + +- **Ladill Care** = clinical ops (patients, visits, specialties, in-app queues, billing). +- **Ladill Queue** = generic ticket QMS (banks, retail, hospitality, …) with **no clinical/Care packaging**. + +--- + +## 2. Goals & non-goals + +### Goals + +1. One in-app Care queue substrate used by consultation, lab, pharmacy, billing, Emergency, Blood Bank, and all specialty modules. +2. Specialty modules follow the design standard (shell, workflow, queues, billing) without a second queue product. +3. Clean cutover: existing Care tenants keep working without Ladill Queue. +4. Queue product remains valuable for non-healthcare industries after healthcare surfaces are stripped. + +### Non-goals (this program) + +- Rebuilding displays/kiosks/voice as feature-parity with Queue Day 1 (phase later if needed). +- Shared database between Care and Queue. +- Keeping a permanent dual-write to Ladill Queue. + +--- + +## 3. Current state (why this plan) + +Today Care is a **remote adapter** to Queue: + +- `QueueClient` → `CareQueueBridge` / `CareQueueProvisioner` +- Clinical entities mirror `queue_ticket_*` fields; ticket **truth** lives in Queue +- Specialty activate provisions Queue stubs over HTTP +- Pro feature flag `queue_integration` + `QUEUE_API_KEY_CARE` + +Specialty modules are a **thin layer** (catalog → departments → waiting list page). The specialty design standard needs a real local queue engine — building that *and* keeping Ladill Queue would double work. + +--- + +## 4. Target architecture + +``` +┌─────────────────────────────────────────────────────────────┐ +│ Ladill Care │ +│ Patients · Visits · Consultations · Lab · Pharmacy · Bills │ +│ │ +│ ┌────────────────────────────────────────────────────────┐ │ +│ │ Care Queue Engine (in-app) │ │ +│ │ ServiceQueue · ServicePoint · Ticket · Call-next │ │ +│ │ Contexts: consultation, lab, pharmacy, billing, │ │ +│ │ specialty:{key}, emergency, blood_bank │ │ +│ └────────────────────────────────────────────────────────┘ │ +│ ▲ │ +│ │ │ +│ ┌────────────────────────────────────────────────────────┐ │ +│ │ Specialty Module Shell (shared) │ │ +│ │ Dashboard · Workflow · Clinical workspace · Billing │ │ +│ └────────────────────────────────────────────────────────┘ │ +└─────────────────────────────────────────────────────────────┘ + +Ladill Queue (decoupled) + Generic QMS only — no healthcare package, no Care API path +``` + +**Care Queue Engine owns** + +- Queues per `(organization, branch, context)` +- Service points (rooms / desks / chairs) with `shared_pool` vs `assigned_only` +- Tickets (number, status, priority, wait time, assigned staff/point) +- Call next / recall / skip / complete / transfer (Care-flavored) +- Sync from clinical waiters (appointments, Rx, labs, bills) into tickets + +**Specialty modules own** + +- Clinical forms, stage maps, KPIs, documents, alerts +- Mapping specialty stages → Care Queue contexts/points +- Service catalog → Billing Engine line items + +**Visit** remains the episode unit (per specialty standard). Tickets attach to visit/appointment/entity — not a parallel patient chart. + +--- + +## 5. Phased program (one roadmap) + +### Phase 0 — Spec freeze (short) + +Deliverables: + +- This plan accepted +- Care Queue Engine domain model + API sketch (tables, statuses, contexts) +- Specialty shell IA locked (from design standard) +- Queue healthcare removal checklist signed off + +Exit: engineering can implement without re-litigating product boundaries. + +--- + +### Phase 1 — Care Queue Engine (MVP) + +Replace remote QMS for Care’s core lines **inside Care**. + +**Data model (illustrative)** + +- `care_service_queues` — org, branch, context, name, prefix, active +- `care_service_points` — queue_id, name, kind (practitioner/desk), staff/member/practitioner refs, routing mode +- `care_queue_tickets` — queue_id, point_id?, entity type/id, visit_id?, ticket_number, status, priority, called_at, completed_at, metadata +- Keep or migrate mirror columns on appointments/Rx/labs/bills to point at **local** ticket IDs (drop remote UUIDs over time) + +**Engine capabilities (MVP)** + +- Issue ticket when patient enters a wait line +- Call next (shared pool + assigned_only for doctors) +- Serve / complete / recall +- Backfill waiters without tickets +- UI: queue ops on consultation / lab / pharmacy / billing pages (same UX patterns as today, local backend) + +**Adapter rewrite** + +- `CareQueueBridge` talks to **local** engine (same method surface where possible) +- `QueueClient` / HTTP provisioner become dead code → delete +- Remove `queue_integration` Pro flag; Care queues are **always available** on Pro clinical modules (or Free waiting list + Pro ticket numbers — product call: recommend tickets on Free for consultation only, full multi-context on Pro) + +**Migration** + +- Tenants with remote Queue UUIDs: import open tickets if API still up **or** re-issue local tickets from current waiters (prefer re-issue for simplicity; archive remote history) +- Demo seed: stop calling Queue HTTP entirely + +Exit: Care Pro demo runs call-next / tickets with `QUEUE_API_*` unset. + +--- + +### Phase 2 — Specialty shell on Care Queue Engine + +Implement the specialty design standard **once**, for all modules. + +1. **Shared specialty shell** — KPI dashboard, left nav (Overview / Visits / History / Module Tabs), standard patient header, action panel +2. **Visit-backed specialty visits** — not appointment-only waiting lists +3. **Patient header + timeline components** — reused on patient chart +4. **Specialty stage maps** in config (per module) → Care Queue contexts/points (e.g. Dentistry: Waiting → Chair → Procedure → Recovery) +5. **Service catalog → billing** — activate module seeds billable services; invoices via Billing Engine +6. **Emergency & Blood Bank** — already `default_on_paid_plans`; first modules to get full shell + local queues (templates for the rest) + +Exit: Emergency (and ideally Dentistry) demonstrate the full standard on the in-app engine; remaining modules are catalog + stage map + forms on the same shell. + +--- + +### Phase 3 — Roll specialties onto the shell + +Per-module work becomes incremental: + +1. Stage map + queue points +2. Clinical forms / structured records +3. Service catalog items +4. Alerts + documents + KPIs +5. Role matrix + +Order suggestion: **Emergency → Blood Bank → Dentistry → Radiology → Maternity → …** (volume / demo story first). + +Do **not** invent new queue backends per specialty. + +--- + +### Phase 4 — Remove healthcare from Ladill Queue + +Parallelizable after Phase 1 starts; finish once Care no longer calls Queue. + +**Remove from Queue** + +- `industry_packages.healthcare` and related templates/priorities +- Care integration: `QUEUE_API_KEY_CARE`, `care_enabled`, Care provision defaults, Care settings UI, Care tests +- Clinic DemoWorld branding (Ridge Clinic / Care staff) → neutral industry demos +- Care-oriented marketing copy (“Care & Frontdesk API” → generic integrations) + +**Keep in Queue** + +- Generic QMS, other industry packages, Frontdesk/POS/CRM integration pattern, assigned_only routing as generic features + +**Suite docs / DemoWorld** + +- Stop listing Queue as required for Care Pro +- Care staff demos no longer include `queue` app by default (optional) + +Exit: Queue CI green without healthcare/Care paths; Care CI green without Queue env vars. + +--- + +### Phase 5 — Hard delete Care→Queue code & polish + +- Delete `QueueClient`, remote provisioner paths, sync artisan that hits Queue API +- Remove env keys from Care `.env.example` +- Optional later: Care TV display / voice (only if product needs; not blocking) + +--- + +## 6. How this maps to the specialty design standard + +| Standard requirement | Provided by | +| --- | --- | +| Workflow-driven stages | Specialty stage maps + Visit workflow | +| Queues per specialty | **Care Queue Engine** contexts/points | +| Episode / visit | Care `Visit` | +| Timeline | Shared timeline component (Phase 2) | +| Dashboard KPIs | Specialty shell (Phase 2) | +| Billing | Care Billing Engine + specialty service catalog | +| Call Next / Start Consultation / … | Shared actions → engine + clinical controllers | +| No standalone specialty apps | One shell, many module configs | + +--- + +## 7. Risks & mitigations + +| Risk | Mitigation | +| --- | --- | +| Ticket history only in Queue DB | Prefer re-issue open waiters locally; don’t block on full history import | +| Demo Pro broken mid-cutover | Feature flag `care_native_queue` during Phase 1; default on for new seeds | +| Specialty work forks before engine ready | Freeze deep specialty UIs until Phase 1 MVP + Phase 2 shell | +| Queue customers using healthcare package | Migrate them to Care **or** keep package until date-certain deprecation notice | +| Scope creep (kiosks/voice) | Explicitly Phase 5+ / optional | + +--- + +## 8. Success criteria + +1. Care runs full clinical + specialty queues with **no** Ladill Queue dependency. +2. Specialty modules share one shell and one queue engine. +3. Ladill Queue has **no** healthcare package or Care integration surface. +4. Docs/demo accounts reflect the split. +5. Tests: Care queue + specialty shell coverage; Queue industry suite without healthcare. + +--- + +## 9. Suggested ownership & sequencing + +``` +Week focus (indicative): + + [Phase 0] Spec freeze + ↓ + [Phase 1] Care Queue Engine MVP ──┬── parallel: Queue healthcare deprecation notice + ↓ │ + [Phase 2] Specialty shell │ + ↓ │ + [Phase 3] Emergency / Blood Bank / Dentistry on shell + ↓ ↓ + [Phase 4] Strip healthcare from Queue (hard remove) + ↓ + [Phase 5] Delete Care remote Queue adapter +``` + +--- + +## 10. Immediate next implementation slice + +When ready to code (first PR): + +1. Migrations for `care_service_queues`, `care_service_points`, `care_queue_tickets` +2. `CareNativeQueue` service with issue / call-next / complete +3. Point `CareQueueBridge` at native service behind a config flag +4. Consultation queue page uses native tickets when flag on +5. Document flag + cutover in this file’s changelog + +Specialty shell follows once native consultation queue is green in demo. diff --git a/docs/specialty-module-design-standard.md b/docs/specialty-module-design-standard.md index 4b140ba..25079a1 100644 --- a/docs/specialty-module-design-standard.md +++ b/docs/specialty-module-design-standard.md @@ -220,3 +220,13 @@ Specialty modules today are a **thin Pro enablement layer**, not the full clinic 5. **Queue + clinical workspace entry points** — stage-aware queue → open consultation/assessment with specialty context. Do not implement dentistry, maternity, cardiology, etc. as separate apps until that shared shell and Visit/queue/billing bridges exist. + +--- + +## Care Queue Engine (product decision) + +Ladill Care uses an **in-app Care Queue Engine**, fully decoupled from Ladill Queue. + +- Specialty queues, call-next, and service points are Care-native. +- Ladill Queue remains a general-purpose QMS; healthcare / Care packaging is removed from Queue. +- Unified program plan: [`docs/care-queue-engine-and-specialty-plan.md`](care-queue-engine-and-specialty-plan.md). diff --git a/phpunit.xml b/phpunit.xml index 973fa99..28931d0 100644 --- a/phpunit.xml +++ b/phpunit.xml @@ -30,6 +30,7 @@ + diff --git a/resources/views/care/settings/edit.blade.php b/resources/views/care/settings/edit.blade.php index c18dde4..93efc33 100644 --- a/resources/views/care/settings/edit.blade.php +++ b/resources/views/care/settings/edit.blade.php @@ -237,16 +237,16 @@ - + @if (! empty($canUseQueueIntegration)) @else
-

Queue integration is a Pro feature

-

Free includes core patient records, appointments & consults, and basic workflows. Upgrade to connect service queues and counters.

+

Service queues are a Pro feature

+

Free includes core patient records, appointments & consults, and basic workflows. Upgrade for ticket numbers and call-next on clinical lines.

View Care Pro plans →
diff --git a/tests/Feature/CareNativeQueueTest.php b/tests/Feature/CareNativeQueueTest.php new file mode 100644 index 0000000..ff0a45a --- /dev/null +++ b/tests/Feature/CareNativeQueueTest.php @@ -0,0 +1,238 @@ +withoutMiddleware(EnsurePlatformSession::class); + + config([ + 'care.queue.driver' => 'native', + 'care.queue.api_url' => '', + 'care.queue.api_key' => '', + ]); + + $this->owner = User::create([ + 'public_id' => 'native-queue-owner', + 'name' => 'Owner', + 'email' => 'native-queue@example.com', + ]); + + $this->organization = Organization::create([ + 'owner_ref' => $this->owner->public_id, + 'name' => 'Native Queue Clinic', + 'slug' => 'native-queue-clinic', + 'settings' => [ + 'onboarded' => true, + 'facility_type' => 'clinic', + 'plan' => 'pro', + 'plan_expires_at' => now()->addMonth()->toIso8601String(), + 'queue_integration_enabled' => true, + ], + ]); + + Member::create([ + 'owner_ref' => $this->owner->public_id, + 'organization_id' => $this->organization->id, + 'user_ref' => $this->owner->public_id, + 'role' => 'hospital_admin', + ]); + + $this->branch = Branch::create([ + 'owner_ref' => $this->owner->public_id, + 'organization_id' => $this->organization->id, + 'name' => 'Main', + 'is_active' => true, + ]); + + $this->practitioner = Practitioner::create([ + 'owner_ref' => $this->owner->public_id, + 'organization_id' => $this->organization->id, + 'branch_id' => $this->branch->id, + 'name' => 'Dr. Native', + 'room' => 'Room 1', + 'is_active' => true, + ]); + + $this->patient = Patient::create([ + 'owner_ref' => $this->owner->public_id, + 'organization_id' => $this->organization->id, + 'branch_id' => $this->branch->id, + 'patient_number' => 'P-N1', + 'first_name' => 'Kwame', + 'last_name' => 'Mensah', + 'gender' => 'male', + 'date_of_birth' => '1988-05-01', + ]); + } + + public function test_bridge_enabled_without_queue_api_keys(): void + { + $this->assertTrue(app(CareQueueBridge::class)->isEnabled($this->organization)); + $this->assertSame('', (string) config('care.queue.api_key')); + } + + public function test_check_in_issues_native_consultation_ticket(): void + { + Http::fake(); + + $appointment = Appointment::create([ + 'owner_ref' => $this->owner->public_id, + 'organization_id' => $this->organization->id, + 'branch_id' => $this->branch->id, + 'patient_id' => $this->patient->id, + 'practitioner_id' => $this->practitioner->id, + 'type' => Appointment::TYPE_SCHEDULED, + 'status' => Appointment::STATUS_SCHEDULED, + 'scheduled_at' => now()->addHour(), + ]); + + $result = app(AppointmentService::class)->checkIn( + $appointment->fresh(['organization', 'patient', 'practitioner']), + $this->owner->public_id, + $this->owner->public_id, + ); + + $this->assertNotNull($result->queue_ticket_uuid); + $this->assertSame('waiting', $result->queue_ticket_status); + $this->assertStringStartsWith('C', (string) $result->queue_ticket_number); + $this->assertDatabaseHas('care_queue_tickets', [ + 'uuid' => $result->queue_ticket_uuid, + 'ticket_number' => $result->queue_ticket_number, + 'status' => CareQueueTicket::STATUS_WAITING, + 'care_entity' => 'appointment', + 'care_entity_id' => $result->id, + ]); + $this->assertDatabaseHas('care_service_queues', [ + 'organization_id' => $this->organization->id, + 'branch_id' => $this->branch->id, + 'context' => CareQueueContexts::CONSULTATION, + ]); + Http::assertNothingSent(); + } + + public function test_call_next_serve_and_complete_without_http(): void + { + Http::fake(); + + $appointment = Appointment::create([ + 'owner_ref' => $this->owner->public_id, + 'organization_id' => $this->organization->id, + 'branch_id' => $this->branch->id, + 'patient_id' => $this->patient->id, + 'practitioner_id' => $this->practitioner->id, + 'type' => Appointment::TYPE_WALK_IN, + 'status' => Appointment::STATUS_SCHEDULED, + 'scheduled_at' => now(), + ]); + + app(AppointmentService::class)->checkIn( + $appointment->fresh(['organization', 'patient', 'practitioner']), + $this->owner->public_id, + $this->owner->public_id, + ); + + $bridge = app(CareQueueBridge::class); + $called = $bridge->callNext( + $this->organization->fresh(), + CareQueueContexts::CONSULTATION, + (int) $this->branch->id, + null, + $this->practitioner->id, + ); + + $this->assertNotNull($called['ticket']); + $this->assertSame('called', $called['ticket']['status']); + $this->assertNotNull($called['entity']); + $this->assertSame('called', $called['entity']->queue_ticket_status); + + $serving = $bridge->serve($this->organization->fresh(), $called['entity']->fresh()); + $this->assertSame('serving', $serving['status'] ?? null); + + $completed = $bridge->complete($this->organization->fresh(), $called['entity']->fresh()); + $this->assertSame('completed', $completed['status'] ?? null); + + $this->assertDatabaseHas('care_queue_tickets', [ + 'uuid' => $called['ticket']['uuid'], + 'status' => CareQueueTicket::STATUS_COMPLETED, + ]); + Http::assertNothingSent(); + } + + public function test_engine_shared_pool_call_next_claims_unassigned_ticket(): void + { + $queue = CareServiceQueue::create([ + 'owner_ref' => $this->owner->public_id, + 'organization_id' => $this->organization->id, + 'branch_id' => $this->branch->id, + 'context' => CareQueueContexts::PHARMACY, + 'name' => 'Pharmacy', + 'prefix' => 'P', + 'routing_mode' => 'shared_pool', + 'external_key' => CareQueueContexts::queueExternalKey(CareQueueContexts::PHARMACY, $this->branch->id), + ]); + + $point = $queue->points()->create([ + 'owner_ref' => $this->owner->public_id, + 'organization_id' => $this->organization->id, + 'name' => 'Pharmacy counter', + 'destination' => 'Pharmacy counter', + 'kind' => 'default', + 'external_key' => CareQueueContexts::pointExternalKey( + CareQueueContexts::PHARMACY, + $this->branch->id, + 'default', + 0, + ), + ]); + + $engine = app(CareQueueEngine::class); + $issued = $engine->issueTicket($this->organization, [ + 'service_queue_id' => $queue->uuid, + 'customer_name' => 'Kwame Mensah', + 'priority' => 'walk_in', + 'metadata' => ['care_entity' => 'prescription'], + ]); + + $this->assertSame('waiting', $issued['status']); + $this->assertNull($issued['assigned_counter']); + + $called = $engine->callNext($this->organization, $queue->uuid, $point->uuid); + $this->assertNotNull($called); + $this->assertSame('called', $called['status']); + $this->assertSame($point->uuid, $called['assigned_counter']['uuid'] ?? null); + } +}