diff --git a/app/Http/Controllers/Care/AppointmentController.php b/app/Http/Controllers/Care/AppointmentController.php index 320802d..9cbd2d4 100644 --- a/app/Http/Controllers/Care/AppointmentController.php +++ b/app/Http/Controllers/Care/AppointmentController.php @@ -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'), - ), ]); } diff --git a/app/Http/Controllers/Care/BillController.php b/app/Http/Controllers/Care/BillController.php index 016d970..870e450 100644 --- a/app/Http/Controllers/Care/BillController.php +++ b/app/Http/Controllers/Care/BillController.php @@ -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'); diff --git a/app/Http/Controllers/Care/DashboardController.php b/app/Http/Controllers/Care/DashboardController.php index b5a1009..007cbd1 100644 --- a/app/Http/Controllers/Care/DashboardController.php +++ b/app/Http/Controllers/Care/DashboardController.php @@ -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), ]); } diff --git a/app/Http/Controllers/Care/InvestigationController.php b/app/Http/Controllers/Care/InvestigationController.php index 71f2987..f875d21 100644 --- a/app/Http/Controllers/Care/InvestigationController.php +++ b/app/Http/Controllers/Care/InvestigationController.php @@ -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'); diff --git a/app/Http/Controllers/Care/PrescriptionController.php b/app/Http/Controllers/Care/PrescriptionController.php index ec6d598..4f827d4 100644 --- a/app/Http/Controllers/Care/PrescriptionController.php +++ b/app/Http/Controllers/Care/PrescriptionController.php @@ -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'); diff --git a/app/Http/Controllers/Care/QueueController.php b/app/Http/Controllers/Care/QueueController.php index fbbf09b..ffc2e3d 100644 --- a/app/Http/Controllers/Care/QueueController.php +++ b/app/Http/Controllers/Care/QueueController.php @@ -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'); diff --git a/app/Http/Controllers/Care/SettingsController.php b/app/Http/Controllers/Care/SettingsController.php index b40cd90..70931b7 100644 --- a/app/Http/Controllers/Care/SettingsController.php +++ b/app/Http/Controllers/Care/SettingsController.php @@ -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.'); diff --git a/app/Http/Controllers/Care/SpecialtyModuleController.php b/app/Http/Controllers/Care/SpecialtyModuleController.php index c005c94..f5e1694 100644 --- a/app/Http/Controllers/Care/SpecialtyModuleController.php +++ b/app/Http/Controllers/Care/SpecialtyModuleController.php @@ -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'].'.'); + } } diff --git a/app/Models/Appointment.php b/app/Models/Appointment.php index e950025..c843e43 100644 --- a/app/Models/Appointment.php +++ b/app/Models/Appointment.php @@ -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', diff --git a/app/Models/Bill.php b/app/Models/Bill.php index 3bbf161..fa530d6 100644 --- a/app/Models/Bill.php +++ b/app/Models/Bill.php @@ -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', ]; diff --git a/app/Models/InvestigationRequest.php b/app/Models/InvestigationRequest.php index 96cda6f..540a581 100644 --- a/app/Models/InvestigationRequest.php +++ b/app/Models/InvestigationRequest.php @@ -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', diff --git a/app/Models/Prescription.php b/app/Models/Prescription.php index 04ef6e0..2c93a8e 100644 --- a/app/Models/Prescription.php +++ b/app/Models/Prescription.php @@ -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', ]; diff --git a/app/Services/Care/AppointmentService.php b/app/Services/Care/AppointmentService.php index 5daadc2..c400da7 100644 --- a/app/Services/Care/AppointmentService.php +++ b/app/Services/Care/AppointmentService.php @@ -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']); } diff --git a/app/Services/Care/BillService.php b/app/Services/Care/BillService.php index 7eea235..bd5b0cd 100644 --- a/app/Services/Care/BillService.php +++ b/app/Services/Care/BillService.php @@ -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 diff --git a/app/Services/Care/CarePermissions.php b/app/Services/Care/CarePermissions.php index 4d1286d..bfd98b1 100644 --- a/app/Services/Care/CarePermissions.php +++ b/app/Services/Care/CarePermissions.php @@ -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', diff --git a/app/Services/Care/CareQueueBridge.php b/app/Services/Care/CareQueueBridge.php new file mode 100644 index 0000000..ea6cf42 --- /dev/null +++ b/app/Services/Care/CareQueueBridge.php @@ -0,0 +1,386 @@ +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, entity: ?Model, resources: ?array} + */ + 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|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|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|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 $metadata + * @return array|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 $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|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(), + }; + } +} diff --git a/app/Services/Care/CareQueueContexts.php b/app/Services/Care/CareQueueContexts.php new file mode 100644 index 0000000..23ec53a --- /dev/null +++ b/app/Services/Care/CareQueueContexts.php @@ -0,0 +1,63 @@ + + */ + 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}"; + } +} diff --git a/app/Services/Care/CareQueueProvisioner.php b/app/Services/Care/CareQueueProvisioner.php new file mode 100644 index 0000000..a935f98 --- /dev/null +++ b/app/Services/Care/CareQueueProvisioner.php @@ -0,0 +1,163 @@ +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 + */ + 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); + } +} diff --git a/app/Services/Care/DemoTenantSeeder.php b/app/Services/Care/DemoTenantSeeder.php index 6608535..522fff6 100644 --- a/app/Services/Care/DemoTenantSeeder.php +++ b/app/Services/Care/DemoTenantSeeder.php @@ -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. + } + } } /** diff --git a/app/Services/Care/InvestigationService.php b/app/Services/Care/InvestigationService.php index 1f1eb2e..43b92ae 100644 --- a/app/Services/Care/InvestigationService.php +++ b/app/Services/Care/InvestigationService.php @@ -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 diff --git a/app/Services/Care/PharmacyService.php b/app/Services/Care/PharmacyService.php index 40a0674..2b04cc1 100644 --- a/app/Services/Care/PharmacyService.php +++ b/app/Services/Care/PharmacyService.php @@ -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; } } diff --git a/app/Services/Care/PrescriptionService.php b/app/Services/Care/PrescriptionService.php index 3cfaa26..6d7eba2 100644 --- a/app/Services/Care/PrescriptionService.php +++ b/app/Services/Care/PrescriptionService.php @@ -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']); } diff --git a/app/Services/Queue/QueueClient.php b/app/Services/Queue/QueueClient.php index 296b92c..ae0f695 100644 --- a/app/Services/Queue/QueueClient.php +++ b/app/Services/Queue/QueueClient.php @@ -197,6 +197,45 @@ class QueueClient return (array) ($response->json('data') ?? []); } + /** + * @param array $payload + * @return array + */ + 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|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 $payload + * @return array + */ + 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). diff --git a/database/migrations/2026_07_17_160000_add_queue_ticket_fields_to_care_entities.php b/database/migrations/2026_07_17_160000_add_queue_ticket_fields_to_care_entities.php new file mode 100644 index 0000000..48dca57 --- /dev/null +++ b/database/migrations/2026_07_17_160000_add_queue_ticket_fields_to_care_entities.php @@ -0,0 +1,30 @@ +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']); + }); + } + } +}; diff --git a/resources/views/care/appointments/index.blade.php b/resources/views/care/appointments/index.blade.php index 37c29c7..36b100e 100644 --- a/resources/views/care/appointments/index.blade.php +++ b/resources/views/care/appointments/index.blade.php @@ -85,7 +85,5 @@
{{ $appointments->links() }}
- - @include('care.service-queues._panel') diff --git a/resources/views/care/bills/index.blade.php b/resources/views/care/bills/index.blade.php index 1155f4e..bf401df 100644 --- a/resources/views/care/bills/index.blade.php +++ b/resources/views/care/bills/index.blade.php @@ -15,23 +15,61 @@ + +
+ @include('care.partials.queue-ops', [ + 'queueIntegration' => $queueIntegration ?? null, + 'queueCallNextRoute' => 'care.bills.call-next', + 'queueCallNextParams' => [], + 'branchId' => $branchId ?? null, + ]) +
+
- + + + + + + + + + @forelse ($bills as $bill) + - + @empty - + @endforelse
InvoicePatientTotalBalanceStatus
TicketInvoicePatientTotalBalanceStatus
+ @if (! empty($queueIntegration['enabled']) && $bill->queue_ticket_number) + @include('care.partials.queue-ticket', [ + 'ticketNumber' => $bill->queue_ticket_number, + 'ticketStatus' => $bill->queue_ticket_status, + ]) + @else + + @endif + {{ $bill->invoice_number }} {{ $bill->patient->fullName() }} {{ $money($bill->total_minor) }} {{ $money($bill->balance_minor) }} {{ $statuses[$bill->status] ?? $bill->status }}View +
+ @if (! empty($queueIntegration['enabled']) && $bill->queue_ticket_uuid && ($bill->queue_ticket_status ?? '') !== 'serving' && $bill->balance_minor > 0) +
+ @csrf + +
+ @endif + View +
+
No bills yet.
No bills yet.
diff --git a/resources/views/care/dashboard.blade.php b/resources/views/care/dashboard.blade.php index 0f8ba08..33c7d92 100644 --- a/resources/views/care/dashboard.blade.php +++ b/resources/views/care/dashboard.blade.php @@ -192,12 +192,6 @@ @endif - @if (! empty($serviceQueuePanel['enabled'])) -
- @include('care.service-queues._panel') -
- @endif - @if (! empty($specialtyModules))

