Files
ladill-care/app/Services/Care/CareQueueEngine.php
T
isaaccladandCursor c123097f3c
Deploy Ladill Care / deploy (push) Successful in 51s
Fix Call next by advancing Care waiters and reclaiming desk tickets.
Call next now picks the next board appointment, issues or reclaims its ticket onto the calling desk, and still fires display/voice announcements — so patients are not left stuck when tickets sit on the wrong point after sync-on-GET was removed.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-18 20:18:52 +00:00

354 lines
13 KiB
PHP

<?php
namespace App\Services\Care;
use App\Models\CareQueueTicket;
use App\Models\CareServicePoint;
use App\Models\CareServiceQueue;
use App\Models\Organization;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
use InvalidArgumentException;
use Throwable;
/**
* In-app Care Queue Engine — issue / call-next / serve / complete / recall.
* Returns ticket arrays shaped like the former Ladill Queue API for CareQueueBridge.
*/
class CareQueueEngine
{
/**
* @param array{
* service_queue_id: string,
* customer_name?: ?string,
* customer_phone?: ?string,
* priority?: string,
* source?: string,
* metadata?: array<string, mixed>,
* assigned_counter_id?: ?string
* } $payload
* @return array<string, mixed>
*/
public function issueTicket(Organization $organization, array $payload): array
{
$queueUuid = (string) ($payload['service_queue_id'] ?? '');
if ($queueUuid === '') {
throw new InvalidArgumentException('service_queue_id is required.');
}
return DB::transaction(function () use ($organization, $payload, $queueUuid) {
/** @var CareServiceQueue|null $queue */
$queue = CareServiceQueue::query()
->where('organization_id', $organization->id)
->where('uuid', $queueUuid)
->lockForUpdate()
->first();
if (! $queue || ! $queue->is_active) {
throw new InvalidArgumentException('Service queue not found.');
}
$point = null;
$counterUuid = (string) ($payload['assigned_counter_id'] ?? '');
if ($counterUuid !== '') {
$point = CareServicePoint::query()
->where('organization_id', $organization->id)
->where('service_queue_id', $queue->id)
->where('uuid', $counterUuid)
->where('is_active', true)
->first();
if (! $point) {
throw new InvalidArgumentException('Service point not found.');
}
}
$seq = (int) $queue->next_number;
$queue->forceFill(['next_number' => $seq + 1])->save();
$ticketNumber = $queue->prefix.str_pad((string) $seq, 3, '0', STR_PAD_LEFT);
$metadata = is_array($payload['metadata'] ?? null) ? $payload['metadata'] : [];
$ticket = CareQueueTicket::create([
'owner_ref' => $organization->owner_ref,
'organization_id' => $organization->id,
'service_queue_id' => $queue->id,
'service_point_id' => $point?->id,
'ticket_number' => $ticketNumber,
'status' => CareQueueTicket::STATUS_WAITING,
'priority' => (string) ($payload['priority'] ?? 'walk_in'),
'source' => (string) ($payload['source'] ?? 'api'),
'customer_name' => $payload['customer_name'] ?? null,
'customer_phone' => $payload['customer_phone'] ?? null,
'care_entity' => $metadata['care_entity'] ?? null,
'care_entity_uuid' => $metadata['care_entity_uuid'] ?? null,
'care_entity_id' => isset($metadata['care_entity_id']) ? (int) $metadata['care_entity_id'] : null,
'visit_id' => isset($metadata['visit_id']) ? (int) $metadata['visit_id'] : null,
'metadata' => $metadata,
]);
$ticket->setRelation('servicePoint', $point);
$ticket->setRelation('serviceQueue', $queue);
return $this->toApiArray($ticket);
});
}
/**
* @return array<string, mixed>|null
*/
public function callNext(Organization $organization, string $queueUuid, string $counterUuid): ?array
{
return DB::transaction(function () use ($organization, $queueUuid, $counterUuid) {
/** @var CareServiceQueue|null $queue */
$queue = CareServiceQueue::query()
->where('organization_id', $organization->id)
->where('uuid', $queueUuid)
->lockForUpdate()
->first();
if (! $queue || ! $queue->is_active) {
return null;
}
/** @var CareServicePoint|null $point */
$point = CareServicePoint::query()
->where('organization_id', $organization->id)
->where('service_queue_id', $queue->id)
->where('uuid', $counterUuid)
->where('is_active', true)
->first();
if (! $point) {
return null;
}
$waiting = CareQueueTicket::query()
->where('service_queue_id', $queue->id)
->where('status', CareQueueTicket::STATUS_WAITING)
->when(
$queue->routing_mode === 'assigned_only',
// Claim desk tickets and unassigned waiters (null point) — never other desks.
fn ($q) => $q->where(function ($inner) use ($point) {
$inner->where('service_point_id', $point->id)
->orWhereNull('service_point_id');
}),
fn ($q) => $q->where(function ($inner) use ($point) {
$inner->whereNull('service_point_id')
->orWhere('service_point_id', $point->id);
}),
)
->orderByRaw("CASE priority WHEN 'appointment' THEN 0 WHEN 'walk_in' THEN 1 ELSE 2 END")
->orderBy('id')
->lockForUpdate()
->first();
if (! $waiting) {
return null;
}
return $this->markTicketCalled($waiting, $point, $queue);
});
}
/**
* Call a specific waiting ticket to a service point (Care board → ticket handoff).
* Reassigns the ticket when it was waiting on another desk or unassigned.
*
* @return array<string, mixed>|null
*/
public function callWaitingTicket(Organization $organization, string $ticketUuid, string $counterUuid): ?array
{
return DB::transaction(function () use ($organization, $ticketUuid, $counterUuid) {
/** @var CareQueueTicket|null $ticket */
$ticket = CareQueueTicket::query()
->where('organization_id', $organization->id)
->where('uuid', $ticketUuid)
->lockForUpdate()
->first();
if (! $ticket || $ticket->status !== CareQueueTicket::STATUS_WAITING) {
return null;
}
/** @var CareServiceQueue|null $queue */
$queue = CareServiceQueue::query()
->where('organization_id', $organization->id)
->whereKey($ticket->service_queue_id)
->lockForUpdate()
->first();
if (! $queue || ! $queue->is_active) {
return null;
}
/** @var CareServicePoint|null $point */
$point = CareServicePoint::query()
->where('organization_id', $organization->id)
->where('service_queue_id', $queue->id)
->where('uuid', $counterUuid)
->where('is_active', true)
->first();
if (! $point) {
return null;
}
return $this->markTicketCalled($ticket, $point, $queue);
});
}
/**
* @return array<string, mixed>
*/
protected function markTicketCalled(CareQueueTicket $ticket, CareServicePoint $point, CareServiceQueue $queue): array
{
$ticket->forceFill([
'service_point_id' => $point->id,
'status' => CareQueueTicket::STATUS_CALLED,
'called_at' => now(),
])->save();
$ticket->setRelation('servicePoint', $point);
$ticket->setRelation('serviceQueue', $queue);
try {
app(CareVoiceAnnouncementService::class)->announceCalled($ticket, $point);
} catch (Throwable $e) {
Log::warning('Care voice announcement failed on call-next', [
'ticket_id' => $ticket->id,
'error' => $e->getMessage(),
]);
}
return $this->toApiArray($ticket);
}
/**
* @param array{action: string} $payload
* @return array<string, mixed>
*/
public function ticketAction(Organization $organization, string $ticketUuid, array $payload): array
{
$action = (string) ($payload['action'] ?? '');
return DB::transaction(function () use ($organization, $ticketUuid, $action) {
/** @var CareQueueTicket|null $ticket */
$ticket = CareQueueTicket::query()
->where('organization_id', $organization->id)
->where('uuid', $ticketUuid)
->lockForUpdate()
->first();
if (! $ticket) {
throw new InvalidArgumentException('Ticket not found.');
}
$ticket->loadMissing('servicePoint', 'serviceQueue');
match ($action) {
'start' => $this->markServing($ticket),
'complete' => $this->markCompleted($ticket),
'recall' => $this->markCalled($ticket),
'skip' => $this->markSkipped($ticket),
'cancel' => $this->markCancelled($ticket),
default => throw new InvalidArgumentException("Unknown ticket action [{$action}]."),
};
return $this->toApiArray($ticket->fresh(['servicePoint', 'serviceQueue']));
});
}
/**
* @return array<string, mixed>
*/
public function toApiArray(CareQueueTicket $ticket): array
{
$point = $ticket->servicePoint;
return [
'uuid' => $ticket->uuid,
'ticket_number' => $ticket->ticket_number,
'status' => $ticket->status,
'priority' => $ticket->priority,
'source' => $ticket->source,
'customer_name' => $ticket->customer_name,
'customer_phone' => $ticket->customer_phone,
'metadata' => $ticket->metadata ?? [],
'assigned_counter' => $point ? [
'uuid' => $point->uuid,
'name' => $point->name,
'staff_display_name' => $point->staff_display_name,
'destination' => $point->destination,
] : null,
'counter' => $point ? [
'uuid' => $point->uuid,
'name' => $point->name,
] : null,
'destination' => $point?->destination,
'staff_display_name' => $point?->staff_display_name,
'called_at' => $ticket->called_at?->toIso8601String(),
'serving_at' => $ticket->serving_at?->toIso8601String(),
'completed_at' => $ticket->completed_at?->toIso8601String(),
];
}
protected function markServing(CareQueueTicket $ticket): void
{
$ticket->forceFill([
'status' => CareQueueTicket::STATUS_SERVING,
'serving_at' => $ticket->serving_at ?? now(),
'called_at' => $ticket->called_at ?? now(),
])->save();
}
protected function markCompleted(CareQueueTicket $ticket): void
{
$ticket->forceFill([
'status' => CareQueueTicket::STATUS_COMPLETED,
'completed_at' => now(),
'serving_at' => $ticket->serving_at ?? now(),
])->save();
}
protected function markCalled(CareQueueTicket $ticket): void
{
$ticket->forceFill([
'status' => CareQueueTicket::STATUS_CALLED,
'called_at' => now(),
])->save();
$point = $ticket->servicePoint;
if (! $point && $ticket->service_point_id) {
$ticket->loadMissing('servicePoint');
$point = $ticket->servicePoint;
}
if ($point) {
try {
app(CareVoiceAnnouncementService::class)->announceCalled($ticket, $point);
} catch (Throwable $e) {
Log::warning('Care voice announcement failed on recall', [
'ticket_id' => $ticket->id,
'error' => $e->getMessage(),
]);
}
}
}
protected function markSkipped(CareQueueTicket $ticket): void
{
$ticket->forceFill([
'status' => CareQueueTicket::STATUS_SKIPPED,
'completed_at' => now(),
])->save();
}
protected function markCancelled(CareQueueTicket $ticket): void
{
$ticket->forceFill([
'status' => CareQueueTicket::STATUS_CANCELLED,
'completed_at' => now(),
])->save();
}
}