Speed up Queue board loads by dropping sync and gate side effects.
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:
isaacclad
2026-07-18 19:50:03 +00:00
co-authored by Cursor
parent 93c7a71ee5
commit a5bbbe23b1
10 changed files with 512 additions and 76 deletions
+50 -20
View File
@@ -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)
+16
View File
@@ -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');
+60 -20
View File
@@ -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) {
+27 -4
View File
@@ -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) {
+6 -8
View File
@@ -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,
));
}
}
+8
View File
@@ -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', [