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:
@@ -12,7 +12,8 @@ Full standard: `docs/specialty-module-design-standard.md`.
|
||||
|
||||
- Specialty modules are **native Care extensions**, not standalone apps.
|
||||
- Design **workflow first** (stages, queues, payment checkpoints, completion), then screens.
|
||||
- Reuse Care engines: Workflow, Queue, Billing, Appointments, Documents, Permissions, Reports.
|
||||
- Reuse Care engines: Workflow, **Care Queue Engine (in-app)**, Billing, Appointments, Documents, Permissions, Reports.
|
||||
- Do **not** depend on Ladill Queue for Care or specialty queues (see `docs/care-queue-engine-and-specialty-plan.md`).
|
||||
- Use **identical primary actions** (Call Next, Start Consultation, Order Test, Prescribe, Generate Invoice, Discharge, …).
|
||||
- Prefer extending **`Visit`** as the clinical episode unit before inventing a separate Episode model.
|
||||
- Contribute to one **patient timeline** — do not create parallel charts.
|
||||
@@ -20,7 +21,7 @@ Full standard: `docs/specialty-module-design-standard.md`.
|
||||
|
||||
## Current reality
|
||||
|
||||
Today specialties are a thin layer: catalog activate → departments/queue stubs → generic `specialty/show` waiting list. Do **not** build deep per-specialty UIs until the shared specialty shell exists (KPI dashboard, patient header, left nav Overview/Visits/History/Module Tabs, billing catalog hooks).
|
||||
Today specialties are a thin layer and clinical call-next still adapts to Ladill Queue via `QueueClient`. Program direction: replace that with the Care Queue Engine, then build the shared specialty shell on it. Do **not** deepen per-specialty UIs on the remote Queue adapter.
|
||||
|
||||
## When adding or deepening a module
|
||||
|
||||
|
||||
+4
-1
@@ -26,7 +26,10 @@ SESSION_LIFETIME=1440
|
||||
CACHE_STORE=redis
|
||||
QUEUE_CONNECTION=database
|
||||
|
||||
# Ladill Queue service API (in-app service queue console)
|
||||
# Care Queue Engine: native (in-app, default) | remote (legacy Ladill Queue HTTP)
|
||||
CARE_QUEUE_DRIVER=native
|
||||
|
||||
# Legacy Ladill Queue API (only when CARE_QUEUE_DRIVER=remote)
|
||||
QUEUE_API_URL=https://queue.ladill.com/api/v1
|
||||
QUEUE_API_KEY_CARE=
|
||||
|
||||
|
||||
@@ -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 !== []
|
||||
) {
|
||||
|
||||
@@ -394,6 +394,8 @@ return [
|
||||
],
|
||||
|
||||
'queue' => [
|
||||
// native = Care Queue Engine (in-app). remote = Ladill Queue HTTP (legacy cutover).
|
||||
'driver' => env('CARE_QUEUE_DRIVER', 'native'),
|
||||
'api_url' => env('QUEUE_API_URL', 'https://queue.ladill.com/api/v1'),
|
||||
'api_key' => env('QUEUE_API_KEY_CARE'),
|
||||
],
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('care_service_queues', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->uuid('uuid')->unique();
|
||||
$table->string('owner_ref', 64)->index();
|
||||
$table->foreignId('organization_id')->constrained('care_organizations')->cascadeOnDelete();
|
||||
$table->foreignId('branch_id')->constrained('care_branches')->cascadeOnDelete();
|
||||
$table->string('context', 64);
|
||||
$table->string('name');
|
||||
$table->string('prefix', 8);
|
||||
$table->string('routing_mode', 32)->default('shared_pool');
|
||||
$table->string('external_key')->nullable()->index();
|
||||
$table->unsignedInteger('next_number')->default(1);
|
||||
$table->boolean('is_active')->default(true);
|
||||
$table->timestamps();
|
||||
|
||||
$table->unique(['organization_id', 'branch_id', 'context']);
|
||||
});
|
||||
|
||||
Schema::create('care_service_points', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->uuid('uuid')->unique();
|
||||
$table->string('owner_ref', 64)->index();
|
||||
$table->foreignId('organization_id')->constrained('care_organizations')->cascadeOnDelete();
|
||||
$table->foreignId('service_queue_id')->constrained('care_service_queues')->cascadeOnDelete();
|
||||
$table->string('name');
|
||||
$table->string('destination')->nullable();
|
||||
$table->string('kind', 32)->default('default');
|
||||
$table->unsignedBigInteger('ref_id')->nullable();
|
||||
$table->string('staff_ref')->nullable();
|
||||
$table->string('staff_display_name')->nullable();
|
||||
$table->string('external_key')->nullable()->index();
|
||||
$table->boolean('is_active')->default(true);
|
||||
$table->timestamps();
|
||||
|
||||
$table->unique(['service_queue_id', 'external_key']);
|
||||
});
|
||||
|
||||
Schema::create('care_queue_tickets', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->uuid('uuid')->unique();
|
||||
$table->string('owner_ref', 64)->index();
|
||||
$table->foreignId('organization_id')->constrained('care_organizations')->cascadeOnDelete();
|
||||
$table->foreignId('service_queue_id')->constrained('care_service_queues')->cascadeOnDelete();
|
||||
$table->foreignId('service_point_id')->nullable()->constrained('care_service_points')->nullOnDelete();
|
||||
$table->string('ticket_number', 32);
|
||||
$table->string('status', 32)->default('waiting')->index();
|
||||
$table->string('priority', 32)->default('walk_in');
|
||||
$table->string('source', 32)->default('api');
|
||||
$table->string('customer_name')->nullable();
|
||||
$table->string('customer_phone')->nullable();
|
||||
$table->string('care_entity', 32)->nullable();
|
||||
$table->string('care_entity_uuid', 36)->nullable()->index();
|
||||
$table->unsignedBigInteger('care_entity_id')->nullable();
|
||||
$table->unsignedBigInteger('visit_id')->nullable();
|
||||
$table->json('metadata')->nullable();
|
||||
$table->timestamp('called_at')->nullable();
|
||||
$table->timestamp('serving_at')->nullable();
|
||||
$table->timestamp('completed_at')->nullable();
|
||||
$table->timestamps();
|
||||
|
||||
$table->index(['service_queue_id', 'status']);
|
||||
$table->index(['service_point_id', 'status']);
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('care_queue_tickets');
|
||||
Schema::dropIfExists('care_service_points');
|
||||
Schema::dropIfExists('care_service_queues');
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,288 @@
|
||||
# Care Queue Engine + Specialty Modules — Unified Plan
|
||||
|
||||
**Status:** Phase 1 in progress
|
||||
**Date:** 2026-07-18
|
||||
**Repos:** ladill-care (primary), ladill-queue (healthcare removal)
|
||||
**Related:** `docs/specialty-module-design-standard.md`
|
||||
|
||||
---
|
||||
|
||||
## Phase 1 notes (implementation)
|
||||
|
||||
- Config: `CARE_QUEUE_DRIVER=native` (default) | `remote` (legacy Ladill Queue HTTP)
|
||||
- Tables: `care_service_queues`, `care_service_points`, `care_queue_tickets`
|
||||
- Service: `App\Services\Care\CareQueueEngine`
|
||||
- Bridge / provisioner route to native when driver is `native`; PHPUnit sets `remote` so legacy Http::fake tests keep working
|
||||
- Exit criterion: Care Pro demo issues / call-next / complete with `QUEUE_API_*` unset
|
||||
|
||||
---
|
||||
|
||||
## 1. Decision
|
||||
|
||||
| Decision | Choice |
|
||||
| --- | --- |
|
||||
| Care waiting lines / call-next / service points | **Built into Ladill Care** as a first-party **Care Queue Engine** |
|
||||
| Ladill Queue dependency for Care | **Removed** — Care never requires Queue API keys or Pro “Queue integration” |
|
||||
| Healthcare in Ladill Queue | **Removed** — Queue becomes general-purpose multi-industry QMS only |
|
||||
| Specialty modules | Built **on top of** Care Queue Engine + Visit/workflow/billing — not on Ladill Queue |
|
||||
|
||||
**Product framing**
|
||||
|
||||
- **Ladill Care** = clinical ops (patients, visits, specialties, in-app queues, billing).
|
||||
- **Ladill Queue** = generic ticket QMS (banks, retail, hospitality, …) with **no clinical/Care packaging**.
|
||||
|
||||
---
|
||||
|
||||
## 2. Goals & non-goals
|
||||
|
||||
### Goals
|
||||
|
||||
1. One in-app Care queue substrate used by consultation, lab, pharmacy, billing, Emergency, Blood Bank, and all specialty modules.
|
||||
2. Specialty modules follow the design standard (shell, workflow, queues, billing) without a second queue product.
|
||||
3. Clean cutover: existing Care tenants keep working without Ladill Queue.
|
||||
4. Queue product remains valuable for non-healthcare industries after healthcare surfaces are stripped.
|
||||
|
||||
### Non-goals (this program)
|
||||
|
||||
- Rebuilding displays/kiosks/voice as feature-parity with Queue Day 1 (phase later if needed).
|
||||
- Shared database between Care and Queue.
|
||||
- Keeping a permanent dual-write to Ladill Queue.
|
||||
|
||||
---
|
||||
|
||||
## 3. Current state (why this plan)
|
||||
|
||||
Today Care is a **remote adapter** to Queue:
|
||||
|
||||
- `QueueClient` → `CareQueueBridge` / `CareQueueProvisioner`
|
||||
- Clinical entities mirror `queue_ticket_*` fields; ticket **truth** lives in Queue
|
||||
- Specialty activate provisions Queue stubs over HTTP
|
||||
- Pro feature flag `queue_integration` + `QUEUE_API_KEY_CARE`
|
||||
|
||||
Specialty modules are a **thin layer** (catalog → departments → waiting list page). The specialty design standard needs a real local queue engine — building that *and* keeping Ladill Queue would double work.
|
||||
|
||||
---
|
||||
|
||||
## 4. Target architecture
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ Ladill Care │
|
||||
│ Patients · Visits · Consultations · Lab · Pharmacy · Bills │
|
||||
│ │
|
||||
│ ┌────────────────────────────────────────────────────────┐ │
|
||||
│ │ Care Queue Engine (in-app) │ │
|
||||
│ │ ServiceQueue · ServicePoint · Ticket · Call-next │ │
|
||||
│ │ Contexts: consultation, lab, pharmacy, billing, │ │
|
||||
│ │ specialty:{key}, emergency, blood_bank │ │
|
||||
│ └────────────────────────────────────────────────────────┘ │
|
||||
│ ▲ │
|
||||
│ │ │
|
||||
│ ┌────────────────────────────────────────────────────────┐ │
|
||||
│ │ Specialty Module Shell (shared) │ │
|
||||
│ │ Dashboard · Workflow · Clinical workspace · Billing │ │
|
||||
│ └────────────────────────────────────────────────────────┘ │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
|
||||
Ladill Queue (decoupled)
|
||||
Generic QMS only — no healthcare package, no Care API path
|
||||
```
|
||||
|
||||
**Care Queue Engine owns**
|
||||
|
||||
- Queues per `(organization, branch, context)`
|
||||
- Service points (rooms / desks / chairs) with `shared_pool` vs `assigned_only`
|
||||
- Tickets (number, status, priority, wait time, assigned staff/point)
|
||||
- Call next / recall / skip / complete / transfer (Care-flavored)
|
||||
- Sync from clinical waiters (appointments, Rx, labs, bills) into tickets
|
||||
|
||||
**Specialty modules own**
|
||||
|
||||
- Clinical forms, stage maps, KPIs, documents, alerts
|
||||
- Mapping specialty stages → Care Queue contexts/points
|
||||
- Service catalog → Billing Engine line items
|
||||
|
||||
**Visit** remains the episode unit (per specialty standard). Tickets attach to visit/appointment/entity — not a parallel patient chart.
|
||||
|
||||
---
|
||||
|
||||
## 5. Phased program (one roadmap)
|
||||
|
||||
### Phase 0 — Spec freeze (short)
|
||||
|
||||
Deliverables:
|
||||
|
||||
- This plan accepted
|
||||
- Care Queue Engine domain model + API sketch (tables, statuses, contexts)
|
||||
- Specialty shell IA locked (from design standard)
|
||||
- Queue healthcare removal checklist signed off
|
||||
|
||||
Exit: engineering can implement without re-litigating product boundaries.
|
||||
|
||||
---
|
||||
|
||||
### Phase 1 — Care Queue Engine (MVP)
|
||||
|
||||
Replace remote QMS for Care’s core lines **inside Care**.
|
||||
|
||||
**Data model (illustrative)**
|
||||
|
||||
- `care_service_queues` — org, branch, context, name, prefix, active
|
||||
- `care_service_points` — queue_id, name, kind (practitioner/desk), staff/member/practitioner refs, routing mode
|
||||
- `care_queue_tickets` — queue_id, point_id?, entity type/id, visit_id?, ticket_number, status, priority, called_at, completed_at, metadata
|
||||
- Keep or migrate mirror columns on appointments/Rx/labs/bills to point at **local** ticket IDs (drop remote UUIDs over time)
|
||||
|
||||
**Engine capabilities (MVP)**
|
||||
|
||||
- Issue ticket when patient enters a wait line
|
||||
- Call next (shared pool + assigned_only for doctors)
|
||||
- Serve / complete / recall
|
||||
- Backfill waiters without tickets
|
||||
- UI: queue ops on consultation / lab / pharmacy / billing pages (same UX patterns as today, local backend)
|
||||
|
||||
**Adapter rewrite**
|
||||
|
||||
- `CareQueueBridge` talks to **local** engine (same method surface where possible)
|
||||
- `QueueClient` / HTTP provisioner become dead code → delete
|
||||
- Remove `queue_integration` Pro flag; Care queues are **always available** on Pro clinical modules (or Free waiting list + Pro ticket numbers — product call: recommend tickets on Free for consultation only, full multi-context on Pro)
|
||||
|
||||
**Migration**
|
||||
|
||||
- Tenants with remote Queue UUIDs: import open tickets if API still up **or** re-issue local tickets from current waiters (prefer re-issue for simplicity; archive remote history)
|
||||
- Demo seed: stop calling Queue HTTP entirely
|
||||
|
||||
Exit: Care Pro demo runs call-next / tickets with `QUEUE_API_*` unset.
|
||||
|
||||
---
|
||||
|
||||
### Phase 2 — Specialty shell on Care Queue Engine
|
||||
|
||||
Implement the specialty design standard **once**, for all modules.
|
||||
|
||||
1. **Shared specialty shell** — KPI dashboard, left nav (Overview / Visits / History / Module Tabs), standard patient header, action panel
|
||||
2. **Visit-backed specialty visits** — not appointment-only waiting lists
|
||||
3. **Patient header + timeline components** — reused on patient chart
|
||||
4. **Specialty stage maps** in config (per module) → Care Queue contexts/points (e.g. Dentistry: Waiting → Chair → Procedure → Recovery)
|
||||
5. **Service catalog → billing** — activate module seeds billable services; invoices via Billing Engine
|
||||
6. **Emergency & Blood Bank** — already `default_on_paid_plans`; first modules to get full shell + local queues (templates for the rest)
|
||||
|
||||
Exit: Emergency (and ideally Dentistry) demonstrate the full standard on the in-app engine; remaining modules are catalog + stage map + forms on the same shell.
|
||||
|
||||
---
|
||||
|
||||
### Phase 3 — Roll specialties onto the shell
|
||||
|
||||
Per-module work becomes incremental:
|
||||
|
||||
1. Stage map + queue points
|
||||
2. Clinical forms / structured records
|
||||
3. Service catalog items
|
||||
4. Alerts + documents + KPIs
|
||||
5. Role matrix
|
||||
|
||||
Order suggestion: **Emergency → Blood Bank → Dentistry → Radiology → Maternity → …** (volume / demo story first).
|
||||
|
||||
Do **not** invent new queue backends per specialty.
|
||||
|
||||
---
|
||||
|
||||
### Phase 4 — Remove healthcare from Ladill Queue
|
||||
|
||||
Parallelizable after Phase 1 starts; finish once Care no longer calls Queue.
|
||||
|
||||
**Remove from Queue**
|
||||
|
||||
- `industry_packages.healthcare` and related templates/priorities
|
||||
- Care integration: `QUEUE_API_KEY_CARE`, `care_enabled`, Care provision defaults, Care settings UI, Care tests
|
||||
- Clinic DemoWorld branding (Ridge Clinic / Care staff) → neutral industry demos
|
||||
- Care-oriented marketing copy (“Care & Frontdesk API” → generic integrations)
|
||||
|
||||
**Keep in Queue**
|
||||
|
||||
- Generic QMS, other industry packages, Frontdesk/POS/CRM integration pattern, assigned_only routing as generic features
|
||||
|
||||
**Suite docs / DemoWorld**
|
||||
|
||||
- Stop listing Queue as required for Care Pro
|
||||
- Care staff demos no longer include `queue` app by default (optional)
|
||||
|
||||
Exit: Queue CI green without healthcare/Care paths; Care CI green without Queue env vars.
|
||||
|
||||
---
|
||||
|
||||
### Phase 5 — Hard delete Care→Queue code & polish
|
||||
|
||||
- Delete `QueueClient`, remote provisioner paths, sync artisan that hits Queue API
|
||||
- Remove env keys from Care `.env.example`
|
||||
- Optional later: Care TV display / voice (only if product needs; not blocking)
|
||||
|
||||
---
|
||||
|
||||
## 6. How this maps to the specialty design standard
|
||||
|
||||
| Standard requirement | Provided by |
|
||||
| --- | --- |
|
||||
| Workflow-driven stages | Specialty stage maps + Visit workflow |
|
||||
| Queues per specialty | **Care Queue Engine** contexts/points |
|
||||
| Episode / visit | Care `Visit` |
|
||||
| Timeline | Shared timeline component (Phase 2) |
|
||||
| Dashboard KPIs | Specialty shell (Phase 2) |
|
||||
| Billing | Care Billing Engine + specialty service catalog |
|
||||
| Call Next / Start Consultation / … | Shared actions → engine + clinical controllers |
|
||||
| No standalone specialty apps | One shell, many module configs |
|
||||
|
||||
---
|
||||
|
||||
## 7. Risks & mitigations
|
||||
|
||||
| Risk | Mitigation |
|
||||
| --- | --- |
|
||||
| Ticket history only in Queue DB | Prefer re-issue open waiters locally; don’t block on full history import |
|
||||
| Demo Pro broken mid-cutover | Feature flag `care_native_queue` during Phase 1; default on for new seeds |
|
||||
| Specialty work forks before engine ready | Freeze deep specialty UIs until Phase 1 MVP + Phase 2 shell |
|
||||
| Queue customers using healthcare package | Migrate them to Care **or** keep package until date-certain deprecation notice |
|
||||
| Scope creep (kiosks/voice) | Explicitly Phase 5+ / optional |
|
||||
|
||||
---
|
||||
|
||||
## 8. Success criteria
|
||||
|
||||
1. Care runs full clinical + specialty queues with **no** Ladill Queue dependency.
|
||||
2. Specialty modules share one shell and one queue engine.
|
||||
3. Ladill Queue has **no** healthcare package or Care integration surface.
|
||||
4. Docs/demo accounts reflect the split.
|
||||
5. Tests: Care queue + specialty shell coverage; Queue industry suite without healthcare.
|
||||
|
||||
---
|
||||
|
||||
## 9. Suggested ownership & sequencing
|
||||
|
||||
```
|
||||
Week focus (indicative):
|
||||
|
||||
[Phase 0] Spec freeze
|
||||
↓
|
||||
[Phase 1] Care Queue Engine MVP ──┬── parallel: Queue healthcare deprecation notice
|
||||
↓ │
|
||||
[Phase 2] Specialty shell │
|
||||
↓ │
|
||||
[Phase 3] Emergency / Blood Bank / Dentistry on shell
|
||||
↓ ↓
|
||||
[Phase 4] Strip healthcare from Queue (hard remove)
|
||||
↓
|
||||
[Phase 5] Delete Care remote Queue adapter
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 10. Immediate next implementation slice
|
||||
|
||||
When ready to code (first PR):
|
||||
|
||||
1. Migrations for `care_service_queues`, `care_service_points`, `care_queue_tickets`
|
||||
2. `CareNativeQueue` service with issue / call-next / complete
|
||||
3. Point `CareQueueBridge` at native service behind a config flag
|
||||
4. Consultation queue page uses native tickets when flag on
|
||||
5. Document flag + cutover in this file’s changelog
|
||||
|
||||
Specialty shell follows once native consultation queue is green in demo.
|
||||
@@ -220,3 +220,13 @@ Specialty modules today are a **thin Pro enablement layer**, not the full clinic
|
||||
5. **Queue + clinical workspace entry points** — stage-aware queue → open consultation/assessment with specialty context.
|
||||
|
||||
Do not implement dentistry, maternity, cardiology, etc. as separate apps until that shared shell and Visit/queue/billing bridges exist.
|
||||
|
||||
---
|
||||
|
||||
## Care Queue Engine (product decision)
|
||||
|
||||
Ladill Care uses an **in-app Care Queue Engine**, fully decoupled from Ladill Queue.
|
||||
|
||||
- Specialty queues, call-next, and service points are Care-native.
|
||||
- Ladill Queue remains a general-purpose QMS; healthcare / Care packaging is removed from Queue.
|
||||
- Unified program plan: [`docs/care-queue-engine-and-specialty-plan.md`](care-queue-engine-and-specialty-plan.md).
|
||||
|
||||
@@ -30,6 +30,7 @@
|
||||
<env name="DB_URL" value=""/>
|
||||
<env name="MAIL_MAILER" value="array"/>
|
||||
<env name="QUEUE_CONNECTION" value="sync"/>
|
||||
<env name="CARE_QUEUE_DRIVER" value="remote"/>
|
||||
<env name="SESSION_DRIVER" value="array"/>
|
||||
<env name="PULSE_ENABLED" value="false"/>
|
||||
<env name="TELESCOPE_ENABLED" value="false"/>
|
||||
|
||||
@@ -237,16 +237,16 @@
|
||||
</div>
|
||||
</x-settings.card>
|
||||
|
||||
<x-settings.card title="Integrations" description="Connect Ladill Queue so existing Care lists (patient queue, pharmacy, lab, billing, specialties) issue ticket numbers and support Call next → Serve → Complete. Requires Care Pro or Enterprise — and Care must also be enabled in Queue settings.">
|
||||
<x-settings.card title="Service queues" description="Enable in-app ticket numbers and Call next → Serve → Complete on patient queue, pharmacy, lab, billing, and specialty modules. Requires Care Pro or Enterprise. (Legacy remote Ladill Queue is only used when CARE_QUEUE_DRIVER=remote.)">
|
||||
@if (! empty($canUseQueueIntegration))
|
||||
<label class="flex items-center gap-2 text-sm">
|
||||
<input type="checkbox" name="queue_integration_enabled" value="1" @checked(data_get($organization->settings, 'queue_integration_enabled'))>
|
||||
Enable Ladill Queue integration
|
||||
Enable service queues
|
||||
</label>
|
||||
@else
|
||||
<div class="rounded-xl border border-amber-100 bg-amber-50 px-4 py-3 text-sm text-amber-900">
|
||||
<p class="font-medium">Queue integration is a Pro feature</p>
|
||||
<p class="mt-1 text-amber-800">Free includes core patient records, appointments & consults, and basic workflows. Upgrade to connect service queues and counters.</p>
|
||||
<p class="font-medium">Service queues are a Pro feature</p>
|
||||
<p class="mt-1 text-amber-800">Free includes core patient records, appointments & consults, and basic workflows. Upgrade for ticket numbers and call-next on clinical lines.</p>
|
||||
<a href="{{ route('care.pro.index') }}" class="mt-3 inline-flex text-sm font-semibold text-indigo-700 hover:text-indigo-900">View Care Pro plans →</a>
|
||||
</div>
|
||||
<input type="hidden" name="queue_integration_enabled" value="0">
|
||||
|
||||
@@ -0,0 +1,238 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature;
|
||||
|
||||
use App\Http\Middleware\EnsurePlatformSession;
|
||||
use App\Models\Appointment;
|
||||
use App\Models\Branch;
|
||||
use App\Models\CareQueueTicket;
|
||||
use App\Models\CareServiceQueue;
|
||||
use App\Models\Member;
|
||||
use App\Models\Organization;
|
||||
use App\Models\Patient;
|
||||
use App\Models\Practitioner;
|
||||
use App\Models\User;
|
||||
use App\Services\Care\AppointmentService;
|
||||
use App\Services\Care\CareQueueBridge;
|
||||
use App\Services\Care\CareQueueContexts;
|
||||
use App\Services\Care\CareQueueEngine;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Tests\TestCase;
|
||||
|
||||
class CareNativeQueueTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
protected User $owner;
|
||||
|
||||
protected Organization $organization;
|
||||
|
||||
protected Branch $branch;
|
||||
|
||||
protected Patient $patient;
|
||||
|
||||
protected Practitioner $practitioner;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
$this->withoutMiddleware(EnsurePlatformSession::class);
|
||||
|
||||
config([
|
||||
'care.queue.driver' => 'native',
|
||||
'care.queue.api_url' => '',
|
||||
'care.queue.api_key' => '',
|
||||
]);
|
||||
|
||||
$this->owner = User::create([
|
||||
'public_id' => 'native-queue-owner',
|
||||
'name' => 'Owner',
|
||||
'email' => 'native-queue@example.com',
|
||||
]);
|
||||
|
||||
$this->organization = Organization::create([
|
||||
'owner_ref' => $this->owner->public_id,
|
||||
'name' => 'Native Queue Clinic',
|
||||
'slug' => 'native-queue-clinic',
|
||||
'settings' => [
|
||||
'onboarded' => true,
|
||||
'facility_type' => 'clinic',
|
||||
'plan' => 'pro',
|
||||
'plan_expires_at' => now()->addMonth()->toIso8601String(),
|
||||
'queue_integration_enabled' => true,
|
||||
],
|
||||
]);
|
||||
|
||||
Member::create([
|
||||
'owner_ref' => $this->owner->public_id,
|
||||
'organization_id' => $this->organization->id,
|
||||
'user_ref' => $this->owner->public_id,
|
||||
'role' => 'hospital_admin',
|
||||
]);
|
||||
|
||||
$this->branch = Branch::create([
|
||||
'owner_ref' => $this->owner->public_id,
|
||||
'organization_id' => $this->organization->id,
|
||||
'name' => 'Main',
|
||||
'is_active' => true,
|
||||
]);
|
||||
|
||||
$this->practitioner = Practitioner::create([
|
||||
'owner_ref' => $this->owner->public_id,
|
||||
'organization_id' => $this->organization->id,
|
||||
'branch_id' => $this->branch->id,
|
||||
'name' => 'Dr. Native',
|
||||
'room' => 'Room 1',
|
||||
'is_active' => true,
|
||||
]);
|
||||
|
||||
$this->patient = Patient::create([
|
||||
'owner_ref' => $this->owner->public_id,
|
||||
'organization_id' => $this->organization->id,
|
||||
'branch_id' => $this->branch->id,
|
||||
'patient_number' => 'P-N1',
|
||||
'first_name' => 'Kwame',
|
||||
'last_name' => 'Mensah',
|
||||
'gender' => 'male',
|
||||
'date_of_birth' => '1988-05-01',
|
||||
]);
|
||||
}
|
||||
|
||||
public function test_bridge_enabled_without_queue_api_keys(): void
|
||||
{
|
||||
$this->assertTrue(app(CareQueueBridge::class)->isEnabled($this->organization));
|
||||
$this->assertSame('', (string) config('care.queue.api_key'));
|
||||
}
|
||||
|
||||
public function test_check_in_issues_native_consultation_ticket(): void
|
||||
{
|
||||
Http::fake();
|
||||
|
||||
$appointment = Appointment::create([
|
||||
'owner_ref' => $this->owner->public_id,
|
||||
'organization_id' => $this->organization->id,
|
||||
'branch_id' => $this->branch->id,
|
||||
'patient_id' => $this->patient->id,
|
||||
'practitioner_id' => $this->practitioner->id,
|
||||
'type' => Appointment::TYPE_SCHEDULED,
|
||||
'status' => Appointment::STATUS_SCHEDULED,
|
||||
'scheduled_at' => now()->addHour(),
|
||||
]);
|
||||
|
||||
$result = app(AppointmentService::class)->checkIn(
|
||||
$appointment->fresh(['organization', 'patient', 'practitioner']),
|
||||
$this->owner->public_id,
|
||||
$this->owner->public_id,
|
||||
);
|
||||
|
||||
$this->assertNotNull($result->queue_ticket_uuid);
|
||||
$this->assertSame('waiting', $result->queue_ticket_status);
|
||||
$this->assertStringStartsWith('C', (string) $result->queue_ticket_number);
|
||||
$this->assertDatabaseHas('care_queue_tickets', [
|
||||
'uuid' => $result->queue_ticket_uuid,
|
||||
'ticket_number' => $result->queue_ticket_number,
|
||||
'status' => CareQueueTicket::STATUS_WAITING,
|
||||
'care_entity' => 'appointment',
|
||||
'care_entity_id' => $result->id,
|
||||
]);
|
||||
$this->assertDatabaseHas('care_service_queues', [
|
||||
'organization_id' => $this->organization->id,
|
||||
'branch_id' => $this->branch->id,
|
||||
'context' => CareQueueContexts::CONSULTATION,
|
||||
]);
|
||||
Http::assertNothingSent();
|
||||
}
|
||||
|
||||
public function test_call_next_serve_and_complete_without_http(): void
|
||||
{
|
||||
Http::fake();
|
||||
|
||||
$appointment = Appointment::create([
|
||||
'owner_ref' => $this->owner->public_id,
|
||||
'organization_id' => $this->organization->id,
|
||||
'branch_id' => $this->branch->id,
|
||||
'patient_id' => $this->patient->id,
|
||||
'practitioner_id' => $this->practitioner->id,
|
||||
'type' => Appointment::TYPE_WALK_IN,
|
||||
'status' => Appointment::STATUS_SCHEDULED,
|
||||
'scheduled_at' => now(),
|
||||
]);
|
||||
|
||||
app(AppointmentService::class)->checkIn(
|
||||
$appointment->fresh(['organization', 'patient', 'practitioner']),
|
||||
$this->owner->public_id,
|
||||
$this->owner->public_id,
|
||||
);
|
||||
|
||||
$bridge = app(CareQueueBridge::class);
|
||||
$called = $bridge->callNext(
|
||||
$this->organization->fresh(),
|
||||
CareQueueContexts::CONSULTATION,
|
||||
(int) $this->branch->id,
|
||||
null,
|
||||
$this->practitioner->id,
|
||||
);
|
||||
|
||||
$this->assertNotNull($called['ticket']);
|
||||
$this->assertSame('called', $called['ticket']['status']);
|
||||
$this->assertNotNull($called['entity']);
|
||||
$this->assertSame('called', $called['entity']->queue_ticket_status);
|
||||
|
||||
$serving = $bridge->serve($this->organization->fresh(), $called['entity']->fresh());
|
||||
$this->assertSame('serving', $serving['status'] ?? null);
|
||||
|
||||
$completed = $bridge->complete($this->organization->fresh(), $called['entity']->fresh());
|
||||
$this->assertSame('completed', $completed['status'] ?? null);
|
||||
|
||||
$this->assertDatabaseHas('care_queue_tickets', [
|
||||
'uuid' => $called['ticket']['uuid'],
|
||||
'status' => CareQueueTicket::STATUS_COMPLETED,
|
||||
]);
|
||||
Http::assertNothingSent();
|
||||
}
|
||||
|
||||
public function test_engine_shared_pool_call_next_claims_unassigned_ticket(): void
|
||||
{
|
||||
$queue = CareServiceQueue::create([
|
||||
'owner_ref' => $this->owner->public_id,
|
||||
'organization_id' => $this->organization->id,
|
||||
'branch_id' => $this->branch->id,
|
||||
'context' => CareQueueContexts::PHARMACY,
|
||||
'name' => 'Pharmacy',
|
||||
'prefix' => 'P',
|
||||
'routing_mode' => 'shared_pool',
|
||||
'external_key' => CareQueueContexts::queueExternalKey(CareQueueContexts::PHARMACY, $this->branch->id),
|
||||
]);
|
||||
|
||||
$point = $queue->points()->create([
|
||||
'owner_ref' => $this->owner->public_id,
|
||||
'organization_id' => $this->organization->id,
|
||||
'name' => 'Pharmacy counter',
|
||||
'destination' => 'Pharmacy counter',
|
||||
'kind' => 'default',
|
||||
'external_key' => CareQueueContexts::pointExternalKey(
|
||||
CareQueueContexts::PHARMACY,
|
||||
$this->branch->id,
|
||||
'default',
|
||||
0,
|
||||
),
|
||||
]);
|
||||
|
||||
$engine = app(CareQueueEngine::class);
|
||||
$issued = $engine->issueTicket($this->organization, [
|
||||
'service_queue_id' => $queue->uuid,
|
||||
'customer_name' => 'Kwame Mensah',
|
||||
'priority' => 'walk_in',
|
||||
'metadata' => ['care_entity' => 'prescription'],
|
||||
]);
|
||||
|
||||
$this->assertSame('waiting', $issued['status']);
|
||||
$this->assertNull($issued['assigned_counter']);
|
||||
|
||||
$called = $engine->callNext($this->organization, $queue->uuid, $point->uuid);
|
||||
$this->assertNotNull($called);
|
||||
$this->assertSame('called', $called['status']);
|
||||
$this->assertSame($point->uuid, $called['assigned_counter']['uuid'] ?? null);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user