456 lines
15 KiB
PHP
456 lines
15 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;
|
|
}
|
|
|
|
self::requireParameters(['id']);
|
|
self::requireType((int)self::getParameter('id'), self::type_int());
|
|
self::requireMinValue((int)self::getParameter('id'), 1);
|
|
self::requireDepartmentAccess((int)self::getParameter('id'));
|
|
|
|
$department = (new departments_o())->select((int)self::getParameter('id'));
|
|
$lat = (float)$department->latitude->value();
|
|
$lon = (float)$department->longitude->value();
|
|
if ($lat === 0.0 && $lon === 0.0) {
|
|
$response->error('Department does not have GPS coordinates configured', 400);
|
|
return;
|
|
}
|
|
|
|
$weather = (new weatherapi())->forecast($lat . ',' . $lon, 2);
|
|
$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);
|
|
}, [
|
|
'departments_weather_get' => 'Get department weather timeline with washes and productivity status',
|
|
'department_access_:id' => 'Access weather timeline for a specific department',
|
|
]);
|
|
}
|
|
|
|
/**
|
|
* @throws Exception
|
|
*/
|
|
private function buildDepartmentWeatherTimeline(int $department_id, departments_o $department, object $forecast): 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();
|
|
$timeline_start = $timeline_range['start'];
|
|
$timeline_end_exclusive = $timeline_range['endExclusive'];
|
|
|
|
$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';
|
|
$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,
|
|
'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(departments_o $department, DateTime $timeline_start, DateTime $timeline_end_exclusive): array
|
|
{
|
|
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 [];
|
|
}
|
|
}
|
|
|
|
private function getDepartmentWeatherTimelineRange(): array
|
|
{
|
|
$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;
|
|
}
|
|
|
|
$department_name = trim((string)$department->name->value());
|
|
if ($department_name === '') {
|
|
return null;
|
|
}
|
|
|
|
$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);
|
|
}
|
|
|
|
/**
|
|
* @throws Exception
|
|
*/
|
|
private function countWashesForHour(int $department_id, DateTime $hour_start): int
|
|
{
|
|
$start = clone $hour_start;
|
|
$end = clone $hour_start;
|
|
$end->add(new DateInterval('PT59M59S'));
|
|
|
|
return (new orders_o())->countWashesInDateRange(
|
|
$start->format('Y-m-d H:i:s'),
|
|
$end->format('Y-m-d H:i:s'),
|
|
$department_id
|
|
);
|
|
}
|
|
|
|
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';
|
|
}
|
|
}
|