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:
@@ -246,10 +246,12 @@ class AppointmentController extends Controller
|
||||
*/
|
||||
protected function validatedWalkInData(Request $request): array
|
||||
{
|
||||
$queueOn = (bool) data_get($this->organization($request)->settings, 'queue_integration_enabled');
|
||||
|
||||
return $request->validate([
|
||||
'branch_id' => ['required', 'integer', 'exists:care_branches,id'],
|
||||
'patient_id' => ['required', 'integer', 'exists:care_patients,id'],
|
||||
'practitioner_id' => ['nullable', 'integer', 'exists:care_practitioners,id'],
|
||||
'practitioner_id' => [$queueOn ? 'required' : 'nullable', 'integer', 'exists:care_practitioners,id'],
|
||||
'department_id' => ['nullable', 'integer', 'exists:care_departments,id'],
|
||||
'reason' => ['nullable', 'string', 'max:1000'],
|
||||
'notes' => ['nullable', 'string', 'max:5000'],
|
||||
|
||||
@@ -69,10 +69,10 @@ class BillController extends Controller
|
||||
abort_unless($branchId > 0, 422);
|
||||
abort_unless($this->queueBridge->isEnabled($organization), 404);
|
||||
|
||||
$result = $this->queueBridge->callNext($organization, CareQueueContexts::BILLING, $branchId);
|
||||
$result = $this->queueBridge->callNext($organization, CareQueueContexts::BILLING, $branchId, $member);
|
||||
$ticket = $result['ticket'] ?? null;
|
||||
if (! $ticket) {
|
||||
return back()->with('info', 'No bills waiting in the billing queue.');
|
||||
return back()->with('info', 'No bills waiting at your billing service point.');
|
||||
}
|
||||
|
||||
return back()->with('success', 'Called '.$ticket['ticket_number'].'.');
|
||||
|
||||
@@ -93,10 +93,10 @@ class InvestigationController extends Controller
|
||||
abort_unless($branchId > 0, 422);
|
||||
abort_unless($this->queueBridge->isEnabled($organization), 404);
|
||||
|
||||
$result = $this->queueBridge->callNext($organization, CareQueueContexts::LABORATORY, $branchId);
|
||||
$result = $this->queueBridge->callNext($organization, CareQueueContexts::LABORATORY, $branchId, $member);
|
||||
$ticket = $result['ticket'] ?? null;
|
||||
if (! $ticket) {
|
||||
return back()->with('info', 'No lab work waiting in the laboratory queue.');
|
||||
return back()->with('info', 'No lab work waiting at your laboratory service point.');
|
||||
}
|
||||
|
||||
return back()->with('success', 'Called '.$ticket['ticket_number'].'.');
|
||||
|
||||
@@ -95,10 +95,10 @@ class PrescriptionController extends Controller
|
||||
abort_unless($branchId > 0, 422);
|
||||
abort_unless($this->queueBridge->isEnabled($organization), 404);
|
||||
|
||||
$result = $this->queueBridge->callNext($organization, CareQueueContexts::PHARMACY, $branchId);
|
||||
$result = $this->queueBridge->callNext($organization, CareQueueContexts::PHARMACY, $branchId, $member);
|
||||
$ticket = $result['ticket'] ?? null;
|
||||
if (! $ticket) {
|
||||
return back()->with('info', 'No prescriptions waiting in the pharmacy queue.');
|
||||
return back()->with('info', 'No prescriptions waiting at your pharmacy service point.');
|
||||
}
|
||||
|
||||
return back()->with('success', 'Called '.$ticket['ticket_number'].'.');
|
||||
|
||||
@@ -108,10 +108,16 @@ class QueueController extends Controller
|
||||
$branchId = (int) ($request->input('branch_id') ?: $branchScope);
|
||||
abort_unless($branchId > 0, 422);
|
||||
|
||||
$result = $this->queueBridge->callNext($organization, CareQueueContexts::CONSULTATION, $branchId);
|
||||
$result = $this->queueBridge->callNext(
|
||||
$organization,
|
||||
CareQueueContexts::CONSULTATION,
|
||||
$branchId,
|
||||
$member,
|
||||
$request->input('practitioner_id') ? (int) $request->input('practitioner_id') : null,
|
||||
);
|
||||
$ticket = $result['ticket'] ?? null;
|
||||
if (! $ticket) {
|
||||
return back()->with('info', 'No patients waiting in the consultation queue.');
|
||||
return back()->with('info', 'No patients waiting at your consultation service point.');
|
||||
}
|
||||
|
||||
return back()->with('success', 'Called '.$ticket['ticket_number'].' — '.($ticket['customer_name'] ?? 'patient').'.');
|
||||
|
||||
@@ -35,6 +35,7 @@ class Appointment extends Model
|
||||
|
||||
protected $fillable = [
|
||||
'uuid', 'queue_ticket_uuid', 'queue_ticket_number', 'queue_ticket_status',
|
||||
'queue_service_point_uuid', 'queue_destination', 'queue_staff_display_name', 'queue_routing_status',
|
||||
'owner_ref', 'organization_id', 'branch_id', 'patient_id',
|
||||
'practitioner_id', 'department_id', 'visit_id', 'type', 'status',
|
||||
'scheduled_at', 'checked_in_at', 'waiting_at', 'started_at',
|
||||
|
||||
@@ -27,6 +27,7 @@ class Bill extends Model
|
||||
|
||||
protected $fillable = [
|
||||
'uuid', 'queue_ticket_uuid', 'queue_ticket_number', 'queue_ticket_status',
|
||||
'queue_service_point_uuid', 'queue_destination', 'queue_staff_display_name', 'queue_routing_status',
|
||||
'owner_ref', 'organization_id', 'branch_id', 'visit_id', 'patient_id',
|
||||
'invoice_number', 'status', 'subtotal_minor', 'discount_minor', 'tax_minor',
|
||||
'total_minor', 'amount_paid_minor', 'balance_minor', 'notes', 'created_by', 'finalized_at',
|
||||
|
||||
@@ -31,6 +31,7 @@ class InvestigationRequest extends Model
|
||||
|
||||
protected $fillable = [
|
||||
'uuid', 'queue_ticket_uuid', 'queue_ticket_number', 'queue_ticket_status',
|
||||
'queue_service_point_uuid', 'queue_destination', 'queue_staff_display_name', 'queue_routing_status',
|
||||
'owner_ref', 'organization_id', 'branch_id', 'visit_id', 'consultation_id',
|
||||
'patient_id', 'investigation_type_id', 'practitioner_id', 'status', 'priority',
|
||||
'clinical_notes', 'requested_by', 'assigned_member_id', 'sample_barcode',
|
||||
|
||||
@@ -16,7 +16,7 @@ class Practitioner extends Model
|
||||
|
||||
protected $fillable = [
|
||||
'owner_ref', 'organization_id', 'branch_id', 'department_id', 'member_id',
|
||||
'user_ref', 'name', 'specialty', 'is_active',
|
||||
'user_ref', 'name', 'specialty', 'room', 'is_active',
|
||||
];
|
||||
|
||||
protected function casts(): array
|
||||
|
||||
@@ -25,6 +25,7 @@ class Prescription extends Model
|
||||
|
||||
protected $fillable = [
|
||||
'uuid', 'queue_ticket_uuid', 'queue_ticket_number', 'queue_ticket_status',
|
||||
'queue_service_point_uuid', 'queue_destination', 'queue_staff_display_name', 'queue_routing_status',
|
||||
'owner_ref', 'organization_id', 'visit_id', 'consultation_id',
|
||||
'patient_id', 'practitioner_id', 'status', 'notes',
|
||||
'prescribed_by', 'dispensed_by', 'dispensed_at',
|
||||
|
||||
@@ -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++;
|
||||
}
|
||||
|
||||
@@ -4,11 +4,18 @@ namespace App\Services\Care;
|
||||
|
||||
/**
|
||||
* Canonical Care → Ladill Queue department / specialty contexts.
|
||||
*
|
||||
* Mapping:
|
||||
* - Department/service → Queue ServiceQueue (+ Queue Department)
|
||||
* - Service point (room/desk/counter) → Queue Counter
|
||||
* - Staff assignment → Counter staff_ref / staff_display_name
|
||||
*/
|
||||
class CareQueueContexts
|
||||
{
|
||||
public const RECEPTION = 'reception';
|
||||
|
||||
public const TRIAGE = 'triage';
|
||||
|
||||
public const CONSULTATION = 'consultation';
|
||||
|
||||
public const PHARMACY = 'pharmacy';
|
||||
@@ -17,19 +24,63 @@ class CareQueueContexts
|
||||
|
||||
public const BILLING = 'billing';
|
||||
|
||||
public const IMAGING = 'imaging';
|
||||
|
||||
public const ROUTING_ROUTED = 'routed';
|
||||
|
||||
public const ROUTING_UNRESOLVED = 'unresolved';
|
||||
|
||||
/**
|
||||
* Core department queues provisioned for every branch when Queue integration is on.
|
||||
* Consultation/pharmacy/lab/billing use assigned_only routing (per service point).
|
||||
*
|
||||
* @return array<string, array{name: string, prefix: string}>
|
||||
* @return array<string, array{name: string, prefix: string, routing_mode: string, type: string}>
|
||||
*/
|
||||
public static function departments(): array
|
||||
{
|
||||
return [
|
||||
self::RECEPTION => ['name' => 'Reception', 'prefix' => 'R'],
|
||||
self::CONSULTATION => ['name' => 'Consultation', 'prefix' => 'C'],
|
||||
self::PHARMACY => ['name' => 'Pharmacy', 'prefix' => 'P'],
|
||||
self::LABORATORY => ['name' => 'Laboratory', 'prefix' => 'L'],
|
||||
self::BILLING => ['name' => 'Billing', 'prefix' => 'B'],
|
||||
self::RECEPTION => [
|
||||
'name' => 'Reception',
|
||||
'prefix' => 'R',
|
||||
'routing_mode' => 'shared_pool',
|
||||
'type' => 'reception',
|
||||
],
|
||||
self::TRIAGE => [
|
||||
'name' => 'Triage',
|
||||
'prefix' => 'T',
|
||||
'routing_mode' => 'assigned_only',
|
||||
'type' => 'consultation',
|
||||
],
|
||||
self::CONSULTATION => [
|
||||
'name' => 'Consultation',
|
||||
'prefix' => 'C',
|
||||
'routing_mode' => 'assigned_only',
|
||||
'type' => 'consultation',
|
||||
],
|
||||
self::PHARMACY => [
|
||||
'name' => 'Pharmacy',
|
||||
'prefix' => 'P',
|
||||
'routing_mode' => 'assigned_only',
|
||||
'type' => 'pharmacy',
|
||||
],
|
||||
self::LABORATORY => [
|
||||
'name' => 'Laboratory',
|
||||
'prefix' => 'L',
|
||||
'routing_mode' => 'assigned_only',
|
||||
'type' => 'laboratory',
|
||||
],
|
||||
self::BILLING => [
|
||||
'name' => 'Billing',
|
||||
'prefix' => 'B',
|
||||
'routing_mode' => 'assigned_only',
|
||||
'type' => 'cashier',
|
||||
],
|
||||
self::IMAGING => [
|
||||
'name' => 'Imaging',
|
||||
'prefix' => 'I',
|
||||
'routing_mode' => 'assigned_only',
|
||||
'type' => 'laboratory',
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
@@ -43,6 +94,15 @@ class CareQueueContexts
|
||||
return array_key_exists($context, config('care.specialty_modules', []));
|
||||
}
|
||||
|
||||
public static function usesAssignedRouting(string $context): bool
|
||||
{
|
||||
if (self::isSpecialty($context)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return (self::departments()[$context]['routing_mode'] ?? 'shared_pool') === 'assigned_only';
|
||||
}
|
||||
|
||||
public static function queueExternalKey(string $context, int|string $careBranchId): string
|
||||
{
|
||||
if (self::isSpecialty($context)) {
|
||||
@@ -52,6 +112,12 @@ class CareQueueContexts
|
||||
return "care:dept:{$context}:queue:{$careBranchId}";
|
||||
}
|
||||
|
||||
public static function departmentExternalKey(string $context, int|string $careBranchId): string
|
||||
{
|
||||
return "care:dept:{$context}:branch:{$careBranchId}";
|
||||
}
|
||||
|
||||
/** @deprecated Prefer pointExternalKey — kept for legacy single-counter stubs. */
|
||||
public static function counterExternalKey(string $context, int|string $careBranchId): string
|
||||
{
|
||||
if (self::isSpecialty($context)) {
|
||||
@@ -60,4 +126,13 @@ class CareQueueContexts
|
||||
|
||||
return "care:dept:{$context}:counter:{$careBranchId}";
|
||||
}
|
||||
|
||||
public static function pointExternalKey(
|
||||
string $context,
|
||||
int|string $careBranchId,
|
||||
string $pointKind,
|
||||
int|string $pointId,
|
||||
): string {
|
||||
return "care:point:{$context}:{$pointKind}:{$careBranchId}:{$pointId}";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,12 +3,15 @@
|
||||
namespace App\Services\Care;
|
||||
|
||||
use App\Models\Branch;
|
||||
use App\Models\Member;
|
||||
use App\Models\Organization;
|
||||
use App\Models\Practitioner;
|
||||
use App\Services\Queue\QueueClient;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
/**
|
||||
* Idempotently provisions Ladill Queue queues/counters for Care departments per branch.
|
||||
* Idempotently provisions Ladill Queue departments, queues, and service points
|
||||
* (counters) from Care staff / branch configuration.
|
||||
*/
|
||||
class CareQueueProvisioner
|
||||
{
|
||||
@@ -25,8 +28,6 @@ class CareQueueProvisioner
|
||||
}
|
||||
|
||||
/**
|
||||
* Provision core department queues for all org branches. Returns updated settings fragment.
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function provisionOrganization(Organization $organization): array
|
||||
@@ -52,31 +53,14 @@ class CareQueueProvisioner
|
||||
|
||||
foreach ($branches as $branch) {
|
||||
$prior = $byBranch->get((string) $branch->id, []);
|
||||
$stub = [
|
||||
'name' => $meta['name'],
|
||||
'prefix' => $meta['prefix'],
|
||||
'module' => $context,
|
||||
'branch_id' => $branch->id,
|
||||
'branch_name' => $branch->name,
|
||||
'active' => true,
|
||||
'queue_external_key' => CareQueueContexts::queueExternalKey($context, $branch->id),
|
||||
'counter_external_key' => CareQueueContexts::counterExternalKey($context, $branch->id),
|
||||
'queue_uuid' => $prior['queue_uuid'] ?? null,
|
||||
'counter_uuid' => $prior['counter_uuid'] ?? null,
|
||||
'synced' => false,
|
||||
];
|
||||
|
||||
try {
|
||||
$synced = $this->queue->syncSpecialtyQueueStubs($owner, [$stub]);
|
||||
$stub = $synced[0] ?? $stub;
|
||||
} catch (\Throwable $e) {
|
||||
Log::warning('care.queue_department_provision_failed', [
|
||||
'context' => $context,
|
||||
'branch_id' => $branch->id,
|
||||
'message' => $e->getMessage(),
|
||||
]);
|
||||
}
|
||||
|
||||
$stub = $this->provisionBranchDepartment(
|
||||
$organization,
|
||||
$owner,
|
||||
$context,
|
||||
$meta,
|
||||
$branch,
|
||||
is_array($prior) ? $prior : [],
|
||||
);
|
||||
$byBranch->put((string) $branch->id, $stub);
|
||||
}
|
||||
|
||||
@@ -93,7 +77,291 @@ class CareQueueProvisioner
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{queue_uuid: ?string, counter_uuid: ?string, queue_external_key: string, counter_external_key: string, synced: bool}|null
|
||||
* @param array<string, mixed> $meta
|
||||
* @param array<string, mixed> $prior
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
protected function provisionBranchDepartment(
|
||||
Organization $organization,
|
||||
string $owner,
|
||||
string $context,
|
||||
array $meta,
|
||||
Branch $branch,
|
||||
array $prior,
|
||||
): array {
|
||||
$stub = [
|
||||
'name' => $meta['name'],
|
||||
'prefix' => $meta['prefix'],
|
||||
'module' => $context,
|
||||
'branch_id' => $branch->id,
|
||||
'branch_name' => $branch->name,
|
||||
'active' => true,
|
||||
'routing_mode' => $meta['routing_mode'],
|
||||
'queue_external_key' => CareQueueContexts::queueExternalKey($context, $branch->id),
|
||||
'department_external_key' => CareQueueContexts::departmentExternalKey($context, $branch->id),
|
||||
'queue_uuid' => $prior['queue_uuid'] ?? null,
|
||||
'department_id' => $prior['department_id'] ?? null,
|
||||
'counter_uuid' => $prior['counter_uuid'] ?? null,
|
||||
'counter_external_key' => $prior['counter_external_key']
|
||||
?? CareQueueContexts::counterExternalKey($context, $branch->id),
|
||||
'points' => is_array($prior['points'] ?? null) ? $prior['points'] : [],
|
||||
'synced' => false,
|
||||
];
|
||||
|
||||
try {
|
||||
$this->ensureBranchName($owner, $branch->name);
|
||||
|
||||
$department = $this->queue->ensureDepartment($owner, [
|
||||
'name' => $meta['name'],
|
||||
'type' => $meta['type'] ?? 'general',
|
||||
'branch_name' => $branch->name,
|
||||
'external_key' => $stub['department_external_key'],
|
||||
'is_active' => true,
|
||||
]);
|
||||
$stub['department_id'] = $department['id'] ?? $stub['department_id'];
|
||||
|
||||
$queue = $this->queue->ensureQueue($owner, [
|
||||
'name' => $meta['name'],
|
||||
'prefix' => $meta['prefix'],
|
||||
'branch_name' => $branch->name,
|
||||
'strategy' => 'fifo',
|
||||
'external_key' => $stub['queue_external_key'],
|
||||
'routing_mode' => $meta['routing_mode'],
|
||||
'department_id' => $stub['department_id'],
|
||||
'is_active' => true,
|
||||
]);
|
||||
$stub['queue_uuid'] = $queue['uuid'] ?? $stub['queue_uuid'];
|
||||
|
||||
$points = $this->buildPointsForContext($organization, $context, $branch, $stub);
|
||||
$syncedPoints = [];
|
||||
foreach ($points as $point) {
|
||||
try {
|
||||
$counter = $this->queue->ensureCounter($owner, [
|
||||
'name' => $point['name'],
|
||||
'destination' => $point['destination'],
|
||||
'staff_ref' => $point['staff_ref'],
|
||||
'staff_display_name' => $point['staff_display_name'],
|
||||
'branch_name' => $branch->name,
|
||||
'department_id' => $stub['department_id'],
|
||||
'queue_uuids' => array_values(array_filter([(string) $stub['queue_uuid']])),
|
||||
'external_key' => $point['external_key'],
|
||||
'is_active' => true,
|
||||
]);
|
||||
$point['counter_uuid'] = $counter['uuid'] ?? null;
|
||||
$point['synced'] = ! empty($point['counter_uuid']);
|
||||
} catch (\Throwable $e) {
|
||||
Log::warning('care.queue_point_provision_failed', [
|
||||
'context' => $context,
|
||||
'branch_id' => $branch->id,
|
||||
'point' => $point['external_key'] ?? null,
|
||||
'message' => $e->getMessage(),
|
||||
]);
|
||||
$point['synced'] = false;
|
||||
}
|
||||
$syncedPoints[] = $point;
|
||||
}
|
||||
|
||||
$stub['points'] = $syncedPoints;
|
||||
// Legacy single counter: first point (or dedicated default) for older callers.
|
||||
$first = collect($syncedPoints)->first(fn ($p) => ! empty($p['counter_uuid']));
|
||||
if ($first) {
|
||||
$stub['counter_uuid'] = $first['counter_uuid'];
|
||||
$stub['counter_external_key'] = $first['external_key'];
|
||||
}
|
||||
$stub['synced'] = ! empty($stub['queue_uuid']) && collect($syncedPoints)->contains(fn ($p) => ! empty($p['synced']));
|
||||
} catch (\Throwable $e) {
|
||||
Log::warning('care.queue_department_provision_failed', [
|
||||
'context' => $context,
|
||||
'branch_id' => $branch->id,
|
||||
'message' => $e->getMessage(),
|
||||
]);
|
||||
}
|
||||
|
||||
return $stub;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $stub
|
||||
* @return list<array<string, mixed>>
|
||||
*/
|
||||
protected function buildPointsForContext(
|
||||
Organization $organization,
|
||||
string $context,
|
||||
Branch $branch,
|
||||
array $stub,
|
||||
): array {
|
||||
$owner = (string) $organization->owner_ref;
|
||||
$priorByKey = collect($stub['points'] ?? [])->keyBy(fn ($p) => (string) ($p['external_key'] ?? ''));
|
||||
|
||||
$points = match ($context) {
|
||||
CareQueueContexts::CONSULTATION => $this->practitionerPoints($organization, $branch, $priorByKey),
|
||||
CareQueueContexts::TRIAGE => $this->memberRolePoints(
|
||||
$organization,
|
||||
$branch,
|
||||
['nurse'],
|
||||
$context,
|
||||
'Nurse station',
|
||||
$priorByKey,
|
||||
),
|
||||
CareQueueContexts::PHARMACY => $this->memberRolePoints(
|
||||
$organization,
|
||||
$branch,
|
||||
['pharmacist'],
|
||||
$context,
|
||||
'Pharmacy counter',
|
||||
$priorByKey,
|
||||
),
|
||||
CareQueueContexts::LABORATORY, CareQueueContexts::IMAGING => $this->memberRolePoints(
|
||||
$organization,
|
||||
$branch,
|
||||
['lab_technician'],
|
||||
$context,
|
||||
$context === CareQueueContexts::IMAGING ? 'Imaging room' : 'Lab desk',
|
||||
$priorByKey,
|
||||
),
|
||||
CareQueueContexts::BILLING => $this->memberRolePoints(
|
||||
$organization,
|
||||
$branch,
|
||||
['cashier'],
|
||||
$context,
|
||||
'Cashier desk',
|
||||
$priorByKey,
|
||||
),
|
||||
CareQueueContexts::RECEPTION => $this->memberRolePoints(
|
||||
$organization,
|
||||
$branch,
|
||||
['receptionist'],
|
||||
$context,
|
||||
'Reception',
|
||||
$priorByKey,
|
||||
),
|
||||
default => [],
|
||||
};
|
||||
|
||||
// Always keep at least one default point so call-next / least-load have a target.
|
||||
if ($points === []) {
|
||||
$key = CareQueueContexts::pointExternalKey($context, $branch->id, 'default', 0);
|
||||
$prior = $priorByKey->get($key, []);
|
||||
$points[] = [
|
||||
'kind' => 'default',
|
||||
'ref_id' => 0,
|
||||
'name' => ($stub['name'] ?? ucfirst($context)).' desk',
|
||||
'destination' => ($stub['name'] ?? ucfirst($context)).' desk',
|
||||
'staff_ref' => null,
|
||||
'staff_display_name' => null,
|
||||
'external_key' => $key,
|
||||
'counter_uuid' => $prior['counter_uuid'] ?? null,
|
||||
'synced' => false,
|
||||
];
|
||||
}
|
||||
|
||||
return $points;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param \Illuminate\Support\Collection<string, array<string, mixed>> $priorByKey
|
||||
* @return list<array<string, mixed>>
|
||||
*/
|
||||
protected function practitionerPoints(Organization $organization, Branch $branch, $priorByKey): array
|
||||
{
|
||||
$practitioners = Practitioner::owned($organization->owner_ref)
|
||||
->where('organization_id', $organization->id)
|
||||
->where('branch_id', $branch->id)
|
||||
->where('is_active', true)
|
||||
->orderBy('name')
|
||||
->get();
|
||||
|
||||
$points = [];
|
||||
$roomIndex = 1;
|
||||
foreach ($practitioners as $practitioner) {
|
||||
$key = CareQueueContexts::pointExternalKey(
|
||||
CareQueueContexts::CONSULTATION,
|
||||
$branch->id,
|
||||
'practitioner',
|
||||
$practitioner->id,
|
||||
);
|
||||
$prior = $priorByKey->get($key, []);
|
||||
$room = trim((string) ($practitioner->room ?? ''));
|
||||
if ($room === '') {
|
||||
$room = 'Consultation Room '.$roomIndex;
|
||||
}
|
||||
$roomIndex++;
|
||||
|
||||
$points[] = [
|
||||
'kind' => 'practitioner',
|
||||
'ref_id' => $practitioner->id,
|
||||
'name' => $practitioner->name,
|
||||
'destination' => $room,
|
||||
'staff_ref' => $practitioner->user_ref ?: ('practitioner:'.$practitioner->id),
|
||||
'staff_display_name' => $practitioner->name,
|
||||
'specialty' => $practitioner->specialty,
|
||||
'external_key' => $key,
|
||||
'counter_uuid' => $prior['counter_uuid'] ?? null,
|
||||
'synced' => false,
|
||||
];
|
||||
}
|
||||
|
||||
return $points;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<string> $roles
|
||||
* @param \Illuminate\Support\Collection<string, array<string, mixed>> $priorByKey
|
||||
* @return list<array<string, mixed>>
|
||||
*/
|
||||
protected function memberRolePoints(
|
||||
Organization $organization,
|
||||
Branch $branch,
|
||||
array $roles,
|
||||
string $context,
|
||||
string $destinationPrefix,
|
||||
$priorByKey,
|
||||
): array {
|
||||
$members = Member::owned($organization->owner_ref)
|
||||
->where('organization_id', $organization->id)
|
||||
->whereIn('role', $roles)
|
||||
->where(function ($q) use ($branch) {
|
||||
$q->whereNull('branch_id')->orWhere('branch_id', $branch->id);
|
||||
})
|
||||
->orderBy('id')
|
||||
->get();
|
||||
|
||||
$points = [];
|
||||
$index = 1;
|
||||
foreach ($members as $member) {
|
||||
$key = CareQueueContexts::pointExternalKey($context, $branch->id, 'member', $member->id);
|
||||
$prior = $priorByKey->get($key, []);
|
||||
$label = $destinationPrefix.' '.$index;
|
||||
$points[] = [
|
||||
'kind' => 'member',
|
||||
'ref_id' => $member->id,
|
||||
'name' => $label,
|
||||
'destination' => $label,
|
||||
'staff_ref' => $member->user_ref,
|
||||
'staff_display_name' => null,
|
||||
'external_key' => $key,
|
||||
'counter_uuid' => $prior['counter_uuid'] ?? null,
|
||||
'synced' => false,
|
||||
];
|
||||
$index++;
|
||||
}
|
||||
|
||||
return $points;
|
||||
}
|
||||
|
||||
protected function ensureBranchName(string $owner, string $branchName): void
|
||||
{
|
||||
try {
|
||||
$this->queue->ensureBranch($owner, ['name' => $branchName]);
|
||||
} catch (\Illuminate\Http\Client\RequestException $e) {
|
||||
if ($e->response?->status() !== 422) {
|
||||
throw $e;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{queue_uuid: ?string, counter_uuid: ?string, queue_external_key: string, counter_external_key: string, synced: bool, points: list<array<string, mixed>>, routing_mode: string}|null
|
||||
*/
|
||||
public function resourcesFor(
|
||||
Organization $organization,
|
||||
@@ -118,6 +386,8 @@ class CareQueueProvisioner
|
||||
return null;
|
||||
}
|
||||
|
||||
$meta = CareQueueContexts::departments()[$context] ?? null;
|
||||
|
||||
return [
|
||||
'queue_uuid' => $match['queue_uuid'] ?? null,
|
||||
'counter_uuid' => $match['counter_uuid'] ?? null,
|
||||
@@ -126,13 +396,82 @@ class CareQueueProvisioner
|
||||
'counter_external_key' => (string) ($match['counter_external_key']
|
||||
?? CareQueueContexts::counterExternalKey($context, $branchId)),
|
||||
'synced' => (bool) ($match['synced'] ?? false),
|
||||
'points' => is_array($match['points'] ?? null) ? $match['points'] : [],
|
||||
'routing_mode' => (string) ($match['routing_mode'] ?? $meta['routing_mode'] ?? 'shared_pool'),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure resources exist for one context+branch (lazy provision).
|
||||
* Resolve a service point for a practitioner (consultation) or member staff_ref.
|
||||
*
|
||||
* @return array{queue_uuid: ?string, counter_uuid: ?string, queue_external_key: string, counter_external_key: string, synced: bool}|null
|
||||
* @return array<string, mixed>|null
|
||||
*/
|
||||
public function pointForPractitioner(Organization $organization, int $branchId, int $practitionerId): ?array
|
||||
{
|
||||
$resources = $this->resourcesFor($organization, CareQueueContexts::CONSULTATION, $branchId);
|
||||
if (! $resources) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return collect($resources['points'])->first(
|
||||
fn ($p) => ($p['kind'] ?? '') === 'practitioner' && (int) ($p['ref_id'] ?? 0) === $practitionerId
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>|null
|
||||
*/
|
||||
public function pointForMember(Organization $organization, string $context, int $branchId, Member $member): ?array
|
||||
{
|
||||
$resources = $this->resourcesFor($organization, $context, $branchId);
|
||||
if (! $resources) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$byMember = collect($resources['points'])->first(
|
||||
fn ($p) => ($p['kind'] ?? '') === 'member' && (int) ($p['ref_id'] ?? 0) === $member->id
|
||||
);
|
||||
if ($byMember) {
|
||||
return $byMember;
|
||||
}
|
||||
|
||||
return collect($resources['points'])->first(
|
||||
fn ($p) => ($p['staff_ref'] ?? null) === $member->user_ref
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Deterministic least-load / first active point when Care has no explicit assignment.
|
||||
*
|
||||
* @return array<string, mixed>|null
|
||||
*/
|
||||
public function defaultPoint(Organization $organization, string $context, int $branchId): ?array
|
||||
{
|
||||
$resources = $this->resourcesFor($organization, $context, $branchId);
|
||||
if (! $resources) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$points = collect($resources['points'])->filter(fn ($p) => ! empty($p['counter_uuid']) && ($p['synced'] ?? true));
|
||||
if ($points->isEmpty()) {
|
||||
if (! empty($resources['counter_uuid'])) {
|
||||
return [
|
||||
'kind' => 'legacy',
|
||||
'counter_uuid' => $resources['counter_uuid'],
|
||||
'external_key' => $resources['counter_external_key'],
|
||||
'destination' => null,
|
||||
'staff_display_name' => null,
|
||||
];
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
return $points->sortBy('ref_id')->first();
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{queue_uuid: ?string, counter_uuid: ?string, queue_external_key: string, counter_external_key: string, synced: bool, points: list<array<string, mixed>>, routing_mode: string}|null
|
||||
*/
|
||||
public function ensure(
|
||||
Organization $organization,
|
||||
@@ -140,7 +479,9 @@ class CareQueueProvisioner
|
||||
int $branchId,
|
||||
): ?array {
|
||||
$existing = $this->resourcesFor($organization, $context, $branchId);
|
||||
if ($existing && ! empty($existing['queue_uuid']) && ! empty($existing['counter_uuid'])) {
|
||||
if ($existing && ! empty($existing['queue_uuid']) && (
|
||||
! empty($existing['points']) || ! empty($existing['counter_uuid'])
|
||||
)) {
|
||||
return $existing;
|
||||
}
|
||||
|
||||
|
||||
@@ -536,6 +536,7 @@ class DemoTenantSeeder
|
||||
'member_id' => null,
|
||||
'name' => 'Dr. '.self::FIRST_NAMES[$i % count(self::FIRST_NAMES)].' '.self::LAST_NAMES[$i % count(self::LAST_NAMES)],
|
||||
'specialty' => self::SPECIALTIES[$i % count(self::SPECIALTIES)],
|
||||
'room' => 'Consultation Room '.($i + 1),
|
||||
'is_active' => true,
|
||||
'deleted_at' => null,
|
||||
],
|
||||
|
||||
@@ -112,6 +112,18 @@ class QueueClient
|
||||
return (array) ($response->json('data') ?? []);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $payload
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function ensureDepartment(string $owner, array $payload): array
|
||||
{
|
||||
$response = $this->http($owner)->post($this->base().'/departments', $payload);
|
||||
$response->throw();
|
||||
|
||||
return (array) ($response->json('data') ?? []);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $payload
|
||||
* @return array<string, mixed>
|
||||
@@ -361,11 +373,13 @@ class QueueClient
|
||||
'branch_name' => $branchName,
|
||||
'strategy' => 'fifo',
|
||||
'external_key' => $queueKey,
|
||||
'routing_mode' => (string) ($stub['routing_mode'] ?? 'shared_pool'),
|
||||
'is_active' => true,
|
||||
]);
|
||||
|
||||
$counter = $this->ensureCounter($owner, [
|
||||
'name' => $name.' counter',
|
||||
'destination' => $name,
|
||||
'branch_name' => $branchName,
|
||||
'queue_uuids' => array_values(array_filter([(string) ($queue['uuid'] ?? '')])),
|
||||
'external_key' => $counterKey,
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('care_practitioners', function (Blueprint $table) {
|
||||
$table->string('room')->nullable()->after('specialty');
|
||||
});
|
||||
|
||||
foreach (['care_appointments', 'care_prescriptions', 'care_investigation_requests', 'care_bills'] as $tableName) {
|
||||
Schema::table($tableName, function (Blueprint $blueprint) {
|
||||
$blueprint->string('queue_service_point_uuid', 36)->nullable()->after('queue_ticket_status');
|
||||
$blueprint->string('queue_destination', 255)->nullable()->after('queue_service_point_uuid');
|
||||
$blueprint->string('queue_staff_display_name', 255)->nullable()->after('queue_destination');
|
||||
$blueprint->string('queue_routing_status', 32)->nullable()->after('queue_staff_display_name');
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
foreach (['care_appointments', 'care_prescriptions', 'care_investigation_requests', 'care_bills'] as $tableName) {
|
||||
Schema::table($tableName, function (Blueprint $blueprint) {
|
||||
$blueprint->dropColumn([
|
||||
'queue_service_point_uuid',
|
||||
'queue_destination',
|
||||
'queue_staff_display_name',
|
||||
'queue_routing_status',
|
||||
]);
|
||||
});
|
||||
}
|
||||
|
||||
Schema::table('care_practitioners', function (Blueprint $table) {
|
||||
$table->dropColumn('room');
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -8,7 +8,7 @@
|
||||
<div class="flex flex-wrap items-center justify-between gap-3 rounded-xl border border-sky-100 bg-sky-50/70 px-4 py-3">
|
||||
<div>
|
||||
<p class="text-xs font-semibold uppercase tracking-wide text-sky-700">Queue</p>
|
||||
<p class="text-sm text-slate-600">Call next, serve, and complete on this list — tickets are for this department only.</p>
|
||||
<p class="text-sm text-slate-600">Call next operates on your assigned service point only — tickets routed to other rooms or desks stay there.</p>
|
||||
</div>
|
||||
<form method="POST" action="{{ route($callNextRoute, $callNextParams) }}">
|
||||
@csrf
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
@php
|
||||
$status = $ticketStatus ?? null;
|
||||
$number = $ticketNumber ?? null;
|
||||
$destination = $destination ?? null;
|
||||
$staffDisplayName = $staffDisplayName ?? null;
|
||||
@endphp
|
||||
@if ($number)
|
||||
<span class="mr-2 inline-flex items-center gap-1.5">
|
||||
<span class="mr-2 inline-flex flex-wrap items-center gap-1.5">
|
||||
<span class="inline-flex h-7 min-w-[2.5rem] items-center justify-center rounded-md bg-sky-100 px-1.5 font-mono text-xs font-bold text-sky-800">{{ $number }}</span>
|
||||
@if ($status)
|
||||
<span @class([
|
||||
@@ -14,5 +16,12 @@
|
||||
'bg-slate-100 text-slate-600' => ! in_array($status, ['waiting', 'called', 'serving'], true),
|
||||
])>{{ $status }}</span>
|
||||
@endif
|
||||
@if ($destination || $staffDisplayName)
|
||||
<span class="text-[11px] font-medium text-slate-500">
|
||||
@if ($destination){{ $destination }}@endif
|
||||
@if ($destination && $staffDisplayName) · @endif
|
||||
@if ($staffDisplayName){{ $staffDisplayName }}@endif
|
||||
</span>
|
||||
@endif
|
||||
</span>
|
||||
@endif
|
||||
|
||||
@@ -34,7 +34,7 @@
|
||||
@include('care.partials.queue-ops', [
|
||||
'queueIntegration' => $queueIntegration ?? null,
|
||||
'queueCallNextRoute' => 'care.queue.call-next',
|
||||
'queueCallNextParams' => [],
|
||||
'queueCallNextParams' => array_filter(['practitioner_id' => $practitionerId]),
|
||||
'branchId' => $branchId,
|
||||
])
|
||||
|
||||
@@ -55,7 +55,11 @@
|
||||
@include('care.partials.queue-ticket', [
|
||||
'ticketNumber' => $appointment->queue_ticket_number,
|
||||
'ticketStatus' => $appointment->queue_ticket_status,
|
||||
'destination' => $appointment->queue_destination,
|
||||
'staffDisplayName' => $appointment->queue_staff_display_name,
|
||||
])
|
||||
@elseif (! empty($queueIntegration['enabled']) && ($appointment->queue_routing_status ?? '') === 'unresolved')
|
||||
<span class="mr-2 text-[11px] font-medium text-amber-700">Unassigned</span>
|
||||
@elseif ($appointment->queue_position)
|
||||
<span class="mr-2 inline-flex h-6 w-6 items-center justify-center rounded-full bg-sky-100 text-xs font-bold text-sky-700">{{ $appointment->queue_position }}</span>
|
||||
@endif
|
||||
|
||||
@@ -80,10 +80,18 @@ class CareQueueBridgeTest extends TestCase
|
||||
'prefix' => 'C',
|
||||
'active' => true,
|
||||
'synced' => true,
|
||||
'routing_mode' => 'assigned_only',
|
||||
'queue_uuid' => 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa',
|
||||
'counter_uuid' => 'bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb',
|
||||
'queue_external_key' => CareQueueContexts::queueExternalKey(CareQueueContexts::CONSULTATION, $this->branch->id),
|
||||
'counter_external_key' => CareQueueContexts::counterExternalKey(CareQueueContexts::CONSULTATION, $this->branch->id),
|
||||
'points' => [[
|
||||
'kind' => 'practitioner',
|
||||
'ref_id' => 0, // filled after practitioner create in tests that need it
|
||||
'destination' => 'Consultation desk',
|
||||
'counter_uuid' => 'bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb',
|
||||
'synced' => true,
|
||||
]],
|
||||
]],
|
||||
],
|
||||
];
|
||||
@@ -103,13 +111,38 @@ class CareQueueBridgeTest extends TestCase
|
||||
|
||||
public function test_check_in_issues_consultation_ticket_when_integration_on(): void
|
||||
{
|
||||
$practitioner = \App\Models\Practitioner::create([
|
||||
'owner_ref' => $this->owner->public_id,
|
||||
'organization_id' => $this->organization->id,
|
||||
'branch_id' => $this->branch->id,
|
||||
'name' => 'Dr. Bridge',
|
||||
'room' => 'Room 1',
|
||||
'is_active' => true,
|
||||
]);
|
||||
|
||||
$settings = $this->organization->settings;
|
||||
$settings['department_queue_provisioning'][CareQueueContexts::CONSULTATION]['queues'][0]['points'] = [[
|
||||
'kind' => 'practitioner',
|
||||
'ref_id' => $practitioner->id,
|
||||
'destination' => 'Room 1',
|
||||
'staff_display_name' => 'Dr. Bridge',
|
||||
'counter_uuid' => 'bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb',
|
||||
'synced' => true,
|
||||
]];
|
||||
$this->organization->update(['settings' => $settings]);
|
||||
|
||||
Http::fake(function (\Illuminate\Http\Client\Request $request) {
|
||||
if (str_contains($request->url(), '/tickets') && $request->method() === 'POST') {
|
||||
$this->assertSame('bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb', $request['assigned_counter_id'] ?? null);
|
||||
|
||||
return Http::response([
|
||||
'data' => [
|
||||
'uuid' => 'cccccccc-cccc-cccc-cccc-cccccccccccc',
|
||||
'ticket_number' => 'C001',
|
||||
'status' => 'waiting',
|
||||
'assigned_counter' => ['uuid' => 'bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb'],
|
||||
'destination' => 'Room 1',
|
||||
'staff_display_name' => 'Dr. Bridge',
|
||||
],
|
||||
], 201);
|
||||
}
|
||||
@@ -122,6 +155,7 @@ class CareQueueBridgeTest extends TestCase
|
||||
'organization_id' => $this->organization->id,
|
||||
'branch_id' => $this->branch->id,
|
||||
'patient_id' => $this->patient->id,
|
||||
'practitioner_id' => $practitioner->id,
|
||||
'type' => Appointment::TYPE_SCHEDULED,
|
||||
'status' => Appointment::STATUS_SCHEDULED,
|
||||
'scheduled_at' => now()->addHour(),
|
||||
@@ -193,10 +227,29 @@ class CareQueueBridgeTest extends TestCase
|
||||
|
||||
public function test_call_next_backfills_missing_ticket_then_calls(): void
|
||||
{
|
||||
$practitioner = \App\Models\Practitioner::create([
|
||||
'owner_ref' => $this->owner->public_id,
|
||||
'organization_id' => $this->organization->id,
|
||||
'branch_id' => $this->branch->id,
|
||||
'name' => 'Dr. Backfill',
|
||||
'room' => 'Room 3',
|
||||
'is_active' => true,
|
||||
]);
|
||||
|
||||
$settings = $this->organization->settings;
|
||||
$settings['department_queue_provisioning'][CareQueueContexts::CONSULTATION]['queues'][0]['points'] = [[
|
||||
'kind' => 'practitioner',
|
||||
'ref_id' => $practitioner->id,
|
||||
'destination' => 'Room 3',
|
||||
'counter_uuid' => 'bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb',
|
||||
'synced' => true,
|
||||
]];
|
||||
$settings['department_queue_provisioning'][CareQueueContexts::CONSULTATION]['queues'][0]['counter_uuid'] = 'bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb';
|
||||
$this->organization->update(['settings' => $settings]);
|
||||
|
||||
$ticketSeq = 0;
|
||||
Http::fake(function (\Illuminate\Http\Client\Request $request) use (&$ticketSeq) {
|
||||
if (str_contains($request->url(), '/call-next') && $request->method() === 'POST') {
|
||||
// First call: empty queue; second (after issue): ticket returned.
|
||||
if ($ticketSeq < 1) {
|
||||
return Http::response(['data' => null], 200);
|
||||
}
|
||||
@@ -219,6 +272,7 @@ class CareQueueBridgeTest extends TestCase
|
||||
'uuid' => 'ffffffff-ffff-ffff-ffff-ffffffffffff',
|
||||
'ticket_number' => 'C100',
|
||||
'status' => 'waiting',
|
||||
'assigned_counter' => ['uuid' => 'bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb'],
|
||||
],
|
||||
], 201);
|
||||
}
|
||||
@@ -231,6 +285,7 @@ class CareQueueBridgeTest extends TestCase
|
||||
'organization_id' => $this->organization->id,
|
||||
'branch_id' => $this->branch->id,
|
||||
'patient_id' => $this->patient->id,
|
||||
'practitioner_id' => $practitioner->id,
|
||||
'type' => Appointment::TYPE_WALK_IN,
|
||||
'status' => Appointment::STATUS_WAITING,
|
||||
'scheduled_at' => now(),
|
||||
@@ -240,7 +295,10 @@ class CareQueueBridgeTest extends TestCase
|
||||
|
||||
$this->actingAs($this->owner)
|
||||
->from(route('care.queue.index'))
|
||||
->post(route('care.queue.call-next'), ['branch_id' => $this->branch->id])
|
||||
->post(route('care.queue.call-next'), [
|
||||
'branch_id' => $this->branch->id,
|
||||
'practitioner_id' => $practitioner->id,
|
||||
])
|
||||
->assertRedirect(route('care.queue.index'))
|
||||
->assertSessionHas('success');
|
||||
|
||||
@@ -259,7 +317,7 @@ class CareQueueBridgeTest extends TestCase
|
||||
->from(route('care.queue.index'))
|
||||
->post(route('care.queue.call-next'), ['branch_id' => $this->branch->id])
|
||||
->assertRedirect(route('care.queue.index'))
|
||||
->assertSessionHas('info', 'No patients waiting in the consultation queue.')
|
||||
->assertSessionHas('info', 'No patients waiting at your consultation service point.')
|
||||
->assertSessionMissing('error');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -213,6 +213,42 @@ class CareQueueWorkflowTest extends TestCase
|
||||
{
|
||||
$this->fakeQueueApi();
|
||||
|
||||
$practitioner = \App\Models\Practitioner::create([
|
||||
'owner_ref' => $this->owner->public_id,
|
||||
'organization_id' => $this->organization->id,
|
||||
'branch_id' => $this->branch->id,
|
||||
'name' => 'Dr. Workflow',
|
||||
'room' => 'Room 2',
|
||||
'is_active' => true,
|
||||
]);
|
||||
|
||||
$this->organization->update([
|
||||
'settings' => array_merge($this->organization->settings ?? [], [
|
||||
'department_queue_provisioning' => [
|
||||
CareQueueContexts::CONSULTATION => [
|
||||
'queues' => [[
|
||||
'branch_id' => $this->branch->id,
|
||||
'branch_name' => 'Main',
|
||||
'name' => 'Consultation',
|
||||
'prefix' => 'C',
|
||||
'active' => true,
|
||||
'synced' => true,
|
||||
'routing_mode' => 'assigned_only',
|
||||
'queue_uuid' => 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa',
|
||||
'counter_uuid' => 'cccccccc-cccc-cccc-cccc-cccccccccccc',
|
||||
'points' => [[
|
||||
'kind' => 'practitioner',
|
||||
'ref_id' => $practitioner->id,
|
||||
'destination' => 'Room 2',
|
||||
'counter_uuid' => 'cccccccc-cccc-cccc-cccc-cccccccccccc',
|
||||
'synced' => true,
|
||||
]],
|
||||
]],
|
||||
],
|
||||
],
|
||||
]),
|
||||
]);
|
||||
|
||||
$patient = Patient::create([
|
||||
'uuid' => (string) Str::uuid(),
|
||||
'owner_ref' => $this->owner->public_id,
|
||||
@@ -229,6 +265,7 @@ class CareQueueWorkflowTest extends TestCase
|
||||
'organization_id' => $this->organization->id,
|
||||
'branch_id' => $this->branch->id,
|
||||
'patient_id' => $patient->id,
|
||||
'practitioner_id' => $practitioner->id,
|
||||
'type' => Appointment::TYPE_SCHEDULED,
|
||||
'status' => Appointment::STATUS_SCHEDULED,
|
||||
'scheduled_at' => now(),
|
||||
@@ -246,6 +283,14 @@ class CareQueueWorkflowTest extends TestCase
|
||||
{
|
||||
$this->fakeQueueApi();
|
||||
|
||||
$pharmacist = Member::create([
|
||||
'owner_ref' => $this->owner->public_id,
|
||||
'organization_id' => $this->organization->id,
|
||||
'user_ref' => 'pharm-workflow',
|
||||
'role' => 'pharmacist',
|
||||
'branch_id' => $this->branch->id,
|
||||
]);
|
||||
|
||||
$this->organization->update([
|
||||
'settings' => array_merge($this->organization->settings ?? [], [
|
||||
'department_queue_provisioning' => [
|
||||
@@ -257,10 +302,19 @@ class CareQueueWorkflowTest extends TestCase
|
||||
'prefix' => 'P',
|
||||
'active' => true,
|
||||
'synced' => true,
|
||||
'routing_mode' => 'assigned_only',
|
||||
'queue_uuid' => 'bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb',
|
||||
'counter_uuid' => 'dddddddd-dddd-dddd-dddd-dddddddddddd',
|
||||
'queue_external_key' => CareQueueContexts::queueExternalKey(CareQueueContexts::PHARMACY, $this->branch->id),
|
||||
'counter_external_key' => CareQueueContexts::counterExternalKey(CareQueueContexts::PHARMACY, $this->branch->id),
|
||||
'points' => [[
|
||||
'kind' => 'member',
|
||||
'ref_id' => $pharmacist->id,
|
||||
'staff_ref' => 'pharm-workflow',
|
||||
'destination' => 'Pharmacy counter 1',
|
||||
'counter_uuid' => 'dddddddd-dddd-dddd-dddd-dddddddddddd',
|
||||
'synced' => true,
|
||||
]],
|
||||
]],
|
||||
],
|
||||
],
|
||||
@@ -341,7 +395,7 @@ class CareQueueWorkflowTest extends TestCase
|
||||
->assertOk()
|
||||
->assertDontSee('Service counter')
|
||||
->assertSee('Call next')
|
||||
->assertSee('tickets are for this department only', false);
|
||||
->assertSee('assigned service point', false);
|
||||
}
|
||||
|
||||
public function test_integration_off_hides_queue_ops_on_pharmacy_page(): void
|
||||
|
||||
@@ -0,0 +1,447 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature;
|
||||
|
||||
use App\Http\Middleware\EnsurePlatformSession;
|
||||
use App\Models\Appointment;
|
||||
use App\Models\Branch;
|
||||
use App\Models\Member;
|
||||
use App\Models\Organization;
|
||||
use App\Models\Patient;
|
||||
use App\Models\Practitioner;
|
||||
use App\Models\User;
|
||||
use App\Services\Care\AppointmentService;
|
||||
use App\Services\Care\CareQueueBridge;
|
||||
use App\Services\Care\CareQueueContexts;
|
||||
use App\Services\Care\CareQueueProvisioner;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Illuminate\Support\Str;
|
||||
use Tests\TestCase;
|
||||
|
||||
class CareServicePointRoutingTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
protected User $owner;
|
||||
|
||||
protected Organization $organization;
|
||||
|
||||
protected Branch $branch;
|
||||
|
||||
protected Practitioner $doctorA;
|
||||
|
||||
protected Practitioner $doctorB;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
$this->withoutMiddleware(EnsurePlatformSession::class);
|
||||
|
||||
config([
|
||||
'care.queue.api_url' => 'https://queue.test/api/v1',
|
||||
'care.queue.api_key' => 'care-test-key',
|
||||
]);
|
||||
|
||||
$this->owner = User::create([
|
||||
'public_id' => 'sp-care-owner',
|
||||
'name' => 'Owner',
|
||||
'email' => 'sp-care@example.com',
|
||||
]);
|
||||
|
||||
$this->organization = Organization::create([
|
||||
'owner_ref' => $this->owner->public_id,
|
||||
'name' => 'Ridge Medical Centre',
|
||||
'slug' => 'ridge-medical',
|
||||
'settings' => [
|
||||
'onboarded' => true,
|
||||
'facility_type' => 'hospital',
|
||||
'plan' => 'pro',
|
||||
'plan_expires_at' => now()->addMonth()->toIso8601String(),
|
||||
'queue_integration_enabled' => true,
|
||||
],
|
||||
]);
|
||||
|
||||
Member::create([
|
||||
'owner_ref' => $this->owner->public_id,
|
||||
'organization_id' => $this->organization->id,
|
||||
'user_ref' => $this->owner->public_id,
|
||||
'role' => 'hospital_admin',
|
||||
]);
|
||||
|
||||
$this->branch = Branch::create([
|
||||
'owner_ref' => $this->owner->public_id,
|
||||
'organization_id' => $this->organization->id,
|
||||
'name' => 'Main',
|
||||
'is_active' => true,
|
||||
]);
|
||||
|
||||
$this->doctorA = Practitioner::create([
|
||||
'owner_ref' => $this->owner->public_id,
|
||||
'organization_id' => $this->organization->id,
|
||||
'branch_id' => $this->branch->id,
|
||||
'name' => 'Dr. Mensah',
|
||||
'specialty' => 'General',
|
||||
'room' => 'Consultation Room 4',
|
||||
'user_ref' => 'doc-mensah',
|
||||
'is_active' => true,
|
||||
]);
|
||||
|
||||
$this->doctorB = Practitioner::create([
|
||||
'owner_ref' => $this->owner->public_id,
|
||||
'organization_id' => $this->organization->id,
|
||||
'branch_id' => $this->branch->id,
|
||||
'name' => 'Dr. Okai',
|
||||
'specialty' => 'Pediatrics',
|
||||
'room' => 'Consultation Room 5',
|
||||
'user_ref' => 'doc-okai',
|
||||
'is_active' => true,
|
||||
]);
|
||||
|
||||
$settings = $this->organization->settings;
|
||||
$settings['department_queue_provisioning'] = [
|
||||
CareQueueContexts::CONSULTATION => [
|
||||
'queues' => [[
|
||||
'branch_id' => $this->branch->id,
|
||||
'branch_name' => 'Main',
|
||||
'name' => 'Consultation',
|
||||
'prefix' => 'C',
|
||||
'active' => true,
|
||||
'synced' => true,
|
||||
'routing_mode' => 'assigned_only',
|
||||
'queue_uuid' => 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa',
|
||||
'queue_external_key' => CareQueueContexts::queueExternalKey(CareQueueContexts::CONSULTATION, $this->branch->id),
|
||||
'points' => [
|
||||
[
|
||||
'kind' => 'practitioner',
|
||||
'ref_id' => $this->doctorA->id,
|
||||
'name' => 'Dr. Mensah',
|
||||
'destination' => 'Consultation Room 4',
|
||||
'staff_display_name' => 'Dr. Mensah',
|
||||
'external_key' => CareQueueContexts::pointExternalKey(
|
||||
CareQueueContexts::CONSULTATION,
|
||||
$this->branch->id,
|
||||
'practitioner',
|
||||
$this->doctorA->id,
|
||||
),
|
||||
'counter_uuid' => '11111111-1111-1111-1111-111111111111',
|
||||
'synced' => true,
|
||||
],
|
||||
[
|
||||
'kind' => 'practitioner',
|
||||
'ref_id' => $this->doctorB->id,
|
||||
'name' => 'Dr. Okai',
|
||||
'destination' => 'Consultation Room 5',
|
||||
'staff_display_name' => 'Dr. Okai',
|
||||
'external_key' => CareQueueContexts::pointExternalKey(
|
||||
CareQueueContexts::CONSULTATION,
|
||||
$this->branch->id,
|
||||
'practitioner',
|
||||
$this->doctorB->id,
|
||||
),
|
||||
'counter_uuid' => '22222222-2222-2222-2222-222222222222',
|
||||
'synced' => true,
|
||||
],
|
||||
],
|
||||
]],
|
||||
],
|
||||
CareQueueContexts::PHARMACY => [
|
||||
'queues' => [[
|
||||
'branch_id' => $this->branch->id,
|
||||
'name' => 'Pharmacy',
|
||||
'prefix' => 'P',
|
||||
'active' => true,
|
||||
'synced' => true,
|
||||
'routing_mode' => 'assigned_only',
|
||||
'queue_uuid' => 'bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb',
|
||||
'points' => [[
|
||||
'kind' => 'member',
|
||||
'ref_id' => 1,
|
||||
'destination' => 'Pharmacy counter 1',
|
||||
'counter_uuid' => '33333333-3333-3333-3333-333333333333',
|
||||
'synced' => true,
|
||||
]],
|
||||
]],
|
||||
],
|
||||
];
|
||||
$this->organization->update(['settings' => $settings]);
|
||||
}
|
||||
|
||||
public function test_fixed_doctor_appointment_issues_to_that_doctors_point(): void
|
||||
{
|
||||
Http::fake(function (\Illuminate\Http\Client\Request $request) {
|
||||
if ($request->method() === 'POST' && str_contains($request->url(), '/tickets')) {
|
||||
$this->assertSame('11111111-1111-1111-1111-111111111111', $request['assigned_counter_id'] ?? null);
|
||||
$this->assertSame('aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa', $request['service_queue_id'] ?? null);
|
||||
|
||||
return Http::response([
|
||||
'data' => [
|
||||
'uuid' => (string) Str::uuid(),
|
||||
'ticket_number' => 'C001',
|
||||
'status' => 'waiting',
|
||||
'destination' => 'Consultation Room 4',
|
||||
'staff_display_name' => 'Dr. Mensah',
|
||||
'assigned_counter' => [
|
||||
'uuid' => '11111111-1111-1111-1111-111111111111',
|
||||
'destination' => 'Consultation Room 4',
|
||||
'staff_display_name' => 'Dr. Mensah',
|
||||
],
|
||||
],
|
||||
], 201);
|
||||
}
|
||||
|
||||
return Http::response(['message' => 'unexpected'], 500);
|
||||
});
|
||||
|
||||
$patient = Patient::create([
|
||||
'uuid' => (string) Str::uuid(),
|
||||
'owner_ref' => $this->owner->public_id,
|
||||
'organization_id' => $this->organization->id,
|
||||
'branch_id' => $this->branch->id,
|
||||
'patient_number' => 'P-1',
|
||||
'first_name' => 'Ada',
|
||||
'last_name' => 'Lovelace',
|
||||
]);
|
||||
|
||||
$appointment = Appointment::create([
|
||||
'uuid' => (string) Str::uuid(),
|
||||
'owner_ref' => $this->owner->public_id,
|
||||
'organization_id' => $this->organization->id,
|
||||
'branch_id' => $this->branch->id,
|
||||
'patient_id' => $patient->id,
|
||||
'practitioner_id' => $this->doctorA->id,
|
||||
'type' => Appointment::TYPE_SCHEDULED,
|
||||
'status' => Appointment::STATUS_SCHEDULED,
|
||||
'scheduled_at' => now()->addHour(),
|
||||
]);
|
||||
|
||||
$result = app(AppointmentService::class)->checkIn($appointment, $this->owner->public_id);
|
||||
|
||||
$this->assertSame('C001', $result->queue_ticket_number);
|
||||
$this->assertSame('11111111-1111-1111-1111-111111111111', $result->queue_service_point_uuid);
|
||||
$this->assertSame('Consultation Room 4', $result->queue_destination);
|
||||
$this->assertSame('Dr. Mensah', $result->queue_staff_display_name);
|
||||
$this->assertSame(CareQueueContexts::ROUTING_ROUTED, $result->queue_routing_status);
|
||||
}
|
||||
|
||||
public function test_walk_in_without_doctor_flags_unresolved_and_does_not_issue(): void
|
||||
{
|
||||
Http::fake();
|
||||
|
||||
$patient = Patient::create([
|
||||
'uuid' => (string) Str::uuid(),
|
||||
'owner_ref' => $this->owner->public_id,
|
||||
'organization_id' => $this->organization->id,
|
||||
'branch_id' => $this->branch->id,
|
||||
'patient_number' => 'P-2',
|
||||
'first_name' => 'Bob',
|
||||
'last_name' => 'Builder',
|
||||
]);
|
||||
|
||||
$appointment = Appointment::create([
|
||||
'uuid' => (string) Str::uuid(),
|
||||
'owner_ref' => $this->owner->public_id,
|
||||
'organization_id' => $this->organization->id,
|
||||
'branch_id' => $this->branch->id,
|
||||
'patient_id' => $patient->id,
|
||||
'practitioner_id' => null,
|
||||
'type' => Appointment::TYPE_WALK_IN,
|
||||
'status' => Appointment::STATUS_WAITING,
|
||||
'waiting_at' => now(),
|
||||
]);
|
||||
|
||||
$result = app(CareQueueBridge::class)->issueForAppointment($this->organization, $appointment);
|
||||
|
||||
$this->assertNull($result->queue_ticket_uuid);
|
||||
$this->assertSame(CareQueueContexts::ROUTING_UNRESOLVED, $result->queue_routing_status);
|
||||
Http::assertNothingSent();
|
||||
}
|
||||
|
||||
public function test_walk_in_with_selected_doctor_routes_only_to_that_point(): void
|
||||
{
|
||||
Http::fake(function (\Illuminate\Http\Client\Request $request) {
|
||||
if ($request->method() === 'POST' && str_contains($request->url(), '/tickets')) {
|
||||
$this->assertSame('22222222-2222-2222-2222-222222222222', $request['assigned_counter_id'] ?? null);
|
||||
|
||||
return Http::response([
|
||||
'data' => [
|
||||
'uuid' => (string) Str::uuid(),
|
||||
'ticket_number' => 'C002',
|
||||
'status' => 'waiting',
|
||||
'assigned_counter' => ['uuid' => '22222222-2222-2222-2222-222222222222'],
|
||||
'destination' => 'Consultation Room 5',
|
||||
'staff_display_name' => 'Dr. Okai',
|
||||
],
|
||||
], 201);
|
||||
}
|
||||
|
||||
return Http::response(['message' => 'unexpected'], 500);
|
||||
});
|
||||
|
||||
$patient = Patient::create([
|
||||
'uuid' => (string) Str::uuid(),
|
||||
'owner_ref' => $this->owner->public_id,
|
||||
'organization_id' => $this->organization->id,
|
||||
'branch_id' => $this->branch->id,
|
||||
'patient_number' => 'P-3',
|
||||
'first_name' => 'Cara',
|
||||
'last_name' => 'Clinic',
|
||||
]);
|
||||
|
||||
$appointment = app(AppointmentService::class)->walkIn(
|
||||
$this->organization,
|
||||
$this->owner->public_id,
|
||||
[
|
||||
'branch_id' => $this->branch->id,
|
||||
'patient_id' => $patient->id,
|
||||
'practitioner_id' => $this->doctorB->id,
|
||||
'reason' => 'Fever',
|
||||
],
|
||||
$this->owner->public_id,
|
||||
);
|
||||
|
||||
$this->assertSame($this->doctorB->id, $appointment->practitioner_id);
|
||||
$this->assertSame('C002', $appointment->queue_ticket_number);
|
||||
$this->assertSame('Consultation Room 5', $appointment->queue_destination);
|
||||
}
|
||||
|
||||
public function test_pharmacist_call_next_uses_pharmacy_point(): void
|
||||
{
|
||||
$pharmacist = Member::create([
|
||||
'owner_ref' => $this->owner->public_id,
|
||||
'organization_id' => $this->organization->id,
|
||||
'user_ref' => 'pharm-1',
|
||||
'role' => 'pharmacist',
|
||||
'branch_id' => $this->branch->id,
|
||||
]);
|
||||
|
||||
$settings = $this->organization->settings;
|
||||
$settings['department_queue_provisioning'][CareQueueContexts::PHARMACY]['queues'][0]['points'] = [[
|
||||
'kind' => 'member',
|
||||
'ref_id' => $pharmacist->id,
|
||||
'destination' => 'Pharmacy counter 1',
|
||||
'staff_ref' => 'pharm-1',
|
||||
'counter_uuid' => '33333333-3333-3333-3333-333333333333',
|
||||
'synced' => true,
|
||||
]];
|
||||
$this->organization->update(['settings' => $settings]);
|
||||
|
||||
Http::fake(function (\Illuminate\Http\Client\Request $request) {
|
||||
if (str_contains($request->url(), '/call-next')) {
|
||||
$this->assertStringContainsString('bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb', $request->url());
|
||||
$this->assertSame('33333333-3333-3333-3333-333333333333', $request['counter_id'] ?? null);
|
||||
|
||||
return Http::response([
|
||||
'data' => [
|
||||
'uuid' => (string) Str::uuid(),
|
||||
'ticket_number' => 'P001',
|
||||
'status' => 'called',
|
||||
],
|
||||
], 200);
|
||||
}
|
||||
|
||||
return Http::response(['message' => 'unexpected'], 500);
|
||||
});
|
||||
|
||||
$result = app(CareQueueBridge::class)->callNext(
|
||||
$this->organization->fresh(),
|
||||
CareQueueContexts::PHARMACY,
|
||||
$this->branch->id,
|
||||
$pharmacist,
|
||||
);
|
||||
|
||||
$this->assertSame('P001', $result['ticket']['ticket_number'] ?? null);
|
||||
}
|
||||
|
||||
public function test_integration_off_preserves_behavior_without_ticket_issuance(): void
|
||||
{
|
||||
$settings = $this->organization->settings;
|
||||
$settings['queue_integration_enabled'] = false;
|
||||
$this->organization->update(['settings' => $settings]);
|
||||
|
||||
Http::fake();
|
||||
|
||||
$patient = Patient::create([
|
||||
'uuid' => (string) Str::uuid(),
|
||||
'owner_ref' => $this->owner->public_id,
|
||||
'organization_id' => $this->organization->id,
|
||||
'branch_id' => $this->branch->id,
|
||||
'patient_number' => 'P-4',
|
||||
'first_name' => 'Dee',
|
||||
'last_name' => 'Demo',
|
||||
]);
|
||||
|
||||
$appointment = Appointment::create([
|
||||
'uuid' => (string) Str::uuid(),
|
||||
'owner_ref' => $this->owner->public_id,
|
||||
'organization_id' => $this->organization->id,
|
||||
'branch_id' => $this->branch->id,
|
||||
'patient_id' => $patient->id,
|
||||
'practitioner_id' => $this->doctorA->id,
|
||||
'type' => Appointment::TYPE_SCHEDULED,
|
||||
'status' => Appointment::STATUS_SCHEDULED,
|
||||
'scheduled_at' => now()->addHour(),
|
||||
]);
|
||||
|
||||
$result = app(AppointmentService::class)->checkIn($appointment, $this->owner->public_id);
|
||||
|
||||
$this->assertNull($result->queue_ticket_uuid);
|
||||
Http::assertNothingSent();
|
||||
}
|
||||
|
||||
public function test_provisioner_builds_one_point_per_practitioner_idempotently(): void
|
||||
{
|
||||
$calls = ['queues' => 0, 'counters' => 0];
|
||||
|
||||
Http::fake(function (\Illuminate\Http\Client\Request $request) use (&$calls) {
|
||||
if (str_contains($request->url(), '/integrations/status')) {
|
||||
return Http::response(['data' => ['provisioned' => true, 'integrations' => ['care' => true]]], 200);
|
||||
}
|
||||
if (str_contains($request->url(), '/branches') && $request->method() === 'POST') {
|
||||
return Http::response(['data' => ['name' => 'Main']], 201);
|
||||
}
|
||||
if (str_contains($request->url(), '/departments') && $request->method() === 'POST') {
|
||||
return Http::response(['data' => ['id' => 9, 'name' => $request['name'] ?? 'Dept']], 201);
|
||||
}
|
||||
if (str_contains($request->url(), '/queues') && $request->method() === 'POST' && ! str_contains($request->url(), 'call-next')) {
|
||||
$calls['queues']++;
|
||||
$this->assertSame('assigned_only', $request['routing_mode'] ?? null);
|
||||
|
||||
return Http::response([
|
||||
'data' => [
|
||||
'uuid' => (string) Str::uuid(),
|
||||
'name' => $request['name'] ?? 'Q',
|
||||
'external_key' => $request['external_key'] ?? null,
|
||||
],
|
||||
], 201);
|
||||
}
|
||||
if (str_contains($request->url(), '/counters') && $request->method() === 'POST') {
|
||||
$calls['counters']++;
|
||||
|
||||
return Http::response([
|
||||
'data' => [
|
||||
'uuid' => (string) Str::uuid(),
|
||||
'name' => $request['name'] ?? 'C',
|
||||
'destination' => $request['destination'] ?? null,
|
||||
],
|
||||
], 201);
|
||||
}
|
||||
|
||||
return Http::response(['message' => 'unexpected '.$request->url()], 500);
|
||||
});
|
||||
|
||||
// Clear prior stub so provisioner builds fresh.
|
||||
$settings = $this->organization->settings;
|
||||
unset($settings['department_queue_provisioning']);
|
||||
$this->organization->update(['settings' => $settings]);
|
||||
|
||||
app(CareQueueProvisioner::class)->provisionOrganization($this->organization->fresh());
|
||||
$first = data_get($this->organization->fresh()->settings, 'department_queue_provisioning.consultation.queues.0.points');
|
||||
$this->assertIsArray($first);
|
||||
$this->assertGreaterThanOrEqual(2, count($first));
|
||||
|
||||
app(CareQueueProvisioner::class)->provisionOrganization($this->organization->fresh());
|
||||
$second = data_get($this->organization->fresh()->settings, 'department_queue_provisioning.consultation.queues.0.points');
|
||||
$this->assertCount(count($first), $second);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user