Deploy Ladill Care / deploy (push) Successful in 46s
Nurses can no longer pick a shift on the form; the server sets it from today's unit roster, falling back to the current clock window. Co-authored-by: Cursor <cursoragent@cursor.com>
347 lines
12 KiB
PHP
347 lines
12 KiB
PHP
<?php
|
||
|
||
namespace App\Services\Care;
|
||
|
||
use App\Models\CareUnit;
|
||
use App\Models\Member;
|
||
use App\Models\Organization;
|
||
use App\Models\RosterEntry;
|
||
use App\Models\Shift;
|
||
use App\Models\User;
|
||
use Carbon\Carbon;
|
||
use Carbon\CarbonInterface;
|
||
use Illuminate\Support\Collection;
|
||
use Illuminate\Validation\ValidationException;
|
||
|
||
class RosterService
|
||
{
|
||
/**
|
||
* Default org shifts (seeded once per organization).
|
||
*
|
||
* @var list<array{code: string, label: string, start_time: string, end_time: string, color: string, sort_order: int}>
|
||
*/
|
||
protected array $defaults = [
|
||
['code' => 'day', 'label' => 'Day shift', 'start_time' => '07:00:00', 'end_time' => '15:00:00', 'color' => '#0ea5e9', 'sort_order' => 10],
|
||
['code' => 'evening', 'label' => 'Evening shift', 'start_time' => '15:00:00', 'end_time' => '23:00:00', 'color' => '#f59e0b', 'sort_order' => 20],
|
||
['code' => 'night', 'label' => 'Night shift', 'start_time' => '23:00:00', 'end_time' => '07:00:00', 'color' => '#6366f1', 'sort_order' => 30],
|
||
];
|
||
|
||
/**
|
||
* @return Collection<int, Shift>
|
||
*/
|
||
public function ensureDefaultShifts(Organization $organization, string $ownerRef): Collection
|
||
{
|
||
foreach ($this->defaults as $row) {
|
||
Shift::query()->firstOrCreate(
|
||
[
|
||
'organization_id' => $organization->id,
|
||
'code' => $row['code'],
|
||
],
|
||
[
|
||
'owner_ref' => $ownerRef,
|
||
'label' => $row['label'],
|
||
'start_time' => $row['start_time'],
|
||
'end_time' => $row['end_time'],
|
||
'color' => $row['color'],
|
||
'sort_order' => $row['sort_order'],
|
||
'is_active' => true,
|
||
],
|
||
);
|
||
}
|
||
|
||
return Shift::owned($ownerRef)
|
||
->where('organization_id', $organization->id)
|
||
->where('is_active', true)
|
||
->orderBy('sort_order')
|
||
->orderBy('label')
|
||
->get();
|
||
}
|
||
|
||
/**
|
||
* Week grid: days × shifts → list of roster entries.
|
||
*
|
||
* @return array{week_start: Carbon, days: list<Carbon>, shifts: Collection, cells: array<string, list<RosterEntry>>, members: Collection}
|
||
*/
|
||
public function unitWeekGrid(
|
||
CareUnit $unit,
|
||
string $ownerRef,
|
||
?CarbonInterface $weekStart = null,
|
||
): array {
|
||
$organization = $unit->organization ?? Organization::query()->findOrFail($unit->organization_id);
|
||
$shifts = $this->ensureDefaultShifts($organization, $ownerRef);
|
||
|
||
$start = Carbon::parse($weekStart ?? now())->startOfWeek(Carbon::MONDAY)->startOfDay();
|
||
$days = [];
|
||
for ($i = 0; $i < 7; $i++) {
|
||
$days[] = $start->copy()->addDays($i);
|
||
}
|
||
|
||
$entries = RosterEntry::owned($ownerRef)
|
||
->where('care_unit_id', $unit->id)
|
||
->whereBetween('duty_date', [$start->toDateString(), $start->copy()->addDays(6)->toDateString()])
|
||
->where('status', '!=', RosterEntry::STATUS_CANCELLED)
|
||
->with(['member', 'shift'])
|
||
->orderBy('id')
|
||
->get();
|
||
|
||
$cells = [];
|
||
foreach ($entries as $entry) {
|
||
$key = $entry->duty_date->toDateString().'|'.$entry->shift_id;
|
||
$cells[$key][] = $entry;
|
||
}
|
||
|
||
$members = Member::owned($ownerRef)
|
||
->where('organization_id', $unit->organization_id)
|
||
->orderBy('role')
|
||
->get();
|
||
|
||
return [
|
||
'week_start' => $start,
|
||
'days' => $days,
|
||
'shifts' => $shifts,
|
||
'cells' => $cells,
|
||
'members' => $members,
|
||
'member_labels' => $this->memberLabels($members),
|
||
];
|
||
}
|
||
|
||
public function assign(
|
||
CareUnit $unit,
|
||
Shift $shift,
|
||
Member $member,
|
||
CarbonInterface|string $dutyDate,
|
||
string $ownerRef,
|
||
?string $actorRef = null,
|
||
?string $notes = null,
|
||
): RosterEntry {
|
||
if ((int) $shift->organization_id !== (int) $unit->organization_id
|
||
|| (int) $member->organization_id !== (int) $unit->organization_id) {
|
||
throw ValidationException::withMessages([
|
||
'member_id' => 'Staff and shift must belong to the same organization as the care unit.',
|
||
]);
|
||
}
|
||
|
||
$date = Carbon::parse($dutyDate)->toDateString();
|
||
|
||
$existing = RosterEntry::withTrashed()
|
||
->where('care_unit_id', $unit->id)
|
||
->where('shift_id', $shift->id)
|
||
->where('member_id', $member->id)
|
||
->whereDate('duty_date', $date)
|
||
->first();
|
||
|
||
if ($existing && ! $existing->trashed() && $existing->status !== RosterEntry::STATUS_CANCELLED) {
|
||
throw ValidationException::withMessages([
|
||
'member_id' => 'This staff member is already rostered on this shift for that date.',
|
||
]);
|
||
}
|
||
|
||
if ($existing) {
|
||
if ($existing->trashed()) {
|
||
$existing->restore();
|
||
}
|
||
$existing->update([
|
||
'status' => RosterEntry::STATUS_SCHEDULED,
|
||
'staff_assignment_id' => null,
|
||
'notes' => $notes,
|
||
'created_by' => $actorRef,
|
||
]);
|
||
$entry = $existing;
|
||
} else {
|
||
$entry = RosterEntry::create([
|
||
'owner_ref' => $ownerRef,
|
||
'organization_id' => $unit->organization_id,
|
||
'care_unit_id' => $unit->id,
|
||
'shift_id' => $shift->id,
|
||
'member_id' => $member->id,
|
||
'duty_date' => $date,
|
||
'status' => RosterEntry::STATUS_SCHEDULED,
|
||
'staff_assignment_id' => null,
|
||
'notes' => $notes,
|
||
'created_by' => $actorRef,
|
||
]);
|
||
}
|
||
|
||
AuditLogger::record(
|
||
$ownerRef,
|
||
'roster.assigned',
|
||
$unit->organization_id,
|
||
$actorRef,
|
||
RosterEntry::class,
|
||
$entry->id,
|
||
);
|
||
|
||
return $entry->load(['member', 'shift']);
|
||
}
|
||
|
||
public function cancel(RosterEntry $entry, string $ownerRef, ?string $actorRef = null): void
|
||
{
|
||
$entryId = $entry->id;
|
||
$organizationId = $entry->organization_id;
|
||
|
||
$entry->update(['status' => RosterEntry::STATUS_CANCELLED]);
|
||
|
||
// Soft-delete so the unique (unit, shift, member, date) can be reused.
|
||
$entry->delete();
|
||
|
||
AuditLogger::record(
|
||
$ownerRef,
|
||
'roster.cancelled',
|
||
$organizationId,
|
||
$actorRef,
|
||
RosterEntry::class,
|
||
$entryId,
|
||
);
|
||
}
|
||
|
||
/**
|
||
* Today's allocation across units (or one unit).
|
||
*
|
||
* @return Collection<int, RosterEntry>
|
||
*/
|
||
public function todayAllocation(Organization $organization, string $ownerRef, ?int $careUnitId = null): Collection
|
||
{
|
||
return RosterEntry::owned($ownerRef)
|
||
->where('organization_id', $organization->id)
|
||
->whereDate('duty_date', now()->toDateString())
|
||
->where('status', '!=', RosterEntry::STATUS_CANCELLED)
|
||
->when($careUnitId, fn ($q) => $q->where('care_unit_id', $careUnitId))
|
||
->with(['member', 'shift', 'careUnit.department'])
|
||
->orderBy('care_unit_id')
|
||
->orderBy('shift_id')
|
||
->get();
|
||
}
|
||
|
||
/**
|
||
* Roster entries for one staff member over a week (or open-ended upcoming window).
|
||
*
|
||
* @return array{week_start: Carbon, days: list<Carbon>, entries: Collection<int, RosterEntry>, today: Collection<int, RosterEntry>}
|
||
*/
|
||
public function memberWeek(
|
||
Member $member,
|
||
string $ownerRef,
|
||
?CarbonInterface $weekStart = null,
|
||
): array {
|
||
$start = Carbon::parse($weekStart ?? now())->startOfWeek(Carbon::MONDAY)->startOfDay();
|
||
$end = $start->copy()->addDays(6);
|
||
$days = [];
|
||
for ($i = 0; $i < 7; $i++) {
|
||
$days[] = $start->copy()->addDays($i);
|
||
}
|
||
|
||
$entries = RosterEntry::owned($ownerRef)
|
||
->where('member_id', $member->id)
|
||
->whereBetween('duty_date', [$start->toDateString(), $end->toDateString()])
|
||
->where('status', '!=', RosterEntry::STATUS_CANCELLED)
|
||
->with(['shift', 'careUnit.department'])
|
||
->orderBy('duty_date')
|
||
->orderBy('shift_id')
|
||
->get();
|
||
|
||
$today = $entries->filter(
|
||
fn (RosterEntry $entry) => $entry->duty_date->isSameDay(now())
|
||
)->values();
|
||
|
||
return [
|
||
'week_start' => $start,
|
||
'days' => $days,
|
||
'entries' => $entries,
|
||
'today' => $today,
|
||
];
|
||
}
|
||
|
||
/**
|
||
* @param iterable<Member> $members
|
||
* @return array<int, string>
|
||
*/
|
||
public function memberLabels(iterable $members): array
|
||
{
|
||
$list = collect($members)->filter()->unique('id')->values();
|
||
$refs = $list->pluck('user_ref')->all();
|
||
$users = User::query()->whereIn('public_id', $refs)->get()->keyBy('public_id');
|
||
$roles = config('care.roles');
|
||
|
||
$labels = [];
|
||
foreach ($list as $member) {
|
||
$user = $users->get($member->user_ref);
|
||
$display = $user?->name
|
||
?? (str_contains((string) $member->user_ref, '@') ? $member->user_ref : ($user?->email ?? $member->user_ref));
|
||
$labels[(int) $member->id] = $display.' · '.($roles[$member->role] ?? $member->role);
|
||
}
|
||
|
||
return $labels;
|
||
}
|
||
|
||
/**
|
||
* Shift code for nursing notes: today's roster on this unit, else clock-based default.
|
||
*/
|
||
public function dutyShiftCodeForMember(
|
||
CareUnit $unit,
|
||
?Member $member,
|
||
string $ownerRef,
|
||
?CarbonInterface $at = null,
|
||
): ?string {
|
||
$at = Carbon::parse($at ?? now());
|
||
$allowed = array_keys(config('care.staff_shift_codes', []));
|
||
|
||
if ($member) {
|
||
$entry = RosterEntry::owned($ownerRef)
|
||
->where('care_unit_id', $unit->id)
|
||
->where('member_id', $member->id)
|
||
->whereDate('duty_date', $at->toDateString())
|
||
->where('status', '!=', RosterEntry::STATUS_CANCELLED)
|
||
->with('shift')
|
||
->orderBy('shift_id')
|
||
->first();
|
||
|
||
$code = $entry?->shift?->code;
|
||
if ($code && in_array($code, $allowed, true)) {
|
||
return $code;
|
||
}
|
||
}
|
||
|
||
$organization = $unit->organization
|
||
?? Organization::query()->find($unit->organization_id);
|
||
if (! $organization) {
|
||
return null;
|
||
}
|
||
|
||
$shifts = $this->ensureDefaultShifts($organization, $ownerRef);
|
||
foreach ($shifts as $shift) {
|
||
if (! in_array($shift->code, $allowed, true)) {
|
||
continue;
|
||
}
|
||
if ($this->clockFallsInShift($at, (string) $shift->start_time, (string) $shift->end_time)) {
|
||
return $shift->code;
|
||
}
|
||
}
|
||
|
||
return null;
|
||
}
|
||
|
||
protected function clockFallsInShift(CarbonInterface $at, string $startTime, string $endTime): bool
|
||
{
|
||
$minutes = ((int) $at->format('H') * 60) + (int) $at->format('i');
|
||
$start = $this->timeToMinutes($startTime);
|
||
$end = $this->timeToMinutes($endTime);
|
||
|
||
if ($start === $end) {
|
||
return true;
|
||
}
|
||
|
||
// Overnight (e.g. 23:00–07:00)
|
||
if ($start > $end) {
|
||
return $minutes >= $start || $minutes < $end;
|
||
}
|
||
|
||
return $minutes >= $start && $minutes < $end;
|
||
}
|
||
|
||
protected function timeToMinutes(string $time): int
|
||
{
|
||
$parts = explode(':', substr($time, 0, 5));
|
||
|
||
return ((int) ($parts[0] ?? 0) * 60) + (int) ($parts[1] ?? 0);
|
||
}
|
||
}
|