Add in-app Care Queue Engine (Phase 1 MVP).
Deploy Ladill Care / deploy (push) Successful in 1m28s

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:
isaacclad
2026-07-18 09:54:06 +00:00
co-authored by Cursor
parent ad04f0cec8
commit 6cf5a24019
19 changed files with 1355 additions and 55 deletions
@@ -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) {
+70
View File
@@ -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');
}
}
+54
View File
@@ -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');
}
}
+58
View File
@@ -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');
}
}
+47 -13
View File
@@ -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;
+266
View File
@@ -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();
}
}
+117 -10
View File
@@ -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>>
+11 -4
View File
@@ -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>
*/
+38 -17
View File
@@ -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 !== []
) {