Files
ladill-care/app/Services/Care/AppointmentService.php
T
isaaccladandCursor f138ea598a
Deploy Ladill Care / deploy (push) Successful in 1m3s
Keep specialty waiters off the regular doctor Queue.
Demo GP appointments now use core desk departments, and the doctor waiting list excludes specialty queue contexts so MAT/PHY tickets stay on specialty modules.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-18 14:17:01 +00:00

497 lines
19 KiB
PHP

<?php
namespace App\Services\Care;
use App\Models\Appointment;
use App\Models\Organization;
use App\Models\Patient;
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();
}
/**
* @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,
): Collection {
if ($practitionerIds !== null) {
return $this->queueForPractitioners($ownerRef, $practitionerIds, $branchId > 0 ? $branchId : null);
}
$this->repairDuplicateQueuePositions($ownerRef, $branchId);
$query = Appointment::owned($ownerRef)
->where('branch_id', $branchId)
->whereIn('status', [Appointment::STATUS_WAITING, Appointment::STATUS_CHECKED_IN])
->with(['patient', 'practitioner', 'visit', 'organization'])
->orderBy('queue_position')
->orderBy('waiting_at')
->orderBy('checked_in_at');
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->canRelease(
$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): Collection
{
if ($practitionerIds === []) {
return new Collection;
}
$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(['patient', 'practitioner', 'visit', 'organization'])
->orderBy('queue_position')
->orderBy('waiting_at')
->orderBy('checked_in_at');
return new Collection(
$query->get()
->filter(function (Appointment $appointment) {
$organization = $appointment->organization;
if (! $organization) {
return true;
}
$context = $this->queueBridge->contextForAppointment($organization, $appointment);
// Regular doctor Queue is consultation-only; specialty waiters belong on specialty modules.
if ($context !== CareQueueContexts::CONSULTATION) {
return false;
}
return $this->workflowGate->canRelease(
$organization,
$appointment->visit,
$context,
);
})
->values()
->all()
);
}
/**
* @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']);
$this->queueBridge->issueForAppointment($organization, $appointment);
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.
*/
protected function repairDuplicateQueuePositions(string $ownerRef, int $branchId): void
{
$waiters = Appointment::owned($ownerRef)
->where('branch_id', $branchId)
->whereIn('status', [Appointment::STATUS_WAITING, Appointment::STATUS_CHECKED_IN])
->orderByRaw('COALESCE(waiting_at, checked_in_at, scheduled_at) asc')
->orderBy('id')
->get(['id', 'queue_position']);
if ($waiters->count() < 2) {
return;
}
$positions = $waiters->pluck('queue_position');
if ($positions->filter(fn ($p) => $p !== null)->unique()->count() === $waiters->count()) {
return;
}
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}.");
}
}
}