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')
->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,
];
}
}
+34 -8
View File
@@ -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);
$ticket = $this->engine->issueTicket($queue, $owner, [
...$validated,
'source' => $validated['source'] ?? 'api',
'actor_ref' => $owner,
'metadata' => $validated['metadata'] ?? null,
]);
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']) {