Specialty modules

diff --git a/resources/views/care/lab/queue/index.blade.php b/resources/views/care/lab/queue/index.blade.php index fb917f9..2b69081 100644 --- a/resources/views/care/lab/queue/index.blade.php +++ b/resources/views/care/lab/queue/index.blade.php @@ -29,21 +29,42 @@ +
+ @include('care.partials.queue-ops', [ + 'queueIntegration' => $queueIntegration ?? null, + 'queueCallNextRoute' => 'care.lab.queue.call-next', + 'queueCallNextParams' => [], + 'branchId' => $branchId, + ]) +
+
@forelse ($queue as $item)
-

{{ $item->patient->fullName() }} — {{ $item->investigationType->name }}

+

+ @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 }} +

{{ $statuses[$item->status] ?? $item->status }} · {{ $item->priority }} · {{ $item->created_at->diffForHumans() }}

- Open +
+ @if (! empty($queueIntegration['enabled']) && $item->queue_ticket_uuid && ($item->queue_ticket_status ?? '') !== 'serving') +
+ @csrf + +
+ @endif + Open +
@empty

Queue is empty.

@endforelse
- -
- @include('care.service-queues._panel') -
diff --git a/resources/views/care/partials/queue-ops.blade.php b/resources/views/care/partials/queue-ops.blade.php new file mode 100644 index 0000000..c6be681 --- /dev/null +++ b/resources/views/care/partials/queue-ops.blade.php @@ -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) +
+
+

