Files
api/services/nginx/app/routes/moduleWeatherAPIRoute.php
T

614 lines
20 KiB
PHP

<?php
namespace routes;
use classes\authentication;
use classes\response;
use classes\router;
use classes\weatherapi;
use classes\workfeed;
use DateInterval;
use DateTime;
use Exception;
use objects\departments_o;
use objects\logs_o;
use objects\orders_o;
use traits\route_t;
class moduleWeatherAPIRoute
{
use route_t;
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 = 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';
}
}