Files
ladill-care/app/Services/Care/CareQueueEngine.php
T
isaaccladandCursor 03befd1e7f
Deploy Ladill Care / deploy (push) Failing after 57s
Add Care waiting-area TV displays with browser TTS voice.
Call-next and recall queue announcements for lobby screens without Ladill Queue.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-18 11:23:15 +00:00

295 lines
11 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',
fn ($q) => $q->where('service_point_id', $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;
}
$waiting->forceFill([
'service_point_id' => $point->id,
'status' => CareQueueTicket::STATUS_CALLED,
'called_at' => now(),
])->save();
$waiting->setRelation('servicePoint', $point);
$waiting->setRelation('serviceQueue', $queue);
try {
app(CareVoiceAnnouncementService::class)->announceCalled($waiting, $point);
} catch (Throwable $e) {
Log::warning('Care voice announcement failed on call-next', [
'ticket_id' => $waiting->id,
'error' => $e->getMessage(),
]);
}
return $this->toApiArray($waiting);
});
}
/**
* @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();
}
}