Queue

+

Call next, serve, and complete on this list — tickets are for this department only.

+
+
+ @csrf + @foreach ($callNextParams as $key => $value) + @if (is_scalar($value)) + + @endif + @endforeach + @if (! empty($branchId)) + + @endif + +
+
+@endif diff --git a/resources/views/care/partials/queue-ticket.blade.php b/resources/views/care/partials/queue-ticket.blade.php new file mode 100644 index 0000000..59ba6db --- /dev/null +++ b/resources/views/care/partials/queue-ticket.blade.php @@ -0,0 +1,18 @@ +@php + $status = $ticketStatus ?? null; + $number = $ticketNumber ?? null; +@endphp +@if ($number) + + {{ $number }} + @if ($status) + $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 }} + @endif + +@endif diff --git a/resources/views/care/prescriptions/queue.blade.php b/resources/views/care/prescriptions/queue.blade.php index 708d0d8..21a8096 100644 --- a/resources/views/care/prescriptions/queue.blade.php +++ b/resources/views/care/prescriptions/queue.blade.php @@ -1,12 +1,30 @@

Pharmacy queue

Active prescriptions awaiting dispensing

+ +
+ @include('care.partials.queue-ops', [ + 'queueIntegration' => $queueIntegration ?? null, + 'queueCallNextRoute' => 'care.prescriptions.call-next', + 'queueCallNextParams' => [], + 'branchId' => $branchId ?? null, + ]) +
+
@forelse ($queue as $rx)
-

