Issue, call-next, serve, and complete tickets locally so Care Pro no longer needs QUEUE_API_* when CARE_QUEUE_DRIVER=native (default). Remote Ladill Queue remains available for cutover; PHPUnit keeps the remote driver for existing fakes. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,266 @@
|
||||
<?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 InvalidArgumentException;
|
||||
|
||||
/**
|
||||
* 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);
|
||||
|
||||
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();
|
||||
}
|
||||
|
||||
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();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user