Add service-point routing so call-next cannot steal assigned tickets.
Deploy Ladill Queue / deploy (push) Successful in 2m17s

Counters gain destination/staff metadata; tickets can be pre-assigned to a
service point with assigned_only queues for healthcare while shared_pool
preserves generic bank/government behavior. Display and announcements expose
ticket, staff, and destination clearly for Care and public boards.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
isaacclad
2026-07-17 17:21:56 +00:00
co-authored by Cursor
parent 2d1f8c1eff
commit 0854b431bc
17 changed files with 753 additions and 42 deletions
@@ -40,10 +40,10 @@ class ConsoleApiController extends Controller
->latest('called_at') ->latest('called_at')
->first(); ->first();
$waitingByQueue = collect($counter->serviceQueues)->mapWithKeys(function (ServiceQueue $queue) { $waitingByQueue = collect($counter->serviceQueues)->mapWithKeys(function (ServiceQueue $queue) use ($counter) {
return [$queue->uuid => array_map( return [$queue->uuid => array_map(
fn ($t) => TicketPresenter::toArray($t), fn ($t) => TicketPresenter::toArray($t),
$this->engine->waitingTickets($queue, 10), $this->engine->waitingTickets($queue, 10, $counter),
)]; )];
}); });
@@ -61,6 +61,8 @@ class ConsoleApiController extends Controller
'counter' => [ 'counter' => [
'uuid' => $counter->uuid, 'uuid' => $counter->uuid,
'name' => $counter->name, 'name' => $counter->name,
'destination' => $counter->displayDestination(),
'staff_display_name' => $counter->staff_display_name,
'status' => $counter->status, 'status' => $counter->status,
'branch' => $counter->branch?->name, 'branch' => $counter->branch?->name,
], ],
@@ -69,6 +71,7 @@ class ConsoleApiController extends Controller
'uuid' => $q->uuid, 'uuid' => $q->uuid,
'name' => $q->name, 'name' => $q->name,
'prefix' => $q->prefix, 'prefix' => $q->prefix,
'routing_mode' => $q->routingMode(),
]), ]),
'waiting_by_queue' => $waitingByQueue, 'waiting_by_queue' => $waitingByQueue,
'transfer_queues' => $allQueues, 'transfer_queues' => $allQueues,
@@ -40,6 +40,9 @@ class CounterController extends Controller
$validated = $request->validate([ $validated = $request->validate([
'name' => ['required', 'string', 'max:255'], 'name' => ['required', 'string', 'max:255'],
'code' => ['nullable', 'string', 'max:50'], 'code' => ['nullable', 'string', 'max:50'],
'destination' => ['nullable', 'string', 'max:255'],
'staff_ref' => ['nullable', 'string', 'max:191'],
'staff_display_name' => ['nullable', 'string', 'max:255'],
'branch_id' => ['nullable', 'integer'], 'branch_id' => ['nullable', 'integer'],
'branch_name' => ['nullable', 'string', 'max:255'], 'branch_name' => ['nullable', 'string', 'max:255'],
'department_id' => ['nullable', 'integer'], 'department_id' => ['nullable', 'integer'],
@@ -65,6 +68,15 @@ class CounterController extends Controller
$existing->update([ $existing->update([
'name' => $validated['name'], 'name' => $validated['name'],
'code' => $validated['code'] ?? $existing->code, 'code' => $validated['code'] ?? $existing->code,
'destination' => array_key_exists('destination', $validated)
? $validated['destination']
: $existing->destination,
'staff_ref' => array_key_exists('staff_ref', $validated)
? $validated['staff_ref']
: $existing->staff_ref,
'staff_display_name' => array_key_exists('staff_display_name', $validated)
? $validated['staff_display_name']
: $existing->staff_display_name,
'branch_id' => $branch->id, 'branch_id' => $branch->id,
'department_id' => $validated['department_id'] ?? $existing->department_id, 'department_id' => $validated['department_id'] ?? $existing->department_id,
'is_active' => array_key_exists('is_active', $validated) 'is_active' => array_key_exists('is_active', $validated)
@@ -86,6 +98,9 @@ class CounterController extends Controller
'department_id' => $validated['department_id'] ?? null, 'department_id' => $validated['department_id'] ?? null,
'name' => $validated['name'], 'name' => $validated['name'],
'code' => $validated['code'] ?? null, 'code' => $validated['code'] ?? null,
'destination' => $validated['destination'] ?? null,
'staff_ref' => $validated['staff_ref'] ?? null,
'staff_display_name' => $validated['staff_display_name'] ?? null,
'status' => 'offline', 'status' => 'offline',
'is_active' => array_key_exists('is_active', $validated) ? (bool) $validated['is_active'] : true, 'is_active' => array_key_exists('is_active', $validated) ? (bool) $validated['is_active'] : true,
'settings' => $externalKey !== '' ? ['external_key' => $externalKey] : null, 'settings' => $externalKey !== '' ? ['external_key' => $externalKey] : null,
@@ -106,6 +121,9 @@ class CounterController extends Controller
$validated = $request->validate([ $validated = $request->validate([
'name' => ['sometimes', 'string', 'max:255'], 'name' => ['sometimes', 'string', 'max:255'],
'code' => ['nullable', 'string', 'max:50'], 'code' => ['nullable', 'string', 'max:50'],
'destination' => ['nullable', 'string', 'max:255'],
'staff_ref' => ['nullable', 'string', 'max:191'],
'staff_display_name' => ['nullable', 'string', 'max:255'],
'is_active' => ['sometimes', 'boolean'], 'is_active' => ['sometimes', 'boolean'],
'queue_ids' => ['nullable', 'array'], 'queue_ids' => ['nullable', 'array'],
'queue_ids.*' => ['uuid'], 'queue_ids.*' => ['uuid'],
@@ -116,6 +134,11 @@ class CounterController extends Controller
$counter->update(array_filter([ $counter->update(array_filter([
'name' => $validated['name'] ?? null, 'name' => $validated['name'] ?? null,
'code' => array_key_exists('code', $validated) ? $validated['code'] : null, 'code' => array_key_exists('code', $validated) ? $validated['code'] : null,
'destination' => array_key_exists('destination', $validated) ? $validated['destination'] : null,
'staff_ref' => array_key_exists('staff_ref', $validated) ? $validated['staff_ref'] : null,
'staff_display_name' => array_key_exists('staff_display_name', $validated)
? $validated['staff_display_name']
: null,
'is_active' => array_key_exists('is_active', $validated) ? (bool) $validated['is_active'] : null, 'is_active' => array_key_exists('is_active', $validated) ? (bool) $validated['is_active'] : null,
], fn ($v) => $v !== null)); ], fn ($v) => $v !== null));
@@ -181,9 +204,13 @@ class CounterController extends Controller
'uuid' => $c->uuid, 'uuid' => $c->uuid,
'name' => $c->name, 'name' => $c->name,
'code' => $c->code, 'code' => $c->code,
'destination' => $c->displayDestination(),
'staff_ref' => $c->staff_ref,
'staff_display_name' => $c->staff_display_name,
'status' => $c->status, 'status' => $c->status,
'is_active' => $c->is_active, 'is_active' => $c->is_active,
'branch' => $c->branch?->name, 'branch' => $c->branch?->name,
'department_id' => $c->department_id,
'external_key' => data_get($c->settings, 'external_key'), 'external_key' => data_get($c->settings, 'external_key'),
'queues' => $c->serviceQueues->map(fn (ServiceQueue $q) => [ 'queues' => $c->serviceQueues->map(fn (ServiceQueue $q) => [
'uuid' => $q->uuid, 'uuid' => $q->uuid,
@@ -0,0 +1,140 @@
<?php
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Api\Concerns\ScopesApiToAccount;
use App\Http\Controllers\Controller;
use App\Models\Branch;
use App\Models\Department;
use App\Services\Qms\AuditLogger;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
class DepartmentController extends Controller
{
use ScopesApiToAccount;
public function index(Request $request): JsonResponse
{
$this->authorizeAbility($request, 'queues.view');
$owner = $this->ownerRef($request);
$organization = $this->organization($request);
$departments = Department::owned($owner)
->whereHas('branch', fn ($q) => $q->where('organization_id', $organization->id))
->with('branch')
->orderBy('name')
->get()
->map(fn (Department $d) => $this->serialize($d));
return response()->json(['data' => $departments]);
}
public function store(Request $request): JsonResponse
{
$this->authorizeAbility($request, 'queues.manage');
$owner = $this->ownerRef($request);
$organization = $this->organization($request);
$validated = $request->validate([
'name' => ['required', 'string', 'max:255'],
'type' => ['nullable', 'string', 'max:50'],
'branch_id' => ['nullable', 'integer'],
'branch_name' => ['nullable', 'string', 'max:255'],
'external_key' => ['nullable', 'string', 'max:191'],
'is_active' => ['nullable', 'boolean'],
]);
$branch = $this->resolveBranch($organization->id, $owner, $validated);
abort_unless($branch, 422, 'A valid branch_id or branch_name is required.');
$externalKey = isset($validated['external_key']) ? trim((string) $validated['external_key']) : '';
if ($externalKey !== '') {
$existing = Department::owned($owner)
->where('external_key', $externalKey)
->first();
if ($existing) {
$existing->update([
'name' => $validated['name'],
'type' => $validated['type'] ?? $existing->type,
'branch_id' => $branch->id,
'is_active' => array_key_exists('is_active', $validated)
? (bool) $validated['is_active']
: true,
]);
AuditLogger::record($owner, 'department.updated', $organization->id, $owner, Department::class, $existing->id);
return response()->json(['data' => $this->serialize($existing->fresh('branch'))]);
}
}
$byName = Department::owned($owner)
->where('branch_id', $branch->id)
->whereRaw('LOWER(name) = ?', [strtolower($validated['name'])])
->first();
if ($byName) {
$byName->update([
'type' => $validated['type'] ?? $byName->type,
'external_key' => $externalKey !== '' ? $externalKey : $byName->external_key,
'is_active' => array_key_exists('is_active', $validated) ? (bool) $validated['is_active'] : true,
]);
AuditLogger::record($owner, 'department.updated', $organization->id, $owner, Department::class, $byName->id);
return response()->json(['data' => $this->serialize($byName->fresh('branch'))]);
}
$department = Department::create([
'owner_ref' => $owner,
'branch_id' => $branch->id,
'name' => $validated['name'],
'type' => $validated['type'] ?? 'general',
'external_key' => $externalKey !== '' ? $externalKey : null,
'is_active' => array_key_exists('is_active', $validated) ? (bool) $validated['is_active'] : true,
]);
AuditLogger::record($owner, 'department.created', $organization->id, $owner, Department::class, $department->id);
return response()->json(['data' => $this->serialize($department->fresh('branch'))], 201);
}
/**
* @param array<string, mixed> $validated
*/
protected function resolveBranch(int $organizationId, string $owner, array $validated): ?Branch
{
if (! empty($validated['branch_id'])) {
return Branch::owned($owner)
->where('organization_id', $organizationId)
->where('id', (int) $validated['branch_id'])
->first();
}
$branchName = trim((string) ($validated['branch_name'] ?? ''));
if ($branchName === '') {
return null;
}
return Branch::owned($owner)
->where('organization_id', $organizationId)
->whereRaw('LOWER(name) = ?', [strtolower($branchName)])
->first();
}
/**
* @return array<string, mixed>
*/
protected function serialize(Department $d): array
{
return [
'id' => $d->id,
'name' => $d->name,
'type' => $d->type,
'external_key' => $d->external_key,
'is_active' => $d->is_active,
'branch' => $d->branch?->name,
'branch_id' => $d->branch_id,
];
}
}
@@ -50,12 +50,14 @@ class ServiceQueueController extends Controller
'max_capacity' => ['nullable', 'integer', 'min:1'], 'max_capacity' => ['nullable', 'integer', 'min:1'],
'is_active' => ['nullable', 'boolean'], 'is_active' => ['nullable', 'boolean'],
'external_key' => ['nullable', 'string', 'max:191'], 'external_key' => ['nullable', 'string', 'max:191'],
'routing_mode' => ['nullable', 'string', 'in:shared_pool,assigned_only'],
]); ]);
$branch = $this->resolveBranch($organization->id, $owner, $validated); $branch = $this->resolveBranch($organization->id, $owner, $validated);
abort_unless($branch, 422, 'A valid branch_id or branch_name is required.'); abort_unless($branch, 422, 'A valid branch_id or branch_name is required.');
$externalKey = isset($validated['external_key']) ? trim((string) $validated['external_key']) : ''; $externalKey = isset($validated['external_key']) ? trim((string) $validated['external_key']) : '';
$routingMode = $validated['routing_mode'] ?? null;
if ($externalKey !== '') { if ($externalKey !== '') {
$existing = ServiceQueue::owned($owner) $existing = ServiceQueue::owned($owner)
->where('organization_id', $organization->id) ->where('organization_id', $organization->id)
@@ -63,6 +65,10 @@ class ServiceQueueController extends Controller
->first(); ->first();
if ($existing) { if ($existing) {
$settings = array_merge($existing->settings ?? [], ['external_key' => $externalKey]);
if ($routingMode) {
$settings['routing_mode'] = $routingMode;
}
$existing->update([ $existing->update([
'name' => $validated['name'], 'name' => $validated['name'],
'description' => $validated['description'] ?? $existing->description, 'description' => $validated['description'] ?? $existing->description,
@@ -76,7 +82,7 @@ class ServiceQueueController extends Controller
'is_active' => array_key_exists('is_active', $validated) 'is_active' => array_key_exists('is_active', $validated)
? (bool) $validated['is_active'] ? (bool) $validated['is_active']
: true, : true,
'settings' => array_merge($existing->settings ?? [], ['external_key' => $externalKey]), 'settings' => $settings,
]); ]);
AuditLogger::record($owner, 'service_queue.updated', $organization->id, $owner, ServiceQueue::class, $existing->id); AuditLogger::record($owner, 'service_queue.updated', $organization->id, $owner, ServiceQueue::class, $existing->id);
@@ -96,6 +102,9 @@ class ServiceQueueController extends Controller
if ($externalKey !== '') { if ($externalKey !== '') {
$settings['external_key'] = $externalKey; $settings['external_key'] = $externalKey;
} }
if ($routingMode) {
$settings['routing_mode'] = $routingMode;
}
$byName->update([ $byName->update([
'prefix' => $validated['prefix'], 'prefix' => $validated['prefix'],
'strategy' => $validated['strategy'] ?? $byName->strategy, 'strategy' => $validated['strategy'] ?? $byName->strategy,
@@ -114,6 +123,14 @@ class ServiceQueueController extends Controller
'Queue limit reached for current plan.', 'Queue limit reached for current plan.',
); );
$settings = [];
if ($externalKey !== '') {
$settings['external_key'] = $externalKey;
}
if ($routingMode) {
$settings['routing_mode'] = $routingMode;
}
$queue = ServiceQueue::create([ $queue = ServiceQueue::create([
'owner_ref' => $owner, 'owner_ref' => $owner,
'organization_id' => $organization->id, 'organization_id' => $organization->id,
@@ -127,7 +144,7 @@ class ServiceQueueController extends Controller
'avg_service_seconds' => $validated['avg_service_seconds'] ?? 300, 'avg_service_seconds' => $validated['avg_service_seconds'] ?? 300,
'max_capacity' => $validated['max_capacity'] ?? null, 'max_capacity' => $validated['max_capacity'] ?? null,
'is_active' => array_key_exists('is_active', $validated) ? (bool) $validated['is_active'] : true, 'is_active' => array_key_exists('is_active', $validated) ? (bool) $validated['is_active'] : true,
'settings' => $externalKey !== '' ? ['external_key' => $externalKey] : null, 'settings' => $settings !== [] ? $settings : null,
]); ]);
AuditLogger::record($owner, 'service_queue.created', $organization->id, $owner, ServiceQueue::class, $queue->id); AuditLogger::record($owner, 'service_queue.created', $organization->id, $owner, ServiceQueue::class, $queue->id);
@@ -148,8 +165,16 @@ class ServiceQueueController extends Controller
'is_active' => ['sometimes', 'boolean'], 'is_active' => ['sometimes', 'boolean'],
'avg_service_seconds' => ['nullable', 'integer', 'min:60', 'max:7200'], 'avg_service_seconds' => ['nullable', 'integer', 'min:60', 'max:7200'],
'max_capacity' => ['nullable', 'integer', 'min:1'], 'max_capacity' => ['nullable', 'integer', 'min:1'],
'routing_mode' => ['nullable', 'string', 'in:shared_pool,assigned_only'],
]); ]);
$settings = $serviceQueue->settings ?? [];
if (! empty($validated['routing_mode'])) {
$settings['routing_mode'] = $validated['routing_mode'];
unset($validated['routing_mode']);
$validated['settings'] = $settings;
}
$serviceQueue->update($validated); $serviceQueue->update($validated);
AuditLogger::record( AuditLogger::record(
@@ -233,6 +258,8 @@ class ServiceQueueController extends Controller
'is_paused' => $q->is_paused, 'is_paused' => $q->is_paused,
'branch' => $q->branch?->name, 'branch' => $q->branch?->name,
'external_key' => data_get($q->settings, 'external_key'), 'external_key' => data_get($q->settings, 'external_key'),
'routing_mode' => $q->routingMode(),
'department_id' => $q->department_id,
]; ];
} }
} }
+34 -8
View File
@@ -58,17 +58,26 @@ class TicketController extends Controller
'priority' => ['nullable', 'string', 'in:'.implode(',', array_keys(config('qms.ticket_priorities')))], 'priority' => ['nullable', 'string', 'in:'.implode(',', array_keys(config('qms.ticket_priorities')))],
'source' => ['nullable', 'string', 'in:'.implode(',', array_keys(config('qms.ticket_sources')))], 'source' => ['nullable', 'string', 'in:'.implode(',', array_keys(config('qms.ticket_sources')))],
'metadata' => ['nullable', 'array'], 'metadata' => ['nullable', 'array'],
'assigned_counter_id' => ['nullable', 'uuid'],
'assigned_counter_uuid' => ['nullable', 'uuid'],
]); ]);
$queue = ServiceQueue::where('uuid', $validated['service_queue_id'])->firstOrFail(); $queue = ServiceQueue::where('uuid', $validated['service_queue_id'])->firstOrFail();
$this->authorizeOwner($request, $queue); $this->authorizeOwner($request, $queue);
$ticket = $this->engine->issueTicket($queue, $owner, [ try {
...$validated, $ticket = $this->engine->issueTicket($queue, $owner, [
'source' => $validated['source'] ?? 'api', ...$validated,
'actor_ref' => $owner, 'source' => $validated['source'] ?? 'api',
'metadata' => $validated['metadata'] ?? null, 'actor_ref' => $owner,
]); 'metadata' => $validated['metadata'] ?? null,
'assigned_counter_uuid' => $validated['assigned_counter_uuid']
?? $validated['assigned_counter_id']
?? null,
]);
} catch (\RuntimeException $e) {
return response()->json(['message' => $e->getMessage()], 422);
}
return response()->json(['data' => TicketPresenter::toArray($ticket)], 201); return response()->json(['data' => TicketPresenter::toArray($ticket)], 201);
} }
@@ -108,6 +117,7 @@ class TicketController extends Controller
'action' => ['required', 'string', 'in:recall,start,hold,skip,complete,no_show,cancel,delay,transfer'], 'action' => ['required', 'string', 'in:recall,start,hold,skip,complete,no_show,cancel,delay,transfer'],
'minutes' => ['nullable', 'integer', 'min:5', 'max:120'], 'minutes' => ['nullable', 'integer', 'min:5', 'max:120'],
'to_queue_id' => ['required_if:action,transfer', 'nullable', 'uuid'], 'to_queue_id' => ['required_if:action,transfer', 'nullable', 'uuid'],
'assigned_counter_id' => ['nullable', 'uuid'],
'reason' => ['nullable', 'string', 'max:500'], 'reason' => ['nullable', 'string', 'max:500'],
]); ]);
@@ -119,9 +129,25 @@ class TicketController extends Controller
); );
$toQueue = ServiceQueue::where('uuid', $validated['to_queue_id'])->firstOrFail(); $toQueue = ServiceQueue::where('uuid', $validated['to_queue_id'])->firstOrFail();
$this->authorizeOwner($request, $toQueue); $this->authorizeOwner($request, $toQueue);
$result = $this->engine->transfer($ticket, $toQueue, null, $validated['reason'] ?? null, $actor); $assignTo = null;
if (! empty($validated['assigned_counter_id'])) {
$assignTo = \App\Models\Counter::where('uuid', $validated['assigned_counter_id'])->firstOrFail();
$this->authorizeOwner($request, $assignTo);
}
try {
$result = $this->engine->transfer(
$ticket,
$toQueue,
null,
$validated['reason'] ?? null,
$actor,
$assignTo,
);
} catch (\RuntimeException $e) {
return response()->json(['message' => $e->getMessage()], 422);
}
return response()->json(['data' => TicketPresenter::toArray($result->load(['serviceQueue', 'counter']))]); return response()->json(['data' => TicketPresenter::toArray($result->load(['serviceQueue', 'counter', 'assignedCounter']))]);
} }
$result = match ($validated['action']) { $result = match ($validated['action']) {
+10 -1
View File
@@ -19,7 +19,8 @@ class Counter extends Model
protected $fillable = [ protected $fillable = [
'uuid', 'owner_ref', 'organization_id', 'branch_id', 'department_id', 'uuid', 'owner_ref', 'organization_id', 'branch_id', 'department_id',
'name', 'code', 'status', 'settings', 'is_active', 'name', 'code', 'destination', 'staff_ref', 'staff_display_name',
'status', 'settings', 'is_active',
]; ];
protected function casts(): array protected function casts(): array
@@ -30,6 +31,14 @@ class Counter extends Model
]; ];
} }
/** Public display label (Room 4, Counter 2, etc.). Falls back to name. */
public function displayDestination(): string
{
$destination = trim((string) ($this->destination ?? ''));
return $destination !== '' ? $destination : (string) $this->name;
}
protected static function booted(): void protected static function booted(): void
{ {
static::creating(function (self $counter) { static::creating(function (self $counter) {
+11 -1
View File
@@ -14,7 +14,7 @@ class Department extends Model
protected $table = 'queue_departments'; protected $table = 'queue_departments';
protected $fillable = [ protected $fillable = [
'owner_ref', 'branch_id', 'name', 'type', 'is_active', 'owner_ref', 'branch_id', 'name', 'type', 'external_key', 'is_active',
]; ];
protected function casts(): array protected function casts(): array
@@ -26,4 +26,14 @@ class Department extends Model
{ {
return $this->belongsTo(Branch::class, 'branch_id'); return $this->belongsTo(Branch::class, 'branch_id');
} }
public function counters(): \Illuminate\Database\Eloquent\Relations\HasMany
{
return $this->hasMany(Counter::class, 'department_id');
}
public function serviceQueues(): \Illuminate\Database\Eloquent\Relations\HasMany
{
return $this->hasMany(ServiceQueue::class, 'department_id');
}
} }
+16
View File
@@ -67,4 +67,20 @@ class ServiceQueue extends Model
return $this->belongsToMany(Counter::class, 'queue_counter_service_queue', 'service_queue_id', 'counter_id') return $this->belongsToMany(Counter::class, 'queue_counter_service_queue', 'service_queue_id', 'counter_id')
->withPivot('priority'); ->withPivot('priority');
} }
/**
* shared_pool any linked service point may pull unassigned waiting tickets.
* assigned_only call-next only returns tickets pre-assigned to that service point.
*/
public function routingMode(): string
{
$mode = (string) data_get($this->settings, 'routing_mode', 'shared_pool');
return in_array($mode, ['shared_pool', 'assigned_only'], true) ? $mode : 'shared_pool';
}
public function usesAssignedOnlyRouting(): bool
{
return $this->routingMode() === 'assigned_only';
}
} }
+7
View File
@@ -18,6 +18,7 @@ class Ticket extends Model
protected $fillable = [ protected $fillable = [
'uuid', 'owner_ref', 'organization_id', 'branch_id', 'service_queue_id', 'counter_id', 'uuid', 'owner_ref', 'organization_id', 'branch_id', 'service_queue_id', 'counter_id',
'assigned_counter_id',
'ticket_number', 'status', 'priority', 'position', 'customer_name', 'customer_phone', 'ticket_number', 'status', 'priority', 'position', 'customer_name', 'customer_phone',
'customer_email', 'source', 'qr_token', 'barcode', 'estimated_wait_seconds', 'customer_email', 'source', 'qr_token', 'barcode', 'estimated_wait_seconds',
'issued_at', 'called_at', 'serving_started_at', 'completed_at', 'metadata', 'issued_at', 'called_at', 'serving_started_at', 'completed_at', 'metadata',
@@ -62,6 +63,12 @@ class Ticket extends Model
return $this->belongsTo(Counter::class, 'counter_id'); return $this->belongsTo(Counter::class, 'counter_id');
} }
/** Service point this ticket is routed to (may differ from serving counter until called). */
public function assignedCounter(): BelongsTo
{
return $this->belongsTo(Counter::class, 'assigned_counter_id');
}
public function events(): HasMany public function events(): HasMany
{ {
return $this->hasMany(TicketEvent::class, 'ticket_id'); return $this->hasMany(TicketEvent::class, 'ticket_id');
+18 -8
View File
@@ -34,25 +34,35 @@ class DisplayService
->orderBy('name') ->orderBy('name')
->get(); ->get();
// One active ticket per destination (counter). Older called/serving // One active ticket per destination (service point / counter). Older called/serving
// tickets at the same window must not crowd the public board. // tickets at the same window must not crowd the public board.
$nowServing = Ticket::query() $nowServing = Ticket::query()
->whereIn('service_queue_id', $queueIds) ->whereIn('service_queue_id', $queueIds)
->whereIn('status', ['called', 'serving']) ->whereIn('status', ['called', 'serving'])
->with(['counter', 'serviceQueue']) ->with(['counter', 'assignedCounter', 'serviceQueue'])
->orderByDesc('called_at') ->orderByDesc('called_at')
->limit(48) ->limit(48)
->get() ->get()
->unique(fn (Ticket $t) => $t->counter_id ->unique(fn (Ticket $t) => $t->counter_id
? 'counter:'.$t->counter_id ? 'counter:'.$t->counter_id
: 'queue:'.$t->service_queue_id) : ($t->assigned_counter_id
? 'assigned:'.$t->assigned_counter_id
: 'queue:'.$t->service_queue_id))
->take(12) ->take(12)
->values() ->values()
->map(fn (Ticket $t) => [ ->map(function (Ticket $t) {
'ticket_number' => $t->ticket_number, $point = $t->counter ?? $t->assignedCounter;
'queue_name' => $t->serviceQueue?->name,
'counter' => $t->counter?->name, return [
]) 'ticket_number' => $t->ticket_number,
'queue_name' => $t->serviceQueue?->name,
'department' => $t->serviceQueue?->name,
'counter' => $point?->displayDestination() ?? $point?->name,
'destination' => $point?->displayDestination(),
'staff_display_name' => $point?->staff_display_name,
'announcement_text' => TicketPresenter::announcementText($t, $point),
];
})
->all(); ->all();
$waiting = Ticket::query() $waiting = Ticket::query()
+87 -14
View File
@@ -56,11 +56,14 @@ class QueueEngine
->count(); ->count();
$estimatedWait = $waitingCount * (int) $queue->avg_service_seconds; $estimatedWait = $waitingCount * (int) $queue->avg_service_seconds;
$assignedCounterId = $this->resolveAssignedCounterId($queue, $attributes);
$ticket = Ticket::create([ $ticket = Ticket::create([
'owner_ref' => $ownerRef, 'owner_ref' => $ownerRef,
'organization_id' => $queue->organization_id, 'organization_id' => $queue->organization_id,
'branch_id' => $queue->branch_id, 'branch_id' => $queue->branch_id,
'service_queue_id' => $queue->id, 'service_queue_id' => $queue->id,
'assigned_counter_id' => $assignedCounterId,
'ticket_number' => $number, 'ticket_number' => $number,
'status' => 'waiting', 'status' => 'waiting',
'priority' => $attributes['priority'] ?? 'walk_in', 'priority' => $attributes['priority'] ?? 'walk_in',
@@ -75,7 +78,9 @@ class QueueEngine
'metadata' => $attributes['metadata'] ?? null, 'metadata' => $attributes['metadata'] ?? null,
]); ]);
$this->recordEvent($ticket, 'issued', $attributes['actor_ref'] ?? null); $this->recordEvent($ticket, 'issued', $attributes['actor_ref'] ?? null, null, [
'assigned_counter_id' => $assignedCounterId,
]);
AuditLogger::record( AuditLogger::record(
$ownerRef, $ownerRef,
@@ -87,7 +92,7 @@ class QueueEngine
['ticket_number' => $number, 'queue' => $queue->name], ['ticket_number' => $number, 'queue' => $queue->name],
); );
$ticket = $ticket->fresh(['serviceQueue', 'branch']); $ticket = $ticket->fresh(['serviceQueue', 'branch', 'assignedCounter']);
app(QueueNotificationService::class)->ticketIssued($ticket); app(QueueNotificationService::class)->ticketIssued($ticket);
return $ticket; return $ticket;
@@ -96,7 +101,7 @@ class QueueEngine
public function callNext(ServiceQueue $queue, Counter $counter, ?string $actorRef = null): ?Ticket public function callNext(ServiceQueue $queue, Counter $counter, ?string $actorRef = null): ?Ticket
{ {
$ticket = $this->nextWaitingTicket($queue); $ticket = $this->nextWaitingTicket($queue, $counter);
if (! $ticket) { if (! $ticket) {
return null; return null;
} }
@@ -106,9 +111,14 @@ class QueueEngine
public function callTicket(Ticket $ticket, Counter $counter, ?string $actorRef = null): Ticket public function callTicket(Ticket $ticket, Counter $counter, ?string $actorRef = null): Ticket
{ {
if ($ticket->assigned_counter_id && (int) $ticket->assigned_counter_id !== (int) $counter->id) {
throw new \RuntimeException('Ticket is assigned to another service point.');
}
$ticket->update([ $ticket->update([
'status' => 'called', 'status' => 'called',
'counter_id' => $counter->id, 'counter_id' => $counter->id,
'assigned_counter_id' => $ticket->assigned_counter_id ?: $counter->id,
'called_at' => now(), 'called_at' => now(),
]); ]);
@@ -229,8 +239,9 @@ class QueueEngine
?Counter $counter = null, ?Counter $counter = null,
?string $reason = null, ?string $reason = null,
?string $actorRef = null, ?string $actorRef = null,
?Counter $assignToCounter = null,
): Ticket { ): Ticket {
return DB::transaction(function () use ($ticket, $toQueue, $counter, $reason, $actorRef) { return DB::transaction(function () use ($ticket, $toQueue, $counter, $reason, $actorRef, $assignToCounter) {
$ticket = Ticket::query()->lockForUpdate()->findOrFail($ticket->id); $ticket = Ticket::query()->lockForUpdate()->findOrFail($ticket->id);
$toQueue = ServiceQueue::query()->lockForUpdate()->findOrFail($toQueue->id); $toQueue = ServiceQueue::query()->lockForUpdate()->findOrFail($toQueue->id);
@@ -256,6 +267,16 @@ class QueueEngine
} }
} }
$assignedCounterId = null;
if ($assignToCounter) {
if ((int) $assignToCounter->organization_id !== (int) $toQueue->organization_id) {
throw new \RuntimeException('Assigned service point is outside this organization.');
}
$assignedCounterId = $assignToCounter->id;
} elseif ($toQueue->usesAssignedOnlyRouting()) {
throw new \RuntimeException('Target queue requires an assigned service point.');
}
$fromQueueId = $ticket->service_queue_id; $fromQueueId = $ticket->service_queue_id;
$fromCounterId = $counter?->id ?? $ticket->counter_id; $fromCounterId = $counter?->id ?? $ticket->counter_id;
$ticketNumber = $ticket->ticket_number; $ticketNumber = $ticket->ticket_number;
@@ -274,6 +295,7 @@ class QueueEngine
'position' => $waitingCount + 1, 'position' => $waitingCount + 1,
'estimated_wait_seconds' => $estimatedWait, 'estimated_wait_seconds' => $estimatedWait,
'counter_id' => null, 'counter_id' => null,
'assigned_counter_id' => $assignedCounterId,
'called_at' => null, 'called_at' => null,
'serving_started_at' => null, 'serving_started_at' => null,
'completed_at' => null, 'completed_at' => null,
@@ -355,26 +377,77 @@ class QueueEngine
/** /**
* @return list<Ticket> * @return list<Ticket>
*/ */
public function waitingTickets(ServiceQueue $queue, int $limit = 50): array public function waitingTickets(ServiceQueue $queue, int $limit = 50, ?Counter $forCounter = null): array
{ {
return Ticket::query() return $this->waitingTicketQuery($queue, $forCounter)
->where('service_queue_id', $queue->id)
->where('status', 'waiting')
->orderByRaw($this->priorityOrderSql())
->orderBy('issued_at')
->limit($limit) ->limit($limit)
->get() ->get()
->all(); ->all();
} }
protected function nextWaitingTicket(ServiceQueue $queue): ?Ticket protected function nextWaitingTicket(ServiceQueue $queue, ?Counter $counter = null): ?Ticket
{ {
return Ticket::query() return $this->waitingTicketQuery($queue, $counter)->first();
}
/**
* Tickets visible/callable for a service point:
* - assigned_only: only tickets assigned to this counter
* - shared_pool: unassigned tickets, or tickets assigned to this counter
* - no counter: all waiting (admin/list views)
*/
protected function waitingTicketQuery(ServiceQueue $queue, ?Counter $counter = null)
{
$query = Ticket::query()
->where('service_queue_id', $queue->id) ->where('service_queue_id', $queue->id)
->where('status', 'waiting') ->where('status', 'waiting')
->orderByRaw($this->priorityOrderSql()) ->orderByRaw($this->priorityOrderSql())
->orderBy('issued_at') ->orderBy('issued_at');
->first();
if (! $counter) {
return $query;
}
if ($queue->usesAssignedOnlyRouting()) {
return $query->where('assigned_counter_id', $counter->id);
}
return $query->where(function ($q) use ($counter) {
$q->whereNull('assigned_counter_id')
->orWhere('assigned_counter_id', $counter->id);
});
}
/**
* @param array<string, mixed> $attributes
*/
protected function resolveAssignedCounterId(ServiceQueue $queue, array $attributes): ?int
{
$counter = null;
if (! empty($attributes['assigned_counter_id'])) {
$value = $attributes['assigned_counter_id'];
$counter = is_numeric($value)
? Counter::query()->find((int) $value)
: Counter::query()->where('uuid', (string) $value)->first();
} elseif (! empty($attributes['assigned_counter_uuid'])) {
$counter = Counter::query()->where('uuid', (string) $attributes['assigned_counter_uuid'])->first();
}
if (! $counter) {
if ($queue->usesAssignedOnlyRouting()) {
throw new \RuntimeException('This queue requires an assigned service point.');
}
return null;
}
if ((int) $counter->organization_id !== (int) $queue->organization_id
|| (string) $counter->owner_ref !== (string) $queue->owner_ref) {
throw new \RuntimeException('Assigned service point is outside this organization.');
}
return (int) $counter->id;
} }
protected function priorityOrderSql(): string protected function priorityOrderSql(): string
+43 -4
View File
@@ -11,6 +11,12 @@ class TicketPresenter
*/ */
public static function toArray(Ticket $ticket, bool $includeQr = true): array public static function toArray(Ticket $ticket, bool $includeQr = true): array
{ {
$ticket->loadMissing(['serviceQueue', 'counter', 'assignedCounter']);
$serving = $ticket->counter;
$assigned = $ticket->assignedCounter;
$point = $serving ?? $assigned;
return [ return [
'uuid' => $ticket->uuid, 'uuid' => $ticket->uuid,
'ticket_number' => $ticket->ticket_number, 'ticket_number' => $ticket->ticket_number,
@@ -28,13 +34,46 @@ class TicketPresenter
'name' => $ticket->serviceQueue->name, 'name' => $ticket->serviceQueue->name,
'prefix' => $ticket->serviceQueue->prefix, 'prefix' => $ticket->serviceQueue->prefix,
'color' => $ticket->serviceQueue->color, 'color' => $ticket->serviceQueue->color,
'routing_mode' => $ticket->serviceQueue->routingMode(),
] : null, ] : null,
'counter' => $ticket->counter ? [ 'counter' => $serving ? self::serializePoint($serving) : null,
'uuid' => $ticket->counter->uuid, 'assigned_counter' => $assigned ? self::serializePoint($assigned) : null,
'name' => $ticket->counter->name, 'destination' => $point?->displayDestination(),
] : null, 'staff_display_name' => $point?->staff_display_name,
'announcement_text' => self::announcementText($ticket, $point),
'track_url' => $includeQr ? route('qms.mobile.show', $ticket->qr_token) : null, 'track_url' => $includeQr ? route('qms.mobile.show', $ticket->qr_token) : null,
'barcode' => $ticket->barcode, 'barcode' => $ticket->barcode,
]; ];
} }
/**
* @return array<string, mixed>
*/
public static function serializePoint(\App\Models\Counter $counter): array
{
return [
'uuid' => $counter->uuid,
'name' => $counter->name,
'destination' => $counter->displayDestination(),
'staff_ref' => $counter->staff_ref,
'staff_display_name' => $counter->staff_display_name,
'code' => $counter->code,
];
}
public static function announcementText(Ticket $ticket, ?\App\Models\Counter $point): ?string
{
if (! $point) {
return $ticket->ticket_number;
}
$parts = array_values(array_filter([
$ticket->ticket_number,
$point->staff_display_name ?: null,
$ticket->serviceQueue?->name,
$point->displayDestination(),
]));
return implode(', ', $parts);
}
} }
+16 -1
View File
@@ -31,10 +31,25 @@ class VoiceAnnouncementService
{ {
$templates = config('qms.announcement_templates'); $templates = config('qms.announcement_templates');
$template = $templates[$locale] ?? $templates['en']; $template = $templates[$locale] ?? $templates['en'];
$destination = $counter->displayDestination();
$staff = trim((string) ($counter->staff_display_name ?? ''));
$queueName = trim((string) ($ticket->serviceQueue?->name ?? ''));
// Prefer rich healthcare-style copy when staff is known:
// "A005, Dr. Mensah, Consultation Room 4."
if ($staff !== '') {
$parts = array_values(array_filter([
$ticket->ticket_number,
$staff,
$queueName !== '' ? $queueName.' '.$destination : $destination,
]));
return implode(', ', $parts).'.';
}
return str_replace( return str_replace(
[':ticket', ':counter'], [':ticket', ':counter'],
[$ticket->ticket_number, $counter->name], [$ticket->ticket_number, $destination],
$template, $template,
); );
} }
@@ -0,0 +1,55 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
/**
* Service-point routing for departments that need explicit assignment
* (e.g. doctor rooms) while preserving shared-pool call-next for generic queues.
*
* Counter service point (room / desk / window). Destination + staff are
* display/assignment metadata; tickets may be pre-assigned via assigned_counter_id.
*/
return new class extends Migration
{
public function up(): void
{
Schema::table('queue_counters', function (Blueprint $table) {
$table->string('destination')->nullable()->after('code');
$table->string('staff_ref')->nullable()->index()->after('destination');
$table->string('staff_display_name')->nullable()->after('staff_ref');
});
Schema::table('queue_tickets', function (Blueprint $table) {
$table->foreignId('assigned_counter_id')
->nullable()
->after('counter_id')
->constrained('queue_counters')
->nullOnDelete();
$table->index(['service_queue_id', 'status', 'assigned_counter_id'], 'queue_tickets_waiting_assignment_idx');
});
Schema::table('queue_departments', function (Blueprint $table) {
$table->string('external_key')->nullable()->after('type');
$table->index(['owner_ref', 'external_key']);
});
}
public function down(): void
{
Schema::table('queue_tickets', function (Blueprint $table) {
$table->dropIndex('queue_tickets_waiting_assignment_idx');
$table->dropConstrainedForeignId('assigned_counter_id');
});
Schema::table('queue_counters', function (Blueprint $table) {
$table->dropColumn(['destination', 'staff_ref', 'staff_display_name']);
});
Schema::table('queue_departments', function (Blueprint $table) {
$table->dropIndex(['owner_ref', 'external_key']);
$table->dropColumn('external_key');
});
}
};
+6 -1
View File
@@ -96,7 +96,12 @@
<p class="qms-display__ticket-number" x-text="item.ticket_number"></p> <p class="qms-display__ticket-number" x-text="item.ticket_number"></p>
<div class="qms-display__destination"> <div class="qms-display__destination">
<span>Please proceed to</span> <span>Please proceed to</span>
<p class="qms-display__counter-name" x-text="item.counter ?? 'Await staff direction'"></p> <p class="qms-display__counter-name" x-text="item.destination || item.counter || 'Await staff direction'"></p>
<p
class="mt-1 text-sm font-medium text-slate-600 lg:text-base"
x-show="item.staff_display_name"
x-text="item.staff_display_name"
></p>
</div> </div>
</div> </div>
</article> </article>
+4
View File
@@ -5,6 +5,7 @@ use App\Http\Controllers\Api\AppointmentController;
use App\Http\Controllers\Api\BranchController; use App\Http\Controllers\Api\BranchController;
use App\Http\Controllers\Api\ConsoleApiController; use App\Http\Controllers\Api\ConsoleApiController;
use App\Http\Controllers\Api\CounterController; use App\Http\Controllers\Api\CounterController;
use App\Http\Controllers\Api\DepartmentController;
use App\Http\Controllers\Api\DeviceHeartbeatController; use App\Http\Controllers\Api\DeviceHeartbeatController;
use App\Http\Controllers\Api\IntegrationController; use App\Http\Controllers\Api\IntegrationController;
use App\Http\Controllers\Api\PublicQueueController; use App\Http\Controllers\Api\PublicQueueController;
@@ -74,6 +75,9 @@ Route::middleware(['auth.service:qms', 'qms.integration'])->prefix('v1')->group(
Route::get('/branches', [BranchController::class, 'index'])->name('api.service.branches.index'); Route::get('/branches', [BranchController::class, 'index'])->name('api.service.branches.index');
Route::post('/branches', [BranchController::class, 'store'])->name('api.service.branches.store'); Route::post('/branches', [BranchController::class, 'store'])->name('api.service.branches.store');
Route::get('/departments', [DepartmentController::class, 'index'])->name('api.service.departments.index');
Route::post('/departments', [DepartmentController::class, 'store'])->name('api.service.departments.store');
Route::get('/queues', [ServiceQueueController::class, 'index'])->name('api.service.queues.index'); Route::get('/queues', [ServiceQueueController::class, 'index'])->name('api.service.queues.index');
Route::post('/queues', [ServiceQueueController::class, 'store'])->name('api.service.queues.store'); Route::post('/queues', [ServiceQueueController::class, 'store'])->name('api.service.queues.store');
Route::patch('/queues/{serviceQueue}', [ServiceQueueController::class, 'update'])->name('api.service.queues.update'); Route::patch('/queues/{serviceQueue}', [ServiceQueueController::class, 'update'])->name('api.service.queues.update');
+245
View File
@@ -0,0 +1,245 @@
<?php
namespace Tests\Feature;
use App\Models\Branch;
use App\Models\Counter;
use App\Models\DisplayScreen;
use App\Models\Organization;
use App\Models\ServiceQueue;
use App\Models\User;
use App\Services\Qms\DisplayService;
use App\Services\Qms\OrganizationResolver;
use App\Services\Qms\QueueEngine;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
class ServicePointRoutingTest extends TestCase
{
use RefreshDatabase;
/**
* @return array{0: User, 1: Organization, 2: Branch, 3: ServiceQueue, 4: Counter, 5: Counter}
*/
protected function setUpAssignedConsultation(): array
{
$user = User::create([
'public_id' => 'sp-owner',
'name' => 'Owner',
'email' => 'sp@example.com',
'password' => bcrypt('password'),
]);
$org = app(OrganizationResolver::class)->completeOnboarding($user, [
'organization_name' => 'Clinic',
'industry' => 'healthcare',
'appointment_mode' => 'hybrid',
'branch_name' => 'Main',
'timezone' => 'UTC',
]);
$branch = Branch::first();
$queue = ServiceQueue::create([
'owner_ref' => $user->public_id,
'organization_id' => $org->id,
'branch_id' => $branch->id,
'name' => 'Consultation',
'prefix' => 'C',
'strategy' => 'fifo',
'is_active' => true,
'settings' => ['routing_mode' => 'assigned_only'],
]);
$room4 = Counter::create([
'owner_ref' => $user->public_id,
'organization_id' => $org->id,
'branch_id' => $branch->id,
'name' => 'Dr. Mensah',
'destination' => 'Consultation Room 4',
'staff_ref' => 'doc-mensah',
'staff_display_name' => 'Dr. Mensah',
'status' => 'available',
'is_active' => true,
]);
$room5 = Counter::create([
'owner_ref' => $user->public_id,
'organization_id' => $org->id,
'branch_id' => $branch->id,
'name' => 'Dr. Okai',
'destination' => 'Consultation Room 5',
'staff_ref' => 'doc-okai',
'staff_display_name' => 'Dr. Okai',
'status' => 'available',
'is_active' => true,
]);
$room4->serviceQueues()->sync([$queue->id]);
$room5->serviceQueues()->sync([$queue->id]);
return [$user, $org, $branch, $queue, $room4, $room5];
}
public function test_call_next_cannot_steal_ticket_assigned_to_another_service_point(): void
{
[$user, , , $queue, $room4, $room5] = $this->setUpAssignedConsultation();
$engine = app(QueueEngine::class);
$ticket = $engine->issueTicket($queue, $user->public_id, [
'customer_name' => 'Ada',
'assigned_counter_uuid' => $room4->uuid,
]);
$this->assertSame($room4->id, $ticket->assigned_counter_id);
$stolen = $engine->callNext($queue, $room5, $user->public_id);
$this->assertNull($stolen);
$called = $engine->callNext($queue, $room4, $user->public_id);
$this->assertNotNull($called);
$this->assertSame($ticket->id, $called->id);
$this->assertSame('called', $called->status);
$this->assertSame($room4->id, $called->counter_id);
}
public function test_assigned_only_queue_requires_service_point_on_issue(): void
{
[$user, , , $queue] = $this->setUpAssignedConsultation();
$this->expectException(\RuntimeException::class);
$this->expectExceptionMessage('assigned service point');
app(QueueEngine::class)->issueTicket($queue, $user->public_id, [
'customer_name' => 'No Point',
]);
}
public function test_display_shows_latest_per_service_point_with_destination_and_staff(): void
{
[$user, , $branch, $queue, $room4, $room5] = $this->setUpAssignedConsultation();
$engine = app(QueueEngine::class);
$t1 = $engine->issueTicket($queue, $user->public_id, [
'assigned_counter_uuid' => $room4->uuid,
'customer_name' => 'Patient A',
]);
$t2 = $engine->issueTicket($queue, $user->public_id, [
'assigned_counter_uuid' => $room5->uuid,
'customer_name' => 'Patient B',
]);
$engine->callTicket($t1, $room4, $user->public_id);
$engine->callTicket($t2, $room5, $user->public_id);
$screen = DisplayScreen::create([
'owner_ref' => $user->public_id,
'organization_id' => $queue->organization_id,
'branch_id' => $branch->id,
'name' => 'Lobby',
'layout' => 'standard',
'service_queue_ids' => [$queue->id],
'is_active' => true,
]);
$payload = app(DisplayService::class)->payload($screen);
$this->assertCount(2, $payload['now_serving']);
$destinations = collect($payload['now_serving'])->pluck('destination')->all();
$this->assertContains('Consultation Room 4', $destinations);
$this->assertContains('Consultation Room 5', $destinations);
$staff = collect($payload['now_serving'])->pluck('staff_display_name')->all();
$this->assertContains('Dr. Mensah', $staff);
$this->assertContains('Dr. Okai', $staff);
$mensah = collect($payload['now_serving'])->firstWhere('staff_display_name', 'Dr. Mensah');
$this->assertStringContainsString('Dr. Mensah', (string) ($mensah['announcement_text'] ?? ''));
$this->assertStringContainsString('Consultation Room 4', (string) ($mensah['announcement_text'] ?? ''));
}
public function test_shared_pool_still_allows_unassigned_call_next(): void
{
$user = User::create([
'public_id' => 'bank-owner',
'name' => 'Bank',
'email' => 'bank@example.com',
'password' => bcrypt('password'),
]);
$org = app(OrganizationResolver::class)->completeOnboarding($user, [
'organization_name' => 'Bank',
'industry' => 'banking',
'appointment_mode' => 'walk_in_only',
'branch_name' => 'Main',
'timezone' => 'UTC',
]);
$branch = Branch::first();
$queue = ServiceQueue::create([
'owner_ref' => $user->public_id,
'organization_id' => $org->id,
'branch_id' => $branch->id,
'name' => 'Teller',
'prefix' => 'T',
'strategy' => 'fifo',
'is_active' => true,
'settings' => ['routing_mode' => 'shared_pool'],
]);
$window = Counter::create([
'owner_ref' => $user->public_id,
'organization_id' => $org->id,
'branch_id' => $branch->id,
'name' => 'Window 1',
'destination' => 'Window 1',
'status' => 'available',
'is_active' => true,
]);
$window->serviceQueues()->sync([$queue->id]);
$engine = app(QueueEngine::class);
$ticket = $engine->issueTicket($queue, $user->public_id, ['customer_name' => 'Customer']);
$this->assertNull($ticket->assigned_counter_id);
$called = $engine->callNext($queue, $window, $user->public_id);
$this->assertNotNull($called);
$this->assertSame($ticket->id, $called->id);
}
public function test_care_api_can_issue_assigned_ticket(): void
{
config(['qms.service_api_keys.care' => 'test-care-key']);
$owner = 'care-sp-owner';
$headers = [
'Authorization' => 'Bearer test-care-key',
'Accept' => 'application/json',
];
$this->postJson('/api/v1/integrations/provision', [
'owner' => $owner,
'organization_name' => 'Care Clinic',
'branch_name' => 'Main',
], $headers)->assertSuccessful();
$queueUuid = $this->postJson('/api/v1/queues', [
'owner' => $owner,
'name' => 'Consultation',
'prefix' => 'C',
'branch_name' => 'Main',
'external_key' => 'care:dept:consultation:queue:1',
'routing_mode' => 'assigned_only',
], $headers)->assertCreated()->json('data.uuid');
$counterUuid = $this->postJson('/api/v1/counters', [
'owner' => $owner,
'name' => 'Dr. Mensah',
'destination' => 'Room 4',
'staff_display_name' => 'Dr. Mensah',
'branch_name' => 'Main',
'queue_uuids' => [$queueUuid],
'external_key' => 'care:point:consultation:practitioner:9',
], $headers)->assertCreated()->json('data.uuid');
$this->postJson('/api/v1/tickets', [
'owner' => $owner,
'service_queue_id' => $queueUuid,
'customer_name' => 'Ada',
'assigned_counter_id' => $counterUuid,
], $headers)
->assertCreated()
->assertJsonPath('data.assigned_counter.uuid', $counterUuid)
->assertJsonPath('data.destination', 'Room 4')
->assertJsonPath('data.staff_display_name', 'Dr. Mensah');
}
}