[ 'interval' => 300, 'last_run' => 0, 'next_run' => 0, 'function' => 'ProcessAccountDeletionRequestsCron', ], // 'CheckUnfulfilledBookings' => [ // 'interval' => 86400, // 24 hours // 'last_run' => 0, // 'next_run' => 0, // 'time' => '15:00', // 'function' => 'checkUnfulfilledBookings', // ], // 'SyncBookings' => [ // 'interval' => 60, // 1 minute // 'last_run' => 0, // 'next_run' => 0, // 'function' => 'syncBookings', // ], 'SyncLogs' => [ 'interval' => 300, // 5 minutes 'last_run' => 0, 'next_run' => 0, 'function' => 'syncLogsToDatabase', ], 'ReplicaFailoverMonitorCron' => [ 'interval' => 60, // 1 minute 'last_run' => 0, 'next_run' => 0, 'function' => 'ReplicaFailoverMonitorCron', ], 'CoolifyAvailabilityMonitorCron' => [ 'interval' => 60, // 1 minute 'last_run' => 0, 'next_run' => 0, 'function' => 'CoolifyAvailabilityMonitorCron', ], 'CoolifyLoadBalancerReconcileCron' => [ 'interval' => 60, // 1 minute 'last_run' => 0, 'next_run' => 0, 'function' => 'CoolifyLoadBalancerReconcileCron', ], 'SyncUserEconomicCustomerDiscounts' => [ 'interval' => 180, // 3 minutes 'last_run' => 0, 'next_run' => 0, 'function' => 'SyncUserEconomicCustomerDiscounts', ], 'SyncUserEconomicCustomerDetails' => [ 'interval' => 43200, // 12 hours 'last_run' => 0, 'next_run' => 0, 'function' => 'SyncUserEconomicCustomerDetails', ], 'SyncSystemSearchEconomicCustomerIndex' => [ 'interval' => 900, // 15 minutes 'last_run' => 0, 'next_run' => 0, 'function' => 'SyncSystemSearchEconomicCustomerIndex', ], 'backup' => [ 'interval' => 43200, // 12 hours 'last_run' => 0, 'next_run' => 0, 'function' => 'backup', ], 'SyncEconomicInvoiceStatus' => [ 'interval' => 120, // 2 minutes 'last_run' => 0, 'next_run' => 0, 'function' => 'SyncEconomicInvoiceStatus', ], 'EconomicTransferQueueCron' => [ 'interval' => 30, // 30 seconds 'last_run' => 0, 'next_run' => 0, 'function' => 'EconomicTransferQueueCron', ], 'SyncXLVaskModuleCron' => [ 'interval' => 3600, // 1 hour 'last_run' => 0, 'next_run' => 0, 'function' => 'SyncXLVaskModuleCron', ], 'SystemSearchCacheMaintenanceCron' => [ 'interval' => 300, // 5 minutes 'last_run' => 0, 'next_run' => 0, 'function' => 'SystemSearchCacheMaintenanceCron', ], 'PreRenderDynamicImagesCron' => [ 'interval' => 900, // 15 minutes 'last_run' => 0, 'next_run' => 0, 'function' => 'PreRenderDynamicImagesCron', ], 'PreloadDepartmentWeatherResponsesCron' => [ 'interval' => 60, // 1 minute 'last_run' => 0, 'next_run' => 0, 'function' => 'PreloadDepartmentWeatherResponsesCron', ], 'WarmWorkfeedEmployeeNamesCron' => [ 'interval' => 21600, // 6 hours 'last_run' => 0, 'next_run' => 0, 'function' => 'WarmWorkfeedEmployeeNamesCron', ], 'GoalsProgressAlertsCron' => [ 'interval' => 60, // check every minute 'last_run' => 0, 'next_run' => 0, 'function' => 'GoalsProgressAlertsCron', ], 'PruneSystemSessionActivityCron' => [ 'interval' => 86400, // 24 hours 'last_run' => 0, 'next_run' => 0, 'function' => 'PruneSystemSessionActivityCron', ], 'WarmInvoicePeriodManualFlagsCron' => [ 'interval' => 300, // 5 minutes 'last_run' => 0, 'next_run' => 0, 'function' => 'WarmInvoicePeriodManualFlagsCron', ], 'WarmInvoicePeriodAutomaticFlagsCron' => [ 'interval' => 300, // 5 minutes 'last_run' => 0, 'next_run' => 0, 'function' => 'WarmInvoicePeriodAutomaticFlagsCron', ], ]; function ProcessAccountDeletionRequestsCron(): array { if (!account_deletion_service::workerEnabled()) { return ['processed' => 0, 'completed' => 0, 'failed' => 0, 'skipped' => true]; } $result = (new account_deletion_service())->processPending(25); echo '[' . date('Y-m-d H:i:s') . '][CRON] Account deletion requests: ' . (int)$result['completed'] . ' completed, ' . (int)$result['failed'] . " failed.\n"; return $result; } function ReplicaFailoverMonitorCron(): void { global $db; if (!($db instanceof \classes\db)) { warn('ReplicaFailoverMonitorCron skipped: database connection is unavailable.'); return; } try { $result = (new replication_manager())->runAutomaticFailoverMonitor(); $promoted = array_filter( $result['results'] ?? [], static fn(array $entry): bool => ($entry['status'] ?? '') === 'promoted' ); echo "[" . date('Y-m-d H:i:s') . "][CRON] ReplicaFailoverMonitorCron: " . count($promoted) . " promotions.\n"; } catch (Throwable $throwable) { warn('ReplicaFailoverMonitorCron failed: ' . $throwable->getMessage()); } } function CoolifyAvailabilityMonitorCron(): void { global $db; if (!($db instanceof \classes\db)) { warn('CoolifyAvailabilityMonitorCron skipped: database connection is unavailable.'); return; } try { $result = (new coolify_manager())->runAvailabilityMaintenance(); echo "[" . date('Y-m-d H:i:s') . "][CRON] CoolifyAvailabilityMonitorCron: " . count($result['targets'] ?? []) . " targets checked.\n"; } catch (Throwable $throwable) { warn('CoolifyAvailabilityMonitorCron failed: ' . $throwable->getMessage()); } } function CoolifyLoadBalancerReconcileCron(): void { global $db; if (!($db instanceof \classes\db)) { warn('CoolifyLoadBalancerReconcileCron skipped: database connection is unavailable.'); return; } try { $manager = new coolify_manager(); if (!$manager->loadBalancerAutomationEnabled()) { echo "[" . date('Y-m-d H:i:s') . "][CRON] CoolifyLoadBalancerReconcileCron: skipped.\n"; return; } $result = $manager->reconcileLoadBalancer(false); echo "[" . date('Y-m-d H:i:s') . "][CRON] CoolifyLoadBalancerReconcileCron: " . count($result['applied'] ?? []) . " applied, " . count($result['skipped'] ?? []) . " skipped.\n"; } catch (Throwable $throwable) { warn('CoolifyLoadBalancerReconcileCron failed: ' . $throwable->getMessage()); } } function WarmInvoicePeriodManualFlagsCron(): void { (new invoice_period_flag_service())->warmManualFlagsCache(); } function WarmInvoicePeriodAutomaticFlagsCron(): void { $service = new invoice_period_flag_service(); $now = new DateTime(); $previousMonth = new DateTime('first day of previous month'); $toWarm = []; foreach ([$now, $previousMonth] as $date) { $dateFrom = $date->format('Y-m-01'); $dateTo = $date->format('Y-m-t'); $toWarm[$dateFrom . '|' . $dateTo] = ['dateFrom' => $dateFrom, 'dateTo' => $dateTo]; } try { $queued = (new redis())->consume_invoice_period_warming_queue(); foreach ($queued as $period) { $key = $period['dateFrom'] . '|' . $period['dateTo']; $toWarm[$key] = $period; } } catch (Throwable) { } foreach ($toWarm as $period) { $service->warmOrderItemRowsForPeriod($period['dateFrom'], $period['dateTo']); $service->warmAutomaticFlagsForPeriod($period['dateFrom'], $period['dateTo']); } } function WarmWorkfeedEmployeeNamesCron(): void { if (!defined('redis')) { warn('WarmWorkfeedEmployeeNamesCron skipped: Redis is unavailable.'); return; } $start = microtime(true); $ttlRaw = getenv('WORKFEED_EMPLOYEE_NAME_CACHE_TTL'); $ttl = max( 60, (int)( $ttlRaw !== false && trim((string)$ttlRaw) !== '' ? $ttlRaw : 86400 ) ); try { $employeesResponse = (new workfeed())->listEmployees(); } catch (Throwable $e) { warn('WarmWorkfeedEmployeeNamesCron failed to list employees: ' . $e->getMessage()); return; } $employees = normalizeWorkfeedEmployeeWarmupCollection($employeesResponse); if ($employees === []) { echo "[" . date('Y-m-d H:i:s') . "][CRON] WarmWorkfeedEmployeeNamesCron: no employees returned.\n"; return; } try { $cache = new redis(); } catch (Throwable $e) { warn('WarmWorkfeedEmployeeNamesCron failed to initialize cache: ' . $e->getMessage()); return; } $cachedCount = 0; $skippedCount = 0; foreach ($employees as $employee) { $identity = extractWorkfeedEmployeeWarmupIdentity($employee); $employeeIds = extractWorkfeedEmployeeWarmupIds($employee); $employeeName = $identity['name'] ?? null; if ($employeeIds === [] || $employeeName === null) { $skippedCount++; continue; } foreach ($employeeIds as $employeeId) { $cache->cache_workfeed_employee_name($employeeId, $employeeName, $ttl); $cachedCount++; } } $durationMs = (int)round((microtime(true) - $start) * 1000); echo "[" . date('Y-m-d H:i:s') . "][CRON] WarmWorkfeedEmployeeNamesCron: cached " . $cachedCount . " employees, skipped " . $skippedCount . " in " . $durationMs . "ms.\n"; } /** * @return array */ function normalizeWorkfeedEmployeeWarmupCollection(mixed $raw): array { if (is_array($raw)) { return array_values($raw); } if ($raw instanceof Traversable) { return array_values(iterator_to_array($raw, false)); } if (!is_object($raw)) { return []; } $record = get_object_vars($raw); foreach (['data', 'items', 'employees', 'results'] as $key) { $nested = $record[$key] ?? null; $normalized = normalizeWorkfeedEmployeeWarmupCollection($nested); if ($normalized !== []) { return $normalized; } } return [$raw]; } /** * @return array{id: ?string, name: ?string} */ function extractWorkfeedEmployeeWarmupIdentity(mixed $employee): array { $employeeIds = extractWorkfeedEmployeeWarmupIds($employee); $record = is_object($employee) ? get_object_vars($employee) : (is_array($employee) ? $employee : []); if ($record === []) { return [ 'id' => null, 'name' => null, ]; } $employeeId = $employeeIds[0] ?? null; $employeeName = workfeed_employee_name_formatter::fromRecord($record, [ 'firstname', 'firstName', 'first_name', 'employee.firstname', 'employee.firstName', 'employee.first_name', 'user.firstname', 'user.firstName', 'user.first_name', ], [ 'lastname', 'lastName', 'last_name', 'employee.lastname', 'employee.lastName', 'employee.last_name', 'user.lastname', 'user.lastName', 'user.last_name', ], [ 'employeeName', 'employee.name', 'employee.fullName', 'employee.full_name', 'employee.displayName', 'employee.display_name', 'name', 'fullName', 'full_name', 'displayName', 'display_name', 'user.name', 'user.fullName', 'user.full_name', 'user.displayName', 'user.display_name', ], $employeeId); return [ 'id' => $employeeId, 'name' => $employeeName, ]; } /** * @return array */ function extractWorkfeedEmployeeWarmupIds(mixed $employee): array { $record = is_object($employee) ? get_object_vars($employee) : (is_array($employee) ? $employee : []); if ($record === []) { return []; } $employeeIds = []; foreach ([ 'employeeID', 'employeeId', 'id', 'uuid', 'employee.id', 'employee.employeeID', 'employee.employeeId', 'employee.uuid', 'employeeUUID', 'employee_uuid', 'user.id', 'userId', ] as $path) { $employeeId = normalizeWarmupTextValue(getWarmupRecordValueByPath($record, $path)); if ($employeeId === null) { continue; } $employeeIds[$employeeId] = true; } return array_keys($employeeIds); } function getWarmupRecordValueByPath(array $record, string $path): mixed { $segments = explode('.', $path); $value = $record; foreach ($segments as $segment) { if (is_array($value) && array_key_exists($segment, $value)) { $value = $value[$segment]; continue; } if (is_object($value) && isset($value->{$segment})) { $value = $value->{$segment}; continue; } return null; } return $value; } function normalizeWarmupTextValue(mixed $value): ?string { if (is_string($value) || is_numeric($value)) { $normalized = trim((string)$value); return $normalized !== '' ? $normalized : null; } return null; } function syncBookings(): void { $bookings_o = new bookings_o(); $response_cron[] = $bookings_o->syncBookings(); } function syncLogsToDatabase(): void { $logs_o = new logs_o(); $logs_o->syncLogsToDatabase(); } function backup(): void { try { $backup = new backup_store(); $backup->enqueueCreateBackup(null, null, null, 'scheduled'); } catch (Exception $e) { warn('Backup failed: ' . $e->getMessage()); } } function processBackupJobs(): array { try { return (new backup_store())->processPendingJobs(3); } catch (Exception $e) { warn('Backup job processing failed: ' . $e->getMessage()); return ['error' => $e->getMessage()]; } } function pruneBackupRetention(): array { try { $backup = new backup_store(); $queued = $backup->enqueueRetentionPrune(); return $backup->runJobById((int)$queued['job_id']); } catch (Exception $e) { warn('Backup retention prune failed: ' . $e->getMessage()); return ['error' => $e->getMessage()]; } } function SyncUserEconomicCustomerDiscounts(): void { $users_o = new users_o(); $users_o->clearAllUsersEconomicCustomerDiscountsFromCache(); } function SyncUserEconomicCustomerDetails(): void { try { $users_o = new users_o(); $users_o->clearAllUsersEconomicCustomerDetailsFromCache(); $users_o->syncAllUsersEconomicCustomerDetails(); $stats = system_search_economic_customer_index::refreshIndex(false); system_search_document_index::refreshIndex([ 'customers', 'customer_discounts', 'customer_fixed_prices', 'employees', 'users', ]); system_search_cache::bumpTableVersion(system_search_economic_customer_index::TABLE); echo "[" . date('Y-m-d H:i:s') . "][CRON] Refreshed e-conomic customer snapshots and search index. Upserted: " . (int)($stats['upserted'] ?? 0) . "\n"; } catch (Throwable $e) { warn('SyncUserEconomicCustomerDetails failed: ' . $e->getMessage()); } } function SyncSystemSearchEconomicCustomerIndex(): void { try { $stats = system_search_economic_customer_index::refreshIndex(false); system_search_document_index::refreshIndex([ 'customers', 'customer_discounts', 'customer_fixed_prices', 'employees', 'users', ]); system_search_cache::bumpTableVersion(system_search_economic_customer_index::TABLE); echo "[" . date('Y-m-d H:i:s') . "][CRON] Synced system search e-conomic customer index. Processed: " . (int)($stats['processed'] ?? 0) . ", upserted: " . (int)($stats['upserted'] ?? 0) . ", deleted: " . (int)($stats['deleted'] ?? 0) . "\n"; } catch (Throwable $e) { warn('SyncSystemSearchEconomicCustomerIndex failed: ' . $e->getMessage()); } } /** * @throws Exception */ function SyncEconomicInvoiceStatus(): void { $economic = new economic(); try { $economic->getTasks()->runCheckErrors(); } catch (Exception $e) { // Do nothing, this is automatically running } try { $economic->getTasks()->runCheckDrafts(); } catch (Exception $e) { // Do nothing, this is automatically running } } function SyncXLVaskModuleCron(): void { $xlvask = new xlvask(); try { if ($xlvask->config->enabled->isTrue()) { $xlvask->getTasks()->runCronTasks(); } } catch (Exception $e) { // Do nothing, this is automatically running } } function EconomicTransferQueueCron(): void { try { $queue = new economic_transfer_queue(); $result = $queue->processPending(10); if ((int)($result['processed'] ?? 0) > 0) { echo "[" . date('Y-m-d H:i:s') . "][CRON] EconomicTransferQueueCron processed=" . (int)($result['processed'] ?? 0) . " completed=" . (int)($result['completed'] ?? 0) . " failed=" . (int)($result['failed'] ?? 0) . "\n"; } } catch (Throwable $e) { warn('EconomicTransferQueueCron failed: ' . $e->getMessage()); } } function PruneSystemSessionActivityCron(): void { try { $deleted = (new \classes\system_session_activity_tracker())->pruneOlderThanDays(30); if ($deleted > 0) { echo "[" . date('Y-m-d H:i:s') . "][CRON] Pruned $deleted stale system session activity rows\n"; } } catch (Throwable $e) { warn('PruneSystemSessionActivityCron failed: ' . $e->getMessage()); } } function SystemSearchCacheMaintenanceCron(): void { try { $rebuildRequest = system_search_cache::consumeRebuildRequest(); $dirtyTables = system_search_cache::consumeDirtyTables(); if ($rebuildRequest !== null) { system_search_cache::clearQueryCaches(); $scope = (string)($rebuildRequest['scope'] ?? 'all'); $types = array_values(array_filter(array_map('strval', (array)($rebuildRequest['types'] ?? [])))); if ($scope === 'types' && !empty($types)) { system_search_document_index::refreshIndex($types); } else { system_search_economic_customer_index::refreshIndex(false); system_search_document_index::refreshIndex(); } echo "[" . date('Y-m-d H:i:s') . "][CRON] System search cache rebuild handled. Scope: " . ($rebuildRequest['scope'] ?? 'all') . "\n"; return; } if (!empty($dirtyTables)) { if (in_array('users', $dirtyTables, true)) { system_search_economic_customer_index::refreshIndex(false); } $typesToRefresh = system_search_registry::entityTypesForDirtyTables($dirtyTables); if (!empty($typesToRefresh)) { system_search_document_index::refreshIndex($typesToRefresh); } echo "[" . date('Y-m-d H:i:s') . "][CRON] System search maintenance handled dirty tables: " . implode(', ', $dirtyTables) . "\n"; } } catch (Throwable $e) { warn('SystemSearchCacheMaintenanceCron failed: ' . $e->getMessage()); } } function PreRenderDynamicImagesCron(): void { $start = microtime(true); $cacheTtlSeconds = 86400; $maxRendersPerRun = 500; if (!defined('redis')) { warn('PreRenderDynamicImagesCron skipped: Redis is unavailable.'); return; } if (!extension_loaded('imagick')) { warn('PreRenderDynamicImagesCron skipped: Imagick extension is not loaded.'); return; } try { $laneRows = (new department_lanes_o())->getFieldsWhere([ 'deleted_at' => null, 'dynamic_image_id' => '!null', ], [ 'id', 'department', 'dynamic_image_id', 'machine_type_id', ]); } catch (Throwable $e) { warn('PreRenderDynamicImagesCron failed to read lanes: ' . $e->getMessage()); return; } if (!is_array($laneRows) || count($laneRows) === 0) { echo "[" . date('Y-m-d H:i:s') . "][CRON] PreRenderDynamicImagesCron: no dynamic-image lanes found.\n"; return; } $variantsByCacheKey = []; $unsupportedImageIds = []; foreach ($laneRows as $laneRow) { $dynamicImageId = (int)($laneRow['dynamic_image_id'] ?? 0); if ($dynamicImageId <= 0) { continue; } if ($dynamicImageId !== 1) { $unsupportedImageIds[$dynamicImageId] = true; continue; } $taskGroups = collectDynamicImageTaskGroupsForLane($laneRow); if ($taskGroups === []) { $taskGroups = [[ 'vehicle_type' => null, 'rows' => [], ]]; } foreach ($taskGroups as $group) { $groupVariants = buildDynamicImageVariantsForTaskGroup( $dynamicImageId, normalizeDynamicImageVehicleType($group['vehicle_type'] ?? null), is_array($group['rows'] ?? null) ? $group['rows'] : [] ); foreach ($groupVariants as $variant) { $cacheKey = buildDynamicImageCacheKey($variant); if ($cacheKey === '') { continue; } $variantsByCacheKey[$cacheKey] = $variant + ['cache_key' => $cacheKey]; } } } $discovered = count($variantsByCacheKey); $rendered = 0; $alreadyCached = 0; $failed = 0; $skippedByCap = 0; $attempted = 0; foreach ($variantsByCacheKey as $cacheKey => $variant) { if (redis->exists($cacheKey)) { $alreadyCached++; continue; } if ($attempted >= $maxRendersPerRun) { $skippedByCap++; continue; } $attempted++; $imageData = renderDynamicImageVariant( (int)$variant['dynamic_image_id'], $variant['buttons'], (int)$variant['current_step'], (bool)$variant['only_current_step'] ); if ($imageData === null) { $failed++; continue; } redis->setEx($cacheKey, $imageData, $cacheTtlSeconds); $rendered++; } $duration = round(microtime(true) - $start, 2); $unsupportedList = empty($unsupportedImageIds) ? 'none' : implode(', ', array_keys($unsupportedImageIds)); echo "[" . date('Y-m-d H:i:s') . "][CRON] PreRenderDynamicImagesCron completed. " . "discovered=$discovered rendered=$rendered cached=$alreadyCached failed=$failed " . "skipped_by_cap=$skippedByCap unsupported_image_ids=$unsupportedList duration={$duration}s\n"; } /** * @return array>}> */ function collectDynamicImageTaskGroupsForLane(array $laneRow): array { $laneId = (int)($laneRow['id'] ?? 0); $departmentId = (int)($laneRow['department'] ?? 0); $machineTypeId = normalizeDynamicImageVehicleType($laneRow['machine_type_id'] ?? null); $tasksObject = new department_selfserve_tasks_o(); $rows = []; if ($machineTypeId !== null) { try { $rows = $tasksObject->getTasksForMachineType($machineTypeId); } catch (Throwable $e) { warn('PreRenderDynamicImagesCron: failed reading machine-type tasks for lane #' . $laneId . ': ' . $e->getMessage()); $rows = []; } } if (!is_array($rows) || $rows === []) { try { $rows = $tasksObject->getFieldsWhere([ 'department' => $departmentId, 'lane' => $laneId, 'deleted_at' => null, ], [ 'id', 'product', 'order_priority', 'buttons', 'dynamic_images_vehicle_type', ]); } catch (Throwable $e) { warn('PreRenderDynamicImagesCron: failed reading legacy tasks for lane #' . $laneId . ': ' . $e->getMessage()); $rows = []; } } if (!is_array($rows) || $rows === []) { return []; } $grouped = []; foreach ($rows as $row) { $vehicleType = normalizeDynamicImageVehicleType($row['dynamic_images_vehicle_type'] ?? null); if ($vehicleType === null) { $productVehicleType = normalizeDynamicImageVehicleType($row['product'] ?? null); if ($productVehicleType !== null && $productVehicleType > 0) { $vehicleType = $productVehicleType; } } $groupKey = $vehicleType === null ? 'null' : 'v' . $vehicleType; if (!isset($grouped[$groupKey])) { $grouped[$groupKey] = [ 'vehicle_type' => $vehicleType, 'rows' => [], ]; } $grouped[$groupKey]['rows'][] = $row; } return array_values($grouped); } /** * @param array> $taskRows * @return array|null,current_step:int,only_current_step:bool,vehicle_type:int|null}> */ function buildDynamicImageVariantsForTaskGroup(int $dynamicImageId, ?int $vehicleType, array $taskRows): array { $variants = []; usort($taskRows, static function (array $a, array $b): int { $priorityA = (int)($a['order_priority'] ?? 0); $priorityB = (int)($b['order_priority'] ?? 0); if ($priorityA !== $priorityB) { return $priorityA <=> $priorityB; } return ((int)($a['id'] ?? 0)) <=> ((int)($b['id'] ?? 0)); }); $buttonSets = [null]; $runningButtons = []; foreach ($taskRows as $row) { $buttons = parseDynamicImageButtons($row['buttons'] ?? null); if ($buttons !== []) { $buttonSets[] = $buttons; $runningButtons = mergeUniqueButtonValues($runningButtons, $buttons); $buttonSets[] = $runningButtons; } } $dedupedButtonSets = []; foreach ($buttonSets as $buttonSet) { $signature = $buttonSet === null ? 'null' : json_encode(array_values($buttonSet)); if ($signature === false || isset($dedupedButtonSets[$signature])) { continue; } $dedupedButtonSets[$signature] = $buttonSet; } foreach ($dedupedButtonSets as $buttons) { $buttonCount = is_array($buttons) ? count($buttons) : 0; $maxStep = min(max(0, $buttonCount + 3), 15); for ($currentStep = 0; $currentStep <= $maxStep; $currentStep++) { $variants[] = [ 'dynamic_image_id' => $dynamicImageId, 'buttons' => $buttons, 'current_step' => $currentStep, 'only_current_step' => false, 'vehicle_type' => $vehicleType, ]; $variants[] = [ 'dynamic_image_id' => $dynamicImageId, 'buttons' => $buttons, 'current_step' => $currentStep, 'only_current_step' => true, 'vehicle_type' => $vehicleType, ]; } } return $variants; } /** * @param array|null $buttons */ function buildDynamicImageCacheKey(array $variant): string { $cacheParams = [ 'dynamic_image_id' => (int)($variant['dynamic_image_id'] ?? 0), 'buttons' => $variant['buttons'] ?? null, 'current_step' => (int)($variant['current_step'] ?? 0), 'only_current_step' => (bool)($variant['only_current_step'] ?? false), 'vehicle_type' => $variant['vehicle_type'] ?? null, 'dynamic_image_size' => getSelfServeDynamicImageSizeModeForCron(), ]; $json = json_encode($cacheParams); if ($json === false) { return ''; } return 'dynamic_image:' . md5($json); } /** * @param array|null $buttons */ function renderDynamicImageVariant(int $dynamicImageId, ?array $buttons, int $currentStep, bool $onlyCurrentStep): ?string { $image = null; try { switch ($dynamicImageId) { case 1: $image = new machine_1(); break; default: warn('PreRenderDynamicImagesCron: unsupported dynamic_image_id=' . $dynamicImageId); return null; } if (is_array($buttons)) { $image->highlighted_buttons = $buttons; } $image->current_step = max(0, $currentStep); $image->only_generate_current_step = $onlyCurrentStep; $image->setup(); if (getSelfServeDynamicImageSizeModeForCron() === selfserve_dynamic_image_size_c::SIZE_RELEVANT) { $image->resizeToMaxWidth(DYNAMIC_IMAGE_RELEVANT_MAX_WIDTH); } return $image->exportBinary('png'); } catch (Throwable $e) { warn('PreRenderDynamicImagesCron: render failed for dynamic_image_id=' . $dynamicImageId . ': ' . $e->getMessage()); return null; } finally { if (is_object($image) && method_exists($image, 'clearImage')) { try { $image->clearImage(); } catch (Throwable) { } } } } function getSelfServeDynamicImageSizeModeForCron(): string { try { $mode = (string)(new selfserve_c())->dynamic_image_size->getVariableValue(); } catch (Throwable) { return selfserve_dynamic_image_size_c::SIZE_ORIGINAL; } return in_array($mode, [selfserve_dynamic_image_size_c::SIZE_ORIGINAL, selfserve_dynamic_image_size_c::SIZE_RELEVANT], true) ? $mode : selfserve_dynamic_image_size_c::SIZE_ORIGINAL; } /** * @param mixed $value * @return array */ function parseDynamicImageButtons(mixed $value): array { if ($value === null || $value === '') { return []; } try { return department_selfserve_tasks_o::normalizeButtonsInput($value); } catch (Throwable) { return []; } } /** * @param mixed $value */ function normalizeDynamicImageVehicleType(mixed $value): ?int { if ($value === null) { return null; } if (is_string($value)) { $value = trim($value); if ($value === '' || strtolower($value) === 'null') { return null; } } if (!is_numeric($value)) { return null; } $normalized = (int)$value; if ($normalized < 0) { return null; } return $normalized; } /** * @param array $base * @param array $append * @return array */ function mergeUniqueButtonValues(array $base, array $append): array { $result = $base; $seen = []; foreach ($result as $value) { $seen[(is_int($value) ? 'int:' : 'string:') . (string)$value] = true; } foreach ($append as $value) { $key = (is_int($value) ? 'int:' : 'string:') . (string)$value; if (!isset($seen[$key])) { $seen[$key] = true; $result[] = $value; } } return array_values($result); } function PreloadDepartmentWeatherResponsesCron(): void { $start = microtime(true); if (!defined('redis')) { warn('PreloadDepartmentWeatherResponsesCron skipped: Redis is unavailable.'); return; } $maxDepartmentsRaw = getenv('DEPARTMENTS_WEATHER_PRELOAD_MAX_DEPARTMENTS'); $maxDepartmentsPerRun = max( 1, (int)( $maxDepartmentsRaw !== false && trim((string)$maxDepartmentsRaw) !== '' ? $maxDepartmentsRaw : 25 ) ); $hotLimitRaw = getenv('DEPARTMENTS_WEATHER_PRELOAD_HOT_LIMIT'); $hotLimit = max( 1, (int)( $hotLimitRaw !== false && trim((string)$hotLimitRaw) !== '' ? $hotLimitRaw : 50 ) ); $hotTtlRaw = getenv('DEPARTMENTS_WEATHER_PRELOAD_HOT_TTL'); $hotTtl = max( 1, (int)( $hotTtlRaw !== false && trim((string)$hotTtlRaw) !== '' ? $hotTtlRaw : 900 ) ); try { $departmentRows = (new departments_o())->list(true); } catch (Throwable $e) { warn('PreloadDepartmentWeatherResponsesCron failed to list departments: ' . $e->getMessage()); return; } if (!is_array($departmentRows) || $departmentRows === []) { echo "[" . date('Y-m-d H:i:s') . "][CRON] PreloadDepartmentWeatherResponsesCron: no departments found.\n"; return; } usort($departmentRows, static function (array $left, array $right): int { $leftPriority = isset($left['order_priority']) ? (int)$left['order_priority'] : PHP_INT_MAX; $rightPriority = isset($right['order_priority']) ? (int)$right['order_priority'] : PHP_INT_MAX; if ($leftPriority !== $rightPriority) { return $leftPriority <=> $rightPriority; } return ((int)($left['id'] ?? 0)) <=> ((int)($right['id'] ?? 0)); }); $departmentIds = []; foreach ($departmentRows as $row) { $departmentId = (int)($row['id'] ?? 0); if ($departmentId < 1) { continue; } if (isset($row['visible']) && (int)$row['visible'] === 0) { continue; } $departmentIds[$departmentId] = true; if (count($departmentIds) >= $maxDepartmentsPerRun) { break; } } // Fallback: if visibility is unavailable or all are hidden, include all departments up to cap. if ($departmentIds === []) { foreach ($departmentRows as $row) { $departmentId = (int)($row['id'] ?? 0); if ($departmentId < 1) { continue; } $departmentIds[$departmentId] = true; if (count($departmentIds) >= $maxDepartmentsPerRun) { break; } } } $departmentIds = array_map('intval', array_keys($departmentIds)); sort($departmentIds, SORT_NUMERIC); if ($departmentIds === []) { echo "[" . date('Y-m-d H:i:s') . "][CRON] PreloadDepartmentWeatherResponsesCron: no preloadable departments.\n"; return; } $warmedSets = 0; $failedSets = 0; $totalEntries = 0; $hotTargetsRequested = 0; $hotTargetsWarmed = 0; $hotTargets = moduleWeatherAPIRoute::getDepartmentWeatherHotPreloadTargets($hotLimit, $hotTtl); $hotTargetsRequested = count($hotTargets); foreach ($hotTargets as $target) { try { $result = moduleWeatherAPIRoute::preloadDepartmentWeatherTimelineCache( $target['department_ids'] ?? [], null, null, $target['timeline_range'] ?? null ); if (($result['warmed'] ?? false) === true) { $warmedSets++; $hotTargetsWarmed++; $totalEntries += (int)($result['entries'] ?? 0); } } catch (Throwable $e) { $failedSets++; warn('PreloadDepartmentWeatherResponsesCron failed for hot target: ' . $e->getMessage()); } } foreach ($departmentIds as $departmentId) { try { $result = moduleWeatherAPIRoute::preloadDepartmentWeatherTimelineCache([$departmentId]); if (($result['warmed'] ?? false) === true) { $warmedSets++; $totalEntries += (int)($result['entries'] ?? 0); } } catch (Throwable $e) { $failedSets++; warn('PreloadDepartmentWeatherResponsesCron failed for department #' . $departmentId . ': ' . $e->getMessage()); } } if (count($departmentIds) > 1) { try { $result = moduleWeatherAPIRoute::preloadDepartmentWeatherTimelineCache($departmentIds); if (($result['warmed'] ?? false) === true) { $warmedSets++; $totalEntries += (int)($result['entries'] ?? 0); } } catch (Throwable $e) { $failedSets++; warn('PreloadDepartmentWeatherResponsesCron failed for aggregate department set: ' . $e->getMessage()); } } $duration = round(microtime(true) - $start, 2); echo "[" . date('Y-m-d H:i:s') . "][CRON] PreloadDepartmentWeatherResponsesCron completed. " . "departments=" . count($departmentIds) . " warmed_sets=$warmedSets failed_sets=$failedSets total_entries=$totalEntries " . "hot_targets_requested=$hotTargetsRequested hot_targets_warmed=$hotTargetsWarmed " . "max_departments_per_run=$maxDepartmentsPerRun hot_limit=$hotLimit hot_ttl=$hotTtl " . "duration={$duration}s\n"; } /** * GoalsProgressAlertsCron * * Iterates all active department goals and, based on each goal's criteria, sends progress alerts * according to frequency, selected weekdays, and an optional time-of-day with timezone. * * Scheduling rules (defaults when not fully specified): * - Frequency NONE: skip. * - DAILY: send once per calendar day at the configured time (or at :00 of the current minute if no time provided). * - WEEKLY: send once per selected weekday(s) at the configured time (if none selected, Monday). * - MONTHLY: send once on the 1st of each month at the configured time (00:00 if not provided). * - CHANGED: send when the computed progress value changes since the last check (debounced to 1 hour). * * Destinations: * - SLACK: send to each department's configured webhook; if none, fallback to default Slack webhook. * - EMAIL/SMS: currently require explicit recipients in manual route; automatic cron will skip and log a warning. * @throws Exception */ function GoalsProgressAlertsCron(): void { // Run department overview goals mondays if ((int)date('N') === 1) { // Check if the time is between 07:00 and 17:00 if (!redis->exists('system:last_sent_monday_message_v2') && (date('H') >= 7 && date('H') < 17)) { // Set the key to expire in 24 hours redis->set('system:last_sent_monday_message_v2', (string)time()); redis->expire('system:last_sent_monday_message_v2', 86400); $department = new departments_o(); $days = 7; // The time should be from 00:00:00 of the start date to 23:59:59 of the end date $date_end = date('Y-m-d 23:59:59', strtotime("-1 days")); // Yesterday (Sunday) at 23:59:59 $date_start = date('Y-m-d 00:00:00', strtotime("-$days days")); // $days ago at 00:00:00 // Send the messages $departments = [ 1, 2, 3, 4, 5, 6, 7, 13, 14, 84, ]; $department->sendSlackInternalStatisticNotification($date_start, $date_end, $departments); } } // List all goals (not deleted) $goals = new department_goals_o(); $rows = $goals->listObjects(function ($row) { return (int)$row['id']; }); if (!is_array($rows) || count($rows) === 0) { return; } $nowUtc = new DateTimeImmutable('now', new DateTimeZone('UTC')); foreach ($rows as $goalId) { try { $goal = (new department_goals_o())->select($goalId); if (!$goal->exists()) { continue; } // Skip soft-deleted if (method_exists($goal, 'deleted_at') && !empty((string)$goal->deleted_at->value())) { continue; } $criteriaArray = (array)$goal->criteria->value(); // Build criteria object $criteria = goals_criteria::fromJson(json_encode($criteriaArray)); $criteria->validateAndSanitize(); $frequency = $criteria->progress_alert_frequency ?? Freq::NONE; if ($frequency === Freq::NONE) { continue; } // Check if due $dueInfo = goalsProgressAlertDue($criteria, $nowUtc); // Special handling for CHANGED: only proceed if progress value changed since last send if (($frequency === Freq::CHANGED)) { $currentProgress = goals_criteria::calculateProgressFromArray($criteriaArray); $lastProgressKey = 'goal_alert_last_progress:' . $goalId; $lastProgress = redis->get($lastProgressKey) ?? null; if ($lastProgress !== null && (string)$lastProgress === (string)$currentProgress) { continue; // no change } // For CHANGED, dedupe by progress value so each distinct value sends once $dueInfo['slot'] = 'changed-' . md5((string)$currentProgress); $dueInfo['ttl'] = 86400 * 30; // keep dedupe for a while } else { if (!$dueInfo['due']) { continue; } } // Deduplicate per goal per slot via Redis using an atomic reservation. // This prevents two concurrent cron runners from both sending the same alert. $slotKey = $dueInfo['slot']; $redisKey = 'goal_alert_sent:' . $goalId . ':' . $slotKey; $ttl = max(1, (int)$dueInfo['ttl']); if (!redis->set_if_absent_with_expiration($redisKey, '1', $ttl)) { continue; } // Render message $message = goals_progress_alert_renderer::render($criteria); // Dispatch according to destination $destination = $criteria->progress_alert_destination ?? Dest::SLACK; $sent = false; try { switch ($destination) { case Dest::SLACK: $departments = (array)$goal->departments->value(); $sentToDept = false; if (count($departments) > 0) { foreach ($departments as $deptId) { if (!is_numeric($deptId)) { continue; } $dept = (new departments_o())->select((int)$deptId); if (!$dept->exists()) { continue; } $webhook = (string)$dept->slack_webhook->value(); if (empty($webhook)) { continue; } (new Slack())->send_webhook_message((string)goals_progress_alert_renderer::render($criteria, $dept), $webhook); $sentToDept = true; } } if (!$sentToDept) { // Fallback to default webhook (new Slack())->send_message($message); } $sent = true; break; case Dest::EMAIL: // No recipient context in goal for automated cron warn('GoalsProgressAlertsCron: EMAIL destination requires explicit recipients; skipping goal #' . $goalId); break; case Dest::SMS: // No recipient context in goal for automated cron warn('GoalsProgressAlertsCron: SMS destination requires explicit recipients; skipping goal #' . $goalId); break; default: // Unsupported or NONE warn('GoalsProgressAlertsCron: Unsupported destination for goal #' . $goalId); } } catch (Throwable $dispatchError) { if (!$sent) { redis->delete($redisKey); } throw $dispatchError; } // Track last progress for CHANGED if ($frequency === Freq::CHANGED) { $progressVal = goals_criteria::calculateProgressFromArray($criteriaArray); redis->set('goal_alert_last_progress:' . $goalId, (string)$progressVal); if (method_exists(redis, 'expire')) { redis->expire('goal_alert_last_progress:' . $goalId, 86400 * 30); } } } catch (Throwable $e) { warn('GoalsProgressAlertsCron error for goal #' . $goalId . ': ' . $e->getMessage()); } } } /** * Decide if an alert is due for the given criteria at the provided UTC time. * Returns ['due' => bool, 'slot' => string, 'ttl' => int] */ function goalsProgressAlertDue(goals_criteria $criteria, DateTimeImmutable $nowUtc): array { $frequency = $criteria->progress_alert_frequency ?? Freq::NONE; // Determine alert time in a timezone (defaults to UTC current minute) $timeStr = $criteria->progress_alert_time_of_day; $alertMinuteUtc = null; // DateTimeImmutable normalized to the scheduled minute in UTC $slot = ''; $ttl = 3600; // default TTL window for deduplication $weekdays = $criteria->progress_alert_weekdays ?? []; $weekdayNames = array_map(fn($e) => ($e instanceof UnitEnum ? $e->name : (string)$e), $weekdays); // Helper to build DateTime at today with provided HH:MM in given offset $buildTodayAt = function (string $hhmmTz) use ($nowUtc): ?DateTimeImmutable { // Convert "HH:MMZ" or with offset to a concrete time today if (!preg_match('/^([01]\d|2[0-3]):([0-5]\d)(Z|[+-](?:[01]\d|2[0-3]):?[0-5]\d)$/', $hhmmTz)) { return null; } // Extract parts [$h, $m, $tz] = [substr($hhmmTz,0,2), substr($hhmmTz,3,2), substr($hhmmTz,5)]; $tzStr = $tz; if ($tzStr === 'Z') { $tzStr = '+00:00'; } if (preg_match('/^[+-]\d{4}$/', $tzStr)) { // Normalize +HHMM $tzStr = substr($tzStr,0,3) . ':' . substr($tzStr,3,2); } $localTz = new DateTimeZone($tzStr); $todayLocal = new DateTimeImmutable('now', $localTz); $scheduledLocal = $todayLocal->setTime((int)$h, (int)$m, 0, 0); // Convert to UTC return $scheduledLocal->setTimezone(new DateTimeZone('UTC')); }; // Build scheduled time today (or fallback to current minute) if (is_string($timeStr) && $timeStr !== '') { $alertMinuteUtc = $buildTodayAt($timeStr); } if (!$alertMinuteUtc) { // Fallback: use current minute $alertMinuteUtc = $nowUtc->setTime((int)$nowUtc->format('H'), (int)$nowUtc->format('i'), 0, 0); } $nowMinute = $nowUtc->setTime((int)$nowUtc->format('H'), (int)$nowUtc->format('i'), 0, 0); switch ($frequency) { case Freq::DAILY: $slot = $nowUtc->format('Y-m-d'); $ttl = 86400; // 1 day // If weekdays specified, restrict to those if (!empty($weekdayNames)) { $phpWeekday = strtoupper($nowUtc->format('l')); if (!in_array($phpWeekday, $weekdayNames, true)) { return ['due' => false, 'slot' => $slot, 'ttl' => $ttl]; } } return ['due' => ($nowMinute == $alertMinuteUtc), 'slot' => $slot, 'ttl' => $ttl]; case Freq::WEEKLY: $slot = $nowUtc->format('o-W') . (empty($weekdayNames) ? '-MONDAY' : '-' . strtoupper($nowUtc->format('l'))); $ttl = 86400 * 7; // Default to Monday if none specified if (empty($weekdayNames)) { $isMonday = ($nowUtc->format('N') === '1'); return ['due' => $isMonday && ($nowMinute == $alertMinuteUtc), 'slot' => $slot, 'ttl' => $ttl]; } $todayName = strtoupper($nowUtc->format('l')); if (!in_array($todayName, $weekdayNames, true)) { return ['due' => false, 'slot' => $slot, 'ttl' => $ttl]; } return ['due' => ($nowMinute == $alertMinuteUtc), 'slot' => $slot, 'ttl' => $ttl]; case Freq::MONTHLY: $slot = $nowUtc->format('Y-m'); $ttl = 86400 * 31; $isFirstOfMonth = ($nowUtc->format('j') === '1'); return ['due' => $isFirstOfMonth && ($nowMinute == $alertMinuteUtc), 'slot' => $slot, 'ttl' => $ttl]; case Freq::CHANGED: // Debounce to 1 hour per change $ttl = 3600; $slot = $nowUtc->format('Y-m-d-H'); $lastProgress = redis->get('goal_alert_last_progress:' . ($criteria->label ?? '')) ?? null; // We'll override with goal id upstream // Always mark due here; upstream dedupe per goal id + hour slot applies, but we also check change upstream return ['due' => true, 'slot' => $slot, 'ttl' => $ttl]; default: return ['due' => false, 'slot' => '', 'ttl' => 3600]; } } function SelfserveOpeningRelayActivationCron(): array { global $db; if (!($db instanceof \classes\db)) { warn('SelfserveOpeningRelayActivationCron skipped: database connection is unavailable.'); return [ 'skipped' => true, 'reason' => 'database_unavailable', ]; } $now = new DateTimeImmutable('now', new DateTimeZone('Europe/Copenhagen')); $candidates = selfserveOpeningCleanerRelayActivationCandidates($now); $summary = [ 'checked_departments' => count($candidates), 'disabled_departments' => 0, 'activated_departments' => 0, 'skipped_departments' => 0, 'failed_departments' => 0, 'activated_relays' => 0, 'skipped_relays' => 0, 'failed_relays' => 0, ]; foreach ($candidates as $candidate) { $departmentId = (int)($candidate['department_id'] ?? 0); $opensAt = (string)($candidate['opens_at'] ?? ''); $openingDate = (string)($candidate['opening_date'] ?? $now->format('Y-m-d')); if ($departmentId <= 0 || $opensAt === '') { $summary['skipped_departments']++; continue; } $cacheKey = selfserveOpeningCleanerRelayActivationKey($departmentId, $opensAt, $openingDate); try { $transition = department_variables_o::withSelfServeTransitionLock( $departmentId, static function () use ($departmentId): array { $departmentSummary = selfserveActivateStaffedDefaultRelaysForDepartment($departmentId); if ((int)$departmentSummary['failed'] > 0) { return [ 'department_summary' => $departmentSummary, 'department_disabled' => false, ]; } return [ 'department_summary' => $departmentSummary, 'department_disabled' => selfserveDisableDepartmentSelfServeForOpening($departmentId), ]; }, 0 ); } catch (Throwable $throwable) { $summary['failed_departments']++; warn( 'SelfserveOpeningRelayActivationCron could not serialize transition for department ' . $departmentId . ': ' . $throwable->getMessage() ); continue; } $departmentSummary = $transition['department_summary']; $summary['activated_relays'] += (int)$departmentSummary['activated']; $summary['skipped_relays'] += (int)$departmentSummary['skipped']; $summary['failed_relays'] += (int)$departmentSummary['failed']; if ((int)$departmentSummary['failed'] > 0) { $summary['failed_departments']++; continue; } $departmentDisabled = (bool)$transition['department_disabled']; if (!$departmentDisabled) { $summary['failed_departments']++; continue; } $summary['disabled_departments']++; selfserveMarkOpeningCleanerRelayActivationHandled($cacheKey); if ((int)$departmentSummary['activated'] > 0) { $summary['activated_departments']++; } else { $summary['skipped_departments']++; } } echo "[" . date('Y-m-d H:i:s') . "][CRON] SelfserveOpeningRelayActivationCron: " . $summary['disabled_departments'] . " departments disabled self-serve, " . $summary['activated_relays'] . " staffed-default relays activated across " . $summary['activated_departments'] . " departments.\n"; return $summary; } function selfserveOpeningCleanerRelayActivationCandidates(DateTimeImmutable $now): array { global $db; $weekday = strtolower($now->format('l')); $allowedWeekdays = ['monday', 'tuesday', 'wednesday', 'thursday', 'friday', 'saturday', 'sunday']; if (!in_array($weekday, $allowedWeekdays, true)) { return []; } $startColumn = $weekday . '_start'; $endColumn = $weekday . '_end'; $previousDate = $now->modify('-1 day'); $previousWeekday = strtolower($previousDate->format('l')); $previousStartColumn = $previousWeekday . '_start'; $previousEndColumn = $previousWeekday . '_end'; $sql = " SELECT oh.department AS department_id, TIME_FORMAT(oh.`$startColumn`, '%H:%i:%s') AS current_opens_at, TIME_FORMAT(oh.`$endColumn`, '%H:%i:%s') AS current_closes_at, TIME_FORMAT(oh.`$previousStartColumn`, '%H:%i:%s') AS previous_opens_at, TIME_FORMAT(oh.`$previousEndColumn`, '%H:%i:%s') AS previous_closes_at FROM department_time_bookings_opening_hours oh INNER JOIN department_variables dv ON dv.department_id = oh.department WHERE dv.variable = 'selfserve_enabled' AND LOWER(TRIM(COALESCE(dv.value, ''))) IN ('true', '1', 'yes', 'on') AND ( (oh.`$startColumn` IS NOT NULL AND oh.`$endColumn` IS NOT NULL) OR (oh.`$previousStartColumn` IS NOT NULL AND oh.`$previousEndColumn` IS NOT NULL) ) GROUP BY oh.department, oh.`$startColumn`, oh.`$endColumn`, oh.`$previousStartColumn`, oh.`$previousEndColumn` "; $result = $db->query($sql); if (!$result instanceof mysqli_result) { return []; } $candidates = []; foreach ($db->fetch_all($result) as $row) { $currentOpensAt = (string)($row['current_opens_at'] ?? ''); $currentClosesAt = (string)($row['current_closes_at'] ?? ''); if (selfserveOpeningCleanerRelayWindowActive($now, $currentOpensAt, $currentClosesAt)) { $candidates[] = [ 'department_id' => $row['department_id'] ?? null, 'opens_at' => $currentOpensAt, 'closes_at' => $currentClosesAt, 'opening_date' => $now->format('Y-m-d'), ]; continue; } $previousOpensAt = (string)($row['previous_opens_at'] ?? ''); $previousClosesAt = (string)($row['previous_closes_at'] ?? ''); if (selfserveOpeningCleanerRelayOvernightCarryoverActive($now, $previousOpensAt, $previousClosesAt)) { $candidates[] = [ 'department_id' => $row['department_id'] ?? null, 'opens_at' => $previousOpensAt, 'closes_at' => $previousClosesAt, 'opening_date' => $previousDate->format('Y-m-d'), ]; } } return $candidates; } function selfserveOpeningCleanerRelayWindowActive( DateTimeImmutable $now, ?string $opensAt, ?string $closesAt ): bool { $opensAtSeconds = selfserveOpeningCleanerRelayTimeToSeconds($opensAt); $closesAtSeconds = selfserveOpeningCleanerRelayTimeToSeconds($closesAt); if ($opensAtSeconds === null || $closesAtSeconds === null || $opensAtSeconds === $closesAtSeconds) { return false; } $nowSeconds = ((int)$now->format('G') * 3600) + ((int)$now->format('i') * 60) + (int)$now->format('s'); if ($opensAtSeconds < $closesAtSeconds) { return $nowSeconds >= $opensAtSeconds && $nowSeconds < $closesAtSeconds; } return $nowSeconds >= $opensAtSeconds; } function selfserveOpeningCleanerRelayOvernightCarryoverActive( DateTimeImmutable $now, ?string $opensAt, ?string $closesAt ): bool { $opensAtSeconds = selfserveOpeningCleanerRelayTimeToSeconds($opensAt); $closesAtSeconds = selfserveOpeningCleanerRelayTimeToSeconds($closesAt); if ($opensAtSeconds === null || $closesAtSeconds === null || $opensAtSeconds <= $closesAtSeconds) { return false; } $nowSeconds = ((int)$now->format('G') * 3600) + ((int)$now->format('i') * 60) + (int)$now->format('s'); return $nowSeconds < $closesAtSeconds; } function selfserveOpeningCleanerRelayTimeToSeconds(?string $time): ?int { $time = trim((string)$time); if ($time === '') { return null; } if (!preg_match('/^(\d{1,2}):(\d{2})(?::(\d{2}))?$/', $time, $matches)) { return null; } $hours = (int)$matches[1]; $minutes = (int)$matches[2]; $seconds = isset($matches[3]) ? (int)$matches[3] : 0; if ($hours > 23 || $minutes > 59 || $seconds > 59) { return null; } return ($hours * 3600) + ($minutes * 60) + $seconds; } function selfserveDisableDepartmentSelfServeForOpening(int $departmentId): bool { try { (new department_variables_o()) ->selectDepartment($departmentId) ->set('selfserve_enabled', 'false'); return true; } catch (Throwable $throwable) { warn( 'SelfserveOpeningRelayActivationCron failed to disable self-serve for department ' . $departmentId . ': ' . $throwable->getMessage() ); return false; } } function selfserveActivateStaffedDefaultRelaysForDepartment(int $departmentId): array { $summary = [ 'activated' => 0, 'skipped' => 0, 'failed' => 0, ]; $selfserve = new selfserve(); $lanes = (new department_lanes_o())->getDepartmentLanes($departmentId); foreach ($lanes as $departmentLane) { if (!($departmentLane instanceof department_lanes_o)) { $summary['skipped']++; continue; } if (!selfserveLaneHasAnyConfiguredStaffedDefaultRelay($departmentLane)) { $summary['skipped']++; continue; } $laneId = (int)$departmentLane->id; if ($laneId <= 0) { $summary['skipped']++; continue; } try { $lane = $selfserve->lane($laneId); $summary['activated'] += selfserveSetOptionalLaneRelayState($lane, 'relay_machine_program_picker_id', static function () use ($lane): void { $lane->setMachineProgramPickerRelayStatusForDepartmentOperation(true); }); $summary['activated'] += selfserveSetOptionalLaneRelayState($lane, 'relay_machine_cleaner_id', static function () use ($lane): void { $lane->setMachineCleanerRelayStatusForDepartmentOperation(true); }); $summary['activated'] += selfserveSetOptionalLaneRelayState($lane, 'relay_machine_id', static function () use ($lane): void { $lane->setMachineRelayStatusForDepartmentOperation(true); }); } catch (Throwable $throwable) { $summary['failed']++; warn( 'SelfserveOpeningRelayActivationCron failed for department ' . $departmentId . ', lane ' . $laneId . ': ' . $throwable->getMessage() ); } } return $summary; } function selfserveLaneHasAnyConfiguredStaffedDefaultRelay(?object $departmentLane): bool { return selfserveLaneHasConfiguredRelay($departmentLane, 'relay_machine_program_picker_id') || selfserveLaneHasConfiguredRelay($departmentLane, 'relay_machine_cleaner_id') || selfserveLaneHasConfiguredRelay($departmentLane, 'relay_machine_id'); } function selfserveLaneHasConfiguredCleanerRelay(?object $departmentLane): bool { return selfserveLaneHasConfiguredRelay($departmentLane, 'relay_machine_cleaner_id'); } function selfserveLaneHasConfiguredRelay(?object $departmentLane, string $relayProperty): bool { if ( $departmentLane === null || !isset($departmentLane->{$relayProperty}) || !is_object($departmentLane->{$relayProperty}) || !method_exists($departmentLane->{$relayProperty}, 'value') ) { return false; } try { $relayId = trim((string)$departmentLane->{$relayProperty}->value()); } catch (Throwable) { return false; } return $relayId !== '' && $relayId !== '0' && strtolower($relayId) !== 'null'; } function selfserveSetOptionalLaneRelayState(object $lane, string $relayProperty, callable $callback): int { if (!selfserveLaneHasConfiguredRelay($lane->department_lane ?? null, $relayProperty)) { return 0; } try { $callback(); return 1; } catch (Throwable $throwable) { warn( 'SelfserveOpeningRelayActivationCron failed to activate relay ' . $relayProperty . ': ' . $throwable->getMessage() ); throw $throwable; } } function selfserveOpeningCleanerRelayActivationKey( int $departmentId, string $opensAt, string $openingDate ): string { $normalizedOpensAt = preg_replace('/[^0-9]/', '', $opensAt) ?: 'unknown'; return 'selfserve:opening-cleaner-relays:' . $openingDate . ':' . $departmentId . ':' . $normalizedOpensAt; } function selfserveOpeningCleanerRelayActivationAlreadyHandled(string $cacheKey): bool { if (!defined('redis')) { return false; } try { return redis->exists($cacheKey); } catch (Throwable) { return false; } } function selfserveMarkOpeningCleanerRelayActivationHandled(string $cacheKey): void { if (!defined('redis')) { return; } try { redis->setEx($cacheKey, '1', 36 * 3600); } catch (Throwable) { // Redis idempotency should not block relay activation. } } if (defined('CRON_LOAD_LEGACY_FUNCTIONS_ONLY') && CRON_LOAD_LEGACY_FUNCTIONS_ONLY) { return; } try { $response_cron = (new \classes\cron_scheduler())->runDue('automatic'); } catch (Throwable $throwable) { warn('Cron scheduler failed: ' . $throwable->getMessage()); $response_cron = [ 'ran' => [], 'count' => 0, 'error' => $throwable->getMessage(), ]; }