0, 'healthy' => 1, 'degraded' => 2, 'unhealthy' => 3, ]; public function run(): void { global /** @var response $response */ /** @var router $router */ $router, $response; $this->get('/modules/weatherapi/current', function () { global $response; self::requirePermission('modules_weatherapi_current'); $user = (new authentication())->get_user(); if (!$user) { $response->error('Invalid session', 400); return; } self::requireParameters(['q']); self::requireType('q', self::type_string()); $result = (new weatherapi())->current(self::getParameter('q')); (new logs_o())->add('modules_weatherapi', 'global', 1, $user->id, 'MODULES_WEATHERAPI', 'Current weather request completed'); $response->success($result, 200); }, [ 'modules_weatherapi_current' => 'Get current weather data from WeatherAPI', ]); $this->get('/modules/weatherapi/forecast', function () { global $response; self::requirePermission('modules_weatherapi_forecast'); $user = (new authentication())->get_user(); if (!$user) { $response->error('Invalid session', 400); return; } self::requireParameters(['q']); self::requireType('q', self::type_string()); $days = (int)(self::getParameter('days') ?? 1); $result = (new weatherapi())->forecast(self::getParameter('q'), $days); (new logs_o())->add('modules_weatherapi', 'global', 1, $user->id, 'MODULES_WEATHERAPI', 'Forecast weather request completed'); $response->success($result, 200); }, [ 'modules_weatherapi_forecast' => 'Get weather forecast data from WeatherAPI', ]); $this->get('/modules/weatherapi/search', function () { global $response; self::requirePermission('modules_weatherapi_search'); $user = (new authentication())->get_user(); if (!$user) { $response->error('Invalid session', 400); return; } self::requireParameters(['q']); self::requireType('q', self::type_string()); $result = (new weatherapi())->search(self::getParameter('q')); (new logs_o())->add('modules_weatherapi', 'global', 1, $user->id, 'MODULES_WEATHERAPI', 'Weather location search completed'); $response->success($result, 200); }, [ 'modules_weatherapi_search' => 'Search location data from WeatherAPI', ]); $this->get('/departments/weather', function () { global $response; self::requirePermission('departments_weather_get'); $user = (new authentication())->get_user(); if (!$user) { $response->error('Invalid session', 400); return; } $department_ids = self::parseDepartmentIdsFromRequest(); foreach ($department_ids as $department_id) { self::requireDepartmentAccess((string)$department_id); } $selected_date_range = self::parseSelectedTimelineDateRangeFromRequest(); $timeline_range = self::getDepartmentWeatherTimelineRange( $selected_date_range['date_from'] ?? null, $selected_date_range['date_to'] ?? null ); $departments = self::loadDepartmentsByIds($department_ids); $status_targets_by_department = $this->loadDepartmentWeatherTargetsByDepartmentId($departments); $coordinates = self::resolveWeatherCoordinates($departments); $department_context = count($department_ids) === 1 ? (string)$department_ids[0] : implode(',', $department_ids); $timeline = $this->withCachedDepartmentWeatherTimeline( $department_ids, $timeline_range, function () use ($coordinates, $department_context, $department_ids, $departments, $timeline_range, $user, $status_targets_by_department): array { $weather_days = $this->resolveForecastDaysForTimelineRange($timeline_range['start'], $timeline_range['endExclusive']); $forecast_result = $this->fetchDepartmentForecastOrFallback($coordinates, $weather_days); if (is_string($forecast_result['fallback_reason'])) { $fallback_message = match ($forecast_result['fallback_reason']) { 'invalid_department_coordinates' => 'Department weather fallback used due to missing or invalid department coordinates', 'weatherapi_location_not_found' => 'Department weather fallback used because WeatherAPI could not resolve the selected department location', default => 'Department weather fallback used due to location lookup failure', }; (new logs_o())->add('modules_weatherapi', $department_context, 1, $user->id, 'DEPARTMENTS_WEATHER_FALLBACK', $fallback_message); } return $this->buildDepartmentWeatherTimeline( $department_ids, $departments, $forecast_result['forecast'], $timeline_range, $status_targets_by_department ); }, true, $status_targets_by_department ); (new logs_o())->add('modules_weatherapi', $department_context, 1, $user->id, 'DEPARTMENTS_WEATHER_GET', 'Department weather timeline fetched'); $response->success($timeline, 200); }, [ 'departments_weather_get' => 'Get department weather timeline with washes and productivity status', 'department_access_:id' => 'Access weather timeline for one or more specific departments', ]); $this->get('/departments/weather/targets', function () { global $response; self::requirePermission('departments_weather_targets_get'); $user = (new authentication())->get_user(); if (!$user) { $response->error('Invalid session', 400); return; } $department_ids = self::parseDepartmentIdsFromRequest(); foreach ($department_ids as $department_id) { self::requireDepartmentAccess((string)$department_id); } $departments = self::loadDepartmentsByIds($department_ids); $targets_by_department = $this->loadDepartmentWeatherTargetsByDepartmentId($departments); $department_context = count($department_ids) === 1 ? (string)$department_ids[0] : implode(',', $department_ids); $data = []; foreach ($department_ids as $department_id) { $target = $this->normalizeDepartmentWeatherTarget($targets_by_department[$department_id] ?? null); $data[] = self::buildDepartmentWeatherTargetResponse($department_id, $target); } (new logs_o())->add('modules_weatherapi', $department_context, 1, $user->id, 'DEPARTMENTS_WEATHER_TARGETS_GET', 'Department weather targets fetched'); $response->success($data, 200); }, [ 'departments_weather_targets_get' => 'Get department weather productivity thresholds', 'department_access_:id' => 'Access weather targets for one or more specific departments', ]); $this->put('/departments/weather/targets', function () { global $response; self::requirePermission('departments_weather_targets_manage'); $user = (new authentication())->get_user(); if (!$user) { $response->error('Invalid session', 400); return; } self::requireParameters(['department_id', 'degraded_threshold', 'healthy_threshold']); $department_id_raw = self::getParameter('department_id'); if (!is_scalar($department_id_raw)) { $response->error('Invalid department_id value', 400); } $department_id_value = trim((string)$department_id_raw); if (!preg_match('/^\d+$/', $department_id_value)) { $response->error('Invalid department_id: ' . $department_id_value, 400); } $department_id = (int)$department_id_value; if ($department_id < 1) { $response->error('department_id must be at least 1', 400); } self::requireDepartmentAccess((string)$department_id); $degraded_threshold = self::normalizeDepartmentWeatherThresholdValue(self::getParameter('degraded_threshold')); if ($degraded_threshold === null) { $response->error('degraded_threshold must be a non-negative number', 400); } $healthy_threshold = self::normalizeDepartmentWeatherThresholdValue(self::getParameter('healthy_threshold')); if ($healthy_threshold === null) { $response->error('healthy_threshold must be a non-negative number', 400); } if ($healthy_threshold < $degraded_threshold) { $response->error('healthy_threshold must be greater than or equal to degraded_threshold', 400); } $department = (new departments_o())->select($department_id); if (!$department->exists()) { $response->error('Department not found', 404); } $department->variables->set(self::DEPARTMENT_WEATHER_TARGET_DEGRADED_KEY, self::formatDepartmentWeatherThreshold($degraded_threshold)); $department->variables->set(self::DEPARTMENT_WEATHER_TARGET_HEALTHY_KEY, self::formatDepartmentWeatherThreshold($healthy_threshold)); $target = [ 'degraded_threshold' => $degraded_threshold, 'healthy_threshold' => $healthy_threshold, ]; (new logs_o())->add('modules_weatherapi', (string)$department_id, 1, $user->id, 'DEPARTMENTS_WEATHER_TARGETS_UPDATE', 'Department weather targets updated'); $response->success(self::buildDepartmentWeatherTargetResponse($department_id, $target), 200); }, [ 'departments_weather_targets_manage' => 'Manage department weather productivity thresholds', 'department_access_:id' => 'Manage weather targets for a specific department', ]); } /** * Response cache TTL (seconds) for department weather timeline endpoint. * Set `DEPARTMENTS_WEATHER_CACHE_TTL` to override. */ private function getDepartmentWeatherCacheTtl(): int { $raw = getenv('DEPARTMENTS_WEATHER_CACHE_TTL'); if ($raw === false || trim((string)$raw) === '') { return 60; } return max(0, (int)$raw); } private function getDepartmentWeatherStaleCacheTtl(int $fresh_ttl): int { if ($fresh_ttl <= 0) { return 0; } $raw = getenv('DEPARTMENTS_WEATHER_STALE_TTL'); if ($raw === false || trim((string)$raw) === '') { return max($fresh_ttl, 300); } return max($fresh_ttl, max(0, (int)$raw)); } private static function getDepartmentWeatherHotActivityTtl(): int { $raw = getenv('DEPARTMENTS_WEATHER_PRELOAD_HOT_TTL'); if ($raw === false || trim((string)$raw) === '') { return 900; } return max(1, (int)$raw); } private function getDepartmentWeatherCacheKey(array $department_ids, array $timeline_range, array $status_targets_by_department = []): string { $normalized_ids = array_values(array_unique(array_map(static function (mixed $department_id): int { return (int)$department_id; }, $department_ids))); sort($normalized_ids, SORT_NUMERIC); $normalized_targets = $this->normalizeDepartmentWeatherTargetsByDepartmentId($normalized_ids, $status_targets_by_department); $start = ($timeline_range['start'] ?? null) instanceof DateTime ? $timeline_range['start']->format('Y-m-d H:i:s') : ''; $end_exclusive = ($timeline_range['endExclusive'] ?? null) instanceof DateTime ? $timeline_range['endExclusive']->format('Y-m-d H:i:s') : ''; return 'departments_weather:timeline:v2:' . md5((string)json_encode([ 'department_ids' => $normalized_ids, 'start' => $start, 'end_exclusive' => $end_exclusive, 'targets' => $this->buildDepartmentWeatherTargetsCacheKeyPayload($normalized_ids, $normalized_targets), ], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES)); } private static function getDepartmentWeatherHotDescriptorKey(string $hash): string { return self::DEPARTMENT_WEATHER_HOT_DESCRIPTOR_PREFIX . $hash; } private function buildDepartmentWeatherHotDescriptor(array $department_ids, array $timeline_range): ?array { $normalized_ids = $this->normalizeDepartmentIdsForCachePreload($department_ids); if ($normalized_ids === []) { return null; } $start = ($timeline_range['start'] ?? null) instanceof DateTime ? $timeline_range['start']->format('Y-m-d H:i:s') : null; $end_exclusive = ($timeline_range['endExclusive'] ?? null) instanceof DateTime ? $timeline_range['endExclusive']->format('Y-m-d H:i:s') : null; if (!is_string($start) || !is_string($end_exclusive) || $start === '' || $end_exclusive === '') { return null; } return [ 'department_ids' => $normalized_ids, 'start' => $start, 'end_exclusive' => $end_exclusive, ]; } private function normalizeDepartmentWeatherHotDescriptor(array $descriptor): ?array { $department_ids = $this->normalizeDepartmentIdsForCachePreload((array)($descriptor['department_ids'] ?? [])); if ($department_ids === []) { return null; } $start_raw = $descriptor['start'] ?? null; $end_raw = $descriptor['end_exclusive'] ?? null; if (!is_string($start_raw) || !is_string($end_raw)) { return null; } try { $start = new DateTime($start_raw); $end_exclusive = new DateTime($end_raw); } catch (Exception) { return null; } if ($end_exclusive <= $start) { return null; } return [ 'department_ids' => $department_ids, 'timeline_range' => [ 'start' => $start, 'endExclusive' => $end_exclusive, ], ]; } private function upsertDepartmentWeatherHotDescriptor(array $descriptor, int $score): ?string { if (!defined('redis')) { return null; } $encoded = json_encode($descriptor, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); if (!is_string($encoded) || $encoded === '') { return null; } $hash = md5($encoded); $activity_ttl = self::getDepartmentWeatherHotActivityTtl(); $cutoff = (string)($score - $activity_ttl); try { redis->setEx(self::getDepartmentWeatherHotDescriptorKey($hash), $encoded, $activity_ttl); $client = redis->get_client(); $client->zadd(self::DEPARTMENT_WEATHER_HOT_ACTIVITY_ZSET_KEY, $score, $hash); $client->zremrangebyscore(self::DEPARTMENT_WEATHER_HOT_ACTIVITY_ZSET_KEY, '-inf', $cutoff); } catch (Throwable) { return null; } return $hash; } private function recordDepartmentWeatherHotRequest(array $department_ids, array $timeline_range): ?string { $descriptor = $this->buildDepartmentWeatherHotDescriptor($department_ids, $timeline_range); if ($descriptor === null) { return null; } return $this->upsertDepartmentWeatherHotDescriptor($descriptor, time()); } private function enqueueDepartmentWeatherRefreshSignal(string $cache_key, array $department_ids, array $timeline_range): void { if (!defined('redis')) { return; } $descriptor = $this->buildDepartmentWeatherHotDescriptor($department_ids, $timeline_range); if ($descriptor === null) { return; } $hash = $this->upsertDepartmentWeatherHotDescriptor($descriptor, time()); if (!is_string($hash) || $hash === '') { return; } try { $lock_key = self::DEPARTMENT_WEATHER_REFRESH_LOCK_PREFIX . md5($cache_key); if (!redis->set_if_absent_with_expiration($lock_key, '1', 30)) { return; } $activity_ttl = self::getDepartmentWeatherHotActivityTtl(); $cutoff = (string)(time() - $activity_ttl); $client = redis->get_client(); $client->zadd(self::DEPARTMENT_WEATHER_REFRESH_QUEUE_ZSET_KEY, time(), $hash); $client->zremrangebyscore(self::DEPARTMENT_WEATHER_REFRESH_QUEUE_ZSET_KEY, '-inf', $cutoff); } catch (Throwable) { // Best-effort refresh signal enqueue. } } private function decodeDepartmentWeatherCachePayload(string $cached): ?array { $decoded = json_decode($cached, true); if (!is_array($decoded)) { return null; } if (isset($decoded['timeline']) && is_array($decoded['timeline'])) { $generated_at = null; if (isset($decoded['generated_at']) && is_numeric($decoded['generated_at'])) { $generated_at = max(0, (int)$decoded['generated_at']); } return [ 'generated_at' => $generated_at, 'timeline' => $decoded['timeline'], ]; } return [ 'generated_at' => null, 'timeline' => $decoded, ]; } private function encodeDepartmentWeatherCachePayload(array $timeline): ?string { $encoded = json_encode([ 'generated_at' => time(), 'timeline' => $timeline, ], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); return is_string($encoded) ? $encoded : null; } /** * Best-effort Redis cache wrapper for department weather timeline payloads. * Falls back to direct computation when Redis is unavailable or TTL is disabled. * * @param callable():array $resolver * @return array */ private function withCachedDepartmentWeatherTimeline( array $department_ids, array $timeline_range, callable $resolver, bool $record_hot_key = true, array $status_targets_by_department = [] ): array { $fresh_ttl = $this->getDepartmentWeatherCacheTtl(); if ($fresh_ttl <= 0 || !defined('redis')) { return (array)$resolver(); } $stale_ttl = $this->getDepartmentWeatherStaleCacheTtl($fresh_ttl); $cache_key = $this->getDepartmentWeatherCacheKey($department_ids, $timeline_range, $status_targets_by_department); if ($record_hot_key) { $this->recordDepartmentWeatherHotRequest($department_ids, $timeline_range); } try { $cached = redis->get($cache_key); if (is_string($cached) && $cached !== '') { $decoded = $this->decodeDepartmentWeatherCachePayload($cached); if (is_array($decoded) && isset($decoded['timeline']) && is_array($decoded['timeline'])) { $age_seconds = $decoded['generated_at'] === null ? ($fresh_ttl + 1) : max(0, time() - (int)$decoded['generated_at']); if ($age_seconds <= $stale_ttl) { if ($age_seconds > $fresh_ttl && $record_hot_key) { $this->enqueueDepartmentWeatherRefreshSignal($cache_key, $department_ids, $timeline_range); } return $decoded['timeline']; } } } } catch (Throwable) { // Best-effort cache read. } $result = (array)$resolver(); try { $encoded = $this->encodeDepartmentWeatherCachePayload($result); if (is_string($encoded)) { redis->setEx($cache_key, $encoded, $stale_ttl); } } catch (Throwable) { // Best-effort cache write. } return $result; } /** * @return array,timeline_range:array{start:DateTime,endExclusive:DateTime}}> */ public static function getDepartmentWeatherHotPreloadTargets(int $limit, int $activity_ttl): array { if (!defined('redis')) { return []; } $limit = max(1, $limit); $activity_ttl = max(1, $activity_ttl); $route = new self(); $targets = []; $seen_hashes = []; try { $client = redis->get_client(); $cutoff = (string)(time() - $activity_ttl); $client->zremrangebyscore(self::DEPARTMENT_WEATHER_HOT_ACTIVITY_ZSET_KEY, '-inf', $cutoff); $client->zremrangebyscore(self::DEPARTMENT_WEATHER_REFRESH_QUEUE_ZSET_KEY, '-inf', $cutoff); $candidate_hashes = []; foreach ((array)$client->zrevrange(self::DEPARTMENT_WEATHER_REFRESH_QUEUE_ZSET_KEY, 0, max(0, $limit - 1)) as $hash) { $hash = trim((string)$hash); if ($hash === '' || isset($seen_hashes[$hash])) { continue; } $seen_hashes[$hash] = true; $candidate_hashes[] = $hash; $client->zrem(self::DEPARTMENT_WEATHER_REFRESH_QUEUE_ZSET_KEY, $hash); } if (count($candidate_hashes) < $limit) { $spill_limit = max($limit * 5, $limit); foreach ((array)$client->zrevrange(self::DEPARTMENT_WEATHER_HOT_ACTIVITY_ZSET_KEY, 0, max(0, $spill_limit - 1)) as $hash) { $hash = trim((string)$hash); if ($hash === '' || isset($seen_hashes[$hash])) { continue; } $seen_hashes[$hash] = true; $candidate_hashes[] = $hash; if (count($candidate_hashes) >= $spill_limit) { break; } } } foreach ($candidate_hashes as $hash) { $payload = redis->get(self::getDepartmentWeatherHotDescriptorKey($hash)); if (!is_string($payload) || $payload === '') { continue; } $decoded = json_decode($payload, true); if (!is_array($decoded)) { continue; } $target = $route->normalizeDepartmentWeatherHotDescriptor($decoded); if ($target === null) { continue; } $targets[] = $target; if (count($targets) >= $limit) { break; } } } catch (Throwable) { return []; } return $targets; } /** * Pre-compute and cache a department weather timeline for background preloading jobs. * * @return array{warmed:bool,department_ids:array,cache_key:string|null,entries:int} * @throws Exception */ public static function preloadDepartmentWeatherTimelineCache( array $department_ids, ?string $date_from = null, ?string $date_to = null, ?array $timeline_range_override = null ): array { $route = new self(); $normalized_ids = $route->normalizeDepartmentIdsForCachePreload($department_ids); if ($normalized_ids === []) { return [ 'warmed' => false, 'department_ids' => [], 'cache_key' => null, 'entries' => 0, ]; } if ( is_array($timeline_range_override) && ($timeline_range_override['start'] ?? null) instanceof DateTime && ($timeline_range_override['endExclusive'] ?? null) instanceof DateTime ) { $timeline_range = [ 'start' => clone $timeline_range_override['start'], 'endExclusive' => clone $timeline_range_override['endExclusive'], ]; } else { if (($date_from === null) xor ($date_to === null)) { $date_from = null; $date_to = null; } $timeline_range = $route->getDepartmentWeatherTimelineRange($date_from, $date_to); } $departments = $route->loadDepartmentsByIds($normalized_ids); $status_targets_by_department = $route->loadDepartmentWeatherTargetsByDepartmentId($departments); $coordinates = $route->resolveWeatherCoordinates($departments); $timeline = $route->withCachedDepartmentWeatherTimeline( $normalized_ids, $timeline_range, function () use ($coordinates, $normalized_ids, $departments, $timeline_range, $status_targets_by_department, $route): array { $weather_days = $route->resolveForecastDaysForTimelineRange($timeline_range['start'], $timeline_range['endExclusive']); $forecast_result = $route->fetchDepartmentForecastOrFallback($coordinates, $weather_days); return $route->buildDepartmentWeatherTimeline( $normalized_ids, $departments, $forecast_result['forecast'], $timeline_range, $status_targets_by_department ); }, false, $status_targets_by_department ); return [ 'warmed' => true, 'department_ids' => $normalized_ids, 'cache_key' => $route->getDepartmentWeatherCacheKey($normalized_ids, $timeline_range, $status_targets_by_department), 'entries' => count($timeline), ]; } /** * @param array $department_ids * @return array */ private function normalizeDepartmentIdsForCachePreload(array $department_ids): array { $normalized = []; foreach ($department_ids as $department_id) { if (!is_numeric($department_id)) { continue; } $value = (int)$department_id; if ($value < 1) { continue; } $normalized[$value] = true; } $ids = array_map('intval', array_keys($normalized)); sort($ids, SORT_NUMERIC); return $ids; } private function parseDepartmentIdsFromRequest(): array { global $response; $parameters = self::getParametersAsArray(); $raw_ids = []; if (array_key_exists('id', $parameters)) { $raw_ids = array_merge($raw_ids, self::normalizeDepartmentIdInput($parameters['id'])); } if (array_key_exists('ids', $parameters)) { $raw_ids = array_merge($raw_ids, self::normalizeDepartmentIdInput($parameters['ids'])); } if ($raw_ids === []) { $response->error('Missing required parameters: id', 400); } $department_ids = []; foreach ($raw_ids as $raw_id) { if (is_array($raw_id) || is_object($raw_id)) { $response->error('Invalid department id value', 400); } $value = trim((string)$raw_id); if ($value === '') { continue; } if (!preg_match('/^\d+$/', $value)) { $response->error('Invalid department id: ' . $value, 400); } $department_id = (int)$value; if ($department_id < 1) { $response->error('Parameter must be at least 1', 400); } $department_ids[] = $department_id; } $department_ids = array_values(array_unique($department_ids)); if ($department_ids === []) { $response->error('Missing required parameters: id', 400); } return $department_ids; } private function normalizeDepartmentIdInput(mixed $value): array { if (is_array($value)) { $result = []; foreach ($value as $item) { $result = array_merge($result, self::normalizeDepartmentIdInput($item)); } return $result; } if (is_string($value) && str_contains($value, ',')) { $parts = array_map('trim', explode(',', $value)); return array_values(array_filter($parts, static function (string $part): bool { return $part !== ''; })); } return [$value]; } private function parseSelectedTimelineDateRangeFromRequest(): ?array { $parameters = self::getParametersAsArray(); $has_date_from = array_key_exists('date_from', $parameters); $has_date_to = array_key_exists('date_to', $parameters); if (!$has_date_from && !$has_date_to) { return null; } global $response; if (!$has_date_from || !$has_date_to) { $missing = []; if (!$has_date_from) { $missing[] = 'date_from'; } if (!$has_date_to) { $missing[] = 'date_to'; } $response->error('Missing required parameters: ' . implode(', ', $missing), 400); } $date_from_value = self::getParameter('date_from'); $date_to_value = self::getParameter('date_to'); if (!is_string($date_from_value) || !is_string($date_to_value)) { $response->error('Invalid type. Expected: string for date_from/date_to', 400); } $date_from = trim((string)$date_from_value); $date_to = trim((string)$date_to_value); self::requireDateFormat($date_from, self::FORMAT_DATE()); self::requireDateFormat($date_to, self::FORMAT_DATE()); $from_dt = new DateTime($date_from . ' 00:00:00'); $to_dt = new DateTime($date_to . ' 00:00:00'); if ($to_dt < $from_dt) { $response->error('Invalid date range. date_to must be on or after date_from', 400); } return [ 'date_from' => $date_from, 'date_to' => $date_to, ]; } private function loadDepartmentsByIds(array $department_ids): array { $departments = []; foreach ($department_ids as $department_id) { $departments[] = (new departments_o())->select((int)$department_id); } return $departments; } private static function normalizeDepartmentWeatherThresholdValue(mixed $value): ?float { if (is_string($value)) { $value = trim($value); if ($value === '') { return null; } } if (!is_numeric($value)) { return null; } $threshold = (float)$value; if (!is_finite($threshold) || $threshold < 0) { return null; } return round($threshold, 6); } private static function formatDepartmentWeatherThreshold(float $threshold): string { $formatted = number_format($threshold, 6, '.', ''); $trimmed = rtrim(rtrim($formatted, '0'), '.'); return $trimmed === '' ? '0' : $trimmed; } private function normalizeDepartmentWeatherTarget(mixed $target): ?array { if (!is_array($target)) { return null; } $degraded_threshold = self::normalizeDepartmentWeatherThresholdValue($target['degraded_threshold'] ?? null); $healthy_threshold = self::normalizeDepartmentWeatherThresholdValue($target['healthy_threshold'] ?? null); if ($degraded_threshold === null || $healthy_threshold === null) { return null; } if ($healthy_threshold < $degraded_threshold) { return null; } return [ 'degraded_threshold' => $degraded_threshold, 'healthy_threshold' => $healthy_threshold, ]; } private function normalizeDepartmentWeatherTargetsByDepartmentId(array $department_ids, array $status_targets_by_department): array { $normalized = []; foreach ($department_ids as $department_id) { $normalized_department_id = (int)$department_id; if ($normalized_department_id < 1) { continue; } $target = $this->normalizeDepartmentWeatherTarget($status_targets_by_department[$normalized_department_id] ?? null); if ($target !== null) { $normalized[$normalized_department_id] = $target; } } ksort($normalized, SORT_NUMERIC); return $normalized; } private function loadDepartmentWeatherTargetsByDepartmentId(array $departments): array { $targets_by_department = []; foreach ($departments as $department) { $department_id = (int)($department->id ?? 0); if ($department_id < 1) { continue; } $target = $this->normalizeDepartmentWeatherTarget([ 'degraded_threshold' => $department->variables->getVariable(self::DEPARTMENT_WEATHER_TARGET_DEGRADED_KEY) ?? self::DEFAULT_DEPARTMENT_WEATHER_DEGRADED_THRESHOLD, 'healthy_threshold' => $department->variables->getVariable(self::DEPARTMENT_WEATHER_TARGET_HEALTHY_KEY) ?? self::DEFAULT_DEPARTMENT_WEATHER_HEALTHY_THRESHOLD, ]); if ($target !== null) { $targets_by_department[$department_id] = $target; } } ksort($targets_by_department, SORT_NUMERIC); return $targets_by_department; } private static function buildDepartmentWeatherTargetResponse(int $department_id, ?array $target): array { return [ 'department_id' => $department_id, 'degraded_threshold' => $target['degraded_threshold'] ?? null, 'healthy_threshold' => $target['healthy_threshold'] ?? null, 'configured' => is_array($target), ]; } private function buildDepartmentWeatherTargetsCacheKeyPayload(array $department_ids, array $status_targets_by_department): array { $payload = []; foreach ($department_ids as $department_id) { $normalized_department_id = (int)$department_id; if ($normalized_department_id < 1) { continue; } $target = $this->normalizeDepartmentWeatherTarget($status_targets_by_department[$normalized_department_id] ?? null); $payload[(string)$normalized_department_id] = $target; } ksort($payload, SORT_STRING); return $payload; } private static function sumDepartmentHoursForSlot(array $department_ids, array $values_by_department_and_slot, string $slot_key): float { $total = 0.0; foreach ($department_ids as $department_id) { $normalized_department_id = (int)$department_id; if ($normalized_department_id < 1) { continue; } $total += (float)($values_by_department_and_slot[$normalized_department_id][$slot_key] ?? 0.0); } return $total; } private static function sumDepartmentWashesForSlot(array $department_ids, array $values_by_department_and_slot, string $slot_key): int { $total = 0; foreach ($department_ids as $department_id) { $normalized_department_id = (int)$department_id; if ($normalized_department_id < 1) { continue; } $total += (int)($values_by_department_and_slot[$normalized_department_id][$slot_key] ?? 0); } return $total; } private function resolveWeatherCoordinates(array $departments): ?array { $lat_sum = 0.0; $lon_sum = 0.0; $count = 0; foreach ($departments as $department) { $lat = self::normalizeCoordinateValue($department->latitude->value(), -90.0, 90.0); $lon = self::normalizeCoordinateValue($department->longitude->value(), -180.0, 180.0); if ($lat === null || $lon === null) { continue; } if ($lat === 0.0 && $lon === 0.0) { continue; } $lat_sum += $lat; $lon_sum += $lon; $count++; } if ($count === 0) { return null; } return [ 'lat' => $lat_sum / $count, 'lon' => $lon_sum / $count, ]; } /** * @throws Exception */ private function fetchDepartmentForecastOrFallback(?array $coordinates, int $weather_days): array { if ($coordinates === null) { return [ 'forecast' => self::createEmptyForecastPayload(), 'fallback_reason' => 'invalid_department_coordinates', ]; } try { return [ 'forecast' => (new weatherapi())->forecast($coordinates['lat'] . ',' . $coordinates['lon'], $weather_days), 'fallback_reason' => null, ]; } catch (Exception $exception) { if (!self::isWeatherLocationLookupFailure($exception)) { throw $exception; } return [ 'forecast' => self::createEmptyForecastPayload(), 'fallback_reason' => 'weatherapi_location_not_found', ]; } } private function createEmptyForecastPayload(): object { return (object)[ 'forecast' => (object)[ 'forecastday' => [], ], ]; } private function isWeatherLocationLookupFailure(Exception $exception): bool { $message = strtolower(trim($exception->getMessage())); return str_contains($message, 'weatherapi request failed with http 400') && str_contains($message, 'no matching location found'); } private function normalizeCoordinateValue(mixed $value, float $min, float $max): ?float { if ($value === null) { return null; } if (is_string($value)) { $value = trim($value); if ($value === '') { return null; } } if (!is_numeric($value)) { return null; } $coordinate = (float)$value; if (!is_finite($coordinate)) { return null; } if ($coordinate < $min || $coordinate > $max) { return null; } return $coordinate; } /** * @throws Exception */ private function buildDepartmentWeatherTimeline( array $department_ids, array $departments, object $forecast, array $timeline_range, array $status_targets_by_department = [] ): array { $hourly_weather = []; foreach (($forecast->forecast->forecastday ?? []) as $day) { foreach (($day->hour ?? []) as $hour) { $key = (new DateTime((string)$hour->time))->format('Y-m-d H:00'); $hourly_weather[$key] = self::mapWeatherCondition((int)($hour->condition->code ?? 1000), (string)($hour->condition->text ?? '')); } } $timeline_start = $timeline_range['start']; $timeline_end_exclusive = $timeline_range['endExclusive']; $normalized_targets = $this->normalizeDepartmentWeatherTargetsByDepartmentId($department_ids, $status_targets_by_department); $normalized_department_ids = $this->normalizeDepartmentIdsForCachePreload($department_ids); $entries = []; $slot = clone $timeline_start; $workfeed_hours_by_department_and_slot = $departments === [] ? [] : self::loadWorkfeedDepartmentHoursBySlotByDepartment($departments, $timeline_start, $timeline_end_exclusive); $washes_by_department_and_slot = $normalized_department_ids === [] ? [] : self::loadDepartmentWashCountsBySlotByDepartment($normalized_department_ids, $timeline_start, $timeline_end_exclusive); $current_slot_start = new DateTime(date('Y-m-d H:00:00')); $current_slot_key = $current_slot_start->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'; $hours = self::sumDepartmentHoursForSlot($normalized_department_ids, $workfeed_hours_by_department_and_slot, $slot_key); $washes = self::sumDepartmentWashesForSlot($normalized_department_ids, $washes_by_department_and_slot, $slot_key); $slot_started = $slot <= $current_slot_start; $entries[] = [ 'date' => $slot->format('Y-m-d'), 'time' => $slot->format('H:00'), 'current' => $slot_key === $current_slot_key, 'weather' => $weather, 'washes' => $washes, 'hours' => $hours, 'status' => self::calculateAggregatedDepartmentStatus( $normalized_department_ids, $washes_by_department_and_slot, $workfeed_hours_by_department_and_slot, $slot_key, $slot_started, $normalized_targets ), ]; $slot->add(new DateInterval('PT1H')); } return $entries; } private function calculateStatus(int $washes, float $hours, bool $slot_started = true, ?array $targets = null): string { if (!$slot_started) { return 'unknown'; } if ($hours <= 0) { return 'unknown'; } $normalized_targets = $this->normalizeDepartmentWeatherTarget($targets); if ($normalized_targets === null) { return 'unknown'; } $ratio = $washes / $hours; if ($ratio >= $normalized_targets['healthy_threshold']) { return 'healthy'; } if ($ratio >= $normalized_targets['degraded_threshold']) { return 'degraded'; } return 'unhealthy'; } private function calculateAggregatedDepartmentStatus( array $department_ids, array $washes_by_department_and_slot, array $hours_by_department_and_slot, string $slot_key, bool $slot_started, array $status_targets_by_department ): string { if (!$slot_started) { return 'unknown'; } $worst_status = 'unknown'; $worst_severity = 0; $has_evaluable_department = false; foreach ($department_ids as $department_id) { $normalized_department_id = (int)$department_id; if ($normalized_department_id < 1) { continue; } $targets = $this->normalizeDepartmentWeatherTarget($status_targets_by_department[$normalized_department_id] ?? null); if ($targets === null) { return 'unknown'; } $hours = (float)($hours_by_department_and_slot[$normalized_department_id][$slot_key] ?? 0.0); if ($hours <= 0) { continue; } $has_evaluable_department = true; $washes = (int)($washes_by_department_and_slot[$normalized_department_id][$slot_key] ?? 0); $status = $this->calculateStatus($washes, $hours, true, $targets); $severity = self::DEPARTMENT_WEATHER_STATUS_SEVERITY[$status] ?? 0; if ($severity > $worst_severity) { $worst_severity = $severity; $worst_status = $status; } } if (!$has_evaluable_department) { return 'unknown'; } return $worst_status; } /** * @throws Exception */ private function loadWorkfeedDepartmentHoursBySlot(array $departments, DateTime $timeline_start, DateTime $timeline_end_exclusive): array { $hours_by_department_and_slot = $this->loadWorkfeedDepartmentHoursBySlotByDepartment($departments, $timeline_start, $timeline_end_exclusive); if ($hours_by_department_and_slot === []) { return []; } $hours_by_slot = []; foreach ($hours_by_department_and_slot as $department_hours_by_slot) { foreach ($department_hours_by_slot as $slot_key => $hours) { if (!isset($hours_by_slot[$slot_key])) { $hours_by_slot[$slot_key] = 0.0; } $hours_by_slot[$slot_key] += (float)$hours; } } return $hours_by_slot; } /** * @throws Exception */ private function loadWorkfeedDepartmentHoursBySlotByDepartment(array $departments, DateTime $timeline_start, DateTime $timeline_end_exclusive): array { try { $workfeed = new workfeed(); $workfeed_department_ids_by_department = $this->resolveWorkfeedDepartmentIdsByDepartmentId($departments, $workfeed); if ($workfeed_department_ids_by_department === []) { 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_department_and_slot = []; foreach ($workfeed_department_ids_by_department as $department_id => $workfeed_department_id) { $slot = clone $timeline_start; while ($slot < $timeline_end_exclusive) { $slot_key = $slot->format('Y-m-d H:00'); if (!isset($hours_by_department_and_slot[$department_id])) { $hours_by_department_and_slot[$department_id] = []; } $hours_by_department_and_slot[$department_id][$slot_key] = self::calculateWorkfeedEmployeeHoursForHour($shifts, $workfeed_department_id, $slot); $slot->add(new DateInterval('PT1H')); } } return $hours_by_department_and_slot; } catch (Exception) { return []; } } private function getDepartmentWeatherTimelineRange(?string $date_from = null, ?string $date_to = null): array { if ($date_from !== null && $date_to !== null) { $start = new DateTime($date_from . ' 00:00:00'); $end_exclusive = new DateTime($date_to . ' 00:00:00'); $end_exclusive->add(new DateInterval('P1D')); } else { $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 resolveForecastDaysForTimelineRange(DateTime $timeline_start, DateTime $timeline_end_exclusive): int { $today_start = new DateTime(date('Y-m-d 00:00:00')); $effective_start = $timeline_start->getTimestamp() > $today_start->getTimestamp() ? clone $timeline_start : $today_start; $seconds = max(0, $timeline_end_exclusive->getTimestamp() - $effective_start->getTimestamp()); $days = (int)ceil($seconds / 86400); return max(1, min(14, $days)); } private function resolveWorkfeedDepartmentIds(array $departments, workfeed $workfeed): array { $resolved_ids = array_values(array_unique(array_values($this->resolveWorkfeedDepartmentIdsByDepartmentId($departments, $workfeed)))); sort($resolved_ids, SORT_STRING); return $resolved_ids; } private function resolveWorkfeedDepartmentIdsByDepartmentId(array $departments, workfeed $workfeed): array { $resolved_ids = []; $workfeed_departments = null; foreach ($departments as $department) { $department_id = (int)($department->id ?? 0); if ($department_id < 1) { continue; } $configured_id = self::getConfiguredWorkfeedDepartmentId($department); if ($configured_id !== null) { $resolved_ids[$department_id] = $configured_id; continue; } if ($workfeed_departments === null) { $workfeed_departments = self::normalizeWorkfeedCollection($workfeed->listDepartments()); } if ($workfeed_departments === []) { continue; } $department_name = trim((string)$department->name->value()); if ($department_name === '') { continue; } $matched_id = self::matchWorkfeedDepartmentIdByName($department_name, $workfeed_departments); if ($matched_id !== null) { $resolved_ids[$department_id] = $matched_id; } } return $resolved_ids; } 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)) { $normalized = trim($value); if ($normalized === '') { return null; } try { return new DateTime($normalized); } catch (Exception) { if (!is_numeric($normalized)) { return null; } $value = (float)$normalized; } } if (is_int($value) || is_float($value)) { if (!is_finite((float)$value)) { return null; } $timestamp = (float)$value; if ($timestamp > 9999999999) { $timestamp /= 1000; } try { $date = new DateTime('@' . (string)(int)round($timestamp)); $date->setTimezone(new DateTimeZone('UTC')); return $date; } catch (Exception) { return null; } } $record = self::normalizeWorkfeedRecord($value); if ($record !== []) { foreach (['seconds', '_seconds', 'epochSeconds', 'timestamp'] as $key) { if (!array_key_exists($key, $record)) { continue; } $parsed = self::parseDateTimeValue($record[$key]); if ($parsed !== null) { return $parsed; } } } return null; } private function getNestedRecordValue(array $record, string $path): mixed { $segments = explode('.', $path); $current = $record; foreach ($segments as $segment) { if (is_array($current)) { if (!array_key_exists($segment, $current)) { return null; } $current = $current[$segment]; continue; } if (is_object($current)) { if (!property_exists($current, $segment)) { return null; } $current = $current->$segment; continue; } return null; } return $current; } private function firstShiftDateTimeFromPaths(array $record, array $paths): ?DateTime { foreach ($paths as $path) { $value = self::getNestedRecordValue($record, $path); $parsed = self::parseDateTimeValue($value); if ($parsed !== null) { return $parsed; } } return null; } private function lastShiftDateTimeFromPaths(array $record, array $paths): ?DateTime { $latest = null; foreach ($paths as $path) { $value = self::getNestedRecordValue($record, $path); $parsed = self::parseDateTimeValue($value); if ($parsed === null) { continue; } if ($latest === null || $parsed->getTimestamp() > $latest->getTimestamp()) { $latest = $parsed; } } return $latest; } private function hasShiftApproval(array $record): bool { if (!array_key_exists('approval', $record)) { return false; } $approval = $record['approval']; if ($approval === null) { return false; } if (is_array($approval)) { return $approval !== []; } if (is_object($approval)) { return get_object_vars($approval) !== []; } return true; } private function resolveEffectiveShiftEnd(array $record, DateTime $shift_start, DateTime $shift_end): DateTime { if (self::hasShiftApproval($record)) { return $shift_end; } $update_time = self::parseDateTimeValue($record['updateTime'] ?? null); if ($update_time === null) { return $shift_end; } $shift_start_ts = $shift_start->getTimestamp(); $shift_end_ts = $shift_end->getTimestamp(); $update_ts = $update_time->getTimestamp(); if ($update_ts <= $shift_end_ts) { return $shift_end; } // Guard against counting late administrative edits as overtime. $max_unapproved_extension_seconds = 6 * 3600; if (($update_ts - $shift_end_ts) > $max_unapproved_extension_seconds) { return $shift_end; } if (($update_ts - $shift_start_ts) > 24 * 3600) { return $shift_end; } return $update_time; } private function calculateWorkfeedEmployeeHoursForHour( array $shifts, string|array $workfeed_department_ids, DateTime $slot_start, ?DateTime $occurred_until = null ): float { $department_id_values = is_array($workfeed_department_ids) ? $workfeed_department_ids : [$workfeed_department_ids]; $department_id_lookup = []; foreach ($department_id_values as $department_id_value) { $normalized = trim((string)$department_id_value); if ($normalized !== '') { $department_id_lookup[$normalized] = true; } } if ($department_id_lookup === []) { return 0.0; } $slot_end = clone $slot_start; $slot_end->add(new DateInterval('PT1H')); $slot_start_ts = $slot_start->getTimestamp(); $slot_end_ts = $slot_end->getTimestamp(); $occurred_until_ts = ($occurred_until ?? new DateTime())->getTimestamp(); $hours = 0.0; foreach ($shifts as $shift) { $shift_department_id = self::extractWorkfeedDepartmentId($shift); if ($shift_department_id === null || !isset($department_id_lookup[$shift_department_id])) { continue; } $timing = workfeed_shift_time_resolver::resolveShiftTiming($shift); if ($timing === null) { continue; } $shift_start_ts = $timing['actualStart']->getTimestamp(); $shift_end_ts = min($timing['actualEnd']->getTimestamp(), $occurred_until_ts); 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); } /** * @throws Exception */ private function loadDepartmentWashCountsBySlot(array $department_ids, DateTime $timeline_start, DateTime $timeline_end_exclusive): array { $counts_by_department_and_slot = $this->loadDepartmentWashCountsBySlotByDepartment($department_ids, $timeline_start, $timeline_end_exclusive); if ($counts_by_department_and_slot === []) { return []; } $counts = []; foreach ($counts_by_department_and_slot as $department_counts_by_slot) { foreach ($department_counts_by_slot as $slot_key => $wash_count) { if (!isset($counts[$slot_key])) { $counts[$slot_key] = 0; } $counts[$slot_key] += (int)$wash_count; } } return $counts; } /** * @throws Exception */ private function loadDepartmentWashCountsBySlotByDepartment(array $department_ids, DateTime $timeline_start, DateTime $timeline_end_exclusive): array { $normalized_ids = $this->normalizeDepartmentIdsForCachePreload($department_ids); if ($normalized_ids === []) { return []; } $range_end = clone $timeline_end_exclusive; $range_end->sub(new DateInterval('PT1S')); if ($range_end < $timeline_start) { return []; } try { $rows = (new orders_o())->countWashesByHourForDepartments( $timeline_start->format('Y-m-d H:i:s'), $range_end->format('Y-m-d H:i:s'), $normalized_ids ); return $this->normalizeDepartmentWashCountRowsByDepartment($rows); } catch (Exception) { return []; } } private function normalizeDepartmentWashCountRows(array $rows): array { $counts_by_department_and_slot = $this->normalizeDepartmentWashCountRowsByDepartment($rows); $counts = []; foreach ($counts_by_department_and_slot as $department_counts_by_slot) { foreach ($department_counts_by_slot as $slot_key => $wash_count) { if (!isset($counts[$slot_key])) { $counts[$slot_key] = 0; } $counts[$slot_key] += (int)$wash_count; } } return $counts; } private function normalizeDepartmentWashCountRowsByDepartment(array $rows): array { $counts = []; foreach ($rows as $row) { if (!is_array($row)) { continue; } $department_id = (int)($row['department_id'] ?? $row['departmentId'] ?? $row['department'] ?? 0); if ($department_id < 1) { continue; } $bucket = trim((string)($row['hour_bucket'] ?? $row['hour'] ?? $row['slot'] ?? '')); if ($bucket === '') { continue; } try { $slot_key = (new DateTime($bucket))->format('Y-m-d H:00'); } catch (Exception) { continue; } $wash_count = (int)($row['wash_count'] ?? $row['count'] ?? 0); if ($wash_count < 0) { $wash_count = 0; } if (!isset($counts[$department_id])) { $counts[$department_id] = []; } if (!isset($counts[$department_id][$slot_key])) { $counts[$department_id][$slot_key] = 0; } $counts[$department_id][$slot_key] += $wash_count; } return $counts; } /** * @throws Exception */ private function countWashesForHour(array $department_ids, DateTime $hour_start): int { $start = clone $hour_start; $end = clone $hour_start; $end->add(new DateInterval('PT59M59S')); $orders = new orders_o(); $total = 0; foreach ($department_ids as $department_id) { $total += $orders->countWashesInDateRange( $start->format('Y-m-d H:i:s'), $end->format('Y-m-d H:i:s'), (int)$department_id ); } return $total; } private function mapWeatherCondition(int $code, string $text): string { $rain = [1063, 1150, 1153, 1180, 1183, 1186, 1189, 1192, 1195, 1240, 1243, 1246]; $showers = [1072, 1168, 1171, 1198, 1201, 1249, 1252]; $snow = [1066, 1069, 1114, 1117, 1204, 1207, 1210, 1213, 1216, 1219, 1222, 1225, 1237, 1255, 1258, 1261, 1264]; $thunder = [1087, 1273, 1276, 1279, 1282]; if ($code === 1000) { return 'clear'; } if ($code === 1003) { return 'mostly_clear'; } if ($code === 1006) { return 'partly_cloudy'; } if ($code === 1009) { return 'mostly_cloudy'; } if ($code === 1030 || str_contains(strtolower($text), 'overcast')) { return 'overcast'; } if ($code === 1135 || $code === 1147) { return 'fog'; } if (in_array($code, $thunder, true)) { return 'thunderstorm'; } if (in_array($code, $snow, true)) { return 'snow'; } if (in_array($code, $showers, true)) { return 'showers'; } if (in_array($code, $rain, true)) { return 'rain'; } return 'mostly_clear'; } }