Deploy Ladill Care / deploy (push) Successful in 38s
Patients can be admitted, transferred, and discharged from unit/bed stays with occupancy sync, so MAR and ward boards have inpatient context. Co-authored-by: Cursor <cursoragent@cursor.com>
246 lines
7.6 KiB
PHP
246 lines
7.6 KiB
PHP
<?php
|
|
|
|
namespace App\Services\Care;
|
|
|
|
use App\Models\Bed;
|
|
use App\Models\BedStay;
|
|
use App\Models\CareUnit;
|
|
use App\Models\Visit;
|
|
use Illuminate\Support\Facades\DB;
|
|
use Illuminate\Validation\ValidationException;
|
|
|
|
class VisitPlacementService
|
|
{
|
|
/**
|
|
* Place (or re-place) a visit onto a care unit, optionally a bed.
|
|
*/
|
|
public function place(
|
|
Visit $visit,
|
|
CareUnit $unit,
|
|
?Bed $bed,
|
|
string $ownerRef,
|
|
?string $actorRef = null,
|
|
?string $notes = null,
|
|
): BedStay {
|
|
$this->assertCompatible($visit, $unit, $bed);
|
|
|
|
return DB::transaction(function () use ($visit, $unit, $bed, $ownerRef, $actorRef, $notes) {
|
|
$hadPlacement = $visit->care_unit_id !== null;
|
|
$this->endCurrentStay($visit, $ownerRef, $actorRef, $hadPlacement ? 'transfer' : 'admit', $notes);
|
|
|
|
if ($bed) {
|
|
$this->occupyBed($bed);
|
|
}
|
|
|
|
$stay = BedStay::create([
|
|
'owner_ref' => $ownerRef,
|
|
'organization_id' => $visit->organization_id,
|
|
'visit_id' => $visit->id,
|
|
'care_unit_id' => $unit->id,
|
|
'bed_id' => $bed?->id,
|
|
'status' => BedStay::STATUS_ACTIVE,
|
|
'admitted_at' => now(),
|
|
'admitted_by' => $actorRef,
|
|
'reason' => $hadPlacement ? 'transfer' : 'admit',
|
|
'notes' => $notes,
|
|
]);
|
|
|
|
$visit->update([
|
|
'care_unit_id' => $unit->id,
|
|
'bed_id' => $bed?->id,
|
|
'placed_at' => now(),
|
|
'placed_by' => $actorRef,
|
|
]);
|
|
|
|
AuditLogger::record(
|
|
$ownerRef,
|
|
$hadPlacement ? 'visit.transferred' : 'visit.placed',
|
|
$visit->organization_id,
|
|
$actorRef,
|
|
Visit::class,
|
|
$visit->id,
|
|
[
|
|
'care_unit_id' => $unit->id,
|
|
'bed_id' => $bed?->id,
|
|
'bed_stay_id' => $stay->id,
|
|
],
|
|
);
|
|
|
|
return $stay->fresh(['careUnit', 'bed', 'visit']);
|
|
});
|
|
}
|
|
|
|
public function transfer(
|
|
Visit $visit,
|
|
CareUnit $unit,
|
|
?Bed $bed,
|
|
string $ownerRef,
|
|
?string $actorRef = null,
|
|
?string $notes = null,
|
|
): BedStay {
|
|
if ($visit->care_unit_id === null) {
|
|
throw ValidationException::withMessages([
|
|
'care_unit_id' => 'Visit is not currently placed. Use admit/place instead.',
|
|
]);
|
|
}
|
|
|
|
return $this->place($visit, $unit, $bed, $ownerRef, $actorRef, $notes);
|
|
}
|
|
|
|
/**
|
|
* Clear unit/bed placement without completing the visit.
|
|
*/
|
|
public function dischargePlacement(
|
|
Visit $visit,
|
|
string $ownerRef,
|
|
?string $actorRef = null,
|
|
?string $notes = null,
|
|
): void {
|
|
$visit->refresh();
|
|
|
|
if ($visit->care_unit_id === null && $visit->bed_id === null) {
|
|
return;
|
|
}
|
|
|
|
DB::transaction(function () use ($visit, $ownerRef, $actorRef, $notes) {
|
|
$this->endCurrentStay($visit, $ownerRef, $actorRef, 'discharge', $notes);
|
|
|
|
$visit->update([
|
|
'care_unit_id' => null,
|
|
'bed_id' => null,
|
|
'placed_at' => null,
|
|
'placed_by' => null,
|
|
]);
|
|
|
|
AuditLogger::record(
|
|
$ownerRef,
|
|
'visit.placement_discharged',
|
|
$visit->organization_id,
|
|
$actorRef,
|
|
Visit::class,
|
|
$visit->id,
|
|
);
|
|
});
|
|
}
|
|
|
|
protected function endCurrentStay(
|
|
Visit $visit,
|
|
string $ownerRef,
|
|
?string $actorRef,
|
|
string $reason,
|
|
?string $notes,
|
|
): void {
|
|
$active = BedStay::query()
|
|
->where('visit_id', $visit->id)
|
|
->active()
|
|
->lockForUpdate()
|
|
->get();
|
|
|
|
foreach ($active as $stay) {
|
|
$stay->update([
|
|
'status' => BedStay::STATUS_ENDED,
|
|
'discharged_at' => now(),
|
|
'discharged_by' => $actorRef,
|
|
'reason' => $reason,
|
|
'notes' => $notes ?? $stay->notes,
|
|
]);
|
|
|
|
if ($stay->bed_id) {
|
|
$bed = Bed::query()->lockForUpdate()->find($stay->bed_id);
|
|
if ($bed && $bed->status === 'occupied') {
|
|
$stillOccupied = BedStay::query()
|
|
->where('bed_id', $bed->id)
|
|
->active()
|
|
->where('id', '!=', $stay->id)
|
|
->exists();
|
|
|
|
if (! $stillOccupied) {
|
|
$bed->update(['status' => 'available']);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Also free visit's current bed if stay rows were missing.
|
|
if ($visit->bed_id) {
|
|
$bed = Bed::query()->lockForUpdate()->find($visit->bed_id);
|
|
if ($bed && $bed->status === 'occupied') {
|
|
$stillOccupied = BedStay::query()
|
|
->where('bed_id', $bed->id)
|
|
->active()
|
|
->exists();
|
|
if (! $stillOccupied) {
|
|
$bed->update(['status' => 'available']);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
protected function occupyBed(Bed $bed): void
|
|
{
|
|
$bed = Bed::query()->lockForUpdate()->findOrFail($bed->id);
|
|
|
|
$conflict = BedStay::query()
|
|
->where('bed_id', $bed->id)
|
|
->active()
|
|
->exists();
|
|
|
|
if ($conflict || in_array($bed->status, ['occupied', 'maintenance', 'closed'], true)) {
|
|
throw ValidationException::withMessages([
|
|
'bed_id' => 'This bed is not available.',
|
|
]);
|
|
}
|
|
|
|
$bed->update(['status' => 'occupied']);
|
|
}
|
|
|
|
protected function assertCompatible(Visit $visit, CareUnit $unit, ?Bed $bed): void
|
|
{
|
|
if ((int) $visit->organization_id !== (int) $unit->organization_id) {
|
|
throw ValidationException::withMessages([
|
|
'care_unit_id' => 'Care unit belongs to a different organization.',
|
|
]);
|
|
}
|
|
|
|
if (! $unit->is_active) {
|
|
throw ValidationException::withMessages([
|
|
'care_unit_id' => 'Care unit is inactive.',
|
|
]);
|
|
}
|
|
|
|
if ($unit->kind === 'float_pool') {
|
|
throw ValidationException::withMessages([
|
|
'care_unit_id' => 'Patients cannot be placed on a float pool. Float pools are for staff only.',
|
|
]);
|
|
}
|
|
|
|
if (in_array($visit->status, [Visit::STATUS_COMPLETED], true)) {
|
|
throw ValidationException::withMessages([
|
|
'visit_id' => 'Cannot place a completed visit.',
|
|
]);
|
|
}
|
|
|
|
if ($bed) {
|
|
if ((int) $bed->care_unit_id !== (int) $unit->id) {
|
|
throw ValidationException::withMessages([
|
|
'bed_id' => 'Bed does not belong to the selected care unit.',
|
|
]);
|
|
}
|
|
|
|
if (! $unit->supportsBeds()) {
|
|
throw ValidationException::withMessages([
|
|
'bed_id' => 'This care unit kind does not use beds.',
|
|
]);
|
|
}
|
|
|
|
if (! $bed->is_active) {
|
|
throw ValidationException::withMessages([
|
|
'bed_id' => 'Bed is inactive.',
|
|
]);
|
|
}
|
|
} elseif ($unit->supportsBeds() === false) {
|
|
// outpatient / service — bed optional, OK
|
|
}
|
|
}
|
|
}
|