Deploy Ladill Care / deploy (push) Failing after 57s
Call-next and recall queue announcements for lobby screens without Ladill Queue. Co-authored-by: Cursor <cursoragent@cursor.com>
295 lines
11 KiB
PHP
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();
|
|
}
|
|
}
|