Fix Call next when Care waiters lack Queue tickets.
Deploy Ladill Care / deploy (push) Successful in 1m35s

Demo/pre-integration waiting lists showed patients but Call next hit an empty Queue and flashed a red error. Backfill missing tickets on Call next and queue load, and treat empty Call next as info.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
isaacclad
2026-07-17 16:53:52 +00:00
co-authored by Cursor
parent 015a4cc7fe
commit c5151ad476
11 changed files with 393 additions and 23 deletions
@@ -0,0 +1,86 @@
<?php
namespace App\Console\Commands;
use App\Models\Branch;
use App\Models\Organization;
use App\Services\Care\CareQueueBridge;
use App\Services\Care\CareQueueContexts;
use Illuminate\Console\Command;
/**
* Backfill Ladill Queue tickets for Care waiters that never received one
* (e.g. demo seed or patients checked in before Queue integration was enabled).
*/
class SyncCareQueueTicketsCommand extends Command
{
protected $signature = 'care:queue-sync-tickets
{--organization= : Organization id (default: all with Queue integration on)}
{--branch= : Limit to one Care branch id}
{--context=consultation : consultation|pharmacy|laboratory|billing|all}
{--limit=100 : Max tickets to issue per branch+context}';
protected $description = 'Issue missing Queue tickets for Care waiting lists';
public function handle(CareQueueBridge $bridge): int
{
$contextOpt = strtolower((string) $this->option('context'));
$contexts = $contextOpt === 'all'
? array_values(array_filter(
array_keys(CareQueueContexts::departments()),
fn (string $c) => $c !== CareQueueContexts::RECEPTION,
))
: [$contextOpt];
foreach ($contexts as $context) {
if ($context !== CareQueueContexts::RECEPTION && ! CareQueueContexts::isDepartment($context) && ! CareQueueContexts::isSpecialty($context)) {
$this->error("Unknown context: {$context}");
return self::FAILURE;
}
}
$orgQuery = Organization::query();
if ($this->option('organization')) {
$orgQuery->where('id', (int) $this->option('organization'));
}
$limit = max(1, (int) $this->option('limit'));
$branchFilter = $this->option('branch') ? (int) $this->option('branch') : null;
$total = 0;
foreach ($orgQuery->cursor() as $organization) {
if (! $bridge->isEnabled($organization)) {
continue;
}
$branches = Branch::owned((string) $organization->owner_ref)
->where('organization_id', $organization->id)
->where('is_active', true)
->when($branchFilter, fn ($q) => $q->where('id', $branchFilter))
->orderBy('id')
->get();
foreach ($branches as $branch) {
foreach ($contexts as $context) {
$issued = $bridge->syncMissingTickets($organization, $context, (int) $branch->id, $limit);
if ($issued > 0) {
$this->info(sprintf(
'org=%d branch=%d (%s) context=%s issued=%d',
$organization->id,
$branch->id,
$branch->name,
$context,
$issued,
));
}
$total += $issued;
}
}
}
$this->info("Done. Issued {$total} ticket(s).");
return self::SUCCESS;
}
}
+1 -1
View File
@@ -72,7 +72,7 @@ class BillController extends Controller
$result = $this->queueBridge->callNext($organization, CareQueueContexts::BILLING, $branchId);
$ticket = $result['ticket'] ?? null;
if (! $ticket) {
return back()->with('error', 'No bills waiting in the billing queue.');
return back()->with('info', 'No bills waiting in the billing queue.');
}
return back()->with('success', 'Called '.$ticket['ticket_number'].'.');
@@ -96,7 +96,7 @@ class InvestigationController extends Controller
$result = $this->queueBridge->callNext($organization, CareQueueContexts::LABORATORY, $branchId);
$ticket = $result['ticket'] ?? null;
if (! $ticket) {
return back()->with('error', 'No lab work waiting in the laboratory queue.');
return back()->with('info', 'No lab work waiting in the laboratory queue.');
}
return back()->with('success', 'Called '.$ticket['ticket_number'].'.');
@@ -98,7 +98,7 @@ class PrescriptionController extends Controller
$result = $this->queueBridge->callNext($organization, CareQueueContexts::PHARMACY, $branchId);
$ticket = $result['ticket'] ?? null;
if (! $ticket) {
return back()->with('error', 'No prescriptions waiting in the pharmacy queue.');
return back()->with('info', 'No prescriptions waiting in the pharmacy queue.');
}
return back()->with('success', 'Called '.$ticket['ticket_number'].'.');
@@ -43,6 +43,13 @@ class QueueController extends Controller
$branchId = (int) ($request->input('branch_id') ?: $branchScope ?: $branches->first()?->id);
$practitionerId = $request->input('practitioner_id') ? (int) $request->input('practitioner_id') : null;
$queueIntegration = ['enabled' => false];
if ($branchId && $this->queueBridge->isEnabled($organization)) {
// Catch up tickets for waiters created before Queue was linked (demo / pre-enable).
$this->queueBridge->syncMissingTickets($organization, CareQueueContexts::CONSULTATION, $branchId, 25);
$queueIntegration = $this->queueBridge->listMeta($organization, CareQueueContexts::CONSULTATION, $branchId);
}
$queue = $branchId
? $this->appointments->queue($this->ownerRef($request), $branchId, $practitionerId)
: collect();
@@ -86,9 +93,7 @@ class QueueController extends Controller
'canManageQueue' => $canManageQueue,
'canConsult' => $canConsult,
'heroStats' => $heroStats,
'queueIntegration' => $branchId
? $this->queueBridge->listMeta($organization, CareQueueContexts::CONSULTATION, $branchId)
: ['enabled' => false],
'queueIntegration' => $queueIntegration,
]);
}
@@ -106,7 +111,7 @@ class QueueController extends Controller
$result = $this->queueBridge->callNext($organization, CareQueueContexts::CONSULTATION, $branchId);
$ticket = $result['ticket'] ?? null;
if (! $ticket) {
return back()->with('error', 'No patients waiting in the consultation queue.');
return back()->with('info', 'No patients waiting in the consultation queue.');
}
return back()->with('success', 'Called '.$ticket['ticket_number'].' — '.($ticket['customer_name'] ?? 'patient').'.');
@@ -9,6 +9,8 @@ use App\Services\Care\AppointmentPatientNotifier;
use App\Services\Care\AuditLogger;
use App\Services\Care\CareFeatures;
use App\Services\Care\CarePermissions;
use App\Services\Care\CareQueueBridge;
use App\Services\Care\CareQueueContexts;
use App\Services\Care\CareQueueProvisioner;
use App\Services\Care\PlanService;
use App\Services\Billing\PlatformEmailClient;
@@ -155,7 +157,22 @@ class SettingsController extends Controller
if ($wantsQueue && $queue->configured()) {
try {
$provisioner->provisionOrganization($organization->fresh());
$organization = $organization->fresh();
$provisioner->provisionOrganization($organization);
// Backfill tickets for patients already waiting when Queue was just linked.
$bridge = app(CareQueueBridge::class);
$branches = Branch::owned($owner)
->where('organization_id', $organization->id)
->where('is_active', true)
->pluck('id');
foreach ($branches as $branchId) {
foreach (array_keys(CareQueueContexts::departments()) as $context) {
if ($context === CareQueueContexts::RECEPTION) {
continue;
}
$bridge->syncMissingTickets($organization, $context, (int) $branchId, 50);
}
}
} catch (\Throwable) {
// Best-effort; role pages will lazy-ensure on next use.
}
@@ -97,7 +97,7 @@ class SpecialtyModuleController extends Controller
$result = $queueBridge->callNext($organization, $module, $branchId);
$ticket = $result['ticket'] ?? null;
if (! $ticket) {
return back()->with('error', 'No patients waiting in this specialty queue.');
return back()->with('info', 'No patients waiting in this specialty queue.');
}
return back()->with('success', 'Called '.$ticket['ticket_number'].'.');
+163 -14
View File
@@ -175,8 +175,30 @@ class CareQueueBridge
return $bill->fresh();
}
/**
* Issue Queue tickets for Care waiting entities that were created before
* integration was enabled (or seeded without going through check-in).
*
* @return int Number of tickets successfully issued
*/
public function syncMissingTickets(Organization $organization, string $context, int $branchId, int $limit = 50): int
{
if (! $this->isEnabled($organization) || $branchId < 1 || $limit < 1) {
return 0;
}
return match (true) {
$context === CareQueueContexts::PHARMACY => $this->syncMissingPrescriptions($organization, $branchId, $limit),
$context === CareQueueContexts::LABORATORY => $this->syncMissingInvestigations($organization, $branchId, $limit),
$context === CareQueueContexts::BILLING => $this->syncMissingBills($organization, $branchId, $limit),
default => $this->syncMissingAppointments($organization, $context, $branchId, $limit),
};
}
/**
* Call the next waiting ticket for a Care page context + branch.
* Backfills missing tickets first so Call next works when Care shows waiters
* that never received a Queue ticket (demo seed / pre-integration check-ins).
*
* @return array{ticket: ?array<string, mixed>, entity: ?Model, resources: ?array<string, mixed>}
*/
@@ -189,20 +211,11 @@ class CareQueueBridge
$owner = (string) $organization->owner_ref;
try {
$ticket = $this->queue->callNext(
$owner,
(string) $resources['queue_uuid'],
(string) $resources['counter_uuid'],
);
} catch (RequestException $e) {
Log::warning('care.queue_call_next_failed', [
'context' => $context,
'branch_id' => $branchId,
'message' => $e->getMessage(),
]);
return ['ticket' => null, 'entity' => null, 'resources' => $resources];
$ticket = $this->attemptCallNext($owner, $resources);
if (! $ticket) {
// Care may list waiters without Queue tickets — issue one (or a batch) then retry.
$this->syncMissingTickets($organization, $context, $branchId, 25);
$ticket = $this->attemptCallNext($owner, $resources);
}
if (! $ticket) {
@@ -217,6 +230,27 @@ class CareQueueBridge
return ['ticket' => $ticket, 'entity' => $entity?->fresh(), 'resources' => $resources];
}
/**
* @param array{queue_uuid?: ?string, counter_uuid?: ?string} $resources
* @return array<string, mixed>|null
*/
protected function attemptCallNext(string $owner, array $resources): ?array
{
try {
return $this->queue->callNext(
$owner,
(string) $resources['queue_uuid'],
(string) $resources['counter_uuid'],
);
} catch (RequestException $e) {
Log::warning('care.queue_call_next_failed', [
'message' => $e->getMessage(),
]);
return null;
}
}
/**
* Mark ticket as serving (Queue "start").
*
@@ -383,4 +417,119 @@ class CareQueueBridge
->first(),
};
}
protected function syncMissingAppointments(Organization $organization, string $context, int $branchId, int $limit): int
{
$owner = (string) $organization->owner_ref;
$appointments = Appointment::owned($owner)
->where('organization_id', $organization->id)
->where('branch_id', $branchId)
->whereIn('status', [Appointment::STATUS_WAITING, Appointment::STATUS_CHECKED_IN])
->where(function ($q) {
$q->whereNull('queue_ticket_uuid')->orWhere('queue_ticket_uuid', '');
})
->with(['patient', 'organization'])
->orderBy('queue_position')
->orderBy('waiting_at')
->orderBy('checked_in_at')
->orderBy('id')
->limit($limit)
->get();
$issued = 0;
foreach ($appointments as $appointment) {
if ($this->contextForAppointment($organization, $appointment) !== $context) {
continue;
}
$updated = $this->issueForAppointment($organization, $appointment->fresh(['patient', 'organization']));
if ($updated && filled($updated->queue_ticket_uuid)) {
$issued++;
}
}
return $issued;
}
protected function syncMissingPrescriptions(Organization $organization, int $branchId, int $limit): int
{
$owner = (string) $organization->owner_ref;
$prescriptions = Prescription::owned($owner)
->where('organization_id', $organization->id)
->where('status', Prescription::STATUS_ACTIVE)
->where(function ($q) {
$q->whereNull('queue_ticket_uuid')->orWhere('queue_ticket_uuid', '');
})
->whereHas('visit', fn ($q) => $q->where('branch_id', $branchId))
->with(['patient', 'visit'])
->orderBy('id')
->limit($limit)
->get();
$issued = 0;
foreach ($prescriptions as $prescription) {
$updated = $this->issueForPrescription($organization, $prescription);
if ($updated && filled($updated->queue_ticket_uuid)) {
$issued++;
}
}
return $issued;
}
protected function syncMissingInvestigations(Organization $organization, int $branchId, int $limit): int
{
$owner = (string) $organization->owner_ref;
$requests = InvestigationRequest::owned($owner)
->where('organization_id', $organization->id)
->where('branch_id', $branchId)
->whereIn('status', [
InvestigationRequest::STATUS_PENDING,
InvestigationRequest::STATUS_SAMPLE_COLLECTED,
InvestigationRequest::STATUS_IN_PROGRESS,
])
->where(function ($q) {
$q->whereNull('queue_ticket_uuid')->orWhere('queue_ticket_uuid', '');
})
->with('patient')
->orderBy('id')
->limit($limit)
->get();
$issued = 0;
foreach ($requests as $request) {
$updated = $this->issueForInvestigation($organization, $request);
if ($updated && filled($updated->queue_ticket_uuid)) {
$issued++;
}
}
return $issued;
}
protected function syncMissingBills(Organization $organization, int $branchId, int $limit): int
{
$owner = (string) $organization->owner_ref;
$bills = Bill::owned($owner)
->where('organization_id', $organization->id)
->where('branch_id', $branchId)
->whereIn('status', [Bill::STATUS_OPEN, Bill::STATUS_PARTIAL, Bill::STATUS_DRAFT])
->where(function ($q) {
$q->whereNull('queue_ticket_uuid')->orWhere('queue_ticket_uuid', '');
})
->with('patient')
->orderBy('id')
->limit($limit)
->get();
$issued = 0;
foreach ($bills as $bill) {
$updated = $this->issueForBill($organization, $bill);
if ($updated && filled($updated->queue_ticket_uuid)) {
$issued++;
}
}
return $issued;
}
}
+32
View File
@@ -140,6 +140,8 @@ class DemoTenantSeeder
);
}
$this->syncQueueTicketsForDemo($organization->fresh(), $branches);
return [
'organization' => $organization->fresh(),
'branches' => count($branches),
@@ -150,6 +152,36 @@ class DemoTenantSeeder
});
}
/**
* @param list<Branch> $branches
*/
private function syncQueueTicketsForDemo(Organization $organization, array $branches): void
{
if (! data_get($organization->settings, 'queue_integration_enabled')) {
return;
}
try {
$bridge = app(CareQueueBridge::class);
if (! $bridge->isEnabled($organization)) {
return;
}
foreach ($branches as $branch) {
foreach ([
CareQueueContexts::CONSULTATION,
CareQueueContexts::PHARMACY,
CareQueueContexts::LABORATORY,
CareQueueContexts::BILLING,
] as $context) {
$bridge->syncMissingTickets($organization, $context, (int) $branch->id, 100);
}
}
} catch (\Throwable) {
// Demo seed should not fail if Queue API is unreachable.
}
}
public function resetTenant(string $ownerRef): void
{
$orgIds = DB::table('care_organizations')->where('owner_ref', $ownerRef)->pluck('id');
+8
View File
@@ -14,6 +14,14 @@
</div>
@endif
@if (session('info'))
<div x-data="{ show: true }" x-show="show" x-init="setTimeout(() => show = false, 5000)" x-transition class="mb-4">
<div class="rounded-lg bg-sky-50 border border-sky-200 p-4">
<p class="text-sm text-sky-800">{{ session('info') }}</p>
</div>
</div>
@endif
@if (session('warning'))
<div x-data="{ show: true }" x-show="show" x-init="setTimeout(() => show = false, 5000)" x-transition class="mb-4">
<div class="rounded-lg bg-yellow-50 border border-yellow-200 p-4">
+73
View File
@@ -190,4 +190,77 @@ class CareQueueBridgeTest extends TestCase
->assertSee('Call next')
->assertSee('Serve');
}
public function test_call_next_backfills_missing_ticket_then_calls(): void
{
$ticketSeq = 0;
Http::fake(function (\Illuminate\Http\Client\Request $request) use (&$ticketSeq) {
if (str_contains($request->url(), '/call-next') && $request->method() === 'POST') {
// First call: empty queue; second (after issue): ticket returned.
if ($ticketSeq < 1) {
return Http::response(['data' => null], 200);
}
return Http::response([
'data' => [
'uuid' => 'ffffffff-ffff-ffff-ffff-ffffffffffff',
'ticket_number' => 'C100',
'customer_name' => 'Ada Lovelace',
'status' => 'called',
],
], 200);
}
if (str_contains($request->url(), '/tickets') && $request->method() === 'POST') {
$ticketSeq++;
return Http::response([
'data' => [
'uuid' => 'ffffffff-ffff-ffff-ffff-ffffffffffff',
'ticket_number' => 'C100',
'status' => 'waiting',
],
], 201);
}
return Http::response(['message' => 'unexpected '.$request->url()], 500);
});
Appointment::create([
'owner_ref' => $this->owner->public_id,
'organization_id' => $this->organization->id,
'branch_id' => $this->branch->id,
'patient_id' => $this->patient->id,
'type' => Appointment::TYPE_WALK_IN,
'status' => Appointment::STATUS_WAITING,
'scheduled_at' => now(),
'waiting_at' => now(),
'queue_position' => 1,
]);
$this->actingAs($this->owner)
->from(route('care.queue.index'))
->post(route('care.queue.call-next'), ['branch_id' => $this->branch->id])
->assertRedirect(route('care.queue.index'))
->assertSessionHas('success');
$this->assertNotNull(Appointment::query()->where('patient_id', $this->patient->id)->value('queue_ticket_uuid'));
Http::assertSent(fn ($r) => str_contains($r->url(), '/tickets') && $r->method() === 'POST');
Http::assertSent(fn ($r) => str_contains($r->url(), '/call-next'));
}
public function test_call_next_empty_queue_flashes_info_not_error(): void
{
Http::fake([
'*/queues/*/call-next*' => Http::response(['data' => null], 200),
]);
$this->actingAs($this->owner)
->from(route('care.queue.index'))
->post(route('care.queue.call-next'), ['branch_id' => $this->branch->id])
->assertRedirect(route('care.queue.index'))
->assertSessionHas('info', 'No patients waiting in the consultation queue.')
->assertSessionMissing('error');
}
}