Files
ladill-care/app/Services/Care/RosterService.php
T
isaaccladandCursor b2cebe2908
Deploy Ladill Care / deploy (push) Successful in 1m0s
Add shift templates, unit duty roster grid, and Nursing Services hub.
Phase 3 staff management: real shift entities, week roster linked to temporary assignments, and a nursing ops module surface (registry, today’s allocation, unit shortcuts).

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-20 10:40:25 +00:00

265 lines
9.1 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?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\StaffAssignment;
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,
bool $linkAssignment = true,
): 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.',
]);
}
$assignmentId = null;
if ($linkAssignment) {
$assignment = StaffAssignment::create([
'owner_ref' => $ownerRef,
'organization_id' => $unit->organization_id,
'member_id' => $member->id,
'department_id' => $unit->department_id,
'care_unit_id' => $unit->id,
'kind' => 'temporary',
'assignment_role' => null,
'shift_code' => $shift->code,
'starts_on' => $date,
'ends_on' => $date,
'status' => 'active',
'notes' => $notes ?? 'Duty roster',
]);
$assignmentId = $assignment->id;
}
if ($existing) {
if ($existing->trashed()) {
$existing->restore();
}
$existing->update([
'status' => RosterEntry::STATUS_SCHEDULED,
'staff_assignment_id' => $assignmentId,
'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' => $assignmentId,
'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]);
if ($entry->staff_assignment_id) {
StaffAssignment::query()->where('id', $entry->staff_assignment_id)->update([
'status' => 'ended',
'ends_on' => $entry->duty_date,
]);
}
// 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();
}
/**
* @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;
}
}