From c714af6c9e2ae45127cdaf8d0d18b814364f1d0b Mon Sep 17 00:00:00 2001 From: Jeppe Bundgaard Date: Tue, 24 Mar 2026 12:50:01 +0100 Subject: [PATCH] Add unit tests for Workfeed weather timeline range, schema conformance, and employee hours calculations. Enhance Workfeed API with improved department ID resolution, timeline range configuration, and workfeed shift handling. Update route logic to remove opening hours dependency and integrate Workfeed data. --- .../app/routes/moduleWeatherAPIRoute.php | 266 +++++++++++++++--- .../DepartmentWeatherTimelineRangeTest.php | 38 +++ ...ntWeatherTimelineSchemaConformanceTest.php | 36 +++ .../DepartmentWeatherWorkfeedHoursTest.php | 88 ++++++ 4 files changed, 390 insertions(+), 38 deletions(-) create mode 100644 services/nginx/app/tests/Unit/Workfeed/DepartmentWeatherTimelineRangeTest.php create mode 100644 services/nginx/app/tests/Unit/Workfeed/DepartmentWeatherTimelineSchemaConformanceTest.php create mode 100644 services/nginx/app/tests/Unit/Workfeed/DepartmentWeatherWorkfeedHoursTest.php diff --git a/services/nginx/app/routes/moduleWeatherAPIRoute.php b/services/nginx/app/routes/moduleWeatherAPIRoute.php index 53c8f614..88337159 100644 --- a/services/nginx/app/routes/moduleWeatherAPIRoute.php +++ b/services/nginx/app/routes/moduleWeatherAPIRoute.php @@ -6,10 +6,10 @@ use classes\authentication; use classes\response; use classes\router; use classes\weatherapi; +use classes\workfeed; use DateInterval; use DateTime; use Exception; -use objects\department_time_bookings_opening_hours_o; use objects\departments_o; use objects\logs_o; use objects\orders_o; @@ -102,11 +102,8 @@ class moduleWeatherAPIRoute return; } - $opening_hours = new department_time_bookings_opening_hours_o(); - $opening_hours->selectByDepartment((int)self::getParameter('id')); - $weather = (new weatherapi())->forecast($lat . ',' . $lon, 2); - $timeline = self::buildDepartmentWeatherTimeline((int)self::getParameter('id'), $weather, $opening_hours); + $timeline = self::buildDepartmentWeatherTimeline((int)self::getParameter('id'), $department, $weather); (new logs_o())->add('modules_weatherapi', (int)self::getParameter('id'), 1, $user->id, 'DEPARTMENTS_WEATHER_GET', 'Department weather timeline fetched'); $response->success($timeline, 200); @@ -119,7 +116,7 @@ class moduleWeatherAPIRoute /** * @throws Exception */ - private function buildDepartmentWeatherTimeline(int $department_id, object $forecast, department_time_bookings_opening_hours_o $opening_hours): array + private function buildDepartmentWeatherTimeline(int $department_id, departments_o $department, object $forecast): array { $hourly_weather = []; foreach (($forecast->forecast->forecastday ?? []) as $day) { @@ -129,18 +126,24 @@ class moduleWeatherAPIRoute } } - $entries = []; - $slot = new DateTime(date('Y-m-d H:00:00')); - $slot->add(new DateInterval('PT1H')); + $timeline_range = self::getDepartmentWeatherTimelineRange(); + $timeline_start = $timeline_range['start']; + $timeline_end_exclusive = $timeline_range['endExclusive']; - for ($i = 0; $i < 24; $i++) { + $entries = []; + $slot = clone $timeline_start; + $workfeed_hours_by_slot = self::loadWorkfeedDepartmentHoursBySlot($department, $timeline_start, $timeline_end_exclusive); + $current_slot_key = (new DateTime(date('Y-m-d H:00:00')))->format('Y-m-d H:00'); + + while ($slot < $timeline_end_exclusive) { $slot_key = $slot->format('Y-m-d H:00'); $weather = $hourly_weather[$slot_key] ?? 'mostly_clear'; - $is_open = self::isDepartmentOpenAtHour($opening_hours, $slot); - $hours = $is_open ? self::getDailyOpenHours($opening_hours, $slot) : 0; + $hours = (float)($workfeed_hours_by_slot[$slot_key] ?? 0.0); $washes = self::countWashesForHour($department_id, $slot); $entries[] = [ + 'date' => $slot->format('Y-m-d'), 'time' => $slot->format('H:00'), + 'current' => $slot_key === $current_slot_key, 'weather' => $weather, 'washes' => $washes, 'hours' => $hours, @@ -152,7 +155,7 @@ class moduleWeatherAPIRoute return $entries; } - private function calculateStatus(int $washes, int $hours): string + private function calculateStatus(int $washes, float $hours): string { if ($hours <= 0) { return 'unknown'; @@ -171,39 +174,226 @@ class moduleWeatherAPIRoute /** * @throws Exception */ - private function isDepartmentOpenAtHour(department_time_bookings_opening_hours_o $opening_hours, DateTime $date_time): bool + private function loadWorkfeedDepartmentHoursBySlot(departments_o $department, DateTime $timeline_start, DateTime $timeline_end_exclusive): array { - $weekday = strtolower($date_time->format('l')); - $start = $opening_hours->{"{$weekday}_start"}->value(); - $end = $opening_hours->{"{$weekday}_end"}->value(); - if ($start === null || $end === null) { - return false; + try { + $workfeed = new workfeed(); + $workfeed_department_id = self::resolveWorkfeedDepartmentId($department, $workfeed); + if ($workfeed_department_id === null) { + return []; + } + + $query_start = clone $timeline_start; + $query_start->sub(new DateInterval('P1D')); + $shifts_response = $workfeed->listShifts([ + 'startFrom' => $query_start->format(DateTime::ATOM), + 'startTo' => $timeline_end_exclusive->format(DateTime::ATOM), + ]); + $shifts = self::normalizeWorkfeedCollection($shifts_response); + if ($shifts === []) { + return []; + } + + $hours_by_slot = []; + $slot = clone $timeline_start; + while ($slot < $timeline_end_exclusive) { + $slot_key = $slot->format('Y-m-d H:00'); + $hours_by_slot[$slot_key] = self::calculateWorkfeedEmployeeHoursForHour($shifts, $workfeed_department_id, $slot); + $slot->add(new DateInterval('PT1H')); + } + + return $hours_by_slot; + } catch (Exception) { + return []; } - - $current = $date_time->format('H:i'); - $start_hour = (new DateTime((string)$start))->format('H:i'); - $end_hour = (new DateTime((string)$end))->format('H:i'); - - return $current >= $start_hour && $current < $end_hour; } - /** - * @throws Exception - */ - private function getDailyOpenHours(department_time_bookings_opening_hours_o $opening_hours, DateTime $date_time): int + private function getDepartmentWeatherTimelineRange(): array { - $weekday = strtolower($date_time->format('l')); - $start = $opening_hours->{"{$weekday}_start"}->value(); - $end = $opening_hours->{"{$weekday}_end"}->value(); - if ($start === null || $end === null) { - return 0; + $start = new DateTime(date('Y-m-d 00:00:00')); + $start->sub(new DateInterval('P1D')); + + $end_exclusive = new DateTime(date('Y-m-d 00:00:00')); + $end_exclusive->add(new DateInterval('P1D')); + + return [ + 'start' => $start, + 'endExclusive' => $end_exclusive, + ]; + } + + private function resolveWorkfeedDepartmentId(departments_o $department, workfeed $workfeed): ?string + { + $configured_id = self::getConfiguredWorkfeedDepartmentId($department); + if ($configured_id !== null) { + return $configured_id; } - $start_dt = new DateTime((string)$start); - $end_dt = new DateTime((string)$end); - $hours = ((int)$end_dt->format('U') - (int)$start_dt->format('U')) / 3600; + $department_name = trim((string)$department->name->value()); + if ($department_name === '') { + return null; + } - return max(0, (int)round($hours)); + $workfeed_departments = self::normalizeWorkfeedCollection($workfeed->listDepartments()); + if ($workfeed_departments === []) { + return null; + } + + return self::matchWorkfeedDepartmentIdByName($department_name, $workfeed_departments); + } + + private function getConfiguredWorkfeedDepartmentId(departments_o $department): ?string + { + $keys = [ + 'workfeed_department_id', + 'workfeedDepartmentId', + 'workfeed_departmentID', + 'workfeed_department', + ]; + + foreach ($keys as $key) { + $value = $department->variables->getVariable($key); + if (!is_string($value)) { + continue; + } + + $trimmed = trim($value); + if ($trimmed !== '') { + return $trimmed; + } + } + + return null; + } + + private function matchWorkfeedDepartmentIdByName(string $department_name, array $workfeed_departments): ?string + { + $needle = self::normalizeDepartmentName($department_name); + foreach ($workfeed_departments as $entry) { + $record = self::normalizeWorkfeedRecord($entry); + $name = trim((string)($record['name'] ?? '')); + $id = trim((string)($record['id'] ?? '')); + if ($name === '' || $id === '') { + continue; + } + + if (self::normalizeDepartmentName($name) === $needle) { + return $id; + } + } + + return null; + } + + private function normalizeDepartmentName(string $name): string + { + $collapsed = preg_replace('/\s+/', ' ', trim($name)); + + return strtolower($collapsed ?? trim($name)); + } + + private function normalizeWorkfeedCollection(array|object $payload): array + { + if (is_array($payload)) { + return $payload; + } + + foreach (['data', 'items', 'results', 'shifts', 'departments'] as $key) { + if (!isset($payload->$key)) { + continue; + } + + $value = $payload->$key; + if (is_array($value)) { + return $value; + } + if (is_object($value)) { + return array_values(get_object_vars($value)); + } + } + + return []; + } + + private function normalizeWorkfeedRecord(mixed $record): array + { + if (is_array($record)) { + return $record; + } + if (is_object($record)) { + return get_object_vars($record); + } + + return []; + } + + private function extractWorkfeedDepartmentId(mixed $shift): ?string + { + $record = self::normalizeWorkfeedRecord($shift); + + $department_id = $record['departmentID'] ?? $record['departmentId'] ?? null; + if (($department_id === null || $department_id === '') && isset($record['department'])) { + $department = self::normalizeWorkfeedRecord($record['department']); + $department_id = $department['id'] ?? $department['departmentID'] ?? $department['departmentId'] ?? null; + } + + if ($department_id === null) { + return null; + } + + $normalized = trim((string)$department_id); + + return $normalized === '' ? null : $normalized; + } + + private function parseDateTimeValue(mixed $value): ?DateTime + { + if (!is_string($value) || trim($value) === '') { + return null; + } + + try { + return new DateTime($value); + } catch (Exception) { + return null; + } + } + + private function calculateWorkfeedEmployeeHoursForHour(array $shifts, string $workfeed_department_id, DateTime $slot_start): float + { + $slot_end = clone $slot_start; + $slot_end->add(new DateInterval('PT1H')); + $slot_start_ts = $slot_start->getTimestamp(); + $slot_end_ts = $slot_end->getTimestamp(); + + $hours = 0.0; + foreach ($shifts as $shift) { + $shift_department_id = self::extractWorkfeedDepartmentId($shift); + if ($shift_department_id === null || $shift_department_id !== $workfeed_department_id) { + continue; + } + + $record = self::normalizeWorkfeedRecord($shift); + $shift_start = self::parseDateTimeValue($record['start'] ?? null); + $shift_end = self::parseDateTimeValue($record['end'] ?? null); + if ($shift_start === null || $shift_end === null) { + continue; + } + + $shift_start_ts = $shift_start->getTimestamp(); + $shift_end_ts = $shift_end->getTimestamp(); + if ($shift_end_ts <= $shift_start_ts) { + continue; + } + + $overlap_start = max($slot_start_ts, $shift_start_ts); + $overlap_end = min($slot_end_ts, $shift_end_ts); + if ($overlap_end > $overlap_start) { + $hours += ($overlap_end - $overlap_start) / 3600; + } + } + + return round($hours, 2); } /** diff --git a/services/nginx/app/tests/Unit/Workfeed/DepartmentWeatherTimelineRangeTest.php b/services/nginx/app/tests/Unit/Workfeed/DepartmentWeatherTimelineRangeTest.php new file mode 100644 index 00000000..54e39f4e --- /dev/null +++ b/services/nginx/app/tests/Unit/Workfeed/DepartmentWeatherTimelineRangeTest.php @@ -0,0 +1,38 @@ +getMethod($method); + $target->setAccessible(true); + + return $target->invokeArgs($route, $args); +} + +beforeEach(function (): void { + $_SERVER['REQUEST_URI'] = '/departments/weather'; +}); + +it('builds a timeline range from start of yesterday to end of today', function (): void { + $route = new moduleWeatherAPIRoute(); + $range = weather_timeline_range_invoke_private($route, 'getDepartmentWeatherTimelineRange'); + + $expectedStart = new DateTime(date('Y-m-d 00:00:00')); + $expectedStart->sub(new DateInterval('P1D')); + + $expectedEndExclusive = new DateTime(date('Y-m-d 00:00:00')); + $expectedEndExclusive->add(new DateInterval('P1D')); + + expect($range['start'] instanceof DateTime)->toBeTrue(); + expect($range['endExclusive'] instanceof DateTime)->toBeTrue(); + expect($range['start']->format('Y-m-d H:i:s'))->toBe($expectedStart->format('Y-m-d H:i:s')); + expect($range['endExclusive']->format('Y-m-d H:i:s'))->toBe($expectedEndExclusive->format('Y-m-d H:i:s')); + + $hourCount = (int)(($range['endExclusive']->getTimestamp() - $range['start']->getTimestamp()) / 3600); + expect($hourCount)->toBe(48); +}); + diff --git a/services/nginx/app/tests/Unit/Workfeed/DepartmentWeatherTimelineSchemaConformanceTest.php b/services/nginx/app/tests/Unit/Workfeed/DepartmentWeatherTimelineSchemaConformanceTest.php new file mode 100644 index 00000000..797715bc --- /dev/null +++ b/services/nginx/app/tests/Unit/Workfeed/DepartmentWeatherTimelineSchemaConformanceTest.php @@ -0,0 +1,36 @@ +toContain("'date' => \$slot->format('Y-m-d')"); + expect($content)->toContain("'current' => \$slot_key === \$current_slot_key"); +}); + +it('documents the date key in the openapi department weather timeline schema', function (): void { + $candidates = [ + WD . '/openapi.yaml', + dirname(WD) . '/openapi.yaml', + dirname(WD, 2) . '/openapi.yaml', + ]; + + $openApiFile = null; + foreach ($candidates as $candidate) { + if (is_file($candidate)) { + $openApiFile = $candidate; + break; + } + } + + if ($openApiFile === null) { + $this->markTestSkipped('openapi.yaml is not mounted in this test container.'); + } + + $content = (string)file_get_contents($openApiFile); + + expect($content)->toContain('DepartmentWeatherTimelineEntry:'); + expect($content)->toContain('format: date'); + expect($content)->toContain('type: boolean'); + expect($content)->toContain('required: [date, time, current, weather, washes, hours, status]'); +}); diff --git a/services/nginx/app/tests/Unit/Workfeed/DepartmentWeatherWorkfeedHoursTest.php b/services/nginx/app/tests/Unit/Workfeed/DepartmentWeatherWorkfeedHoursTest.php new file mode 100644 index 00000000..4197e58c --- /dev/null +++ b/services/nginx/app/tests/Unit/Workfeed/DepartmentWeatherWorkfeedHoursTest.php @@ -0,0 +1,88 @@ +getMethod($method); + $target->setAccessible(true); + + 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', + 'start' => '2026-03-24T13:00:00+00:00', + 'end' => '2026-03-24T14:00:00+00:00', + ], + (object)[ + 'departmentID' => 'dep_1', + 'start' => '2026-03-24T13:30:00+00:00', + 'end' => '2026-03-24T15:00:00+00:00', + ], + (object)[ + 'departmentID' => 'dep_other', + 'start' => '2026-03-24T13:00:00+00:00', + 'end' => '2026-03-24T14:00:00+00:00', + ], + (object)[ + 'departmentID' => 'dep_1', + 'start' => '2026-03-24T14: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('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'); +}); +