diff --git a/app/Http/Controllers/Care/AppointmentController.php b/app/Http/Controllers/Care/AppointmentController.php index 9cbd2d4..04555ea 100644 --- a/app/Http/Controllers/Care/AppointmentController.php +++ b/app/Http/Controllers/Care/AppointmentController.php @@ -246,10 +246,12 @@ class AppointmentController extends Controller */ protected function validatedWalkInData(Request $request): array { + $queueOn = (bool) data_get($this->organization($request)->settings, 'queue_integration_enabled'); + return $request->validate([ 'branch_id' => ['required', 'integer', 'exists:care_branches,id'], 'patient_id' => ['required', 'integer', 'exists:care_patients,id'], - 'practitioner_id' => ['nullable', 'integer', 'exists:care_practitioners,id'], + 'practitioner_id' => [$queueOn ? 'required' : 'nullable', 'integer', 'exists:care_practitioners,id'], 'department_id' => ['nullable', 'integer', 'exists:care_departments,id'], 'reason' => ['nullable', 'string', 'max:1000'], 'notes' => ['nullable', 'string', 'max:5000'], diff --git a/app/Http/Controllers/Care/BillController.php b/app/Http/Controllers/Care/BillController.php index 70d40df..0ceba65 100644 --- a/app/Http/Controllers/Care/BillController.php +++ b/app/Http/Controllers/Care/BillController.php @@ -69,10 +69,10 @@ class BillController extends Controller abort_unless($branchId > 0, 422); abort_unless($this->queueBridge->isEnabled($organization), 404); - $result = $this->queueBridge->callNext($organization, CareQueueContexts::BILLING, $branchId); + $result = $this->queueBridge->callNext($organization, CareQueueContexts::BILLING, $branchId, $member); $ticket = $result['ticket'] ?? null; if (! $ticket) { - return back()->with('info', 'No bills waiting in the billing queue.'); + return back()->with('info', 'No bills waiting at your billing service point.'); } return back()->with('success', 'Called '.$ticket['ticket_number'].'.'); diff --git a/app/Http/Controllers/Care/InvestigationController.php b/app/Http/Controllers/Care/InvestigationController.php index 9141bbe..67453c5 100644 --- a/app/Http/Controllers/Care/InvestigationController.php +++ b/app/Http/Controllers/Care/InvestigationController.php @@ -93,10 +93,10 @@ class InvestigationController extends Controller abort_unless($branchId > 0, 422); abort_unless($this->queueBridge->isEnabled($organization), 404); - $result = $this->queueBridge->callNext($organization, CareQueueContexts::LABORATORY, $branchId); + $result = $this->queueBridge->callNext($organization, CareQueueContexts::LABORATORY, $branchId, $member); $ticket = $result['ticket'] ?? null; if (! $ticket) { - return back()->with('info', 'No lab work waiting in the laboratory queue.'); + return back()->with('info', 'No lab work waiting at your laboratory service point.'); } return back()->with('success', 'Called '.$ticket['ticket_number'].'.'); diff --git a/app/Http/Controllers/Care/PrescriptionController.php b/app/Http/Controllers/Care/PrescriptionController.php index 13884eb..d462d89 100644 --- a/app/Http/Controllers/Care/PrescriptionController.php +++ b/app/Http/Controllers/Care/PrescriptionController.php @@ -95,10 +95,10 @@ class PrescriptionController extends Controller abort_unless($branchId > 0, 422); abort_unless($this->queueBridge->isEnabled($organization), 404); - $result = $this->queueBridge->callNext($organization, CareQueueContexts::PHARMACY, $branchId); + $result = $this->queueBridge->callNext($organization, CareQueueContexts::PHARMACY, $branchId, $member); $ticket = $result['ticket'] ?? null; if (! $ticket) { - return back()->with('info', 'No prescriptions waiting in the pharmacy queue.'); + return back()->with('info', 'No prescriptions waiting at your pharmacy service point.'); } return back()->with('success', 'Called '.$ticket['ticket_number'].'.'); diff --git a/app/Http/Controllers/Care/QueueController.php b/app/Http/Controllers/Care/QueueController.php index 1f5da71..4f256a6 100644 --- a/app/Http/Controllers/Care/QueueController.php +++ b/app/Http/Controllers/Care/QueueController.php @@ -108,10 +108,16 @@ class QueueController extends Controller $branchId = (int) ($request->input('branch_id') ?: $branchScope); abort_unless($branchId > 0, 422); - $result = $this->queueBridge->callNext($organization, CareQueueContexts::CONSULTATION, $branchId); + $result = $this->queueBridge->callNext( + $organization, + CareQueueContexts::CONSULTATION, + $branchId, + $member, + $request->input('practitioner_id') ? (int) $request->input('practitioner_id') : null, + ); $ticket = $result['ticket'] ?? null; if (! $ticket) { - return back()->with('info', 'No patients waiting in the consultation queue.'); + return back()->with('info', 'No patients waiting at your consultation service point.'); } return back()->with('success', 'Called '.$ticket['ticket_number'].' — '.($ticket['customer_name'] ?? 'patient').'.'); diff --git a/app/Models/Appointment.php b/app/Models/Appointment.php index c843e43..7ea2df8 100644 --- a/app/Models/Appointment.php +++ b/app/Models/Appointment.php @@ -35,6 +35,7 @@ class Appointment extends Model protected $fillable = [ 'uuid', 'queue_ticket_uuid', 'queue_ticket_number', 'queue_ticket_status', + 'queue_service_point_uuid', 'queue_destination', 'queue_staff_display_name', 'queue_routing_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', diff --git a/app/Models/Bill.php b/app/Models/Bill.php index fa530d6..df8da0b 100644 --- a/app/Models/Bill.php +++ b/app/Models/Bill.php @@ -27,6 +27,7 @@ class Bill extends Model protected $fillable = [ 'uuid', 'queue_ticket_uuid', 'queue_ticket_number', 'queue_ticket_status', + 'queue_service_point_uuid', 'queue_destination', 'queue_staff_display_name', 'queue_routing_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 540a581..d2dcd11 100644 --- a/app/Models/InvestigationRequest.php +++ b/app/Models/InvestigationRequest.php @@ -31,6 +31,7 @@ class InvestigationRequest extends Model protected $fillable = [ 'uuid', 'queue_ticket_uuid', 'queue_ticket_number', 'queue_ticket_status', + 'queue_service_point_uuid', 'queue_destination', 'queue_staff_display_name', 'queue_routing_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', diff --git a/app/Models/Practitioner.php b/app/Models/Practitioner.php index db775f7..aeccc2b 100644 --- a/app/Models/Practitioner.php +++ b/app/Models/Practitioner.php @@ -16,7 +16,7 @@ class Practitioner extends Model protected $fillable = [ 'owner_ref', 'organization_id', 'branch_id', 'department_id', 'member_id', - 'user_ref', 'name', 'specialty', 'is_active', + 'user_ref', 'name', 'specialty', 'room', 'is_active', ]; protected function casts(): array diff --git a/app/Models/Prescription.php b/app/Models/Prescription.php index 2c93a8e..3382b73 100644 --- a/app/Models/Prescription.php +++ b/app/Models/Prescription.php @@ -25,6 +25,7 @@ class Prescription extends Model protected $fillable = [ 'uuid', 'queue_ticket_uuid', 'queue_ticket_number', 'queue_ticket_status', + 'queue_service_point_uuid', 'queue_destination', 'queue_staff_display_name', 'queue_routing_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/CareQueueBridge.php b/app/Services/Care/CareQueueBridge.php index cda88cb..682b90e 100644 --- a/app/Services/Care/CareQueueBridge.php +++ b/app/Services/Care/CareQueueBridge.php @@ -5,6 +5,7 @@ namespace App\Services\Care; use App\Models\Appointment; use App\Models\Bill; use App\Models\InvestigationRequest; +use App\Models\Member; use App\Models\Organization; use App\Models\Patient; use App\Models\Prescription; @@ -14,7 +15,8 @@ use Illuminate\Http\Client\RequestException; use Illuminate\Support\Facades\Log; /** - * Bridges Care workflow entities to Ladill Queue tickets for the matching department queue. + * Bridges Care workflow entities to Ladill Queue tickets for department + * service points (not a single shared consultation counter per branch). */ class CareQueueBridge { @@ -32,9 +34,6 @@ class CareQueueBridge && $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; @@ -59,6 +58,20 @@ class CareQueueBridge } $context = $this->contextForAppointment($organization, $appointment); + $point = $this->resolveConsultationPoint($organization, $appointment); + + if (CareQueueContexts::usesAssignedRouting($context) && ! $point) { + $appointment->forceFill([ + 'queue_routing_status' => CareQueueContexts::ROUTING_UNRESOLVED, + ])->save(); + Log::info('care.queue_issue_unresolved_assignment', [ + 'appointment_id' => $appointment->id, + 'practitioner_id' => $appointment->practitioner_id, + ]); + + return $appointment->fresh(); + } + $ticket = $this->issue( $organization, $context, @@ -68,16 +81,18 @@ class CareQueueBridge 'care_entity' => 'appointment', 'care_entity_uuid' => $appointment->uuid, 'care_entity_id' => $appointment->id, + 'practitioner_id' => $appointment->practitioner_id, ], $appointment->type === Appointment::TYPE_WALK_IN ? 'walk_in' : 'appointment', 'appointment', + $point, ); if (! $ticket) { return $appointment; } - $this->applyTicket($appointment, $ticket); + $this->applyTicket($appointment, $ticket, $point); return $appointment->fresh(); } @@ -94,6 +109,7 @@ class CareQueueBridge return $prescription; } + $point = $this->provisioner->defaultPoint($organization, CareQueueContexts::PHARMACY, $branchId); $ticket = $this->issue( $organization, CareQueueContexts::PHARMACY, @@ -104,13 +120,16 @@ class CareQueueBridge 'care_entity_uuid' => $prescription->uuid, 'care_entity_id' => $prescription->id, ], + 'walk_in', + 'api', + $point, ); if (! $ticket) { return $prescription; } - $this->applyTicket($prescription, $ticket); + $this->applyTicket($prescription, $ticket, $point); return $prescription->fresh(); } @@ -122,6 +141,11 @@ class CareQueueBridge } $request->loadMissing('patient'); + $point = $this->provisioner->defaultPoint( + $organization, + CareQueueContexts::LABORATORY, + (int) $request->branch_id, + ); $ticket = $this->issue( $organization, CareQueueContexts::LABORATORY, @@ -132,13 +156,16 @@ class CareQueueBridge 'care_entity_uuid' => $request->uuid, 'care_entity_id' => $request->id, ], + 'walk_in', + 'api', + $point, ); if (! $ticket) { return $request; } - $this->applyTicket($request, $ticket); + $this->applyTicket($request, $ticket, $point); return $request->fresh(); } @@ -154,6 +181,11 @@ class CareQueueBridge } $bill->loadMissing('patient'); + $point = $this->provisioner->defaultPoint( + $organization, + CareQueueContexts::BILLING, + (int) $bill->branch_id, + ); $ticket = $this->issue( $organization, CareQueueContexts::BILLING, @@ -164,21 +196,52 @@ class CareQueueBridge 'care_entity_uuid' => $bill->uuid, 'care_entity_id' => $bill->id, ], + 'walk_in', + 'api', + $point, ); if (! $ticket) { return $bill; } - $this->applyTicket($bill, $ticket); + $this->applyTicket($bill, $ticket, $point); return $bill->fresh(); } /** - * Issue Queue tickets for Care waiting entities that were created before - * integration was enabled (or seeded without going through check-in). - * + * After completing a stage, issue the next department ticket when applicable. + */ + public function advanceAfterComplete(Organization $organization, Model $entity, string $fromContext): void + { + if (! $this->isEnabled($organization)) { + return; + } + + // Lab/imaging completion → return-to-doctor consultation ticket when practitioner known. + if ($fromContext === CareQueueContexts::LABORATORY && $entity instanceof InvestigationRequest) { + $entity->loadMissing(['patient', 'practitioner', 'visit']); + $appointment = Appointment::owned($organization->owner_ref) + ->where('organization_id', $organization->id) + ->where('patient_id', $entity->patient_id) + ->where('branch_id', $entity->branch_id) + ->whereIn('status', [ + Appointment::STATUS_IN_CONSULTATION, + Appointment::STATUS_WAITING, + Appointment::STATUS_CHECKED_IN, + ]) + ->when($entity->practitioner_id, fn ($q) => $q->where('practitioner_id', $entity->practitioner_id)) + ->latest('id') + ->first(); + + if ($appointment && blank($appointment->queue_ticket_uuid)) { + $this->issueForAppointment($organization, $appointment); + } + } + } + + /** * @return int Number of tickets successfully issued */ public function syncMissingTickets(Organization $organization, string $context, int $branchId, int $limit = 50): int @@ -187,6 +250,8 @@ class CareQueueBridge return 0; } + $this->provisioner->ensure($organization, $context, $branchId); + return match (true) { $context === CareQueueContexts::PHARMACY => $this->syncMissingPrescriptions($organization, $branchId, $limit), $context === CareQueueContexts::LABORATORY => $this->syncMissingInvestigations($organization, $branchId, $limit), @@ -196,26 +261,35 @@ class CareQueueBridge } /** - * Call the next waiting ticket for a Care page context + branch. - * Backfills missing tickets first so Call next works when Care shows waiters - * that never received a Queue ticket (demo seed / pre-integration check-ins). - * * @return array{ticket: ?array, entity: ?Model, resources: ?array} */ - public function callNext(Organization $organization, string $context, int $branchId): array - { + public function callNext( + Organization $organization, + string $context, + int $branchId, + ?Member $member = null, + ?int $practitionerId = null, + ): array { $resources = $this->provisioner->ensure($organization, $context, $branchId); - if (! $resources || empty($resources['queue_uuid']) || empty($resources['counter_uuid'])) { + if (! $resources || empty($resources['queue_uuid'])) { + return ['ticket' => null, 'entity' => null, 'resources' => $resources]; + } + + $point = $this->resolveCallNextPoint($organization, $context, $branchId, $member, $practitionerId, $resources); + if (! $point || empty($point['counter_uuid'])) { return ['ticket' => null, 'entity' => null, 'resources' => $resources]; } $owner = (string) $organization->owner_ref; + $callResources = [ + 'queue_uuid' => $resources['queue_uuid'], + 'counter_uuid' => $point['counter_uuid'], + ]; - $ticket = $this->attemptCallNext($owner, $resources); + $ticket = $this->attemptCallNext($owner, $callResources); if (! $ticket) { - // Care may list waiters without Queue tickets — issue one (or a batch) then retry. $this->syncMissingTickets($organization, $context, $branchId, 25); - $ticket = $this->attemptCallNext($owner, $resources); + $ticket = $this->attemptCallNext($owner, $callResources); } if (! $ticket) { @@ -224,7 +298,7 @@ class CareQueueBridge $entity = $this->findEntityByTicket($organization, $context, (string) ($ticket['uuid'] ?? '')); if ($entity) { - $this->applyTicket($entity, $ticket); + $this->applyTicket($entity, $ticket, $point); } return ['ticket' => $ticket, 'entity' => $entity?->fresh(), 'resources' => $resources]; @@ -252,8 +326,6 @@ class CareQueueBridge } /** - * Mark ticket as serving (Queue "start"). - * * @return array|null */ public function serve(Organization $organization, Model $entity): ?array @@ -262,18 +334,19 @@ class CareQueueBridge } /** - * Complete the Queue ticket linked to a Care entity. - * * @return array|null */ - public function complete(Organization $organization, Model $entity): ?array + public function complete(Organization $organization, Model $entity, ?string $context = null): ?array { - return $this->ticketAction($organization, $entity, 'complete'); + $ticket = $this->ticketAction($organization, $entity, 'complete'); + if ($ticket && $context) { + $this->advanceAfterComplete($organization, $entity, $context); + } + + return $ticket; } /** - * Recall the Queue ticket linked to a Care entity. - * * @return array|null */ public function recall(Organization $organization, Model $entity): ?array @@ -282,7 +355,7 @@ class CareQueueBridge } /** - * @return array{enabled: bool, context: string, queue_uuid: ?string, counter_uuid: ?string} + * @return array{enabled: bool, context: string, queue_uuid: ?string, counter_uuid: ?string, points: list>, routing_mode: ?string} */ public function listMeta(Organization $organization, string $context, int $branchId): array { @@ -292,6 +365,8 @@ class CareQueueBridge 'context' => $context, 'queue_uuid' => null, 'counter_uuid' => null, + 'points' => [], + 'routing_mode' => null, ]; } @@ -302,11 +377,85 @@ class CareQueueBridge 'context' => $context, 'queue_uuid' => $resources['queue_uuid'] ?? null, 'counter_uuid' => $resources['counter_uuid'] ?? null, + 'points' => $resources['points'] ?? [], + 'routing_mode' => $resources['routing_mode'] ?? null, ]; } + /** + * @return array|null + */ + protected function resolveConsultationPoint(Organization $organization, Appointment $appointment): ?array + { + if ($appointment->practitioner_id) { + $point = $this->provisioner->pointForPractitioner( + $organization, + (int) $appointment->branch_id, + (int) $appointment->practitioner_id, + ); + if ($point) { + return $point; + } + // Practitioner assigned but point not provisioned yet — refresh once. + $this->provisioner->ensure($organization, CareQueueContexts::CONSULTATION, (int) $appointment->branch_id); + + return $this->provisioner->pointForPractitioner( + $organization->fresh(), + (int) $appointment->branch_id, + (int) $appointment->practitioner_id, + ); + } + + return null; + } + + /** + * @param array $resources + * @return array|null + */ + protected function resolveCallNextPoint( + Organization $organization, + string $context, + int $branchId, + ?Member $member, + ?int $practitionerId, + array $resources, + ): ?array { + if ($context === CareQueueContexts::CONSULTATION || CareQueueContexts::isSpecialty($context)) { + if ($practitionerId) { + return $this->provisioner->pointForPractitioner($organization, $branchId, $practitionerId) + ?? collect($resources['points'] ?? [])->first( + fn ($p) => ($p['kind'] ?? '') === 'practitioner' && (int) ($p['ref_id'] ?? 0) === $practitionerId + ); + } + if ($member && $member->role === 'doctor') { + $prac = \App\Models\Practitioner::owned($organization->owner_ref) + ->where('organization_id', $organization->id) + ->where('branch_id', $branchId) + ->where(function ($q) use ($member) { + $q->where('member_id', $member->id) + ->orWhere('user_ref', $member->user_ref); + }) + ->first(); + if ($prac) { + return $this->provisioner->pointForPractitioner($organization, $branchId, $prac->id); + } + } + } + + if ($member) { + $byMember = $this->provisioner->pointForMember($organization, $context, $branchId, $member); + if ($byMember) { + return $byMember; + } + } + + return $this->provisioner->defaultPoint($organization, $context, $branchId); + } + /** * @param array $metadata + * @param array|null $point * @return array|null */ protected function issue( @@ -317,6 +466,7 @@ class CareQueueBridge array $metadata = [], string $priority = 'walk_in', string $source = 'api', + ?array $point = null, ): ?array { $resources = $this->provisioner->ensure($organization, $context, $branchId); if (! $resources || empty($resources['queue_uuid'])) { @@ -328,17 +478,30 @@ class CareQueueBridge return null; } + if (CareQueueContexts::usesAssignedRouting($context) && empty($point['counter_uuid'])) { + Log::info('care.queue_issue_skipped_no_point', [ + 'context' => $context, + 'branch_id' => $branchId, + ]); + + return null; + } + $owner = (string) $organization->owner_ref; + $payload = [ + 'service_queue_id' => $resources['queue_uuid'], + 'customer_name' => $patient?->fullName(), + 'customer_phone' => $patient?->phone, + 'priority' => $priority, + 'source' => $source, + 'metadata' => $metadata, + ]; + if (! empty($point['counter_uuid'])) { + $payload['assigned_counter_id'] = $point['counter_uuid']; + } 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, - ]); + return $this->queue->issueTicket($owner, $payload); } catch (\Throwable $e) { Log::warning('care.queue_issue_failed', [ 'context' => $context, @@ -352,13 +515,27 @@ class CareQueueBridge /** * @param array $ticket + * @param array|null $point */ - protected function applyTicket(Model $entity, array $ticket): void + protected function applyTicket(Model $entity, array $ticket, ?array $point = null): void { + $assigned = $ticket['assigned_counter'] ?? null; $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'), + 'queue_service_point_uuid' => $ticket['assigned_counter']['uuid'] + ?? $ticket['counter']['uuid'] + ?? $point['counter_uuid'] + ?? $entity->getAttribute('queue_service_point_uuid'), + 'queue_destination' => $ticket['destination'] + ?? $point['destination'] + ?? $entity->getAttribute('queue_destination'), + 'queue_staff_display_name' => $ticket['staff_display_name'] + ?? $point['staff_display_name'] + ?? (is_array($assigned) ? ($assigned['staff_display_name'] ?? null) : null) + ?? $entity->getAttribute('queue_staff_display_name'), + 'queue_routing_status' => CareQueueContexts::ROUTING_ROUTED, ])->save(); } @@ -428,7 +605,7 @@ class CareQueueBridge ->where(function ($q) { $q->whereNull('queue_ticket_uuid')->orWhere('queue_ticket_uuid', ''); }) - ->with(['patient', 'organization']) + ->with(['patient', 'organization', 'practitioner']) ->orderBy('queue_position') ->orderBy('waiting_at') ->orderBy('checked_in_at') @@ -442,7 +619,7 @@ class CareQueueBridge continue; } - $updated = $this->issueForAppointment($organization, $appointment->fresh(['patient', 'organization'])); + $updated = $this->issueForAppointment($organization, $appointment->fresh(['patient', 'organization', 'practitioner'])); if ($updated && filled($updated->queue_ticket_uuid)) { $issued++; } diff --git a/app/Services/Care/CareQueueContexts.php b/app/Services/Care/CareQueueContexts.php index 23ec53a..79bd7c6 100644 --- a/app/Services/Care/CareQueueContexts.php +++ b/app/Services/Care/CareQueueContexts.php @@ -4,11 +4,18 @@ namespace App\Services\Care; /** * Canonical Care → Ladill Queue department / specialty contexts. + * + * Mapping: + * - Department/service → Queue ServiceQueue (+ Queue Department) + * - Service point (room/desk/counter) → Queue Counter + * - Staff assignment → Counter staff_ref / staff_display_name */ class CareQueueContexts { public const RECEPTION = 'reception'; + public const TRIAGE = 'triage'; + public const CONSULTATION = 'consultation'; public const PHARMACY = 'pharmacy'; @@ -17,19 +24,63 @@ class CareQueueContexts public const BILLING = 'billing'; + public const IMAGING = 'imaging'; + + public const ROUTING_ROUTED = 'routed'; + + public const ROUTING_UNRESOLVED = 'unresolved'; + /** * Core department queues provisioned for every branch when Queue integration is on. + * Consultation/pharmacy/lab/billing use assigned_only routing (per service point). * - * @return array + * @return array */ 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'], + self::RECEPTION => [ + 'name' => 'Reception', + 'prefix' => 'R', + 'routing_mode' => 'shared_pool', + 'type' => 'reception', + ], + self::TRIAGE => [ + 'name' => 'Triage', + 'prefix' => 'T', + 'routing_mode' => 'assigned_only', + 'type' => 'consultation', + ], + self::CONSULTATION => [ + 'name' => 'Consultation', + 'prefix' => 'C', + 'routing_mode' => 'assigned_only', + 'type' => 'consultation', + ], + self::PHARMACY => [ + 'name' => 'Pharmacy', + 'prefix' => 'P', + 'routing_mode' => 'assigned_only', + 'type' => 'pharmacy', + ], + self::LABORATORY => [ + 'name' => 'Laboratory', + 'prefix' => 'L', + 'routing_mode' => 'assigned_only', + 'type' => 'laboratory', + ], + self::BILLING => [ + 'name' => 'Billing', + 'prefix' => 'B', + 'routing_mode' => 'assigned_only', + 'type' => 'cashier', + ], + self::IMAGING => [ + 'name' => 'Imaging', + 'prefix' => 'I', + 'routing_mode' => 'assigned_only', + 'type' => 'laboratory', + ], ]; } @@ -43,6 +94,15 @@ class CareQueueContexts return array_key_exists($context, config('care.specialty_modules', [])); } + public static function usesAssignedRouting(string $context): bool + { + if (self::isSpecialty($context)) { + return true; + } + + return (self::departments()[$context]['routing_mode'] ?? 'shared_pool') === 'assigned_only'; + } + public static function queueExternalKey(string $context, int|string $careBranchId): string { if (self::isSpecialty($context)) { @@ -52,6 +112,12 @@ class CareQueueContexts return "care:dept:{$context}:queue:{$careBranchId}"; } + public static function departmentExternalKey(string $context, int|string $careBranchId): string + { + return "care:dept:{$context}:branch:{$careBranchId}"; + } + + /** @deprecated Prefer pointExternalKey — kept for legacy single-counter stubs. */ public static function counterExternalKey(string $context, int|string $careBranchId): string { if (self::isSpecialty($context)) { @@ -60,4 +126,13 @@ class CareQueueContexts return "care:dept:{$context}:counter:{$careBranchId}"; } + + public static function pointExternalKey( + string $context, + int|string $careBranchId, + string $pointKind, + int|string $pointId, + ): string { + return "care:point:{$context}:{$pointKind}:{$careBranchId}:{$pointId}"; + } } diff --git a/app/Services/Care/CareQueueProvisioner.php b/app/Services/Care/CareQueueProvisioner.php index a935f98..0afb890 100644 --- a/app/Services/Care/CareQueueProvisioner.php +++ b/app/Services/Care/CareQueueProvisioner.php @@ -3,12 +3,15 @@ namespace App\Services\Care; use App\Models\Branch; +use App\Models\Member; use App\Models\Organization; +use App\Models\Practitioner; use App\Services\Queue\QueueClient; use Illuminate\Support\Facades\Log; /** - * Idempotently provisions Ladill Queue queues/counters for Care departments per branch. + * Idempotently provisions Ladill Queue departments, queues, and service points + * (counters) from Care staff / branch configuration. */ class CareQueueProvisioner { @@ -25,8 +28,6 @@ class CareQueueProvisioner } /** - * Provision core department queues for all org branches. Returns updated settings fragment. - * * @return array */ public function provisionOrganization(Organization $organization): array @@ -52,31 +53,14 @@ class CareQueueProvisioner 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(), - ]); - } - + $stub = $this->provisionBranchDepartment( + $organization, + $owner, + $context, + $meta, + $branch, + is_array($prior) ? $prior : [], + ); $byBranch->put((string) $branch->id, $stub); } @@ -93,7 +77,291 @@ class CareQueueProvisioner } /** - * @return array{queue_uuid: ?string, counter_uuid: ?string, queue_external_key: string, counter_external_key: string, synced: bool}|null + * @param array $meta + * @param array $prior + * @return array + */ + protected function provisionBranchDepartment( + Organization $organization, + string $owner, + string $context, + array $meta, + Branch $branch, + array $prior, + ): array { + $stub = [ + 'name' => $meta['name'], + 'prefix' => $meta['prefix'], + 'module' => $context, + 'branch_id' => $branch->id, + 'branch_name' => $branch->name, + 'active' => true, + 'routing_mode' => $meta['routing_mode'], + 'queue_external_key' => CareQueueContexts::queueExternalKey($context, $branch->id), + 'department_external_key' => CareQueueContexts::departmentExternalKey($context, $branch->id), + 'queue_uuid' => $prior['queue_uuid'] ?? null, + 'department_id' => $prior['department_id'] ?? null, + 'counter_uuid' => $prior['counter_uuid'] ?? null, + 'counter_external_key' => $prior['counter_external_key'] + ?? CareQueueContexts::counterExternalKey($context, $branch->id), + 'points' => is_array($prior['points'] ?? null) ? $prior['points'] : [], + 'synced' => false, + ]; + + try { + $this->ensureBranchName($owner, $branch->name); + + $department = $this->queue->ensureDepartment($owner, [ + 'name' => $meta['name'], + 'type' => $meta['type'] ?? 'general', + 'branch_name' => $branch->name, + 'external_key' => $stub['department_external_key'], + 'is_active' => true, + ]); + $stub['department_id'] = $department['id'] ?? $stub['department_id']; + + $queue = $this->queue->ensureQueue($owner, [ + 'name' => $meta['name'], + 'prefix' => $meta['prefix'], + 'branch_name' => $branch->name, + 'strategy' => 'fifo', + 'external_key' => $stub['queue_external_key'], + 'routing_mode' => $meta['routing_mode'], + 'department_id' => $stub['department_id'], + 'is_active' => true, + ]); + $stub['queue_uuid'] = $queue['uuid'] ?? $stub['queue_uuid']; + + $points = $this->buildPointsForContext($organization, $context, $branch, $stub); + $syncedPoints = []; + foreach ($points as $point) { + try { + $counter = $this->queue->ensureCounter($owner, [ + 'name' => $point['name'], + 'destination' => $point['destination'], + 'staff_ref' => $point['staff_ref'], + 'staff_display_name' => $point['staff_display_name'], + 'branch_name' => $branch->name, + 'department_id' => $stub['department_id'], + 'queue_uuids' => array_values(array_filter([(string) $stub['queue_uuid']])), + 'external_key' => $point['external_key'], + 'is_active' => true, + ]); + $point['counter_uuid'] = $counter['uuid'] ?? null; + $point['synced'] = ! empty($point['counter_uuid']); + } catch (\Throwable $e) { + Log::warning('care.queue_point_provision_failed', [ + 'context' => $context, + 'branch_id' => $branch->id, + 'point' => $point['external_key'] ?? null, + 'message' => $e->getMessage(), + ]); + $point['synced'] = false; + } + $syncedPoints[] = $point; + } + + $stub['points'] = $syncedPoints; + // Legacy single counter: first point (or dedicated default) for older callers. + $first = collect($syncedPoints)->first(fn ($p) => ! empty($p['counter_uuid'])); + if ($first) { + $stub['counter_uuid'] = $first['counter_uuid']; + $stub['counter_external_key'] = $first['external_key']; + } + $stub['synced'] = ! empty($stub['queue_uuid']) && collect($syncedPoints)->contains(fn ($p) => ! empty($p['synced'])); + } catch (\Throwable $e) { + Log::warning('care.queue_department_provision_failed', [ + 'context' => $context, + 'branch_id' => $branch->id, + 'message' => $e->getMessage(), + ]); + } + + return $stub; + } + + /** + * @param array $stub + * @return list> + */ + protected function buildPointsForContext( + Organization $organization, + string $context, + Branch $branch, + array $stub, + ): array { + $owner = (string) $organization->owner_ref; + $priorByKey = collect($stub['points'] ?? [])->keyBy(fn ($p) => (string) ($p['external_key'] ?? '')); + + $points = match ($context) { + CareQueueContexts::CONSULTATION => $this->practitionerPoints($organization, $branch, $priorByKey), + CareQueueContexts::TRIAGE => $this->memberRolePoints( + $organization, + $branch, + ['nurse'], + $context, + 'Nurse station', + $priorByKey, + ), + CareQueueContexts::PHARMACY => $this->memberRolePoints( + $organization, + $branch, + ['pharmacist'], + $context, + 'Pharmacy counter', + $priorByKey, + ), + CareQueueContexts::LABORATORY, CareQueueContexts::IMAGING => $this->memberRolePoints( + $organization, + $branch, + ['lab_technician'], + $context, + $context === CareQueueContexts::IMAGING ? 'Imaging room' : 'Lab desk', + $priorByKey, + ), + CareQueueContexts::BILLING => $this->memberRolePoints( + $organization, + $branch, + ['cashier'], + $context, + 'Cashier desk', + $priorByKey, + ), + CareQueueContexts::RECEPTION => $this->memberRolePoints( + $organization, + $branch, + ['receptionist'], + $context, + 'Reception', + $priorByKey, + ), + default => [], + }; + + // Always keep at least one default point so call-next / least-load have a target. + if ($points === []) { + $key = CareQueueContexts::pointExternalKey($context, $branch->id, 'default', 0); + $prior = $priorByKey->get($key, []); + $points[] = [ + 'kind' => 'default', + 'ref_id' => 0, + 'name' => ($stub['name'] ?? ucfirst($context)).' desk', + 'destination' => ($stub['name'] ?? ucfirst($context)).' desk', + 'staff_ref' => null, + 'staff_display_name' => null, + 'external_key' => $key, + 'counter_uuid' => $prior['counter_uuid'] ?? null, + 'synced' => false, + ]; + } + + return $points; + } + + /** + * @param \Illuminate\Support\Collection> $priorByKey + * @return list> + */ + protected function practitionerPoints(Organization $organization, Branch $branch, $priorByKey): array + { + $practitioners = Practitioner::owned($organization->owner_ref) + ->where('organization_id', $organization->id) + ->where('branch_id', $branch->id) + ->where('is_active', true) + ->orderBy('name') + ->get(); + + $points = []; + $roomIndex = 1; + foreach ($practitioners as $practitioner) { + $key = CareQueueContexts::pointExternalKey( + CareQueueContexts::CONSULTATION, + $branch->id, + 'practitioner', + $practitioner->id, + ); + $prior = $priorByKey->get($key, []); + $room = trim((string) ($practitioner->room ?? '')); + if ($room === '') { + $room = 'Consultation Room '.$roomIndex; + } + $roomIndex++; + + $points[] = [ + 'kind' => 'practitioner', + 'ref_id' => $practitioner->id, + 'name' => $practitioner->name, + 'destination' => $room, + 'staff_ref' => $practitioner->user_ref ?: ('practitioner:'.$practitioner->id), + 'staff_display_name' => $practitioner->name, + 'specialty' => $practitioner->specialty, + 'external_key' => $key, + 'counter_uuid' => $prior['counter_uuid'] ?? null, + 'synced' => false, + ]; + } + + return $points; + } + + /** + * @param list $roles + * @param \Illuminate\Support\Collection> $priorByKey + * @return list> + */ + protected function memberRolePoints( + Organization $organization, + Branch $branch, + array $roles, + string $context, + string $destinationPrefix, + $priorByKey, + ): array { + $members = Member::owned($organization->owner_ref) + ->where('organization_id', $organization->id) + ->whereIn('role', $roles) + ->where(function ($q) use ($branch) { + $q->whereNull('branch_id')->orWhere('branch_id', $branch->id); + }) + ->orderBy('id') + ->get(); + + $points = []; + $index = 1; + foreach ($members as $member) { + $key = CareQueueContexts::pointExternalKey($context, $branch->id, 'member', $member->id); + $prior = $priorByKey->get($key, []); + $label = $destinationPrefix.' '.$index; + $points[] = [ + 'kind' => 'member', + 'ref_id' => $member->id, + 'name' => $label, + 'destination' => $label, + 'staff_ref' => $member->user_ref, + 'staff_display_name' => null, + 'external_key' => $key, + 'counter_uuid' => $prior['counter_uuid'] ?? null, + 'synced' => false, + ]; + $index++; + } + + return $points; + } + + protected function ensureBranchName(string $owner, string $branchName): void + { + try { + $this->queue->ensureBranch($owner, ['name' => $branchName]); + } catch (\Illuminate\Http\Client\RequestException $e) { + if ($e->response?->status() !== 422) { + throw $e; + } + } + } + + /** + * @return array{queue_uuid: ?string, counter_uuid: ?string, queue_external_key: string, counter_external_key: string, synced: bool, points: list>, routing_mode: string}|null */ public function resourcesFor( Organization $organization, @@ -118,6 +386,8 @@ class CareQueueProvisioner return null; } + $meta = CareQueueContexts::departments()[$context] ?? null; + return [ 'queue_uuid' => $match['queue_uuid'] ?? null, 'counter_uuid' => $match['counter_uuid'] ?? null, @@ -126,13 +396,82 @@ class CareQueueProvisioner 'counter_external_key' => (string) ($match['counter_external_key'] ?? CareQueueContexts::counterExternalKey($context, $branchId)), 'synced' => (bool) ($match['synced'] ?? false), + 'points' => is_array($match['points'] ?? null) ? $match['points'] : [], + 'routing_mode' => (string) ($match['routing_mode'] ?? $meta['routing_mode'] ?? 'shared_pool'), ]; } /** - * Ensure resources exist for one context+branch (lazy provision). + * Resolve a service point for a practitioner (consultation) or member staff_ref. * - * @return array{queue_uuid: ?string, counter_uuid: ?string, queue_external_key: string, counter_external_key: string, synced: bool}|null + * @return array|null + */ + public function pointForPractitioner(Organization $organization, int $branchId, int $practitionerId): ?array + { + $resources = $this->resourcesFor($organization, CareQueueContexts::CONSULTATION, $branchId); + if (! $resources) { + return null; + } + + return collect($resources['points'])->first( + fn ($p) => ($p['kind'] ?? '') === 'practitioner' && (int) ($p['ref_id'] ?? 0) === $practitionerId + ); + } + + /** + * @return array|null + */ + public function pointForMember(Organization $organization, string $context, int $branchId, Member $member): ?array + { + $resources = $this->resourcesFor($organization, $context, $branchId); + if (! $resources) { + return null; + } + + $byMember = collect($resources['points'])->first( + fn ($p) => ($p['kind'] ?? '') === 'member' && (int) ($p['ref_id'] ?? 0) === $member->id + ); + if ($byMember) { + return $byMember; + } + + return collect($resources['points'])->first( + fn ($p) => ($p['staff_ref'] ?? null) === $member->user_ref + ); + } + + /** + * Deterministic least-load / first active point when Care has no explicit assignment. + * + * @return array|null + */ + public function defaultPoint(Organization $organization, string $context, int $branchId): ?array + { + $resources = $this->resourcesFor($organization, $context, $branchId); + if (! $resources) { + return null; + } + + $points = collect($resources['points'])->filter(fn ($p) => ! empty($p['counter_uuid']) && ($p['synced'] ?? true)); + if ($points->isEmpty()) { + if (! empty($resources['counter_uuid'])) { + return [ + 'kind' => 'legacy', + 'counter_uuid' => $resources['counter_uuid'], + 'external_key' => $resources['counter_external_key'], + 'destination' => null, + 'staff_display_name' => null, + ]; + } + + return null; + } + + return $points->sortBy('ref_id')->first(); + } + + /** + * @return array{queue_uuid: ?string, counter_uuid: ?string, queue_external_key: string, counter_external_key: string, synced: bool, points: list>, routing_mode: string}|null */ public function ensure( Organization $organization, @@ -140,7 +479,9 @@ class CareQueueProvisioner int $branchId, ): ?array { $existing = $this->resourcesFor($organization, $context, $branchId); - if ($existing && ! empty($existing['queue_uuid']) && ! empty($existing['counter_uuid'])) { + if ($existing && ! empty($existing['queue_uuid']) && ( + ! empty($existing['points']) || ! empty($existing['counter_uuid']) + )) { return $existing; } diff --git a/app/Services/Care/DemoTenantSeeder.php b/app/Services/Care/DemoTenantSeeder.php index 0918d8f..1eb844c 100644 --- a/app/Services/Care/DemoTenantSeeder.php +++ b/app/Services/Care/DemoTenantSeeder.php @@ -536,6 +536,7 @@ class DemoTenantSeeder 'member_id' => null, 'name' => 'Dr. '.self::FIRST_NAMES[$i % count(self::FIRST_NAMES)].' '.self::LAST_NAMES[$i % count(self::LAST_NAMES)], 'specialty' => self::SPECIALTIES[$i % count(self::SPECIALTIES)], + 'room' => 'Consultation Room '.($i + 1), 'is_active' => true, 'deleted_at' => null, ], diff --git a/app/Services/Queue/QueueClient.php b/app/Services/Queue/QueueClient.php index ae0f695..b955e4d 100644 --- a/app/Services/Queue/QueueClient.php +++ b/app/Services/Queue/QueueClient.php @@ -112,6 +112,18 @@ class QueueClient return (array) ($response->json('data') ?? []); } + /** + * @param array $payload + * @return array + */ + public function ensureDepartment(string $owner, array $payload): array + { + $response = $this->http($owner)->post($this->base().'/departments', $payload); + $response->throw(); + + return (array) ($response->json('data') ?? []); + } + /** * @param array $payload * @return array @@ -361,11 +373,13 @@ class QueueClient 'branch_name' => $branchName, 'strategy' => 'fifo', 'external_key' => $queueKey, + 'routing_mode' => (string) ($stub['routing_mode'] ?? 'shared_pool'), 'is_active' => true, ]); $counter = $this->ensureCounter($owner, [ 'name' => $name.' counter', + 'destination' => $name, 'branch_name' => $branchName, 'queue_uuids' => array_values(array_filter([(string) ($queue['uuid'] ?? '')])), 'external_key' => $counterKey, diff --git a/database/migrations/2026_07_17_180000_add_service_point_routing_to_care.php b/database/migrations/2026_07_17_180000_add_service_point_routing_to_care.php new file mode 100644 index 0000000..63f4516 --- /dev/null +++ b/database/migrations/2026_07_17_180000_add_service_point_routing_to_care.php @@ -0,0 +1,42 @@ +string('room')->nullable()->after('specialty'); + }); + + foreach (['care_appointments', 'care_prescriptions', 'care_investigation_requests', 'care_bills'] as $tableName) { + Schema::table($tableName, function (Blueprint $blueprint) { + $blueprint->string('queue_service_point_uuid', 36)->nullable()->after('queue_ticket_status'); + $blueprint->string('queue_destination', 255)->nullable()->after('queue_service_point_uuid'); + $blueprint->string('queue_staff_display_name', 255)->nullable()->after('queue_destination'); + $blueprint->string('queue_routing_status', 32)->nullable()->after('queue_staff_display_name'); + }); + } + } + + public function down(): void + { + foreach (['care_appointments', 'care_prescriptions', 'care_investigation_requests', 'care_bills'] as $tableName) { + Schema::table($tableName, function (Blueprint $blueprint) { + $blueprint->dropColumn([ + 'queue_service_point_uuid', + 'queue_destination', + 'queue_staff_display_name', + 'queue_routing_status', + ]); + }); + } + + Schema::table('care_practitioners', function (Blueprint $table) { + $table->dropColumn('room'); + }); + } +}; diff --git a/resources/views/care/partials/queue-ops.blade.php b/resources/views/care/partials/queue-ops.blade.php index c6be681..ebd3bd5 100644 --- a/resources/views/care/partials/queue-ops.blade.php +++ b/resources/views/care/partials/queue-ops.blade.php @@ -8,7 +8,7 @@

Queue

-

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

+

Call next operates on your assigned service point only — tickets routed to other rooms or desks stay there.

@csrf diff --git a/resources/views/care/partials/queue-ticket.blade.php b/resources/views/care/partials/queue-ticket.blade.php index 59ba6db..7663b83 100644 --- a/resources/views/care/partials/queue-ticket.blade.php +++ b/resources/views/care/partials/queue-ticket.blade.php @@ -1,9 +1,11 @@ @php $status = $ticketStatus ?? null; $number = $ticketNumber ?? null; + $destination = $destination ?? null; + $staffDisplayName = $staffDisplayName ?? null; @endphp @if ($number) - + {{ $number }} @if ($status) ! in_array($status, ['waiting', 'called', 'serving'], true), ])>{{ $status }} @endif + @if ($destination || $staffDisplayName) + + @if ($destination){{ $destination }}@endif + @if ($destination && $staffDisplayName) · @endif + @if ($staffDisplayName){{ $staffDisplayName }}@endif + + @endif @endif diff --git a/resources/views/care/queue/index.blade.php b/resources/views/care/queue/index.blade.php index dc114e3..0ce03d1 100644 --- a/resources/views/care/queue/index.blade.php +++ b/resources/views/care/queue/index.blade.php @@ -34,7 +34,7 @@ @include('care.partials.queue-ops', [ 'queueIntegration' => $queueIntegration ?? null, 'queueCallNextRoute' => 'care.queue.call-next', - 'queueCallNextParams' => [], + 'queueCallNextParams' => array_filter(['practitioner_id' => $practitionerId]), 'branchId' => $branchId, ]) @@ -55,7 +55,11 @@ @include('care.partials.queue-ticket', [ 'ticketNumber' => $appointment->queue_ticket_number, 'ticketStatus' => $appointment->queue_ticket_status, + 'destination' => $appointment->queue_destination, + 'staffDisplayName' => $appointment->queue_staff_display_name, ]) + @elseif (! empty($queueIntegration['enabled']) && ($appointment->queue_routing_status ?? '') === 'unresolved') + Unassigned @elseif ($appointment->queue_position) {{ $appointment->queue_position }} @endif diff --git a/tests/Feature/CareQueueBridgeTest.php b/tests/Feature/CareQueueBridgeTest.php index 7b62e95..dff2dbb 100644 --- a/tests/Feature/CareQueueBridgeTest.php +++ b/tests/Feature/CareQueueBridgeTest.php @@ -80,10 +80,18 @@ class CareQueueBridgeTest extends TestCase 'prefix' => 'C', 'active' => true, 'synced' => true, + 'routing_mode' => 'assigned_only', '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), + 'points' => [[ + 'kind' => 'practitioner', + 'ref_id' => 0, // filled after practitioner create in tests that need it + 'destination' => 'Consultation desk', + 'counter_uuid' => 'bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb', + 'synced' => true, + ]], ]], ], ]; @@ -103,13 +111,38 @@ class CareQueueBridgeTest extends TestCase public function test_check_in_issues_consultation_ticket_when_integration_on(): void { + $practitioner = \App\Models\Practitioner::create([ + 'owner_ref' => $this->owner->public_id, + 'organization_id' => $this->organization->id, + 'branch_id' => $this->branch->id, + 'name' => 'Dr. Bridge', + 'room' => 'Room 1', + 'is_active' => true, + ]); + + $settings = $this->organization->settings; + $settings['department_queue_provisioning'][CareQueueContexts::CONSULTATION]['queues'][0]['points'] = [[ + 'kind' => 'practitioner', + 'ref_id' => $practitioner->id, + 'destination' => 'Room 1', + 'staff_display_name' => 'Dr. Bridge', + 'counter_uuid' => 'bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb', + 'synced' => true, + ]]; + $this->organization->update(['settings' => $settings]); + Http::fake(function (\Illuminate\Http\Client\Request $request) { if (str_contains($request->url(), '/tickets') && $request->method() === 'POST') { + $this->assertSame('bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb', $request['assigned_counter_id'] ?? null); + return Http::response([ 'data' => [ 'uuid' => 'cccccccc-cccc-cccc-cccc-cccccccccccc', 'ticket_number' => 'C001', 'status' => 'waiting', + 'assigned_counter' => ['uuid' => 'bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb'], + 'destination' => 'Room 1', + 'staff_display_name' => 'Dr. Bridge', ], ], 201); } @@ -122,6 +155,7 @@ class CareQueueBridgeTest extends TestCase 'organization_id' => $this->organization->id, 'branch_id' => $this->branch->id, 'patient_id' => $this->patient->id, + 'practitioner_id' => $practitioner->id, 'type' => Appointment::TYPE_SCHEDULED, 'status' => Appointment::STATUS_SCHEDULED, 'scheduled_at' => now()->addHour(), @@ -193,10 +227,29 @@ class CareQueueBridgeTest extends TestCase public function test_call_next_backfills_missing_ticket_then_calls(): void { + $practitioner = \App\Models\Practitioner::create([ + 'owner_ref' => $this->owner->public_id, + 'organization_id' => $this->organization->id, + 'branch_id' => $this->branch->id, + 'name' => 'Dr. Backfill', + 'room' => 'Room 3', + 'is_active' => true, + ]); + + $settings = $this->organization->settings; + $settings['department_queue_provisioning'][CareQueueContexts::CONSULTATION]['queues'][0]['points'] = [[ + 'kind' => 'practitioner', + 'ref_id' => $practitioner->id, + 'destination' => 'Room 3', + 'counter_uuid' => 'bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb', + 'synced' => true, + ]]; + $settings['department_queue_provisioning'][CareQueueContexts::CONSULTATION]['queues'][0]['counter_uuid'] = 'bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb'; + $this->organization->update(['settings' => $settings]); + $ticketSeq = 0; Http::fake(function (\Illuminate\Http\Client\Request $request) use (&$ticketSeq) { if (str_contains($request->url(), '/call-next') && $request->method() === 'POST') { - // First call: empty queue; second (after issue): ticket returned. if ($ticketSeq < 1) { return Http::response(['data' => null], 200); } @@ -219,6 +272,7 @@ class CareQueueBridgeTest extends TestCase 'uuid' => 'ffffffff-ffff-ffff-ffff-ffffffffffff', 'ticket_number' => 'C100', 'status' => 'waiting', + 'assigned_counter' => ['uuid' => 'bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb'], ], ], 201); } @@ -231,6 +285,7 @@ class CareQueueBridgeTest extends TestCase 'organization_id' => $this->organization->id, 'branch_id' => $this->branch->id, 'patient_id' => $this->patient->id, + 'practitioner_id' => $practitioner->id, 'type' => Appointment::TYPE_WALK_IN, 'status' => Appointment::STATUS_WAITING, 'scheduled_at' => now(), @@ -240,7 +295,10 @@ class CareQueueBridgeTest extends TestCase $this->actingAs($this->owner) ->from(route('care.queue.index')) - ->post(route('care.queue.call-next'), ['branch_id' => $this->branch->id]) + ->post(route('care.queue.call-next'), [ + 'branch_id' => $this->branch->id, + 'practitioner_id' => $practitioner->id, + ]) ->assertRedirect(route('care.queue.index')) ->assertSessionHas('success'); @@ -259,7 +317,7 @@ class CareQueueBridgeTest extends TestCase ->from(route('care.queue.index')) ->post(route('care.queue.call-next'), ['branch_id' => $this->branch->id]) ->assertRedirect(route('care.queue.index')) - ->assertSessionHas('info', 'No patients waiting in the consultation queue.') + ->assertSessionHas('info', 'No patients waiting at your consultation service point.') ->assertSessionMissing('error'); } } diff --git a/tests/Feature/CareQueueWorkflowTest.php b/tests/Feature/CareQueueWorkflowTest.php index 9803912..8ce6ea9 100644 --- a/tests/Feature/CareQueueWorkflowTest.php +++ b/tests/Feature/CareQueueWorkflowTest.php @@ -213,6 +213,42 @@ class CareQueueWorkflowTest extends TestCase { $this->fakeQueueApi(); + $practitioner = \App\Models\Practitioner::create([ + 'owner_ref' => $this->owner->public_id, + 'organization_id' => $this->organization->id, + 'branch_id' => $this->branch->id, + 'name' => 'Dr. Workflow', + 'room' => 'Room 2', + 'is_active' => true, + ]); + + $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, + 'routing_mode' => 'assigned_only', + 'queue_uuid' => 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa', + 'counter_uuid' => 'cccccccc-cccc-cccc-cccc-cccccccccccc', + 'points' => [[ + 'kind' => 'practitioner', + 'ref_id' => $practitioner->id, + 'destination' => 'Room 2', + 'counter_uuid' => 'cccccccc-cccc-cccc-cccc-cccccccccccc', + 'synced' => true, + ]], + ]], + ], + ], + ]), + ]); + $patient = Patient::create([ 'uuid' => (string) Str::uuid(), 'owner_ref' => $this->owner->public_id, @@ -229,6 +265,7 @@ class CareQueueWorkflowTest extends TestCase 'organization_id' => $this->organization->id, 'branch_id' => $this->branch->id, 'patient_id' => $patient->id, + 'practitioner_id' => $practitioner->id, 'type' => Appointment::TYPE_SCHEDULED, 'status' => Appointment::STATUS_SCHEDULED, 'scheduled_at' => now(), @@ -246,6 +283,14 @@ class CareQueueWorkflowTest extends TestCase { $this->fakeQueueApi(); + $pharmacist = Member::create([ + 'owner_ref' => $this->owner->public_id, + 'organization_id' => $this->organization->id, + 'user_ref' => 'pharm-workflow', + 'role' => 'pharmacist', + 'branch_id' => $this->branch->id, + ]); + $this->organization->update([ 'settings' => array_merge($this->organization->settings ?? [], [ 'department_queue_provisioning' => [ @@ -257,10 +302,19 @@ class CareQueueWorkflowTest extends TestCase 'prefix' => 'P', 'active' => true, 'synced' => true, + 'routing_mode' => 'assigned_only', '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), + 'points' => [[ + 'kind' => 'member', + 'ref_id' => $pharmacist->id, + 'staff_ref' => 'pharm-workflow', + 'destination' => 'Pharmacy counter 1', + 'counter_uuid' => 'dddddddd-dddd-dddd-dddd-dddddddddddd', + 'synced' => true, + ]], ]], ], ], @@ -341,7 +395,7 @@ class CareQueueWorkflowTest extends TestCase ->assertOk() ->assertDontSee('Service counter') ->assertSee('Call next') - ->assertSee('tickets are for this department only', false); + ->assertSee('assigned service point', false); } public function test_integration_off_hides_queue_ops_on_pharmacy_page(): void diff --git a/tests/Feature/CareServicePointRoutingTest.php b/tests/Feature/CareServicePointRoutingTest.php new file mode 100644 index 0000000..4fa88a0 --- /dev/null +++ b/tests/Feature/CareServicePointRoutingTest.php @@ -0,0 +1,447 @@ +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' => 'sp-care-owner', + 'name' => 'Owner', + 'email' => 'sp-care@example.com', + ]); + + $this->organization = Organization::create([ + 'owner_ref' => $this->owner->public_id, + 'name' => 'Ridge Medical Centre', + 'slug' => 'ridge-medical', + 'settings' => [ + 'onboarded' => true, + 'facility_type' => 'hospital', + '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, + ]); + + $this->doctorA = Practitioner::create([ + 'owner_ref' => $this->owner->public_id, + 'organization_id' => $this->organization->id, + 'branch_id' => $this->branch->id, + 'name' => 'Dr. Mensah', + 'specialty' => 'General', + 'room' => 'Consultation Room 4', + 'user_ref' => 'doc-mensah', + 'is_active' => true, + ]); + + $this->doctorB = Practitioner::create([ + 'owner_ref' => $this->owner->public_id, + 'organization_id' => $this->organization->id, + 'branch_id' => $this->branch->id, + 'name' => 'Dr. Okai', + 'specialty' => 'Pediatrics', + 'room' => 'Consultation Room 5', + 'user_ref' => 'doc-okai', + '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, + 'routing_mode' => 'assigned_only', + 'queue_uuid' => 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa', + 'queue_external_key' => CareQueueContexts::queueExternalKey(CareQueueContexts::CONSULTATION, $this->branch->id), + 'points' => [ + [ + 'kind' => 'practitioner', + 'ref_id' => $this->doctorA->id, + 'name' => 'Dr. Mensah', + 'destination' => 'Consultation Room 4', + 'staff_display_name' => 'Dr. Mensah', + 'external_key' => CareQueueContexts::pointExternalKey( + CareQueueContexts::CONSULTATION, + $this->branch->id, + 'practitioner', + $this->doctorA->id, + ), + 'counter_uuid' => '11111111-1111-1111-1111-111111111111', + 'synced' => true, + ], + [ + 'kind' => 'practitioner', + 'ref_id' => $this->doctorB->id, + 'name' => 'Dr. Okai', + 'destination' => 'Consultation Room 5', + 'staff_display_name' => 'Dr. Okai', + 'external_key' => CareQueueContexts::pointExternalKey( + CareQueueContexts::CONSULTATION, + $this->branch->id, + 'practitioner', + $this->doctorB->id, + ), + 'counter_uuid' => '22222222-2222-2222-2222-222222222222', + 'synced' => true, + ], + ], + ]], + ], + CareQueueContexts::PHARMACY => [ + 'queues' => [[ + 'branch_id' => $this->branch->id, + 'name' => 'Pharmacy', + 'prefix' => 'P', + 'active' => true, + 'synced' => true, + 'routing_mode' => 'assigned_only', + 'queue_uuid' => 'bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb', + 'points' => [[ + 'kind' => 'member', + 'ref_id' => 1, + 'destination' => 'Pharmacy counter 1', + 'counter_uuid' => '33333333-3333-3333-3333-333333333333', + 'synced' => true, + ]], + ]], + ], + ]; + $this->organization->update(['settings' => $settings]); + } + + public function test_fixed_doctor_appointment_issues_to_that_doctors_point(): void + { + Http::fake(function (\Illuminate\Http\Client\Request $request) { + if ($request->method() === 'POST' && str_contains($request->url(), '/tickets')) { + $this->assertSame('11111111-1111-1111-1111-111111111111', $request['assigned_counter_id'] ?? null); + $this->assertSame('aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa', $request['service_queue_id'] ?? null); + + return Http::response([ + 'data' => [ + 'uuid' => (string) Str::uuid(), + 'ticket_number' => 'C001', + 'status' => 'waiting', + 'destination' => 'Consultation Room 4', + 'staff_display_name' => 'Dr. Mensah', + 'assigned_counter' => [ + 'uuid' => '11111111-1111-1111-1111-111111111111', + 'destination' => 'Consultation Room 4', + 'staff_display_name' => 'Dr. Mensah', + ], + ], + ], 201); + } + + return Http::response(['message' => 'unexpected'], 500); + }); + + $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, + 'practitioner_id' => $this->doctorA->id, + 'type' => Appointment::TYPE_SCHEDULED, + 'status' => Appointment::STATUS_SCHEDULED, + 'scheduled_at' => now()->addHour(), + ]); + + $result = app(AppointmentService::class)->checkIn($appointment, $this->owner->public_id); + + $this->assertSame('C001', $result->queue_ticket_number); + $this->assertSame('11111111-1111-1111-1111-111111111111', $result->queue_service_point_uuid); + $this->assertSame('Consultation Room 4', $result->queue_destination); + $this->assertSame('Dr. Mensah', $result->queue_staff_display_name); + $this->assertSame(CareQueueContexts::ROUTING_ROUTED, $result->queue_routing_status); + } + + public function test_walk_in_without_doctor_flags_unresolved_and_does_not_issue(): void + { + 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-2', + 'first_name' => 'Bob', + 'last_name' => 'Builder', + ]); + + $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, + 'practitioner_id' => null, + 'type' => Appointment::TYPE_WALK_IN, + 'status' => Appointment::STATUS_WAITING, + 'waiting_at' => now(), + ]); + + $result = app(CareQueueBridge::class)->issueForAppointment($this->organization, $appointment); + + $this->assertNull($result->queue_ticket_uuid); + $this->assertSame(CareQueueContexts::ROUTING_UNRESOLVED, $result->queue_routing_status); + Http::assertNothingSent(); + } + + public function test_walk_in_with_selected_doctor_routes_only_to_that_point(): void + { + Http::fake(function (\Illuminate\Http\Client\Request $request) { + if ($request->method() === 'POST' && str_contains($request->url(), '/tickets')) { + $this->assertSame('22222222-2222-2222-2222-222222222222', $request['assigned_counter_id'] ?? null); + + return Http::response([ + 'data' => [ + 'uuid' => (string) Str::uuid(), + 'ticket_number' => 'C002', + 'status' => 'waiting', + 'assigned_counter' => ['uuid' => '22222222-2222-2222-2222-222222222222'], + 'destination' => 'Consultation Room 5', + 'staff_display_name' => 'Dr. Okai', + ], + ], 201); + } + + return Http::response(['message' => 'unexpected'], 500); + }); + + $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' => 'Cara', + 'last_name' => 'Clinic', + ]); + + $appointment = app(AppointmentService::class)->walkIn( + $this->organization, + $this->owner->public_id, + [ + 'branch_id' => $this->branch->id, + 'patient_id' => $patient->id, + 'practitioner_id' => $this->doctorB->id, + 'reason' => 'Fever', + ], + $this->owner->public_id, + ); + + $this->assertSame($this->doctorB->id, $appointment->practitioner_id); + $this->assertSame('C002', $appointment->queue_ticket_number); + $this->assertSame('Consultation Room 5', $appointment->queue_destination); + } + + public function test_pharmacist_call_next_uses_pharmacy_point(): void + { + $pharmacist = Member::create([ + 'owner_ref' => $this->owner->public_id, + 'organization_id' => $this->organization->id, + 'user_ref' => 'pharm-1', + 'role' => 'pharmacist', + 'branch_id' => $this->branch->id, + ]); + + $settings = $this->organization->settings; + $settings['department_queue_provisioning'][CareQueueContexts::PHARMACY]['queues'][0]['points'] = [[ + 'kind' => 'member', + 'ref_id' => $pharmacist->id, + 'destination' => 'Pharmacy counter 1', + 'staff_ref' => 'pharm-1', + 'counter_uuid' => '33333333-3333-3333-3333-333333333333', + 'synced' => true, + ]]; + $this->organization->update(['settings' => $settings]); + + Http::fake(function (\Illuminate\Http\Client\Request $request) { + if (str_contains($request->url(), '/call-next')) { + $this->assertStringContainsString('bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb', $request->url()); + $this->assertSame('33333333-3333-3333-3333-333333333333', $request['counter_id'] ?? null); + + return Http::response([ + 'data' => [ + 'uuid' => (string) Str::uuid(), + 'ticket_number' => 'P001', + 'status' => 'called', + ], + ], 200); + } + + return Http::response(['message' => 'unexpected'], 500); + }); + + $result = app(CareQueueBridge::class)->callNext( + $this->organization->fresh(), + CareQueueContexts::PHARMACY, + $this->branch->id, + $pharmacist, + ); + + $this->assertSame('P001', $result['ticket']['ticket_number'] ?? null); + } + + public function test_integration_off_preserves_behavior_without_ticket_issuance(): 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-4', + 'first_name' => 'Dee', + 'last_name' => 'Demo', + ]); + + $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, + 'practitioner_id' => $this->doctorA->id, + 'type' => Appointment::TYPE_SCHEDULED, + 'status' => Appointment::STATUS_SCHEDULED, + 'scheduled_at' => now()->addHour(), + ]); + + $result = app(AppointmentService::class)->checkIn($appointment, $this->owner->public_id); + + $this->assertNull($result->queue_ticket_uuid); + Http::assertNothingSent(); + } + + public function test_provisioner_builds_one_point_per_practitioner_idempotently(): void + { + $calls = ['queues' => 0, 'counters' => 0]; + + Http::fake(function (\Illuminate\Http\Client\Request $request) use (&$calls) { + if (str_contains($request->url(), '/integrations/status')) { + return Http::response(['data' => ['provisioned' => true, 'integrations' => ['care' => true]]], 200); + } + if (str_contains($request->url(), '/branches') && $request->method() === 'POST') { + return Http::response(['data' => ['name' => 'Main']], 201); + } + if (str_contains($request->url(), '/departments') && $request->method() === 'POST') { + return Http::response(['data' => ['id' => 9, 'name' => $request['name'] ?? 'Dept']], 201); + } + if (str_contains($request->url(), '/queues') && $request->method() === 'POST' && ! str_contains($request->url(), 'call-next')) { + $calls['queues']++; + $this->assertSame('assigned_only', $request['routing_mode'] ?? null); + + return Http::response([ + 'data' => [ + 'uuid' => (string) Str::uuid(), + 'name' => $request['name'] ?? 'Q', + 'external_key' => $request['external_key'] ?? null, + ], + ], 201); + } + if (str_contains($request->url(), '/counters') && $request->method() === 'POST') { + $calls['counters']++; + + return Http::response([ + 'data' => [ + 'uuid' => (string) Str::uuid(), + 'name' => $request['name'] ?? 'C', + 'destination' => $request['destination'] ?? null, + ], + ], 201); + } + + return Http::response(['message' => 'unexpected '.$request->url()], 500); + }); + + // Clear prior stub so provisioner builds fresh. + $settings = $this->organization->settings; + unset($settings['department_queue_provisioning']); + $this->organization->update(['settings' => $settings]); + + app(CareQueueProvisioner::class)->provisionOrganization($this->organization->fresh()); + $first = data_get($this->organization->fresh()->settings, 'department_queue_provisioning.consultation.queues.0.points'); + $this->assertIsArray($first); + $this->assertGreaterThanOrEqual(2, count($first)); + + app(CareQueueProvisioner::class)->provisionOrganization($this->organization->fresh()); + $second = data_get($this->organization->fresh()->settings, 'department_queue_provisioning.consultation.queues.0.points'); + $this->assertCount(count($first), $second); + } +}