Files
ladill-care/app/Services/Care/CareQueueBridge.php
T
isaaccladandCursor ac870bcf33
Deploy Ladill Care / deploy (push) Successful in 57s
Implement Care RBAC role→permission→app matrix.
Replace broad doctor/nurse specialty access with granular roles and primary
apps, permission inheritance for lab/BB managers, and cannot-rules for
discharge, lab approve, and cashier vs billing officer.

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

1143 lines
40 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];
$candidates = $this->callableAppointments($organization, $context, $branchId, $practitionerId);
if ($candidates === []) {
$this->syncMissingTickets($organization, $context, $branchId, 25);
$candidates = $this->callableAppointments($organization, $context, $branchId, $practitionerId);
}
foreach ($candidates as $appointment) {
$called = $this->callSpecificAppointment($organization, $context, $branchId, $appointment, $resources, $point);
if ($called['ticket']) {
return $called;
}
}
return $empty;
}
/**
* @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 callSpecificAppointment(
Organization $organization,
string $context,
int $branchId,
Appointment $appointment,
array $resources,
array $point,
): array {
$empty = ['ticket' => null, 'entity' => null, 'resources' => $resources];
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'],
);
// Stale uuid / wrong queue / non-waiting row — clear and re-issue onto this desk.
if (! $ticket) {
$appointment->forceFill([
'queue_ticket_uuid' => null,
'queue_ticket_number' => null,
'queue_ticket_status' => null,
'queue_service_point_uuid' => null,
])->save();
$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) {
return $empty;
}
$this->applyTicket($appointment, $ticket, $point);
$appointment = $appointment->fresh() ?? $appointment;
$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,
];
}
/**
* @return list<Appointment>
*/
protected function callableAppointments(
Organization $organization,
string $context,
int $branchId,
?int $practitionerId,
): array {
$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);
}
$callable = [];
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;
}
$callable[] = $appointment;
}
return $callable;
}
/**
* @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 && app(CarePermissions::class)->usesPractitionerBranchScope($member)) {
$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;
}
}