Hard-delete Care remote Queue adapter (Phase 5).
Deploy Ladill Care / deploy (push) Failing after 1m13s

Care Queue Engine is the only path; remove QueueClient, driver config, and remote HTTP tests.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
isaacclad
2026-07-18 11:16:41 +00:00
co-authored by Cursor
parent 966f0496b2
commit dd4bc493b4
31 changed files with 441 additions and 2108 deletions
-7
View File
@@ -26,13 +26,6 @@ SESSION_LIFETIME=1440
CACHE_STORE=redis
QUEUE_CONNECTION=database
# 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=
# --- Ladill SSO (Sign in with Ladill — auth.ladill.com OIDC client) ---
LADILL_SSO_CLIENT_ID=
LADILL_SSO_CLIENT_SECRET=
@@ -9,18 +9,18 @@ use App\Services\Care\CareQueueContexts;
use Illuminate\Console\Command;
/**
* Backfill Ladill Queue tickets for Care waiters that never received one
* (e.g. demo seed or patients checked in before Queue integration was enabled).
* Backfill Care Queue Engine tickets for waiters that never received one
* (e.g. demo seed or patients checked in before service queues were enabled).
*/
class SyncCareQueueTicketsCommand extends Command
{
protected $signature = 'care:queue-sync-tickets
{--organization= : Organization id (default: all with Queue integration on)}
{--organization= : Organization id (default: all with service queues on)}
{--branch= : Limit to one Care branch id}
{--context=consultation : consultation|pharmacy|laboratory|billing|all}
{--limit=100 : Max tickets to issue per branch+context}';
protected $description = 'Issue missing Queue tickets for Care waiting lists';
protected $description = 'Issue missing Care Queue Engine tickets for waiting lists';
public function handle(CareQueueBridge $bridge): int
{
@@ -4,13 +4,12 @@ namespace App\Http\Controllers\Care;
use App\Http\Controllers\Controller;
use App\Http\Controllers\Care\Concerns\ScopesToAccount;
use App\Services\Care\ServiceQueuePresenter;
use App\Services\Queue\QueueClient;
use Illuminate\Http\Client\RequestException;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\View\View;
/**
* Legacy Ladill Queue console routes redirect to the Care patient queue.
*/
class ServiceQueueController extends Controller
{
use ScopesToAccount;
@@ -19,76 +18,20 @@ class ServiceQueueController extends Controller
{
$this->authorizeAbility($request, 'service_queues.console');
// Natural Queue: counters live on role pages (clinical queue, pharmacy, lab, specialties).
return redirect()->route('care.queue.index');
}
public function console(Request $request, string $counterUuid, QueueClient $queue): View|RedirectResponse
public function console(Request $request, string $counterUuid): RedirectResponse
{
$this->authorizeAbility($request, 'service_queues.console');
$organization = $this->organization($request);
$owner = (string) $organization->owner_ref;
if (! $this->integrationEnabled($organization)) {
return redirect()->route('care.settings');
return redirect()->route('care.queue.index');
}
try {
$state = $queue->console($owner, $counterUuid);
} catch (RequestException) {
return redirect()->route('care.queue.index')->with('error', 'Counter console unavailable.');
}
return view('care.service-queues.console', [
'state' => $state,
'counterUuid' => $counterUuid,
'organization' => $organization,
]);
}
public function action(Request $request, string $counterUuid, QueueClient $queue): RedirectResponse
public function action(Request $request, string $counterUuid): RedirectResponse
{
$this->authorizeAbility($request, 'service_queues.console');
$organization = $this->organization($request);
$owner = (string) $organization->owner_ref;
$validated = $request->validate([
'action' => ['required', 'string'],
'queue_uuid' => ['nullable', 'uuid'],
'ticket_uuid' => ['nullable', 'uuid'],
'to_queue_uuid' => ['nullable', 'uuid'],
'reason' => ['nullable', 'string', 'max:500'],
]);
try {
$result = $queue->consoleAction($owner, $counterUuid, $validated);
} catch (RequestException $e) {
$message = $e->response?->json('message')
?: $e->response?->json('error')
?: 'Queue action failed. Try again.';
return back()->with('error', is_string($message) ? $message : 'Queue action failed. Try again.');
}
$success = match ($validated['action']) {
'transfer' => sprintf(
'Handed off %s to %s. Ticket number unchanged.',
(string) ($result['ticket_number'] ?? 'ticket'),
(string) data_get($result, 'queue.name', 'next queue'),
),
'call_next' => ! empty($result['ticket_number'])
? "Called {$result['ticket_number']}."
: 'No tickets waiting in that queue.',
'available' => 'Counter marked available.',
'offline' => 'Counter went offline.',
default => 'Queue updated.',
};
return back()->with('success', $success);
}
protected function integrationEnabled(\App\Models\Organization $organization): bool
{
return (bool) data_get($organization->settings, 'queue_integration_enabled', false);
return redirect()->route('care.queue.index');
}
}
@@ -19,7 +19,6 @@ use App\Services\Care\PlanService;
use App\Services\Care\Workflow\WorkflowTemplateInstaller;
use App\Services\Care\Workflow\WorkflowTemplateRegistry;
use App\Services\Messaging\MessagingCredentialsService;
use App\Services\Queue\QueueClient;
use App\Support\OrganizationBranding;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
@@ -79,7 +78,7 @@ class SettingsController extends Controller
]);
}
public function update(Request $request, QueueClient $queue): RedirectResponse
public function update(Request $request): RedirectResponse
{
$this->authorizeAbility($request, 'settings.manage');
$organization = $this->organization($request);
@@ -130,7 +129,7 @@ class SettingsController extends Controller
if ($wantsQueue && ! $plans->canUseQueueIntegration($organization)) {
return redirect()
->route('care.pro.index')
->with('upsell', 'Ladill Queue integration is part of Care Pro. Upgrade to connect service queues and counters.');
->with('upsell', 'In-app service queues are part of Care Pro. Upgrade to enable ticket numbers and Call next.');
}
if (! $plans->canUseQueueIntegration($organization)) {
$wantsQueue = false;
@@ -144,15 +143,6 @@ 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 && 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);
} catch (\Throwable) {
return back()->withInput()->with('error', 'Could not provision Ladill Queue for this account. Ensure Queue API keys are configured and Queue allows Care integration.');
}
}
$logoPath = $organization->logo_path;
if ($request->boolean('remove_logo')) {
@@ -187,8 +177,7 @@ class SettingsController extends Controller
}
// Queue department sync + waiting-ticket backfill — run after the redirect.
$queueReady = config('care.queue.driver', 'native') === 'native' || $queue->configured();
if ($wantsQueue && $queueReady) {
if ($wantsQueue) {
$organizationId = (int) $organization->id;
$backfillTickets = $queueJustEnabled;
dispatch(function () use ($organizationId, $owner, $backfillTickets) {
+2 -2
View File
@@ -9,7 +9,7 @@ use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;
/**
* Blocks free-plan accounts from Pro modules (lab, pharmacy, billing, Queue integration).
* Blocks free-plan accounts from Pro modules (lab, pharmacy, billing, service queues).
*/
class EnsurePaidPlan
{
@@ -39,6 +39,6 @@ class EnsurePaidPlan
return redirect()
->route('care.pro.index')
->with('upsell', 'Lab, pharmacy, billing, and Queue integration are part of Care Pro. Upgrade to unlock them.');
->with('upsell', 'Lab, pharmacy, billing, and in-app service queues are part of Care Pro. Upgrade to unlock them.');
}
}
+2 -42
View File
@@ -11,19 +11,15 @@ use App\Models\Patient;
use App\Models\Practitioner;
use App\Models\Prescription;
use App\Services\Care\Workflow\WorkflowQueueGate;
use App\Services\Queue\QueueClient;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Http\Client\RequestException;
use Illuminate\Support\Facades\Log;
/**
* Bridges Care workflow entities to service-queue tickets (native Care Queue
* Engine by default; optional legacy Ladill Queue HTTP when driver=remote).
* Bridges Care workflow entities to Care Queue Engine tickets.
*/
class CareQueueBridge
{
public function __construct(
protected QueueClient $queue,
protected CareQueueEngine $engine,
protected CareQueueProvisioner $provisioner,
protected PlanService $plans,
@@ -31,21 +27,10 @@ class CareQueueBridge
protected WorkflowQueueGate $workflowGate,
) {}
public function usesNative(): bool
{
return config('care.queue.driver', 'native') === 'native';
}
public function isEnabled(Organization $organization): bool
{
$enabled = $this->plans->canUseQueueIntegration($organization)
return $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
@@ -380,25 +365,11 @@ class CareQueueBridge
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(
(string) $organization->owner_ref,
(string) $resources['queue_uuid'],
(string) $resources['counter_uuid'],
);
} catch (RequestException $e) {
Log::warning('care.queue_call_next_failed', [
'message' => $e->getMessage(),
]);
return null;
} catch (\Throwable $e) {
Log::warning('care.queue_call_next_failed', [
'message' => $e->getMessage(),
@@ -583,7 +554,6 @@ class CareQueueBridge
return null;
}
$owner = (string) $organization->owner_ref;
$payload = [
'service_queue_id' => $resources['queue_uuid'],
'customer_name' => $patient?->fullName(),
@@ -597,11 +567,7 @@ 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', [
'context' => $context,
@@ -650,15 +616,9 @@ class CareQueueBridge
}
try {
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;
+3 -122
View File
@@ -8,36 +8,22 @@ use App\Models\CareServiceQueue;
use App\Models\Member;
use App\Models\Organization;
use App\Models\Practitioner;
use App\Services\Queue\QueueClient;
use Illuminate\Support\Facades\Log;
/**
* 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).
* Idempotently provisions Care Queue Engine service queues and service points
* from Care staff / branch configuration.
*/
class CareQueueProvisioner
{
public function __construct(
protected QueueClient $queue,
protected PlanService $plans,
) {}
public function usesNative(): bool
{
return config('care.queue.driver', 'native') === 'native';
}
public function shouldProvision(Organization $organization): bool
{
$enabled = $this->plans->canUseQueueIntegration($organization)
return $this->plans->canUseQueueIntegration($organization)
&& (bool) data_get($organization->settings, 'queue_integration_enabled');
if (! $enabled) {
return false;
}
return $this->usesNative() || $this->queue->configured();
}
/**
@@ -121,95 +107,6 @@ class CareQueueProvisioner
'synced' => false,
];
if ($this->usesNative()) {
return $this->provisionBranchDepartmentNative($organization, $owner, $context, $meta, $branch, $stub);
}
try {
$this->ensureBranchName($owner, $branch->name);
$department = $this->queue->ensureDepartment($owner, [
'name' => $meta['name'],
'type' => $meta['type'] ?? 'general',
'branch_name' => $branch->name,
'external_key' => $stub['department_external_key'],
'is_active' => true,
]);
$stub['department_id'] = $department['id'] ?? $stub['department_id'];
$queue = $this->queue->ensureQueue($owner, [
'name' => $meta['name'],
'prefix' => $meta['prefix'],
'branch_name' => $branch->name,
'strategy' => 'fifo',
'external_key' => $stub['queue_external_key'],
'routing_mode' => $meta['routing_mode'],
'department_id' => $stub['department_id'],
'is_active' => true,
]);
$stub['queue_uuid'] = $queue['uuid'] ?? $stub['queue_uuid'];
$points = $this->buildPointsForContext($organization, $context, $branch, $stub);
$syncedPoints = [];
foreach ($points as $point) {
try {
$counter = $this->queue->ensureCounter($owner, [
'name' => $point['name'],
'destination' => $point['destination'],
'staff_ref' => $point['staff_ref'],
'staff_display_name' => $point['staff_display_name'],
'branch_name' => $branch->name,
'department_id' => $stub['department_id'],
'queue_uuids' => array_values(array_filter([(string) $stub['queue_uuid']])),
'external_key' => $point['external_key'],
'is_active' => true,
]);
$point['counter_uuid'] = $counter['uuid'] ?? null;
$point['synced'] = ! empty($point['counter_uuid']);
} catch (\Throwable $e) {
Log::warning('care.queue_point_provision_failed', [
'context' => $context,
'branch_id' => $branch->id,
'point' => $point['external_key'] ?? null,
'message' => $e->getMessage(),
]);
$point['synced'] = false;
}
$syncedPoints[] = $point;
}
$stub['points'] = $syncedPoints;
// Legacy single counter: first point (or dedicated default) for older callers.
$first = collect($syncedPoints)->first(fn ($p) => ! empty($p['counter_uuid']));
if ($first) {
$stub['counter_uuid'] = $first['counter_uuid'];
$stub['counter_external_key'] = $first['external_key'];
}
$stub['synced'] = ! empty($stub['queue_uuid']) && collect($syncedPoints)->contains(fn ($p) => ! empty($p['synced']));
} catch (\Throwable $e) {
Log::warning('care.queue_department_provision_failed', [
'context' => $context,
'branch_id' => $branch->id,
'message' => $e->getMessage(),
]);
}
return $stub;
}
/**
* @param array<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(
[
@@ -264,7 +161,6 @@ class CareQueueProvisioner
$syncedPoints[] = $point;
}
// Soft-deactivate points no longer in the staff roster.
CareServicePoint::query()
->where('service_queue_id', $queue->id)
->when(
@@ -302,7 +198,6 @@ class CareQueueProvisioner
Branch $branch,
array $stub,
): array {
$owner = (string) $organization->owner_ref;
$priorByKey = collect($stub['points'] ?? [])->keyBy(fn ($p) => (string) ($p['external_key'] ?? ''));
$points = match (true) {
@@ -362,7 +257,6 @@ class CareQueueProvisioner
default => [],
};
// Always keep at least one default point so call-next / least-load have a target.
if ($points === []) {
$key = CareQueueContexts::pointExternalKey($context, $branch->id, 'default', 0);
$prior = $priorByKey->get($key, []);
@@ -487,17 +381,6 @@ class CareQueueProvisioner
return $points;
}
protected function ensureBranchName(string $owner, string $branchName): void
{
try {
$this->queue->ensureBranch($owner, ['name' => $branchName]);
} catch (\Illuminate\Http\Client\RequestException $e) {
if ($e->response?->status() !== 422) {
throw $e;
}
}
}
/**
* @return array{queue_uuid: ?string, counter_uuid: ?string, queue_external_key: string, counter_external_key: string, synced: bool, points: list<array<string, mixed>>, routing_mode: string}|null
*/
@@ -632,8 +515,6 @@ class CareQueueProvisioner
if ($existing && ! empty($existing['queue_uuid']) && (
! empty($existing['points']) || ! empty($existing['counter_uuid'])
) && $modeMatches) {
// Specialty module stubs often only store a legacy counter — still provision
// practitioner points so check-in / sync can assign real desks.
if (! CareQueueContexts::isSpecialty($context) || ! empty($existing['points'])) {
return $existing;
}
+2 -16
View File
@@ -734,16 +734,8 @@ class DemoTenantSeeder
private function seedSpecialtyModules(Organization $organization, string $ownerRef): void
{
// 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');
$native = config('care.queue.driver', 'native') === 'native';
if ($queueEnabled && ! $native) {
data_set($settings, 'queue_integration_enabled', false);
$organization->update(['settings' => $settings]);
}
$queueEnabled = (bool) data_get($organization->settings, 'queue_integration_enabled');
$service = app(SpecialtyModuleService::class);
foreach (array_keys($service->catalog()) as $key) {
@@ -754,13 +746,7 @@ class DemoTenantSeeder
}
}
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) {
if ($queueEnabled) {
try {
app(CareQueueProvisioner::class)->provisionOrganization($organization->fresh());
} catch (\Throwable) {
+1 -1
View File
@@ -42,7 +42,7 @@ class PlanService
return in_array($this->planKey($organization), ['pro', 'enterprise'], true);
}
/** Lab, pharmacy, billing, and Ladill Queue integration require Pro or Enterprise. */
/** Lab, pharmacy, billing, and in-app service queues require Pro or Enterprise. */
public function canUseProModules(Organization $organization): bool
{
return $this->hasPaidPlan($organization);
-327
View File
@@ -1,327 +0,0 @@
<?php
namespace App\Services\Care;
use App\Models\Branch;
use App\Models\Member;
use App\Models\Organization;
use App\Services\Queue\QueueClient;
use Illuminate\Http\Client\RequestException;
use Illuminate\Http\Request;
/**
* Loads Ladill Queue console state for embedding on role-native pages
* (clinical queue, pharmacy, lab, specialty modules) instead of a standalone Service Queues nav.
*/
class ServiceQueuePresenter
{
/** @var array<string, list<string>> */
protected array $contextKeywords = [
'clinical' => ['reception', 'consult', 'outpatient', 'general', 'doctor', 'nurse', 'triage', 'waiting'],
'pharmacy' => ['pharm', 'dispens', 'drug', 'rx'],
'laboratory' => ['lab', 'sample', 'patholog', 'investig'],
'dentistry' => ['dent', 'dental', 'oral'],
'ophthalmology' => ['eye', 'ophthal', 'optom'],
'physiotherapy' => ['physio', 'rehab', 'therapy'],
'maternity' => ['matern', 'antenatal', 'obstetric'],
'radiology' => ['radio', 'imaging', 'x-ray', 'xray', 'ultrasound', 'ct', 'mri'],
];
public function __construct(
protected QueueClient $queue,
protected CarePermissions $permissions,
protected PlanService $plans,
protected OrganizationResolver $resolver,
) {}
/**
* @return array{
* enabled: bool,
* counters: list<array<string, mixed>>,
* counter_uuid: ?string,
* state: ?array<string, mixed>,
* stubs: list<array<string, mixed>>,
* error: ?string,
* context: string
* }|null
*/
public function panel(
Request $request,
Organization $organization,
?Member $member,
string $context = 'clinical',
?string $preferredCounterUuid = null,
): ?array {
if (! $this->shouldShow($organization, $member)) {
return null;
}
$owner = (string) $organization->owner_ref;
$branchScope = $this->resolver->branchScope($member);
$branchName = null;
if ($branchScope) {
$branchName = Branch::owned($owner)->where('id', $branchScope)->value('name');
}
$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,
'counters' => [],
'counter_uuid' => null,
'state' => null,
'stubs' => $stubs,
'error' => 'Ladill Queue API is not configured on this Care instance.',
'context' => $context,
];
}
try {
$counters = $this->queue->counters($owner);
} catch (RequestException $e) {
if ($e->response?->status() === 403) {
try {
$this->queue->syncIntegration($owner, true);
$counters = $this->queue->counters($owner);
} catch (RequestException) {
return [
'enabled' => true,
'counters' => [],
'counter_uuid' => null,
'state' => null,
'stubs' => $stubs,
'error' => 'Queue integration is not linked. Re-save Settings with Queue enabled.',
'context' => $context,
];
}
} else {
return [
'enabled' => true,
'counters' => [],
'counter_uuid' => null,
'state' => null,
'stubs' => $stubs,
'error' => 'Could not reach Ladill Queue.',
'context' => $context,
];
}
}
$stubCounterUuid = collect($stubs)
->map(fn ($s) => (string) ($s['counter_uuid'] ?? ''))
->first(fn ($uuid) => $uuid !== '');
$counters = $this->filterCounters($counters, $branchName, $context, $stubCounterUuid);
$counterUuid = $preferredCounterUuid
?: ($stubCounterUuid ?: '')
?: (string) ($counters[0]['uuid'] ?? '');
if ($counterUuid !== '' && ! collect($counters)->contains(fn ($c) => ($c['uuid'] ?? '') === $counterUuid)) {
$counterUuid = (string) ($counters[0]['uuid'] ?? '');
}
$state = null;
if ($counterUuid !== '') {
try {
$state = $this->queue->console($owner, $counterUuid);
} catch (RequestException) {
return [
'enabled' => true,
'counters' => $counters,
'counter_uuid' => $counterUuid,
'state' => null,
'stubs' => $stubs,
'error' => 'Counter console unavailable.',
'context' => $context,
];
}
}
return [
'enabled' => true,
'counters' => $counters,
'counter_uuid' => $counterUuid !== '' ? $counterUuid : null,
'state' => $state,
'stubs' => $stubs,
'error' => null,
'context' => $context,
];
}
public function shouldShow(Organization $organization, ?Member $member): bool
{
if (! $member) {
return false;
}
if (! $this->plans->canUseQueueIntegration($organization)) {
return false;
}
if (! data_get($organization->settings, 'queue_integration_enabled')) {
return false;
}
return $this->permissions->can($member, 'service_queues.console');
}
/**
* @param list<array<string, mixed>> $counters
* @return list<array<string, mixed>>
*/
protected function filterCounters(
array $counters,
?string $branchName,
string $context,
?string $preferredUuid = null,
): array {
$all = collect($counters);
$filtered = $all;
if ($preferredUuid) {
$linked = $all->first(fn ($c) => ($c['uuid'] ?? '') === $preferredUuid);
if ($linked) {
return [$linked];
}
}
if ($branchName) {
$branchFiltered = $filtered->filter(function ($counter) use ($branchName) {
$counterBranch = (string) ($counter['branch'] ?? '');
return $counterBranch === '' || strcasecmp($counterBranch, $branchName) === 0;
});
if ($branchFiltered->isNotEmpty()) {
$filtered = $branchFiltered;
}
}
$keywords = $this->keywordsFor($context);
if ($keywords !== []) {
$keywordFiltered = $filtered->filter(function ($counter) use ($keywords) {
$queueNames = collect($counter['queues'] ?? [])->map(function ($q) {
return is_array($q) ? (string) ($q['name'] ?? '') : (string) $q;
})->implode(' ');
$haystack = strtolower(implode(' ', [
(string) ($counter['name'] ?? ''),
$queueNames,
]));
foreach ($keywords as $keyword) {
if ($keyword !== '' && str_contains($haystack, $keyword)) {
return true;
}
}
return false;
});
// Fall back to branch-scoped counters when no keyword match (demo / generic counters).
if ($keywordFiltered->isNotEmpty()) {
$filtered = $keywordFiltered;
}
}
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>
*/
protected function keywordsFor(string $context): array
{
if (isset($this->contextKeywords[$context])) {
return $this->contextKeywords[$context];
}
$module = config("care.specialty_modules.{$context}.queue_keywords");
return is_array($module) ? $module : [];
}
/**
* @return list<array<string, mixed>>
*/
protected function stubsForContext(Organization $organization, string $context, ?int $branchId): array
{
if (! array_key_exists($context, config('care.specialty_modules', []))) {
return [];
}
$queues = data_get($organization->settings, "specialty_module_provisioning.{$context}.queues", []);
if (! is_array($queues)) {
return [];
}
return array_values(array_filter($queues, function ($queue) use ($branchId) {
if (! ($queue['active'] ?? false)) {
return false;
}
if ($branchId !== null && (int) ($queue['branch_id'] ?? 0) !== $branchId) {
return false;
}
return true;
}));
}
}
+10 -37
View File
@@ -3,18 +3,18 @@
namespace App\Services\Care;
use App\Models\Branch;
use App\Models\CareServiceQueue;
use App\Models\Department;
use App\Models\Member;
use App\Models\Organization;
use App\Models\Practitioner;
use App\Services\Queue\QueueClient;
use Illuminate\Support\Facades\Log;
/**
* Activate/deactivate specialty practice modules (dentistry, eye care, ).
*
* Persistence: organization.settings.specialty_modules[key] = bool
* Provisioning: departments per branch + Care-side queue stubs (and best-effort Queue API sync).
* Provisioning: departments per branch + Care Queue Engine stubs/points.
* Deactivate hides UI and marks queues inactive; does not destroy clinical history.
*
* Plan gate: Pro/Enterprise via PlanService feature specialty_modules (same tier as queue_integration).
@@ -23,7 +23,6 @@ class SpecialtyModuleService
{
public function __construct(
protected PlanService $plans,
protected QueueClient $queue,
) {}
/**
@@ -332,7 +331,6 @@ class SpecialtyModuleService
$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)
@@ -349,25 +347,6 @@ class SpecialtyModuleService
]);
}
}
} 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(),
]);
}
}
}
}
@@ -399,27 +378,21 @@ class SpecialtyModuleService
}
$queues = is_array($record['queues'] ?? null) ? $record['queues'] : [];
foreach ($queues as $i => $queue) {
$queues[$i]['active'] = false;
}
if (
data_get($settings, 'queue_integration_enabled')
&& config('care.queue.driver', 'native') !== 'native'
&& $this->queue->configured()
&& $queues !== []
) {
if (data_get($settings, 'queue_integration_enabled') && $queues !== []) {
try {
$queues = $this->queue->deactivateSpecialtyQueueStubs($ownerRef, $queues);
CareServiceQueue::query()
->where('organization_id', $organization->id)
->where('context', $key)
->update(['is_active' => false]);
} catch (\Throwable $e) {
Log::warning('specialty_module.queue_deactivate_failed', [
'key' => $key,
'message' => $e->getMessage(),
]);
foreach ($queues as $i => $queue) {
$queues[$i]['active'] = false;
}
}
} else {
foreach ($queues as $i => $queue) {
$queues[$i]['active'] = false;
}
}
-397
View File
@@ -1,397 +0,0 @@
<?php
namespace App\Services\Queue;
use Illuminate\Http\Client\RequestException;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
class QueueClient
{
private function base(): string
{
return rtrim((string) config('care.queue.api_url'), '/');
}
private function token(): string
{
return (string) (config('care.queue.api_key') ?? '');
}
private function http(string $owner)
{
return Http::withToken($this->token())
->acceptJson()
->timeout(12)
->withQueryParameters(['owner' => $owner]);
}
public function configured(): bool
{
return $this->token() !== '' && $this->base() !== '';
}
/**
* @return array<string, mixed>
*/
public function status(string $owner): array
{
$response = $this->http($owner)->get($this->base().'/integrations/status');
$response->throw();
return (array) ($response->json('data') ?? []);
}
/**
* @return array<string, mixed>
*/
public function enable(string $owner, string $organizationName, ?string $branchName = null): array
{
$this->http($owner)->post($this->base().'/integrations/provision', array_filter([
'organization_name' => $organizationName,
'branch_name' => $branchName,
'industry' => 'healthcare',
]))->throw();
return $this->syncIntegration($owner, true);
}
/**
* @return array<string, mixed>
*/
public function syncIntegration(string $owner, bool $enabled = true): array
{
$response = $this->http($owner)->patch($this->base().'/integrations', [
'care_enabled' => $enabled,
]);
$response->throw();
return (array) ($response->json('data') ?? []);
}
public function disable(string $owner): void
{
// Local Care setting only; Queue must be disabled separately in Queue settings.
}
public function isLinked(string $owner): bool
{
if (! $this->configured()) {
return false;
}
try {
$status = $this->status($owner);
return (bool) ($status['provisioned'] ?? false)
&& (bool) data_get($status, 'integrations.care', false);
} catch (RequestException) {
return false;
}
}
/**
* @return list<array<string, mixed>>
*/
public function counters(string $owner): array
{
$response = $this->http($owner)->get($this->base().'/counters');
$response->throw();
return (array) ($response->json('data') ?? []);
}
/**
* @return list<array<string, mixed>>
*/
public function branches(string $owner): array
{
$response = $this->http($owner)->get($this->base().'/branches');
$response->throw();
return (array) ($response->json('data') ?? []);
}
/**
* @param array<string, mixed> $payload
* @return array<string, mixed>
*/
public function ensureDepartment(string $owner, array $payload): array
{
$response = $this->http($owner)->post($this->base().'/departments', $payload);
$response->throw();
return (array) ($response->json('data') ?? []);
}
/**
* @param array<string, mixed> $payload
* @return array<string, mixed>
*/
public function ensureBranch(string $owner, array $payload): array
{
$response = $this->http($owner)->post($this->base().'/branches', $payload);
$response->throw();
return (array) ($response->json('data') ?? []);
}
/**
* @param array<string, mixed> $payload
* @return array<string, mixed>
*/
public function ensureQueue(string $owner, array $payload): array
{
$response = $this->http($owner)->post($this->base().'/queues', $payload);
$response->throw();
return (array) ($response->json('data') ?? []);
}
/**
* @param array<string, mixed> $payload
* @return array<string, mixed>
*/
public function ensureCounter(string $owner, array $payload): array
{
$response = $this->http($owner)->post($this->base().'/counters', $payload);
$response->throw();
return (array) ($response->json('data') ?? []);
}
/**
* Soft-deactivate a Queue counter without destroying history.
*
* @return array<string, mixed>
*/
public function updateCounter(string $owner, string $counterUuid, array $payload): array
{
$response = $this->http($owner)->patch($this->base().'/counters/'.$counterUuid, $payload);
$response->throw();
return (array) ($response->json('data') ?? []);
}
/**
* Soft-deactivate a Queue service queue without destroying history.
*
* @return array<string, mixed>
*/
public function updateQueue(string $owner, string $queueUuid, array $payload): array
{
$response = $this->http($owner)->patch($this->base().'/queues/'.$queueUuid, $payload);
$response->throw();
return (array) ($response->json('data') ?? []);
}
/**
* @return array<string, mixed>
*/
public function console(string $owner, string $counterUuid): array
{
$response = $this->http($owner)->get($this->base().'/counters/'.$counterUuid.'/console');
$response->throw();
return (array) ($response->json('data') ?? []);
}
/**
* @param array<string, mixed> $payload
* @return array<string, mixed>
*/
public function consoleAction(string $owner, string $counterUuid, array $payload): array
{
$response = $this->http($owner)->post($this->base().'/counters/'.$counterUuid.'/console', $payload);
$response->throw();
return (array) ($response->json('data') ?? []);
}
/**
* @param array<string, mixed> $payload
* @return array<string, mixed>
*/
public function issueTicket(string $owner, array $payload): array
{
$response = $this->http($owner)->post($this->base().'/tickets', $payload);
$response->throw();
return (array) ($response->json('data') ?? []);
}
/**
* @return array<string, mixed>|null
*/
public function callNext(string $owner, string $queueUuid, string $counterUuid): ?array
{
$response = $this->http($owner)->post($this->base().'/queues/'.$queueUuid.'/call-next', [
'counter_id' => $counterUuid,
]);
$response->throw();
$data = $response->json('data');
return is_array($data) ? $data : null;
}
/**
* @param array<string, mixed> $payload
* @return array<string, mixed>
*/
public function ticketAction(string $owner, string $ticketUuid, array $payload): array
{
$response = $this->http($owner)->post($this->base().'/tickets/'.$ticketUuid.'/action', $payload);
$response->throw();
return (array) ($response->json('data') ?? []);
}
/**
* Create or link real Queue queues/counters for Care specialty stubs.
* Idempotent via external_key (and Queue-side name+branch fallback).
*
* @param list<array<string, mixed>> $stubs
* @return list<array<string, mixed>>
*/
public function syncSpecialtyQueueStubs(string $owner, array $stubs): array
{
if (! $this->configured() || $stubs === []) {
return $stubs;
}
try {
if (! $this->isLinked($owner)) {
return $stubs;
}
} catch (\Throwable) {
return $stubs;
}
foreach ($stubs as $i => $stub) {
if (! ($stub['active'] ?? true)) {
continue;
}
try {
$stubs[$i] = $this->ensureSpecialtyStub($owner, $stub);
} catch (\Throwable $e) {
Log::warning('queue.specialty_stub_sync_failed', [
'owner' => $owner,
'stub' => $stub['name'] ?? null,
'branch' => $stub['branch_name'] ?? null,
'message' => $e->getMessage(),
]);
$stubs[$i]['synced'] = false;
}
}
return $stubs;
}
/**
* Soft-deactivate Queue resources linked on Care stubs (leaves history intact).
*
* @param list<array<string, mixed>> $stubs
* @return list<array<string, mixed>>
*/
public function deactivateSpecialtyQueueStubs(string $owner, array $stubs): array
{
if (! $this->configured() || $stubs === []) {
return $stubs;
}
foreach ($stubs as $i => $stub) {
try {
if (! empty($stub['counter_uuid'])) {
$this->updateCounter($owner, (string) $stub['counter_uuid'], ['is_active' => false]);
}
if (! empty($stub['queue_uuid'])) {
$this->updateQueue($owner, (string) $stub['queue_uuid'], ['is_active' => false]);
}
} catch (\Throwable $e) {
Log::warning('queue.specialty_stub_deactivate_failed', [
'owner' => $owner,
'counter_uuid' => $stub['counter_uuid'] ?? null,
'queue_uuid' => $stub['queue_uuid'] ?? null,
'message' => $e->getMessage(),
]);
}
$stubs[$i]['active'] = false;
}
return $stubs;
}
/**
* @param array<string, mixed> $stub
* @return array<string, mixed>
*/
protected function ensureSpecialtyStub(string $owner, array $stub): array
{
$branchName = trim((string) ($stub['branch_name'] ?? ''));
$name = trim((string) ($stub['name'] ?? ''));
$prefix = trim((string) ($stub['prefix'] ?? 'A'));
$module = (string) ($stub['module'] ?? 'specialty');
$careBranchId = (string) ($stub['branch_id'] ?? '0');
if ($branchName === '' || $name === '') {
$stub['synced'] = false;
return $stub;
}
$queueKey = (string) ($stub['queue_external_key'] ?? "care:specialty:{$module}:queue:{$careBranchId}");
$counterKey = (string) ($stub['counter_external_key'] ?? "care:specialty:{$module}:counter:{$careBranchId}");
try {
$this->ensureBranch($owner, ['name' => $branchName]);
} catch (RequestException $e) {
// Free Queue plans may already be at branch cap — continue if a branch can be resolved.
if ($e->response?->status() !== 422) {
throw $e;
}
$branches = collect($this->branches($owner));
$existing = $branches->first(
fn ($b) => strcasecmp((string) ($b['name'] ?? ''), $branchName) === 0
);
if (! $existing && $branches->count() === 1) {
// Single-location Queue orgs often use a different default name (e.g. "Main Branch").
$existing = $branches->first();
$branchName = (string) ($existing['name'] ?? $branchName);
}
if (! $existing) {
throw $e;
}
}
$queue = $this->ensureQueue($owner, [
'name' => $name,
'prefix' => $prefix !== '' ? $prefix : 'A',
'branch_name' => $branchName,
'strategy' => 'fifo',
'external_key' => $queueKey,
'routing_mode' => (string) ($stub['routing_mode'] ?? 'shared_pool'),
'is_active' => true,
]);
$counter = $this->ensureCounter($owner, [
'name' => $name.' counter',
'destination' => $name,
'branch_name' => $branchName,
'queue_uuids' => array_values(array_filter([(string) ($queue['uuid'] ?? '')])),
'external_key' => $counterKey,
'is_active' => true,
]);
$stub['queue_external_key'] = $queueKey;
$stub['counter_external_key'] = $counterKey;
$stub['queue_uuid'] = $queue['uuid'] ?? null;
$stub['counter_uuid'] = $counter['uuid'] ?? null;
$stub['synced'] = ! empty($stub['queue_uuid']) && ! empty($stub['counter_uuid']);
return $stub;
}
}
-7
View File
@@ -396,13 +396,6 @@ return [
'period_days' => (int) env('CARE_PRO_PERIOD_DAYS', 30),
],
'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'),
],
'upgrade_banner' => [
'free' => [
'title' => 'Unlock Care Pro or Enterprise',
+10 -3
View File
@@ -1,6 +1,6 @@
# Care Queue Engine + Specialty Modules — Unified Plan
**Status:** Phase 4 complete (Queue healthcare removed); Phase 5 next
**Status:** Phase 5 complete (Care remote Queue adapter deleted)
**Date:** 2026-07-18
**Repos:** ladill-care (primary), ladill-queue (healthcare removal)
**Related:** `docs/specialty-module-design-standard.md`
@@ -9,7 +9,7 @@
## Phase 1 notes (implementation)
- Config: `CARE_QUEUE_DRIVER=native` (default) | `remote` (legacy Ladill Queue HTTP)
- Config: Care Queue Engine only (in-app tables + `CareQueueEngine`)
- Tables: `care_service_queues`, `care_service_points`, `care_queue_tickets`
- Service: `App\Services\Care\CareQueueEngine`
@@ -34,7 +34,14 @@
- Queue marketing/settings copy now Frontdesk & POS only; `assigned_only` kept as generic routing
- DemoWorld: Care receptionist apps no longer include `queue`; dedicated Queue operators (`demo-*-queue@ladill.com`)
- Care upgrade banner no longer lists Ladill Queue as a Pro dependency
- Phase 5 still deletes Cares remote `QueueClient` adapter
## Phase 5 notes (implementation)
- Deleted `QueueClient`, remote provisioner/bridge arms, Ladill Queue console views/presenter
- Removed `CARE_QUEUE_DRIVER` / `QUEUE_API_*` from Care config and `.env.example`
- Bridge / provisioner / specialty activate always use Care Queue Engine tables
- `care:queue-sync-tickets` remains (native backfill only)
- Tests assert local `CareQueueTicket` / `CareServiceQueue` (no Queue HTTP fakes)
---
-1
View File
@@ -30,7 +30,6 @@
<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"/>
+1 -1
View File
@@ -102,7 +102,7 @@
<li>Laboratory & pharmacy</li>
<li>Encounter billing</li>
<li>Your own Paystack / Flutterwave / Hubtel</li>
<li>Ladill Queue integration</li>
<li>In-app service queues</li>
<li>Unlimited branches (billed per branch)</li>
</ul>
<div class="mt-6">
@@ -1,145 +0,0 @@
{{-- Compact Ladill Queue panel for role-native pages (not a standalone Service Queues nav). --}}
@php
$panel = $serviceQueuePanel ?? null;
@endphp
@if (! empty($panel['enabled']))
@php
$counterUuid = $panel['counter_uuid'] ?? null;
$state = $panel['state'] ?? null;
$counters = $panel['counters'] ?? [];
$current = $state['current_ticket'] ?? null;
$queues = $state['queues'] ?? [];
$waiting = $state['waiting_by_queue'] ?? [];
$counter = $state['counter'] ?? [];
$stubs = $panel['stubs'] ?? [];
$statusLabel = match ($current['status'] ?? null) {
'called' => 'Called',
'serving' => 'Serving',
'on_hold' => 'On hold',
default => $current['status'] ?? null,
};
@endphp
<section class="rounded-2xl border border-indigo-100 bg-gradient-to-br from-indigo-50/80 via-white to-white shadow-sm">
<div class="flex flex-wrap items-start justify-between gap-3 border-b border-indigo-100 px-5 py-4">
<div>
<p class="text-xs font-semibold uppercase tracking-wider text-indigo-600">Service counter</p>
<h2 class="mt-0.5 text-sm font-semibold text-slate-900">
{{ $counter['name'] ?? 'Ladill Queue' }}
@if (! empty($counter['branch']))
<span class="font-normal text-slate-500">· {{ $counter['branch'] }}</span>
@endif
</h2>
</div>
<div class="flex flex-wrap items-center gap-2">
@if (count($counters) > 1 && $counterUuid)
<form method="GET" class="flex items-center gap-2">
@foreach (request()->except('counter_uuid') as $key => $value)
@if (is_scalar($value))
<input type="hidden" name="{{ $key }}" value="{{ $value }}">
@endif
@endforeach
<select name="counter_uuid" class="rounded-lg border-slate-300 text-xs" onchange="this.form.submit()">
@foreach ($counters as $option)
<option value="{{ $option['uuid'] }}" @selected(($option['uuid'] ?? '') === $counterUuid)>{{ $option['name'] ?? 'Counter' }}</option>
@endforeach
</select>
</form>
@endif
@if ($counterUuid)
<a href="{{ route('care.service-queues.console', $counterUuid) }}" class="text-xs font-medium text-indigo-600 hover:text-indigo-800">Full console</a>
@endif
</div>
</div>
<div class="space-y-4 px-5 py-4">
@if (! empty($panel['error']))
<p class="rounded-lg bg-amber-50 px-3 py-2 text-sm text-amber-900">{{ $panel['error'] }}</p>
@endif
@if ($current && $counterUuid)
<div class="flex flex-wrap items-start justify-between gap-3">
<div>
<p class="text-xs font-medium uppercase tracking-wide text-slate-500">Now serving</p>
<p class="mt-1 text-3xl font-bold tracking-tight text-slate-900">{{ $current['ticket_number'] }}</p>
<p class="mt-1 text-sm text-slate-600">{{ $current['customer_name'] ?? 'Walk-in' }} · {{ $current['queue']['name'] ?? '' }}</p>
</div>
@if ($statusLabel)
<span class="rounded-full bg-indigo-100 px-2.5 py-1 text-xs font-semibold text-indigo-700">{{ $statusLabel }}</span>
@endif
</div>
<div class="flex flex-wrap gap-2">
@foreach ([
'start' => 'Start',
'complete' => 'Complete',
'recall' => 'Recall',
'skip' => 'Skip',
] as $action => $label)
<form method="POST" action="{{ route('care.service-queues.action', $counterUuid) }}">
@csrf
<input type="hidden" name="action" value="{{ $action }}">
<input type="hidden" name="ticket_uuid" value="{{ $current['uuid'] }}">
<button type="submit" class="{{ $action === 'complete' || $action === 'start' ? 'btn-primary' : 'btn-secondary' }} text-xs">{{ $label }}</button>
</form>
@endforeach
</div>
@elseif ($counterUuid)
<p class="text-sm text-slate-600">No active ticket. Call the next patient from a queue below.</p>
@elseif (count($stubs))
<div class="rounded-xl border border-dashed border-slate-200 bg-slate-50 px-4 py-3 text-sm text-slate-600">
<p class="font-medium text-slate-800">Specialty queues ready</p>
<ul class="mt-2 space-y-1 text-xs">
@foreach ($stubs as $stub)
<li>
{{ $stub['name'] ?? 'Queue' }}
@if (! empty($stub['branch_name'])) · {{ $stub['branch_name'] }} @endif
@if (! empty($stub['prefix']))
<span class="font-mono text-slate-500">({{ $stub['prefix'] }})</span>
@endif
@if (! empty($stub['synced']))
<span class="text-emerald-600">· linked</span>
@else
<span class="text-slate-400">· create in Queue admin if missing</span>
@endif
</li>
@endforeach
</ul>
</div>
@elseif (! $panel['error'])
<p class="text-sm text-slate-500">No counters found for this branch. Create counters in Queue admin, then refresh.</p>
@endif
@if ($counterUuid && count($queues))
<div class="grid gap-3 sm:grid-cols-2">
@foreach ($queues as $queue)
@php $waitingTickets = $waiting[$queue['uuid']] ?? []; @endphp
<div class="rounded-xl border border-slate-200 bg-white px-4 py-3">
<div class="flex items-center justify-between gap-2">
<div>
<p class="text-sm font-semibold text-slate-900">{{ $queue['name'] }}</p>
<p class="text-xs text-slate-500">{{ count($waitingTickets) }} waiting</p>
</div>
<form method="POST" action="{{ route('care.service-queues.action', $counterUuid) }}">
@csrf
<input type="hidden" name="action" value="call_next">
<input type="hidden" name="queue_uuid" value="{{ $queue['uuid'] }}">
<button type="submit" class="btn-primary text-xs">Call next</button>
</form>
</div>
@if (count($waitingTickets))
<ul class="mt-2 space-y-1 border-t border-slate-50 pt-2">
@foreach (array_slice($waitingTickets, 0, 3) as $ticket)
<li class="flex justify-between text-xs text-slate-600">
<span class="font-mono font-semibold text-slate-800">{{ $ticket['ticket_number'] }}</span>
<span class="truncate pl-2">{{ $ticket['customer_name'] ?? 'Walk-in' }}</span>
</li>
@endforeach
</ul>
@endif
</div>
@endforeach
</div>
@endif
</div>
</section>
@endif
@@ -1,142 +0,0 @@
@php
$counter = $state['counter'] ?? [];
$current = $state['current_ticket'] ?? null;
$queues = $state['queues'] ?? [];
$waiting = $state['waiting_by_queue'] ?? [];
$transferQueues = collect($state['transfer_queues'] ?? []);
$handoffQueues = $transferQueues
->filter(fn ($q) => ($q['uuid'] ?? '') !== ($current['queue']['uuid'] ?? ''))
->values();
$statusLabel = match ($current['status'] ?? null) {
'called' => 'Called',
'serving' => 'Serving',
'on_hold' => 'On hold',
default => $current['status'] ?? null,
};
@endphp
<x-app-layout title="Console · {{ $counter['name'] ?? 'Counter' }}">
<div class="flex flex-wrap items-start justify-between gap-4">
<div>
<a href="{{ route('care.queue.index') }}" class="text-sm font-medium text-indigo-600 hover:text-indigo-800"> Patient queue</a>
<h1 class="mt-2 text-xl font-semibold text-slate-900">{{ $counter['name'] ?? 'Counter' }}</h1>
<p class="mt-1 text-sm text-slate-500">{{ $counter['branch'] ?? '' }} · Ladill Queue console</p>
</div>
<div class="flex flex-wrap items-center gap-2">
@if (! empty($counter['status']))
<span class="rounded-full bg-slate-100 px-2.5 py-1 text-xs font-medium capitalize text-slate-600">{{ $counter['status'] }}</span>
@endif
<div class="btn-group">
<form method="POST" action="{{ route('care.service-queues.action', $counterUuid) }}">@csrf<input type="hidden" name="action" value="available"><button class="btn-secondary text-sm">Mark available</button></form>
<form method="POST" action="{{ route('care.service-queues.action', $counterUuid) }}">@csrf<input type="hidden" name="action" value="offline"><button class="btn-secondary text-sm">Go offline</button></form>
</div>
</div>
</div>
@if (session('success'))
<p class="mt-4 rounded-lg bg-emerald-50 px-3 py-2 text-sm text-emerald-800">{{ session('success') }}</p>
@endif
@if (session('error'))
<p class="mt-4 rounded-lg bg-rose-50 px-3 py-2 text-sm text-rose-800">{{ session('error') }}</p>
@endif
@if ($current)
<section class="mt-6 overflow-hidden rounded-2xl border border-indigo-200 bg-gradient-to-br from-indigo-50 via-white to-white shadow-sm">
<div class="border-b border-indigo-100 px-6 py-4">
<p class="text-xs font-semibold uppercase tracking-wider text-indigo-600">Current customer</p>
</div>
<div class="px-6 py-5">
<div class="flex flex-wrap items-start justify-between gap-4">
<div>
<p class="text-5xl font-bold tracking-tight text-slate-900">{{ $current['ticket_number'] }}</p>
<p class="mt-2 text-sm text-slate-600">{{ $current['queue']['name'] ?? '' }}</p>
<p class="mt-1 text-base font-medium text-slate-800">{{ $current['customer_name'] ?? 'Walk-in customer' }}</p>
</div>
@if ($statusLabel)
<span class="rounded-full bg-indigo-100 px-2.5 py-1 text-xs font-semibold text-indigo-700">{{ $statusLabel }}</span>
@endif
</div>
<div class="mt-6 grid gap-2 sm:grid-cols-2 lg:grid-cols-3">
@foreach ([
'recall' => ['Recall', 'btn-secondary'],
'start' => ['Start serving', 'btn-primary'],
'hold' => ['Hold', 'btn-secondary'],
'skip' => ['Skip', 'btn-secondary'],
'complete' => ['Complete', 'btn-primary'],
'no_show' => ['No show', 'btn-secondary'],
] as $action => [$label, $btnClass])
<form method="POST" action="{{ route('care.service-queues.action', $counterUuid) }}">
@csrf
<input type="hidden" name="action" value="{{ $action }}">
<input type="hidden" name="ticket_uuid" value="{{ $current['uuid'] }}">
<button type="submit" class="{{ $btnClass }} w-full text-sm">{{ $label }}</button>
</form>
@endforeach
</div>
@if ($handoffQueues->isNotEmpty())
<form method="POST" action="{{ route('care.service-queues.action', $counterUuid) }}" class="mt-4 border-t border-indigo-100 pt-4">
@csrf
<input type="hidden" name="action" value="transfer">
<input type="hidden" name="ticket_uuid" value="{{ $current['uuid'] }}">
<div class="mb-3">
<p class="text-sm font-medium text-slate-800">Hand off to another queue</p>
<p class="mt-1 text-xs text-slate-500">Sends this patient to the next service while keeping ticket <span class="font-mono font-semibold text-slate-700">{{ $current['ticket_number'] }}</span>.</p>
</div>
<div class="flex flex-wrap items-end gap-2">
<div class="min-w-[12rem] flex-1">
<label class="block text-xs font-medium text-slate-500">Next queue</label>
<select name="to_queue_uuid" required class="mt-1 w-full rounded-lg border-slate-300 text-sm">
@foreach ($handoffQueues as $q)
<option value="{{ $q['uuid'] }}">{{ $q['name'] }}@if (! empty($q['prefix'])) ({{ $q['prefix'] }})@endif</option>
@endforeach
</select>
</div>
<div class="min-w-[12rem] flex-1">
<label class="block text-xs font-medium text-slate-500">Reason (optional)</label>
<input type="text" name="reason" placeholder="e.g. Lab tests needed" class="mt-1 w-full rounded-lg border-slate-300 text-sm">
</div>
<button type="submit" class="btn-primary text-sm">Hand off</button>
</div>
</form>
@endif
</div>
</section>
@else
<section class="mt-6 rounded-2xl border border-dashed border-slate-300 bg-slate-50 px-6 py-10 text-center">
<p class="text-sm font-medium text-slate-700">No active customer</p>
<p class="mt-1 text-sm text-slate-500">Call the next waiting ticket from a queue below.</p>
</section>
@endif
<div class="mt-6 grid gap-4 lg:grid-cols-2">
@foreach ($queues as $queue)
@php $waitingTickets = $waiting[$queue['uuid']] ?? []; @endphp
<section class="rounded-2xl border border-slate-200 bg-white shadow-sm">
<div class="flex items-center justify-between border-b border-slate-100 px-5 py-4">
<div>
<h2 class="font-semibold text-slate-900">{{ $queue['name'] }}</h2>
<p class="text-xs text-slate-500">{{ count($waitingTickets) }} waiting</p>
</div>
<form method="POST" action="{{ route('care.service-queues.action', $counterUuid) }}">
@csrf
<input type="hidden" name="action" value="call_next">
<input type="hidden" name="queue_uuid" value="{{ $queue['uuid'] }}">
<button type="submit" class="btn-primary text-sm">Call next</button>
</form>
</div>
<ul class="divide-y divide-slate-50 px-2 py-2">
@forelse ($waitingTickets as $ticket)
<li class="flex items-center justify-between rounded-lg px-3 py-2.5 text-sm hover:bg-slate-50">
<span class="font-mono text-base font-semibold text-slate-900">{{ $ticket['ticket_number'] }}</span>
<span class="text-slate-500">{{ $ticket['customer_name'] ?? 'Walk-in' }}</span>
</li>
@empty
<li class="px-3 py-6 text-center text-sm text-slate-500">Queue is empty</li>
@endforelse
</ul>
</section>
@endforeach
</div>
</x-app-layout>
@@ -1,34 +0,0 @@
<x-app-layout title="Service queues">
<div class="flex flex-wrap items-start justify-between gap-4">
<div>
<h1 class="text-xl font-semibold text-slate-900">Service queues</h1>
<p class="mt-1 text-sm text-slate-500">Powered by Ladill Queue call tickets and manage counters without leaving Care</p>
</div>
<a href="https://queue.ladill.com" target="_blank" class="btn-secondary text-sm">Open Queue admin</a>
</div>
@if (session('error'))
<div class="mt-4 rounded-lg bg-rose-50 px-4 py-3 text-sm text-rose-800">{{ session('error') }}</div>
@endif
<div class="mt-5 grid gap-4 md:grid-cols-2 xl:grid-cols-3">
@forelse ($counters as $counter)
<article class="rounded-2xl border border-slate-200 bg-white p-5 shadow-sm">
<div class="flex items-start justify-between gap-3">
<div>
<h2 class="text-lg font-semibold text-slate-900">{{ $counter['name'] }}</h2>
<p class="mt-1 text-sm text-slate-500">{{ $counter['status'] ?? 'offline' }}</p>
</div>
</div>
<p class="mt-3 text-sm text-slate-600">
Queues: {{ collect($counter['queues'] ?? [])->join(', ') ?: 'None assigned' }}
</p>
<a href="{{ route('care.service-queues.console', $counter['uuid']) }}" class="btn-primary mt-4 inline-block w-full text-center text-sm">Open console</a>
</article>
@empty
<div class="col-span-full rounded-2xl border border-dashed border-slate-300 bg-slate-50 px-6 py-12 text-center text-sm text-slate-600">
No counters found in Ladill Queue. Create counters in Queue admin, then return here.
</div>
@endforelse
</div>
</x-app-layout>
+2 -2
View File
@@ -134,7 +134,7 @@
</label>
</x-settings.card>
<x-settings.card title="Patient journey workflow" description="Move each visit through configured stages and control when it may enter a Ladill Queue service line.">
<x-settings.card title="Patient journey workflow" description="Move each visit through configured stages and control when it may enter a service queue.">
<div class="space-y-4">
<label class="flex items-start gap-3 text-sm">
<input type="checkbox" name="workflow_engine" value="1"
@@ -237,7 +237,7 @@
</div>
</x-settings.card>
<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.)">
<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.">
@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'))>
@@ -9,7 +9,7 @@
<x-settings.card title="Upgrade your plan">
<p class="text-sm text-slate-600">
GHS {{ number_format($proPriceMinor / 100, 0) }} / branch / month for Pro
multi-branch locations, laboratory, pharmacy, encounter billing, and Ladill Queue integration.
multi-branch locations, laboratory, pharmacy, encounter billing, and in-app service queues.
</p>
<div class="mt-4 flex flex-wrap items-center gap-3">
<a href="{{ route('care.pro.index') }}" class="btn-primary">View plans</a>
@@ -47,11 +47,6 @@ class CareFinancialWorkflowRuntimeTest extends TestCase
parent::setUp();
$this->withoutMiddleware(EnsurePlatformSession::class);
config([
'care.queue.api_url' => 'https://queue.test/api/v1',
'care.queue.api_key' => 'workflow-test-key',
]);
$this->owner = User::create([
'public_id' => 'financial-workflow-owner',
'name' => 'Workflow Admin',
+1 -8
View File
@@ -39,12 +39,6 @@ class CareNativeQueueTest extends TestCase
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',
@@ -99,10 +93,9 @@ class CareNativeQueueTest extends TestCase
]);
}
public function test_bridge_enabled_without_queue_api_keys(): void
public function test_bridge_enabled_when_integration_on(): 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
+58 -59
View File
@@ -3,11 +3,17 @@
namespace Tests\Feature;
use App\Http\Middleware\EnsurePlatformSession;
use App\Models\Appointment;
use App\Models\Branch;
use App\Models\CareQueueTicket;
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 Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Http;
use Tests\TestCase;
/**
@@ -21,16 +27,13 @@ class CareNaturalQueueTest extends TestCase
protected Organization $organization;
protected Branch $branch;
protected function setUp(): void
{
parent::setUp();
$this->withoutMiddleware(EnsurePlatformSession::class);
config([
'care.queue.api_url' => 'https://queue.test/api/v1',
'care.queue.api_key' => 'care-test-key',
]);
$this->owner = User::create([
'public_id' => 'care-queue-owner',
'name' => 'Owner',
@@ -47,38 +50,6 @@ class CareNaturalQueueTest extends TestCase
'plan' => 'pro',
'plan_expires_at' => now()->addMonth()->toIso8601String(),
'queue_integration_enabled' => true,
'department_queue_provisioning' => [
'consultation' => [
'queues' => [[
'branch_id' => 1,
'name' => 'Consultation',
'active' => true,
'synced' => true,
'queue_uuid' => 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa',
'counter_uuid' => 'bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb',
]],
],
'pharmacy' => [
'queues' => [[
'branch_id' => 1,
'name' => 'Pharmacy',
'active' => true,
'synced' => true,
'queue_uuid' => 'cccccccc-cccc-cccc-cccc-cccccccccccc',
'counter_uuid' => 'dddddddd-dddd-dddd-dddd-dddddddddddd',
]],
],
'laboratory' => [
'queues' => [[
'branch_id' => 1,
'name' => 'Laboratory',
'active' => true,
'synced' => true,
'queue_uuid' => 'eeeeeeee-eeee-eeee-eeee-eeeeeeeeeeee',
'counter_uuid' => 'ffffffff-ffff-ffff-ffff-ffffffffffff',
]],
],
],
],
]);
@@ -89,19 +60,12 @@ class CareNaturalQueueTest extends TestCase
'role' => 'hospital_admin',
]);
$branch = Branch::create([
$this->branch = Branch::create([
'owner_ref' => $this->owner->public_id,
'organization_id' => $this->organization->id,
'name' => 'Main',
'is_active' => true,
]);
// Keep provisioning stubs aligned with the created branch id.
$settings = $this->organization->settings;
foreach (['consultation', 'pharmacy', 'laboratory'] as $ctx) {
$settings['department_queue_provisioning'][$ctx]['queues'][0]['branch_id'] = $branch->id;
}
$this->organization->update(['settings' => $settings]);
}
public function test_sidebar_does_not_show_service_queues_nav(): void
@@ -165,24 +129,59 @@ class CareNaturalQueueTest extends TestCase
public function test_doctor_call_next_uses_consultation_queue_not_reception(): void
{
\Illuminate\Support\Facades\Http::fake([
'*/queues/*/call-next*' => \Illuminate\Support\Facades\Http::response([
'data' => [
'uuid' => '77777777-7777-7777-7777-777777777777',
'ticket_number' => 'C001',
'status' => 'called',
],
], 200),
Http::fake();
$practitioner = Practitioner::create([
'owner_ref' => $this->owner->public_id,
'organization_id' => $this->organization->id,
'branch_id' => $this->branch->id,
'name' => 'Dr. Natural',
'room' => 'Room 1',
'is_active' => true,
]);
$branchId = Branch::query()->firstOrFail()->id;
$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' => 'Ada',
'last_name' => 'Lovelace',
]);
$appointment = Appointment::create([
'owner_ref' => $this->owner->public_id,
'organization_id' => $this->organization->id,
'branch_id' => $this->branch->id,
'patient_id' => $patient->id,
'practitioner_id' => $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,
);
$this->actingAs($this->owner)
->post(route('care.queue.call-next'), ['branch_id' => $branchId])
->assertRedirect();
->post(route('care.queue.call-next'), [
'branch_id' => $this->branch->id,
'practitioner_id' => $practitioner->id,
])
->assertRedirect()
->assertSessionHas('success');
\Illuminate\Support\Facades\Http::assertSent(function ($request) {
return str_contains($request->url(), '/queues/aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa/call-next');
});
$this->assertDatabaseHas('care_queue_tickets', [
'care_entity_id' => $appointment->id,
'status' => CareQueueTicket::STATUS_CALLED,
]);
$this->assertDatabaseMissing('care_service_queues', [
'organization_id' => $this->organization->id,
'context' => 'reception',
]);
Http::assertNothingSent();
}
}
+1 -1
View File
@@ -64,7 +64,7 @@ class CareProTest extends TestCase
->assertSee('/branch/mo')
->assertSee('Unlimited branches')
->assertSee('Core patient records')
->assertSee('Queue integration')
->assertSee('In-app service queues')
->assertSee('Custom multi-department workflow')
->assertSee('AI-assisted healthcare integration')
->assertSee('Upgrade to Pro')
+76 -179
View File
@@ -5,6 +5,7 @@ namespace Tests\Feature;
use App\Http\Middleware\EnsurePlatformSession;
use App\Models\Appointment;
use App\Models\Branch;
use App\Models\CareQueueTicket;
use App\Models\Member;
use App\Models\Organization;
use App\Models\Patient;
@@ -32,11 +33,6 @@ class CareQueueBridgeTest extends TestCase
parent::setUp();
$this->withoutMiddleware(EnsurePlatformSession::class);
config([
'care.queue.api_url' => 'https://queue.test/api/v1',
'care.queue.api_key' => 'care-test-key',
]);
$this->owner = User::create([
'public_id' => 'bridge-owner',
'name' => 'Owner',
@@ -70,33 +66,6 @@ class CareQueueBridgeTest extends TestCase
'is_active' => true,
]);
$settings = $this->organization->settings;
$settings['department_queue_provisioning'] = [
CareQueueContexts::CONSULTATION => [
'queues' => [[
'branch_id' => $this->branch->id,
'branch_name' => 'Main',
'name' => 'Consultation',
'prefix' => 'C',
'active' => true,
'synced' => true,
'routing_mode' => 'assigned_only',
'queue_uuid' => 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa',
'counter_uuid' => 'bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb',
'queue_external_key' => CareQueueContexts::queueExternalKey(CareQueueContexts::CONSULTATION, $this->branch->id),
'counter_external_key' => CareQueueContexts::counterExternalKey(CareQueueContexts::CONSULTATION, $this->branch->id),
'points' => [[
'kind' => 'practitioner',
'ref_id' => 0, // filled after practitioner create in tests that need it
'destination' => 'Consultation desk',
'counter_uuid' => 'bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb',
'synced' => true,
]],
]],
],
];
$this->organization->update(['settings' => $settings]);
$this->patient = Patient::create([
'owner_ref' => $this->owner->public_id,
'organization_id' => $this->organization->id,
@@ -111,6 +80,8 @@ class CareQueueBridgeTest extends TestCase
public function test_check_in_issues_consultation_ticket_when_integration_on(): void
{
Http::fake();
$practitioner = \App\Models\Practitioner::create([
'owner_ref' => $this->owner->public_id,
'organization_id' => $this->organization->id,
@@ -120,36 +91,6 @@ class CareQueueBridgeTest extends TestCase
'is_active' => true,
]);
$settings = $this->organization->settings;
$settings['department_queue_provisioning'][CareQueueContexts::CONSULTATION]['queues'][0]['points'] = [[
'kind' => 'practitioner',
'ref_id' => $practitioner->id,
'destination' => 'Room 1',
'staff_display_name' => 'Dr. Bridge',
'counter_uuid' => 'bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb',
'synced' => true,
]];
$this->organization->update(['settings' => $settings]);
Http::fake(function (\Illuminate\Http\Client\Request $request) {
if (str_contains($request->url(), '/tickets') && $request->method() === 'POST') {
$this->assertSame('bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb', $request['assigned_counter_id'] ?? null);
return Http::response([
'data' => [
'uuid' => 'cccccccc-cccc-cccc-cccc-cccccccccccc',
'ticket_number' => 'C001',
'status' => 'waiting',
'assigned_counter' => ['uuid' => 'bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb'],
'destination' => 'Room 1',
'staff_display_name' => 'Dr. Bridge',
],
], 201);
}
return Http::response(['message' => 'unexpected '.$request->url()], 500);
});
$appointment = Appointment::create([
'owner_ref' => $this->owner->public_id,
'organization_id' => $this->organization->id,
@@ -163,13 +104,19 @@ class CareQueueBridgeTest extends TestCase
$result = app(AppointmentService::class)->checkIn($appointment, $this->owner->public_id, $this->owner->public_id);
$this->assertSame('C001', $result->queue_ticket_number);
$this->assertNotNull($result->queue_ticket_uuid);
$this->assertSame('waiting', $result->queue_ticket_status);
$this->assertSame('cccccccc-cccc-cccc-cccc-cccccccccccc', $result->queue_ticket_uuid);
Http::assertSent(fn ($request) => str_contains($request->url(), '/tickets')
&& ($request['service_queue_id'] ?? null) === 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa'
&& ($request['customer_name'] ?? null) === 'Ada Lovelace');
$this->assertStringStartsWith('C', (string) $result->queue_ticket_number);
$this->assertSame('Room 1', $result->queue_destination);
$this->assertSame('Dr. Bridge', $result->queue_staff_display_name);
$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,
]);
Http::assertNothingSent();
}
public function test_check_in_skips_ticket_when_integration_off(): void
@@ -227,6 +174,8 @@ class CareQueueBridgeTest extends TestCase
public function test_call_next_backfills_missing_ticket_then_calls(): void
{
Http::fake();
$practitioner = \App\Models\Practitioner::create([
'owner_ref' => $this->owner->public_id,
'organization_id' => $this->organization->id,
@@ -236,50 +185,6 @@ class CareQueueBridgeTest extends TestCase
'is_active' => true,
]);
$settings = $this->organization->settings;
$settings['department_queue_provisioning'][CareQueueContexts::CONSULTATION]['queues'][0]['points'] = [[
'kind' => 'practitioner',
'ref_id' => $practitioner->id,
'destination' => 'Room 3',
'counter_uuid' => 'bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb',
'synced' => true,
]];
$settings['department_queue_provisioning'][CareQueueContexts::CONSULTATION]['queues'][0]['counter_uuid'] = 'bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb';
$this->organization->update(['settings' => $settings]);
$ticketSeq = 0;
Http::fake(function (\Illuminate\Http\Client\Request $request) use (&$ticketSeq) {
if (str_contains($request->url(), '/call-next') && $request->method() === 'POST') {
if ($ticketSeq < 1) {
return Http::response(['data' => null], 200);
}
return Http::response([
'data' => [
'uuid' => 'ffffffff-ffff-ffff-ffff-ffffffffffff',
'ticket_number' => 'C100',
'customer_name' => 'Ada Lovelace',
'status' => 'called',
],
], 200);
}
if (str_contains($request->url(), '/tickets') && $request->method() === 'POST') {
$ticketSeq++;
return Http::response([
'data' => [
'uuid' => 'ffffffff-ffff-ffff-ffff-ffffffffffff',
'ticket_number' => 'C100',
'status' => 'waiting',
'assigned_counter' => ['uuid' => 'bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb'],
],
], 201);
}
return Http::response(['message' => 'unexpected '.$request->url()], 500);
});
Appointment::create([
'owner_ref' => $this->owner->public_id,
'organization_id' => $this->organization->id,
@@ -302,16 +207,19 @@ class CareQueueBridgeTest extends TestCase
->assertRedirect(route('care.queue.index'))
->assertSessionHas('success');
$this->assertNotNull(Appointment::query()->where('patient_id', $this->patient->id)->value('queue_ticket_uuid'));
Http::assertSent(fn ($r) => str_contains($r->url(), '/tickets') && $r->method() === 'POST');
Http::assertSent(fn ($r) => str_contains($r->url(), '/call-next'));
$appointment = Appointment::query()->where('patient_id', $this->patient->id)->first();
$this->assertNotNull($appointment?->queue_ticket_uuid);
$this->assertSame('called', $appointment->queue_ticket_status);
$this->assertDatabaseHas('care_queue_tickets', [
'uuid' => $appointment->queue_ticket_uuid,
'status' => CareQueueTicket::STATUS_CALLED,
]);
Http::assertNothingSent();
}
public function test_call_next_empty_queue_flashes_info_not_error(): void
{
Http::fake([
'*/queues/*/call-next*' => Http::response(['data' => null], 200),
]);
Http::fake();
$this->actingAs($this->owner)
->from(route('care.queue.index'))
@@ -319,35 +227,51 @@ class CareQueueBridgeTest extends TestCase
->assertRedirect(route('care.queue.index'))
->assertSessionHas('info', 'No patients waiting at your consultation service point.')
->assertSessionMissing('error');
Http::assertNothingSent();
}
public function test_recall_reannounces_called_ticket_from_patient_queue(): void
{
Http::fake();
$practitioner = \App\Models\Practitioner::create([
'owner_ref' => $this->owner->public_id,
'organization_id' => $this->organization->id,
'branch_id' => $this->branch->id,
'name' => 'Dr. Recall',
'room' => 'Room 2',
'is_active' => true,
]);
$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' => $practitioner->id,
'type' => Appointment::TYPE_WALK_IN,
'status' => Appointment::STATUS_WAITING,
'status' => Appointment::STATUS_SCHEDULED,
'scheduled_at' => now(),
'waiting_at' => now(),
'queue_position' => 1,
'queue_ticket_uuid' => 'eeeeeeee-eeee-eeee-eeee-eeeeeeeeeeee',
'queue_ticket_number' => 'C055',
'queue_ticket_status' => 'called',
]);
Http::fake([
'*/tickets/*/action*' => Http::response([
'data' => [
'uuid' => 'eeeeeeee-eeee-eeee-eeee-eeeeeeeeeeee',
'ticket_number' => 'C055',
'status' => 'called',
'customer_name' => 'Ada Lovelace',
],
], 200),
]);
$checkedIn = app(AppointmentService::class)->checkIn(
$appointment->fresh(['organization', 'patient', 'practitioner']),
$this->owner->public_id,
$this->owner->public_id,
);
$bridge = app(\App\Services\Care\CareQueueBridge::class);
$called = $bridge->callNext(
$this->organization->fresh(),
CareQueueContexts::CONSULTATION,
(int) $this->branch->id,
null,
$practitioner->id,
);
$this->assertNotNull($called['ticket']);
$appointment = $checkedIn->fresh();
$this->assertSame('called', $appointment->queue_ticket_status);
$this->actingAs($this->owner)
->from(route('care.queue.index'))
@@ -355,22 +279,20 @@ class CareQueueBridgeTest extends TestCase
->assertRedirect(route('care.queue.index'))
->assertSessionHas('success');
Http::assertSent(function ($request) {
return str_contains($request->url(), '/tickets/eeeeeeee-eeee-eeee-eeee-eeeeeeeeeeee/action')
&& ($request['action'] ?? null) === 'recall';
});
$this->assertSame('called', $appointment->fresh()->queue_ticket_status);
$this->actingAs($this->owner)
->get(route('care.queue.index', ['branch_id' => $this->branch->id]))
->assertOk()
->assertSee('Call again')
->assertSee('C055');
->assertSee($appointment->queue_ticket_number);
Http::assertNothingSent();
}
public function test_patient_queue_sync_issues_specialty_tickets(): void
{
Http::fake();
$department = \App\Models\Department::create([
'owner_ref' => $this->owner->public_id,
'branch_id' => $this->branch->id,
@@ -402,20 +324,7 @@ class CareQueueBridgeTest extends TestCase
'name' => 'Dentistry',
'prefix' => 'DEN',
'active' => true,
'synced' => true,
'routing_mode' => 'assigned_only',
'queue_uuid' => 'dddddddd-dddd-dddd-dddd-dddddddddddd',
'counter_uuid' => 'eeeeeeee-eeee-eeee-eeee-eeeeeeeeeeee',
'queue_external_key' => CareQueueContexts::queueExternalKey('dentistry', $this->branch->id),
'counter_external_key' => CareQueueContexts::counterExternalKey('dentistry', $this->branch->id),
'points' => [[
'kind' => 'practitioner',
'ref_id' => $practitioner->id,
'destination' => 'Dental Bay 1',
'staff_display_name' => 'Dentistry Clinic',
'counter_uuid' => 'eeeeeeee-eeee-eeee-eeee-eeeeeeeeeeee',
'synced' => true,
]],
'synced' => false,
]],
],
];
@@ -436,36 +345,24 @@ class CareQueueBridgeTest extends TestCase
'reason' => 'Toothache',
]);
Http::fake(function (\Illuminate\Http\Client\Request $request) {
if (str_contains($request->url(), '/tickets') && $request->method() === 'POST') {
$this->assertSame('dddddddd-dddd-dddd-dddd-dddddddddddd', $request['service_queue_id'] ?? null);
$this->assertSame('eeeeeeee-eeee-eeee-eeee-eeeeeeeeeeee', $request['assigned_counter_id'] ?? null);
return Http::response([
'data' => [
'uuid' => 'ffffffff-ffff-ffff-ffff-ffffffffffff',
'ticket_number' => 'DEN001',
'status' => 'waiting',
'assigned_counter' => ['uuid' => 'eeeeeeee-eeee-eeee-eeee-eeeeeeeeeeee'],
'destination' => 'Dental Bay 1',
'staff_display_name' => 'Dentistry Clinic',
],
], 201);
}
return Http::response(['data' => []], 200);
});
$this->actingAs($this->owner)
->get(route('care.queue.index', ['branch_id' => $this->branch->id]))
->assertOk()
->assertSee('DEN001')
->assertSee('Toothache');
$this->assertDatabaseHas('care_appointments', [
'patient_id' => $this->patient->id,
'queue_ticket_number' => 'DEN001',
$appointment = Appointment::query()->where('patient_id', $this->patient->id)->first();
$this->assertNotNull($appointment?->queue_ticket_uuid);
$this->assertStringStartsWith('DEN', (string) $appointment->queue_ticket_number);
$this->assertDatabaseHas('care_service_queues', [
'organization_id' => $this->organization->id,
'branch_id' => $this->branch->id,
'context' => 'dentistry',
]);
$this->assertDatabaseHas('care_queue_tickets', [
'uuid' => $appointment->queue_ticket_uuid,
'ticket_number' => $appointment->queue_ticket_number,
'status' => CareQueueTicket::STATUS_WAITING,
]);
Http::assertNothingSent();
}
}
+105 -178
View File
@@ -5,12 +5,12 @@ 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\Prescription;
use App\Models\User;
use App\Models\Visit;
use App\Services\Care\AppointmentService;
use App\Services\Care\CareQueueBridge;
use App\Services\Care\CareQueueContexts;
@@ -34,11 +34,6 @@ class CareQueueWorkflowTest extends TestCase
parent::setUp();
$this->withoutMiddleware(EnsurePlatformSession::class);
config([
'care.queue.api_url' => 'https://queue.test/api/v1',
'care.queue.api_key' => 'care-test-key',
]);
$this->owner = User::create([
'public_id' => 'care-queue-workflow-owner',
'name' => 'Owner',
@@ -73,105 +68,6 @@ class CareQueueWorkflowTest extends TestCase
]);
}
protected function fakeQueueApi(): void
{
$consultationQueue = 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa';
$pharmacyQueue = 'bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb';
$consultationCounter = 'cccccccc-cccc-cccc-cccc-cccccccccccc';
$pharmacyCounter = 'dddddddd-dddd-dddd-dddd-dddddddddddd';
$seq = ['C' => 0, 'P' => 0];
Http::fake(function (\Illuminate\Http\Client\Request $request) use (
$consultationQueue,
$pharmacyQueue,
$consultationCounter,
$pharmacyCounter,
&$seq,
) {
$url = $request->url();
$method = $request->method();
if (str_contains($url, '/integrations/status')) {
return Http::response([
'data' => [
'provisioned' => true,
'integrations' => ['care' => true],
],
], 200);
}
if ($method === 'POST' && str_contains($url, '/branches')) {
return Http::response(['data' => ['id' => 1, 'name' => 'Main']], 201);
}
if ($method === 'POST' && str_contains($url, '/queues') && ! str_contains($url, 'call-next')) {
$payload = $request->data();
$key = (string) ($payload['external_key'] ?? '');
$isPharmacy = str_contains($key, 'pharmacy');
return Http::response([
'data' => [
'uuid' => $isPharmacy ? $pharmacyQueue : $consultationQueue,
'name' => $payload['name'] ?? 'Queue',
'prefix' => $payload['prefix'] ?? 'X',
],
], 201);
}
if ($method === 'POST' && str_contains($url, '/counters')) {
$payload = $request->data();
$key = (string) ($payload['external_key'] ?? '');
$isPharmacy = str_contains($key, 'pharmacy');
return Http::response([
'data' => [
'uuid' => $isPharmacy ? $pharmacyCounter : $consultationCounter,
'name' => $payload['name'] ?? 'Counter',
],
], 201);
}
if ($method === 'POST' && preg_match('#/tickets$#', parse_url($url, PHP_URL_PATH) ?? '')) {
$payload = $request->data();
$queueId = (string) ($payload['service_queue_id'] ?? '');
$prefix = $queueId === $pharmacyQueue ? 'P' : 'C';
$seq[$prefix]++;
return Http::response([
'data' => [
'uuid' => (string) Str::uuid(),
'ticket_number' => $prefix.sprintf('%03d', $seq[$prefix]),
'status' => 'waiting',
'customer_name' => $payload['customer_name'] ?? null,
],
], 201);
}
if ($method === 'POST' && str_contains($url, '/call-next')) {
$path = parse_url($url, PHP_URL_PATH) ?? '';
$isPharmacy = str_contains($path, $pharmacyQueue);
return Http::response([
'data' => [
'uuid' => (string) Str::uuid(),
'ticket_number' => $isPharmacy ? 'P001' : 'C001',
'status' => 'called',
'customer_name' => 'Called Patient',
],
], 200);
}
if ($method === 'POST' && str_contains($url, '/tickets/') && str_contains($url, '/action')) {
$payload = $request->data();
return Http::response([
'data' => [
'uuid' => basename(dirname(parse_url($url, PHP_URL_PATH) ?? '/x/x')),
'ticket_number' => 'C001',
'status' => $payload['action'] === 'complete' ? 'completed' : ($payload['action'] === 'start' ? 'serving' : 'called'),
],
], 200);
}
return Http::response(['message' => 'unexpected '.$method.' '.$url], 500);
});
}
public function test_integration_off_does_not_issue_tickets_on_check_in(): void
{
$settings = $this->organization->settings;
@@ -211,7 +107,7 @@ class CareQueueWorkflowTest extends TestCase
public function test_integration_on_issues_consultation_ticket_on_check_in(): void
{
$this->fakeQueueApi();
Http::fake();
$practitioner = \App\Models\Practitioner::create([
'owner_ref' => $this->owner->public_id,
@@ -222,33 +118,6 @@ class CareQueueWorkflowTest extends TestCase
'is_active' => true,
]);
$this->organization->update([
'settings' => array_merge($this->organization->settings ?? [], [
'department_queue_provisioning' => [
CareQueueContexts::CONSULTATION => [
'queues' => [[
'branch_id' => $this->branch->id,
'branch_name' => 'Main',
'name' => 'Consultation',
'prefix' => 'C',
'active' => true,
'synced' => true,
'routing_mode' => 'assigned_only',
'queue_uuid' => 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa',
'counter_uuid' => 'cccccccc-cccc-cccc-cccc-cccccccccccc',
'points' => [[
'kind' => 'practitioner',
'ref_id' => $practitioner->id,
'destination' => 'Room 2',
'counter_uuid' => 'cccccccc-cccc-cccc-cccc-cccccccccccc',
'synced' => true,
]],
]],
],
],
]),
]);
$patient = Patient::create([
'uuid' => (string) Str::uuid(),
'owner_ref' => $this->owner->public_id,
@@ -277,11 +146,22 @@ class CareQueueWorkflowTest extends TestCase
$this->assertNotNull($appointment->queue_ticket_uuid);
$this->assertSame('C001', $appointment->queue_ticket_number);
$this->assertSame('waiting', $appointment->queue_ticket_status);
$this->assertDatabaseHas('care_queue_tickets', [
'uuid' => $appointment->queue_ticket_uuid,
'ticket_number' => 'C001',
'status' => CareQueueTicket::STATUS_WAITING,
]);
$this->assertDatabaseHas('care_service_queues', [
'organization_id' => $this->organization->id,
'branch_id' => $this->branch->id,
'context' => CareQueueContexts::CONSULTATION,
]);
Http::assertNothingSent();
}
public function test_pharmacy_call_next_uses_pharmacy_queue_not_reception(): void
{
$this->fakeQueueApi();
Http::fake();
$pharmacist = Member::create([
'owner_ref' => $this->owner->public_id,
@@ -291,9 +171,37 @@ class CareQueueWorkflowTest extends TestCase
'branch_id' => $this->branch->id,
]);
$this->organization->update([
'settings' => array_merge($this->organization->settings ?? [], [
'department_queue_provisioning' => [
Member::query()->where('user_ref', $this->owner->public_id)->update(['role' => 'pharmacist']);
$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' => 'assigned_only',
'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 1',
'destination' => 'Pharmacy counter 1',
'kind' => 'member',
'ref_id' => $pharmacist->id,
'staff_ref' => 'pharm-workflow',
'external_key' => CareQueueContexts::pointExternalKey(
CareQueueContexts::PHARMACY,
$this->branch->id,
'member',
$pharmacist->id,
),
]);
$settings = $this->organization->settings;
$settings['department_queue_provisioning'] = [
CareQueueContexts::PHARMACY => [
'queues' => [[
'branch_id' => $this->branch->id,
@@ -303,39 +211,58 @@ class CareQueueWorkflowTest extends TestCase
'active' => true,
'synced' => true,
'routing_mode' => 'assigned_only',
'queue_uuid' => 'bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb',
'counter_uuid' => 'dddddddd-dddd-dddd-dddd-dddddddddddd',
'queue_external_key' => CareQueueContexts::queueExternalKey(CareQueueContexts::PHARMACY, $this->branch->id),
'counter_external_key' => CareQueueContexts::counterExternalKey(CareQueueContexts::PHARMACY, $this->branch->id),
'queue_uuid' => $queue->uuid,
'counter_uuid' => $point->uuid,
'queue_external_key' => $queue->external_key,
'points' => [[
'kind' => 'member',
'ref_id' => $pharmacist->id,
'staff_ref' => 'pharm-workflow',
'destination' => 'Pharmacy counter 1',
'counter_uuid' => 'dddddddd-dddd-dddd-dddd-dddddddddddd',
'counter_uuid' => $point->uuid,
'external_key' => $point->external_key,
'synced' => true,
]],
]],
],
],
]),
]);
];
$this->organization->update(['settings' => $settings]);
Member::query()->where('user_ref', $this->owner->public_id)->update(['role' => 'pharmacist']);
CareQueueTicket::create([
'owner_ref' => $this->owner->public_id,
'organization_id' => $this->organization->id,
'service_queue_id' => $queue->id,
'service_point_id' => $point->id,
'ticket_number' => 'P001',
'status' => CareQueueTicket::STATUS_WAITING,
'priority' => 'walk_in',
'customer_name' => 'Pharmacy Patient',
]);
$this->actingAs($this->owner)
->post(route('care.prescriptions.call-next'), ['branch_id' => $this->branch->id])
->assertRedirect();
Http::assertSent(function (\Illuminate\Http\Client\Request $request) {
return $request->method() === 'POST'
&& str_contains($request->url(), '/queues/bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb/call-next');
});
$this->assertDatabaseHas('care_queue_tickets', [
'ticket_number' => 'P001',
'status' => CareQueueTicket::STATUS_CALLED,
'service_queue_id' => $queue->id,
]);
Http::assertNothingSent();
}
public function test_complete_advances_ticket_status_on_appointment(): void
{
$this->fakeQueueApi();
Http::fake();
$practitioner = \App\Models\Practitioner::create([
'owner_ref' => $this->owner->public_id,
'organization_id' => $this->organization->id,
'branch_id' => $this->branch->id,
'name' => 'Dr. Complete',
'room' => 'Room 1',
'is_active' => true,
]);
$patient = Patient::create([
'uuid' => (string) Str::uuid(),
@@ -353,42 +280,41 @@ class CareQueueWorkflowTest extends TestCase
'organization_id' => $this->organization->id,
'branch_id' => $this->branch->id,
'patient_id' => $patient->id,
'practitioner_id' => $practitioner->id,
'type' => Appointment::TYPE_WALK_IN,
'status' => Appointment::STATUS_WAITING,
'queue_ticket_uuid' => 'eeeeeeee-eeee-eeee-eeee-eeeeeeeeeeee',
'queue_ticket_number' => 'C009',
'queue_ticket_status' => 'serving',
'status' => Appointment::STATUS_SCHEDULED,
'scheduled_at' => now(),
]);
app(CareQueueBridge::class)->complete($this->organization, $appointment);
$appointment->refresh();
$appointment = app(AppointmentService::class)->checkIn(
$appointment->fresh(['organization', 'patient', 'practitioner']),
$this->owner->public_id,
$this->owner->public_id,
);
$bridge = app(CareQueueBridge::class);
$bridge->callNext(
$this->organization->fresh(),
CareQueueContexts::CONSULTATION,
(int) $this->branch->id,
null,
$practitioner->id,
);
$bridge->serve($this->organization->fresh(), $appointment->fresh());
$bridge->complete($this->organization->fresh(), $appointment->fresh());
$appointment->refresh();
$this->assertSame('completed', $appointment->queue_ticket_status);
$this->assertDatabaseHas('care_queue_tickets', [
'uuid' => $appointment->queue_ticket_uuid,
'status' => CareQueueTicket::STATUS_COMPLETED,
]);
Http::assertNothingSent();
}
public function test_patient_queue_shows_call_next_not_service_counter_panel(): void
{
$this->fakeQueueApi();
$this->organization->update([
'settings' => array_merge($this->organization->settings ?? [], [
'department_queue_provisioning' => [
CareQueueContexts::CONSULTATION => [
'queues' => [[
'branch_id' => $this->branch->id,
'branch_name' => 'Main',
'name' => 'Consultation',
'prefix' => 'C',
'active' => true,
'synced' => true,
'queue_uuid' => 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa',
'counter_uuid' => 'cccccccc-cccc-cccc-cccc-cccccccccccc',
]],
],
],
]),
]);
Http::fake();
$this->actingAs($this->owner)
->get(route('care.queue.index'))
@@ -396,6 +322,7 @@ class CareQueueWorkflowTest extends TestCase
->assertDontSee('Service counter')
->assertSee('Call next')
->assertSee('assigned service point', false);
Http::assertNothingSent();
}
public function test_integration_off_hides_queue_ops_on_pharmacy_page(): void
+78 -186
View File
@@ -5,6 +5,9 @@ namespace Tests\Feature;
use App\Http\Middleware\EnsurePlatformSession;
use App\Models\Appointment;
use App\Models\Branch;
use App\Models\CareQueueTicket;
use App\Models\CareServicePoint;
use App\Models\CareServiceQueue;
use App\Models\Member;
use App\Models\Organization;
use App\Models\Patient;
@@ -38,11 +41,6 @@ class CareServicePointRoutingTest extends TestCase
parent::setUp();
$this->withoutMiddleware(EnsurePlatformSession::class);
config([
'care.queue.api_url' => 'https://queue.test/api/v1',
'care.queue.api_key' => 'care-test-key',
]);
$this->owner = User::create([
'public_id' => 'sp-care-owner',
'name' => 'Owner',
@@ -97,101 +95,11 @@ class CareServicePointRoutingTest extends TestCase
'user_ref' => 'doc-okai',
'is_active' => true,
]);
$settings = $this->organization->settings;
$settings['department_queue_provisioning'] = [
CareQueueContexts::CONSULTATION => [
'queues' => [[
'branch_id' => $this->branch->id,
'branch_name' => 'Main',
'name' => 'Consultation',
'prefix' => 'C',
'active' => true,
'synced' => true,
'routing_mode' => 'assigned_only',
'queue_uuid' => 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa',
'queue_external_key' => CareQueueContexts::queueExternalKey(CareQueueContexts::CONSULTATION, $this->branch->id),
'points' => [
[
'kind' => 'practitioner',
'ref_id' => $this->doctorA->id,
'name' => 'Dr. Mensah',
'destination' => 'Consultation Room 4',
'staff_display_name' => 'Dr. Mensah',
'external_key' => CareQueueContexts::pointExternalKey(
CareQueueContexts::CONSULTATION,
$this->branch->id,
'practitioner',
$this->doctorA->id,
),
'counter_uuid' => '11111111-1111-1111-1111-111111111111',
'synced' => true,
],
[
'kind' => 'practitioner',
'ref_id' => $this->doctorB->id,
'name' => 'Dr. Okai',
'destination' => 'Consultation Room 5',
'staff_display_name' => 'Dr. Okai',
'external_key' => CareQueueContexts::pointExternalKey(
CareQueueContexts::CONSULTATION,
$this->branch->id,
'practitioner',
$this->doctorB->id,
),
'counter_uuid' => '22222222-2222-2222-2222-222222222222',
'synced' => true,
],
],
]],
],
CareQueueContexts::PHARMACY => [
'queues' => [[
'branch_id' => $this->branch->id,
'name' => 'Pharmacy',
'prefix' => 'P',
'active' => true,
'synced' => true,
'routing_mode' => 'assigned_only',
'queue_uuid' => 'bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb',
'points' => [[
'kind' => 'member',
'ref_id' => 1,
'destination' => 'Pharmacy counter 1',
'counter_uuid' => '33333333-3333-3333-3333-333333333333',
'synced' => true,
]],
]],
],
];
$this->organization->update(['settings' => $settings]);
}
public function test_fixed_doctor_appointment_issues_to_that_doctors_point(): void
{
Http::fake(function (\Illuminate\Http\Client\Request $request) {
if ($request->method() === 'POST' && str_contains($request->url(), '/tickets')) {
$this->assertSame('11111111-1111-1111-1111-111111111111', $request['assigned_counter_id'] ?? null);
$this->assertSame('aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa', $request['service_queue_id'] ?? null);
return Http::response([
'data' => [
'uuid' => (string) Str::uuid(),
'ticket_number' => 'C001',
'status' => 'waiting',
'destination' => 'Consultation Room 4',
'staff_display_name' => 'Dr. Mensah',
'assigned_counter' => [
'uuid' => '11111111-1111-1111-1111-111111111111',
'destination' => 'Consultation Room 4',
'staff_display_name' => 'Dr. Mensah',
],
],
], 201);
}
return Http::response(['message' => 'unexpected'], 500);
});
Http::fake();
$patient = Patient::create([
'uuid' => (string) Str::uuid(),
@@ -217,14 +125,22 @@ class CareServicePointRoutingTest extends TestCase
$result = app(AppointmentService::class)->checkIn($appointment, $this->owner->public_id);
$point = CareServicePoint::query()
->where('organization_id', $this->organization->id)
->where('kind', 'practitioner')
->where('ref_id', $this->doctorA->id)
->first();
$this->assertNotNull($point);
$this->assertSame('C001', $result->queue_ticket_number);
$this->assertSame('11111111-1111-1111-1111-111111111111', $result->queue_service_point_uuid);
$this->assertSame($point->uuid, $result->queue_service_point_uuid);
$this->assertSame('Consultation Room 4', $result->queue_destination);
$this->assertSame('Dr. Mensah', $result->queue_staff_display_name);
$this->assertSame(CareQueueContexts::ROUTING_ROUTED, $result->queue_routing_status);
Http::assertNothingSent();
}
public function test_walk_in_without_doctor_flags_unresolved_and_does_not_issue(): void
public function test_walk_in_without_doctor_routes_to_default_desk(): void
{
Http::fake();
@@ -252,31 +168,19 @@ class CareServicePointRoutingTest extends TestCase
$result = app(CareQueueBridge::class)->issueForAppointment($this->organization, $appointment);
$this->assertNull($result->queue_ticket_uuid);
$this->assertSame(CareQueueContexts::ROUTING_UNRESOLVED, $result->queue_routing_status);
// Native engine prefers an available desk over leaving waiters without a ticket.
$this->assertNotNull($result->queue_ticket_uuid);
$this->assertSame(CareQueueContexts::ROUTING_ROUTED, $result->queue_routing_status);
$this->assertDatabaseHas('care_queue_tickets', [
'uuid' => $result->queue_ticket_uuid,
'status' => CareQueueTicket::STATUS_WAITING,
]);
Http::assertNothingSent();
}
public function test_walk_in_with_selected_doctor_routes_only_to_that_point(): void
{
Http::fake(function (\Illuminate\Http\Client\Request $request) {
if ($request->method() === 'POST' && str_contains($request->url(), '/tickets')) {
$this->assertSame('22222222-2222-2222-2222-222222222222', $request['assigned_counter_id'] ?? null);
return Http::response([
'data' => [
'uuid' => (string) Str::uuid(),
'ticket_number' => 'C002',
'status' => 'waiting',
'assigned_counter' => ['uuid' => '22222222-2222-2222-2222-222222222222'],
'destination' => 'Consultation Room 5',
'staff_display_name' => 'Dr. Okai',
],
], 201);
}
return Http::response(['message' => 'unexpected'], 500);
});
Http::fake();
$patient = Patient::create([
'uuid' => (string) Str::uuid(),
@@ -300,13 +204,24 @@ class CareServicePointRoutingTest extends TestCase
$this->owner->public_id,
);
$point = CareServicePoint::query()
->where('organization_id', $this->organization->id)
->where('kind', 'practitioner')
->where('ref_id', $this->doctorB->id)
->first();
$this->assertSame($this->doctorB->id, $appointment->practitioner_id);
$this->assertSame('C002', $appointment->queue_ticket_number);
$this->assertSame('C001', $appointment->queue_ticket_number);
$this->assertSame('Consultation Room 5', $appointment->queue_destination);
$this->assertNotNull($point);
$this->assertSame($point->uuid, $appointment->queue_service_point_uuid);
Http::assertNothingSent();
}
public function test_pharmacist_call_next_uses_pharmacy_point(): void
{
Http::fake();
$pharmacist = Member::create([
'owner_ref' => $this->owner->public_id,
'organization_id' => $this->organization->id,
@@ -315,33 +230,34 @@ class CareServicePointRoutingTest extends TestCase
'branch_id' => $this->branch->id,
]);
$settings = $this->organization->settings;
$settings['department_queue_provisioning'][CareQueueContexts::PHARMACY]['queues'][0]['points'] = [[
'kind' => 'member',
'ref_id' => $pharmacist->id,
'destination' => 'Pharmacy counter 1',
'staff_ref' => 'pharm-1',
'counter_uuid' => '33333333-3333-3333-3333-333333333333',
'synced' => true,
]];
$this->organization->update(['settings' => $settings]);
app(CareQueueProvisioner::class)->ensure(
$this->organization->fresh(),
CareQueueContexts::PHARMACY,
$this->branch->id,
);
Http::fake(function (\Illuminate\Http\Client\Request $request) {
if (str_contains($request->url(), '/call-next')) {
$this->assertStringContainsString('bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb', $request->url());
$this->assertSame('33333333-3333-3333-3333-333333333333', $request['counter_id'] ?? null);
$queue = CareServiceQueue::query()
->where('organization_id', $this->organization->id)
->where('context', CareQueueContexts::PHARMACY)
->where('branch_id', $this->branch->id)
->firstOrFail();
return Http::response([
'data' => [
'uuid' => (string) Str::uuid(),
$point = CareServicePoint::query()
->where('service_queue_id', $queue->id)
->where('kind', 'member')
->where('ref_id', $pharmacist->id)
->firstOrFail();
CareQueueTicket::create([
'owner_ref' => $this->owner->public_id,
'organization_id' => $this->organization->id,
'service_queue_id' => $queue->id,
'service_point_id' => $point->id,
'ticket_number' => 'P001',
'status' => 'called',
],
], 200);
}
return Http::response(['message' => 'unexpected'], 500);
});
'status' => CareQueueTicket::STATUS_WAITING,
'priority' => 'walk_in',
'customer_name' => 'Pharmacy Patient',
]);
$result = app(CareQueueBridge::class)->callNext(
$this->organization->fresh(),
@@ -351,6 +267,9 @@ class CareServicePointRoutingTest extends TestCase
);
$this->assertSame('P001', $result['ticket']['ticket_number'] ?? null);
$this->assertSame('called', $result['ticket']['status'] ?? null);
$this->assertSame($point->uuid, $result['ticket']['assigned_counter']['uuid'] ?? null);
Http::assertNothingSent();
}
public function test_integration_off_preserves_behavior_without_ticket_issuance(): void
@@ -391,57 +310,30 @@ class CareServicePointRoutingTest extends TestCase
public function test_provisioner_builds_one_point_per_practitioner_idempotently(): void
{
$calls = ['queues' => 0, 'counters' => 0];
Http::fake(function (\Illuminate\Http\Client\Request $request) use (&$calls) {
if (str_contains($request->url(), '/integrations/status')) {
return Http::response(['data' => ['provisioned' => true, 'integrations' => ['care' => true]]], 200);
}
if (str_contains($request->url(), '/branches') && $request->method() === 'POST') {
return Http::response(['data' => ['name' => 'Main']], 201);
}
if (str_contains($request->url(), '/departments') && $request->method() === 'POST') {
return Http::response(['data' => ['id' => 9, 'name' => $request['name'] ?? 'Dept']], 201);
}
if (str_contains($request->url(), '/queues') && $request->method() === 'POST' && ! str_contains($request->url(), 'call-next')) {
$calls['queues']++;
$this->assertSame('assigned_only', $request['routing_mode'] ?? null);
return Http::response([
'data' => [
'uuid' => (string) Str::uuid(),
'name' => $request['name'] ?? 'Q',
'external_key' => $request['external_key'] ?? null,
],
], 201);
}
if (str_contains($request->url(), '/counters') && $request->method() === 'POST') {
$calls['counters']++;
return Http::response([
'data' => [
'uuid' => (string) Str::uuid(),
'name' => $request['name'] ?? 'C',
'destination' => $request['destination'] ?? null,
],
], 201);
}
return Http::response(['message' => 'unexpected '.$request->url()], 500);
});
// Clear prior stub so provisioner builds fresh.
$settings = $this->organization->settings;
unset($settings['department_queue_provisioning']);
$this->organization->update(['settings' => $settings]);
Http::fake();
app(CareQueueProvisioner::class)->provisionOrganization($this->organization->fresh());
$first = data_get($this->organization->fresh()->settings, 'department_queue_provisioning.consultation.queues.0.points');
$this->assertIsArray($first);
$this->assertGreaterThanOrEqual(2, count($first));
$this->assertDatabaseHas('care_service_queues', [
'organization_id' => $this->organization->id,
'branch_id' => $this->branch->id,
'context' => CareQueueContexts::CONSULTATION,
]);
$this->assertEquals(
2,
CareServicePoint::query()
->where('organization_id', $this->organization->id)
->where('kind', 'practitioner')
->whereIn('ref_id', [$this->doctorA->id, $this->doctorB->id])
->count(),
);
app(CareQueueProvisioner::class)->provisionOrganization($this->organization->fresh());
$second = data_get($this->organization->fresh()->settings, 'department_queue_provisioning.consultation.queues.0.points');
$this->assertCount(count($first), $second);
Http::assertNothingSent();
}
}
@@ -30,7 +30,6 @@ class CareSpecialtyClinicalTest extends TestCase
{
parent::setUp();
$this->withoutMiddleware(EnsurePlatformSession::class);
config(['care.queue.driver' => 'native']);
$this->owner = User::create([
'public_id' => 'clinical-owner',
+42 -88
View File
@@ -4,6 +4,7 @@ namespace Tests\Feature;
use App\Http\Middleware\EnsurePlatformSession;
use App\Models\Branch;
use App\Models\CareServiceQueue;
use App\Models\Department;
use App\Models\Member;
use App\Models\Organization;
@@ -85,12 +86,9 @@ class CareSpecialtyModulesTest extends TestCase
$this->assertSame($this->branch->id, $stubs[0]['branch_id']);
}
public function test_activating_with_queue_integration_creates_real_counters(): void
public function test_activating_with_queue_integration_creates_native_service_queues(): void
{
config([
'care.queue.api_url' => 'https://queue.test/api/v1',
'care.queue.api_key' => 'care-test-key',
]);
Http::fake();
$this->organization->update([
'settings' => array_merge($this->organization->settings ?? [], [
@@ -98,37 +96,6 @@ class CareSpecialtyModulesTest extends TestCase
]),
]);
$queueUuid = 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa';
$counterUuid = 'bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb';
Http::fake([
'https://queue.test/api/v1/integrations/status*' => Http::response([
'data' => [
'provisioned' => true,
'integrations' => ['care' => true],
],
], 200),
'https://queue.test/api/v1/branches*' => Http::sequence()
->push(['data' => ['id' => 1, 'name' => 'Main', 'is_active' => true]], 200)
->push(['data' => ['id' => 1, 'name' => 'Main', 'is_active' => true]], 200),
'https://queue.test/api/v1/queues*' => Http::response([
'data' => [
'uuid' => $queueUuid,
'name' => 'Dentistry',
'prefix' => 'DEN',
'external_key' => 'care:specialty:dentistry:queue:'.$this->branch->id,
],
], 201),
'https://queue.test/api/v1/counters*' => Http::response([
'data' => [
'uuid' => $counterUuid,
'name' => 'Dentistry counter',
'branch' => 'Main',
'queues' => [['uuid' => $queueUuid, 'name' => 'Dentistry']],
],
], 201),
]);
app(SpecialtyModuleService::class)->activate(
$this->organization->fresh(),
$this->owner->public_id,
@@ -138,69 +105,47 @@ class CareSpecialtyModulesTest extends TestCase
$this->organization->refresh();
$stub = data_get($this->organization->settings, 'specialty_module_provisioning.dentistry.queues.0');
$this->assertTrue((bool) ($stub['synced'] ?? false));
$this->assertSame($queueUuid, $stub['queue_uuid'] ?? null);
$this->assertSame($counterUuid, $stub['counter_uuid'] ?? null);
$this->assertNotEmpty($stub['queue_uuid'] ?? null);
$this->assertNotEmpty($stub['counter_uuid'] ?? null);
Http::assertSent(function ($request) {
return $request->method() === 'POST'
&& str_contains($request->url(), '/queues')
&& ($request['external_key'] ?? null) === 'care:specialty:dentistry:queue:'.$this->branch->id;
});
$this->assertDatabaseHas('care_service_queues', [
'organization_id' => $this->organization->id,
'branch_id' => $this->branch->id,
'context' => 'dentistry',
'uuid' => $stub['queue_uuid'],
]);
Http::assertNothingSent();
}
public function test_reactivation_is_idempotent_for_queue_external_keys(): void
{
config([
'care.queue.api_url' => 'https://queue.test/api/v1',
'care.queue.api_key' => 'care-test-key',
]);
Http::fake();
$this->organization->update([
'settings' => array_merge($this->organization->settings ?? [], [
'queue_integration_enabled' => true,
'specialty_module_provisioning' => [
'dentistry' => [
'active' => false,
'queues' => [[
'module' => 'dentistry',
'branch_id' => $this->branch->id,
'branch_name' => 'Main',
'name' => 'Dentistry',
'prefix' => 'DEN',
'active' => false,
'synced' => true,
'queue_uuid' => 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa',
'counter_uuid' => 'bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb',
'queue_external_key' => 'care:specialty:dentistry:queue:'.$this->branch->id,
'counter_external_key' => 'care:specialty:dentistry:counter:'.$this->branch->id,
]],
],
],
]),
]);
Http::fake([
'https://queue.test/api/v1/integrations/status*' => Http::response([
'data' => ['provisioned' => true, 'integrations' => ['care' => true]],
], 200),
'https://queue.test/api/v1/branches*' => Http::response([
'data' => ['id' => 1, 'name' => 'Main', 'is_active' => true],
], 200),
'https://queue.test/api/v1/queues*' => Http::response([
'data' => [
'uuid' => 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa',
'name' => 'Dentistry',
'external_key' => 'care:specialty:dentistry:queue:'.$this->branch->id,
],
], 200),
'https://queue.test/api/v1/counters*' => Http::response([
'data' => [
'uuid' => 'bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb',
'name' => 'Dentistry counter',
],
], 200),
]);
app(SpecialtyModuleService::class)->activate(
$this->organization->fresh(),
$this->owner->public_id,
'dentistry',
);
$firstQueue = CareServiceQueue::query()
->where('organization_id', $this->organization->id)
->where('context', 'dentistry')
->where('branch_id', $this->branch->id)
->firstOrFail();
$firstUuid = $firstQueue->uuid;
$externalKey = $firstQueue->external_key;
app(SpecialtyModuleService::class)->deactivate(
$this->organization->fresh(),
$this->owner->public_id,
'dentistry',
);
app(SpecialtyModuleService::class)->activate(
$this->organization->fresh(),
$this->owner->public_id,
@@ -209,8 +154,17 @@ class CareSpecialtyModulesTest extends TestCase
$this->organization->refresh();
$stub = data_get($this->organization->settings, 'specialty_module_provisioning.dentistry.queues.0');
$this->assertSame('care:specialty:dentistry:queue:'.$this->branch->id, $stub['queue_external_key']);
$this->assertSame('aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa', $stub['queue_uuid']);
$this->assertSame($externalKey, $stub['queue_external_key'] ?? $firstQueue->external_key);
$this->assertSame($firstUuid, $stub['queue_uuid'] ?? null);
$this->assertEquals(
1,
CareServiceQueue::query()
->where('organization_id', $this->organization->id)
->where('context', 'dentistry')
->where('branch_id', $this->branch->id)
->count(),
);
Http::assertNothingSent();
}
public function test_deactivating_hides_department_without_deleting(): void
-2
View File
@@ -32,8 +32,6 @@ class CareSpecialtyShellTest extends TestCase
parent::setUp();
$this->withoutMiddleware(EnsurePlatformSession::class);
config(['care.queue.driver' => 'native']);
$this->owner = User::create([
'public_id' => 'shell-owner',
'name' => 'Owner',