getMethod($method); return $target->invokeArgs($route, $args); } beforeEach(function (): void { $_SERVER['REQUEST_URI'] = '/departments/weather'; }); it('calculates workfeed employee hours for the hour slot based on overlap', function (): void { $route = new moduleWeatherAPIRoute(); $slot = new DateTime('2026-03-24T13:00:00+00:00'); $shifts = [ (object)[ 'departmentID' => 'dep_1', 'checkIn' => (object)['time' => '2026-03-24T13:00:00+00:00'], 'checkOut' => (object)['time' => '2026-03-24T14:00:00+00:00'], 'end' => '2026-03-24T14:00:00+00:00', ], (object)[ 'departmentID' => 'dep_1', 'checkIn' => (object)['time' => '2026-03-24T13:30:00+00:00'], 'checkOut' => (object)['time' => '2026-03-24T15:00:00+00:00'], 'end' => '2026-03-24T15:00:00+00:00', ], (object)[ 'departmentID' => 'dep_other', 'checkIn' => (object)['time' => '2026-03-24T13:00:00+00:00'], 'checkOut' => (object)['time' => '2026-03-24T14:00:00+00:00'], 'end' => '2026-03-24T14:00:00+00:00', ], (object)[ 'departmentID' => 'dep_1', 'checkIn' => (object)['time' => '2026-03-24T14:00:00+00:00'], 'checkOut' => (object)['time' => '2026-03-24T13:00:00+00:00'], 'end' => '2026-03-24T13:00:00+00:00', ], ]; $hours = weather_route_invoke_private($route, 'calculateWorkfeedEmployeeHoursForHour', [$shifts, 'dep_1', $slot]); expect($hours)->toBe(1.5); }); it('calculates weather hour contributions grouped per employee for a slot', function (): void { $route = new moduleWeatherAPIRoute(); $slot = new DateTime('2026-03-24T13:00:00+00:00'); $shifts = [ (object)[ 'departmentID' => 'dep_1', 'employeeID' => 'emp_1', 'employeeName' => 'Alice', 'checkIn' => (object)['time' => '2026-03-24T13:00:00+00:00'], 'checkOut' => (object)['time' => '2026-03-24T14:00:00+00:00'], 'end' => '2026-03-24T14:00:00+00:00', ], (object)[ 'departmentID' => 'dep_1', 'employeeID' => 'emp_1', 'employeeName' => 'Alice', 'checkIn' => (object)['time' => '2026-03-24T13:00:00+00:00'], 'checkOut' => (object)['time' => '2026-03-24T13:30:00+00:00'], 'end' => '2026-03-24T13:30:00+00:00', ], (object)[ 'departmentID' => 'dep_1', 'employeeID' => 'emp_2', 'employeeName' => 'Bob', 'checkIn' => (object)['time' => '2026-03-24T13:15:00+00:00'], 'checkOut' => (object)['time' => '2026-03-24T14:00:00+00:00'], 'end' => '2026-03-24T14:00:00+00:00', ], (object)[ 'departmentID' => 'dep_other', 'employeeID' => 'emp_3', 'employeeName' => 'Ignored', 'checkIn' => (object)['time' => '2026-03-24T13:00:00+00:00'], 'checkOut' => (object)['time' => '2026-03-24T14:00:00+00:00'], 'end' => '2026-03-24T14:00:00+00:00', ], ]; $details = weather_route_invoke_private($route, 'calculateWorkfeedEmployeeHoursForHourByEmployee', [$shifts, 'dep_1', $slot]); expect($details)->toBe([ [ 'employee_id' => 'emp_1', 'employee_name' => 'Alice', 'hours' => 1.5, ], [ 'employee_id' => 'emp_2', 'employee_name' => 'Bob', 'hours' => 0.75, ], ]); }); it('calculates weather hour contributions for canonical nested workfeed employee schema', function (): void { $route = new moduleWeatherAPIRoute(); $slot = new DateTime('2026-03-24T13:00:00+00:00'); $shifts = [ (object)[ 'departmentID' => 'dep_1', 'employee' => (object)[ 'id' => '005FnnP0fHohybM1f3tx', 'firstname' => 'Michael', 'lastname' => 'Stenbæk Stampe', ], 'checkIn' => (object)['time' => '2026-03-24T13:00:00+00:00'], 'checkOut' => (object)['time' => '2026-03-24T14:00:00+00:00'], 'end' => '2026-03-24T14:00:00+00:00', ], ]; $details = weather_route_invoke_private($route, 'calculateWorkfeedEmployeeHoursForHourByEmployee', [$shifts, 'dep_1', $slot]); expect($details)->toBe([ [ 'employee_id' => '005FnnP0fHohybM1f3tx', 'employee_name' => 'Michael Stenbæk Stampe', 'hours' => 1.0, ], ]); }); it('extracts weather employee identity from supported shift payload shapes', function (): void { $route = new moduleWeatherAPIRoute(); $fromTopLevel = weather_route_invoke_private($route, 'extractWorkfeedEmployeeIdentity', [ (object)[ 'employeeID' => 'emp_1', 'employeeName' => 'Alice', ], ]); $fromNested = weather_route_invoke_private($route, 'extractWorkfeedEmployeeIdentity', [ (object)[ 'employee' => (object)[ 'id' => 'emp_2', 'firstName' => 'Bob', 'lastName' => 'Builder', ], ], ]); $fromCanonicalNestedSchema = weather_route_invoke_private($route, 'extractWorkfeedEmployeeIdentity', [ (object)[ 'employee' => (object)[ 'id' => 'emp_3', 'firstname' => 'Charlie', 'lastname' => 'Day', ], ], ]); expect($fromTopLevel)->toBe([ 'id' => 'emp_1', 'name' => 'Alice', ]); expect($fromNested)->toBe([ 'id' => 'emp_2', 'name' => 'Bob Builder', ]); expect($fromCanonicalNestedSchema)->toBe([ 'id' => 'emp_3', 'name' => 'Charlie Day', ]); }); it('prefers canonical workfeed employee schema fields over generic employee names', function (): void { $route = new moduleWeatherAPIRoute(); $identity = weather_route_invoke_private($route, 'extractWorkfeedEmployeeIdentity', [ (object)[ 'employee' => (object)[ 'id' => 'emp_4', 'firstname' => 'Dana', 'lastname' => 'Scully', 'name' => 'Wrong Name', ], 'employeeName' => 'Also Wrong', ], ]); expect($identity)->toBe([ 'id' => 'emp_4', 'name' => 'Dana Scully', ]); }); it('does not use workfeed schema name fields as employee ids', function (): void { $route = new moduleWeatherAPIRoute(); $identity = weather_route_invoke_private($route, 'extractWorkfeedEmployeeIdentity', [ (object)[ 'employee' => (object)[ 'firstname' => 'Fox', 'lastname' => 'Mulder', ], ], ]); expect($identity)->toBe([ 'id' => null, 'name' => 'Fox Mulder', ]); }); it('returns a null employee name when no workfeed employee name is available', function (): void { $route = new moduleWeatherAPIRoute(); $identity = weather_route_invoke_private($route, 'extractWorkfeedEmployeeIdentity', [ (object)[ 'employeeID' => 'emp_99', ], ]); expect($identity)->toBe([ 'id' => 'emp_99', 'name' => null, ]); }); it('resolves missing workfeed employee names from the employees endpoint', function (): void { $route = new moduleWeatherAPIRoute(); $workfeed = new class extends workfeed { public function __construct() { } public function listEmployees(array $filters = []): array|object { return [ (object)[ 'id' => 'emp_42', 'firstname' => 'Jane', 'lastname' => 'Doe', ], ]; } public function getEmployee(string $id): object { return (object)[]; } }; $resolvedNames = []; $employees = weather_route_invoke_private($route, 'resolveMissingWorkfeedEmployeeNames', [[ [ 'employee_id' => 'emp_42', 'employee_name' => null, 'hours' => 1.0, ], ], null, &$resolvedNames, $workfeed]); expect($employees)->toBe([[ 'employee_id' => 'emp_42', 'employee_name' => 'Jane Doe', 'hours' => 1.0, ]]); }); it('filters employee hour rows that cannot be resolved to a workfeed schema name', function (): void { $route = new moduleWeatherAPIRoute(); $resolvedNames = []; $employees = weather_route_invoke_private($route, 'resolveMissingWorkfeedEmployeeNames', [[ [ 'employee_id' => 'emp_missing', 'employee_name' => null, 'hours' => 1.0, ], ], null, &$resolvedNames, null]); expect($employees)->toBe([]); }); it('resolves employee display name from cache when shift payload lacks a name', function (): void { $route = new moduleWeatherAPIRoute(); $cache = new class([ 'emp_42' => 'Jane Doe', ]) extends redis { private array $namesByEmployeeId; public function __construct(array $namesByEmployeeId) { $this->namesByEmployeeId = $namesByEmployeeId; } public function get_workfeed_employee_name(string $employeeId): string|null { return $this->namesByEmployeeId[$employeeId] ?? null; } }; $resolved_name = weather_route_invoke_private($route, 'fetchCachedWorkfeedEmployeeDisplayName', [$cache, 'emp_42']); expect($resolved_name)->toBe('Jane Doe'); }); it('extracts department id from supported workfeed shift shapes', function (): void { $route = new moduleWeatherAPIRoute(); $idFromNested = weather_route_invoke_private($route, 'extractWorkfeedDepartmentId', [ (object)[ 'department' => (object)['id' => 'dep_nested'], 'start' => '2026-03-24T12:45:00+00:00', 'end' => '2026-03-24T13:15:00+00:00', ], ]); $idFromCamelCase = weather_route_invoke_private($route, 'extractWorkfeedDepartmentId', [[ 'departmentId' => 'dep_camel', 'start' => '2026-03-24T12:45:00+00:00', 'end' => '2026-03-24T13:15:00+00:00', ]]); expect($idFromNested)->toBe('dep_nested'); expect($idFromCamelCase)->toBe('dep_camel'); }); it('normalizes wrapped workfeed collections from common response keys', function (): void { $route = new moduleWeatherAPIRoute(); $wrapped = (object)[ 'data' => [ (object)['id' => 'a'], (object)['id' => 'b'], ], ]; $items = weather_route_invoke_private($route, 'normalizeWorkfeedCollection', [$wrapped]); expect($items)->toHaveCount(2); expect($items[0]->id ?? null)->toBe('a'); expect($items[1]->id ?? null)->toBe('b'); }); it('calculates workfeed employee hours across multiple departments for one hour slot', function (): void { $route = new moduleWeatherAPIRoute(); $slot = new DateTime('2026-03-24T13:00:00+00:00'); $shifts = [ (object)[ 'departmentID' => 'dep_1', 'checkIn' => (object)['time' => '2026-03-24T13:00:00+00:00'], 'checkOut' => (object)['time' => '2026-03-24T14:00:00+00:00'], 'end' => '2026-03-24T14:00:00+00:00', ], (object)[ 'departmentID' => 'dep_2', 'checkIn' => (object)['time' => '2026-03-24T13:00:00+00:00'], 'checkOut' => (object)['time' => '2026-03-24T14:00:00+00:00'], 'end' => '2026-03-24T14:00:00+00:00', ], ]; $hours = weather_route_invoke_private($route, 'calculateWorkfeedEmployeeHoursForHour', [$shifts, ['dep_1', 'dep_2'], $slot]); expect($hours)->toBe(2.0); }); it('does not count shifts without punches when checkIn/checkOut are null', function (): void { $route = new moduleWeatherAPIRoute(); $slot = new DateTime('2026-03-24T13:00:00+00:00'); $shifts = [ (object)[ 'departmentID' => 'dep_1', 'checkIn' => null, 'checkOut' => null, 'start' => '2026-03-24T13:00:00+00:00', 'end' => '2026-03-24T14:00:00+00:00', ], ]; $hours = weather_route_invoke_private($route, 'calculateWorkfeedEmployeeHoursForHour', [$shifts, 'dep_1', $slot]); expect($hours)->toBe(0.0); }); it('counts checked-in shifts without checkOut up to occurredUntil', function (): void { $route = new moduleWeatherAPIRoute(); $slot = new DateTime('2026-03-24T13:00:00+00:00'); $occurredUntil = new DateTime('2026-03-24T13:40:00+00:00'); $shifts = [ (object)[ 'departmentID' => 'dep_1', 'checkIn' => (object)['time' => '2026-03-24T13:10:00+00:00'], 'checkOut' => null, 'end' => '2026-03-24T16:00:00+00:00', ], ]; $hours = weather_route_invoke_private($route, 'calculateWorkfeedEmployeeHoursForHour', [$shifts, 'dep_1', $slot, $occurredUntil]); expect($hours)->toBe(0.5); }); it('counts overtime minutes when a saved shift end extends past the approved original end', function (): void { $route = new moduleWeatherAPIRoute(); $slot = new DateTime('2026-03-23T18:00:00+00:00'); $shifts = [ (object)[ 'departmentID' => 'dep_1', 'start' => '2026-03-23T10:00:00+00:00', 'end' => '2026-03-23T18:17:00+00:00', 'approval' => (object)[ 'originalEnd' => '2026-03-23T18:00:00+00:00', ], ], ]; $hours = weather_route_invoke_private($route, 'calculateWorkfeedEmployeeHoursForHour', [$shifts, 'dep_1', $slot]); expect($hours)->toBe(0.28); }); it('does not count removed approved time when a saved shift end is shortened', function (): void { $route = new moduleWeatherAPIRoute(); $slot = new DateTime('2026-03-23T17:00:00+00:00'); $shifts = [ (object)[ 'departmentID' => 'dep_1', 'start' => '2026-03-23T10:00:00+00:00', 'end' => '2026-03-23T17:00:00+00:00', 'approval' => (object)[ 'originalEnd' => '2026-03-23T18:00:00+00:00', ], ], ]; $hours = weather_route_invoke_private($route, 'calculateWorkfeedEmployeeHoursForHour', [$shifts, 'dep_1', $slot]); expect($hours)->toBe(0.0); }); it('counts unapproved overtime from a bounded shift updateTime fallback', function (): void { $route = new moduleWeatherAPIRoute(); $slot = new DateTime('2026-03-23T17:00:00+00:00'); $shifts = [ (object)[ 'departmentID' => 'dep_1', 'start' => '2026-03-23T09:00:00+00:00', 'end' => '2026-03-23T17:00:00+00:00', 'approval' => null, 'updateTime' => '2026-03-23T17:15:00+00:00', ], ]; $hours = weather_route_invoke_private($route, 'calculateWorkfeedEmployeeHoursForHour', [$shifts, 'dep_1', $slot]); expect($hours)->toBe(0.25); }); it('does not treat late unapproved administrative edits as overtime', function (): void { $route = new moduleWeatherAPIRoute(); $slot = new DateTime('2026-03-23T17:00:00+00:00'); $shifts = [ (object)[ 'departmentID' => 'dep_1', 'start' => '2026-03-23T09:00:00+00:00', 'end' => '2026-03-23T17:00:00+00:00', 'approval' => null, 'updateTime' => '2026-03-24T02:30:00+00:00', ], ]; $hours = weather_route_invoke_private($route, 'calculateWorkfeedEmployeeHoursForHour', [$shifts, 'dep_1', $slot]); expect($hours)->toBe(0.0); }); it('caps current-slot hours to elapsed minutes and zeroes future slots', function (): void { $route = new moduleWeatherAPIRoute(); $currentSlot = new DateTime('2026-03-24T13:00:00+00:00'); $futureSlot = new DateTime('2026-03-24T14:00:00+00:00'); $occurredUntil = new DateTime('2026-03-24T13:15:00+00:00'); $shifts = [ (object)[ 'departmentID' => 'dep_1', 'checkIn' => (object)['time' => '2026-03-24T13:00:00+00:00'], 'checkOut' => null, 'end' => '2026-03-24T15:00:00+00:00', ], ]; $currentHours = weather_route_invoke_private($route, 'calculateWorkfeedEmployeeHoursForHour', [$shifts, 'dep_1', $currentSlot, $occurredUntil]); $futureHours = weather_route_invoke_private($route, 'calculateWorkfeedEmployeeHoursForHour', [$shifts, 'dep_1', $futureSlot, $occurredUntil]); expect($currentHours)->toBe(0.25); expect($futureHours)->toBe(0.0); }); it('normalizes department id input from scalar csv and nested array values', function (): void { $route = new moduleWeatherAPIRoute(); $single = weather_route_invoke_private($route, 'normalizeDepartmentIdInput', ['7']); $csv = weather_route_invoke_private($route, 'normalizeDepartmentIdInput', ['1, 2,3']); $nested = weather_route_invoke_private($route, 'normalizeDepartmentIdInput', [[1, '2,3', [4, '5']]]); expect($single)->toBe(['7']); expect($csv)->toBe(['1', '2', '3']); expect($nested)->toBe([1, '2', '3', 4, '5']); }); it('normalizes batched wash count rows into hourly slot totals', function (): void { $route = new moduleWeatherAPIRoute(); $rows = [ [ 'department_id' => 1, 'hour_bucket' => '2026-03-24 08:00:00', 'wash_count' => 3, ], [ 'department_id' => 2, 'hour_bucket' => '2026-03-24 08:15:00', 'wash_count' => 2, ], [ 'department_id' => 2, 'hour_bucket' => '2026-03-24 09:00:00', 'wash_count' => 4, ], ]; $counts = weather_route_invoke_private($route, 'normalizeDepartmentWashCountRows', [$rows]); expect($counts)->toBe([ '2026-03-24 08:00' => 5, '2026-03-24 09:00' => 4, ]); }); it('wires department weather route to use batched wash aggregation', function (): void { $routeContent = (string)file_get_contents(app_path('routes/moduleWeatherAPIRoute.php')); $ordersContent = (string)file_get_contents(app_path('objects/orders_o.php')); expect($routeContent)->toContain('loadDepartmentWashCountsBySlot'); expect($routeContent)->toContain('countWashesByHourForDepartments'); expect($ordersContent)->toContain('public function countWashesByHourForDepartments'); expect($ordersContent)->toContain('department_wash_count_service'); });