{{ $rx->patient->fullName() }}

+

+ @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() }} +

{{ $rx->patient->patient_number }} · {{ $rx->visit->branch?->name }}

    @foreach ($rx->items as $item) @@ -14,6 +32,14 @@ @endforeach
@if ($canDispense) +
+ @if (! empty($queueIntegration['enabled']) && $rx->queue_ticket_uuid && ($rx->queue_ticket_status ?? '') !== 'serving') +
+ @csrf + +
+ @endif +
@csrf @php $allocIndex = 0; @endphp @@ -41,7 +67,9 @@ @endif @endforeach
- + View details
@@ -56,8 +84,4 @@

No prescriptions in queue.

@endforelse
- -
- @include('care.service-queues._panel') -
diff --git a/resources/views/care/queue/index.blade.php b/resources/views/care/queue/index.blade.php index 045a2b0..dc114e3 100644 --- a/resources/views/care/queue/index.blade.php +++ b/resources/views/care/queue/index.blade.php @@ -31,15 +31,32 @@ + @include('care.partials.queue-ops', [ + 'queueIntegration' => $queueIntegration ?? null, + 'queueCallNextRoute' => 'care.queue.call-next', + 'queueCallNextParams' => [], + 'branchId' => $branchId, + ]) +

Waiting ({{ $queue->count() }})

@forelse ($queue as $appointment) -
+
($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), + ])>

- @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) {{ $appointment->queue_position }} @endif {{ $appointment->patient->fullName() }} @@ -49,7 +66,9 @@ @if ($canConsult)

@csrf - +
@endif
@@ -65,7 +84,15 @@ @forelse ($inConsultation as $appointment)
-

{{ $appointment->patient->fullName() }}

+

+ @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() }} +

{{ $appointment->practitioner?->name ?? 'Unassigned' }}

@if ($appointment->consultation) @@ -78,7 +105,5 @@
- - @include('care.service-queues._panel')
diff --git a/resources/views/care/settings/edit.blade.php b/resources/views/care/settings/edit.blade.php index 92496a3..86e6346 100644 --- a/resources/views/care/settings/edit.blade.php +++ b/resources/views/care/settings/edit.blade.php @@ -207,7 +207,7 @@
- + @if (! empty($canUseQueueIntegration))
+ @include('care.partials.queue-ops', [ + 'queueIntegration' => $queueIntegration ?? null, + 'queueCallNextRoute' => 'care.specialty.call-next', + 'queueCallNextParams' => ['module' => $moduleKey], + 'branchId' => $branchId ?? null, + ]) +

Waiting patients

@@ -51,7 +58,15 @@ @forelse ($waiting as $appointment)
-

{{ $appointment->patient?->fullName() }}

+

+ @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() }} +

{{ $appointment->department?->name }} · {{ $appointment->practitioner?->name ?? 'Unassigned' }}

View @@ -61,7 +76,5 @@ @endforelse
- - @include('care.service-queues._panel') diff --git a/routes/web.php b/routes/web.php index e21059b..4144a15 100644 --- a/routes/web.php +++ b/routes/web.php @@ -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'); diff --git a/tests/Feature/CareNaturalQueueTest.php b/tests/Feature/CareNaturalQueueTest.php index 5e9f444..8aa5ff1 100644 --- a/tests/Feature/CareNaturalQueueTest.php +++ b/tests/Feature/CareNaturalQueueTest.php @@ -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'); + }); } } diff --git a/tests/Feature/CareQueueBridgeTest.php b/tests/Feature/CareQueueBridgeTest.php new file mode 100644 index 0000000..8f77af8 --- /dev/null +++ b/tests/Feature/CareQueueBridgeTest.php @@ -0,0 +1,193 @@ +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'); + } +} diff --git a/tests/Feature/CareQueueWorkflowTest.php b/tests/Feature/CareQueueWorkflowTest.php new file mode 100644 index 0000000..9803912 --- /dev/null +++ b/tests/Feature/CareQueueWorkflowTest.php @@ -0,0 +1,361 @@ +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'); + } +}