Deploy Ladill Care / deploy (push) Failing after 1m9s
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>
391 lines
15 KiB
PHP
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}.");
|
|
}
|
|
}
|
|
}
|