Files
api/services/nginx/app/routes/moduleWeatherAPIRoute.php
T
Jeppe Bundgaard 7d450e285e Remove outdated edge gateway object classes, add new agent implementation
Transitioned from obsolete gateway object classes (`edge_gateway_shell_action_jobs_o`, `edge_gateway_shell_events_o`, `edge_gateway_shell_sessions_o`, `edge_gateway_update_jobs_o`) to the new agent implementation (`edge-gateway-agent/agent.php`).
2026-04-21 14:13:17 +02:00

1864 lines
65 KiB
PHP

<?php
namespace routes;
use classes\authentication;
use classes\response;
use classes\router;
use classes\weatherapi;
use classes\workfeed;
use classes\workfeed_shift_time_resolver;
use DateInterval;
use DateTime;
use DateTimeZone;
use Exception;
use Throwable;
use objects\departments_o;
use objects\logs_o;
use objects\orders_o;
use traits\route_t;
class moduleWeatherAPIRoute
{
use route_t;
private const DEPARTMENT_WEATHER_HOT_ACTIVITY_ZSET_KEY = 'departments_weather:hot_activity:v1';
private const DEPARTMENT_WEATHER_REFRESH_QUEUE_ZSET_KEY = 'departments_weather:refresh_queue:v1';
private const DEPARTMENT_WEATHER_HOT_DESCRIPTOR_PREFIX = 'departments_weather:hot_descriptor:v1:';
private const DEPARTMENT_WEATHER_REFRESH_LOCK_PREFIX = 'departments_weather:refresh_lock:v1:';
private const DEPARTMENT_WEATHER_TARGET_DEGRADED_KEY = 'weather_status_degraded_threshold';
private const DEPARTMENT_WEATHER_TARGET_HEALTHY_KEY = 'weather_status_healthy_threshold';
private const DEFAULT_DEPARTMENT_WEATHER_DEGRADED_THRESHOLD = 1.0;
private const DEFAULT_DEPARTMENT_WEATHER_HEALTHY_THRESHOLD = 1.3;
private const DEPARTMENT_WEATHER_STATUS_SEVERITY = [
'unknown' => 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<int,array{department_ids:array<int>,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<int>,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<mixed> $department_ids
* @return array<int>
*/
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';
}
}