Files
ladill-care/app/Services/Care/AppointmentService.php
T
isaaccladandCursor 015a4cc7fe
Deploy Ladill Care / deploy (push) Successful in 1m45s
Wire Care lists into Ladill Queue workflows instead of reception panels.
When Queue integration is on, role pages issue tickets into their own
department queues and drive Call next → Serve → Complete on the native
lists. Integration off leaves existing UI unchanged.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-17 16:27:48 +00:00

346 lines
13 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\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,
) {}
public function queryForOrganization(string $ownerRef, int $organizationId): Builder
{
return Appointment::owned($ownerRef)->where('organization_id', $organizationId);
}
/**
* @param array<string, mixed> $filters
*/
public function list(string $ownerRef, int $organizationId, array $filters = [], ?int $branchId = null): LengthAwarePaginator
{
$query = $this->queryForOrganization($ownerRef, $organizationId)
->with(['patient', 'practitioner', 'branch', 'department'])
->orderByDesc('scheduled_at');
if ($branchId !== null) {
$query->where('branch_id', $branchId);
}
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();
}
/**
* @return Collection<int, Appointment>
*/
public function queue(string $ownerRef, int $branchId, ?int $practitionerId = null): Collection
{
$query = Appointment::owned($ownerRef)
->where('branch_id', $branchId)
->whereIn('status', [Appointment::STATUS_WAITING, Appointment::STATUS_CHECKED_IN])
->with(['patient', 'practitioner'])
->orderBy('queue_position')
->orderBy('waiting_at')
->orderBy('checked_in_at');
if ($practitionerId !== null) {
$query->where(function (Builder $q) use ($practitionerId) {
$q->where('practitioner_id', $practitionerId)->orWhereNull('practitioner_id');
});
}
return $query->get();
}
/**
* @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->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(),
]);
if ($appointment->visit) {
$this->visits->complete($appointment->visit, $ownerRef, $actorRef);
}
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);
}
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;
}
protected function assertTransition(Appointment $appointment, string ...$allowedFrom): void
{
if (! in_array($appointment->status, $allowedFrom, true)) {
throw new InvalidArgumentException("Cannot transition from {$appointment->status}.");
}
}
}