Files
api/services/nginx/app/cron/Cron.php
T
Jeppe B da0113e3ed Auto-disable department self-serve at opening (#323)
Auto-disable department self-serve at opening
2026-07-28 18:03:05 +02:00

1928 lines
64 KiB
PHP

<?php
// prevent direct access
use classes\backup_store;
use classes\account_deletion_service;
use classes\economic;
use classes\economic_transfer_queue;
use classes\invoice_period_flag_service;
use classes\coolify_manager;
use classes\replication_manager;
use classes\redis;
use classes\selfserve;
use classes\system_search_cache;
use classes\system_search_document_index;
use classes\system_search_economic_customer_index;
use classes\system_search_registry;
use classes\workfeed;
use classes\workfeed_employee_name_formatter;
use classes\xlvask;
use classes\slack as Slack;
use classes\email as Email;
use classes\gatewayapi as GatewayAPI;
use dynamicimages\images\machine_1;
use modules\selfserve\config\selfserve_dynamic_image_size_c;
use modules\selfserve\selfserve_c;
use goals\classes\goals_criteria;
use goals\services\goals_progress_alert_renderer;
use goals\helpers\goals_criteria_progress_alert_destination as Dest;
use goals\helpers\goals_criteria_progress_alert_frequency as Freq;
use objects\department_lanes_o;
use objects\department_goals_o;
use objects\department_selfserve_tasks_o;
use objects\department_variables_o;
use objects\departments_o;
use objects\bookings_o;
use objects\logs_o;
use objects\users_o;
use routes\moduleWeatherAPIRoute;
require_once __DIR__ . '/../classes/economic_transfer_executor.php';
const DYNAMIC_IMAGE_RELEVANT_MAX_WIDTH = 1600;
require_once __DIR__ . '/../classes/economic_transfer_queue_schema_bootstrap.php';
require_once __DIR__ . '/../classes/economic_transfer_queue.php';
require_once __DIR__ . '/../classes/backup_schema_bootstrap.php';
require_once __DIR__ . '/../classes/backup_store.php';
require_once __DIR__ . '/../classes/workfeed_employee_name_formatter.php';
require_once __DIR__ . '/../classes/cron_schedule.php';
require_once __DIR__ . '/../classes/cron_task_definition.php';
require_once __DIR__ . '/../classes/cron_task_registry.php';
require_once __DIR__ . '/../classes/cron_schema_bootstrap.php';
require_once __DIR__ . '/../classes/cron_scheduler.php';
if (!defined('WD')) {
exit;
}
// Define the warning function
function warn($message): void
{
echo "\n\033[33m$message\033[0m\n";
}
$response_cron = [];
// Define the cron tasks
$cron_tasks = [
'ProcessAccountDeletionRequestsCron' => [
'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<int,mixed>
*/
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<int,string>
*/
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<int,array{vehicle_type:int|null,rows:array<int,array<string,mixed>>}>
*/
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<int,array<string,mixed>> $taskRows
* @return array<int,array{dynamic_image_id:int,buttons:array<int|string>|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<int|string>|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<int|string>|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<int|string>
*/
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<int|string> $base
* @param array<int|string> $append
* @return array<int|string>
*/
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(),
];
}