Fix Call next when doctor desk is missing from cached points.
Deploy Ladill Care / deploy (push) Successful in 48s
Deploy Ladill Care / deploy (push) Successful in 48s
Assigned doctors could see board waiters while Call next returned empty because ensure() early-returned without provisioning a newly linked desk. Re-provision on point miss, recover stale tickets, and align empty messaging with visible waiters. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -125,7 +125,12 @@ class QueueController extends Controller
|
|||||||
|
|
||||||
$waitingCountQuery = Appointment::owned($owner)
|
$waitingCountQuery = Appointment::owned($owner)
|
||||||
->where('organization_id', $organization->id)
|
->where('organization_id', $organization->id)
|
||||||
->whereIn('status', [Appointment::STATUS_WAITING, Appointment::STATUS_CHECKED_IN]);
|
->whereIn('status', [Appointment::STATUS_WAITING, Appointment::STATUS_CHECKED_IN])
|
||||||
|
->where(function ($q) {
|
||||||
|
$q->whereNull('queue_ticket_status')
|
||||||
|
->orWhere('queue_ticket_status', '')
|
||||||
|
->orWhereNotIn('queue_ticket_status', ['called', 'serving']);
|
||||||
|
});
|
||||||
if ($lockToPractitioner) {
|
if ($lockToPractitioner) {
|
||||||
$waitingCountQuery->whereIn('practitioner_id', $practitionerScope ?: [0]);
|
$waitingCountQuery->whereIn('practitioner_id', $practitionerScope ?: [0]);
|
||||||
if ($branchId > 0) {
|
if ($branchId > 0) {
|
||||||
@@ -142,6 +147,14 @@ class QueueController extends Controller
|
|||||||
$waitingCountQuery->whereRaw('1 = 0');
|
$waitingCountQuery->whereRaw('1 = 0');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Hero Waiting must match the Waiting column (same list the doctor acts on).
|
||||||
|
$waitingHero = $queue->reject(
|
||||||
|
fn ($a) => in_array($a->queue_ticket_status ?? '', ['called', 'serving'], true)
|
||||||
|
)->count();
|
||||||
|
if ($queue->count() >= $boardLimit) {
|
||||||
|
$waitingHero = max($waitingHero, $waitingCountQuery->count());
|
||||||
|
}
|
||||||
|
|
||||||
$inCareCountQuery = Appointment::owned($owner)
|
$inCareCountQuery = Appointment::owned($owner)
|
||||||
->where('organization_id', $organization->id)
|
->where('organization_id', $organization->id)
|
||||||
->where('status', Appointment::STATUS_IN_CONSULTATION);
|
->where('status', Appointment::STATUS_IN_CONSULTATION);
|
||||||
@@ -172,7 +185,7 @@ class QueueController extends Controller
|
|||||||
->get();
|
->get();
|
||||||
|
|
||||||
$heroStats = [
|
$heroStats = [
|
||||||
'waiting' => $waitingCountQuery->count(),
|
'waiting' => $waitingHero,
|
||||||
'in_consultation' => $inCareCountQuery->count(),
|
'in_consultation' => $inCareCountQuery->count(),
|
||||||
'today' => $todayQuery->count(),
|
'today' => $todayQuery->count(),
|
||||||
];
|
];
|
||||||
@@ -258,6 +271,25 @@ class QueueController extends Controller
|
|||||||
? 'consultation'
|
? 'consultation'
|
||||||
: $context;
|
: $context;
|
||||||
|
|
||||||
|
$waitingVisible = Appointment::owned($owner)
|
||||||
|
->where('organization_id', $organization->id)
|
||||||
|
->where('branch_id', $branchId)
|
||||||
|
->whereIn('status', [Appointment::STATUS_WAITING, Appointment::STATUS_CHECKED_IN])
|
||||||
|
->when($practitionerId, fn ($q) => $q->where('practitioner_id', $practitionerId))
|
||||||
|
->where(function ($q) {
|
||||||
|
$q->whereNull('queue_ticket_status')
|
||||||
|
->orWhere('queue_ticket_status', '')
|
||||||
|
->orWhereNotIn('queue_ticket_status', ['called', 'serving']);
|
||||||
|
})
|
||||||
|
->count();
|
||||||
|
|
||||||
|
if ($waitingVisible > 0) {
|
||||||
|
return back()->with(
|
||||||
|
'info',
|
||||||
|
"{$waitingVisible} waiting but none are callable at your {$label} desk right now (payment hold or specialty mismatch)."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return back()->with('info', "No patients waiting at your {$label} service point.");
|
return back()->with('info', "No patients waiting at your {$label} service point.");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -455,16 +455,37 @@ class CareQueueBridge
|
|||||||
): array {
|
): array {
|
||||||
$empty = ['ticket' => null, 'entity' => null, 'resources' => $resources];
|
$empty = ['ticket' => null, 'entity' => null, 'resources' => $resources];
|
||||||
|
|
||||||
$appointment = $this->nextCallableAppointment($organization, $context, $branchId, $practitionerId);
|
$candidates = $this->callableAppointments($organization, $context, $branchId, $practitionerId);
|
||||||
if (! $appointment) {
|
if ($candidates === []) {
|
||||||
$this->syncMissingTickets($organization, $context, $branchId, 25);
|
$this->syncMissingTickets($organization, $context, $branchId, 25);
|
||||||
$appointment = $this->nextCallableAppointment($organization, $context, $branchId, $practitionerId);
|
$candidates = $this->callableAppointments($organization, $context, $branchId, $practitionerId);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (! $appointment) {
|
foreach ($candidates as $appointment) {
|
||||||
return $empty;
|
$called = $this->callSpecificAppointment($organization, $context, $branchId, $appointment, $resources, $point);
|
||||||
|
if ($called['ticket']) {
|
||||||
|
return $called;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return $empty;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array{queue_uuid?: ?string, counter_uuid?: ?string, points?: list<array<string, mixed>>} $resources
|
||||||
|
* @param array<string, mixed> $point
|
||||||
|
* @return array{ticket: ?array<string, mixed>, entity: ?Model, resources: array<string, mixed>}
|
||||||
|
*/
|
||||||
|
protected function callSpecificAppointment(
|
||||||
|
Organization $organization,
|
||||||
|
string $context,
|
||||||
|
int $branchId,
|
||||||
|
Appointment $appointment,
|
||||||
|
array $resources,
|
||||||
|
array $point,
|
||||||
|
): array {
|
||||||
|
$empty = ['ticket' => null, 'entity' => null, 'resources' => $resources];
|
||||||
|
|
||||||
if (blank($appointment->queue_ticket_uuid)) {
|
if (blank($appointment->queue_ticket_uuid)) {
|
||||||
$appointment = $this->issueForAppointment(
|
$appointment = $this->issueForAppointment(
|
||||||
$organization,
|
$organization,
|
||||||
@@ -505,6 +526,43 @@ class CareQueueBridge
|
|||||||
(string) $point['counter_uuid'],
|
(string) $point['counter_uuid'],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Stale uuid / wrong queue / non-waiting row — clear and re-issue onto this desk.
|
||||||
|
if (! $ticket) {
|
||||||
|
$appointment->forceFill([
|
||||||
|
'queue_ticket_uuid' => null,
|
||||||
|
'queue_ticket_number' => null,
|
||||||
|
'queue_ticket_status' => null,
|
||||||
|
'queue_service_point_uuid' => null,
|
||||||
|
])->save();
|
||||||
|
|
||||||
|
$ticket = $this->issue(
|
||||||
|
$organization,
|
||||||
|
$context,
|
||||||
|
$branchId,
|
||||||
|
$appointment->patient,
|
||||||
|
[
|
||||||
|
'care_entity' => 'appointment',
|
||||||
|
'care_entity_uuid' => $appointment->uuid,
|
||||||
|
'care_entity_id' => $appointment->id,
|
||||||
|
'practitioner_id' => $appointment->practitioner_id,
|
||||||
|
],
|
||||||
|
$appointment->type === Appointment::TYPE_WALK_IN ? 'walk_in' : 'appointment',
|
||||||
|
'appointment',
|
||||||
|
$point,
|
||||||
|
);
|
||||||
|
if (! $ticket) {
|
||||||
|
return $empty;
|
||||||
|
}
|
||||||
|
$this->applyTicket($appointment, $ticket, $point);
|
||||||
|
$appointment = $appointment->fresh() ?? $appointment;
|
||||||
|
|
||||||
|
$ticket = $this->attemptCallWaitingTicket(
|
||||||
|
$organization,
|
||||||
|
(string) $appointment->queue_ticket_uuid,
|
||||||
|
(string) $point['counter_uuid'],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
if (! $ticket) {
|
if (! $ticket) {
|
||||||
return $empty;
|
return $empty;
|
||||||
}
|
}
|
||||||
@@ -518,12 +576,15 @@ class CareQueueBridge
|
|||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
protected function nextCallableAppointment(
|
/**
|
||||||
|
* @return list<Appointment>
|
||||||
|
*/
|
||||||
|
protected function callableAppointments(
|
||||||
Organization $organization,
|
Organization $organization,
|
||||||
string $context,
|
string $context,
|
||||||
int $branchId,
|
int $branchId,
|
||||||
?int $practitionerId,
|
?int $practitionerId,
|
||||||
): ?Appointment {
|
): array {
|
||||||
$owner = (string) $organization->owner_ref;
|
$owner = (string) $organization->owner_ref;
|
||||||
|
|
||||||
$query = Appointment::owned($owner)
|
$query = Appointment::owned($owner)
|
||||||
@@ -546,6 +607,7 @@ class CareQueueBridge
|
|||||||
$query->where('practitioner_id', $practitionerId);
|
$query->where('practitioner_id', $practitionerId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$callable = [];
|
||||||
foreach ($query->get() as $appointment) {
|
foreach ($query->get() as $appointment) {
|
||||||
if ($this->contextForAppointment($organization, $appointment) !== $context) {
|
if ($this->contextForAppointment($organization, $appointment) !== $context) {
|
||||||
continue;
|
continue;
|
||||||
@@ -557,10 +619,10 @@ class CareQueueBridge
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
return $appointment;
|
$callable[] = $appointment;
|
||||||
}
|
}
|
||||||
|
|
||||||
return null;
|
return $callable;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -475,6 +475,10 @@ class CareQueueProvisioner
|
|||||||
/**
|
/**
|
||||||
* Resolve a service point for a practitioner on a queue context.
|
* Resolve a service point for a practitioner on a queue context.
|
||||||
*
|
*
|
||||||
|
* When a doctor is linked after queues were first provisioned, ensure() may
|
||||||
|
* early-return with a stale points list — refresh provisioning on miss so
|
||||||
|
* Call next can still reach board waiters assigned to that desk.
|
||||||
|
*
|
||||||
* @return array<string, mixed>|null
|
* @return array<string, mixed>|null
|
||||||
*/
|
*/
|
||||||
public function pointForPractitioner(
|
public function pointForPractitioner(
|
||||||
@@ -482,17 +486,93 @@ class CareQueueProvisioner
|
|||||||
int $branchId,
|
int $branchId,
|
||||||
int $practitionerId,
|
int $practitionerId,
|
||||||
string $context = CareQueueContexts::CONSULTATION,
|
string $context = CareQueueContexts::CONSULTATION,
|
||||||
|
): ?array {
|
||||||
|
$point = $this->findPractitionerPoint($organization, $context, $branchId, $practitionerId);
|
||||||
|
if ($point && ! empty($point['counter_uuid'])) {
|
||||||
|
return $point;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! $this->shouldProvision($organization)) {
|
||||||
|
return $this->practitionerPointFromDb($organization, $context, $branchId, $practitionerId);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (CareQueueContexts::isSpecialty($context)) {
|
||||||
|
$this->provisionSpecialtyContextBranch($organization->fresh() ?? $organization, $context, $branchId);
|
||||||
|
} elseif (CareQueueContexts::isDepartment($context)) {
|
||||||
|
$this->provisionContextBranch($organization->fresh() ?? $organization, $context, $branchId);
|
||||||
|
} else {
|
||||||
|
return $this->practitionerPointFromDb($organization, $context, $branchId, $practitionerId);
|
||||||
|
}
|
||||||
|
|
||||||
|
$organization = $organization->fresh() ?? $organization;
|
||||||
|
$point = $this->findPractitionerPoint($organization, $context, $branchId, $practitionerId);
|
||||||
|
if ($point && ! empty($point['counter_uuid'])) {
|
||||||
|
return $point;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->practitionerPointFromDb($organization, $context, $branchId, $practitionerId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, mixed>|null
|
||||||
|
*/
|
||||||
|
protected function findPractitionerPoint(
|
||||||
|
Organization $organization,
|
||||||
|
string $context,
|
||||||
|
int $branchId,
|
||||||
|
int $practitionerId,
|
||||||
): ?array {
|
): ?array {
|
||||||
$resources = $this->resourcesFor($organization, $context, $branchId);
|
$resources = $this->resourcesFor($organization, $context, $branchId);
|
||||||
if (! $resources) {
|
if (! $resources) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
return collect($resources['points'])->first(
|
return collect($resources['points'] ?? [])->first(
|
||||||
fn ($p) => ($p['kind'] ?? '') === 'practitioner' && (int) ($p['ref_id'] ?? 0) === $practitionerId
|
fn ($p) => ($p['kind'] ?? '') === 'practitioner' && (int) ($p['ref_id'] ?? 0) === $practitionerId
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, mixed>|null
|
||||||
|
*/
|
||||||
|
protected function practitionerPointFromDb(
|
||||||
|
Organization $organization,
|
||||||
|
string $context,
|
||||||
|
int $branchId,
|
||||||
|
int $practitionerId,
|
||||||
|
): ?array {
|
||||||
|
$row = CareServicePoint::query()
|
||||||
|
->where('organization_id', $organization->id)
|
||||||
|
->where('kind', 'practitioner')
|
||||||
|
->where('ref_id', $practitionerId)
|
||||||
|
->where('is_active', true)
|
||||||
|
->whereHas(
|
||||||
|
'serviceQueue',
|
||||||
|
fn ($q) => $q
|
||||||
|
->where('organization_id', $organization->id)
|
||||||
|
->where('context', $context)
|
||||||
|
->where('branch_id', $branchId)
|
||||||
|
->where('is_active', true),
|
||||||
|
)
|
||||||
|
->first();
|
||||||
|
|
||||||
|
if (! $row) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
'kind' => 'practitioner',
|
||||||
|
'ref_id' => $practitionerId,
|
||||||
|
'name' => $row->name,
|
||||||
|
'destination' => $row->destination,
|
||||||
|
'staff_ref' => $row->staff_ref,
|
||||||
|
'staff_display_name' => $row->staff_display_name,
|
||||||
|
'external_key' => $row->external_key,
|
||||||
|
'counter_uuid' => $row->uuid,
|
||||||
|
'synced' => true,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @return array<string, mixed>|null
|
* @return array<string, mixed>|null
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -506,6 +506,93 @@ class CareQueueBridgeTest extends TestCase
|
|||||||
Http::assertNothingSent();
|
Http::assertNothingSent();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function test_assigned_doctor_call_next_succeeds_with_waiting_appointments(): void
|
||||||
|
{
|
||||||
|
Http::fake();
|
||||||
|
|
||||||
|
// Provision consultation desks for an existing GP first so ensure() early-returns
|
||||||
|
// with a points list that does not yet include the demo doctor we link next.
|
||||||
|
$other = \App\Models\Practitioner::create([
|
||||||
|
'owner_ref' => $this->owner->public_id,
|
||||||
|
'organization_id' => $this->organization->id,
|
||||||
|
'branch_id' => $this->branch->id,
|
||||||
|
'name' => 'Dr. Other Desk',
|
||||||
|
'room' => 'Room 1',
|
||||||
|
'is_active' => true,
|
||||||
|
]);
|
||||||
|
|
||||||
|
app(\App\Services\Care\CareQueueProvisioner::class)->ensure(
|
||||||
|
$this->organization->fresh(),
|
||||||
|
CareQueueContexts::CONSULTATION,
|
||||||
|
$this->branch->id,
|
||||||
|
);
|
||||||
|
|
||||||
|
$doctorUser = User::create([
|
||||||
|
'public_id' => 'bridge-assigned-doc',
|
||||||
|
'name' => 'Assigned Doctor',
|
||||||
|
'email' => 'bridge-assigned@example.com',
|
||||||
|
]);
|
||||||
|
$doctorMember = Member::create([
|
||||||
|
'owner_ref' => $this->owner->public_id,
|
||||||
|
'organization_id' => $this->organization->id,
|
||||||
|
'user_ref' => $doctorUser->public_id,
|
||||||
|
'role' => 'doctor',
|
||||||
|
'branch_id' => $this->branch->id,
|
||||||
|
]);
|
||||||
|
$doctor = \App\Models\Practitioner::create([
|
||||||
|
'owner_ref' => $this->owner->public_id,
|
||||||
|
'organization_id' => $this->organization->id,
|
||||||
|
'branch_id' => $this->branch->id,
|
||||||
|
'member_id' => $doctorMember->id,
|
||||||
|
'user_ref' => $doctorUser->public_id,
|
||||||
|
'name' => 'Dr. Assigned',
|
||||||
|
'room' => 'Room 9',
|
||||||
|
'is_active' => true,
|
||||||
|
]);
|
||||||
|
|
||||||
|
Appointment::create([
|
||||||
|
'owner_ref' => $this->owner->public_id,
|
||||||
|
'organization_id' => $this->organization->id,
|
||||||
|
'branch_id' => $this->branch->id,
|
||||||
|
'patient_id' => $this->patient->id,
|
||||||
|
'practitioner_id' => $doctor->id,
|
||||||
|
'type' => Appointment::TYPE_WALK_IN,
|
||||||
|
'status' => Appointment::STATUS_WAITING,
|
||||||
|
'scheduled_at' => now(),
|
||||||
|
'waiting_at' => now(),
|
||||||
|
'queue_position' => 1,
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Board KPI / Assigned to you still lists the waiter.
|
||||||
|
$this->actingAs($doctorUser)
|
||||||
|
->get(route('care.queue.index', ['branch_id' => $this->branch->id]))
|
||||||
|
->assertOk()
|
||||||
|
->assertSee('Assigned to you')
|
||||||
|
->assertSee('Ada Lovelace');
|
||||||
|
|
||||||
|
$response = $this->actingAs($doctorUser)
|
||||||
|
->from(route('care.queue.index', ['branch_id' => $this->branch->id]))
|
||||||
|
->post(route('care.queue.call-next'), [
|
||||||
|
'branch_id' => $this->branch->id,
|
||||||
|
])
|
||||||
|
->assertSessionMissing('info')
|
||||||
|
->assertSessionHas('success');
|
||||||
|
|
||||||
|
$appointment = Appointment::query()->where('practitioner_id', $doctor->id)->first();
|
||||||
|
$this->assertNotNull($appointment?->queue_ticket_uuid);
|
||||||
|
$this->assertSame('called', $appointment->queue_ticket_status);
|
||||||
|
$response->assertRedirect(route('care.appointments.show', $appointment));
|
||||||
|
|
||||||
|
$this->assertDatabaseHas('care_service_points', [
|
||||||
|
'organization_id' => $this->organization->id,
|
||||||
|
'kind' => 'practitioner',
|
||||||
|
'ref_id' => $doctor->id,
|
||||||
|
'is_active' => true,
|
||||||
|
]);
|
||||||
|
$this->assertNotNull($other->fresh());
|
||||||
|
Http::assertNothingSent();
|
||||||
|
}
|
||||||
|
|
||||||
public function test_call_next_reclaims_ticket_from_wrong_desk_and_announces(): void
|
public function test_call_next_reclaims_ticket_from_wrong_desk_and_announces(): void
|
||||||
{
|
{
|
||||||
Http::fake();
|
Http::fake();
|
||||||
|
|||||||
Reference in New Issue
Block a user