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'); +}); +