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 = self::parseSelectedTimelineDateFromRequest(); $departments = self::loadDepartmentsByIds($department_ids); $coordinates = self::resolveWeatherCoordinates($departments); if ($coordinates === null) { $response->error('Selected department(s) do not have GPS coordinates configured', 400); return; } $weather = (new weatherapi())->forecast($coordinates['lat'] . ',' . $coordinates['lon'], 2); $timeline = self::buildDepartmentWeatherTimeline($department_ids, $departments, $weather, $selected_date); $department_context = count($department_ids) === 1 ? (string)$department_ids[0] : implode(',', $department_ids); (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', ]); } 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 parseSelectedTimelineDateFromRequest(): ?string { $parameters = self::getParametersAsArray(); if (!array_key_exists('date', $parameters)) { return null; } $date = self::getParameter('date'); if (!is_string($date)) { global $response; $response->error('Invalid type. Expected: string Got: ' . gettype($date), 400); } $selected_date = trim((string)$date); self::requireDateFormat($selected_date, self::FORMAT_DATE()); return $selected_date; } 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 function resolveWeatherCoordinates(array $departments): ?array { $lat_sum = 0.0; $lon_sum = 0.0; $count = 0; foreach ($departments as $department) { $lat = (float)$department->latitude->value(); $lon = (float)$department->longitude->value(); 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 buildDepartmentWeatherTimeline(array $department_ids, array $departments, object $forecast, ?string $selected_date = null): 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_range = self::getDepartmentWeatherTimelineRange($selected_date); $timeline_start = $timeline_range['start']; $timeline_end_exclusive = $timeline_range['endExclusive']; $entries = []; $slot = clone $timeline_start; $workfeed_hours_by_slot = self::loadWorkfeedDepartmentHoursBySlot($departments, $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'; $hours = (float)($workfeed_hours_by_slot[$slot_key] ?? 0.0); $washes = self::countWashesForHour($department_ids, $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, 'status' => self::calculateStatus($washes, $hours), ]; $slot->add(new DateInterval('PT1H')); } return $entries; } private function calculateStatus(int $washes, float $hours): string { if ($hours <= 0) { return 'unknown'; } $ratio = $washes / $hours; if ($ratio >= 0.8) { return 'healthy'; } if ($ratio >= 0.4) { return 'degraded'; } return 'unhealthy'; } /** * @throws Exception */ private function loadWorkfeedDepartmentHoursBySlot(array $departments, DateTime $timeline_start, DateTime $timeline_end_exclusive): array { try { $workfeed = new workfeed(); $workfeed_department_ids = self::resolveWorkfeedDepartmentIds($departments, $workfeed); if ($workfeed_department_ids === []) { 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_ids, $slot); $slot->add(new DateInterval('PT1H')); } return $hours_by_slot; } catch (Exception) { return []; } } private function getDepartmentWeatherTimelineRange(?string $selected_date = null): array { $anchor_date = $selected_date ?? date('Y-m-d'); $start = new DateTime($anchor_date . ' 00:00:00'); $start->sub(new DateInterval('P1D')); $end_exclusive = new DateTime($anchor_date . ' 00:00:00'); $end_exclusive->add(new DateInterval('P1D')); return [ 'start' => $start, 'endExclusive' => $end_exclusive, ]; } private function resolveWorkfeedDepartmentIds(array $departments, workfeed $workfeed): array { $resolved_ids = []; $workfeed_departments = null; foreach ($departments as $department) { $configured_id = self::getConfiguredWorkfeedDepartmentId($department); if ($configured_id !== null) { $resolved_ids[] = $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[] = $matched_id; } } return array_values(array_unique($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) || trim($value) === '') { return null; } try { return new DateTime($value); } catch (Exception) { return null; } } private function calculateWorkfeedEmployeeHoursForHour(array $shifts, string|array $workfeed_department_ids, DateTime $slot_start): 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(); $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; } $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); } /** * @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'; } }