Files
ladill-care/app/Services/Care/AppointmentService.php
T
isaaccladandCursor 4e753e68c0
Deploy Ladill Care / deploy (push) Successful in 35s
Replace ambulance Call next queue with an EMS dispatch board.
Home columns follow New call → Dispatched → On scene → Transport → Handover; walk-ins land as New call and Call next is disabled for this module.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-20 12:13:33 +00:00

568 lines
21 KiB
PHP

<?php
namespace App\Services\Care;
use App\Models\Appointment;
use App\Models\Organization;
use App\Models\Patient;
use App\Models\Practitioner;
use App\Models\Visit;
use App\Services\Care\Workflow\WorkflowQueueGate;
use App\Services\Care\Workflow\WorkflowStageCompletionService;
use App\Services\Meet\MeetClient;
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\Log;
use InvalidArgumentException;
class AppointmentService
{
public function __construct(
protected VisitService $visits,
protected AppointmentPatientNotifier $notifier,
protected CareQueueBridge $queueBridge,
protected WorkflowQueueGate $workflowGate,
protected WorkflowStageCompletionService $workflowCompletion,
) {}
public function queueContextFor(Organization $organization, Appointment $appointment): string
{
return $this->queueBridge->contextForAppointment($organization, $appointment);
}
public function queryForOrganization(string $ownerRef, int $organizationId): Builder
{
return Appointment::owned($ownerRef)->where('organization_id', $organizationId);
}
/**
* @param array<string, mixed> $filters
* @param list<int>|null $practitionerIds When set, only appointments assigned to these desks.
*/
public function list(
string $ownerRef,
int $organizationId,
array $filters = [],
?int $branchId = null,
?array $practitionerIds = null,
): LengthAwarePaginator {
$query = $this->queryForOrganization($ownerRef, $organizationId)
->with(['patient', 'practitioner', 'branch', 'department'])
->orderByDesc('scheduled_at');
if ($branchId !== null) {
$query->where('branch_id', $branchId);
}
if ($practitionerIds !== null) {
if ($practitionerIds === []) {
$query->whereRaw('1 = 0');
} else {
$query->whereIn('practitioner_id', $practitionerIds);
}
}
if ($status = $filters['status'] ?? null) {
$query->where('status', $status);
}
if ($practitionerId = $filters['practitioner_id'] ?? null) {
$query->where('practitioner_id', $practitionerId);
}
if ($date = $filters['date'] ?? null) {
$query->whereDate('scheduled_at', Carbon::parse($date)->toDateString());
}
if ($patientId = $filters['patient_id'] ?? null) {
$query->where('patient_id', $patientId);
}
return $query->paginate((int) ($filters['per_page'] ?? 20))->withQueryString();
}
/** Max waiters / in-care rows rendered on the patient-flow board. */
public const QUEUE_BOARD_LIMIT = 50;
/**
* @param list<int>|null $practitionerIds
* @return Collection<int, Appointment>
*/
public function queue(
string $ownerRef,
int $branchId,
?int $practitionerId = null,
bool $includeUnassigned = true,
?array $practitionerIds = null,
?int $limit = null,
): Collection {
if ($practitionerIds !== null) {
return $this->queueForPractitioners(
$ownerRef,
$practitionerIds,
$branchId > 0 ? $branchId : null,
$limit,
);
}
$this->repairDuplicateQueuePositions($ownerRef, $branchId);
$limit = $limit ?? self::QUEUE_BOARD_LIMIT;
$query = Appointment::owned($ownerRef)
->where('branch_id', $branchId)
->whereIn('status', [Appointment::STATUS_WAITING, Appointment::STATUS_CHECKED_IN])
->with($this->queueEagerLoads())
->orderBy('queue_position')
->orderBy('waiting_at')
->orderBy('checked_in_at')
->limit($limit);
if ($practitionerId !== null) {
$query->where(function (Builder $q) use ($practitionerId, $includeUnassigned) {
$q->where('practitioner_id', $practitionerId);
if ($includeUnassigned) {
$q->orWhereNull('practitioner_id');
}
});
}
return $query->get()
->filter(function (Appointment $appointment) {
$organization = $appointment->organization;
if (! $organization) {
return true;
}
return $this->workflowGate->isReleasedForDisplay(
$organization,
$appointment->visit,
$this->queueBridge->contextForAppointment($organization, $appointment),
);
})
->values();
}
/**
* Doctor queue: only appointments assigned to their linked practitioner desk(s).
*
* @param list<int> $practitionerIds
* @return Collection<int, Appointment>
*/
public function queueForPractitioners(
string $ownerRef,
array $practitionerIds,
?int $branchId = null,
?int $limit = null,
): Collection {
if ($practitionerIds === []) {
return new Collection;
}
$limit = $limit ?? self::QUEUE_BOARD_LIMIT;
$query = Appointment::owned($ownerRef)
->whereIn('practitioner_id', $practitionerIds)
->whereIn('status', [Appointment::STATUS_WAITING, Appointment::STATUS_CHECKED_IN])
->when($branchId, fn ($q) => $q->where('branch_id', $branchId))
->with($this->queueEagerLoads())
->orderBy('queue_position')
->orderBy('waiting_at')
->orderBy('checked_in_at')
->limit($limit);
$practitioners = Practitioner::owned($ownerRef)
->whereIn('id', $practitionerIds)
->get();
$allowedSpecialtyContexts = [];
$orgIds = $practitioners->pluck('organization_id')->filter()->unique()->all();
$orgsById = \App\Models\Organization::query()
->whereIn('id', $orgIds ?: [0])
->get()
->keyBy('id');
foreach ($practitioners as $practitioner) {
$org = $orgsById->get((int) $practitioner->organization_id);
if (! $org) {
continue;
}
$deskContext = $this->queueBridge->contextForPractitioner($org, $practitioner);
if ($deskContext !== CareQueueContexts::CONSULTATION) {
$allowedSpecialtyContexts[$deskContext] = true;
}
}
$allowedSpecialtyContexts = array_keys($allowedSpecialtyContexts);
return new Collection(
$query->get()
->filter(function (Appointment $appointment) use ($allowedSpecialtyContexts) {
$organization = $appointment->organization;
if (! $organization) {
return true;
}
$context = $this->queueBridge->contextForAppointment($organization, $appointment);
// GPs see consultation only. Specialty desks see their own specialty waiters on Queue.
if ($context !== CareQueueContexts::CONSULTATION
&& ! in_array($context, $allowedSpecialtyContexts, true)) {
return false;
}
return $this->workflowGate->isReleasedForDisplay(
$organization,
$appointment->visit,
$context,
);
})
->values()
->all()
);
}
/**
* @return list<string|array<string, mixed>>
*/
protected function queueEagerLoads(): array
{
return [
'patient',
'practitioner',
'organization',
'visit.currentStageAdvance.stage',
];
}
/**
* @param array<string, mixed> $data
*/
public function book(Organization $organization, string $ownerRef, array $data, ?string $actorRef = null): Appointment
{
$appointment = Appointment::create([
'owner_ref' => $ownerRef,
'organization_id' => $organization->id,
'branch_id' => $data['branch_id'],
'patient_id' => $data['patient_id'],
'practitioner_id' => $data['practitioner_id'] ?? null,
'department_id' => $data['department_id'] ?? null,
'type' => Appointment::TYPE_SCHEDULED,
'status' => Appointment::STATUS_SCHEDULED,
'scheduled_at' => $data['scheduled_at'],
'reason' => $data['reason'] ?? null,
'notes' => $data['notes'] ?? null,
'created_by' => $actorRef,
]);
AuditLogger::record($ownerRef, 'appointment.created', $organization->id, $actorRef, Appointment::class, $appointment->id);
$appointment = $appointment->load(['patient', 'practitioner', 'branch']);
$this->notifier->notifyAppointmentBooked($organization, $appointment);
return $appointment;
}
/**
* @param array<string, mixed> $data
*/
public function walkIn(Organization $organization, string $ownerRef, array $data, ?string $actorRef = null): Appointment
{
$visit = $this->visits->checkIn(
$organization,
$ownerRef,
Patient::findOrFail($data['patient_id']),
(int) $data['branch_id'],
$actorRef,
);
$appointment = Appointment::create([
'owner_ref' => $ownerRef,
'organization_id' => $organization->id,
'branch_id' => $data['branch_id'],
'patient_id' => $data['patient_id'],
'practitioner_id' => $data['practitioner_id'] ?? null,
'department_id' => $data['department_id'] ?? null,
'visit_id' => $visit->id,
'type' => Appointment::TYPE_WALK_IN,
'status' => Appointment::STATUS_WAITING,
'scheduled_at' => now(),
'checked_in_at' => now(),
'waiting_at' => now(),
'queue_position' => $this->nextQueuePosition($ownerRef, (int) $data['branch_id']),
'reason' => $data['reason'] ?? 'Walk-in',
'notes' => $data['notes'] ?? null,
'created_by' => $actorRef,
]);
AuditLogger::record($ownerRef, 'appointment.walk_in', $organization->id, $actorRef, Appointment::class, $appointment->id);
$appointment = $appointment->load(['patient', 'practitioner', 'branch', 'visit', 'department']);
$this->queueBridge->issueForAppointment($organization, $appointment);
if ($appointment->department?->type === 'ambulance' && $appointment->visit) {
$appointment->visit->update([
'specialty_stage' => \App\Services\Care\Ambulance\AmbulanceWorkflowService::STAGE_CHECK_IN,
]);
}
return $appointment->fresh(['patient', 'practitioner', 'branch', 'visit']);
}
public function checkIn(Appointment $appointment, string $ownerRef, ?string $actorRef = null): Appointment
{
$this->assertTransition($appointment, Appointment::STATUS_SCHEDULED);
$visit = $this->visits->checkIn(
$appointment->organization,
$ownerRef,
$appointment->patient,
$appointment->branch_id,
$actorRef,
);
$appointment->update([
'visit_id' => $visit->id,
'status' => Appointment::STATUS_WAITING,
'checked_in_at' => now(),
'waiting_at' => now(),
'queue_position' => $this->nextQueuePosition($ownerRef, $appointment->branch_id),
]);
AuditLogger::record($ownerRef, 'appointment.checked_in', $appointment->organization_id, $actorRef, Appointment::class, $appointment->id);
$appointment = $appointment->fresh(['patient', 'practitioner', 'visit', 'organization']);
$this->queueBridge->issueForAppointment($appointment->organization, $appointment);
return $appointment->fresh(['patient', 'practitioner', 'visit']);
}
public function markWaiting(Appointment $appointment, string $ownerRef, ?string $actorRef = null): Appointment
{
if (! in_array($appointment->status, [Appointment::STATUS_CHECKED_IN, Appointment::STATUS_SCHEDULED], true)) {
$this->assertTransition($appointment, Appointment::STATUS_CHECKED_IN);
}
$updates = [
'status' => Appointment::STATUS_WAITING,
'waiting_at' => $appointment->waiting_at ?? now(),
];
if ($appointment->queue_position === null) {
$updates['queue_position'] = $this->nextQueuePosition($ownerRef, $appointment->branch_id);
}
$appointment->update($updates);
AuditLogger::record($ownerRef, 'appointment.waiting', $appointment->organization_id, $actorRef, Appointment::class, $appointment->id);
$appointment = $appointment->fresh(['patient', 'practitioner', 'organization']);
if ($appointment->organization) {
$this->queueBridge->issueForAppointment($appointment->organization, $appointment);
}
return $appointment->fresh(['patient', 'practitioner']);
}
public function startConsultation(Appointment $appointment, string $ownerRef, ?int $practitionerId = null, ?string $actorRef = null): Appointment
{
$this->assertTransition($appointment, Appointment::STATUS_WAITING, Appointment::STATUS_CHECKED_IN);
if ($appointment->visit_id === null) {
$visit = $this->visits->checkIn(
$appointment->organization,
$ownerRef,
$appointment->patient,
$appointment->branch_id,
$actorRef,
);
$appointment->update(['visit_id' => $visit->id, 'checked_in_at' => now()]);
}
$appointment->loadMissing(['organization', 'visit']);
if ($appointment->organization && ! $this->workflowGate->canRelease(
$appointment->organization,
$appointment->visit,
$this->queueBridge->contextForAppointment($appointment->organization, $appointment),
)) {
throw new InvalidArgumentException('This visit has not reached a financially cleared consultation stage.');
}
$appointment->visit->update(['status' => Visit::STATUS_IN_PROGRESS]);
$appointment->update([
'status' => Appointment::STATUS_IN_CONSULTATION,
'started_at' => now(),
'practitioner_id' => $practitionerId ?? $appointment->practitioner_id,
]);
AuditLogger::record($ownerRef, 'appointment.in_consultation', $appointment->organization_id, $actorRef, Appointment::class, $appointment->id);
$appointment = $appointment->fresh(['patient', 'practitioner', 'visit', 'organization']);
if ($appointment->organization) {
$this->queueBridge->serve($appointment->organization, $appointment);
}
return $appointment->fresh(['patient', 'practitioner', 'visit']);
}
public function complete(Appointment $appointment, string $ownerRef, ?string $actorRef = null): Appointment
{
$this->assertTransition($appointment, Appointment::STATUS_IN_CONSULTATION);
$appointment->update([
'status' => Appointment::STATUS_COMPLETED,
'completed_at' => now(),
]);
$visit = $appointment->visit;
$organization = $appointment->organization;
$workflowEnabled = $visit
&& $organization
&& $this->workflowGate->enabled($organization);
AuditLogger::record($ownerRef, 'appointment.completed', $appointment->organization_id, $actorRef, Appointment::class, $appointment->id);
$appointment = $appointment->fresh(['patient', 'practitioner', 'visit', 'organization']);
if ($appointment->organization) {
$this->queueBridge->complete($appointment->organization, $appointment);
}
if ($workflowEnabled && $visit && $organization) {
$this->workflowCompletion->complete(
$organization,
$visit,
$this->queueBridge->contextForAppointment($organization, $appointment),
$ownerRef,
$actorRef,
);
} elseif ($visit) {
$this->visits->complete($visit, $ownerRef, $actorRef);
}
return $appointment->fresh(['patient', 'practitioner', 'visit']);
}
public function cancel(Appointment $appointment, string $ownerRef, ?string $actorRef = null): Appointment
{
if (in_array($appointment->status, [Appointment::STATUS_COMPLETED, Appointment::STATUS_CANCELLED], true)) {
throw new InvalidArgumentException('Appointment cannot be cancelled.');
}
$meetRoomUuid = filled($appointment->meet_room_uuid)
? (string) $appointment->meet_room_uuid
: null;
$appointment->update([
'status' => Appointment::STATUS_CANCELLED,
'cancelled_at' => now(),
]);
AuditLogger::record($ownerRef, 'appointment.cancelled', $appointment->organization_id, $actorRef, Appointment::class, $appointment->id);
if ($meetRoomUuid !== null) {
$this->cancelLinkedMeetRoom($appointment, $ownerRef, $meetRoomUuid, $actorRef);
}
return $appointment->fresh(['patient', 'practitioner']);
}
protected function cancelLinkedMeetRoom(
Appointment $appointment,
string $ownerRef,
string $roomUuid,
?string $actorRef = null,
): void {
try {
MeetClient::for($ownerRef)->cancelRoom($roomUuid, $actorRef ?? $ownerRef);
AuditLogger::record(
$ownerRef,
'appointment.meet_cancelled',
$appointment->organization_id,
$actorRef,
Appointment::class,
$appointment->id,
['meet_room_uuid' => $roomUuid],
);
} catch (\Throwable $e) {
Log::warning('Care appointment cancel: could not cancel linked Meet room', [
'appointment_id' => $appointment->id,
'meet_room_uuid' => $roomUuid,
'error' => $e->getMessage(),
]);
}
}
public function markNoShow(Appointment $appointment, string $ownerRef, ?string $actorRef = null): Appointment
{
$this->assertTransition($appointment, Appointment::STATUS_SCHEDULED);
$appointment->update(['status' => Appointment::STATUS_NO_SHOW]);
AuditLogger::record($ownerRef, 'appointment.no_show', $appointment->organization_id, $actorRef, Appointment::class, $appointment->id);
return $appointment->fresh(['patient', 'practitioner']);
}
public function callNext(string $ownerRef, int $branchId, ?int $practitionerId = null): ?Appointment
{
$next = $this->queue($ownerRef, $branchId, $practitionerId)->first();
return $next;
}
protected function nextQueuePosition(string $ownerRef, int $branchId): int
{
$max = Appointment::owned($ownerRef)
->where('branch_id', $branchId)
->whereIn('status', [Appointment::STATUS_WAITING, Appointment::STATUS_CHECKED_IN])
->max('queue_position');
return ($max ?? 0) + 1;
}
/**
* Demo specialty seed used to set every module's waiter to position 1.
* Repair duplicates in place so the patient queue shows 1..n.
* Cheap existence check first so healthy queues skip the full rewrite.
*/
protected function repairDuplicateQueuePositions(string $ownerRef, int $branchId): void
{
$base = Appointment::owned($ownerRef)
->where('branch_id', $branchId)
->whereIn('status', [Appointment::STATUS_WAITING, Appointment::STATUS_CHECKED_IN]);
$waiterCount = (clone $base)->count();
if ($waiterCount < 2) {
return;
}
$hasNulls = (clone $base)->whereNull('queue_position')->exists();
$distinctPositions = (int) (clone $base)
->whereNotNull('queue_position')
->selectRaw('count(distinct queue_position) as aggregate')
->value('aggregate');
if (! $hasNulls && $distinctPositions === $waiterCount) {
return;
}
$waiters = (clone $base)
->orderByRaw('COALESCE(waiting_at, checked_in_at, scheduled_at) asc')
->orderBy('id')
->get(['id', 'queue_position']);
foreach ($waiters as $index => $appointment) {
$position = $index + 1;
if ((int) $appointment->queue_position === $position) {
continue;
}
Appointment::whereKey($appointment->id)->update(['queue_position' => $position]);
}
}
protected function assertTransition(Appointment $appointment, string ...$allowedFrom): void
{
if (! in_array($appointment->status, $allowedFrom, true)) {
throw new InvalidArgumentException("Cannot transition from {$appointment->status}.");
}
}
}