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);
|
||||
|
||||
$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']) {
|
||||
|
||||
+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) => [
|
||||
'ticket_number' => $t->ticket_number,
|
||||
'queue_name' => $t->serviceQueue?->name,
|
||||
'counter' => $t->counter?->name,
|
||||
])
|
||||
->map(function (Ticket $t) {
|
||||
$point = $t->counter ?? $t->assignedCounter;
|
||||
|
||||
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();
|
||||
|
||||
$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,
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user