Add in-app Care Queue Engine (Phase 1 MVP).
Deploy Ladill Care / deploy (push) Successful in 1m28s

Issue, call-next, serve, and complete tickets locally so Care Pro no longer
needs QUEUE_API_* when CARE_QUEUE_DRIVER=native (default). Remote Ladill Queue
remains available for cutover; PHPUnit keeps the remote driver for existing fakes.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
isaacclad
2026-07-18 09:54:06 +00:00
co-authored by Cursor
parent ad04f0cec8
commit 6cf5a24019
19 changed files with 1355 additions and 55 deletions
+3 -2
View File
@@ -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
+4 -1
View File
@@ -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=
@@ -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) {
+70
View File
@@ -0,0 +1,70 @@
<?php
namespace App\Models;
use App\Models\Concerns\BelongsToOwner;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Support\Str;
class CareQueueTicket extends Model
{
use BelongsToOwner;
public const STATUS_WAITING = 'waiting';
public const STATUS_CALLED = 'called';
public const STATUS_SERVING = 'serving';
public const STATUS_COMPLETED = 'completed';
public const STATUS_SKIPPED = 'skipped';
public const STATUS_CANCELLED = 'cancelled';
protected $table = 'care_queue_tickets';
protected $fillable = [
'uuid', 'owner_ref', 'organization_id', 'service_queue_id', 'service_point_id',
'ticket_number', 'status', 'priority', 'source', 'customer_name', 'customer_phone',
'care_entity', 'care_entity_uuid', 'care_entity_id', 'visit_id', 'metadata',
'called_at', 'serving_at', 'completed_at',
];
protected function casts(): array
{
return [
'metadata' => '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');
}
}
+54
View File
@@ -0,0 +1,54 @@
<?php
namespace App\Models;
use App\Models\Concerns\BelongsToOwner;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Support\Str;
class CareServicePoint extends Model
{
use BelongsToOwner;
protected $table = 'care_service_points';
protected $fillable = [
'uuid', 'owner_ref', 'organization_id', 'service_queue_id',
'name', 'destination', 'kind', 'ref_id', 'staff_ref',
'staff_display_name', 'external_key', 'is_active',
];
protected function casts(): array
{
return [
'is_active' => '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');
}
}
+58
View File
@@ -0,0 +1,58 @@
<?php
namespace App\Models;
use App\Models\Concerns\BelongsToOwner;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Support\Str;
class CareServiceQueue extends Model
{
use BelongsToOwner;
protected $table = 'care_service_queues';
protected $fillable = [
'uuid', 'owner_ref', 'organization_id', 'branch_id', 'context',
'name', 'prefix', 'routing_mode', 'external_key', 'next_number', 'is_active',
];
protected function casts(): array
{
return [
'is_active' => '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');
}
}
+47 -13
View File
@@ -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<string, mixed>|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;
+266
View File
@@ -0,0 +1,266 @@
<?php
namespace App\Services\Care;
use App\Models\CareQueueTicket;
use App\Models\CareServicePoint;
use App\Models\CareServiceQueue;
use App\Models\Organization;
use Illuminate\Support\Facades\DB;
use InvalidArgumentException;
/**
* In-app Care Queue Engine issue / call-next / serve / complete / recall.
* Returns ticket arrays shaped like the former Ladill Queue API for CareQueueBridge.
*/
class CareQueueEngine
{
/**
* @param array{
* service_queue_id: string,
* customer_name?: ?string,
* customer_phone?: ?string,
* priority?: string,
* source?: string,
* metadata?: array<string, mixed>,
* assigned_counter_id?: ?string
* } $payload
* @return array<string, mixed>
*/
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<string, mixed>|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<string, mixed>
*/
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<string, mixed>
*/
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();
}
}
+117 -10
View File
@@ -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<string, mixed> $meta
* @param array<string, mixed> $stub
* @return array<string, mixed>
*/
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<string, mixed> $stub
* @return list<array<string, mixed>>
+11 -4
View File
@@ -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.
}
}
}
@@ -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<array<string, mixed>> $stubs
* @return list<array<string, mixed>>
*/
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<string>
*/
+38 -17
View File
@@ -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 !== []
) {
+2
View File
@@ -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'),
],
@@ -0,0 +1,82 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('care_service_queues', 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('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');
}
};
@@ -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 Cares 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; dont 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 files changelog
Specialty shell follows once native consultation queue is green in demo.
+10
View File
@@ -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).
+1
View File
@@ -30,6 +30,7 @@
<env name="DB_URL" value=""/>
<env name="MAIL_MAILER" value="array"/>
<env name="QUEUE_CONNECTION" value="sync"/>
<env name="CARE_QUEUE_DRIVER" value="remote"/>
<env name="SESSION_DRIVER" value="array"/>
<env name="PULSE_ENABLED" value="false"/>
<env name="TELESCOPE_ENABLED" value="false"/>
+4 -4
View File
@@ -237,16 +237,16 @@
</div>
</x-settings.card>
<x-settings.card title="Integrations" description="Connect Ladill Queue so existing Care lists (patient queue, pharmacy, lab, billing, specialties) issue ticket numbers and support Call next → Serve → Complete. Requires Care Pro or Enterprise — and Care must also be enabled in Queue settings.">
<x-settings.card title="Service queues" description="Enable in-app ticket numbers and Call next → Serve → Complete on patient queue, pharmacy, lab, billing, and specialty modules. Requires Care Pro or Enterprise. (Legacy remote Ladill Queue is only used when CARE_QUEUE_DRIVER=remote.)">
@if (! empty($canUseQueueIntegration))
<label class="flex items-center gap-2 text-sm">
<input type="checkbox" name="queue_integration_enabled" value="1" @checked(data_get($organization->settings, 'queue_integration_enabled'))>
Enable Ladill Queue integration
Enable service queues
</label>
@else
<div class="rounded-xl border border-amber-100 bg-amber-50 px-4 py-3 text-sm text-amber-900">
<p class="font-medium">Queue integration is a Pro feature</p>
<p class="mt-1 text-amber-800">Free includes core patient records, appointments &amp; consults, and basic workflows. Upgrade to connect service queues and counters.</p>
<p class="font-medium">Service queues are a Pro feature</p>
<p class="mt-1 text-amber-800">Free includes core patient records, appointments &amp; consults, and basic workflows. Upgrade for ticket numbers and call-next on clinical lines.</p>
<a href="{{ route('care.pro.index') }}" class="mt-3 inline-flex text-sm font-semibold text-indigo-700 hover:text-indigo-900">View Care Pro plans </a>
</div>
<input type="hidden" name="queue_integration_enabled" value="0">
+238
View File
@@ -0,0 +1,238 @@
<?php
namespace Tests\Feature;
use App\Http\Middleware\EnsurePlatformSession;
use App\Models\Appointment;
use App\Models\Branch;
use App\Models\CareQueueTicket;
use App\Models\CareServiceQueue;
use App\Models\Member;
use App\Models\Organization;
use App\Models\Patient;
use App\Models\Practitioner;
use App\Models\User;
use App\Services\Care\AppointmentService;
use App\Services\Care\CareQueueBridge;
use App\Services\Care\CareQueueContexts;
use App\Services\Care\CareQueueEngine;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Http;
use Tests\TestCase;
class CareNativeQueueTest extends TestCase
{
use RefreshDatabase;
protected User $owner;
protected Organization $organization;
protected Branch $branch;
protected Patient $patient;
protected Practitioner $practitioner;
protected function setUp(): void
{
parent::setUp();
$this->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);
}
}