Add visit placement on care units and beds.
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>
This commit is contained in:
isaacclad
2026-07-20 10:10:37 +00:00
co-authored by Cursor
parent 3735be3425
commit 45a1a95142
16 changed files with 1036 additions and 22 deletions
@@ -6,7 +6,9 @@ use App\Http\Controllers\Controller;
use App\Http\Controllers\Care\Concerns\ScopesToAccount;
use App\Models\CareUnit;
use App\Models\Department;
use App\Models\Visit;
use App\Services\Care\AuditLogger;
use App\Services\Care\CarePermissions;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Validation\Rule;
@@ -18,7 +20,7 @@ class CareUnitController extends Controller
public function index(Request $request): View
{
$this->authorizeAbility($request, 'admin.departments.view');
$this->authorizeCareUnitView($request);
$organization = $this->organization($request);
$owner = $this->ownerRef($request);
@@ -98,18 +100,63 @@ class CareUnitController extends Controller
public function show(Request $request, CareUnit $careUnit): View
{
$this->authorizeAbility($request, 'admin.departments.view');
$this->authorizeCareUnitView($request);
$this->authorizeOwner($request, $careUnit);
$organization = $this->organization($request);
abort_unless((int) $careUnit->organization_id === (int) $organization->id, 404);
$careUnit->load(['department.branch', 'beds']);
$placedVisits = Visit::owned($this->ownerRef($request))
->where('organization_id', $organization->id)
->where('care_unit_id', $careUnit->id)
->whereIn('status', [Visit::STATUS_OPEN, Visit::STATUS_IN_PROGRESS])
->with(['patient', 'bed'])
->orderBy('placed_at')
->get();
$placeableVisits = Visit::owned($this->ownerRef($request))
->where('organization_id', $organization->id)
->where('branch_id', $careUnit->department?->branch_id)
->whereIn('status', [Visit::STATUS_OPEN, Visit::STATUS_IN_PROGRESS])
->where(function ($q) use ($careUnit) {
$q->whereNull('care_unit_id')
->orWhere('care_unit_id', '!=', $careUnit->id);
})
->with(['patient', 'careUnit'])
->orderByDesc('checked_in_at')
->limit(50)
->get();
$availableBeds = $careUnit->beds
->where('is_active', true)
->whereIn('status', ['available', 'reserved'])
->values();
$canPlace = app(CarePermissions::class)->can($this->member($request), 'inpatient.placement.manage');
return view('care.admin.care-units.show', [
'unit' => $careUnit,
'kinds' => config('care.care_unit_kinds'),
'bedStatuses' => config('care.bed_statuses'),
'placedVisits' => $placedVisits,
'placeableVisits' => $placeableVisits,
'availableBeds' => $availableBeds,
'canPlace' => $canPlace,
]);
}
protected function authorizeCareUnitView(Request $request): void
{
$permissions = app(CarePermissions::class);
$member = $this->member($request);
abort_unless(
$permissions->can($member, 'admin.departments.view')
|| $permissions->can($member, 'inpatient.placement.view'),
403,
);
}
public function edit(Request $request, CareUnit $careUnit): View
{
$this->authorizeAbility($request, 'admin.departments.manage');
@@ -0,0 +1,118 @@
<?php
namespace App\Http\Controllers\Care;
use App\Http\Controllers\Controller;
use App\Http\Controllers\Care\Concerns\ScopesToAccount;
use App\Models\Bed;
use App\Models\CareUnit;
use App\Models\Visit;
use App\Services\Care\VisitPlacementService;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
class VisitPlacementController extends Controller
{
use ScopesToAccount;
public function store(Request $request, CareUnit $careUnit, VisitPlacementService $placements): RedirectResponse
{
$this->authorizeAbility($request, 'inpatient.placement.manage');
$this->authorizeOwner($request, $careUnit);
$organization = $this->organization($request);
abort_unless((int) $careUnit->organization_id === (int) $organization->id, 404);
$validated = $request->validate([
'visit_id' => ['required', 'integer', 'exists:care_visits,id'],
'bed_id' => ['nullable', 'integer', 'exists:care_beds,id'],
'notes' => ['nullable', 'string', 'max:2000'],
]);
$visit = Visit::owned($this->ownerRef($request))
->where('organization_id', $organization->id)
->findOrFail($validated['visit_id']);
$this->authorizeBranch($request, (int) $visit->branch_id);
$bed = null;
if (! empty($validated['bed_id'])) {
$bed = Bed::owned($this->ownerRef($request))
->where('care_unit_id', $careUnit->id)
->findOrFail($validated['bed_id']);
}
$placements->place(
$visit,
$careUnit,
$bed,
$this->ownerRef($request),
$this->actorRef($request),
$validated['notes'] ?? null,
);
return redirect()
->route('care.care-units.show', $careUnit)
->with('success', 'Patient placed on this care unit.');
}
public function transfer(Request $request, Visit $visit, VisitPlacementService $placements): RedirectResponse
{
$this->authorizeAbility($request, 'inpatient.placement.manage');
$this->authorizeOwner($request, $visit);
$this->authorizeBranch($request, (int) $visit->branch_id);
$organization = $this->organization($request);
$validated = $request->validate([
'care_unit_id' => ['required', 'integer', 'exists:care_care_units,id'],
'bed_id' => ['nullable', 'integer', 'exists:care_beds,id'],
'notes' => ['nullable', 'string', 'max:2000'],
]);
$unit = CareUnit::owned($this->ownerRef($request))
->where('organization_id', $organization->id)
->findOrFail($validated['care_unit_id']);
$bed = null;
if (! empty($validated['bed_id'])) {
$bed = Bed::owned($this->ownerRef($request))
->where('care_unit_id', $unit->id)
->findOrFail($validated['bed_id']);
}
$placements->transfer(
$visit,
$unit,
$bed,
$this->ownerRef($request),
$this->actorRef($request),
$validated['notes'] ?? null,
);
return redirect()
->route('care.care-units.show', $unit)
->with('success', 'Patient transferred.');
}
public function destroy(Request $request, Visit $visit, VisitPlacementService $placements): RedirectResponse
{
$this->authorizeAbility($request, 'inpatient.placement.manage');
$this->authorizeOwner($request, $visit);
$this->authorizeBranch($request, (int) $visit->branch_id);
$unitId = $visit->care_unit_id;
$placements->dischargePlacement(
$visit,
$this->ownerRef($request),
$this->actorRef($request),
$request->input('notes'),
);
if ($unitId) {
return redirect()
->route('care.care-units.show', $unitId)
->with('success', 'Placement discharged; bed freed if applicable.');
}
return back()->with('success', 'Placement discharged.');
}
}
+16
View File
@@ -5,6 +5,8 @@ namespace App\Models;
use App\Models\Concerns\BelongsToOwner;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\Relations\HasOne;
use Illuminate\Database\Eloquent\SoftDeletes;
class Bed extends Model
@@ -41,4 +43,18 @@ class Bed extends Model
{
return $this->belongsTo(CareUnit::class, 'care_unit_id');
}
public function stays(): HasMany
{
return $this->hasMany(BedStay::class, 'bed_id');
}
public function activeStay(): HasOne
{
return $this->hasOne(BedStay::class, 'bed_id')
->ofMany(
['id' => 'max'],
fn ($query) => $query->where('status', BedStay::STATUS_ACTIVE),
);
}
}
+67
View File
@@ -0,0 +1,67 @@
<?php
namespace App\Models;
use App\Models\Concerns\BelongsToOwner;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class BedStay extends Model
{
use BelongsToOwner;
public const STATUS_ACTIVE = 'active';
public const STATUS_ENDED = 'ended';
protected $table = 'care_bed_stays';
protected $fillable = [
'owner_ref',
'organization_id',
'visit_id',
'care_unit_id',
'bed_id',
'status',
'admitted_at',
'discharged_at',
'admitted_by',
'discharged_by',
'reason',
'notes',
];
protected function casts(): array
{
return [
'admitted_at' => 'datetime',
'discharged_at' => 'datetime',
];
}
public function organization(): BelongsTo
{
return $this->belongsTo(Organization::class, 'organization_id');
}
public function visit(): BelongsTo
{
return $this->belongsTo(Visit::class, 'visit_id');
}
public function careUnit(): BelongsTo
{
return $this->belongsTo(CareUnit::class, 'care_unit_id');
}
public function bed(): BelongsTo
{
return $this->belongsTo(Bed::class, 'bed_id');
}
public function scopeActive(Builder $query): Builder
{
return $query->where('status', self::STATUS_ACTIVE);
}
}
+10
View File
@@ -53,6 +53,16 @@ class CareUnit extends Model
return $this->hasMany(StaffAssignment::class, 'care_unit_id');
}
public function visits(): HasMany
{
return $this->hasMany(Visit::class, 'care_unit_id');
}
public function activeStays(): HasMany
{
return $this->hasMany(BedStay::class, 'care_unit_id')->where('status', BedStay::STATUS_ACTIVE);
}
public function supportsBeds(): bool
{
return $this->kind === 'inpatient';
+26
View File
@@ -24,6 +24,7 @@ class Visit extends Model
protected $fillable = [
'uuid', 'owner_ref', 'organization_id', 'branch_id', 'patient_id',
'care_unit_id', 'bed_id', 'placed_at', 'placed_by',
'status', 'specialty_stage', 'checked_in_at', 'completed_at', 'checked_in_by',
];
@@ -32,6 +33,7 @@ class Visit extends Model
return [
'checked_in_at' => 'datetime',
'completed_at' => 'datetime',
'placed_at' => 'datetime',
];
}
@@ -64,6 +66,30 @@ class Visit extends Model
return $this->belongsTo(Organization::class, 'organization_id');
}
public function careUnit(): BelongsTo
{
return $this->belongsTo(CareUnit::class, 'care_unit_id');
}
public function bed(): BelongsTo
{
return $this->belongsTo(Bed::class, 'bed_id');
}
public function bedStays(): HasMany
{
return $this->hasMany(BedStay::class, 'visit_id');
}
public function activeBedStay(): HasOne
{
return $this->hasOne(BedStay::class, 'visit_id')
->ofMany(
['id' => 'max'],
fn ($query) => $query->where('status', BedStay::STATUS_ACTIVE),
);
}
public function appointment(): HasOne
{
return $this->hasOne(Appointment::class, 'visit_id');
+25 -1
View File
@@ -566,7 +566,31 @@ class CarePermissions
}
}
return array_keys($merged);
return $this->withDerivedAbilities(array_keys($merged));
}
/**
* Floor clinicians who record vitals may also place patients on care units/beds.
*
* @param list<string> $abilities
* @return list<string>
*/
protected function withDerivedAbilities(array $abilities): array
{
if (in_array('*', $abilities, true)) {
return $abilities;
}
if (in_array('vitals.manage', $abilities, true)) {
$abilities[] = 'inpatient.placement.view';
$abilities[] = 'inpatient.placement.manage';
}
if (in_array('analytics.department.view', $abilities, true)) {
$abilities[] = 'inpatient.placement.view';
}
return array_values(array_unique($abilities));
}
public function isAdmin(?Member $member): bool
+1 -1
View File
@@ -215,7 +215,7 @@ class PatientService
]);
$visits = $patient->visits()
->with(['branch'])
->with(['branch', 'careUnit', 'bed'])
->orderByDesc('checked_in_at')
->limit(10)
->get();
+245
View File
@@ -0,0 +1,245 @@
<?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
}
}
}
+6
View File
@@ -42,6 +42,12 @@ class VisitService
public function complete(Visit $visit, string $ownerRef, ?string $actorRef = null): Visit
{
$visit->refresh();
app(VisitPlacementService::class)->dischargePlacement($visit, $ownerRef, $actorRef, 'Visit completed');
$visit->refresh();
$visit->update([
'status' => Visit::STATUS_COMPLETED,
'completed_at' => now(),