Fix specialty Call next vs WAITING KPI mismatch.
Deploy Ladill Care / deploy (push) Successful in 1m44s

Admins without a desk now scan all specialty service points, KPIs use the same branch as queue ops, and empty Call next flashes explain tickets vs other branches.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
isaacclad
2026-07-18 11:41:45 +00:00
co-authored by Cursor
parent 4c4dfc7dbf
commit bd8218fb45
5 changed files with 235 additions and 21 deletions
@@ -304,10 +304,50 @@ class SpecialtyModuleController extends Controller
$result = $queueBridge->callNext($organization, $module, $branchId, $member, $practitionerId);
$ticket = $result['ticket'] ?? null;
if (! $ticket) {
$owner = $this->ownerRef($request);
$departmentIds = Department::owned($owner)
->where('type', $definition['department_type'] ?? 'general')
->where('is_active', true)
->when($branchScope, fn ($q) => $q->where('branch_id', $branchScope))
->pluck('id');
$waitingAtBranch = Appointment::owned($owner)
->where('organization_id', $organization->id)
->where('branch_id', $branchId)
->whereIn('status', [Appointment::STATUS_WAITING, Appointment::STATUS_CHECKED_IN])
->when($departmentIds->isNotEmpty(), fn ($q) => $q->whereIn('department_id', $departmentIds))
->when($departmentIds->isEmpty(), fn ($q) => $q->whereRaw('1 = 0'))
->count();
$waitingOrg = Appointment::owned($owner)
->where('organization_id', $organization->id)
->whereIn('status', [Appointment::STATUS_WAITING, Appointment::STATUS_CHECKED_IN])
->when($departmentIds->isNotEmpty(), fn ($q) => $q->whereIn('department_id', $departmentIds))
->when($departmentIds->isEmpty(), fn ($q) => $q->whereRaw('1 = 0'))
->count();
$branchName = \App\Models\Branch::owned($owner)->whereKey($branchId)->value('name') ?: 'this branch';
if ($waitingAtBranch > 0) {
return back()->with(
'info',
"{$waitingAtBranch} patient(s) are waiting in {$definition['label']} at {$branchName}, but none have a callable ticket at a service desk yet. Open Visits and use Start consultation, or check in again so a ticket is issued."
);
}
if ($waitingOrg > $waitingAtBranch) {
return back()->with(
'info',
"No patients waiting in {$definition['label']} at {$branchName}. {$waitingOrg} are waiting at other branches — open Visits or switch branch."
);
}
return back()->with('info', 'No patients waiting in this specialty queue.');
}
return back()->with('success', 'Called '.$ticket['ticket_number'].'.');
$name = $ticket['customer_name'] ?? 'patient';
return back()->with('success', 'Called '.$ticket['ticket_number'].' — '.$name.'.');
}
protected function renderShell(
@@ -341,22 +381,6 @@ class SpecialtyModuleController extends Controller
$departmentIds = $departments->pluck('id')->all();
$waiting = Appointment::owned($owner)
->where('organization_id', $organization->id)
->whereIn('status', [Appointment::STATUS_WAITING, Appointment::STATUS_CHECKED_IN])
->when($departmentIds !== [], fn ($q) => $q->whereIn('department_id', $departmentIds))
->when($departmentIds === [], fn ($q) => $q->whereRaw('1 = 0'))
->when($branchScope, fn ($q) => $q->where('branch_id', $branchScope))
->when(
$practitionerScope !== null,
fn ($q) => $q->whereIn('practitioner_id', $practitionerScope ?: [0]),
)
->with(['patient', 'practitioner', 'department', 'visit'])
->orderBy('queue_position')
->orderBy('checked_in_at')
->limit(25)
->get();
$branchId = (int) ($branchScope ?: $departments->first()?->branch_id);
if ($practitionerScope !== null && $practitionerScope !== []) {
$pracBranch = (int) \App\Models\Practitioner::owned($owner)
@@ -367,11 +391,32 @@ class SpecialtyModuleController extends Controller
}
}
// Match Call next: when the member has no branch scope (e.g. hospital admin),
// KPIs / waiting lists use the same branch as queue ops so WAITING isn't org-wide
// while Call next is local.
$kpiBranch = $branchScope ?? ($branchId > 0 ? $branchId : null);
$waiting = Appointment::owned($owner)
->where('organization_id', $organization->id)
->whereIn('status', [Appointment::STATUS_WAITING, Appointment::STATUS_CHECKED_IN])
->when($departmentIds !== [], fn ($q) => $q->whereIn('department_id', $departmentIds))
->when($departmentIds === [], fn ($q) => $q->whereRaw('1 = 0'))
->when($kpiBranch, fn ($q) => $q->where('branch_id', $kpiBranch))
->when(
$practitionerScope !== null,
fn ($q) => $q->whereIn('practitioner_id', $practitionerScope ?: [0]),
)
->with(['patient', 'practitioner', 'department', 'visit'])
->orderBy('queue_position')
->orderBy('checked_in_at')
->limit(25)
->get();
$shellDefinition = $shell->definition($module);
$kpis = $shell->kpis($organization, $module, $owner, $branchScope, $practitionerScope);
$openVisits = $shell->openVisits($organization, $module, $owner, $branchScope, $practitionerScope);
$kpis = $shell->kpis($organization, $module, $owner, $kpiBranch, $practitionerScope);
$openVisits = $shell->openVisits($organization, $module, $owner, $kpiBranch, $practitionerScope);
$visitHistory = $section === 'history'
? $shell->visitHistory($organization, $module, $owner, $branchScope, $practitionerScope)
? $shell->visitHistory($organization, $module, $owner, $kpiBranch, $practitionerScope)
: collect();
$services = $shell->provisionedServices($organization, $module);
@@ -472,6 +517,7 @@ class SpecialtyModuleController extends Controller
'visitDocuments' => $visitDocuments,
'queueStubs' => $modules->queueStubsFor($organization, $module),
'branchId' => $branchId,
'practitionerScope' => $practitionerScope,
'queueIntegration' => $branchId
? $queueBridge->listMeta($organization, $module, $branchId)
: ['enabled' => false],
+55 -1
View File
@@ -346,18 +346,72 @@ class CareQueueBridge
$ticket = $this->attemptCallNext($organization, $callResources);
}
// Admins / reception without a desk: try every service point on this queue so
// Call next matches the specialty waiting list instead of one empty desk.
if (! $ticket && ! $practitionerId && (
CareQueueContexts::isSpecialty($context)
|| $context === CareQueueContexts::CONSULTATION
)) {
$ticket = $this->attemptCallNextAcrossPoints($organization, $resources, $point);
}
if (! $ticket) {
return ['ticket' => null, 'entity' => null, 'resources' => $resources];
}
$entity = $this->findEntityByTicket($organization, $context, (string) ($ticket['uuid'] ?? ''));
if ($entity) {
$this->applyTicket($entity, $ticket, $point);
$matchedPoint = $point;
$ticketPointUuid = (string) data_get($ticket, 'assigned_counter.uuid', data_get($ticket, 'counter.uuid', ''));
if ($ticketPointUuid !== '') {
$matchedPoint = collect($resources['points'] ?? [])->first(
fn ($p) => ($p['counter_uuid'] ?? null) === $ticketPointUuid
) ?? $point;
}
$this->applyTicket($entity, $ticket, $matchedPoint);
}
return ['ticket' => $ticket, 'entity' => $entity?->fresh(), 'resources' => $resources];
}
/**
* @param array{queue_uuid?: ?string, counter_uuid?: ?string, points?: list<array<string, mixed>>} $resources
* @param array<string, mixed> $preferredPoint
* @return array<string, mixed>|null
*/
protected function attemptCallNextAcrossPoints(
Organization $organization,
array $resources,
array $preferredPoint,
): ?array {
$queueUuid = (string) ($resources['queue_uuid'] ?? '');
if ($queueUuid === '') {
return null;
}
$preferredUuid = (string) ($preferredPoint['counter_uuid'] ?? '');
$points = collect($resources['points'] ?? [])
->filter(fn ($p) => ! empty($p['counter_uuid']))
->sortBy(fn ($p) => ($p['counter_uuid'] ?? '') === $preferredUuid ? 0 : 1)
->values();
foreach ($points as $candidate) {
$uuid = (string) $candidate['counter_uuid'];
if ($uuid === $preferredUuid) {
continue;
}
$ticket = $this->attemptCallNext($organization, [
'queue_uuid' => $queueUuid,
'counter_uuid' => $uuid,
]);
if ($ticket) {
return $ticket;
}
}
return null;
}
/**
* @param array{queue_uuid?: ?string, counter_uuid?: ?string} $resources
* @return array<string, mixed>|null