Wire Care lists into Ladill Queue workflows instead of reception panels.
Deploy Ladill Care / deploy (push) Successful in 1m45s

When Queue integration is on, role pages issue tickets into their own
department queues and drive Call next → Serve → Complete on the native
lists. Integration off leaves existing UI unchanged.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
isaacclad
2026-07-17 16:27:48 +00:00
co-authored by Cursor
parent 15638d1672
commit 015a4cc7fe
38 changed files with 1857 additions and 196 deletions
@@ -11,7 +11,6 @@ use App\Models\Patient;
use App\Models\Practitioner;
use App\Services\Care\AppointmentService;
use App\Services\Care\OrganizationResolver;
use App\Services\Care\ServiceQueuePresenter;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\View\View;
@@ -22,7 +21,6 @@ class AppointmentController extends Controller
public function __construct(
protected AppointmentService $appointments,
protected ServiceQueuePresenter $serviceQueues,
) {}
public function index(Request $request): View
@@ -61,13 +59,6 @@ class AppointmentController extends Controller
'practitioners' => $practitioners,
'statuses' => config('care.appointment_statuses'),
'heroStats' => $heroStats,
'serviceQueuePanel' => $this->serviceQueues->panel(
$request,
$organization,
$member,
'clinical',
$request->input('counter_uuid'),
),
]);
}
+51 -1
View File
@@ -7,6 +7,8 @@ use App\Http\Controllers\Care\Concerns\ScopesToAccount;
use App\Models\Bill;
use App\Models\Visit;
use App\Services\Care\BillService;
use App\Services\Care\CareQueueBridge;
use App\Services\Care\CareQueueContexts;
use App\Services\Care\OrganizationResolver;
use App\Services\Payments\MerchantGatewayService;
use Illuminate\Http\RedirectResponse;
@@ -21,13 +23,15 @@ class BillController extends Controller
public function __construct(
protected BillService $bills,
protected MerchantGatewayService $gateway,
protected CareQueueBridge $queueBridge,
) {}
public function index(Request $request): View
{
$this->authorizeAbility($request, 'bills.view');
$organization = $this->organization($request);
$branchScope = app(OrganizationResolver::class)->branchScope($this->member($request));
$member = $this->member($request);
$branchScope = app(OrganizationResolver::class)->branchScope($member);
$bills = $this->bills->list(
$this->ownerRef($request),
@@ -36,13 +40,59 @@ class BillController extends Controller
$branchScope,
);
$branchId = (int) ($branchScope ?: \App\Models\Branch::owned($this->ownerRef($request))
->where('organization_id', $organization->id)
->where('is_active', true)
->orderBy('name')
->value('id'));
return view('care.bills.index', [
'organization' => $organization,
'bills' => $bills,
'statuses' => config('care.bill_statuses'),
'branchId' => $branchId,
'queueIntegration' => $branchId
? $this->queueBridge->listMeta($organization, CareQueueContexts::BILLING, $branchId)
: ['enabled' => false],
'canManageQueue' => app(\App\Services\Care\CarePermissions::class)
->can($member, 'service_queues.console'),
]);
}
public function callNext(Request $request): RedirectResponse
{
$this->authorizeAbility($request, 'bills.manage');
$organization = $this->organization($request);
$member = $this->member($request);
$branchScope = app(OrganizationResolver::class)->branchScope($member);
$branchId = (int) ($request->input('branch_id') ?: $branchScope);
abort_unless($branchId > 0, 422);
abort_unless($this->queueBridge->isEnabled($organization), 404);
$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('success', 'Called '.$ticket['ticket_number'].'.');
}
public function serve(Request $request, Bill $bill): RedirectResponse
{
$this->authorizeAbility($request, 'bills.manage');
$this->authorizeBill($request, $bill);
$organization = $this->organization($request);
abort_unless($this->queueBridge->isEnabled($organization), 404);
$ticket = $this->queueBridge->serve($organization, $bill);
if (! $ticket) {
return back()->with('error', 'Could not serve billing ticket.');
}
return back()->with('success', 'Now serving '.$ticket['ticket_number'].'.');
}
public function generate(Request $request, Visit $visit): RedirectResponse
{
$this->authorizeAbility($request, 'bills.manage');
@@ -12,7 +12,6 @@ use App\Services\Care\AppointmentService;
use App\Services\Care\CarePermissions;
use App\Services\Care\OrganizationResolver;
use App\Services\Care\ReportService;
use App\Services\Care\ServiceQueuePresenter;
use App\Services\Care\SpecialtyModuleService;
use Illuminate\Http\Request;
use Illuminate\Support\Collection;
@@ -26,7 +25,6 @@ class DashboardController extends Controller
protected ReportService $reports,
protected CarePermissions $permissions,
protected AppointmentService $appointments,
protected ServiceQueuePresenter $serviceQueues,
protected SpecialtyModuleService $specialtyModules,
) {}
@@ -80,12 +78,6 @@ class DashboardController extends Controller
? $this->patientQueueForMember($request, $organization->id, $owner, $member, $branchScope)
: [collect(), collect(), null];
$serviceQueueContext = match ($member?->role) {
'pharmacist' => 'pharmacy',
'lab_technician' => 'laboratory',
default => 'clinical',
};
return view('care.dashboard', [
'organization' => $organization,
'member' => $member,
@@ -103,13 +95,6 @@ class DashboardController extends Controller
'patientQueue' => $queue,
'inConsultation' => $inConsultation,
'practitionerId' => $practitionerId,
'serviceQueuePanel' => $this->serviceQueues->panel(
$request,
$organization,
$member,
$serviceQueueContext,
$request->input('counter_uuid'),
),
'specialtyModules' => $this->specialtyModules->enabledModules($organization),
]);
}
@@ -9,9 +9,10 @@ use App\Models\Consultation;
use App\Models\InvestigationRequest;
use App\Models\InvestigationType;
use App\Models\Member;
use App\Services\Care\CareQueueBridge;
use App\Services\Care\CareQueueContexts;
use App\Services\Care\InvestigationService;
use App\Services\Care\OrganizationResolver;
use App\Services\Care\ServiceQueuePresenter;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\View\View;
@@ -22,7 +23,7 @@ class InvestigationController extends Controller
public function __construct(
protected InvestigationService $investigations,
protected ServiceQueuePresenter $serviceQueues,
protected CareQueueBridge $queueBridge,
) {}
public function index(Request $request): View
@@ -76,16 +77,46 @@ class InvestigationController extends Controller
->where('organization_id', $organization->id)
->orderBy('role')
->get(),
'serviceQueuePanel' => $this->serviceQueues->panel(
$request,
$organization,
$this->member($request),
'laboratory',
$request->input('counter_uuid'),
),
'queueIntegration' => $branchId
? $this->queueBridge->listMeta($organization, CareQueueContexts::LABORATORY, $branchId)
: ['enabled' => false],
]);
}
public function callNext(Request $request): RedirectResponse
{
$this->authorizeAbility($request, 'lab.manage');
$organization = $this->organization($request);
$member = $this->member($request);
$branchScope = app(OrganizationResolver::class)->branchScope($member);
$branchId = (int) ($request->input('branch_id') ?: $branchScope);
abort_unless($branchId > 0, 422);
abort_unless($this->queueBridge->isEnabled($organization), 404);
$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('success', 'Called '.$ticket['ticket_number'].'.');
}
public function serve(Request $request, InvestigationRequest $investigation): RedirectResponse
{
$this->authorizeAbility($request, 'lab.manage');
$this->authorizeInvestigation($request, $investigation);
$organization = $this->organization($request);
abort_unless($this->queueBridge->isEnabled($organization), 404);
$ticket = $this->queueBridge->serve($organization, $investigation);
if (! $ticket) {
return back()->with('error', 'Could not serve lab ticket.');
}
return back()->with('success', 'Now serving '.$ticket['ticket_number'].'.');
}
public function requestFromConsultation(Request $request, Consultation $consultation): RedirectResponse
{
$this->authorizeAbility($request, 'investigations.request');
@@ -7,10 +7,11 @@ use App\Http\Controllers\Care\Concerns\ScopesToAccount;
use App\Models\Consultation;
use App\Models\Prescription;
use App\Models\Practitioner;
use App\Services\Care\CareQueueBridge;
use App\Services\Care\CareQueueContexts;
use App\Services\Care\OrganizationResolver;
use App\Services\Care\PharmacyService;
use App\Services\Care\PrescriptionService;
use App\Services\Care\ServiceQueuePresenter;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\View\View;
@@ -22,7 +23,7 @@ class PrescriptionController extends Controller
public function __construct(
protected PrescriptionService $prescriptions,
protected PharmacyService $pharmacy,
protected ServiceQueuePresenter $serviceQueues,
protected CareQueueBridge $queueBridge,
) {}
public function index(Request $request): View
@@ -49,8 +50,18 @@ class PrescriptionController extends Controller
{
$this->authorizeAbility($request, 'prescriptions.view');
$organization = $this->organization($request);
$member = $this->member($request);
$branchScope = app(OrganizationResolver::class)->branchScope($member);
$branchId = (int) ($branchScope ?: \App\Models\Branch::owned($this->ownerRef($request))
->where('organization_id', $organization->id)
->where('is_active', true)
->orderBy('name')
->value('id'));
$queue = $this->prescriptions->pharmacyQueue($this->ownerRef($request), $organization->id);
if ($branchScope) {
$queue = $queue->filter(fn (Prescription $rx) => (int) ($rx->visit?->branch_id) === $branchScope)->values();
}
$drugs = \App\Models\Drug::owned($this->ownerRef($request))
->where('organization_id', $organization->id)
@@ -60,23 +71,54 @@ class PrescriptionController extends Controller
->get();
$canDispense = app(\App\Services\Care\CarePermissions::class)
->can($this->member($request), 'prescriptions.dispense');
->can($member, 'prescriptions.dispense');
return view('care.prescriptions.queue', [
'organization' => $organization,
'queue' => $queue,
'drugs' => $drugs,
'canDispense' => $canDispense,
'serviceQueuePanel' => $this->serviceQueues->panel(
$request,
$organization,
$this->member($request),
'pharmacy',
$request->input('counter_uuid'),
),
'branchId' => $branchId,
'queueIntegration' => $branchId
? $this->queueBridge->listMeta($organization, CareQueueContexts::PHARMACY, $branchId)
: ['enabled' => false],
]);
}
public function callNext(Request $request): RedirectResponse
{
$this->authorizeAbility($request, 'prescriptions.dispense');
$organization = $this->organization($request);
$member = $this->member($request);
$branchScope = app(OrganizationResolver::class)->branchScope($member);
$branchId = (int) ($request->input('branch_id') ?: $branchScope);
abort_unless($branchId > 0, 422);
abort_unless($this->queueBridge->isEnabled($organization), 404);
$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('success', 'Called '.$ticket['ticket_number'].'.');
}
public function serve(Request $request, Prescription $prescription): RedirectResponse
{
$this->authorizeAbility($request, 'prescriptions.dispense');
$this->authorizePrescription($request, $prescription);
$organization = $this->organization($request);
abort_unless($this->queueBridge->isEnabled($organization), 404);
$ticket = $this->queueBridge->serve($organization, $prescription);
if (! $ticket) {
return back()->with('error', 'Could not serve pharmacy ticket.');
}
return back()->with('success', 'Now serving '.$ticket['ticket_number'].'.');
}
public function create(Request $request, Consultation $consultation): View
{
$this->authorizeAbility($request, 'prescriptions.manage');
+41 -9
View File
@@ -8,9 +8,10 @@ use App\Models\Appointment;
use App\Models\Branch;
use App\Models\Practitioner;
use App\Services\Care\AppointmentService;
use App\Services\Care\CareQueueBridge;
use App\Services\Care\CareQueueContexts;
use App\Services\Care\ConsultationService;
use App\Services\Care\OrganizationResolver;
use App\Services\Care\ServiceQueuePresenter;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\View\View;
@@ -22,7 +23,7 @@ class QueueController extends Controller
public function __construct(
protected AppointmentService $appointments,
protected ConsultationService $consultations,
protected ServiceQueuePresenter $serviceQueues,
protected CareQueueBridge $queueBridge,
) {}
public function index(Request $request): View
@@ -85,16 +86,47 @@ class QueueController extends Controller
'canManageQueue' => $canManageQueue,
'canConsult' => $canConsult,
'heroStats' => $heroStats,
'serviceQueuePanel' => $this->serviceQueues->panel(
$request,
$organization,
$member,
'clinical',
$request->input('counter_uuid'),
),
'queueIntegration' => $branchId
? $this->queueBridge->listMeta($organization, CareQueueContexts::CONSULTATION, $branchId)
: ['enabled' => false],
]);
}
public function callNext(Request $request): RedirectResponse
{
$this->authorizeAbility($request, 'appointments.view');
$organization = $this->organization($request);
abort_unless($this->queueBridge->isEnabled($organization), 404);
$member = $this->member($request);
$branchScope = app(OrganizationResolver::class)->branchScope($member);
$branchId = (int) ($request->input('branch_id') ?: $branchScope);
abort_unless($branchId > 0, 422);
$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('success', 'Called '.$ticket['ticket_number'].' — '.($ticket['customer_name'] ?? 'patient').'.');
}
public function completeTicket(Request $request, Appointment $appointment): RedirectResponse
{
$this->authorizeAbility($request, 'consultations.manage');
$this->authorizeAppointment($request, $appointment);
$organization = $this->organization($request);
abort_unless($this->queueBridge->isEnabled($organization), 404);
$ticket = $this->queueBridge->complete($organization, $appointment);
if (! $ticket) {
return back()->with('error', 'Could not complete queue ticket.');
}
return back()->with('success', 'Completed '.$ticket['ticket_number'].'.');
}
public function start(Request $request, Appointment $appointment): RedirectResponse
{
$this->authorizeAbility($request, 'consultations.manage');
@@ -9,6 +9,7 @@ use App\Services\Care\AppointmentPatientNotifier;
use App\Services\Care\AuditLogger;
use App\Services\Care\CareFeatures;
use App\Services\Care\CarePermissions;
use App\Services\Care\CareQueueProvisioner;
use App\Services\Care\PlanService;
use App\Services\Billing\PlatformEmailClient;
use App\Services\Billing\PlatformSmsClient;
@@ -75,7 +76,7 @@ class SettingsController extends Controller
]);
}
public function update(Request $request, QueueClient $queue): RedirectResponse
public function update(Request $request, QueueClient $queue, CareQueueProvisioner $provisioner): RedirectResponse
{
$this->authorizeAbility($request, 'settings.manage');
$organization = $this->organization($request);
@@ -152,6 +153,14 @@ class SettingsController extends Controller
'settings' => $settings,
]);
if ($wantsQueue && $queue->configured()) {
try {
$provisioner->provisionOrganization($organization->fresh());
} catch (\Throwable) {
// Best-effort; role pages will lazy-ensure on next use.
}
}
AuditLogger::record($owner, 'organization.updated', $organization->id, $owner, \App\Models\Organization::class, $organization->id);
return back()->with('success', 'Settings saved.');
@@ -6,9 +6,10 @@ use App\Http\Controllers\Controller;
use App\Http\Controllers\Care\Concerns\ScopesToAccount;
use App\Models\Appointment;
use App\Models\Department;
use App\Services\Care\CareQueueBridge;
use App\Services\Care\OrganizationResolver;
use App\Services\Care\ServiceQueuePresenter;
use App\Services\Care\SpecialtyModuleService;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\View\View;
@@ -20,7 +21,7 @@ class SpecialtyModuleController extends Controller
Request $request,
string $module,
SpecialtyModuleService $modules,
ServiceQueuePresenter $presenter,
CareQueueBridge $queueBridge,
): View {
$definition = $modules->definition($module);
abort_unless($definition, 404);
@@ -59,13 +60,7 @@ class SpecialtyModuleController extends Controller
->limit(25)
->get();
$serviceQueuePanel = $presenter->panel(
$request,
$organization,
$member,
$module,
$request->input('counter_uuid'),
);
$branchId = (int) ($branchScope ?: $departments->first()?->branch_id);
return view('care.specialty.show', [
'organization' => $organization,
@@ -74,7 +69,37 @@ class SpecialtyModuleController extends Controller
'departments' => $departments,
'waiting' => $waiting,
'queueStubs' => $modules->queueStubsFor($organization, $module),
'serviceQueuePanel' => $serviceQueuePanel,
'branchId' => $branchId,
'queueIntegration' => $branchId
? $queueBridge->listMeta($organization, $module, $branchId)
: ['enabled' => false],
]);
}
public function callNext(
Request $request,
string $module,
SpecialtyModuleService $modules,
CareQueueBridge $queueBridge,
): RedirectResponse {
$definition = $modules->definition($module);
abort_unless($definition, 404);
$organization = $this->organization($request);
abort_unless($modules->isEnabled($organization, $module), 404);
abort_unless($queueBridge->isEnabled($organization), 404);
$member = $this->member($request);
$branchScope = app(OrganizationResolver::class)->branchScope($member);
$branchId = (int) ($request->input('branch_id') ?: $branchScope);
abort_unless($branchId > 0, 422);
$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('success', 'Called '.$ticket['ticket_number'].'.');
}
}
+2 -1
View File
@@ -34,7 +34,8 @@ class Appointment extends Model
protected $table = 'care_appointments';
protected $fillable = [
'uuid', 'owner_ref', 'organization_id', 'branch_id', 'patient_id',
'uuid', 'queue_ticket_uuid', 'queue_ticket_number', 'queue_ticket_status',
'owner_ref', 'organization_id', 'branch_id', 'patient_id',
'practitioner_id', 'department_id', 'visit_id', 'type', 'status',
'scheduled_at', 'checked_in_at', 'waiting_at', 'started_at',
'completed_at', 'cancelled_at', 'queue_position', 'reason', 'notes',
+2 -1
View File
@@ -26,7 +26,8 @@ class Bill extends Model
protected $table = 'care_bills';
protected $fillable = [
'uuid', 'owner_ref', 'organization_id', 'branch_id', 'visit_id', 'patient_id',
'uuid', 'queue_ticket_uuid', 'queue_ticket_number', 'queue_ticket_status',
'owner_ref', 'organization_id', 'branch_id', 'visit_id', 'patient_id',
'invoice_number', 'status', 'subtotal_minor', 'discount_minor', 'tax_minor',
'total_minor', 'amount_paid_minor', 'balance_minor', 'notes', 'created_by', 'finalized_at',
];
+2 -1
View File
@@ -30,7 +30,8 @@ class InvestigationRequest extends Model
protected $table = 'care_investigation_requests';
protected $fillable = [
'uuid', 'owner_ref', 'organization_id', 'branch_id', 'visit_id', 'consultation_id',
'uuid', 'queue_ticket_uuid', 'queue_ticket_number', 'queue_ticket_status',
'owner_ref', 'organization_id', 'branch_id', 'visit_id', 'consultation_id',
'patient_id', 'investigation_type_id', 'practitioner_id', 'status', 'priority',
'clinical_notes', 'requested_by', 'assigned_member_id', 'sample_barcode',
'sample_collected_at', 'sample_collected_by', 'completed_at', 'delivered_at',
+2 -1
View File
@@ -24,7 +24,8 @@ class Prescription extends Model
protected $table = 'care_prescriptions';
protected $fillable = [
'uuid', 'owner_ref', 'organization_id', 'visit_id', 'consultation_id',
'uuid', 'queue_ticket_uuid', 'queue_ticket_number', 'queue_ticket_status',
'owner_ref', 'organization_id', 'visit_id', 'consultation_id',
'patient_id', 'practitioner_id', 'status', 'notes',
'prescribed_by', 'dispensed_by', 'dispensed_at',
];
+23 -1
View File
@@ -19,6 +19,7 @@ class AppointmentService
public function __construct(
protected VisitService $visits,
protected AppointmentPatientNotifier $notifier,
protected CareQueueBridge $queueBridge,
) {}
public function queryForOrganization(string $ownerRef, int $organizationId): Builder
@@ -142,7 +143,10 @@ class AppointmentService
AuditLogger::record($ownerRef, 'appointment.walk_in', $organization->id, $actorRef, Appointment::class, $appointment->id);
return $appointment->load(['patient', 'practitioner', 'branch', 'visit']);
$appointment = $appointment->load(['patient', 'practitioner', 'branch', 'visit']);
$this->queueBridge->issueForAppointment($organization, $appointment);
return $appointment->fresh(['patient', 'practitioner', 'branch', 'visit']);
}
public function checkIn(Appointment $appointment, string $ownerRef, ?string $actorRef = null): Appointment
@@ -167,6 +171,9 @@ class AppointmentService
AuditLogger::record($ownerRef, 'appointment.checked_in', $appointment->organization_id, $actorRef, Appointment::class, $appointment->id);
$appointment = $appointment->fresh(['patient', 'practitioner', 'visit', 'organization']);
$this->queueBridge->issueForAppointment($appointment->organization, $appointment);
return $appointment->fresh(['patient', 'practitioner', 'visit']);
}
@@ -188,6 +195,11 @@ class AppointmentService
$appointment->update($updates);
AuditLogger::record($ownerRef, 'appointment.waiting', $appointment->organization_id, $actorRef, Appointment::class, $appointment->id);
$appointment = $appointment->fresh(['patient', 'practitioner', 'organization']);
if ($appointment->organization) {
$this->queueBridge->issueForAppointment($appointment->organization, $appointment);
}
return $appointment->fresh(['patient', 'practitioner']);
}
@@ -216,6 +228,11 @@ class AppointmentService
AuditLogger::record($ownerRef, 'appointment.in_consultation', $appointment->organization_id, $actorRef, Appointment::class, $appointment->id);
$appointment = $appointment->fresh(['patient', 'practitioner', 'visit', 'organization']);
if ($appointment->organization) {
$this->queueBridge->serve($appointment->organization, $appointment);
}
return $appointment->fresh(['patient', 'practitioner', 'visit']);
}
@@ -234,6 +251,11 @@ class AppointmentService
AuditLogger::record($ownerRef, 'appointment.completed', $appointment->organization_id, $actorRef, Appointment::class, $appointment->id);
$appointment = $appointment->fresh(['patient', 'practitioner', 'visit', 'organization']);
if ($appointment->organization) {
$this->queueBridge->complete($appointment->organization, $appointment);
}
return $appointment->fresh(['patient', 'practitioner', 'visit']);
}
+14
View File
@@ -22,6 +22,7 @@ class BillService
public function __construct(
protected InvoiceNumberGenerator $invoices,
protected MerchantGatewayService $gateway,
protected CareQueueBridge $queueBridge,
) {}
public function queryForOrganization(string $ownerRef, int $organizationId): Builder
@@ -81,6 +82,11 @@ class BillService
AuditLogger::record($ownerRef, 'bill.created', $visit->organization_id, $actorRef, Bill::class, $bill->id);
$bill = $bill->fresh(['lineItems', 'patient', 'payments']);
if ($visit->organization) {
$this->queueBridge->issueForBill($visit->organization, $bill);
}
return $bill->fresh(['lineItems', 'patient', 'payments']);
}
@@ -441,6 +447,7 @@ class BillService
$bill->refresh();
$paid = $this->paidSum($bill);
$balance = max(0, $bill->total_minor - $paid);
$wasPaid = $bill->status === Bill::STATUS_PAID;
$status = Bill::STATUS_OPEN;
if ($paid > 0 && $balance > 0) {
@@ -460,6 +467,13 @@ class BillService
'balance_minor' => $balance,
'status' => $status,
]);
if (! $wasPaid && $status === Bill::STATUS_PAID) {
$organization = Organization::query()->find($bill->organization_id);
if ($organization) {
$this->queueBridge->complete($organization, $bill);
}
}
}
protected function paidSum(Bill $bill): int
+1
View File
@@ -40,6 +40,7 @@ class CarePermissions
],
'cashier' => [
'dashboard.view', 'patients.view', 'bills.view', 'bills.manage', 'payments.manage',
'service_queues.console',
],
'accountant' => [
'dashboard.view', 'bills.view', 'reports.finance.view', 'reports.finance.export',
+386
View File
@@ -0,0 +1,386 @@
<?php
namespace App\Services\Care;
use App\Models\Appointment;
use App\Models\Bill;
use App\Models\InvestigationRequest;
use App\Models\Organization;
use App\Models\Patient;
use App\Models\Prescription;
use App\Services\Queue\QueueClient;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Http\Client\RequestException;
use Illuminate\Support\Facades\Log;
/**
* Bridges Care workflow entities to Ladill Queue tickets for the matching department queue.
*/
class CareQueueBridge
{
public function __construct(
protected QueueClient $queue,
protected CareQueueProvisioner $provisioner,
protected PlanService $plans,
protected SpecialtyModuleService $specialties,
) {}
public function isEnabled(Organization $organization): bool
{
return $this->plans->canUseQueueIntegration($organization)
&& (bool) data_get($organization->settings, 'queue_integration_enabled')
&& $this->queue->configured();
}
/**
* Resolve which queue context an appointment should use (specialty if applicable).
*/
public function contextForAppointment(Organization $organization, Appointment $appointment): string
{
$departmentId = $appointment->department_id;
if (! $departmentId) {
return CareQueueContexts::CONSULTATION;
}
foreach ($this->specialties->enabledKeys($organization) as $key) {
$deptIds = data_get($organization->settings, "specialty_module_provisioning.{$key}.department_ids", []);
if (is_array($deptIds) && in_array($departmentId, array_map('intval', $deptIds), true)) {
return $key;
}
}
return CareQueueContexts::CONSULTATION;
}
public function issueForAppointment(Organization $organization, Appointment $appointment): ?Appointment
{
if (! $this->isEnabled($organization) || filled($appointment->queue_ticket_uuid)) {
return $appointment;
}
$context = $this->contextForAppointment($organization, $appointment);
$ticket = $this->issue(
$organization,
$context,
(int) $appointment->branch_id,
$appointment->patient,
[
'care_entity' => 'appointment',
'care_entity_uuid' => $appointment->uuid,
'care_entity_id' => $appointment->id,
],
$appointment->type === Appointment::TYPE_WALK_IN ? 'walk_in' : 'appointment',
'appointment',
);
if (! $ticket) {
return $appointment;
}
$this->applyTicket($appointment, $ticket);
return $appointment->fresh();
}
public function issueForPrescription(Organization $organization, Prescription $prescription): ?Prescription
{
if (! $this->isEnabled($organization) || filled($prescription->queue_ticket_uuid)) {
return $prescription;
}
$prescription->loadMissing(['patient', 'visit']);
$branchId = (int) ($prescription->visit?->branch_id ?? 0);
if ($branchId < 1) {
return $prescription;
}
$ticket = $this->issue(
$organization,
CareQueueContexts::PHARMACY,
$branchId,
$prescription->patient,
[
'care_entity' => 'prescription',
'care_entity_uuid' => $prescription->uuid,
'care_entity_id' => $prescription->id,
],
);
if (! $ticket) {
return $prescription;
}
$this->applyTicket($prescription, $ticket);
return $prescription->fresh();
}
public function issueForInvestigation(Organization $organization, InvestigationRequest $request): ?InvestigationRequest
{
if (! $this->isEnabled($organization) || filled($request->queue_ticket_uuid)) {
return $request;
}
$request->loadMissing('patient');
$ticket = $this->issue(
$organization,
CareQueueContexts::LABORATORY,
(int) $request->branch_id,
$request->patient,
[
'care_entity' => 'investigation_request',
'care_entity_uuid' => $request->uuid,
'care_entity_id' => $request->id,
],
);
if (! $ticket) {
return $request;
}
$this->applyTicket($request, $ticket);
return $request->fresh();
}
public function issueForBill(Organization $organization, Bill $bill): ?Bill
{
if (! $this->isEnabled($organization) || filled($bill->queue_ticket_uuid)) {
return $bill;
}
if (in_array($bill->status, [Bill::STATUS_PAID, Bill::STATUS_VOID], true)) {
return $bill;
}
$bill->loadMissing('patient');
$ticket = $this->issue(
$organization,
CareQueueContexts::BILLING,
(int) $bill->branch_id,
$bill->patient,
[
'care_entity' => 'bill',
'care_entity_uuid' => $bill->uuid,
'care_entity_id' => $bill->id,
],
);
if (! $ticket) {
return $bill;
}
$this->applyTicket($bill, $ticket);
return $bill->fresh();
}
/**
* Call the next waiting ticket for a Care page context + branch.
*
* @return array{ticket: ?array<string, mixed>, entity: ?Model, resources: ?array<string, mixed>}
*/
public function callNext(Organization $organization, string $context, int $branchId): array
{
$resources = $this->provisioner->ensure($organization, $context, $branchId);
if (! $resources || empty($resources['queue_uuid']) || empty($resources['counter_uuid'])) {
return ['ticket' => null, 'entity' => null, 'resources' => $resources];
}
$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];
}
if (! $ticket) {
return ['ticket' => null, 'entity' => null, 'resources' => $resources];
}
$entity = $this->findEntityByTicket($organization, $context, (string) ($ticket['uuid'] ?? ''));
if ($entity) {
$this->applyTicket($entity, $ticket);
}
return ['ticket' => $ticket, 'entity' => $entity?->fresh(), 'resources' => $resources];
}
/**
* Mark ticket as serving (Queue "start").
*
* @return array<string, mixed>|null
*/
public function serve(Organization $organization, Model $entity): ?array
{
return $this->ticketAction($organization, $entity, 'start');
}
/**
* Complete the Queue ticket linked to a Care entity.
*
* @return array<string, mixed>|null
*/
public function complete(Organization $organization, Model $entity): ?array
{
return $this->ticketAction($organization, $entity, 'complete');
}
/**
* Recall the Queue ticket linked to a Care entity.
*
* @return array<string, mixed>|null
*/
public function recall(Organization $organization, Model $entity): ?array
{
return $this->ticketAction($organization, $entity, 'recall');
}
/**
* @return array{enabled: bool, context: string, queue_uuid: ?string, counter_uuid: ?string}
*/
public function listMeta(Organization $organization, string $context, int $branchId): array
{
if (! $this->isEnabled($organization)) {
return [
'enabled' => false,
'context' => $context,
'queue_uuid' => null,
'counter_uuid' => null,
];
}
$resources = $this->provisioner->ensure($organization->fresh(), $context, $branchId);
return [
'enabled' => true,
'context' => $context,
'queue_uuid' => $resources['queue_uuid'] ?? null,
'counter_uuid' => $resources['counter_uuid'] ?? null,
];
}
/**
* @param array<string, mixed> $metadata
* @return array<string, mixed>|null
*/
protected function issue(
Organization $organization,
string $context,
int $branchId,
?Patient $patient,
array $metadata = [],
string $priority = 'walk_in',
string $source = 'api',
): ?array {
$resources = $this->provisioner->ensure($organization, $context, $branchId);
if (! $resources || empty($resources['queue_uuid'])) {
Log::info('care.queue_issue_skipped_no_queue', [
'context' => $context,
'branch_id' => $branchId,
]);
return null;
}
$owner = (string) $organization->owner_ref;
try {
return $this->queue->issueTicket($owner, [
'service_queue_id' => $resources['queue_uuid'],
'customer_name' => $patient?->fullName(),
'customer_phone' => $patient?->phone,
'priority' => $priority,
'source' => $source,
'metadata' => $metadata,
]);
} catch (\Throwable $e) {
Log::warning('care.queue_issue_failed', [
'context' => $context,
'branch_id' => $branchId,
'message' => $e->getMessage(),
]);
return null;
}
}
/**
* @param array<string, mixed> $ticket
*/
protected function applyTicket(Model $entity, array $ticket): void
{
$entity->forceFill([
'queue_ticket_uuid' => $ticket['uuid'] ?? $entity->getAttribute('queue_ticket_uuid'),
'queue_ticket_number' => $ticket['ticket_number'] ?? $entity->getAttribute('queue_ticket_number'),
'queue_ticket_status' => $ticket['status'] ?? $entity->getAttribute('queue_ticket_status'),
])->save();
}
/**
* @return array<string, mixed>|null
*/
protected function ticketAction(Organization $organization, Model $entity, string $action): ?array
{
$uuid = (string) $entity->getAttribute('queue_ticket_uuid');
if ($uuid === '' || ! $this->isEnabled($organization)) {
return null;
}
try {
$ticket = $this->queue->ticketAction((string) $organization->owner_ref, $uuid, [
'action' => $action,
]);
$this->applyTicket($entity, $ticket);
return $ticket;
} catch (\Throwable $e) {
Log::warning('care.queue_ticket_action_failed', [
'action' => $action,
'ticket_uuid' => $uuid,
'message' => $e->getMessage(),
]);
return null;
}
}
protected function findEntityByTicket(Organization $organization, string $context, string $ticketUuid): ?Model
{
if ($ticketUuid === '') {
return null;
}
$owner = (string) $organization->owner_ref;
return match (true) {
$context === CareQueueContexts::PHARMACY => Prescription::owned($owner)
->where('organization_id', $organization->id)
->where('queue_ticket_uuid', $ticketUuid)
->first(),
$context === CareQueueContexts::LABORATORY => InvestigationRequest::owned($owner)
->where('organization_id', $organization->id)
->where('queue_ticket_uuid', $ticketUuid)
->first(),
$context === CareQueueContexts::BILLING => Bill::owned($owner)
->where('organization_id', $organization->id)
->where('queue_ticket_uuid', $ticketUuid)
->first(),
default => Appointment::owned($owner)
->where('organization_id', $organization->id)
->where('queue_ticket_uuid', $ticketUuid)
->first(),
};
}
}
+63
View File
@@ -0,0 +1,63 @@
<?php
namespace App\Services\Care;
/**
* Canonical Care Ladill Queue department / specialty contexts.
*/
class CareQueueContexts
{
public const RECEPTION = 'reception';
public const CONSULTATION = 'consultation';
public const PHARMACY = 'pharmacy';
public const LABORATORY = 'laboratory';
public const BILLING = 'billing';
/**
* Core department queues provisioned for every branch when Queue integration is on.
*
* @return array<string, array{name: string, prefix: string}>
*/
public static function departments(): array
{
return [
self::RECEPTION => ['name' => 'Reception', 'prefix' => 'R'],
self::CONSULTATION => ['name' => 'Consultation', 'prefix' => 'C'],
self::PHARMACY => ['name' => 'Pharmacy', 'prefix' => 'P'],
self::LABORATORY => ['name' => 'Laboratory', 'prefix' => 'L'],
self::BILLING => ['name' => 'Billing', 'prefix' => 'B'],
];
}
public static function isDepartment(string $context): bool
{
return array_key_exists($context, self::departments());
}
public static function isSpecialty(string $context): bool
{
return array_key_exists($context, config('care.specialty_modules', []));
}
public static function queueExternalKey(string $context, int|string $careBranchId): string
{
if (self::isSpecialty($context)) {
return "care:specialty:{$context}:queue:{$careBranchId}";
}
return "care:dept:{$context}:queue:{$careBranchId}";
}
public static function counterExternalKey(string $context, int|string $careBranchId): string
{
if (self::isSpecialty($context)) {
return "care:specialty:{$context}:counter:{$careBranchId}";
}
return "care:dept:{$context}:counter:{$careBranchId}";
}
}
+163
View File
@@ -0,0 +1,163 @@
<?php
namespace App\Services\Care;
use App\Models\Branch;
use App\Models\Organization;
use App\Services\Queue\QueueClient;
use Illuminate\Support\Facades\Log;
/**
* Idempotently provisions Ladill Queue queues/counters for Care departments per branch.
*/
class CareQueueProvisioner
{
public function __construct(
protected QueueClient $queue,
protected PlanService $plans,
) {}
public function shouldProvision(Organization $organization): bool
{
return $this->plans->canUseQueueIntegration($organization)
&& (bool) data_get($organization->settings, 'queue_integration_enabled')
&& $this->queue->configured();
}
/**
* Provision core department queues for all org branches. Returns updated settings fragment.
*
* @return array<string, mixed>
*/
public function provisionOrganization(Organization $organization): array
{
if (! $this->shouldProvision($organization)) {
return (array) data_get($organization->settings, 'department_queue_provisioning', []);
}
$owner = (string) $organization->owner_ref;
$branches = Branch::owned($owner)
->where('organization_id', $organization->id)
->where('is_active', true)
->orderBy('name')
->get();
$provisioning = (array) data_get($organization->settings, 'department_queue_provisioning', []);
foreach (CareQueueContexts::departments() as $context => $meta) {
$queues = is_array($provisioning[$context]['queues'] ?? null)
? $provisioning[$context]['queues']
: [];
$byBranch = collect($queues)->keyBy(fn ($q) => (string) ($q['branch_id'] ?? ''));
foreach ($branches as $branch) {
$prior = $byBranch->get((string) $branch->id, []);
$stub = [
'name' => $meta['name'],
'prefix' => $meta['prefix'],
'module' => $context,
'branch_id' => $branch->id,
'branch_name' => $branch->name,
'active' => true,
'queue_external_key' => CareQueueContexts::queueExternalKey($context, $branch->id),
'counter_external_key' => CareQueueContexts::counterExternalKey($context, $branch->id),
'queue_uuid' => $prior['queue_uuid'] ?? null,
'counter_uuid' => $prior['counter_uuid'] ?? null,
'synced' => false,
];
try {
$synced = $this->queue->syncSpecialtyQueueStubs($owner, [$stub]);
$stub = $synced[0] ?? $stub;
} catch (\Throwable $e) {
Log::warning('care.queue_department_provision_failed', [
'context' => $context,
'branch_id' => $branch->id,
'message' => $e->getMessage(),
]);
}
$byBranch->put((string) $branch->id, $stub);
}
$provisioning[$context] = [
'queues' => $byBranch->values()->all(),
];
}
$settings = $organization->settings ?? [];
$settings['department_queue_provisioning'] = $provisioning;
$organization->update(['settings' => $settings]);
return $provisioning;
}
/**
* @return array{queue_uuid: ?string, counter_uuid: ?string, queue_external_key: string, counter_external_key: string, synced: bool}|null
*/
public function resourcesFor(
Organization $organization,
string $context,
int $branchId,
): ?array {
if (CareQueueContexts::isSpecialty($context)) {
$queues = data_get($organization->settings, "specialty_module_provisioning.{$context}.queues", []);
} else {
$queues = data_get($organization->settings, "department_queue_provisioning.{$context}.queues", []);
}
if (! is_array($queues)) {
return null;
}
$match = collect($queues)->first(
fn ($q) => (int) ($q['branch_id'] ?? 0) === $branchId && ($q['active'] ?? true)
);
if (! $match) {
return null;
}
return [
'queue_uuid' => $match['queue_uuid'] ?? null,
'counter_uuid' => $match['counter_uuid'] ?? null,
'queue_external_key' => (string) ($match['queue_external_key']
?? CareQueueContexts::queueExternalKey($context, $branchId)),
'counter_external_key' => (string) ($match['counter_external_key']
?? CareQueueContexts::counterExternalKey($context, $branchId)),
'synced' => (bool) ($match['synced'] ?? false),
];
}
/**
* Ensure resources exist for one context+branch (lazy provision).
*
* @return array{queue_uuid: ?string, counter_uuid: ?string, queue_external_key: string, counter_external_key: string, synced: bool}|null
*/
public function ensure(
Organization $organization,
string $context,
int $branchId,
): ?array {
$existing = $this->resourcesFor($organization, $context, $branchId);
if ($existing && ! empty($existing['queue_uuid']) && ! empty($existing['counter_uuid'])) {
return $existing;
}
if (! $this->shouldProvision($organization)) {
return $existing;
}
if (CareQueueContexts::isSpecialty($context)) {
return $existing;
}
if (! CareQueueContexts::isDepartment($context)) {
return null;
}
$this->provisionOrganization($organization->fresh());
return $this->resourcesFor($organization->fresh(), $context, $branchId);
}
}
+8
View File
@@ -462,6 +462,14 @@ class DemoTenantSeeder
} catch (\Throwable) {
// Demo seed should not fail if Queue API is unreachable; settings flag remains.
}
if (data_get($organization->fresh()->settings, 'queue_integration_enabled')) {
try {
app(CareQueueProvisioner::class)->provisionOrganization($organization->fresh());
} catch (\Throwable) {
// Best-effort department queue stubs for Pro demo ticket flow.
}
}
}
/**
+25 -3
View File
@@ -18,6 +18,10 @@ use InvalidArgumentException;
class InvestigationService
{
public function __construct(
protected CareQueueBridge $queueBridge,
) {}
public function queryForOrganization(string $ownerRef, int $organizationId): Builder
{
return InvestigationRequest::owned($ownerRef)->where('organization_id', $organizationId);
@@ -108,7 +112,13 @@ class InvestigationService
$request->id,
);
$created->push($request->load('investigationType'));
$request = $request->load(['investigationType', 'patient']);
$organization = Organization::query()->find($request->organization_id);
if ($organization) {
$this->queueBridge->issueForInvestigation($organization, $request);
}
$created->push($request->fresh(['investigationType', 'patient']));
}
return $created;
@@ -131,7 +141,13 @@ class InvestigationService
AuditLogger::record($ownerRef, 'investigation.sample_collected', $request->organization_id, $actorRef, InvestigationRequest::class, $request->id);
return $request->fresh(['patient', 'investigationType']);
$request = $request->fresh(['patient', 'investigationType']);
$organization = Organization::query()->find($request->organization_id);
if ($organization) {
$this->queueBridge->serve($organization, $request);
}
return $request;
}
public function startProcessing(
@@ -261,7 +277,13 @@ class InvestigationService
AuditLogger::record($ownerRef, 'investigation.approved', $request->organization_id, $actorRef, InvestigationRequest::class, $request->id);
return $request->fresh(['patient', 'investigationType', 'result.values']);
$request = $request->fresh(['patient', 'investigationType', 'result.values']);
$organization = Organization::query()->find($request->organization_id);
if ($organization) {
$this->queueBridge->complete($organization, $request);
}
return $request;
}
public function deliver(InvestigationRequest $request, string $ownerRef, ?string $actorRef = null): InvestigationRequest
+11 -1
View File
@@ -15,6 +15,10 @@ use InvalidArgumentException;
class PharmacyService
{
public function __construct(
protected CareQueueBridge $queueBridge,
) {}
public function queryDrugs(string $ownerRef, int $organizationId): Builder
{
return Drug::owned($ownerRef)->where('organization_id', $organizationId);
@@ -197,6 +201,12 @@ class PharmacyService
AuditLogger::record($ownerRef, 'prescription.dispensed', $prescription->organization_id, $actorRef, Prescription::class, $prescription->id);
return $prescription->fresh(['items', 'patient']);
$prescription = $prescription->fresh(['items', 'patient']);
$organization = Organization::query()->find($prescription->organization_id);
if ($organization) {
$this->queueBridge->complete($organization, $prescription);
}
return $prescription;
}
}
+25
View File
@@ -3,6 +3,7 @@
namespace App\Services\Care;
use App\Models\Consultation;
use App\Models\Organization;
use App\Models\Prescription;
use App\Models\PrescriptionItem;
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
@@ -12,6 +13,10 @@ use InvalidArgumentException;
class PrescriptionService
{
public function __construct(
protected CareQueueBridge $queueBridge,
) {}
public function queryForOrganization(string $ownerRef, int $organizationId): Builder
{
return Prescription::owned($ownerRef)->where('organization_id', $organizationId);
@@ -89,6 +94,14 @@ class PrescriptionService
$prescription->id,
);
$prescription = $prescription->fresh(['items', 'patient', 'practitioner', 'visit']);
if ($status === Prescription::STATUS_ACTIVE) {
$organization = Organization::query()->find($prescription->organization_id);
if ($organization) {
$this->queueBridge->issueForPrescription($organization, $prescription);
}
}
return $prescription->fresh(['items', 'patient', 'practitioner']);
}
@@ -122,6 +135,12 @@ class PrescriptionService
$prescription->update(['status' => Prescription::STATUS_ACTIVE]);
AuditLogger::record($ownerRef, 'prescription.activated', $prescription->organization_id, $actorRef, Prescription::class, $prescription->id);
$prescription = $prescription->fresh(['items', 'patient', 'visit']);
$organization = Organization::query()->find($prescription->organization_id);
if ($organization) {
$this->queueBridge->issueForPrescription($organization, $prescription);
}
return $prescription->fresh(['items', 'patient']);
}
@@ -137,6 +156,12 @@ class PrescriptionService
AuditLogger::record($ownerRef, 'prescription.dispensed', $prescription->organization_id, $actorRef, Prescription::class, $prescription->id);
$prescription = $prescription->fresh(['items', 'patient', 'visit']);
$organization = Organization::query()->find($prescription->organization_id);
if ($organization) {
$this->queueBridge->complete($organization, $prescription);
}
return $prescription->fresh(['items', 'patient']);
}
+39
View File
@@ -197,6 +197,45 @@ class QueueClient
return (array) ($response->json('data') ?? []);
}
/**
* @param array<string, mixed> $payload
* @return array<string, mixed>
*/
public function issueTicket(string $owner, array $payload): array
{
$response = $this->http($owner)->post($this->base().'/tickets', $payload);
$response->throw();
return (array) ($response->json('data') ?? []);
}
/**
* @return array<string, mixed>|null
*/
public function callNext(string $owner, string $queueUuid, string $counterUuid): ?array
{
$response = $this->http($owner)->post($this->base().'/queues/'.$queueUuid.'/call-next', [
'counter_id' => $counterUuid,
]);
$response->throw();
$data = $response->json('data');
return is_array($data) ? $data : null;
}
/**
* @param array<string, mixed> $payload
* @return array<string, mixed>
*/
public function ticketAction(string $owner, string $ticketUuid, array $payload): array
{
$response = $this->http($owner)->post($this->base().'/tickets/'.$ticketUuid.'/action', $payload);
$response->throw();
return (array) ($response->json('data') ?? []);
}
/**
* Create or link real Queue queues/counters for Care specialty stubs.
* Idempotent via external_key (and Queue-side name+branch fallback).
@@ -0,0 +1,30 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
foreach (['care_appointments', 'care_prescriptions', 'care_investigation_requests', 'care_bills'] as $table) {
Schema::table($table, function (Blueprint $blueprint) {
$blueprint->string('queue_ticket_uuid', 36)->nullable()->after('uuid');
$blueprint->string('queue_ticket_number', 32)->nullable()->after('queue_ticket_uuid');
$blueprint->string('queue_ticket_status', 32)->nullable()->after('queue_ticket_number');
$blueprint->index('queue_ticket_uuid');
});
}
}
public function down(): void
{
foreach (['care_appointments', 'care_prescriptions', 'care_investigation_requests', 'care_bills'] as $table) {
Schema::table($table, function (Blueprint $blueprint) {
$blueprint->dropIndex(['queue_ticket_uuid']);
$blueprint->dropColumn(['queue_ticket_uuid', 'queue_ticket_number', 'queue_ticket_status']);
});
}
}
};
@@ -85,7 +85,5 @@
</div>
<div>{{ $appointments->links() }}</div>
@include('care.service-queues._panel')
</div>
</x-app-layout>
+41 -3
View File
@@ -15,23 +15,61 @@
</select>
<button type="submit" class="btn-primary">Filter</button>
</form>
<div class="mt-4">
@include('care.partials.queue-ops', [
'queueIntegration' => $queueIntegration ?? null,
'queueCallNextRoute' => 'care.bills.call-next',
'queueCallNextParams' => [],
'branchId' => $branchId ?? null,
])
</div>
<div class="mt-4 overflow-hidden rounded-2xl border border-slate-200 bg-white">
<table class="min-w-full text-sm">
<thead class="bg-slate-50 text-left text-xs uppercase text-slate-500">
<tr><th class="px-4 py-3">Invoice</th><th class="px-4 py-3">Patient</th><th class="px-4 py-3">Total</th><th class="px-4 py-3">Balance</th><th class="px-4 py-3">Status</th><th></th></tr>
<tr>
<th class="px-4 py-3">Ticket</th>
<th class="px-4 py-3">Invoice</th>
<th class="px-4 py-3">Patient</th>
<th class="px-4 py-3">Total</th>
<th class="px-4 py-3">Balance</th>
<th class="px-4 py-3">Status</th>
<th></th>
</tr>
</thead>
<tbody class="divide-y divide-slate-50">
@forelse ($bills as $bill)
<tr>
<td class="px-4 py-3">
@if (! empty($queueIntegration['enabled']) && $bill->queue_ticket_number)
@include('care.partials.queue-ticket', [
'ticketNumber' => $bill->queue_ticket_number,
'ticketStatus' => $bill->queue_ticket_status,
])
@else
<span class="text-slate-400"></span>
@endif
</td>
<td class="px-4 py-3 font-mono text-xs">{{ $bill->invoice_number }}</td>
<td class="px-4 py-3">{{ $bill->patient->fullName() }}</td>
<td class="px-4 py-3">{{ $money($bill->total_minor) }}</td>
<td class="px-4 py-3 {{ $bill->balance_minor > 0 ? 'text-amber-700' : '' }}">{{ $money($bill->balance_minor) }}</td>
<td class="px-4 py-3">{{ $statuses[$bill->status] ?? $bill->status }}</td>
<td class="px-4 py-3 text-right"><a href="{{ route('care.bills.show', $bill) }}" class="text-sky-600">View</a></td>
<td class="px-4 py-3 text-right">
<div class="flex items-center justify-end gap-2">
@if (! empty($queueIntegration['enabled']) && $bill->queue_ticket_uuid && ($bill->queue_ticket_status ?? '') !== 'serving' && $bill->balance_minor > 0)
<form method="POST" action="{{ route('care.bills.serve', $bill) }}">
@csrf
<button type="submit" class="rounded-lg bg-sky-600 px-2.5 py-1 text-xs font-medium text-white hover:bg-sky-700">Serve</button>
</form>
@endif
<a href="{{ route('care.bills.show', $bill) }}" class="text-sky-600">View</a>
</div>
</td>
</tr>
@empty
<tr><td colspan="6" class="px-4 py-8 text-center text-slate-500">No bills yet.</td></tr>
<tr><td colspan="7" class="px-4 py-8 text-center text-slate-500">No bills yet.</td></tr>
@endforelse
</tbody>
</table>
-6
View File
@@ -192,12 +192,6 @@
</section>
@endif
@if (! empty($serviceQueuePanel['enabled']))
<div class="mt-6">
@include('care.service-queues._panel')
</div>
@endif
@if (! empty($specialtyModules))
<section class="mt-6">
<h2 class="text-sm font-semibold text-slate-900">Specialty modules</h2>
+27 -6
View File
@@ -29,21 +29,42 @@
<button type="submit" class="btn-primary">Filter</button>
</form>
<div class="mt-4">
@include('care.partials.queue-ops', [
'queueIntegration' => $queueIntegration ?? null,
'queueCallNextRoute' => 'care.lab.queue.call-next',
'queueCallNextParams' => [],
'branchId' => $branchId,
])
</div>
<div class="mt-4 space-y-3">
@forelse ($queue as $item)
<div class="flex items-center justify-between rounded-2xl border border-slate-200 bg-white p-4">
<div>
<p class="font-medium text-slate-900">{{ $item->patient->fullName() }} {{ $item->investigationType->name }}</p>
<p class="font-medium text-slate-900">
@if (! empty($queueIntegration['enabled']) && $item->queue_ticket_number)
@include('care.partials.queue-ticket', [
'ticketNumber' => $item->queue_ticket_number,
'ticketStatus' => $item->queue_ticket_status,
])
@endif
{{ $item->patient->fullName() }} {{ $item->investigationType->name }}
</p>
<p class="text-xs text-slate-500">{{ $statuses[$item->status] ?? $item->status }} · {{ $item->priority }} · {{ $item->created_at->diffForHumans() }}</p>
</div>
<a href="{{ route('care.lab.requests.show', $item) }}" class="text-sm text-sky-600">Open</a>
<div class="flex items-center gap-2">
@if (! empty($queueIntegration['enabled']) && $item->queue_ticket_uuid && ($item->queue_ticket_status ?? '') !== 'serving')
<form method="POST" action="{{ route('care.lab.queue.serve', $item) }}">
@csrf
<button type="submit" class="rounded-lg bg-sky-600 px-3 py-1.5 text-xs font-medium text-white hover:bg-sky-700">Serve</button>
</form>
@endif
<a href="{{ route('care.lab.requests.show', $item) }}" class="text-sm text-sky-600">Open</a>
</div>
</div>
@empty
<p class="rounded-2xl border border-dashed border-slate-200 bg-white p-8 text-center text-sm text-slate-500">Queue is empty.</p>
@endforelse
</div>
<div class="mt-6">
@include('care.service-queues._panel')
</div>
</x-app-layout>
@@ -0,0 +1,26 @@
{{-- Inline Queue ops when Care↔Queue integration is enabled (not a bolted-on Service counter). --}}
@php
$qi = $queueIntegration ?? null;
$callNextRoute = $queueCallNextRoute ?? null;
$callNextParams = $queueCallNextParams ?? [];
@endphp
@if (! empty($qi['enabled']) && $callNextRoute)
<div class="flex flex-wrap items-center justify-between gap-3 rounded-xl border border-sky-100 bg-sky-50/70 px-4 py-3">
<div>
<p class="text-xs font-semibold uppercase tracking-wide text-sky-700">Queue</p>
<p class="text-sm text-slate-600">Call next, serve, and complete on this list tickets are for this department only.</p>
</div>
<form method="POST" action="{{ route($callNextRoute, $callNextParams) }}">
@csrf
@foreach ($callNextParams as $key => $value)
@if (is_scalar($value))
<input type="hidden" name="{{ $key }}" value="{{ $value }}">
@endif
@endforeach
@if (! empty($branchId))
<input type="hidden" name="branch_id" value="{{ $branchId }}">
@endif
<button type="submit" class="btn-primary text-sm">Call next</button>
</form>
</div>
@endif
@@ -0,0 +1,18 @@
@php
$status = $ticketStatus ?? null;
$number = $ticketNumber ?? null;
@endphp
@if ($number)
<span class="mr-2 inline-flex items-center gap-1.5">
<span class="inline-flex h-7 min-w-[2.5rem] items-center justify-center rounded-md bg-sky-100 px-1.5 font-mono text-xs font-bold text-sky-800">{{ $number }}</span>
@if ($status)
<span @class([
'rounded-full px-2 py-0.5 text-[10px] font-semibold uppercase tracking-wide',
'bg-amber-100 text-amber-800' => $status === 'waiting',
'bg-indigo-100 text-indigo-800' => $status === 'called',
'bg-emerald-100 text-emerald-800' => $status === 'serving',
'bg-slate-100 text-slate-600' => ! in_array($status, ['waiting', 'called', 'serving'], true),
])>{{ $status }}</span>
@endif
</span>
@endif
@@ -1,12 +1,30 @@
<x-app-layout title="Pharmacy queue">
<h1 class="text-xl font-semibold text-slate-900">Pharmacy queue</h1>
<p class="mt-1 text-sm text-slate-500">Active prescriptions awaiting dispensing</p>
<div class="mt-4">
@include('care.partials.queue-ops', [
'queueIntegration' => $queueIntegration ?? null,
'queueCallNextRoute' => 'care.prescriptions.call-next',
'queueCallNextParams' => [],
'branchId' => $branchId ?? null,
])
</div>
<div class="mt-4 space-y-3">
@forelse ($queue as $rx)
<div class="rounded-2xl border border-slate-200 bg-white p-4">
<div class="flex items-start justify-between gap-4">
<div class="min-w-0 flex-1">
<p class="font-medium text-slate-900">{{ $rx->patient->fullName() }}</p>
<p class="font-medium text-slate-900">
@if (! empty($queueIntegration['enabled']) && $rx->queue_ticket_number)
@include('care.partials.queue-ticket', [
'ticketNumber' => $rx->queue_ticket_number,
'ticketStatus' => $rx->queue_ticket_status,
])
@endif
{{ $rx->patient->fullName() }}
</p>
<p class="text-xs text-slate-500">{{ $rx->patient->patient_number }} · {{ $rx->visit->branch?->name }}</p>
<ul class="mt-2 space-y-1 text-sm text-slate-700">
@foreach ($rx->items as $item)
@@ -14,6 +32,14 @@
@endforeach
</ul>
@if ($canDispense)
<div class="mt-3 flex flex-wrap gap-2">
@if (! empty($queueIntegration['enabled']) && $rx->queue_ticket_uuid && ($rx->queue_ticket_status ?? '') !== 'serving')
<form method="POST" action="{{ route('care.prescriptions.serve', $rx) }}">
@csrf
<button type="submit" class="rounded-lg bg-sky-600 px-3 py-1.5 text-xs font-medium text-white hover:bg-sky-700">Serve</button>
</form>
@endif
</div>
<form method="POST" action="{{ route('care.prescriptions.dispense', $rx) }}" class="mt-4 border-t border-slate-100 pt-4">
@csrf
@php $allocIndex = 0; @endphp
@@ -41,7 +67,9 @@
@endif
@endforeach
<div class="mt-3 flex gap-2">
<button type="submit" class="rounded-lg bg-emerald-600 px-3 py-1.5 text-xs font-medium text-white hover:bg-emerald-700">Dispense</button>
<button type="submit" class="rounded-lg bg-emerald-600 px-3 py-1.5 text-xs font-medium text-white hover:bg-emerald-700">
{{ ! empty($queueIntegration['enabled']) ? 'Complete & dispense' : 'Dispense' }}
</button>
<a href="{{ route('care.prescriptions.show', $rx) }}" class="text-sm text-sky-600">View details</a>
</div>
</form>
@@ -56,8 +84,4 @@
<p class="rounded-2xl border border-dashed border-slate-200 bg-white p-8 text-center text-sm text-slate-500">No prescriptions in queue.</p>
@endforelse
</div>
<div class="mt-6">
@include('care.service-queues._panel')
</div>
</x-app-layout>
+31 -6
View File
@@ -31,15 +31,32 @@
<button type="submit" class="btn-primary">Filter</button>
</form>
@include('care.partials.queue-ops', [
'queueIntegration' => $queueIntegration ?? null,
'queueCallNextRoute' => 'care.queue.call-next',
'queueCallNextParams' => [],
'branchId' => $branchId,
])
<div class="grid gap-6 lg:grid-cols-2">
<section class="rounded-2xl border border-slate-200 bg-white p-6">
<h2 class="text-sm font-semibold uppercase tracking-wide text-slate-500">Waiting ({{ $queue->count() }})</h2>
<div class="mt-4 space-y-3">
@forelse ($queue as $appointment)
<div class="flex items-center justify-between rounded-xl border border-slate-100 bg-slate-50 p-4">
<div @class([
'flex items-center justify-between rounded-xl border p-4',
'border-indigo-200 bg-indigo-50/60' => ($appointment->queue_ticket_status ?? null) === 'called',
'border-emerald-200 bg-emerald-50' => ($appointment->queue_ticket_status ?? null) === 'serving',
'border-slate-100 bg-slate-50' => ! in_array($appointment->queue_ticket_status ?? null, ['called', 'serving'], true),
])>
<div>
<p class="font-medium text-slate-900">
@if ($appointment->queue_position)
@if (! empty($queueIntegration['enabled']) && $appointment->queue_ticket_number)
@include('care.partials.queue-ticket', [
'ticketNumber' => $appointment->queue_ticket_number,
'ticketStatus' => $appointment->queue_ticket_status,
])
@elseif ($appointment->queue_position)
<span class="mr-2 inline-flex h-6 w-6 items-center justify-center rounded-full bg-sky-100 text-xs font-bold text-sky-700">{{ $appointment->queue_position }}</span>
@endif
{{ $appointment->patient->fullName() }}
@@ -49,7 +66,9 @@
@if ($canConsult)
<form method="POST" action="{{ route('care.queue.start', $appointment) }}">
@csrf
<button type="submit" class="rounded-lg bg-sky-600 px-3 py-1.5 text-xs font-medium text-white hover:bg-sky-700">Start</button>
<button type="submit" class="rounded-lg bg-sky-600 px-3 py-1.5 text-xs font-medium text-white hover:bg-sky-700">
{{ ! empty($queueIntegration['enabled']) && ($appointment->queue_ticket_status ?? '') === 'called' ? 'Serve & start' : 'Start' }}
</button>
</form>
@endif
</div>
@@ -65,7 +84,15 @@
@forelse ($inConsultation as $appointment)
<div class="flex items-center justify-between rounded-xl border border-emerald-100 bg-emerald-50 p-4">
<div>
<p class="font-medium text-slate-900">{{ $appointment->patient->fullName() }}</p>
<p class="font-medium text-slate-900">
@if (! empty($queueIntegration['enabled']) && $appointment->queue_ticket_number)
@include('care.partials.queue-ticket', [
'ticketNumber' => $appointment->queue_ticket_number,
'ticketStatus' => $appointment->queue_ticket_status,
])
@endif
{{ $appointment->patient->fullName() }}
</p>
<p class="text-xs text-slate-500">{{ $appointment->practitioner?->name ?? 'Unassigned' }}</p>
</div>
@if ($appointment->consultation)
@@ -78,7 +105,5 @@
</div>
</section>
</div>
@include('care.service-queues._panel')
</div>
</x-app-layout>
+1 -1
View File
@@ -207,7 +207,7 @@
</div>
</x-settings.card>
<x-settings.card title="Integrations" description="Connect Ladill Queue for service counters on clinical, pharmacy, and lab pages. Requires Care Pro or Enterprise — and Care must also be enabled in Queue settings.">
<x-settings.card title="Integrations" description="Connect Ladill Queue so existing Care lists (patient queue, pharmacy, lab, billing, specialties) issue ticket numbers and support Call next → Serve → Complete. Requires Care Pro or Enterprise — and Care must also be enabled in Queue settings.">
@if (! empty($canUseQueueIntegration))
<label class="flex items-center gap-2 text-sm">
<input type="checkbox" name="queue_integration_enabled" value="1" @checked(data_get($organization->settings, 'queue_integration_enabled'))>
+16 -3
View File
@@ -42,6 +42,13 @@
</section>
</div>
@include('care.partials.queue-ops', [
'queueIntegration' => $queueIntegration ?? null,
'queueCallNextRoute' => 'care.specialty.call-next',
'queueCallNextParams' => ['module' => $moduleKey],
'branchId' => $branchId ?? null,
])
<section class="rounded-2xl border border-slate-200 bg-white p-5">
<div class="flex items-center justify-between gap-3">
<h2 class="text-sm font-semibold text-slate-900">Waiting patients</h2>
@@ -51,7 +58,15 @@
@forelse ($waiting as $appointment)
<div class="flex items-center justify-between rounded-xl border border-slate-100 bg-slate-50 px-3 py-3 text-sm">
<div>
<p class="font-medium text-slate-900">{{ $appointment->patient?->fullName() }}</p>
<p class="font-medium text-slate-900">
@if (! empty($queueIntegration['enabled']) && $appointment->queue_ticket_number)
@include('care.partials.queue-ticket', [
'ticketNumber' => $appointment->queue_ticket_number,
'ticketStatus' => $appointment->queue_ticket_status,
])
@endif
{{ $appointment->patient?->fullName() }}
</p>
<p class="text-xs text-slate-500">{{ $appointment->department?->name }} · {{ $appointment->practitioner?->name ?? 'Unassigned' }}</p>
</div>
<a href="{{ route('care.appointments.show', $appointment) }}" class="text-sky-600">View</a>
@@ -61,7 +76,5 @@
@endforelse
</div>
</section>
@include('care.service-queues._panel')
</div>
</x-app-layout>
+9
View File
@@ -88,7 +88,9 @@ Route::middleware(['auth', 'platform.session'])->group(function () {
Route::post('/appointments/{appointment}/start-meet', [AppointmentMeetController::class, 'start'])->name('care.appointments.start-meet');
Route::get('/queue', [QueueController::class, 'index'])->name('care.queue.index');
Route::post('/queue/call-next', [QueueController::class, 'callNext'])->name('care.queue.call-next');
Route::post('/queue/{appointment}/start', [QueueController::class, 'start'])->name('care.queue.start');
Route::post('/queue/{appointment}/complete-ticket', [QueueController::class, 'completeTicket'])->name('care.queue.complete-ticket');
Route::middleware('care.paid')->group(function () {
Route::get('/service-queues', [ServiceQueueController::class, 'index'])->name('care.service-queues.index');
@@ -96,9 +98,12 @@ Route::middleware(['auth', 'platform.session'])->group(function () {
Route::post('/service-queues/console/{counterUuid}/action', [ServiceQueueController::class, 'action'])->name('care.service-queues.action');
Route::get('/specialty/{module}', [SpecialtyModuleController::class, 'show'])->name('care.specialty.show');
Route::post('/specialty/{module}/call-next', [SpecialtyModuleController::class, 'callNext'])->name('care.specialty.call-next');
Route::get('/lab/requests', [InvestigationController::class, 'index'])->name('care.lab.requests.index');
Route::get('/lab/queue', [InvestigationController::class, 'queue'])->name('care.lab.queue.index');
Route::post('/lab/queue/call-next', [InvestigationController::class, 'callNext'])->name('care.lab.queue.call-next');
Route::post('/lab/queue/{investigation}/serve', [InvestigationController::class, 'serve'])->name('care.lab.queue.serve');
Route::get('/lab/requests/{investigation}', [InvestigationController::class, 'show'])->name('care.lab.requests.show');
Route::post('/consultations/{consultation}/investigations', [InvestigationController::class, 'requestFromConsultation'])->name('care.lab.requests.store');
Route::post('/lab/requests/{investigation}/collect-sample', [InvestigationController::class, 'collectSample'])->name('care.lab.requests.collect-sample');
@@ -116,6 +121,8 @@ Route::middleware(['auth', 'platform.session'])->group(function () {
Route::get('/prescriptions', [PrescriptionController::class, 'index'])->name('care.prescriptions.index');
Route::get('/prescriptions/queue', [PrescriptionController::class, 'queue'])->name('care.prescriptions.queue');
Route::post('/prescriptions/queue/call-next', [PrescriptionController::class, 'callNext'])->name('care.prescriptions.call-next');
Route::post('/prescriptions/{prescription}/serve', [PrescriptionController::class, 'serve'])->name('care.prescriptions.serve');
Route::get('/consultations/{consultation}/prescriptions/create', [PrescriptionController::class, 'create'])->name('care.prescriptions.create');
Route::post('/consultations/{consultation}/prescriptions', [PrescriptionController::class, 'store'])->name('care.prescriptions.store');
Route::get('/prescriptions/{prescription}', [PrescriptionController::class, 'show'])->name('care.prescriptions.show');
@@ -124,6 +131,8 @@ Route::middleware(['auth', 'platform.session'])->group(function () {
Route::post('/prescriptions/{prescription}/cancel', [PrescriptionController::class, 'cancel'])->name('care.prescriptions.cancel');
Route::get('/bills', [BillController::class, 'index'])->name('care.bills.index');
Route::post('/bills/call-next', [BillController::class, 'callNext'])->name('care.bills.call-next');
Route::post('/bills/{bill}/serve', [BillController::class, 'serve'])->name('care.bills.serve');
Route::post('/visits/{visit}/bill', [BillController::class, 'generate'])->name('care.bills.generate');
Route::get('/bills/payments/callback', [BillController::class, 'paymentCallback'])->name('care.bills.payments.callback');
Route::get('/bills/{bill}', [BillController::class, 'show'])->name('care.bills.show');
+79 -90
View File
@@ -8,9 +8,11 @@ use App\Models\Member;
use App\Models\Organization;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Http;
use Tests\TestCase;
/**
* Natural list UX: no bolted-on Service counter panels; Queue ops live on role lists when enabled.
*/
class CareNaturalQueueTest extends TestCase
{
use RefreshDatabase;
@@ -45,6 +47,38 @@ class CareNaturalQueueTest extends TestCase
'plan' => 'pro',
'plan_expires_at' => now()->addMonth()->toIso8601String(),
'queue_integration_enabled' => true,
'department_queue_provisioning' => [
'consultation' => [
'queues' => [[
'branch_id' => 1,
'name' => 'Consultation',
'active' => true,
'synced' => true,
'queue_uuid' => 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa',
'counter_uuid' => 'bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb',
]],
],
'pharmacy' => [
'queues' => [[
'branch_id' => 1,
'name' => 'Pharmacy',
'active' => true,
'synced' => true,
'queue_uuid' => 'cccccccc-cccc-cccc-cccc-cccccccccccc',
'counter_uuid' => 'dddddddd-dddd-dddd-dddd-dddddddddddd',
]],
],
'laboratory' => [
'queues' => [[
'branch_id' => 1,
'name' => 'Laboratory',
'active' => true,
'synced' => true,
'queue_uuid' => 'eeeeeeee-eeee-eeee-eeee-eeeeeeeeeeee',
'counter_uuid' => 'ffffffff-ffff-ffff-ffff-ffffffffffff',
]],
],
],
],
]);
@@ -55,49 +89,23 @@ class CareNaturalQueueTest extends TestCase
'role' => 'hospital_admin',
]);
Branch::create([
$branch = Branch::create([
'owner_ref' => $this->owner->public_id,
'organization_id' => $this->organization->id,
'name' => 'Main',
'is_active' => true,
]);
}
protected function fakeQueueApi(): void
{
Http::fake(function (\Illuminate\Http\Client\Request $request) {
$url = $request->url();
if (str_contains($url, '/counters/') && str_contains($url, '/console')) {
return Http::response([
'data' => [
'counter' => ['name' => 'Reception desk', 'branch' => 'Main', 'status' => 'available'],
'current_ticket' => null,
'queues' => [['uuid' => '22222222-2222-2222-2222-222222222222', 'name' => 'Consultation']],
'waiting_by_queue' => [],
'transfer_queues' => [],
],
], 200);
}
if (str_contains($url, '/counters')) {
return Http::response([
'data' => [[
'uuid' => '11111111-1111-1111-1111-111111111111',
'name' => 'Reception desk',
'branch' => 'Main',
'status' => 'available',
'queues' => [['uuid' => '22222222-2222-2222-2222-222222222222', 'name' => 'Consultation']],
]],
], 200);
}
return Http::response(['message' => 'unexpected '.$url], 500);
});
// Keep provisioning stubs aligned with the created branch id.
$settings = $this->organization->settings;
foreach (['consultation', 'pharmacy', 'laboratory'] as $ctx) {
$settings['department_queue_provisioning'][$ctx]['queues'][0]['branch_id'] = $branch->id;
}
$this->organization->update(['settings' => $settings]);
}
public function test_sidebar_does_not_show_service_queues_nav(): void
{
$this->fakeQueueApi();
$this->actingAs($this->owner)
->get(route('care.dashboard'))
->assertOk()
@@ -111,89 +119,70 @@ class CareNaturalQueueTest extends TestCase
->assertRedirect(route('care.queue.index'));
}
public function test_clinical_queue_embeds_service_counter_panel(): void
public function test_clinical_queue_shows_inline_ops_not_service_counter(): void
{
$this->fakeQueueApi();
$this->actingAs($this->owner)
->get(route('care.queue.index'))
->assertOk()
->assertSee('Service counter')
->assertSee('Reception desk')
->assertDontSee('Service counter')
->assertSee('Call next');
}
public function test_pharmacist_sees_queue_panel_on_pharmacy_page(): void
public function test_pharmacist_sees_call_next_on_pharmacy_page(): void
{
$this->fakeQueueApi();
Member::query()->where('user_ref', $this->owner->public_id)->update(['role' => 'pharmacist']);
$this->actingAs($this->owner)
->get(route('care.prescriptions.queue'))
->assertOk()
->assertSee('Service counter');
->assertDontSee('Service counter')
->assertSee('Call next');
}
public function test_lab_page_embeds_service_counter_when_integration_on(): void
public function test_lab_page_shows_call_next_when_integration_on(): void
{
$this->fakeQueueApi();
Member::query()->where('user_ref', $this->owner->public_id)->update(['role' => 'lab_technician']);
$this->actingAs($this->owner)
->get(route('care.lab.queue.index'))
->assertOk()
->assertSee('Service counter');
->assertDontSee('Service counter')
->assertSee('Call next');
}
public function test_branch_scoped_member_panel_prefers_matching_branch_counter(): void
public function test_integration_off_hides_queue_controls(): void
{
Http::fake(function (\Illuminate\Http\Client\Request $request) {
$url = $request->url();
if (str_contains($url, '/counters/') && str_contains($url, '/console')) {
return Http::response([
'data' => [
'counter' => ['name' => 'Main reception', 'branch' => 'Main'],
'current_ticket' => null,
'queues' => [['uuid' => 'cccccccc-cccc-cccc-cccc-cccccccccccc', 'name' => 'Consultation']],
'waiting_by_queue' => [],
'transfer_queues' => [],
],
], 200);
}
if (str_contains($url, '/counters')) {
return Http::response([
'data' => [
[
'uuid' => 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa',
'name' => 'Other site desk',
'branch' => 'Other',
'queues' => [['name' => 'Consultation']],
],
[
'uuid' => 'bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb',
'name' => 'Main reception',
'branch' => 'Main',
'queues' => [['name' => 'Consultation']],
],
],
], 200);
}
return Http::response(['message' => 'unexpected '.$url], 500);
});
$branch = Branch::query()->first();
Member::query()->where('user_ref', $this->owner->public_id)->update([
'role' => 'receptionist',
'branch_id' => $branch->id,
]);
$settings = $this->organization->settings;
$settings['queue_integration_enabled'] = false;
$this->organization->update(['settings' => $settings]);
$this->actingAs($this->owner)
->get(route('care.queue.index'))
->assertOk()
->assertSee('Main reception')
->assertDontSee('Other site desk');
->assertDontSee('Call next')
->assertDontSee('Service counter');
}
public function test_doctor_call_next_uses_consultation_queue_not_reception(): void
{
\Illuminate\Support\Facades\Http::fake([
'*/queues/*/call-next*' => \Illuminate\Support\Facades\Http::response([
'data' => [
'uuid' => '77777777-7777-7777-7777-777777777777',
'ticket_number' => 'C001',
'status' => 'called',
],
], 200),
]);
$branchId = Branch::query()->firstOrFail()->id;
$this->actingAs($this->owner)
->post(route('care.queue.call-next'), ['branch_id' => $branchId])
->assertRedirect();
\Illuminate\Support\Facades\Http::assertSent(function ($request) {
return str_contains($request->url(), '/queues/aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa/call-next');
});
}
}
+193
View File
@@ -0,0 +1,193 @@
<?php
namespace Tests\Feature;
use App\Http\Middleware\EnsurePlatformSession;
use App\Models\Appointment;
use App\Models\Branch;
use App\Models\Member;
use App\Models\Organization;
use App\Models\Patient;
use App\Models\User;
use App\Services\Care\AppointmentService;
use App\Services\Care\CareQueueContexts;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Http;
use Tests\TestCase;
class CareQueueBridgeTest extends TestCase
{
use RefreshDatabase;
protected User $owner;
protected Organization $organization;
protected Branch $branch;
protected Patient $patient;
protected function setUp(): void
{
parent::setUp();
$this->withoutMiddleware(EnsurePlatformSession::class);
config([
'care.queue.api_url' => 'https://queue.test/api/v1',
'care.queue.api_key' => 'care-test-key',
]);
$this->owner = User::create([
'public_id' => 'bridge-owner',
'name' => 'Owner',
'email' => 'bridge@example.com',
]);
$this->organization = Organization::create([
'owner_ref' => $this->owner->public_id,
'name' => 'Bridge Clinic',
'slug' => 'bridge-clinic',
'settings' => [
'onboarded' => true,
'facility_type' => 'clinic',
'plan' => 'pro',
'plan_expires_at' => now()->addMonth()->toIso8601String(),
'queue_integration_enabled' => true,
],
]);
Member::create([
'owner_ref' => $this->owner->public_id,
'organization_id' => $this->organization->id,
'user_ref' => $this->owner->public_id,
'role' => 'hospital_admin',
]);
$this->branch = Branch::create([
'owner_ref' => $this->owner->public_id,
'organization_id' => $this->organization->id,
'name' => 'Main',
'is_active' => true,
]);
$settings = $this->organization->settings;
$settings['department_queue_provisioning'] = [
CareQueueContexts::CONSULTATION => [
'queues' => [[
'branch_id' => $this->branch->id,
'branch_name' => 'Main',
'name' => 'Consultation',
'prefix' => 'C',
'active' => true,
'synced' => true,
'queue_uuid' => 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa',
'counter_uuid' => 'bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb',
'queue_external_key' => CareQueueContexts::queueExternalKey(CareQueueContexts::CONSULTATION, $this->branch->id),
'counter_external_key' => CareQueueContexts::counterExternalKey(CareQueueContexts::CONSULTATION, $this->branch->id),
]],
],
];
$this->organization->update(['settings' => $settings]);
$this->patient = Patient::create([
'owner_ref' => $this->owner->public_id,
'organization_id' => $this->organization->id,
'branch_id' => $this->branch->id,
'patient_number' => 'P-100',
'first_name' => 'Ada',
'last_name' => 'Lovelace',
'gender' => 'female',
'date_of_birth' => '1990-01-01',
]);
}
public function test_check_in_issues_consultation_ticket_when_integration_on(): void
{
Http::fake(function (\Illuminate\Http\Client\Request $request) {
if (str_contains($request->url(), '/tickets') && $request->method() === 'POST') {
return Http::response([
'data' => [
'uuid' => 'cccccccc-cccc-cccc-cccc-cccccccccccc',
'ticket_number' => 'C001',
'status' => 'waiting',
],
], 201);
}
return Http::response(['message' => 'unexpected '.$request->url()], 500);
});
$appointment = 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_SCHEDULED,
'status' => Appointment::STATUS_SCHEDULED,
'scheduled_at' => now()->addHour(),
]);
$result = app(AppointmentService::class)->checkIn($appointment, $this->owner->public_id, $this->owner->public_id);
$this->assertSame('C001', $result->queue_ticket_number);
$this->assertSame('waiting', $result->queue_ticket_status);
$this->assertSame('cccccccc-cccc-cccc-cccc-cccccccccccc', $result->queue_ticket_uuid);
Http::assertSent(fn ($request) => str_contains($request->url(), '/tickets')
&& ($request['service_queue_id'] ?? null) === 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa'
&& ($request['customer_name'] ?? null) === 'Ada Lovelace');
}
public function test_check_in_skips_ticket_when_integration_off(): void
{
Http::fake();
$settings = $this->organization->settings;
$settings['queue_integration_enabled'] = false;
$this->organization->update(['settings' => $settings]);
$appointment = 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_SCHEDULED,
'status' => Appointment::STATUS_SCHEDULED,
'scheduled_at' => now()->addHour(),
]);
$result = app(AppointmentService::class)->checkIn(
$appointment->fresh(['organization', 'patient']),
$this->owner->public_id,
$this->owner->public_id,
);
$this->assertNull($result->queue_ticket_uuid);
Http::assertNothingSent();
}
public function test_waiting_list_shows_ticket_number_when_integration_on(): void
{
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,
'queue_ticket_uuid' => 'dddddddd-dddd-dddd-dddd-dddddddddddd',
'queue_ticket_number' => 'C042',
'queue_ticket_status' => 'waiting',
]);
$this->actingAs($this->owner)
->get(route('care.queue.index'))
->assertOk()
->assertSee('C042')
->assertSee('Call next')
->assertSee('Serve');
}
}
+361
View File
@@ -0,0 +1,361 @@
<?php
namespace Tests\Feature;
use App\Http\Middleware\EnsurePlatformSession;
use App\Models\Appointment;
use App\Models\Branch;
use App\Models\Member;
use App\Models\Organization;
use App\Models\Patient;
use App\Models\Prescription;
use App\Models\User;
use App\Models\Visit;
use App\Services\Care\AppointmentService;
use App\Services\Care\CareQueueBridge;
use App\Services\Care\CareQueueContexts;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Str;
use Tests\TestCase;
class CareQueueWorkflowTest extends TestCase
{
use RefreshDatabase;
protected User $owner;
protected Organization $organization;
protected Branch $branch;
protected function setUp(): void
{
parent::setUp();
$this->withoutMiddleware(EnsurePlatformSession::class);
config([
'care.queue.api_url' => 'https://queue.test/api/v1',
'care.queue.api_key' => 'care-test-key',
]);
$this->owner = User::create([
'public_id' => 'care-queue-workflow-owner',
'name' => 'Owner',
'email' => 'queue-workflow@example.com',
]);
$this->organization = Organization::create([
'owner_ref' => $this->owner->public_id,
'name' => 'Workflow Clinic',
'slug' => 'workflow-clinic',
'settings' => [
'onboarded' => true,
'facility_type' => 'clinic',
'plan' => 'pro',
'plan_expires_at' => now()->addMonth()->toIso8601String(),
'queue_integration_enabled' => true,
],
]);
Member::create([
'owner_ref' => $this->owner->public_id,
'organization_id' => $this->organization->id,
'user_ref' => $this->owner->public_id,
'role' => 'hospital_admin',
]);
$this->branch = Branch::create([
'owner_ref' => $this->owner->public_id,
'organization_id' => $this->organization->id,
'name' => 'Main',
'is_active' => true,
]);
}
protected function fakeQueueApi(): void
{
$consultationQueue = 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa';
$pharmacyQueue = 'bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb';
$consultationCounter = 'cccccccc-cccc-cccc-cccc-cccccccccccc';
$pharmacyCounter = 'dddddddd-dddd-dddd-dddd-dddddddddddd';
$seq = ['C' => 0, 'P' => 0];
Http::fake(function (\Illuminate\Http\Client\Request $request) use (
$consultationQueue,
$pharmacyQueue,
$consultationCounter,
$pharmacyCounter,
&$seq,
) {
$url = $request->url();
$method = $request->method();
if (str_contains($url, '/integrations/status')) {
return Http::response([
'data' => [
'provisioned' => true,
'integrations' => ['care' => true],
],
], 200);
}
if ($method === 'POST' && str_contains($url, '/branches')) {
return Http::response(['data' => ['id' => 1, 'name' => 'Main']], 201);
}
if ($method === 'POST' && str_contains($url, '/queues') && ! str_contains($url, 'call-next')) {
$payload = $request->data();
$key = (string) ($payload['external_key'] ?? '');
$isPharmacy = str_contains($key, 'pharmacy');
return Http::response([
'data' => [
'uuid' => $isPharmacy ? $pharmacyQueue : $consultationQueue,
'name' => $payload['name'] ?? 'Queue',
'prefix' => $payload['prefix'] ?? 'X',
],
], 201);
}
if ($method === 'POST' && str_contains($url, '/counters')) {
$payload = $request->data();
$key = (string) ($payload['external_key'] ?? '');
$isPharmacy = str_contains($key, 'pharmacy');
return Http::response([
'data' => [
'uuid' => $isPharmacy ? $pharmacyCounter : $consultationCounter,
'name' => $payload['name'] ?? 'Counter',
],
], 201);
}
if ($method === 'POST' && preg_match('#/tickets$#', parse_url($url, PHP_URL_PATH) ?? '')) {
$payload = $request->data();
$queueId = (string) ($payload['service_queue_id'] ?? '');
$prefix = $queueId === $pharmacyQueue ? 'P' : 'C';
$seq[$prefix]++;
return Http::response([
'data' => [
'uuid' => (string) Str::uuid(),
'ticket_number' => $prefix.sprintf('%03d', $seq[$prefix]),
'status' => 'waiting',
'customer_name' => $payload['customer_name'] ?? null,
],
], 201);
}
if ($method === 'POST' && str_contains($url, '/call-next')) {
$path = parse_url($url, PHP_URL_PATH) ?? '';
$isPharmacy = str_contains($path, $pharmacyQueue);
return Http::response([
'data' => [
'uuid' => (string) Str::uuid(),
'ticket_number' => $isPharmacy ? 'P001' : 'C001',
'status' => 'called',
'customer_name' => 'Called Patient',
],
], 200);
}
if ($method === 'POST' && str_contains($url, '/tickets/') && str_contains($url, '/action')) {
$payload = $request->data();
return Http::response([
'data' => [
'uuid' => basename(dirname(parse_url($url, PHP_URL_PATH) ?? '/x/x')),
'ticket_number' => 'C001',
'status' => $payload['action'] === 'complete' ? 'completed' : ($payload['action'] === 'start' ? 'serving' : 'called'),
],
], 200);
}
return Http::response(['message' => 'unexpected '.$method.' '.$url], 500);
});
}
public function test_integration_off_does_not_issue_tickets_on_check_in(): void
{
$settings = $this->organization->settings;
$settings['queue_integration_enabled'] = false;
$this->organization->update(['settings' => $settings]);
Http::fake();
$patient = Patient::create([
'uuid' => (string) Str::uuid(),
'owner_ref' => $this->owner->public_id,
'organization_id' => $this->organization->id,
'branch_id' => $this->branch->id,
'patient_number' => 'P-1',
'first_name' => 'Ada',
'last_name' => 'Lovelace',
]);
$appointment = Appointment::create([
'uuid' => (string) Str::uuid(),
'owner_ref' => $this->owner->public_id,
'organization_id' => $this->organization->id,
'branch_id' => $this->branch->id,
'patient_id' => $patient->id,
'type' => Appointment::TYPE_SCHEDULED,
'status' => Appointment::STATUS_SCHEDULED,
'scheduled_at' => now(),
]);
app(AppointmentService::class)->checkIn($appointment, $this->owner->public_id, $this->owner->public_id);
$appointment->refresh();
$this->assertNull($appointment->queue_ticket_uuid);
$this->assertNull($appointment->queue_ticket_number);
Http::assertNothingSent();
}
public function test_integration_on_issues_consultation_ticket_on_check_in(): void
{
$this->fakeQueueApi();
$patient = Patient::create([
'uuid' => (string) Str::uuid(),
'owner_ref' => $this->owner->public_id,
'organization_id' => $this->organization->id,
'branch_id' => $this->branch->id,
'patient_number' => 'P-2',
'first_name' => 'Grace',
'last_name' => 'Hopper',
]);
$appointment = Appointment::create([
'uuid' => (string) Str::uuid(),
'owner_ref' => $this->owner->public_id,
'organization_id' => $this->organization->id,
'branch_id' => $this->branch->id,
'patient_id' => $patient->id,
'type' => Appointment::TYPE_SCHEDULED,
'status' => Appointment::STATUS_SCHEDULED,
'scheduled_at' => now(),
]);
app(AppointmentService::class)->checkIn($appointment, $this->owner->public_id, $this->owner->public_id);
$appointment->refresh();
$this->assertNotNull($appointment->queue_ticket_uuid);
$this->assertSame('C001', $appointment->queue_ticket_number);
$this->assertSame('waiting', $appointment->queue_ticket_status);
}
public function test_pharmacy_call_next_uses_pharmacy_queue_not_reception(): void
{
$this->fakeQueueApi();
$this->organization->update([
'settings' => array_merge($this->organization->settings ?? [], [
'department_queue_provisioning' => [
CareQueueContexts::PHARMACY => [
'queues' => [[
'branch_id' => $this->branch->id,
'branch_name' => 'Main',
'name' => 'Pharmacy',
'prefix' => 'P',
'active' => true,
'synced' => true,
'queue_uuid' => 'bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb',
'counter_uuid' => 'dddddddd-dddd-dddd-dddd-dddddddddddd',
'queue_external_key' => CareQueueContexts::queueExternalKey(CareQueueContexts::PHARMACY, $this->branch->id),
'counter_external_key' => CareQueueContexts::counterExternalKey(CareQueueContexts::PHARMACY, $this->branch->id),
]],
],
],
]),
]);
Member::query()->where('user_ref', $this->owner->public_id)->update(['role' => 'pharmacist']);
$this->actingAs($this->owner)
->post(route('care.prescriptions.call-next'), ['branch_id' => $this->branch->id])
->assertRedirect();
Http::assertSent(function (\Illuminate\Http\Client\Request $request) {
return $request->method() === 'POST'
&& str_contains($request->url(), '/queues/bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb/call-next');
});
}
public function test_complete_advances_ticket_status_on_appointment(): void
{
$this->fakeQueueApi();
$patient = Patient::create([
'uuid' => (string) Str::uuid(),
'owner_ref' => $this->owner->public_id,
'organization_id' => $this->organization->id,
'branch_id' => $this->branch->id,
'patient_number' => 'P-3',
'first_name' => 'Alan',
'last_name' => 'Turing',
]);
$appointment = Appointment::create([
'uuid' => (string) Str::uuid(),
'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,
'queue_ticket_uuid' => 'eeeeeeee-eeee-eeee-eeee-eeeeeeeeeeee',
'queue_ticket_number' => 'C009',
'queue_ticket_status' => 'serving',
'scheduled_at' => now(),
]);
app(CareQueueBridge::class)->complete($this->organization, $appointment);
$appointment->refresh();
$this->assertSame('completed', $appointment->queue_ticket_status);
}
public function test_patient_queue_shows_call_next_not_service_counter_panel(): void
{
$this->fakeQueueApi();
$this->organization->update([
'settings' => array_merge($this->organization->settings ?? [], [
'department_queue_provisioning' => [
CareQueueContexts::CONSULTATION => [
'queues' => [[
'branch_id' => $this->branch->id,
'branch_name' => 'Main',
'name' => 'Consultation',
'prefix' => 'C',
'active' => true,
'synced' => true,
'queue_uuid' => 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa',
'counter_uuid' => 'cccccccc-cccc-cccc-cccc-cccccccccccc',
]],
],
],
]),
]);
$this->actingAs($this->owner)
->get(route('care.queue.index'))
->assertOk()
->assertDontSee('Service counter')
->assertSee('Call next')
->assertSee('tickets are for this department only', false);
}
public function test_integration_off_hides_queue_ops_on_pharmacy_page(): void
{
$settings = $this->organization->settings;
$settings['queue_integration_enabled'] = false;
$this->organization->update(['settings' => $settings]);
Member::query()->where('user_ref', $this->owner->public_id)->update(['role' => 'pharmacist']);
$this->actingAs($this->owner)
->get(route('care.prescriptions.queue'))
->assertOk()
->assertDontSee('Call next')
->assertDontSee('Service counter');
}
}