Route Care tickets to per-staff service points instead of shared branch queues.
Deploy Ladill Care / deploy (push) Successful in 1m40s
Deploy Ladill Care / deploy (push) Successful in 1m40s
Provision one consultation point per doctor (room + identity) and role-based points for pharmacy, lab, billing, and triage. Fixed-doctor appointments and walk-ins issue only to the assigned point; Call next respects that assignment and flags unresolved patients rather than random routing. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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<string, mixed>, entity: ?Model, resources: ?array<string, mixed>}
|
||||
*/
|
||||
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<string, mixed>|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<string, mixed>|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<string, mixed>|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<array<string, mixed>>, 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<string, mixed>|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<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)
|
||||
?? 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<string, mixed> $metadata
|
||||
* @param array<string, mixed>|null $point
|
||||
* @return array<string, mixed>|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<string, mixed> $ticket
|
||||
* @param array<string, mixed>|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++;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user