Speed up Queue board loads by dropping sync and gate side effects.
Deploy Ladill Care / deploy (push) Successful in 1m22s
Deploy Ladill Care / deploy (push) Successful in 1m22s
Stop issuing missing tickets and refreshing financial gates on every GET; eager-load advances, cap board rows, and cache specialty department maps. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -61,37 +61,32 @@ class QueueController extends Controller
|
||||
}
|
||||
|
||||
if ($this->queueBridge->isEnabled($organization)) {
|
||||
$syncBranches = $lockToPractitioner
|
||||
? Practitioner::owned($owner)
|
||||
->whereIn('id', $practitionerScope ?: [0])
|
||||
->pluck('branch_id')
|
||||
->map(fn ($id) => (int) $id)
|
||||
->unique()
|
||||
->filter()
|
||||
->values()
|
||||
->all()
|
||||
: array_filter([$branchId]);
|
||||
|
||||
foreach ($syncBranches as $syncBranchId) {
|
||||
$this->queueBridge->syncMissingAppointmentTickets($organization, $syncBranchId, 100);
|
||||
}
|
||||
|
||||
// Ticket backfill belongs on check-in / Call next / care:queue-sync-tickets —
|
||||
// not on every board GET (can issue up to 100 tickets with provisioner work).
|
||||
if ($branchId > 0) {
|
||||
$queueIntegration = $this->queueBridge->listMeta($organization, $queueContext, $branchId);
|
||||
} elseif ($syncBranches !== []) {
|
||||
} elseif ($lockToPractitioner && $practitionerScope) {
|
||||
$firstBranch = (int) Practitioner::owned($owner)
|
||||
->whereIn('id', $practitionerScope)
|
||||
->value('branch_id');
|
||||
if ($firstBranch > 0) {
|
||||
$queueIntegration = $this->queueBridge->listMeta(
|
||||
$organization,
|
||||
$queueContext,
|
||||
(int) $syncBranches[0],
|
||||
$firstBranch,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$boardLimit = AppointmentService::QUEUE_BOARD_LIMIT;
|
||||
|
||||
if ($lockToPractitioner) {
|
||||
$queue = $this->appointments->queueForPractitioners(
|
||||
$owner,
|
||||
$practitionerScope ?? [],
|
||||
$branchId > 0 ? $branchId : null,
|
||||
$boardLimit,
|
||||
);
|
||||
$inConsultation = Appointment::owned($owner)
|
||||
->where('organization_id', $organization->id)
|
||||
@@ -99,10 +94,11 @@ class QueueController extends Controller
|
||||
->whereIn('practitioner_id', $practitionerScope ?: [0])
|
||||
->with(['patient', 'practitioner', 'consultation', 'visit', 'organization'])
|
||||
->orderBy('started_at')
|
||||
->limit($boardLimit)
|
||||
->get();
|
||||
} else {
|
||||
$queue = $branchId
|
||||
? $this->appointments->queue($owner, $branchId, $practitionerId)
|
||||
? $this->appointments->queue($owner, $branchId, $practitionerId, true, null, $boardLimit)
|
||||
: collect();
|
||||
$inConsultation = Appointment::owned($owner)
|
||||
->where('organization_id', $organization->id)
|
||||
@@ -111,6 +107,7 @@ class QueueController extends Controller
|
||||
->when($practitionerId, fn ($q) => $q->where('practitioner_id', $practitionerId))
|
||||
->with(['patient', 'practitioner', 'consultation', 'visit', 'organization'])
|
||||
->orderBy('started_at')
|
||||
->limit($boardLimit)
|
||||
->get();
|
||||
}
|
||||
|
||||
@@ -126,6 +123,36 @@ class QueueController extends Controller
|
||||
$todayQuery->where('branch_id', $branchId);
|
||||
}
|
||||
|
||||
$waitingCountQuery = Appointment::owned($owner)
|
||||
->where('organization_id', $organization->id)
|
||||
->whereIn('status', [Appointment::STATUS_WAITING, Appointment::STATUS_CHECKED_IN]);
|
||||
if ($lockToPractitioner) {
|
||||
$waitingCountQuery->whereIn('practitioner_id', $practitionerScope ?: [0]);
|
||||
if ($branchId > 0) {
|
||||
$waitingCountQuery->where('branch_id', $branchId);
|
||||
}
|
||||
} elseif ($branchId) {
|
||||
$waitingCountQuery->where('branch_id', $branchId);
|
||||
if ($practitionerId) {
|
||||
$waitingCountQuery->where(function ($q) use ($practitionerId) {
|
||||
$q->where('practitioner_id', $practitionerId)->orWhereNull('practitioner_id');
|
||||
});
|
||||
}
|
||||
} else {
|
||||
$waitingCountQuery->whereRaw('1 = 0');
|
||||
}
|
||||
|
||||
$inCareCountQuery = Appointment::owned($owner)
|
||||
->where('organization_id', $organization->id)
|
||||
->where('status', Appointment::STATUS_IN_CONSULTATION);
|
||||
if ($lockToPractitioner) {
|
||||
$inCareCountQuery->whereIn('practitioner_id', $practitionerScope ?: [0]);
|
||||
} else {
|
||||
$inCareCountQuery
|
||||
->when($branchId, fn ($q) => $q->where('branch_id', $branchId))
|
||||
->when($practitionerId, fn ($q) => $q->where('practitioner_id', $practitionerId));
|
||||
}
|
||||
|
||||
$doneQuery = Appointment::owned($owner)
|
||||
->where('organization_id', $organization->id)
|
||||
->where('status', Appointment::STATUS_COMPLETED)
|
||||
@@ -145,11 +172,13 @@ class QueueController extends Controller
|
||||
->get();
|
||||
|
||||
$heroStats = [
|
||||
'waiting' => $queue->count(),
|
||||
'in_consultation' => $inConsultation->count(),
|
||||
'waiting' => $waitingCountQuery->count(),
|
||||
'in_consultation' => $inCareCountQuery->count(),
|
||||
'today' => $todayQuery->count(),
|
||||
];
|
||||
|
||||
$specialtyDepartmentMap = $this->queueBridge->specialtyDepartmentMap($organization);
|
||||
|
||||
return view('care.queue.index', [
|
||||
'organization' => $organization,
|
||||
'branches' => $branches,
|
||||
@@ -174,6 +203,7 @@ class QueueController extends Controller
|
||||
'queueIntegration' => $queueIntegration,
|
||||
'queueContext' => $queueContext,
|
||||
'specialtyLabel' => $specialtyLabel,
|
||||
'specialtyDepartmentMap' => $specialtyDepartmentMap,
|
||||
]);
|
||||
}
|
||||
|
||||
|
||||
@@ -623,7 +623,7 @@ class SpecialtyModuleController extends Controller
|
||||
$practitionerScope !== null,
|
||||
fn ($q) => $q->whereIn('practitioner_id', $practitionerScope ?: [0]),
|
||||
)
|
||||
->with(['patient', 'practitioner', 'department', 'visit'])
|
||||
->with(['patient', 'practitioner', 'department', 'visit.currentStageAdvance.stage'])
|
||||
->orderBy('queue_position')
|
||||
->orderBy('checked_in_at')
|
||||
->limit(50)
|
||||
|
||||
@@ -94,6 +94,22 @@ class Visit extends Model
|
||||
return $this->hasMany(VisitStageAdvance::class, 'visit_id');
|
||||
}
|
||||
|
||||
/**
|
||||
* Active or blocked advance for the visit's current workflow stage.
|
||||
* Prefer this for eager-loaded queue board filtering (avoids N+1 currentAdvance queries).
|
||||
*/
|
||||
public function currentStageAdvance(): HasOne
|
||||
{
|
||||
return $this->hasOne(VisitStageAdvance::class, 'visit_id')
|
||||
->ofMany(
|
||||
['id' => 'max'],
|
||||
fn ($query) => $query->whereIn('status', [
|
||||
VisitStageAdvance::STATUS_ACTIVE,
|
||||
VisitStageAdvance::STATUS_BLOCKED,
|
||||
]),
|
||||
);
|
||||
}
|
||||
|
||||
public function financialObligations(): HasMany
|
||||
{
|
||||
return $this->hasMany(FinancialObligation::class, 'visit_id');
|
||||
|
||||
@@ -83,6 +83,9 @@ class AppointmentService
|
||||
return $query->paginate((int) ($filters['per_page'] ?? 20))->withQueryString();
|
||||
}
|
||||
|
||||
/** Max waiters / in-care rows rendered on the patient-flow board. */
|
||||
public const QUEUE_BOARD_LIMIT = 50;
|
||||
|
||||
/**
|
||||
* @param list<int>|null $practitionerIds
|
||||
* @return Collection<int, Appointment>
|
||||
@@ -93,20 +96,29 @@ class AppointmentService
|
||||
?int $practitionerId = null,
|
||||
bool $includeUnassigned = true,
|
||||
?array $practitionerIds = null,
|
||||
?int $limit = null,
|
||||
): Collection {
|
||||
if ($practitionerIds !== null) {
|
||||
return $this->queueForPractitioners($ownerRef, $practitionerIds, $branchId > 0 ? $branchId : null);
|
||||
return $this->queueForPractitioners(
|
||||
$ownerRef,
|
||||
$practitionerIds,
|
||||
$branchId > 0 ? $branchId : null,
|
||||
$limit,
|
||||
);
|
||||
}
|
||||
|
||||
$this->repairDuplicateQueuePositions($ownerRef, $branchId);
|
||||
|
||||
$limit = $limit ?? self::QUEUE_BOARD_LIMIT;
|
||||
|
||||
$query = Appointment::owned($ownerRef)
|
||||
->where('branch_id', $branchId)
|
||||
->whereIn('status', [Appointment::STATUS_WAITING, Appointment::STATUS_CHECKED_IN])
|
||||
->with(['patient', 'practitioner', 'visit', 'organization'])
|
||||
->with($this->queueEagerLoads())
|
||||
->orderBy('queue_position')
|
||||
->orderBy('waiting_at')
|
||||
->orderBy('checked_in_at');
|
||||
->orderBy('checked_in_at')
|
||||
->limit($limit);
|
||||
|
||||
if ($practitionerId !== null) {
|
||||
$query->where(function (Builder $q) use ($practitionerId, $includeUnassigned) {
|
||||
@@ -124,7 +136,7 @@ class AppointmentService
|
||||
return true;
|
||||
}
|
||||
|
||||
return $this->workflowGate->canRelease(
|
||||
return $this->workflowGate->isReleasedForDisplay(
|
||||
$organization,
|
||||
$appointment->visit,
|
||||
$this->queueBridge->contextForAppointment($organization, $appointment),
|
||||
@@ -139,20 +151,27 @@ class AppointmentService
|
||||
* @param list<int> $practitionerIds
|
||||
* @return Collection<int, Appointment>
|
||||
*/
|
||||
public function queueForPractitioners(string $ownerRef, array $practitionerIds, ?int $branchId = null): Collection
|
||||
{
|
||||
public function queueForPractitioners(
|
||||
string $ownerRef,
|
||||
array $practitionerIds,
|
||||
?int $branchId = null,
|
||||
?int $limit = null,
|
||||
): Collection {
|
||||
if ($practitionerIds === []) {
|
||||
return new Collection;
|
||||
}
|
||||
|
||||
$limit = $limit ?? self::QUEUE_BOARD_LIMIT;
|
||||
|
||||
$query = Appointment::owned($ownerRef)
|
||||
->whereIn('practitioner_id', $practitionerIds)
|
||||
->whereIn('status', [Appointment::STATUS_WAITING, Appointment::STATUS_CHECKED_IN])
|
||||
->when($branchId, fn ($q) => $q->where('branch_id', $branchId))
|
||||
->with(['patient', 'practitioner', 'visit', 'organization'])
|
||||
->with($this->queueEagerLoads())
|
||||
->orderBy('queue_position')
|
||||
->orderBy('waiting_at')
|
||||
->orderBy('checked_in_at');
|
||||
->orderBy('checked_in_at')
|
||||
->limit($limit);
|
||||
|
||||
$practitioners = Practitioner::owned($ownerRef)
|
||||
->whereIn('id', $practitionerIds)
|
||||
@@ -192,7 +211,7 @@ class AppointmentService
|
||||
return false;
|
||||
}
|
||||
|
||||
return $this->workflowGate->canRelease(
|
||||
return $this->workflowGate->isReleasedForDisplay(
|
||||
$organization,
|
||||
$appointment->visit,
|
||||
$context,
|
||||
@@ -203,6 +222,19 @@ class AppointmentService
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<string|array<string, mixed>>
|
||||
*/
|
||||
protected function queueEagerLoads(): array
|
||||
{
|
||||
return [
|
||||
'patient',
|
||||
'practitioner',
|
||||
'organization',
|
||||
'visit.currentStageAdvance.stage',
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $data
|
||||
*/
|
||||
@@ -484,25 +516,33 @@ class AppointmentService
|
||||
/**
|
||||
* Demo specialty seed used to set every module's waiter to position 1.
|
||||
* Repair duplicates in place so the patient queue shows 1..n.
|
||||
* Cheap existence check first so healthy queues skip the full rewrite.
|
||||
*/
|
||||
protected function repairDuplicateQueuePositions(string $ownerRef, int $branchId): void
|
||||
{
|
||||
$waiters = Appointment::owned($ownerRef)
|
||||
$base = Appointment::owned($ownerRef)
|
||||
->where('branch_id', $branchId)
|
||||
->whereIn('status', [Appointment::STATUS_WAITING, Appointment::STATUS_CHECKED_IN])
|
||||
->whereIn('status', [Appointment::STATUS_WAITING, Appointment::STATUS_CHECKED_IN]);
|
||||
|
||||
$waiterCount = (clone $base)->count();
|
||||
if ($waiterCount < 2) {
|
||||
return;
|
||||
}
|
||||
|
||||
$hasNulls = (clone $base)->whereNull('queue_position')->exists();
|
||||
$distinctPositions = (int) (clone $base)
|
||||
->whereNotNull('queue_position')
|
||||
->selectRaw('count(distinct queue_position) as aggregate')
|
||||
->value('aggregate');
|
||||
if (! $hasNulls && $distinctPositions === $waiterCount) {
|
||||
return;
|
||||
}
|
||||
|
||||
$waiters = (clone $base)
|
||||
->orderByRaw('COALESCE(waiting_at, checked_in_at, scheduled_at) asc')
|
||||
->orderBy('id')
|
||||
->get(['id', 'queue_position']);
|
||||
|
||||
if ($waiters->count() < 2) {
|
||||
return;
|
||||
}
|
||||
|
||||
$positions = $waiters->pluck('queue_position');
|
||||
if ($positions->filter(fn ($p) => $p !== null)->unique()->count() === $waiters->count()) {
|
||||
return;
|
||||
}
|
||||
|
||||
foreach ($waiters as $index => $appointment) {
|
||||
$position = $index + 1;
|
||||
if ((int) $appointment->queue_position === $position) {
|
||||
|
||||
@@ -19,6 +19,9 @@ use Illuminate\Support\Facades\Log;
|
||||
*/
|
||||
class CareQueueBridge
|
||||
{
|
||||
/** @var array<int, array<int, string>> organization_id => [department_id => specialty_key] */
|
||||
protected array $specialtyDepartmentMaps = [];
|
||||
|
||||
public function __construct(
|
||||
protected CareQueueEngine $engine,
|
||||
protected CareQueueProvisioner $provisioner,
|
||||
@@ -49,14 +52,34 @@ class CareQueueBridge
|
||||
return CareQueueContexts::CONSULTATION;
|
||||
}
|
||||
|
||||
return $this->specialtyDepartmentMap($organization)[$departmentId]
|
||||
?? CareQueueContexts::CONSULTATION;
|
||||
}
|
||||
|
||||
/**
|
||||
* Department id → specialty module key for this org (request-scoped cache).
|
||||
*
|
||||
* @return array<int, string>
|
||||
*/
|
||||
public function specialtyDepartmentMap(Organization $organization): array
|
||||
{
|
||||
$orgId = (int) $organization->id;
|
||||
if (isset($this->specialtyDepartmentMaps[$orgId])) {
|
||||
return $this->specialtyDepartmentMaps[$orgId];
|
||||
}
|
||||
|
||||
$map = [];
|
||||
foreach ($this->specialties->enabledKeys($organization) as $key) {
|
||||
$deptIds = data_get($organization->settings, "specialty_module_provisioning.{$key}.department_ids", []);
|
||||
if (is_array($deptIds) && in_array($departmentId, array_map('intval', $deptIds), true)) {
|
||||
return $key;
|
||||
if (! is_array($deptIds)) {
|
||||
continue;
|
||||
}
|
||||
foreach ($deptIds as $deptId) {
|
||||
$map[(int) $deptId] = $key;
|
||||
}
|
||||
}
|
||||
|
||||
return CareQueueContexts::CONSULTATION;
|
||||
return $this->specialtyDepartmentMaps[$orgId] = $map;
|
||||
}
|
||||
|
||||
public function contextForPractitioner(Organization $organization, ?Practitioner $practitioner): string
|
||||
@@ -496,7 +519,7 @@ class CareQueueBridge
|
||||
];
|
||||
}
|
||||
|
||||
$resources = $this->provisioner->ensure($organization->fresh(), $context, $branchId);
|
||||
$resources = $this->provisioner->ensure($organization, $context, $branchId);
|
||||
|
||||
return [
|
||||
'enabled' => true,
|
||||
|
||||
@@ -64,6 +64,17 @@ class WorkflowEngine
|
||||
|
||||
public function currentAdvance(Visit $visit): ?VisitStageAdvance
|
||||
{
|
||||
if ($visit->relationLoaded('currentStageAdvance')) {
|
||||
return $visit->currentStageAdvance;
|
||||
}
|
||||
|
||||
if ($visit->relationLoaded('stageAdvances')) {
|
||||
return $visit->stageAdvances
|
||||
->whereIn('status', [VisitStageAdvance::STATUS_ACTIVE, VisitStageAdvance::STATUS_BLOCKED])
|
||||
->sortByDesc('id')
|
||||
->first();
|
||||
}
|
||||
|
||||
return VisitStageAdvance::query()
|
||||
->where('visit_id', $visit->id)
|
||||
->whereIn('status', [VisitStageAdvance::STATUS_ACTIVE, VisitStageAdvance::STATUS_BLOCKED])
|
||||
|
||||
@@ -4,6 +4,7 @@ namespace App\Services\Care\Workflow;
|
||||
|
||||
use App\Models\Organization;
|
||||
use App\Models\Visit;
|
||||
use App\Models\VisitStageAdvance;
|
||||
use App\Models\WorkflowStage;
|
||||
use App\Services\Care\CareFeatures;
|
||||
use App\Services\Care\CareQueueContexts;
|
||||
@@ -24,6 +25,10 @@ class WorkflowQueueGate
|
||||
return $this->features->enabled($organization, CareFeatures::WORKFLOW_ENGINE);
|
||||
}
|
||||
|
||||
/**
|
||||
* Mutating gate used when issuing tickets or starting encounters.
|
||||
* May start a workflow or refresh financial gate state.
|
||||
*/
|
||||
public function canRelease(
|
||||
Organization $organization,
|
||||
?Visit $visit,
|
||||
@@ -36,29 +41,31 @@ class WorkflowQueueGate
|
||||
$advance = $this->engine->currentAdvance($visit)
|
||||
? $this->engine->refreshGate($visit)
|
||||
: $this->engine->start($visit);
|
||||
$stage = $advance?->stage;
|
||||
|
||||
// An enabled org without a materialized workflow keeps legacy behavior.
|
||||
if ($stage === null) {
|
||||
return $this->evaluateAdvance($organization, $visit, $advance, $queueContext, mutate: true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Read-only gate for waiting-list / board rendering.
|
||||
* Does not start workflows, create obligations, or refresh gate rows.
|
||||
*/
|
||||
public function isReleasedForDisplay(
|
||||
Organization $organization,
|
||||
?Visit $visit,
|
||||
string $queueContext,
|
||||
): bool {
|
||||
if (! $this->enabled($organization) || $visit === null) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Pay-before-service holds belong on the cashier / billing line, not the
|
||||
// clinical service queue for the current stage.
|
||||
if ($queueContext === CareQueueContexts::BILLING
|
||||
&& $this->isAwaitingFinancialGate($organization, $visit, $stage)) {
|
||||
$advance = $this->engine->currentAdvance($visit);
|
||||
|
||||
// No materialized advance yet — keep legacy visibility (do not start on GET).
|
||||
if ($advance === null) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (! $this->contextsMatch($stage->queue_context, $stage->type, $queueContext)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (! $this->features->enabled($organization, CareFeatures::FINANCIAL_GATES)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return $this->engine->isQueueReleasable($visit);
|
||||
return $this->evaluateAdvance($organization, $visit, $advance, $queueContext, mutate: false);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -81,6 +88,46 @@ class WorkflowQueueGate
|
||||
return ! $this->engine->isQueueReleasable($visit);
|
||||
}
|
||||
|
||||
protected function evaluateAdvance(
|
||||
Organization $organization,
|
||||
Visit $visit,
|
||||
?VisitStageAdvance $advance,
|
||||
string $queueContext,
|
||||
bool $mutate,
|
||||
): bool {
|
||||
$stage = $advance?->stage;
|
||||
|
||||
// An enabled org without a materialized workflow keeps legacy behavior.
|
||||
if ($stage === null) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Pay-before-service holds belong on the cashier / billing line, not the
|
||||
// clinical service queue for the current stage.
|
||||
if ($queueContext === CareQueueContexts::BILLING) {
|
||||
if ($mutate
|
||||
? $this->isAwaitingFinancialGate($organization, $visit, $stage)
|
||||
: ($advance->isBlocked() && $stage->gatesBeforeService())) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
if (! $this->contextsMatch($stage->queue_context, $stage->type, $queueContext)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (! $this->features->enabled($organization, CareFeatures::FINANCIAL_GATES)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Display path trusts advance status (payment clear paths refresh the gate).
|
||||
if (! $mutate) {
|
||||
return ! $advance->isBlocked();
|
||||
}
|
||||
|
||||
return $this->engine->isQueueReleasable($visit);
|
||||
}
|
||||
|
||||
protected function contextsMatch(?string $stageContext, string $stageType, string $queueContext): bool
|
||||
{
|
||||
if ($stageContext === $queueContext) {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
@php
|
||||
$specialtyLabel = $specialtyLabel ?? null;
|
||||
$queueBridge = app(\App\Services\Care\CareQueueBridge::class);
|
||||
$specialtyDepartmentMap = $specialtyDepartmentMap ?? [];
|
||||
@endphp
|
||||
<x-app-layout title="Queue">
|
||||
<div class="space-y-6">
|
||||
@@ -40,17 +40,15 @@
|
||||
'queueCallNextParams' => array_filter(['practitioner_id' => $practitionerId]),
|
||||
'startRouteName' => 'care.queue.start',
|
||||
'inCareLabel' => $specialtyLabel ? 'In care' : 'In consultation',
|
||||
'openInCareUrl' => function ($appointment) use ($queueBridge) {
|
||||
$organization = $appointment->organization;
|
||||
if ($organization && $appointment->visit) {
|
||||
$context = $queueBridge->contextForAppointment($organization, $appointment);
|
||||
if ($context !== \App\Services\Care\CareQueueContexts::CONSULTATION) {
|
||||
'openInCareUrl' => function ($appointment) use ($specialtyDepartmentMap) {
|
||||
$deptId = (int) ($appointment->department_id ?? 0);
|
||||
$module = $deptId > 0 ? ($specialtyDepartmentMap[$deptId] ?? null) : null;
|
||||
if ($module && $appointment->visit) {
|
||||
return route('care.specialty.workspace', [
|
||||
'module' => $context,
|
||||
'module' => $module,
|
||||
'visit' => $appointment->visit,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
return $appointment->consultation
|
||||
? route('care.consultations.show', $appointment->consultation)
|
||||
|
||||
@@ -0,0 +1,263 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature;
|
||||
|
||||
use App\Http\Middleware\EnsurePlatformSession;
|
||||
use App\Models\Appointment;
|
||||
use App\Models\Branch;
|
||||
use App\Models\FacilityWorkflow;
|
||||
use App\Models\Member;
|
||||
use App\Models\Organization;
|
||||
use App\Models\Patient;
|
||||
use App\Models\User;
|
||||
use App\Models\Visit;
|
||||
use App\Models\VisitStageAdvance;
|
||||
use App\Models\WorkflowStage;
|
||||
use App\Services\Care\AppointmentService;
|
||||
use App\Services\Care\CareFeatures;
|
||||
use App\Services\Care\CareQueueContexts;
|
||||
use App\Services\Care\Workflow\WorkflowQueueGate;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Tests\TestCase;
|
||||
|
||||
class CareQueueBoardPerformanceTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
protected User $owner;
|
||||
|
||||
protected Organization $organization;
|
||||
|
||||
protected Branch $branch;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
$this->withoutMiddleware(EnsurePlatformSession::class);
|
||||
|
||||
$this->owner = User::create([
|
||||
'public_id' => 'queue-perf-owner',
|
||||
'name' => 'Owner',
|
||||
'email' => 'queue-perf@example.com',
|
||||
]);
|
||||
|
||||
$this->organization = Organization::create([
|
||||
'owner_ref' => $this->owner->public_id,
|
||||
'name' => 'Perf Clinic',
|
||||
'slug' => 'perf-clinic',
|
||||
'settings' => [
|
||||
'onboarded' => true,
|
||||
'facility_type' => 'clinic',
|
||||
'plan' => 'pro',
|
||||
'plan_expires_at' => now()->addMonth()->toIso8601String(),
|
||||
'queue_integration_enabled' => true,
|
||||
'rollout' => [
|
||||
CareFeatures::WORKFLOW_ENGINE => true,
|
||||
CareFeatures::FINANCIAL_GATES => true,
|
||||
],
|
||||
],
|
||||
]);
|
||||
|
||||
Member::create([
|
||||
'owner_ref' => $this->owner->public_id,
|
||||
'organization_id' => $this->organization->id,
|
||||
'user_ref' => $this->owner->public_id,
|
||||
'role' => 'receptionist',
|
||||
]);
|
||||
|
||||
$this->branch = Branch::create([
|
||||
'owner_ref' => $this->owner->public_id,
|
||||
'organization_id' => $this->organization->id,
|
||||
'name' => 'Main',
|
||||
'is_active' => true,
|
||||
]);
|
||||
}
|
||||
|
||||
public function test_queue_index_does_not_backfill_tickets_or_start_workflows(): void
|
||||
{
|
||||
$workflow = FacilityWorkflow::create([
|
||||
'owner_ref' => $this->owner->public_id,
|
||||
'organization_id' => $this->organization->id,
|
||||
'branch_id' => $this->branch->id,
|
||||
'name' => 'Default',
|
||||
'is_active' => true,
|
||||
]);
|
||||
WorkflowStage::create([
|
||||
'owner_ref' => $this->owner->public_id,
|
||||
'workflow_id' => $workflow->id,
|
||||
'code' => 'consultation',
|
||||
'name' => 'Consultation',
|
||||
'type' => 'consultation',
|
||||
'queue_context' => CareQueueContexts::CONSULTATION,
|
||||
'sort_order' => 1,
|
||||
'requires_payment' => false,
|
||||
]);
|
||||
|
||||
$seedWaiters = function (int $count, int $offset = 0): void {
|
||||
for ($i = 0; $i < $count; $i++) {
|
||||
$n = $offset + $i;
|
||||
$patient = Patient::create([
|
||||
'owner_ref' => $this->owner->public_id,
|
||||
'organization_id' => $this->organization->id,
|
||||
'branch_id' => $this->branch->id,
|
||||
'patient_number' => 'P-PERF-'.$n,
|
||||
'first_name' => 'Pat',
|
||||
'last_name' => (string) $n,
|
||||
]);
|
||||
$visit = Visit::create([
|
||||
'owner_ref' => $this->owner->public_id,
|
||||
'organization_id' => $this->organization->id,
|
||||
'branch_id' => $this->branch->id,
|
||||
'patient_id' => $patient->id,
|
||||
'status' => Visit::STATUS_OPEN,
|
||||
'checked_in_at' => now(),
|
||||
]);
|
||||
Appointment::create([
|
||||
'owner_ref' => $this->owner->public_id,
|
||||
'organization_id' => $this->organization->id,
|
||||
'branch_id' => $this->branch->id,
|
||||
'patient_id' => $patient->id,
|
||||
'visit_id' => $visit->id,
|
||||
'type' => Appointment::TYPE_WALK_IN,
|
||||
'status' => Appointment::STATUS_WAITING,
|
||||
'scheduled_at' => now(),
|
||||
'waiting_at' => now()->subMinutes(100 - $n),
|
||||
'queue_position' => $n + 1,
|
||||
]);
|
||||
}
|
||||
};
|
||||
|
||||
$seedWaiters(5);
|
||||
DB::flushQueryLog();
|
||||
DB::enableQueryLog();
|
||||
$this->actingAs($this->owner)
|
||||
->get(route('care.queue.index', ['branch_id' => $this->branch->id]))
|
||||
->assertOk();
|
||||
$queriesWith5 = count(DB::getQueryLog());
|
||||
DB::disableQueryLog();
|
||||
|
||||
$seedWaiters(20, 5);
|
||||
DB::flushQueryLog();
|
||||
DB::enableQueryLog();
|
||||
$this->actingAs($this->owner)
|
||||
->get(route('care.queue.index', ['branch_id' => $this->branch->id]))
|
||||
->assertOk()
|
||||
->assertSee('Patient flow');
|
||||
$log = collect(DB::getQueryLog());
|
||||
DB::disableQueryLog();
|
||||
$queriesWith25 = $log->count();
|
||||
|
||||
$this->assertSame(0, Appointment::query()->whereNotNull('queue_ticket_uuid')->where('queue_ticket_uuid', '!=', '')->count());
|
||||
$this->assertSame(0, VisitStageAdvance::query()->count(), 'Board GET must not start workflows');
|
||||
|
||||
// Adding 20 waiters must not add ~N queries (gate refresh / ticket sync / context loops).
|
||||
$delta = $queriesWith25 - $queriesWith5;
|
||||
$this->assertLessThan(
|
||||
25,
|
||||
$delta,
|
||||
"Query delta for +20 waiters should be small (eager loads), got {$delta} (5={$queriesWith5}, 25={$queriesWith25})",
|
||||
);
|
||||
|
||||
$advanceLookups = $log->filter(
|
||||
fn ($q) => str_contains(strtolower($q['query']), 'care_visit_stage_advances'),
|
||||
)->count();
|
||||
$this->assertLessThanOrEqual(
|
||||
3,
|
||||
$advanceLookups,
|
||||
"visit_stage_advances lookups should be eager-loaded, got {$advanceLookups}",
|
||||
);
|
||||
}
|
||||
|
||||
public function test_queue_board_caps_waiting_list_size(): void
|
||||
{
|
||||
$limit = AppointmentService::QUEUE_BOARD_LIMIT;
|
||||
|
||||
for ($i = 0; $i < $limit + 15; $i++) {
|
||||
$patient = Patient::create([
|
||||
'owner_ref' => $this->owner->public_id,
|
||||
'organization_id' => $this->organization->id,
|
||||
'branch_id' => $this->branch->id,
|
||||
'patient_number' => 'P-CAP-'.$i,
|
||||
'first_name' => 'Cap',
|
||||
'last_name' => (string) $i,
|
||||
]);
|
||||
Appointment::create([
|
||||
'owner_ref' => $this->owner->public_id,
|
||||
'organization_id' => $this->organization->id,
|
||||
'branch_id' => $this->branch->id,
|
||||
'patient_id' => $patient->id,
|
||||
'type' => Appointment::TYPE_WALK_IN,
|
||||
'status' => Appointment::STATUS_WAITING,
|
||||
'scheduled_at' => now(),
|
||||
'waiting_at' => now()->subMinutes($limit + 15 - $i),
|
||||
'queue_position' => $i + 1,
|
||||
]);
|
||||
}
|
||||
|
||||
$queue = app(AppointmentService::class)->queue(
|
||||
$this->owner->public_id,
|
||||
$this->branch->id,
|
||||
);
|
||||
|
||||
$this->assertCount($limit, $queue);
|
||||
$this->assertSame(1, (int) $queue->first()->queue_position);
|
||||
}
|
||||
|
||||
public function test_display_gate_hides_blocked_visits_without_refresh(): void
|
||||
{
|
||||
$workflow = FacilityWorkflow::create([
|
||||
'owner_ref' => $this->owner->public_id,
|
||||
'organization_id' => $this->organization->id,
|
||||
'branch_id' => $this->branch->id,
|
||||
'name' => 'Gated',
|
||||
'is_active' => true,
|
||||
]);
|
||||
$stage = WorkflowStage::create([
|
||||
'owner_ref' => $this->owner->public_id,
|
||||
'workflow_id' => $workflow->id,
|
||||
'code' => 'consultation',
|
||||
'name' => 'Consultation',
|
||||
'type' => 'consultation',
|
||||
'queue_context' => CareQueueContexts::CONSULTATION,
|
||||
'sort_order' => 1,
|
||||
'requires_payment' => true,
|
||||
'payment_timing' => 'before',
|
||||
'default_amount_minor' => 1000,
|
||||
]);
|
||||
|
||||
$patient = Patient::create([
|
||||
'owner_ref' => $this->owner->public_id,
|
||||
'organization_id' => $this->organization->id,
|
||||
'branch_id' => $this->branch->id,
|
||||
'patient_number' => 'P-GATE',
|
||||
'first_name' => 'Blocked',
|
||||
'last_name' => 'Patient',
|
||||
]);
|
||||
$visit = Visit::create([
|
||||
'owner_ref' => $this->owner->public_id,
|
||||
'organization_id' => $this->organization->id,
|
||||
'branch_id' => $this->branch->id,
|
||||
'patient_id' => $patient->id,
|
||||
'status' => Visit::STATUS_OPEN,
|
||||
'checked_in_at' => now(),
|
||||
]);
|
||||
VisitStageAdvance::create([
|
||||
'owner_ref' => $this->owner->public_id,
|
||||
'visit_id' => $visit->id,
|
||||
'workflow_id' => $workflow->id,
|
||||
'workflow_stage_id' => $stage->id,
|
||||
'stage_code' => 'consultation',
|
||||
'status' => VisitStageAdvance::STATUS_BLOCKED,
|
||||
'blocked_reason' => 'awaiting_payment',
|
||||
'entered_at' => now(),
|
||||
]);
|
||||
|
||||
$gate = app(WorkflowQueueGate::class);
|
||||
$this->assertFalse($gate->isReleasedForDisplay(
|
||||
$this->organization,
|
||||
$visit->fresh('currentStageAdvance.stage'),
|
||||
CareQueueContexts::CONSULTATION,
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -367,12 +367,20 @@ class CareQueueBridgeTest extends TestCase
|
||||
'reason' => 'Toothache',
|
||||
]);
|
||||
|
||||
// Board GET must remain read-only; ticket backfill is explicit (Call next / artisan).
|
||||
$this->actingAs($this->owner)
|
||||
->get(route('care.queue.index', ['branch_id' => $this->branch->id]))
|
||||
->assertOk()
|
||||
->assertSee('Toothache');
|
||||
|
||||
$appointment = Appointment::query()->where('patient_id', $this->patient->id)->first();
|
||||
$this->assertNull($appointment?->queue_ticket_uuid);
|
||||
|
||||
$issued = app(\App\Services\Care\CareQueueBridge::class)
|
||||
->syncMissingAppointmentTickets($this->organization->fresh(), (int) $this->branch->id, 10);
|
||||
$this->assertSame(1, $issued);
|
||||
|
||||
$appointment = $appointment->fresh();
|
||||
$this->assertNotNull($appointment?->queue_ticket_uuid);
|
||||
$this->assertStringStartsWith('DEN', (string) $appointment->queue_ticket_number);
|
||||
$this->assertDatabaseHas('care_service_queues', [
|
||||
|
||||
Reference in New Issue
Block a user