Fix Call next when Care waiters lack Queue tickets.
Deploy Ladill Care / deploy (push) Successful in 1m35s
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:
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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'].'.');
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user