Files
ladill-care/app/Services/Care/CareQueueBridge.php
T
isaaccladandCursor c123097f3c
Deploy Ladill Care / deploy (push) Successful in 51s
Fix Call next by advancing Care waiters and reclaiming desk tickets.
Call next now picks the next board appointment, issues or reclaims its ticket onto the calling desk, and still fires display/voice announcements — so patients are not left stuck when tickets sit on the wrong point after sync-on-GET was removed.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-18 20:18:52 +00:00

1081 lines
38 KiB
PHP

<?php
namespace App\Services\Care;
use App\Models\Appointment;
use App\Models\Bill;
use App\Models\CareQueueTicket;
use App\Models\InvestigationRequest;
use App\Models\Member;
use App\Models\Organization;
use App\Models\Patient;
use App\Models\Practitioner;
use App\Models\Prescription;
use App\Services\Care\Workflow\WorkflowQueueGate;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Facades\Log;
/**
* Bridges Care workflow entities to Care Queue Engine tickets.
*/
class CareQueueBridge
{
/** @var array<int, array<int, string>> organization_id => [department_id => specialty_key] */
protected array $specialtyDepartmentMaps = [];
public function __construct(
protected CareQueueEngine $engine,
protected CareQueueProvisioner $provisioner,
protected PlanService $plans,
protected SpecialtyModuleService $specialties,
protected WorkflowQueueGate $workflowGate,
) {}
public function isEnabled(Organization $organization): bool
{
return $this->plans->canUseQueueIntegration($organization)
&& (bool) data_get($organization->settings, 'queue_integration_enabled');
}
public function contextForAppointment(Organization $organization, Appointment $appointment): string
{
$departmentId = $appointment->department_id;
if (! $departmentId) {
return CareQueueContexts::CONSULTATION;
}
return $this->contextForDepartment($organization, (int) $departmentId);
}
public function contextForDepartment(Organization $organization, int $departmentId): string
{
if ($departmentId <= 0) {
return CareQueueContexts::CONSULTATION;
}
return $this->specialtyDepartmentMap($organization)[$departmentId]
?? CareQueueContexts::CONSULTATION;
}
/**
* Department id → specialty module key for this org (request-scoped cache).
*
* @return array<int, string>
*/
public function specialtyDepartmentMap(Organization $organization): array
{
$orgId = (int) $organization->id;
if (isset($this->specialtyDepartmentMaps[$orgId])) {
return $this->specialtyDepartmentMaps[$orgId];
}
$map = [];
foreach ($this->specialties->enabledKeys($organization) as $key) {
$deptIds = data_get($organization->settings, "specialty_module_provisioning.{$key}.department_ids", []);
if (! is_array($deptIds)) {
continue;
}
foreach ($deptIds as $deptId) {
$map[(int) $deptId] = $key;
}
}
return $this->specialtyDepartmentMaps[$orgId] = $map;
}
public function contextForPractitioner(Organization $organization, ?Practitioner $practitioner): string
{
if (! $practitioner?->department_id) {
return CareQueueContexts::CONSULTATION;
}
return $this->contextForDepartment($organization, (int) $practitioner->department_id);
}
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->resolveAppointmentPoint($organization, $appointment);
if (CareQueueContexts::usesAssignedRouting($context) && empty($point['counter_uuid'] ?? null)) {
$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,
'context' => $context,
]);
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),
};
}
/**
* Issue Queue tickets for every waiting appointment at a branch (consultation + specialties).
*/
public function syncMissingAppointmentTickets(Organization $organization, int $branchId, int $limit = 100): int
{
if (! $this->isEnabled($organization) || $branchId < 1 || $limit < 1) {
return 0;
}
$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', 'visit'])
->orderBy('queue_position')
->orderBy('waiting_at')
->orderBy('checked_in_at')
->orderBy('id')
->limit($limit)
->get();
$issued = 0;
foreach ($appointments as $appointment) {
$updated = $this->issueForAppointment($organization, $appointment->fresh([
'patient', 'organization', 'practitioner', 'visit',
]));
if ($updated && filled($updated->queue_ticket_uuid)) {
$issued++;
}
}
return $issued;
}
/**
* @return array{ticket: ?array<string, mixed>, entity: ?Model, resources: ?array<string, mixed>}
*/
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];
}
// GP / specialty: advance the next Care waiter (board order), even when their
// ticket sits on another desk or was never issued (sync-on-GET was removed).
if ($this->isAppointmentQueueContext($context)) {
$fromCare = $this->callNextAppointment(
$organization,
$context,
$branchId,
$practitionerId,
$resources,
$point,
);
if ($fromCare['ticket']) {
return $fromCare;
}
}
$callResources = [
'queue_uuid' => $resources['queue_uuid'],
'counter_uuid' => $point['counter_uuid'],
];
$ticket = $this->attemptCallNext($organization, $callResources);
if (! $ticket) {
$this->syncMissingTickets($organization, $context, $branchId, 25);
$ticket = $this->attemptCallNext($organization, $callResources);
}
// Admins / reception without a desk: try every service point on this queue so
// Call next matches the specialty waiting list instead of one empty desk.
if (! $ticket && ! $practitionerId && (
CareQueueContexts::isSpecialty($context)
|| $context === CareQueueContexts::CONSULTATION
)) {
$ticket = $this->attemptCallNextAcrossPoints($organization, $resources, $point);
}
if (! $ticket) {
return ['ticket' => null, 'entity' => null, 'resources' => $resources];
}
$entity = $this->findEntityByTicket($organization, $context, (string) ($ticket['uuid'] ?? ''));
if ($entity) {
$matchedPoint = $point;
$ticketPointUuid = (string) data_get($ticket, 'assigned_counter.uuid', data_get($ticket, 'counter.uuid', ''));
if ($ticketPointUuid !== '') {
$matchedPoint = collect($resources['points'] ?? [])->first(
fn ($p) => ($p['counter_uuid'] ?? null) === $ticketPointUuid
) ?? $point;
}
$this->applyTicket($entity, $ticket, $matchedPoint);
}
return ['ticket' => $ticket, 'entity' => $entity?->fresh(), 'resources' => $resources];
}
protected function isAppointmentQueueContext(string $context): bool
{
return $context === CareQueueContexts::CONSULTATION
|| CareQueueContexts::isSpecialty($context);
}
/**
* Call the next waiting appointment for this context — matching board order.
*
* @param array{queue_uuid?: ?string, counter_uuid?: ?string, points?: list<array<string, mixed>>} $resources
* @param array<string, mixed> $point
* @return array{ticket: ?array<string, mixed>, entity: ?Model, resources: array<string, mixed>}
*/
protected function callNextAppointment(
Organization $organization,
string $context,
int $branchId,
?int $practitionerId,
array $resources,
array $point,
): array {
$empty = ['ticket' => null, 'entity' => null, 'resources' => $resources];
$appointment = $this->nextCallableAppointment($organization, $context, $branchId, $practitionerId);
if (! $appointment) {
$this->syncMissingTickets($organization, $context, $branchId, 25);
$appointment = $this->nextCallableAppointment($organization, $context, $branchId, $practitionerId);
}
if (! $appointment) {
return $empty;
}
if (blank($appointment->queue_ticket_uuid)) {
$appointment = $this->issueForAppointment(
$organization,
$appointment->fresh(['patient', 'organization', 'practitioner', 'visit']) ?? $appointment,
) ?? $appointment;
}
if (blank($appointment->queue_ticket_uuid)) {
// Last resort: issue directly onto the calling desk.
$ticket = $this->issue(
$organization,
$context,
$branchId,
$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) {
$this->applyTicket($appointment, $ticket, $point);
$appointment = $appointment->fresh() ?? $appointment;
}
}
if (blank($appointment->queue_ticket_uuid)) {
return $empty;
}
$ticket = $this->attemptCallWaitingTicket(
$organization,
(string) $appointment->queue_ticket_uuid,
(string) $point['counter_uuid'],
);
if (! $ticket) {
return $empty;
}
$this->applyTicket($appointment, $ticket, $point);
return [
'ticket' => $ticket,
'entity' => $appointment->fresh(),
'resources' => $resources,
];
}
protected function nextCallableAppointment(
Organization $organization,
string $context,
int $branchId,
?int $practitionerId,
): ?Appointment {
$owner = (string) $organization->owner_ref;
$query = 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_status')
->orWhere('queue_ticket_status', '')
->orWhereNotIn('queue_ticket_status', ['called', 'serving']);
})
->with(['patient', 'organization', 'practitioner', 'visit'])
->orderBy('queue_position')
->orderBy('waiting_at')
->orderBy('checked_in_at')
->orderBy('id')
->limit(50);
if ($practitionerId) {
$query->where('practitioner_id', $practitionerId);
}
foreach ($query->get() as $appointment) {
if ($this->contextForAppointment($organization, $appointment) !== $context) {
continue;
}
// Already ticketed waiters stay callable; gate only blocks new issuance.
if (blank($appointment->queue_ticket_uuid)
&& ! $this->workflowGate->canRelease($organization, $appointment->visit, $context)) {
continue;
}
return $appointment;
}
return null;
}
/**
* @return array<string, mixed>|null
*/
protected function attemptCallWaitingTicket(
Organization $organization,
string $ticketUuid,
string $counterUuid,
): ?array {
try {
return $this->engine->callWaitingTicket($organization, $ticketUuid, $counterUuid);
} catch (\Throwable $e) {
Log::warning('care.queue_call_waiting_ticket_failed', [
'ticket_uuid' => $ticketUuid,
'message' => $e->getMessage(),
]);
return null;
}
}
/**
* @param array{queue_uuid?: ?string, counter_uuid?: ?string, points?: list<array<string, mixed>>} $resources
* @param array<string, mixed> $preferredPoint
* @return array<string, mixed>|null
*/
protected function attemptCallNextAcrossPoints(
Organization $organization,
array $resources,
array $preferredPoint,
): ?array {
$queueUuid = (string) ($resources['queue_uuid'] ?? '');
if ($queueUuid === '') {
return null;
}
$preferredUuid = (string) ($preferredPoint['counter_uuid'] ?? '');
$points = collect($resources['points'] ?? [])
->filter(fn ($p) => ! empty($p['counter_uuid']))
->sortBy(fn ($p) => ($p['counter_uuid'] ?? '') === $preferredUuid ? 0 : 1)
->values();
foreach ($points as $candidate) {
$uuid = (string) $candidate['counter_uuid'];
if ($uuid === $preferredUuid) {
continue;
}
$ticket = $this->attemptCallNext($organization, [
'queue_uuid' => $queueUuid,
'counter_uuid' => $uuid,
]);
if ($ticket) {
return $ticket;
}
}
return null;
}
/**
* @param array{queue_uuid?: ?string, counter_uuid?: ?string} $resources
* @return array<string, mixed>|null
*/
protected function attemptCallNext(Organization $organization, array $resources): ?array
{
try {
return $this->engine->callNext(
$organization,
(string) $resources['queue_uuid'],
(string) $resources['counter_uuid'],
);
} catch (\Throwable $e) {
Log::warning('care.queue_call_next_failed', [
'message' => $e->getMessage(),
]);
return null;
}
}
/**
* @return array<string, mixed>|null
*/
public function serve(Organization $organization, Model $entity): ?array
{
return $this->ticketAction($organization, $entity, 'start');
}
/**
* @return array<string, mixed>|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<string, mixed>|null
*/
public function recall(Organization $organization, Model $entity): ?array
{
return $this->ticketAction($organization, $entity, 'recall');
}
/**
* @return array{enabled: bool, context: string, queue_uuid: ?string, counter_uuid: ?string, points: list<array<string, mixed>>, 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, $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<string, mixed>|null
*/
protected function resolveAppointmentPoint(Organization $organization, Appointment $appointment): ?array
{
$branchId = (int) $appointment->branch_id;
$context = $this->contextForAppointment($organization, $appointment);
$this->provisioner->ensure($organization, $context, $branchId);
$organization = $organization->fresh() ?? $organization;
if ($appointment->practitioner_id) {
$point = $this->provisioner->pointForPractitioner(
$organization,
$branchId,
(int) $appointment->practitioner_id,
$context,
);
if ($point && ! empty($point['counter_uuid'])) {
return $point;
}
// Refresh points for this context only, then retry the assigned desk.
$this->provisioner->ensure($organization, $context, $branchId);
$organization = $organization->fresh() ?? $organization;
$point = $this->provisioner->pointForPractitioner(
$organization,
$branchId,
(int) $appointment->practitioner_id,
$context,
);
if ($point && ! empty($point['counter_uuid'])) {
return $point;
}
}
// Prefer a desk ticket over leaving waiters without a Queue number.
return $this->provisioner->defaultPoint($organization, $context, $branchId);
}
/**
* @param array<string, mixed> $resources
* @return array<string, mixed>|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, $context)
?? 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, $context);
}
}
}
if ($member) {
$byMember = $this->provisioner->pointForMember($organization, $context, $branchId, $member);
if ($byMember) {
return $byMember;
}
}
return $this->provisioner->defaultPoint($organization, $context, $branchId);
}
/**
* @param array<string, mixed> $metadata
* @param array<string, mixed>|null $point
* @return array<string, mixed>|null
*/
protected function issue(
Organization $organization,
string $context,
int $branchId,
?Patient $patient,
array $metadata = [],
string $priority = 'walk_in',
string $source = 'api',
?array $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;
}
$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->engine->issueTicket($organization, $payload);
} catch (\Throwable $e) {
Log::warning('care.queue_issue_failed', [
'context' => $context,
'branch_id' => $branchId,
'message' => $e->getMessage(),
]);
return null;
}
}
/**
* @param array<string, mixed> $ticket
* @param array<string, mixed>|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<string, mixed>|null
*/
protected function ticketAction(Organization $organization, Model $entity, string $action): ?array
{
$uuid = (string) $entity->getAttribute('queue_ticket_uuid');
if ($uuid === '' || ! $this->isEnabled($organization)) {
return null;
}
try {
$ticket = $this->engine->ticketAction($organization, $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;
$entity = 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(),
};
if ($entity) {
return $entity;
}
// Recover when the Care row lost its uuid mirror but the ticket still points at it.
$ticket = CareQueueTicket::query()
->where('organization_id', $organization->id)
->where('uuid', $ticketUuid)
->first();
if (! $ticket || ! $ticket->care_entity_id) {
return null;
}
return match ($ticket->care_entity) {
'prescription' => Prescription::owned($owner)
->where('organization_id', $organization->id)
->whereKey($ticket->care_entity_id)
->first(),
'investigation_request' => InvestigationRequest::owned($owner)
->where('organization_id', $organization->id)
->whereKey($ticket->care_entity_id)
->first(),
'bill' => Bill::owned($owner)
->where('organization_id', $organization->id)
->whereKey($ticket->care_entity_id)
->first(),
'appointment' => Appointment::owned($owner)
->where('organization_id', $organization->id)
->whereKey($ticket->care_entity_id)
->first(),
default => null,
};
}
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('created_at')
->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;
}
}