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)) {
|
if ($this->queueBridge->isEnabled($organization)) {
|
||||||
$syncBranches = $lockToPractitioner
|
// Ticket backfill belongs on check-in / Call next / care:queue-sync-tickets —
|
||||||
? Practitioner::owned($owner)
|
// not on every board GET (can issue up to 100 tickets with provisioner work).
|
||||||
->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);
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($branchId > 0) {
|
if ($branchId > 0) {
|
||||||
$queueIntegration = $this->queueBridge->listMeta($organization, $queueContext, $branchId);
|
$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(
|
$queueIntegration = $this->queueBridge->listMeta(
|
||||||
$organization,
|
$organization,
|
||||||
$queueContext,
|
$queueContext,
|
||||||
(int) $syncBranches[0],
|
$firstBranch,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$boardLimit = AppointmentService::QUEUE_BOARD_LIMIT;
|
||||||
|
|
||||||
if ($lockToPractitioner) {
|
if ($lockToPractitioner) {
|
||||||
$queue = $this->appointments->queueForPractitioners(
|
$queue = $this->appointments->queueForPractitioners(
|
||||||
$owner,
|
$owner,
|
||||||
$practitionerScope ?? [],
|
$practitionerScope ?? [],
|
||||||
$branchId > 0 ? $branchId : null,
|
$branchId > 0 ? $branchId : null,
|
||||||
|
$boardLimit,
|
||||||
);
|
);
|
||||||
$inConsultation = Appointment::owned($owner)
|
$inConsultation = Appointment::owned($owner)
|
||||||
->where('organization_id', $organization->id)
|
->where('organization_id', $organization->id)
|
||||||
@@ -99,10 +94,11 @@ class QueueController extends Controller
|
|||||||
->whereIn('practitioner_id', $practitionerScope ?: [0])
|
->whereIn('practitioner_id', $practitionerScope ?: [0])
|
||||||
->with(['patient', 'practitioner', 'consultation', 'visit', 'organization'])
|
->with(['patient', 'practitioner', 'consultation', 'visit', 'organization'])
|
||||||
->orderBy('started_at')
|
->orderBy('started_at')
|
||||||
|
->limit($boardLimit)
|
||||||
->get();
|
->get();
|
||||||
} else {
|
} else {
|
||||||
$queue = $branchId
|
$queue = $branchId
|
||||||
? $this->appointments->queue($owner, $branchId, $practitionerId)
|
? $this->appointments->queue($owner, $branchId, $practitionerId, true, null, $boardLimit)
|
||||||
: collect();
|
: collect();
|
||||||
$inConsultation = Appointment::owned($owner)
|
$inConsultation = Appointment::owned($owner)
|
||||||
->where('organization_id', $organization->id)
|
->where('organization_id', $organization->id)
|
||||||
@@ -111,6 +107,7 @@ class QueueController extends Controller
|
|||||||
->when($practitionerId, fn ($q) => $q->where('practitioner_id', $practitionerId))
|
->when($practitionerId, fn ($q) => $q->where('practitioner_id', $practitionerId))
|
||||||
->with(['patient', 'practitioner', 'consultation', 'visit', 'organization'])
|
->with(['patient', 'practitioner', 'consultation', 'visit', 'organization'])
|
||||||
->orderBy('started_at')
|
->orderBy('started_at')
|
||||||
|
->limit($boardLimit)
|
||||||
->get();
|
->get();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -126,6 +123,36 @@ class QueueController extends Controller
|
|||||||
$todayQuery->where('branch_id', $branchId);
|
$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)
|
$doneQuery = Appointment::owned($owner)
|
||||||
->where('organization_id', $organization->id)
|
->where('organization_id', $organization->id)
|
||||||
->where('status', Appointment::STATUS_COMPLETED)
|
->where('status', Appointment::STATUS_COMPLETED)
|
||||||
@@ -145,11 +172,13 @@ class QueueController extends Controller
|
|||||||
->get();
|
->get();
|
||||||
|
|
||||||
$heroStats = [
|
$heroStats = [
|
||||||
'waiting' => $queue->count(),
|
'waiting' => $waitingCountQuery->count(),
|
||||||
'in_consultation' => $inConsultation->count(),
|
'in_consultation' => $inCareCountQuery->count(),
|
||||||
'today' => $todayQuery->count(),
|
'today' => $todayQuery->count(),
|
||||||
];
|
];
|
||||||
|
|
||||||
|
$specialtyDepartmentMap = $this->queueBridge->specialtyDepartmentMap($organization);
|
||||||
|
|
||||||
return view('care.queue.index', [
|
return view('care.queue.index', [
|
||||||
'organization' => $organization,
|
'organization' => $organization,
|
||||||
'branches' => $branches,
|
'branches' => $branches,
|
||||||
@@ -174,6 +203,7 @@ class QueueController extends Controller
|
|||||||
'queueIntegration' => $queueIntegration,
|
'queueIntegration' => $queueIntegration,
|
||||||
'queueContext' => $queueContext,
|
'queueContext' => $queueContext,
|
||||||
'specialtyLabel' => $specialtyLabel,
|
'specialtyLabel' => $specialtyLabel,
|
||||||
|
'specialtyDepartmentMap' => $specialtyDepartmentMap,
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -623,7 +623,7 @@ class SpecialtyModuleController extends Controller
|
|||||||
$practitionerScope !== null,
|
$practitionerScope !== null,
|
||||||
fn ($q) => $q->whereIn('practitioner_id', $practitionerScope ?: [0]),
|
fn ($q) => $q->whereIn('practitioner_id', $practitionerScope ?: [0]),
|
||||||
)
|
)
|
||||||
->with(['patient', 'practitioner', 'department', 'visit'])
|
->with(['patient', 'practitioner', 'department', 'visit.currentStageAdvance.stage'])
|
||||||
->orderBy('queue_position')
|
->orderBy('queue_position')
|
||||||
->orderBy('checked_in_at')
|
->orderBy('checked_in_at')
|
||||||
->limit(50)
|
->limit(50)
|
||||||
|
|||||||
@@ -94,6 +94,22 @@ class Visit extends Model
|
|||||||
return $this->hasMany(VisitStageAdvance::class, 'visit_id');
|
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
|
public function financialObligations(): HasMany
|
||||||
{
|
{
|
||||||
return $this->hasMany(FinancialObligation::class, 'visit_id');
|
return $this->hasMany(FinancialObligation::class, 'visit_id');
|
||||||
|
|||||||
@@ -83,6 +83,9 @@ class AppointmentService
|
|||||||
return $query->paginate((int) ($filters['per_page'] ?? 20))->withQueryString();
|
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
|
* @param list<int>|null $practitionerIds
|
||||||
* @return Collection<int, Appointment>
|
* @return Collection<int, Appointment>
|
||||||
@@ -93,20 +96,29 @@ class AppointmentService
|
|||||||
?int $practitionerId = null,
|
?int $practitionerId = null,
|
||||||
bool $includeUnassigned = true,
|
bool $includeUnassigned = true,
|
||||||
?array $practitionerIds = null,
|
?array $practitionerIds = null,
|
||||||
|
?int $limit = null,
|
||||||
): Collection {
|
): Collection {
|
||||||
if ($practitionerIds !== null) {
|
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);
|
$this->repairDuplicateQueuePositions($ownerRef, $branchId);
|
||||||
|
|
||||||
|
$limit = $limit ?? self::QUEUE_BOARD_LIMIT;
|
||||||
|
|
||||||
$query = Appointment::owned($ownerRef)
|
$query = Appointment::owned($ownerRef)
|
||||||
->where('branch_id', $branchId)
|
->where('branch_id', $branchId)
|
||||||
->whereIn('status', [Appointment::STATUS_WAITING, Appointment::STATUS_CHECKED_IN])
|
->whereIn('status', [Appointment::STATUS_WAITING, Appointment::STATUS_CHECKED_IN])
|
||||||
->with(['patient', 'practitioner', 'visit', 'organization'])
|
->with($this->queueEagerLoads())
|
||||||
->orderBy('queue_position')
|
->orderBy('queue_position')
|
||||||
->orderBy('waiting_at')
|
->orderBy('waiting_at')
|
||||||
->orderBy('checked_in_at');
|
->orderBy('checked_in_at')
|
||||||
|
->limit($limit);
|
||||||
|
|
||||||
if ($practitionerId !== null) {
|
if ($practitionerId !== null) {
|
||||||
$query->where(function (Builder $q) use ($practitionerId, $includeUnassigned) {
|
$query->where(function (Builder $q) use ($practitionerId, $includeUnassigned) {
|
||||||
@@ -124,7 +136,7 @@ class AppointmentService
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
return $this->workflowGate->canRelease(
|
return $this->workflowGate->isReleasedForDisplay(
|
||||||
$organization,
|
$organization,
|
||||||
$appointment->visit,
|
$appointment->visit,
|
||||||
$this->queueBridge->contextForAppointment($organization, $appointment),
|
$this->queueBridge->contextForAppointment($organization, $appointment),
|
||||||
@@ -139,20 +151,27 @@ class AppointmentService
|
|||||||
* @param list<int> $practitionerIds
|
* @param list<int> $practitionerIds
|
||||||
* @return Collection<int, Appointment>
|
* @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 === []) {
|
if ($practitionerIds === []) {
|
||||||
return new Collection;
|
return new Collection;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$limit = $limit ?? self::QUEUE_BOARD_LIMIT;
|
||||||
|
|
||||||
$query = Appointment::owned($ownerRef)
|
$query = Appointment::owned($ownerRef)
|
||||||
->whereIn('practitioner_id', $practitionerIds)
|
->whereIn('practitioner_id', $practitionerIds)
|
||||||
->whereIn('status', [Appointment::STATUS_WAITING, Appointment::STATUS_CHECKED_IN])
|
->whereIn('status', [Appointment::STATUS_WAITING, Appointment::STATUS_CHECKED_IN])
|
||||||
->when($branchId, fn ($q) => $q->where('branch_id', $branchId))
|
->when($branchId, fn ($q) => $q->where('branch_id', $branchId))
|
||||||
->with(['patient', 'practitioner', 'visit', 'organization'])
|
->with($this->queueEagerLoads())
|
||||||
->orderBy('queue_position')
|
->orderBy('queue_position')
|
||||||
->orderBy('waiting_at')
|
->orderBy('waiting_at')
|
||||||
->orderBy('checked_in_at');
|
->orderBy('checked_in_at')
|
||||||
|
->limit($limit);
|
||||||
|
|
||||||
$practitioners = Practitioner::owned($ownerRef)
|
$practitioners = Practitioner::owned($ownerRef)
|
||||||
->whereIn('id', $practitionerIds)
|
->whereIn('id', $practitionerIds)
|
||||||
@@ -192,7 +211,7 @@ class AppointmentService
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
return $this->workflowGate->canRelease(
|
return $this->workflowGate->isReleasedForDisplay(
|
||||||
$organization,
|
$organization,
|
||||||
$appointment->visit,
|
$appointment->visit,
|
||||||
$context,
|
$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
|
* @param array<string, mixed> $data
|
||||||
*/
|
*/
|
||||||
@@ -484,25 +516,33 @@ class AppointmentService
|
|||||||
/**
|
/**
|
||||||
* Demo specialty seed used to set every module's waiter to position 1.
|
* Demo specialty seed used to set every module's waiter to position 1.
|
||||||
* Repair duplicates in place so the patient queue shows 1..n.
|
* 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
|
protected function repairDuplicateQueuePositions(string $ownerRef, int $branchId): void
|
||||||
{
|
{
|
||||||
$waiters = Appointment::owned($ownerRef)
|
$base = Appointment::owned($ownerRef)
|
||||||
->where('branch_id', $branchId)
|
->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')
|
->orderByRaw('COALESCE(waiting_at, checked_in_at, scheduled_at) asc')
|
||||||
->orderBy('id')
|
->orderBy('id')
|
||||||
->get(['id', 'queue_position']);
|
->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) {
|
foreach ($waiters as $index => $appointment) {
|
||||||
$position = $index + 1;
|
$position = $index + 1;
|
||||||
if ((int) $appointment->queue_position === $position) {
|
if ((int) $appointment->queue_position === $position) {
|
||||||
|
|||||||
@@ -19,6 +19,9 @@ use Illuminate\Support\Facades\Log;
|
|||||||
*/
|
*/
|
||||||
class CareQueueBridge
|
class CareQueueBridge
|
||||||
{
|
{
|
||||||
|
/** @var array<int, array<int, string>> organization_id => [department_id => specialty_key] */
|
||||||
|
protected array $specialtyDepartmentMaps = [];
|
||||||
|
|
||||||
public function __construct(
|
public function __construct(
|
||||||
protected CareQueueEngine $engine,
|
protected CareQueueEngine $engine,
|
||||||
protected CareQueueProvisioner $provisioner,
|
protected CareQueueProvisioner $provisioner,
|
||||||
@@ -49,14 +52,34 @@ class CareQueueBridge
|
|||||||
return CareQueueContexts::CONSULTATION;
|
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) {
|
foreach ($this->specialties->enabledKeys($organization) as $key) {
|
||||||
$deptIds = data_get($organization->settings, "specialty_module_provisioning.{$key}.department_ids", []);
|
$deptIds = data_get($organization->settings, "specialty_module_provisioning.{$key}.department_ids", []);
|
||||||
if (is_array($deptIds) && in_array($departmentId, array_map('intval', $deptIds), true)) {
|
if (! is_array($deptIds)) {
|
||||||
return $key;
|
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
|
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 [
|
return [
|
||||||
'enabled' => true,
|
'enabled' => true,
|
||||||
|
|||||||
@@ -64,6 +64,17 @@ class WorkflowEngine
|
|||||||
|
|
||||||
public function currentAdvance(Visit $visit): ?VisitStageAdvance
|
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()
|
return VisitStageAdvance::query()
|
||||||
->where('visit_id', $visit->id)
|
->where('visit_id', $visit->id)
|
||||||
->whereIn('status', [VisitStageAdvance::STATUS_ACTIVE, VisitStageAdvance::STATUS_BLOCKED])
|
->whereIn('status', [VisitStageAdvance::STATUS_ACTIVE, VisitStageAdvance::STATUS_BLOCKED])
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ namespace App\Services\Care\Workflow;
|
|||||||
|
|
||||||
use App\Models\Organization;
|
use App\Models\Organization;
|
||||||
use App\Models\Visit;
|
use App\Models\Visit;
|
||||||
|
use App\Models\VisitStageAdvance;
|
||||||
use App\Models\WorkflowStage;
|
use App\Models\WorkflowStage;
|
||||||
use App\Services\Care\CareFeatures;
|
use App\Services\Care\CareFeatures;
|
||||||
use App\Services\Care\CareQueueContexts;
|
use App\Services\Care\CareQueueContexts;
|
||||||
@@ -24,6 +25,10 @@ class WorkflowQueueGate
|
|||||||
return $this->features->enabled($organization, CareFeatures::WORKFLOW_ENGINE);
|
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(
|
public function canRelease(
|
||||||
Organization $organization,
|
Organization $organization,
|
||||||
?Visit $visit,
|
?Visit $visit,
|
||||||
@@ -36,29 +41,31 @@ class WorkflowQueueGate
|
|||||||
$advance = $this->engine->currentAdvance($visit)
|
$advance = $this->engine->currentAdvance($visit)
|
||||||
? $this->engine->refreshGate($visit)
|
? $this->engine->refreshGate($visit)
|
||||||
: $this->engine->start($visit);
|
: $this->engine->start($visit);
|
||||||
$stage = $advance?->stage;
|
|
||||||
|
|
||||||
// An enabled org without a materialized workflow keeps legacy behavior.
|
return $this->evaluateAdvance($organization, $visit, $advance, $queueContext, mutate: true);
|
||||||
if ($stage === null) {
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Pay-before-service holds belong on the cashier / billing line, not the
|
$advance = $this->engine->currentAdvance($visit);
|
||||||
// clinical service queue for the current stage.
|
|
||||||
if ($queueContext === CareQueueContexts::BILLING
|
// No materialized advance yet — keep legacy visibility (do not start on GET).
|
||||||
&& $this->isAwaitingFinancialGate($organization, $visit, $stage)) {
|
if ($advance === null) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (! $this->contextsMatch($stage->queue_context, $stage->type, $queueContext)) {
|
return $this->evaluateAdvance($organization, $visit, $advance, $queueContext, mutate: false);
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (! $this->features->enabled($organization, CareFeatures::FINANCIAL_GATES)) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
return $this->engine->isQueueReleasable($visit);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -81,6 +88,46 @@ class WorkflowQueueGate
|
|||||||
return ! $this->engine->isQueueReleasable($visit);
|
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
|
protected function contextsMatch(?string $stageContext, string $stageType, string $queueContext): bool
|
||||||
{
|
{
|
||||||
if ($stageContext === $queueContext) {
|
if ($stageContext === $queueContext) {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
@php
|
@php
|
||||||
$specialtyLabel = $specialtyLabel ?? null;
|
$specialtyLabel = $specialtyLabel ?? null;
|
||||||
$queueBridge = app(\App\Services\Care\CareQueueBridge::class);
|
$specialtyDepartmentMap = $specialtyDepartmentMap ?? [];
|
||||||
@endphp
|
@endphp
|
||||||
<x-app-layout title="Queue">
|
<x-app-layout title="Queue">
|
||||||
<div class="space-y-6">
|
<div class="space-y-6">
|
||||||
@@ -40,17 +40,15 @@
|
|||||||
'queueCallNextParams' => array_filter(['practitioner_id' => $practitionerId]),
|
'queueCallNextParams' => array_filter(['practitioner_id' => $practitionerId]),
|
||||||
'startRouteName' => 'care.queue.start',
|
'startRouteName' => 'care.queue.start',
|
||||||
'inCareLabel' => $specialtyLabel ? 'In care' : 'In consultation',
|
'inCareLabel' => $specialtyLabel ? 'In care' : 'In consultation',
|
||||||
'openInCareUrl' => function ($appointment) use ($queueBridge) {
|
'openInCareUrl' => function ($appointment) use ($specialtyDepartmentMap) {
|
||||||
$organization = $appointment->organization;
|
$deptId = (int) ($appointment->department_id ?? 0);
|
||||||
if ($organization && $appointment->visit) {
|
$module = $deptId > 0 ? ($specialtyDepartmentMap[$deptId] ?? null) : null;
|
||||||
$context = $queueBridge->contextForAppointment($organization, $appointment);
|
if ($module && $appointment->visit) {
|
||||||
if ($context !== \App\Services\Care\CareQueueContexts::CONSULTATION) {
|
|
||||||
return route('care.specialty.workspace', [
|
return route('care.specialty.workspace', [
|
||||||
'module' => $context,
|
'module' => $module,
|
||||||
'visit' => $appointment->visit,
|
'visit' => $appointment->visit,
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
return $appointment->consultation
|
return $appointment->consultation
|
||||||
? route('care.consultations.show', $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',
|
'reason' => 'Toothache',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
// Board GET must remain read-only; ticket backfill is explicit (Call next / artisan).
|
||||||
$this->actingAs($this->owner)
|
$this->actingAs($this->owner)
|
||||||
->get(route('care.queue.index', ['branch_id' => $this->branch->id]))
|
->get(route('care.queue.index', ['branch_id' => $this->branch->id]))
|
||||||
->assertOk()
|
->assertOk()
|
||||||
->assertSee('Toothache');
|
->assertSee('Toothache');
|
||||||
|
|
||||||
$appointment = Appointment::query()->where('patient_id', $this->patient->id)->first();
|
$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->assertNotNull($appointment?->queue_ticket_uuid);
|
||||||
$this->assertStringStartsWith('DEN', (string) $appointment->queue_ticket_number);
|
$this->assertStringStartsWith('DEN', (string) $appointment->queue_ticket_number);
|
||||||
$this->assertDatabaseHas('care_service_queues', [
|
$this->assertDatabaseHas('care_service_queues', [
|
||||||
|
|||||||
Reference in New Issue
Block a user