Add service-point routing so call-next cannot steal assigned tickets.
Deploy Ladill Queue / deploy (push) Successful in 2m17s
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:
@@ -40,10 +40,10 @@ class ConsoleApiController extends Controller
|
||||
->latest('called_at')
|
||||
->first();
|
||||
|
||||
$waitingByQueue = collect($counter->serviceQueues)->mapWithKeys(function (ServiceQueue $queue) {
|
||||
$waitingByQueue = collect($counter->serviceQueues)->mapWithKeys(function (ServiceQueue $queue) use ($counter) {
|
||||
return [$queue->uuid => array_map(
|
||||
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' => [
|
||||
'uuid' => $counter->uuid,
|
||||
'name' => $counter->name,
|
||||
'destination' => $counter->displayDestination(),
|
||||
'staff_display_name' => $counter->staff_display_name,
|
||||
'status' => $counter->status,
|
||||
'branch' => $counter->branch?->name,
|
||||
],
|
||||
@@ -69,6 +71,7 @@ class ConsoleApiController extends Controller
|
||||
'uuid' => $q->uuid,
|
||||
'name' => $q->name,
|
||||
'prefix' => $q->prefix,
|
||||
'routing_mode' => $q->routingMode(),
|
||||
]),
|
||||
'waiting_by_queue' => $waitingByQueue,
|
||||
'transfer_queues' => $allQueues,
|
||||
|
||||
@@ -40,6 +40,9 @@ class CounterController extends Controller
|
||||
$validated = $request->validate([
|
||||
'name' => ['required', 'string', 'max:255'],
|
||||
'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_name' => ['nullable', 'string', 'max:255'],
|
||||
'department_id' => ['nullable', 'integer'],
|
||||
@@ -65,6 +68,15 @@ class CounterController extends Controller
|
||||
$existing->update([
|
||||
'name' => $validated['name'],
|
||||
'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,
|
||||
'department_id' => $validated['department_id'] ?? $existing->department_id,
|
||||
'is_active' => array_key_exists('is_active', $validated)
|
||||
@@ -86,6 +98,9 @@ class CounterController extends Controller
|
||||
'department_id' => $validated['department_id'] ?? null,
|
||||
'name' => $validated['name'],
|
||||
'code' => $validated['code'] ?? null,
|
||||
'destination' => $validated['destination'] ?? null,
|
||||
'staff_ref' => $validated['staff_ref'] ?? null,
|
||||
'staff_display_name' => $validated['staff_display_name'] ?? null,
|
||||
'status' => 'offline',
|
||||
'is_active' => array_key_exists('is_active', $validated) ? (bool) $validated['is_active'] : true,
|
||||
'settings' => $externalKey !== '' ? ['external_key' => $externalKey] : null,
|
||||
@@ -106,6 +121,9 @@ class CounterController extends Controller
|
||||
$validated = $request->validate([
|
||||
'name' => ['sometimes', 'string', 'max:255'],
|
||||
'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'],
|
||||
'queue_ids' => ['nullable', 'array'],
|
||||
'queue_ids.*' => ['uuid'],
|
||||
@@ -116,6 +134,11 @@ class CounterController extends Controller
|
||||
$counter->update(array_filter([
|
||||
'name' => $validated['name'] ?? 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,
|
||||
], fn ($v) => $v !== null));
|
||||
|
||||
@@ -181,9 +204,13 @@ class CounterController extends Controller
|
||||
'uuid' => $c->uuid,
|
||||
'name' => $c->name,
|
||||
'code' => $c->code,
|
||||
'destination' => $c->displayDestination(),
|
||||
'staff_ref' => $c->staff_ref,
|
||||
'staff_display_name' => $c->staff_display_name,
|
||||
'status' => $c->status,
|
||||
'is_active' => $c->is_active,
|
||||
'branch' => $c->branch?->name,
|
||||
'department_id' => $c->department_id,
|
||||
'external_key' => data_get($c->settings, 'external_key'),
|
||||
'queues' => $c->serviceQueues->map(fn (ServiceQueue $q) => [
|
||||
'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'],
|
||||
'is_active' => ['nullable', 'boolean'],
|
||||
'external_key' => ['nullable', 'string', 'max:191'],
|
||||
'routing_mode' => ['nullable', 'string', 'in:shared_pool,assigned_only'],
|
||||
]);
|
||||
|
||||
$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']) : '';
|
||||
$routingMode = $validated['routing_mode'] ?? null;
|
||||
if ($externalKey !== '') {
|
||||
$existing = ServiceQueue::owned($owner)
|
||||
->where('organization_id', $organization->id)
|
||||
@@ -63,6 +65,10 @@ class ServiceQueueController extends Controller
|
||||
->first();
|
||||
|
||||
if ($existing) {
|
||||
$settings = array_merge($existing->settings ?? [], ['external_key' => $externalKey]);
|
||||
if ($routingMode) {
|
||||
$settings['routing_mode'] = $routingMode;
|
||||
}
|
||||
$existing->update([
|
||||
'name' => $validated['name'],
|
||||
'description' => $validated['description'] ?? $existing->description,
|
||||
@@ -76,7 +82,7 @@ class ServiceQueueController extends Controller
|
||||
'is_active' => array_key_exists('is_active', $validated)
|
||||
? (bool) $validated['is_active']
|
||||
: true,
|
||||
'settings' => array_merge($existing->settings ?? [], ['external_key' => $externalKey]),
|
||||
'settings' => $settings,
|
||||
]);
|
||||
AuditLogger::record($owner, 'service_queue.updated', $organization->id, $owner, ServiceQueue::class, $existing->id);
|
||||
|
||||
@@ -96,6 +102,9 @@ class ServiceQueueController extends Controller
|
||||
if ($externalKey !== '') {
|
||||
$settings['external_key'] = $externalKey;
|
||||
}
|
||||
if ($routingMode) {
|
||||
$settings['routing_mode'] = $routingMode;
|
||||
}
|
||||
$byName->update([
|
||||
'prefix' => $validated['prefix'],
|
||||
'strategy' => $validated['strategy'] ?? $byName->strategy,
|
||||
@@ -114,6 +123,14 @@ class ServiceQueueController extends Controller
|
||||
'Queue limit reached for current plan.',
|
||||
);
|
||||
|
||||
$settings = [];
|
||||
if ($externalKey !== '') {
|
||||
$settings['external_key'] = $externalKey;
|
||||
}
|
||||
if ($routingMode) {
|
||||
$settings['routing_mode'] = $routingMode;
|
||||
}
|
||||
|
||||
$queue = ServiceQueue::create([
|
||||
'owner_ref' => $owner,
|
||||
'organization_id' => $organization->id,
|
||||
@@ -127,7 +144,7 @@ class ServiceQueueController extends Controller
|
||||
'avg_service_seconds' => $validated['avg_service_seconds'] ?? 300,
|
||||
'max_capacity' => $validated['max_capacity'] ?? null,
|
||||
'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);
|
||||
@@ -148,8 +165,16 @@ class ServiceQueueController extends Controller
|
||||
'is_active' => ['sometimes', 'boolean'],
|
||||
'avg_service_seconds' => ['nullable', 'integer', 'min:60', 'max:7200'],
|
||||
'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);
|
||||
|
||||
AuditLogger::record(
|
||||
@@ -233,6 +258,8 @@ class ServiceQueueController extends Controller
|
||||
'is_paused' => $q->is_paused,
|
||||
'branch' => $q->branch?->name,
|
||||
'external_key' => data_get($q->settings, 'external_key'),
|
||||
'routing_mode' => $q->routingMode(),
|
||||
'department_id' => $q->department_id,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -58,17 +58,26 @@ class TicketController extends Controller
|
||||
'priority' => ['nullable', 'string', 'in:'.implode(',', array_keys(config('qms.ticket_priorities')))],
|
||||
'source' => ['nullable', 'string', 'in:'.implode(',', array_keys(config('qms.ticket_sources')))],
|
||||
'metadata' => ['nullable', 'array'],
|
||||
'assigned_counter_id' => ['nullable', 'uuid'],
|
||||
'assigned_counter_uuid' => ['nullable', 'uuid'],
|
||||
]);
|
||||
|
||||
$queue = ServiceQueue::where('uuid', $validated['service_queue_id'])->firstOrFail();
|
||||
$this->authorizeOwner($request, $queue);
|
||||
|
||||
try {
|
||||
$ticket = $this->engine->issueTicket($queue, $owner, [
|
||||
...$validated,
|
||||
'source' => $validated['source'] ?? 'api',
|
||||
'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);
|
||||
}
|
||||
@@ -108,6 +117,7 @@ class TicketController extends Controller
|
||||
'action' => ['required', 'string', 'in:recall,start,hold,skip,complete,no_show,cancel,delay,transfer'],
|
||||
'minutes' => ['nullable', 'integer', 'min:5', 'max:120'],
|
||||
'to_queue_id' => ['required_if:action,transfer', 'nullable', 'uuid'],
|
||||
'assigned_counter_id' => ['nullable', 'uuid'],
|
||||
'reason' => ['nullable', 'string', 'max:500'],
|
||||
]);
|
||||
|
||||
@@ -119,9 +129,25 @@ class TicketController extends Controller
|
||||
);
|
||||
$toQueue = ServiceQueue::where('uuid', $validated['to_queue_id'])->firstOrFail();
|
||||
$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']) {
|
||||
|
||||
+10
-1
@@ -19,7 +19,8 @@ class Counter extends Model
|
||||
|
||||
protected $fillable = [
|
||||
'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
|
||||
@@ -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
|
||||
{
|
||||
static::creating(function (self $counter) {
|
||||
|
||||
@@ -14,7 +14,7 @@ class Department extends Model
|
||||
protected $table = 'queue_departments';
|
||||
|
||||
protected $fillable = [
|
||||
'owner_ref', 'branch_id', 'name', 'type', 'is_active',
|
||||
'owner_ref', 'branch_id', 'name', 'type', 'external_key', 'is_active',
|
||||
];
|
||||
|
||||
protected function casts(): array
|
||||
@@ -26,4 +26,14 @@ class Department extends Model
|
||||
{
|
||||
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');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -67,4 +67,20 @@ class ServiceQueue extends Model
|
||||
return $this->belongsToMany(Counter::class, 'queue_counter_service_queue', 'service_queue_id', 'counter_id')
|
||||
->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';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,6 +18,7 @@ class Ticket extends Model
|
||||
|
||||
protected $fillable = [
|
||||
'uuid', 'owner_ref', 'organization_id', 'branch_id', 'service_queue_id', 'counter_id',
|
||||
'assigned_counter_id',
|
||||
'ticket_number', 'status', 'priority', 'position', 'customer_name', 'customer_phone',
|
||||
'customer_email', 'source', 'qr_token', 'barcode', 'estimated_wait_seconds',
|
||||
'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');
|
||||
}
|
||||
|
||||
/** 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
|
||||
{
|
||||
return $this->hasMany(TicketEvent::class, 'ticket_id');
|
||||
|
||||
@@ -34,25 +34,35 @@ class DisplayService
|
||||
->orderBy('name')
|
||||
->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.
|
||||
$nowServing = Ticket::query()
|
||||
->whereIn('service_queue_id', $queueIds)
|
||||
->whereIn('status', ['called', 'serving'])
|
||||
->with(['counter', 'serviceQueue'])
|
||||
->with(['counter', 'assignedCounter', 'serviceQueue'])
|
||||
->orderByDesc('called_at')
|
||||
->limit(48)
|
||||
->get()
|
||||
->unique(fn (Ticket $t) => $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)
|
||||
->values()
|
||||
->map(fn (Ticket $t) => [
|
||||
->map(function (Ticket $t) {
|
||||
$point = $t->counter ?? $t->assignedCounter;
|
||||
|
||||
return [
|
||||
'ticket_number' => $t->ticket_number,
|
||||
'queue_name' => $t->serviceQueue?->name,
|
||||
'counter' => $t->counter?->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();
|
||||
|
||||
$waiting = Ticket::query()
|
||||
|
||||
@@ -56,11 +56,14 @@ class QueueEngine
|
||||
->count();
|
||||
$estimatedWait = $waitingCount * (int) $queue->avg_service_seconds;
|
||||
|
||||
$assignedCounterId = $this->resolveAssignedCounterId($queue, $attributes);
|
||||
|
||||
$ticket = Ticket::create([
|
||||
'owner_ref' => $ownerRef,
|
||||
'organization_id' => $queue->organization_id,
|
||||
'branch_id' => $queue->branch_id,
|
||||
'service_queue_id' => $queue->id,
|
||||
'assigned_counter_id' => $assignedCounterId,
|
||||
'ticket_number' => $number,
|
||||
'status' => 'waiting',
|
||||
'priority' => $attributes['priority'] ?? 'walk_in',
|
||||
@@ -75,7 +78,9 @@ class QueueEngine
|
||||
'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(
|
||||
$ownerRef,
|
||||
@@ -87,7 +92,7 @@ class QueueEngine
|
||||
['ticket_number' => $number, 'queue' => $queue->name],
|
||||
);
|
||||
|
||||
$ticket = $ticket->fresh(['serviceQueue', 'branch']);
|
||||
$ticket = $ticket->fresh(['serviceQueue', 'branch', 'assignedCounter']);
|
||||
app(QueueNotificationService::class)->ticketIssued($ticket);
|
||||
|
||||
return $ticket;
|
||||
@@ -96,7 +101,7 @@ class QueueEngine
|
||||
|
||||
public function callNext(ServiceQueue $queue, Counter $counter, ?string $actorRef = null): ?Ticket
|
||||
{
|
||||
$ticket = $this->nextWaitingTicket($queue);
|
||||
$ticket = $this->nextWaitingTicket($queue, $counter);
|
||||
if (! $ticket) {
|
||||
return null;
|
||||
}
|
||||
@@ -106,9 +111,14 @@ class QueueEngine
|
||||
|
||||
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([
|
||||
'status' => 'called',
|
||||
'counter_id' => $counter->id,
|
||||
'assigned_counter_id' => $ticket->assigned_counter_id ?: $counter->id,
|
||||
'called_at' => now(),
|
||||
]);
|
||||
|
||||
@@ -229,8 +239,9 @@ class QueueEngine
|
||||
?Counter $counter = null,
|
||||
?string $reason = null,
|
||||
?string $actorRef = null,
|
||||
?Counter $assignToCounter = null,
|
||||
): 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);
|
||||
$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;
|
||||
$fromCounterId = $counter?->id ?? $ticket->counter_id;
|
||||
$ticketNumber = $ticket->ticket_number;
|
||||
@@ -274,6 +295,7 @@ class QueueEngine
|
||||
'position' => $waitingCount + 1,
|
||||
'estimated_wait_seconds' => $estimatedWait,
|
||||
'counter_id' => null,
|
||||
'assigned_counter_id' => $assignedCounterId,
|
||||
'called_at' => null,
|
||||
'serving_started_at' => null,
|
||||
'completed_at' => null,
|
||||
@@ -355,26 +377,77 @@ class QueueEngine
|
||||
/**
|
||||
* @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()
|
||||
->where('service_queue_id', $queue->id)
|
||||
->where('status', 'waiting')
|
||||
->orderByRaw($this->priorityOrderSql())
|
||||
->orderBy('issued_at')
|
||||
return $this->waitingTicketQuery($queue, $forCounter)
|
||||
->limit($limit)
|
||||
->get()
|
||||
->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('status', 'waiting')
|
||||
->orderByRaw($this->priorityOrderSql())
|
||||
->orderBy('issued_at')
|
||||
->first();
|
||||
->orderBy('issued_at');
|
||||
|
||||
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
|
||||
|
||||
@@ -11,6 +11,12 @@ class TicketPresenter
|
||||
*/
|
||||
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 [
|
||||
'uuid' => $ticket->uuid,
|
||||
'ticket_number' => $ticket->ticket_number,
|
||||
@@ -28,13 +34,46 @@ class TicketPresenter
|
||||
'name' => $ticket->serviceQueue->name,
|
||||
'prefix' => $ticket->serviceQueue->prefix,
|
||||
'color' => $ticket->serviceQueue->color,
|
||||
'routing_mode' => $ticket->serviceQueue->routingMode(),
|
||||
] : null,
|
||||
'counter' => $ticket->counter ? [
|
||||
'uuid' => $ticket->counter->uuid,
|
||||
'name' => $ticket->counter->name,
|
||||
] : null,
|
||||
'counter' => $serving ? self::serializePoint($serving) : null,
|
||||
'assigned_counter' => $assigned ? self::serializePoint($assigned) : null,
|
||||
'destination' => $point?->displayDestination(),
|
||||
'staff_display_name' => $point?->staff_display_name,
|
||||
'announcement_text' => self::announcementText($ticket, $point),
|
||||
'track_url' => $includeQr ? route('qms.mobile.show', $ticket->qr_token) : null,
|
||||
'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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -31,10 +31,25 @@ class VoiceAnnouncementService
|
||||
{
|
||||
$templates = config('qms.announcement_templates');
|
||||
$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(
|
||||
[':ticket', ':counter'],
|
||||
[$ticket->ticket_number, $counter->name],
|
||||
[$ticket->ticket_number, $destination],
|
||||
$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');
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -96,7 +96,12 @@
|
||||
<p class="qms-display__ticket-number" x-text="item.ticket_number"></p>
|
||||
<div class="qms-display__destination">
|
||||
<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>
|
||||
</article>
|
||||
|
||||
@@ -5,6 +5,7 @@ use App\Http\Controllers\Api\AppointmentController;
|
||||
use App\Http\Controllers\Api\BranchController;
|
||||
use App\Http\Controllers\Api\ConsoleApiController;
|
||||
use App\Http\Controllers\Api\CounterController;
|
||||
use App\Http\Controllers\Api\DepartmentController;
|
||||
use App\Http\Controllers\Api\DeviceHeartbeatController;
|
||||
use App\Http\Controllers\Api\IntegrationController;
|
||||
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::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::post('/queues', [ServiceQueueController::class, 'store'])->name('api.service.queues.store');
|
||||
Route::patch('/queues/{serviceQueue}', [ServiceQueueController::class, 'update'])->name('api.service.queues.update');
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user