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:
@@ -144,7 +144,7 @@ class SettingsController extends Controller
|
||||
$settings[AppointmentPatientNotifier::SETTING_VIDEO_SMS] = $request->boolean('notify_video_scheduled_sms');
|
||||
$settings[AppointmentPatientNotifier::SETTING_VIDEO_EMAIL] = $request->boolean('notify_video_scheduled_email');
|
||||
|
||||
if ($wantsQueue && $queue->configured()) {
|
||||
if ($wantsQueue && config('care.queue.driver', 'native') !== 'native' && $queue->configured()) {
|
||||
$branch = Branch::owned($owner)->where('organization_id', $organization->id)->orderBy('name')->first();
|
||||
try {
|
||||
$queue->enable($owner, $organization->name, $branch?->name);
|
||||
@@ -186,9 +186,9 @@ class SettingsController extends Controller
|
||||
}
|
||||
}
|
||||
|
||||
// Queue department sync + waiting-ticket backfill can take minutes over HTTP.
|
||||
// Never block the settings response on it — run after the redirect goes out.
|
||||
if ($wantsQueue && $queue->configured()) {
|
||||
// Queue department sync + waiting-ticket backfill — run after the redirect.
|
||||
$queueReady = config('care.queue.driver', 'native') === 'native' || $queue->configured();
|
||||
if ($wantsQueue && $queueReady) {
|
||||
$organizationId = (int) $organization->id;
|
||||
$backfillTickets = $queueJustEnabled;
|
||||
dispatch(function () use ($organizationId, $owner, $backfillTickets) {
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use App\Models\Concerns\BelongsToOwner;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
class CareQueueTicket extends Model
|
||||
{
|
||||
use BelongsToOwner;
|
||||
|
||||
public const STATUS_WAITING = 'waiting';
|
||||
|
||||
public const STATUS_CALLED = 'called';
|
||||
|
||||
public const STATUS_SERVING = 'serving';
|
||||
|
||||
public const STATUS_COMPLETED = 'completed';
|
||||
|
||||
public const STATUS_SKIPPED = 'skipped';
|
||||
|
||||
public const STATUS_CANCELLED = 'cancelled';
|
||||
|
||||
protected $table = 'care_queue_tickets';
|
||||
|
||||
protected $fillable = [
|
||||
'uuid', 'owner_ref', 'organization_id', 'service_queue_id', 'service_point_id',
|
||||
'ticket_number', 'status', 'priority', 'source', 'customer_name', 'customer_phone',
|
||||
'care_entity', 'care_entity_uuid', 'care_entity_id', 'visit_id', 'metadata',
|
||||
'called_at', 'serving_at', 'completed_at',
|
||||
];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'metadata' => 'array',
|
||||
'called_at' => 'datetime',
|
||||
'serving_at' => 'datetime',
|
||||
'completed_at' => 'datetime',
|
||||
'care_entity_id' => 'integer',
|
||||
'visit_id' => 'integer',
|
||||
];
|
||||
}
|
||||
|
||||
protected static function booted(): void
|
||||
{
|
||||
static::creating(function (CareQueueTicket $ticket) {
|
||||
if (! $ticket->uuid) {
|
||||
$ticket->uuid = (string) Str::uuid();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public function organization(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Organization::class, 'organization_id');
|
||||
}
|
||||
|
||||
public function serviceQueue(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(CareServiceQueue::class, 'service_queue_id');
|
||||
}
|
||||
|
||||
public function servicePoint(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(CareServicePoint::class, 'service_point_id');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use App\Models\Concerns\BelongsToOwner;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
class CareServicePoint extends Model
|
||||
{
|
||||
use BelongsToOwner;
|
||||
|
||||
protected $table = 'care_service_points';
|
||||
|
||||
protected $fillable = [
|
||||
'uuid', 'owner_ref', 'organization_id', 'service_queue_id',
|
||||
'name', 'destination', 'kind', 'ref_id', 'staff_ref',
|
||||
'staff_display_name', 'external_key', 'is_active',
|
||||
];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'is_active' => 'boolean',
|
||||
'ref_id' => 'integer',
|
||||
];
|
||||
}
|
||||
|
||||
protected static function booted(): void
|
||||
{
|
||||
static::creating(function (CareServicePoint $point) {
|
||||
if (! $point->uuid) {
|
||||
$point->uuid = (string) Str::uuid();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public function organization(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Organization::class, 'organization_id');
|
||||
}
|
||||
|
||||
public function serviceQueue(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(CareServiceQueue::class, 'service_queue_id');
|
||||
}
|
||||
|
||||
public function tickets(): HasMany
|
||||
{
|
||||
return $this->hasMany(CareQueueTicket::class, 'service_point_id');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use App\Models\Concerns\BelongsToOwner;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
class CareServiceQueue extends Model
|
||||
{
|
||||
use BelongsToOwner;
|
||||
|
||||
protected $table = 'care_service_queues';
|
||||
|
||||
protected $fillable = [
|
||||
'uuid', 'owner_ref', 'organization_id', 'branch_id', 'context',
|
||||
'name', 'prefix', 'routing_mode', 'external_key', 'next_number', 'is_active',
|
||||
];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'is_active' => 'boolean',
|
||||
'next_number' => 'integer',
|
||||
];
|
||||
}
|
||||
|
||||
protected static function booted(): void
|
||||
{
|
||||
static::creating(function (CareServiceQueue $queue) {
|
||||
if (! $queue->uuid) {
|
||||
$queue->uuid = (string) Str::uuid();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public function organization(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Organization::class, 'organization_id');
|
||||
}
|
||||
|
||||
public function branch(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Branch::class, 'branch_id');
|
||||
}
|
||||
|
||||
public function points(): HasMany
|
||||
{
|
||||
return $this->hasMany(CareServicePoint::class, 'service_queue_id');
|
||||
}
|
||||
|
||||
public function tickets(): HasMany
|
||||
{
|
||||
return $this->hasMany(CareQueueTicket::class, 'service_queue_id');
|
||||
}
|
||||
}
|
||||
@@ -17,24 +17,35 @@ use Illuminate\Http\Client\RequestException;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
/**
|
||||
* Bridges Care workflow entities to Ladill Queue tickets for department
|
||||
* service points (not a single shared consultation counter per branch).
|
||||
* Bridges Care workflow entities to service-queue tickets (native Care Queue
|
||||
* Engine by default; optional legacy Ladill Queue HTTP when driver=remote).
|
||||
*/
|
||||
class CareQueueBridge
|
||||
{
|
||||
public function __construct(
|
||||
protected QueueClient $queue,
|
||||
protected CareQueueEngine $engine,
|
||||
protected CareQueueProvisioner $provisioner,
|
||||
protected PlanService $plans,
|
||||
protected SpecialtyModuleService $specialties,
|
||||
protected WorkflowQueueGate $workflowGate,
|
||||
) {}
|
||||
|
||||
public function usesNative(): bool
|
||||
{
|
||||
return config('care.queue.driver', 'native') === 'native';
|
||||
}
|
||||
|
||||
public function isEnabled(Organization $organization): bool
|
||||
{
|
||||
return $this->plans->canUseQueueIntegration($organization)
|
||||
&& (bool) data_get($organization->settings, 'queue_integration_enabled')
|
||||
&& $this->queue->configured();
|
||||
$enabled = $this->plans->canUseQueueIntegration($organization)
|
||||
&& (bool) data_get($organization->settings, 'queue_integration_enabled');
|
||||
|
||||
if (! $enabled) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return $this->usesNative() || $this->queue->configured();
|
||||
}
|
||||
|
||||
public function contextForAppointment(Organization $organization, Appointment $appointment): string
|
||||
@@ -339,16 +350,15 @@ class CareQueueBridge
|
||||
return ['ticket' => null, 'entity' => null, 'resources' => $resources];
|
||||
}
|
||||
|
||||
$owner = (string) $organization->owner_ref;
|
||||
$callResources = [
|
||||
'queue_uuid' => $resources['queue_uuid'],
|
||||
'counter_uuid' => $point['counter_uuid'],
|
||||
];
|
||||
|
||||
$ticket = $this->attemptCallNext($owner, $callResources);
|
||||
$ticket = $this->attemptCallNext($organization, $callResources);
|
||||
if (! $ticket) {
|
||||
$this->syncMissingTickets($organization, $context, $branchId, 25);
|
||||
$ticket = $this->attemptCallNext($owner, $callResources);
|
||||
$ticket = $this->attemptCallNext($organization, $callResources);
|
||||
}
|
||||
|
||||
if (! $ticket) {
|
||||
@@ -367,11 +377,19 @@ class CareQueueBridge
|
||||
* @param array{queue_uuid?: ?string, counter_uuid?: ?string} $resources
|
||||
* @return array<string, mixed>|null
|
||||
*/
|
||||
protected function attemptCallNext(string $owner, array $resources): ?array
|
||||
protected function attemptCallNext(Organization $organization, array $resources): ?array
|
||||
{
|
||||
try {
|
||||
if ($this->usesNative()) {
|
||||
return $this->engine->callNext(
|
||||
$organization,
|
||||
(string) $resources['queue_uuid'],
|
||||
(string) $resources['counter_uuid'],
|
||||
);
|
||||
}
|
||||
|
||||
return $this->queue->callNext(
|
||||
$owner,
|
||||
(string) $organization->owner_ref,
|
||||
(string) $resources['queue_uuid'],
|
||||
(string) $resources['counter_uuid'],
|
||||
);
|
||||
@@ -380,6 +398,12 @@ class CareQueueBridge
|
||||
'message' => $e->getMessage(),
|
||||
]);
|
||||
|
||||
return null;
|
||||
} catch (\Throwable $e) {
|
||||
Log::warning('care.queue_call_next_failed', [
|
||||
'message' => $e->getMessage(),
|
||||
]);
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -573,6 +597,10 @@ class CareQueueBridge
|
||||
}
|
||||
|
||||
try {
|
||||
if ($this->usesNative()) {
|
||||
return $this->engine->issueTicket($organization, $payload);
|
||||
}
|
||||
|
||||
return $this->queue->issueTicket($owner, $payload);
|
||||
} catch (\Throwable $e) {
|
||||
Log::warning('care.queue_issue_failed', [
|
||||
@@ -622,9 +650,15 @@ class CareQueueBridge
|
||||
}
|
||||
|
||||
try {
|
||||
$ticket = $this->queue->ticketAction((string) $organization->owner_ref, $uuid, [
|
||||
'action' => $action,
|
||||
]);
|
||||
if ($this->usesNative()) {
|
||||
$ticket = $this->engine->ticketAction($organization, $uuid, [
|
||||
'action' => $action,
|
||||
]);
|
||||
} else {
|
||||
$ticket = $this->queue->ticketAction((string) $organization->owner_ref, $uuid, [
|
||||
'action' => $action,
|
||||
]);
|
||||
}
|
||||
$this->applyTicket($entity, $ticket);
|
||||
|
||||
return $ticket;
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,8 @@
|
||||
namespace App\Services\Care;
|
||||
|
||||
use App\Models\Branch;
|
||||
use App\Models\CareServicePoint;
|
||||
use App\Models\CareServiceQueue;
|
||||
use App\Models\Member;
|
||||
use App\Models\Organization;
|
||||
use App\Models\Practitioner;
|
||||
@@ -10,13 +12,9 @@ use App\Services\Queue\QueueClient;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
/**
|
||||
* Idempotently provisions Ladill Queue departments, queues, and service points
|
||||
* (counters) from Care staff / branch configuration.
|
||||
*
|
||||
* Ladill Queue v2 industry packages define the Healthcare template in Queue Core.
|
||||
* When Care integration is enabled, Queue skips materializing healthcare stages
|
||||
* and this provisioner remains the source of truth for clinical service points
|
||||
* (per-doctor rooms, pharmacy desks, lab desks, cashiers).
|
||||
* Idempotently provisions service queues and service points from Care staff /
|
||||
* branch configuration. Native driver writes Care Queue Engine tables; remote
|
||||
* driver syncs to Ladill Queue over HTTP (legacy cutover).
|
||||
*/
|
||||
class CareQueueProvisioner
|
||||
{
|
||||
@@ -25,11 +23,21 @@ class CareQueueProvisioner
|
||||
protected PlanService $plans,
|
||||
) {}
|
||||
|
||||
public function usesNative(): bool
|
||||
{
|
||||
return config('care.queue.driver', 'native') === 'native';
|
||||
}
|
||||
|
||||
public function shouldProvision(Organization $organization): bool
|
||||
{
|
||||
return $this->plans->canUseQueueIntegration($organization)
|
||||
&& (bool) data_get($organization->settings, 'queue_integration_enabled')
|
||||
&& $this->queue->configured();
|
||||
$enabled = $this->plans->canUseQueueIntegration($organization)
|
||||
&& (bool) data_get($organization->settings, 'queue_integration_enabled');
|
||||
|
||||
if (! $enabled) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return $this->usesNative() || $this->queue->configured();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -113,6 +121,10 @@ class CareQueueProvisioner
|
||||
'synced' => false,
|
||||
];
|
||||
|
||||
if ($this->usesNative()) {
|
||||
return $this->provisionBranchDepartmentNative($organization, $owner, $context, $meta, $branch, $stub);
|
||||
}
|
||||
|
||||
try {
|
||||
$this->ensureBranchName($owner, $branch->name);
|
||||
|
||||
@@ -185,6 +197,101 @@ class CareQueueProvisioner
|
||||
return $stub;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $meta
|
||||
* @param array<string, mixed> $stub
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
protected function provisionBranchDepartmentNative(
|
||||
Organization $organization,
|
||||
string $owner,
|
||||
string $context,
|
||||
array $meta,
|
||||
Branch $branch,
|
||||
array $stub,
|
||||
): array {
|
||||
try {
|
||||
$queue = CareServiceQueue::query()->updateOrCreate(
|
||||
[
|
||||
'organization_id' => $organization->id,
|
||||
'branch_id' => $branch->id,
|
||||
'context' => $context,
|
||||
],
|
||||
[
|
||||
'owner_ref' => $owner,
|
||||
'name' => $meta['name'],
|
||||
'prefix' => $meta['prefix'],
|
||||
'routing_mode' => $meta['routing_mode'],
|
||||
'external_key' => $stub['queue_external_key'],
|
||||
'is_active' => true,
|
||||
],
|
||||
);
|
||||
|
||||
$stub['queue_uuid'] = $queue->uuid;
|
||||
$stub['department_id'] = null;
|
||||
|
||||
$points = $this->buildPointsForContext($organization, $context, $branch, $stub);
|
||||
$syncedPoints = [];
|
||||
$seenExternalKeys = [];
|
||||
|
||||
foreach ($points as $point) {
|
||||
$externalKey = (string) ($point['external_key'] ?? '');
|
||||
if ($externalKey === '') {
|
||||
continue;
|
||||
}
|
||||
$seenExternalKeys[] = $externalKey;
|
||||
|
||||
$row = CareServicePoint::query()->updateOrCreate(
|
||||
[
|
||||
'service_queue_id' => $queue->id,
|
||||
'external_key' => $externalKey,
|
||||
],
|
||||
[
|
||||
'owner_ref' => $owner,
|
||||
'organization_id' => $organization->id,
|
||||
'name' => (string) ($point['name'] ?? $point['destination'] ?? 'Desk'),
|
||||
'destination' => $point['destination'] ?? null,
|
||||
'kind' => (string) ($point['kind'] ?? 'default'),
|
||||
'ref_id' => isset($point['ref_id']) ? (int) $point['ref_id'] : null,
|
||||
'staff_ref' => $point['staff_ref'] ?? null,
|
||||
'staff_display_name' => $point['staff_display_name'] ?? null,
|
||||
'is_active' => true,
|
||||
],
|
||||
);
|
||||
|
||||
$point['counter_uuid'] = $row->uuid;
|
||||
$point['synced'] = true;
|
||||
$syncedPoints[] = $point;
|
||||
}
|
||||
|
||||
// Soft-deactivate points no longer in the staff roster.
|
||||
CareServicePoint::query()
|
||||
->where('service_queue_id', $queue->id)
|
||||
->when(
|
||||
$seenExternalKeys !== [],
|
||||
fn ($q) => $q->whereNotIn('external_key', $seenExternalKeys),
|
||||
fn ($q) => $q->whereRaw('1 = 1'),
|
||||
)
|
||||
->update(['is_active' => false]);
|
||||
|
||||
$stub['points'] = $syncedPoints;
|
||||
$first = collect($syncedPoints)->first(fn ($p) => ! empty($p['counter_uuid']));
|
||||
if ($first) {
|
||||
$stub['counter_uuid'] = $first['counter_uuid'];
|
||||
$stub['counter_external_key'] = $first['external_key'];
|
||||
}
|
||||
$stub['synced'] = ! empty($stub['queue_uuid']) && collect($syncedPoints)->isNotEmpty();
|
||||
} catch (\Throwable $e) {
|
||||
Log::warning('care.native_queue_department_provision_failed', [
|
||||
'context' => $context,
|
||||
'branch_id' => $branch->id,
|
||||
'message' => $e->getMessage(),
|
||||
]);
|
||||
}
|
||||
|
||||
return $stub;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $stub
|
||||
* @return list<array<string, mixed>>
|
||||
|
||||
@@ -734,12 +734,13 @@ class DemoTenantSeeder
|
||||
|
||||
private function seedSpecialtyModules(Organization $organization, string $ownerRef): void
|
||||
{
|
||||
// Pause Queue HTTP during per-module activate so Enterprise demos (many
|
||||
// branches × specialties) do not spend minutes on remote timeouts.
|
||||
// Remote Queue HTTP during per-module activate can hang Enterprise demos.
|
||||
// Native Care Queue Engine is local — keep integration on so desks provision.
|
||||
$organization = $organization->fresh();
|
||||
$settings = $organization->settings ?? [];
|
||||
$queueEnabled = (bool) data_get($settings, 'queue_integration_enabled');
|
||||
if ($queueEnabled) {
|
||||
$native = config('care.queue.driver', 'native') === 'native';
|
||||
if ($queueEnabled && ! $native) {
|
||||
data_set($settings, 'queue_integration_enabled', false);
|
||||
$organization->update(['settings' => $settings]);
|
||||
}
|
||||
@@ -753,12 +754,18 @@ class DemoTenantSeeder
|
||||
}
|
||||
}
|
||||
|
||||
if ($queueEnabled) {
|
||||
if ($queueEnabled && ! $native) {
|
||||
$organization = $organization->fresh();
|
||||
$settings = $organization->settings ?? [];
|
||||
data_set($settings, 'queue_integration_enabled', true);
|
||||
$organization->update(['settings' => $settings]);
|
||||
// Skip CareQueueProvisioner here — same Queue HTTP storm as ticket sync.
|
||||
} elseif ($queueEnabled && $native) {
|
||||
try {
|
||||
app(CareQueueProvisioner::class)->provisionOrganization($organization->fresh());
|
||||
} catch (\Throwable) {
|
||||
// Demo seed continues; pages lazy-provision on use.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -65,6 +65,26 @@ class ServiceQueuePresenter
|
||||
|
||||
$stubs = $this->stubsForContext($organization, $context, $branchScope);
|
||||
|
||||
if (config('care.queue.driver', 'native') === 'native') {
|
||||
$counters = $this->nativeCountersFromStubs($stubs, $context);
|
||||
$stubCounterUuid = collect($stubs)
|
||||
->map(fn ($s) => (string) ($s['counter_uuid'] ?? ''))
|
||||
->first(fn ($uuid) => $uuid !== '');
|
||||
$counterUuid = $preferredCounterUuid
|
||||
?: ($stubCounterUuid ?: '')
|
||||
?: (string) ($counters[0]['uuid'] ?? '');
|
||||
|
||||
return [
|
||||
'enabled' => true,
|
||||
'counters' => $counters,
|
||||
'counter_uuid' => $counterUuid !== '' ? $counterUuid : null,
|
||||
'state' => null,
|
||||
'stubs' => $stubs,
|
||||
'error' => null,
|
||||
'context' => $context,
|
||||
];
|
||||
}
|
||||
|
||||
if (! $this->queue->configured()) {
|
||||
return [
|
||||
'enabled' => true,
|
||||
@@ -227,6 +247,44 @@ class ServiceQueuePresenter
|
||||
return $filtered->values()->all();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<array<string, mixed>> $stubs
|
||||
* @return list<array<string, mixed>>
|
||||
*/
|
||||
protected function nativeCountersFromStubs(array $stubs, string $context): array
|
||||
{
|
||||
$counters = [];
|
||||
foreach ($stubs as $stub) {
|
||||
$branchName = $stub['branch_name'] ?? null;
|
||||
$queueName = $stub['name'] ?? $context;
|
||||
$points = is_array($stub['points'] ?? null) ? $stub['points'] : [];
|
||||
|
||||
foreach ($points as $point) {
|
||||
$uuid = (string) ($point['counter_uuid'] ?? '');
|
||||
if ($uuid === '') {
|
||||
continue;
|
||||
}
|
||||
$counters[] = [
|
||||
'uuid' => $uuid,
|
||||
'name' => (string) ($point['name'] ?? $point['destination'] ?? 'Desk'),
|
||||
'branch' => $branchName,
|
||||
'queues' => [['name' => $queueName]],
|
||||
];
|
||||
}
|
||||
|
||||
if ($points === [] && ! empty($stub['counter_uuid'])) {
|
||||
$counters[] = [
|
||||
'uuid' => (string) $stub['counter_uuid'],
|
||||
'name' => (string) $queueName,
|
||||
'branch' => $branchName,
|
||||
'queues' => [['name' => $queueName]],
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
return $counters;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<string>
|
||||
*/
|
||||
|
||||
@@ -320,23 +320,43 @@ class SpecialtyModuleService
|
||||
|
||||
$organization->update(['settings' => $settings]);
|
||||
|
||||
if (data_get($organization->fresh()->settings, 'queue_integration_enabled') && $this->queue->configured()) {
|
||||
try {
|
||||
$synced = $this->queue->syncSpecialtyQueueStubs($ownerRef, $queueStubs);
|
||||
$settings = $organization->fresh()->settings ?? [];
|
||||
$provisioning = is_array($settings['specialty_module_provisioning'] ?? null)
|
||||
? $settings['specialty_module_provisioning']
|
||||
: [];
|
||||
$provisioning[$key] = array_merge($provisioning[$key] ?? [], [
|
||||
'queues' => $synced,
|
||||
]);
|
||||
$settings['specialty_module_provisioning'] = $provisioning;
|
||||
$organization->update(['settings' => $settings]);
|
||||
} catch (\Throwable $e) {
|
||||
Log::warning('specialty_module.queue_sync_failed', [
|
||||
'key' => $key,
|
||||
'message' => $e->getMessage(),
|
||||
]);
|
||||
$fresh = $organization->fresh();
|
||||
if (data_get($fresh?->settings, 'queue_integration_enabled')) {
|
||||
if (config('care.queue.driver', 'native') === 'native') {
|
||||
$provisioner = app(CareQueueProvisioner::class);
|
||||
$branches = Branch::owned($ownerRef)
|
||||
->where('organization_id', $organization->id)
|
||||
->where('is_active', true)
|
||||
->pluck('id');
|
||||
foreach ($branches as $branchId) {
|
||||
try {
|
||||
$provisioner->ensure($fresh, $key, (int) $branchId);
|
||||
} catch (\Throwable $e) {
|
||||
Log::warning('specialty_module.native_queue_provision_failed', [
|
||||
'key' => $key,
|
||||
'branch_id' => $branchId,
|
||||
'message' => $e->getMessage(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
} elseif ($this->queue->configured()) {
|
||||
try {
|
||||
$synced = $this->queue->syncSpecialtyQueueStubs($ownerRef, $queueStubs);
|
||||
$settings = $fresh->settings ?? [];
|
||||
$provisioning = is_array($settings['specialty_module_provisioning'] ?? null)
|
||||
? $settings['specialty_module_provisioning']
|
||||
: [];
|
||||
$provisioning[$key] = array_merge($provisioning[$key] ?? [], [
|
||||
'queues' => $synced,
|
||||
]);
|
||||
$settings['specialty_module_provisioning'] = $provisioning;
|
||||
$organization->update(['settings' => $settings]);
|
||||
} catch (\Throwable $e) {
|
||||
Log::warning('specialty_module.queue_sync_failed', [
|
||||
'key' => $key,
|
||||
'message' => $e->getMessage(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -372,6 +392,7 @@ class SpecialtyModuleService
|
||||
|
||||
if (
|
||||
data_get($settings, 'queue_integration_enabled')
|
||||
&& config('care.queue.driver', 'native') !== 'native'
|
||||
&& $this->queue->configured()
|
||||
&& $queues !== []
|
||||
) {
|
||||
|
||||
Reference in New Issue
Block a user