plans->canUseQueueIntegration($organization) && (bool) data_get($organization->settings, 'queue_integration_enabled') && $this->queue->configured(); } 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; } $appointment->loadMissing('visit'); $context = $this->contextForAppointment($organization, $appointment); if (! $this->workflowGate->canRelease($organization, $appointment->visit, $context)) { return $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, (int) $appointment->branch_id, $appointment->patient, [ '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, $point); 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']); if (! $this->workflowGate->canRelease($organization, $prescription->visit, CareQueueContexts::PHARMACY)) { return $prescription; } $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, ], 'walk_in', 'api', null, ); if (! $ticket) { return $prescription; } $this->applyTicket($prescription, $ticket, null); 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', 'visit', 'investigationType']); $context = $this->contextForInvestigation($request); if (! $this->workflowGate->canRelease($organization, $request->visit, $context)) { return $request; } $ticket = $this->issue( $organization, $context, (int) $request->branch_id, $request->patient, [ 'care_entity' => 'investigation_request', 'care_entity_uuid' => $request->uuid, 'care_entity_id' => $request->id, ], 'walk_in', 'api', null, ); if (! $ticket) { return $request; } $this->applyTicket($request, $ticket, null); return $request->fresh(); } public function contextForInvestigation(InvestigationRequest $request): string { $request->loadMissing('investigationType'); return in_array($request->investigationType?->category, ['xray', 'ultrasound', 'ct', 'mri'], true) ? CareQueueContexts::IMAGING : CareQueueContexts::LABORATORY; } 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', 'visit']); if (! $this->workflowGate->canRelease($organization, $bill->visit, CareQueueContexts::BILLING)) { return $bill; } $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, ], 'walk_in', 'api', null, ); if (! $ticket) { return $bill; } $this->applyTicket($bill, $ticket, null); return $bill->fresh(); } /** * 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 (in_array($fromContext, [CareQueueContexts::LABORATORY, CareQueueContexts::IMAGING], true) && $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 { if (! $this->isEnabled($organization) || $branchId < 1 || $limit < 1) { return 0; } $this->provisioner->ensure($organization, $context, $branchId); return match (true) { $context === CareQueueContexts::PHARMACY => $this->syncMissingPrescriptions($organization, $branchId, $limit), in_array($context, [CareQueueContexts::LABORATORY, CareQueueContexts::IMAGING], true) => $this->syncMissingInvestigations($organization, $context, $branchId, $limit), $context === CareQueueContexts::BILLING => $this->syncMissingBills($organization, $branchId, $limit), default => $this->syncMissingAppointments($organization, $context, $branchId, $limit), }; } /** * @return array{ticket: ?array, entity: ?Model, resources: ?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'])) { 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, $callResources); if (! $ticket) { $this->syncMissingTickets($organization, $context, $branchId, 25); $ticket = $this->attemptCallNext($owner, $callResources); } if (! $ticket) { return ['ticket' => null, 'entity' => null, 'resources' => $resources]; } $entity = $this->findEntityByTicket($organization, $context, (string) ($ticket['uuid'] ?? '')); if ($entity) { $this->applyTicket($entity, $ticket, $point); } return ['ticket' => $ticket, 'entity' => $entity?->fresh(), 'resources' => $resources]; } /** * @param array{queue_uuid?: ?string, counter_uuid?: ?string} $resources * @return array|null */ protected function attemptCallNext(string $owner, array $resources): ?array { try { return $this->queue->callNext( $owner, (string) $resources['queue_uuid'], (string) $resources['counter_uuid'], ); } catch (RequestException $e) { Log::warning('care.queue_call_next_failed', [ 'message' => $e->getMessage(), ]); return null; } } /** * @return array|null */ public function serve(Organization $organization, Model $entity): ?array { return $this->ticketAction($organization, $entity, 'start'); } /** * @return array|null */ public function complete(Organization $organization, Model $entity, ?string $context = null): ?array { $ticket = $this->ticketAction($organization, $entity, 'complete'); if ($ticket && $context) { $this->advanceAfterComplete($organization, $entity, $context); } return $ticket; } /** * @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, points: list>, routing_mode: ?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, 'points' => [], 'routing_mode' => 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, '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 = 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( Organization $organization, string $context, int $branchId, ?Patient $patient, 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'])) { Log::info('care.queue_issue_skipped_no_queue', [ 'context' => $context, 'branch_id' => $branchId, ]); 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, $payload); } catch (\Throwable $e) { Log::warning('care.queue_issue_failed', [ 'context' => $context, 'branch_id' => $branchId, 'message' => $e->getMessage(), ]); return null; } } /** * @param array $ticket * @param array|null $point */ 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(); } /** * @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(), in_array($context, [CareQueueContexts::LABORATORY, CareQueueContexts::IMAGING], true) => 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(), }; } protected function syncMissingAppointments(Organization $organization, string $context, int $branchId, int $limit): int { $owner = (string) $organization->owner_ref; $appointments = Appointment::owned($owner) ->where('organization_id', $organization->id) ->where('branch_id', $branchId) ->whereIn('status', [Appointment::STATUS_WAITING, Appointment::STATUS_CHECKED_IN]) ->where(function ($q) { $q->whereNull('queue_ticket_uuid')->orWhere('queue_ticket_uuid', ''); }) ->with(['patient', 'organization', 'practitioner']) ->orderBy('queue_position') ->orderBy('waiting_at') ->orderBy('checked_in_at') ->orderBy('id') ->limit($limit) ->get(); $issued = 0; foreach ($appointments as $appointment) { if ($this->contextForAppointment($organization, $appointment) !== $context) { continue; } $updated = $this->issueForAppointment($organization, $appointment->fresh(['patient', 'organization', 'practitioner'])); if ($updated && filled($updated->queue_ticket_uuid)) { $issued++; } } return $issued; } protected function syncMissingPrescriptions(Organization $organization, int $branchId, int $limit): int { $owner = (string) $organization->owner_ref; $prescriptions = Prescription::owned($owner) ->where('organization_id', $organization->id) ->where('status', Prescription::STATUS_ACTIVE) ->where(function ($q) { $q->whereNull('queue_ticket_uuid')->orWhere('queue_ticket_uuid', ''); }) ->whereHas('visit', fn ($q) => $q->where('branch_id', $branchId)) ->with(['patient', 'visit']) ->orderBy('id') ->limit($limit) ->get(); $issued = 0; foreach ($prescriptions as $prescription) { $updated = $this->issueForPrescription($organization, $prescription); if ($updated && filled($updated->queue_ticket_uuid)) { $issued++; } } return $issued; } protected function syncMissingInvestigations(Organization $organization, string $context, int $branchId, int $limit): int { $owner = (string) $organization->owner_ref; $requests = InvestigationRequest::owned($owner) ->where('organization_id', $organization->id) ->where('branch_id', $branchId) ->whereIn('status', [ InvestigationRequest::STATUS_PENDING, InvestigationRequest::STATUS_SAMPLE_COLLECTED, InvestigationRequest::STATUS_IN_PROGRESS, ]) ->where(function ($q) { $q->whereNull('queue_ticket_uuid')->orWhere('queue_ticket_uuid', ''); }) ->with(['patient', 'visit', 'investigationType']) ->orderBy('id') ->limit($limit) ->get(); $issued = 0; foreach ($requests as $request) { if ($this->contextForInvestigation($request) !== $context) { continue; } $updated = $this->issueForInvestigation($organization, $request); if ($updated && filled($updated->queue_ticket_uuid)) { $issued++; } } return $issued; } protected function syncMissingBills(Organization $organization, int $branchId, int $limit): int { $owner = (string) $organization->owner_ref; $bills = Bill::owned($owner) ->where('organization_id', $organization->id) ->where('branch_id', $branchId) ->whereIn('status', [Bill::STATUS_OPEN, Bill::STATUS_PARTIAL, Bill::STATUS_DRAFT]) ->where(function ($q) { $q->whereNull('queue_ticket_uuid')->orWhere('queue_ticket_uuid', ''); }) ->with('patient') ->orderBy('id') ->limit($limit) ->get(); $issued = 0; foreach ($bills as $bill) { $updated = $this->issueForBill($organization, $bill); if ($updated && filled($updated->queue_ticket_uuid)) { $issued++; } } return $issued; } }