Files
ladill-care/app/Services/Care/AppointmentService.php
T
isaaccladandCursor 3ee59a0956
Deploy Ladill Care / deploy (push) Failing after 1m9s
Activate Care workflows across check-in and financial clearance.
Enforce service-queue gates, cashier settlements, and clinical stage progression so patient journeys cannot bypass configured payment or authorization rules.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-17 21:13:06 +00:00

391 lines
15 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
*/
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', 'visit', 'organization'])
->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()
->filter(function (Appointment $appointment) {
$organization = $appointment->organization;
if (! $organization) {
return true;
}
return $this->workflowGate->canRelease(
$organization,
$appointment->visit,
$this->queueBridge->contextForAppointment($organization, $appointment),
);
})
->values();
}
/**
* @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;
}
protected function assertTransition(Appointment $appointment, string ...$allowedFrom): void
{
if (! in_array($appointment->status, $allowedFrom, true)) {
throw new InvalidArgumentException("Cannot transition from {$appointment->status}.");
}
}
}