Files
ladill-care/app/Services/Care/VisitPlacementService.php
isaaccladandCursor 45a1a95142
Deploy Ladill Care / deploy (push) Successful in 38s
Add visit placement on care units and beds.
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>
2026-07-20 10:10:37 +00:00

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
}
}
}