Compare commits

...
Author SHA1 Message Date
Jeppe Bundgaard b686e578f7 Activate self-serve opening relays 2026-07-09 11:26:03 +02:00
Jeppe Bundgaard 6a00f023b1 Refactor cron scheduling 2026-07-09 11:04:13 +02:00
Jeppe B 8aefbd8fb3 Guard wash subscription distribution SQL
Guard the wash subscription distribution query after invoice-inclusion filtering removes all candidate orders, preventing an empty IN () clause on the invoicing distribution endpoint.

Verified with focused syntax, Pest, PHPStan, and invoicing unit-suite checks.
2026-07-09 10:24:02 +02:00
27 changed files with 1663 additions and 55 deletions
@@ -0,0 +1,58 @@
<?php
namespace classes;
use InvalidArgumentException;
class cron_schedule
{
public static function normalize(array $schedule): array
{
$type = strtolower(trim((string)($schedule['type'] ?? 'interval')));
if ($type !== 'interval') {
throw new InvalidArgumentException('Unsupported cron schedule type: ' . $type);
}
$seconds = (int)($schedule['seconds'] ?? $schedule['interval'] ?? 0);
if ($seconds < 30 || $seconds > 2678400) {
throw new InvalidArgumentException('Cron interval must be between 30 seconds and 31 days.');
}
return [
'type' => 'interval',
'seconds' => $seconds,
];
}
public static function nextRunAt(array $schedule, ?string $anchorDateTime, int $now): string
{
$normalized = self::normalize($schedule);
$anchor = $anchorDateTime !== null && trim($anchorDateTime) !== ''
? strtotime($anchorDateTime)
: false;
$base = $anchor !== false ? (int)$anchor : $now;
$next = $base + (int)$normalized['seconds'];
if ($next <= $now) {
$missed = (int)floor(($now - $next) / (int)$normalized['seconds']) + 1;
$next += $missed * (int)$normalized['seconds'];
}
return date('Y-m-d H:i:s', $next);
}
public static function dueAt(array $schedule, ?string $lastRunAt, int $now, ?int $legacyLastRun = null): string
{
$normalized = self::normalize($schedule);
if ($lastRunAt !== null && trim($lastRunAt) !== '') {
return self::nextRunAt($normalized, $lastRunAt, $now);
}
if ($legacyLastRun !== null && $legacyLastRun > 0) {
return date('Y-m-d H:i:s', $legacyLastRun + (int)$normalized['seconds']);
}
return date('Y-m-d H:i:s', $now);
}
}
@@ -0,0 +1,464 @@
<?php
namespace classes;
use RuntimeException;
use Throwable;
class cron_scheduler
{
private cron_task_registry $registry;
private string $lock_owner;
public function __construct(?cron_task_registry $registry = null)
{
$this->registry = $registry ?? new cron_task_registry();
$this->lock_owner = gethostname() . ':' . getmypid() . ':' . bin2hex(random_bytes(4));
}
public function listTasks(): array
{
$this->ensureReady();
$this->syncDefinitions();
$states = $this->stateRows();
$estimates = $this->durationEstimates();
$tasks = [];
$now = time();
foreach ($this->registry->definitions() as $definition) {
$state = $states[$definition->id] ?? [];
$schedule = is_array($state['schedule'] ?? null) && $state['schedule'] !== []
? $state['schedule']
: $definition->schedule;
$nextRunAt = $state['next_run_at'] ?? null;
if ($nextRunAt === null || trim((string)$nextRunAt) === '') {
$nextRunAt = cron_schedule::dueAt($schedule, $state['last_run_at'] ?? null, $now);
}
$task = $definition->asArray($state + ['next_run_at' => $nextRunAt], $estimates[$definition->id] ?? null);
$task['due'] = strtotime($nextRunAt) !== false && strtotime($nextRunAt) <= $now;
$task['seconds_until_due'] = max(0, (int)strtotime($nextRunAt) - $now);
$tasks[] = $task;
}
return [
'tasks' => $tasks,
'summary' => [
'total' => count($tasks),
'enabled' => count(array_filter($tasks, static fn(array $task): bool => (bool)$task['enabled'])),
'due' => count(array_filter($tasks, static fn(array $task): bool => (bool)$task['due'] && (bool)$task['enabled'])),
],
];
}
public function listRuns(?string $task_id = null, int $limit = 50): array
{
$this->ensureReady();
$limit = max(1, min(200, $limit));
$where = '';
if ($task_id !== null && trim($task_id) !== '') {
$where = "WHERE task_id = " . $this->sql($task_id);
}
return $this->fetchAll(
"SELECT * FROM cron_task_runs $where ORDER BY id DESC LIMIT $limit"
);
}
public function runDue(string $source = 'automatic'): array
{
$this->ensureReady();
$this->syncDefinitions();
$ran = [];
$now = time();
$states = $this->stateRows();
foreach ($this->registry->definitions() as $definition) {
$state = $states[$definition->id] ?? [];
if (!(bool)($state['enabled'] ?? $definition->enabled)) {
continue;
}
$nextRunAt = (string)($state['next_run_at'] ?? '');
if ($nextRunAt === '' || strtotime($nextRunAt) === false || strtotime($nextRunAt) > $now) {
continue;
}
try {
$ran[] = $this->runTask($definition->id, $source, null, false, $nextRunAt);
} catch (Throwable $throwable) {
$ran[] = [
'task_id' => $definition->id,
'module' => $definition->module,
'source' => $source,
'status' => 'skipped',
'error_message' => $throwable->getMessage(),
];
}
}
return [
'ran' => $ran,
'count' => count($ran),
];
}
public function runTask(
string $task_id_or_legacy_name,
string $source = 'manual',
?int $actor_user_id = null,
bool $force = false,
?string $scheduled_for = null
): array {
$this->ensureReady();
$this->syncDefinitions();
$definition = $this->registry->get($task_id_or_legacy_name);
if ($definition === null) {
throw new RuntimeException('Cron task not found.');
}
$state = $this->stateRows()[$definition->id] ?? [];
$enabled = (bool)($state['enabled'] ?? $definition->enabled);
if (!$enabled && !$force) {
throw new RuntimeException('Cron task is disabled.');
}
if (!$this->claimLock($definition)) {
throw new RuntimeException('Cron task is already running.');
}
$started = microtime(true);
$started_at = date('Y-m-d H:i:s', (int)$started);
$run_id = $this->createRun($definition, $source, $actor_user_id, $scheduled_for, $started_at);
$this->query(
"UPDATE cron_task_state SET current_run_id = $run_id WHERE task_id = " . $this->sql($definition->id)
);
$status = 'succeeded';
$summary = [];
$error_message = null;
$output = '';
try {
if (function_exists('set_time_limit')) {
@set_time_limit($definition->timeout_seconds + 30);
}
$this->ensureLegacyFunctionsLoaded($definition);
if (!is_callable($definition->handler)) {
throw new RuntimeException('Cron task handler is not callable: ' . $definition->handler);
}
ob_start();
$result = call_user_func($definition->handler);
$output = (string)ob_get_clean();
$summary = is_array($result) ? $result : [];
} catch (Throwable $throwable) {
if (ob_get_level() > 0) {
$output .= (string)ob_get_clean();
}
$status = 'failed';
$error_message = $throwable->getMessage();
}
$completed = microtime(true);
$duration_ms = (int)round(($completed - $started) * 1000);
if ($duration_ms > ($definition->timeout_seconds * 1000) && $status === 'succeeded') {
$status = 'timed_out';
$error_message = 'Task exceeded its configured timeout window.';
}
if ($output !== '') {
$summary['output'] = substr($output, 0, 8000);
}
$completed_at = date('Y-m-d H:i:s', (int)$completed);
$this->completeRun($run_id, $status, $completed_at, $duration_ms, $summary, $error_message);
$this->releaseLock($definition, $status, $error_message, $completed_at);
$run = $this->fetchOne("SELECT * FROM cron_task_runs WHERE id = $run_id") ?? [];
$run['summary'] = $this->decodeJson($run['summary_json'] ?? null);
return $run;
}
public function updateTaskConfig(string $task_id, array $config): array
{
$this->ensureReady();
$this->syncDefinitions();
$definition = $this->registry->get($task_id);
if ($definition === null) {
throw new RuntimeException('Cron task not found.');
}
$updates = [];
if (array_key_exists('enabled', $config)) {
$updates[] = 'enabled = ' . ((bool)$config['enabled'] ? '1' : '0');
}
if (array_key_exists('schedule', $config)) {
$schedule = $config['schedule'] === null ? null : cron_schedule::normalize((array)$config['schedule']);
$updates[] = 'schedule_json = ' . ($schedule === null ? 'NULL' : $this->sql(json_encode($schedule)));
$anchor = (string)($this->fetchOne("SELECT last_run_at FROM cron_task_state WHERE task_id = " . $this->sql($definition->id))['last_run_at'] ?? '');
$updates[] = 'next_run_at = ' . $this->sql(cron_schedule::dueAt($schedule ?? $definition->schedule, $anchor !== '' ? $anchor : null, time()));
}
if ($updates !== []) {
$this->query(
"UPDATE cron_task_state SET " . implode(', ', $updates) . " WHERE task_id = " . $this->sql($definition->id)
);
}
return $this->listTasks();
}
private function ensureReady(): void
{
cron_schema_bootstrap::ensureTables();
}
private function syncDefinitions(): void
{
$now = time();
foreach ($this->registry->definitions() as $definition) {
$row = $this->fetchOne(
"SELECT * FROM cron_task_state WHERE task_id = " . $this->sql($definition->id)
);
if ($row !== null) {
continue;
}
$legacyLastRun = $this->legacyLastRun($definition);
$nextRunAt = cron_schedule::dueAt($definition->schedule, null, $now, $legacyLastRun);
$this->query(
"INSERT INTO cron_task_state (task_id, module, enabled, schedule_json, next_run_at)
VALUES ("
. $this->sql($definition->id) . ', '
. $this->sql($definition->module) . ', '
. ($definition->enabled ? '1' : '0') . ', NULL, '
. $this->sql($nextRunAt)
. ")"
);
}
}
private function legacyLastRun(cron_task_definition $definition): ?int
{
if ($definition->legacy_name === null || !defined('redis')) {
return null;
}
try {
$last_run = redis->get_last_crond_run($definition->legacy_name);
return $last_run !== null ? (int)$last_run : null;
} catch (Throwable) {
return null;
}
}
private function claimLock(cron_task_definition $definition): bool
{
$now = date('Y-m-d H:i:s');
$locked_until = date('Y-m-d H:i:s', time() + $definition->timeout_seconds + 60);
$this->query(
"UPDATE cron_task_state
SET locked_until = " . $this->sql($locked_until) . ",
lock_owner = " . $this->sql($this->lock_owner) . "
WHERE task_id = " . $this->sql($definition->id) . "
AND (locked_until IS NULL OR locked_until < " . $this->sql($now) . ")"
);
return $this->affectedRows() === 1;
}
private function releaseLock(cron_task_definition $definition, string $status, ?string $error_message, string $completed_at): void
{
$state = $this->fetchOne("SELECT schedule_json FROM cron_task_state WHERE task_id = " . $this->sql($definition->id));
$schedule = $this->decodeJson($state['schedule_json'] ?? null);
if ($schedule === []) {
$schedule = $definition->schedule;
}
$nextRunAt = cron_schedule::nextRunAt($schedule, $completed_at, time());
if ($status !== 'succeeded') {
$retrySeconds = min(300, max(60, (int)$schedule['seconds']));
$nextRunAt = date('Y-m-d H:i:s', time() + $retrySeconds);
}
$this->query(
"UPDATE cron_task_state
SET last_run_at = " . $this->sql($completed_at) . ",
next_run_at = " . $this->sql($nextRunAt) . ",
locked_until = NULL,
lock_owner = NULL,
current_run_id = NULL,
last_status = " . $this->sql($status) . ",
last_error = " . $this->nullableSql($error_message) . "
WHERE task_id = " . $this->sql($definition->id) . "
AND lock_owner = " . $this->sql($this->lock_owner)
);
if ($definition->legacy_name !== null && defined('redis')) {
try {
redis->set_last_crond_run($definition->legacy_name, time());
} catch (Throwable) {
}
}
}
private function createRun(
cron_task_definition $definition,
string $source,
?int $actor_user_id,
?string $scheduled_for,
string $started_at
): int {
$this->query(
"INSERT INTO cron_task_runs
(task_id, module, source, status, actor_user_id, scheduled_for, started_at, lock_owner)
VALUES ("
. $this->sql($definition->id) . ', '
. $this->sql($definition->module) . ', '
. $this->sql($source) . ", 'running', "
. ($actor_user_id === null ? 'NULL' : (string)(int)$actor_user_id) . ', '
. $this->nullableSql($scheduled_for) . ', '
. $this->sql($started_at) . ', '
. $this->sql($this->lock_owner)
. ")"
);
return $this->insertId();
}
private function completeRun(
int $run_id,
string $status,
string $completed_at,
int $duration_ms,
array $summary,
?string $error_message
): void {
$this->query(
"UPDATE cron_task_runs
SET status = " . $this->sql($status) . ",
completed_at = " . $this->sql($completed_at) . ",
duration_ms = " . (string)$duration_ms . ",
summary_json = " . $this->sql(json_encode($summary, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES)) . ",
error_message = " . $this->nullableSql($error_message) . "
WHERE id = " . (string)$run_id
);
}
private function ensureLegacyFunctionsLoaded(cron_task_definition $definition): void
{
if (function_exists($definition->handler)) {
return;
}
if (!defined('WD')) {
return;
}
if (!defined('CRON_LOAD_LEGACY_FUNCTIONS_ONLY')) {
define('CRON_LOAD_LEGACY_FUNCTIONS_ONLY', true);
}
require_once WD . '/cron/Cron.php';
}
/**
* @return array<string, array<string, mixed>>
*/
private function stateRows(): array
{
$rows = $this->fetchAll("SELECT * FROM cron_task_state");
$states = [];
foreach ($rows as $row) {
$row['enabled'] = (bool)$row['enabled'];
$row['schedule'] = $this->decodeJson($row['schedule_json'] ?? null);
$states[(string)$row['task_id']] = $row;
}
return $states;
}
/**
* @return array<string, int>
*/
private function durationEstimates(): array
{
$rows = $this->fetchAll(
"SELECT task_id, AVG(duration_ms) AS avg_duration_ms
FROM (
SELECT task_id, duration_ms
FROM cron_task_runs
WHERE status = 'succeeded' AND duration_ms IS NOT NULL
ORDER BY id DESC
LIMIT 500
) recent_runs
GROUP BY task_id"
);
$estimates = [];
foreach ($rows as $row) {
$estimates[(string)$row['task_id']] = (int)round((float)$row['avg_duration_ms']);
}
return $estimates;
}
private function decodeJson(mixed $json): array
{
if (!is_string($json) || trim($json) === '') {
return [];
}
$decoded = json_decode($json, true);
return is_array($decoded) ? $decoded : [];
}
private function fetchOne(string $sql): ?array
{
$rows = $this->fetchAll($sql);
return $rows[0] ?? null;
}
private function fetchAll(string $sql): array
{
$result = $this->query($sql);
if ($result === false || $result === true) {
return [];
}
return $result->fetch_all(MYSQLI_ASSOC);
}
private function query(string $sql): \mysqli_result|bool
{
global $db;
return $db->query($sql);
}
private function sql(string $value): string
{
global $db;
return "'" . $db->escape_string($value) . "'";
}
private function nullableSql(?string $value): string
{
return $value === null ? 'NULL' : $this->sql($value);
}
private function affectedRows(): int
{
global $db;
return (int)$db->conn()->affected_rows;
}
private function insertId(): int
{
global $db;
return (int)$db->insert_id();
}
}
@@ -0,0 +1,66 @@
<?php
namespace classes;
class cron_schema_bootstrap
{
private static bool $initialized = false;
public static function ensureTables(): void
{
if (self::$initialized) {
return;
}
global $db;
if (!isset($db) || !is_object($db) || !method_exists($db, 'query')) {
return;
}
$db->query(
"CREATE TABLE IF NOT EXISTS cron_task_state (
task_id VARCHAR(191) NOT NULL PRIMARY KEY,
module VARCHAR(64) NOT NULL,
enabled TINYINT(1) NOT NULL DEFAULT 1,
schedule_json LONGTEXT NULL,
last_run_at DATETIME NULL,
next_run_at DATETIME NULL,
locked_until DATETIME NULL,
lock_owner VARCHAR(191) NULL,
current_run_id BIGINT UNSIGNED NULL,
last_status VARCHAR(32) NULL,
last_error TEXT NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NULL DEFAULT NULL ON UPDATE CURRENT_TIMESTAMP,
KEY idx_cron_task_state_next_run (enabled, next_run_at),
KEY idx_cron_task_state_lock (locked_until)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci"
);
$db->query(
"CREATE TABLE IF NOT EXISTS cron_task_runs (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
task_id VARCHAR(191) NOT NULL,
module VARCHAR(64) NOT NULL,
source VARCHAR(32) NOT NULL,
status VARCHAR(32) NOT NULL DEFAULT 'running',
actor_user_id INT NULL,
scheduled_for DATETIME NULL,
started_at DATETIME NULL,
completed_at DATETIME NULL,
duration_ms INT UNSIGNED NULL,
summary_json LONGTEXT NULL,
error_message TEXT NULL,
lock_owner VARCHAR(191) NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NULL DEFAULT NULL ON UPDATE CURRENT_TIMESTAMP,
KEY idx_cron_task_runs_task_created (task_id, created_at),
KEY idx_cron_task_runs_status_created (status, created_at),
KEY idx_cron_task_runs_module_created (module, created_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci"
);
self::$initialized = true;
}
}
@@ -0,0 +1,85 @@
<?php
namespace classes;
use InvalidArgumentException;
class cron_task_definition
{
public string $id;
public string $name;
public string $description;
public string $module;
public string $handler;
public array $schedule;
public bool $enabled;
public int $timeout_seconds;
public int $estimated_duration_ms;
public int $priority;
public ?string $legacy_name;
public function __construct(array $definition)
{
$this->id = self::requiredString($definition, 'id');
$this->name = self::requiredString($definition, 'name');
$this->description = (string)($definition['description'] ?? '');
$this->module = self::requiredString($definition, 'module');
$this->handler = self::requiredString($definition, 'handler');
$this->schedule = cron_schedule::normalize($definition['schedule'] ?? []);
$this->enabled = (bool)($definition['enabled'] ?? true);
$this->timeout_seconds = max(30, (int)($definition['timeout_seconds'] ?? 600));
$this->estimated_duration_ms = max(0, (int)($definition['estimated_duration_ms'] ?? 0));
$this->priority = (int)($definition['priority'] ?? 100);
$legacy_name = trim((string)($definition['legacy_name'] ?? ''));
$this->legacy_name = $legacy_name !== '' ? $legacy_name : null;
if (!preg_match('/^[a-z0-9][a-z0-9_.-]{1,190}$/', $this->id)) {
throw new InvalidArgumentException('Invalid cron task id: ' . $this->id);
}
if (!preg_match('/^[a-z0-9][a-z0-9_-]{1,63}$/', $this->module)) {
throw new InvalidArgumentException('Invalid cron task module: ' . $this->module);
}
}
public function asArray(?array $state = null, ?int $estimatedDurationMs = null): array
{
$schedule = is_array($state['schedule'] ?? null) && ($state['schedule'] ?? []) !== []
? $state['schedule']
: $this->schedule;
$enabled = array_key_exists('enabled', $state ?? [])
? (bool)$state['enabled']
: $this->enabled;
return [
'id' => $this->id,
'name' => $this->name,
'description' => $this->description,
'module' => $this->module,
'handler' => $this->handler,
'schedule' => $schedule,
'default_schedule' => $this->schedule,
'enabled' => $enabled,
'default_enabled' => $this->enabled,
'timeout_seconds' => $this->timeout_seconds,
'estimated_duration_ms' => $estimatedDurationMs ?? $this->estimated_duration_ms,
'priority' => $this->priority,
'legacy_name' => $this->legacy_name,
'last_run_at' => $state['last_run_at'] ?? null,
'next_run_at' => $state['next_run_at'] ?? null,
'locked_until' => $state['locked_until'] ?? null,
'lock_owner' => $state['lock_owner'] ?? null,
'current_run_id' => $state['current_run_id'] ?? null,
'last_status' => $state['last_status'] ?? null,
'last_error' => $state['last_error'] ?? null,
];
}
private static function requiredString(array $definition, string $key): string
{
$value = trim((string)($definition[$key] ?? ''));
if ($value === '') {
throw new InvalidArgumentException('Missing cron task definition field: ' . $key);
}
return $value;
}
}
@@ -0,0 +1,85 @@
<?php
namespace classes;
use InvalidArgumentException;
class cron_task_registry
{
private string $modules_root;
/** @var array<string, cron_task_definition>|null */
private ?array $definitions = null;
public function __construct(?string $modules_root = null)
{
$this->modules_root = $modules_root ?? (defined('WD') ? WD . '/modules' : dirname(__DIR__) . '/modules');
}
/**
* @return array<string, cron_task_definition>
*/
public function definitions(): array
{
if ($this->definitions !== null) {
return $this->definitions;
}
$definitions = [];
foreach ($this->definitionFiles() as $file) {
$module_definitions = require $file;
if (!is_array($module_definitions)) {
throw new InvalidArgumentException('Cron definition file must return an array: ' . $file);
}
foreach ($module_definitions as $definition) {
$task = new cron_task_definition($definition);
if (isset($definitions[$task->id])) {
throw new InvalidArgumentException('Duplicate cron task id: ' . $task->id);
}
$definitions[$task->id] = $task;
}
}
uasort($definitions, static function (cron_task_definition $left, cron_task_definition $right): int {
if ($left->priority !== $right->priority) {
return $left->priority <=> $right->priority;
}
return strcmp($left->id, $right->id);
});
$this->definitions = $definitions;
return $definitions;
}
public function get(string $id_or_legacy_name): ?cron_task_definition
{
$normalized = trim($id_or_legacy_name);
if ($normalized === '') {
return null;
}
$definitions = $this->definitions();
if (isset($definitions[$normalized])) {
return $definitions[$normalized];
}
foreach ($definitions as $definition) {
if ($definition->legacy_name !== null && hash_equals($definition->legacy_name, $normalized)) {
return $definition;
}
}
return null;
}
/**
* @return array<int, string>
*/
private function definitionFiles(): array
{
$files = glob($this->modules_root . '/*/cron/tasks.php') ?: [];
sort($files, SORT_STRING);
return $files;
}
}
+5 -2
View File
@@ -39,8 +39,11 @@ $htaccess = "
# php -- END cPanel-generated handler, do not edit
";
// Write the .htaccess file (This is not really ideal, but it works for now. This is because the .htaccess file is changed by cPanel, and it's not going to be kept there anyway.)
file_put_contents(__DIR__ . '/.htaccess', $htaccess);
// Keep the compatibility rewrite file stable without rewriting it every minute.
$htaccessPath = __DIR__ . '/.htaccess';
if (!is_file($htaccessPath) || file_get_contents($htaccessPath) !== $htaccess) {
file_put_contents($htaccessPath, $htaccess);
}
// Log the time of the cron job
file_put_contents(__DIR__ . '/cron.log', date('Y-m-d H:i:s', $now) . ' - Cron job executed in ' . (time() - $now) . ' seconds ( ' . (microtime(true) - $now) . 'ms )' . PHP_EOL, FILE_APPEND);
+293 -12
View File
@@ -8,6 +8,7 @@ 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;
@@ -40,6 +41,11 @@ 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/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;
@@ -1469,17 +1475,292 @@ function goalsProgressAlertDue(goals_criteria $criteria, DateTimeImmutable $nowU
}
}
foreach ( $cron_tasks as $task => $data ) {
$lastRun = redis->get_last_crond_run($task) === null ? 0 : redis->get_last_crond_run($task);
$nextRun = $lastRun + $data['interval'];
$cron_tasks[$task]['last_run'] = $lastRun;
$cron_tasks[$task]['next_run'] = $nextRun;
if ($nextRun <= time()) {
echo "[" . date('Y-m-d H:i:s') . "][CRON] Running task: " . $task . "\n";
$data['function']();
redis->set_last_crond_run($task, time());
echo "[" . date('Y-m-d H:i:s') . "][CRON] Completed task: " . $task . "\n";
} else {
$response_cron[] = $task . ' is not due to run yet, next run is at ' . date('Y-m-d H:i:s', $nextRun) . ' (' . ($nextRun - time()) . ' seconds)';
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),
'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'] ?? '');
if ($departmentId <= 0 || $opensAt === '') {
$summary['skipped_departments']++;
continue;
}
$cacheKey = selfserveOpeningCleanerRelayActivationKey($departmentId, $opensAt, $now);
if (selfserveOpeningCleanerRelayActivationAlreadyHandled($cacheKey)) {
$summary['skipped_departments']++;
continue;
}
$departmentSummary = selfserveActivateOpeningCleanerRelaysForDepartment($departmentId);
$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;
}
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['activated_relays'] . " cleaner 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';
$sql = "
SELECT
oh.department AS department_id,
TIME_FORMAT(oh.`$startColumn`, '%H:%i:%s') AS opens_at,
TIME_FORMAT(oh.`$endColumn`, '%H:%i:%s') AS closes_at
FROM department_time_bookings_opening_hours oh
INNER JOIN department_variables dv ON dv.department_id = oh.department
INNER JOIN department_lanes dl ON dl.department = 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
AND dl.deleted_at IS NULL
AND COALESCE(dl.selfserve_enabled, 1) = 1
AND dl.relay_machine_cleaner_id IS NOT NULL
AND TRIM(dl.relay_machine_cleaner_id) <> ''
GROUP BY oh.department, oh.`$startColumn`, oh.`$endColumn`
";
$result = $db->query($sql);
if (!$result instanceof mysqli_result) {
return [];
}
return array_values(array_filter(
$db->fetch_all($result),
static fn(array $row): bool => selfserveOpeningCleanerRelayWindowActive(
$now,
(string)($row['opens_at'] ?? ''),
(string)($row['closes_at'] ?? '')
)
));
}
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 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 selfserveActivateOpeningCleanerRelaysForDepartment(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;
}
try {
if (!$departmentLane->isSelfServeEnabled()) {
$summary['skipped']++;
continue;
}
} catch (Throwable) {
$summary['skipped']++;
continue;
}
if (!selfserveLaneHasConfiguredCleanerRelay($departmentLane)) {
$summary['skipped']++;
continue;
}
$laneId = (int)$departmentLane->id;
if ($laneId <= 0) {
$summary['skipped']++;
continue;
}
try {
$lane = $selfserve->lane($laneId);
if (!selfserveLaneHasConfiguredCleanerRelay($lane->department_lane)) {
$summary['skipped']++;
continue;
}
$lane->setMachineCleanerRelayStatusHard(true);
$summary['activated']++;
} catch (Throwable $throwable) {
$summary['failed']++;
warn(
'SelfserveOpeningRelayActivationCron failed for department '
. $departmentId
. ', lane '
. $laneId
. ': '
. $throwable->getMessage()
);
}
}
return $summary;
}
function selfserveLaneHasConfiguredCleanerRelay(?object $departmentLane): bool
{
if (
$departmentLane === null
|| !isset($departmentLane->relay_machine_cleaner_id)
|| !is_object($departmentLane->relay_machine_cleaner_id)
|| !method_exists($departmentLane->relay_machine_cleaner_id, 'value')
) {
return false;
}
try {
$relayId = trim((string)$departmentLane->relay_machine_cleaner_id->value());
} catch (Throwable) {
return false;
}
return $relayId !== '' && $relayId !== '0' && strtolower($relayId) !== 'null';
}
function selfserveOpeningCleanerRelayActivationKey(
int $departmentId,
string $opensAt,
DateTimeImmutable $now
): string {
$normalizedOpensAt = preg_replace('/[^0-9]/', '', $opensAt) ?: 'unknown';
return 'selfserve:opening-cleaner-relays:'
. $now->format('Y-m-d')
. ':'
. $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(),
];
}
@@ -0,0 +1,16 @@
<?php
return [
[
'id' => 'backups.create_backup',
'legacy_name' => 'backup',
'name' => 'Create backup',
'description' => 'Creates the scheduled backup bundle through the configured backup store.',
'module' => 'backups',
'handler' => 'backup',
'schedule' => ['type' => 'interval', 'seconds' => 43200],
'timeout_seconds' => 1800,
'estimated_duration_ms' => 60000,
'priority' => 115,
],
];
@@ -0,0 +1,28 @@
<?php
return [
[
'id' => 'coolify.availability_monitor',
'legacy_name' => 'CoolifyAvailabilityMonitorCron',
'name' => 'Coolify availability monitor',
'description' => 'Checks registered Coolify-managed infrastructure targets.',
'module' => 'coolify',
'handler' => 'CoolifyAvailabilityMonitorCron',
'schedule' => ['type' => 'interval', 'seconds' => 60],
'timeout_seconds' => 300,
'estimated_duration_ms' => 2000,
'priority' => 25,
],
[
'id' => 'coolify.load_balancer_reconcile',
'legacy_name' => 'CoolifyLoadBalancerReconcileCron',
'name' => 'Coolify load balancer reconcile',
'description' => 'Reconciles public gateway load balancer targets when automation is enabled.',
'module' => 'coolify',
'handler' => 'CoolifyLoadBalancerReconcileCron',
'schedule' => ['type' => 'interval', 'seconds' => 60],
'timeout_seconds' => 300,
'estimated_duration_ms' => 3000,
'priority' => 26,
],
];
@@ -0,0 +1,16 @@
<?php
return [
[
'id' => 'dynamicimages.pre_render',
'legacy_name' => 'PreRenderDynamicImagesCron',
'name' => 'Pre-render dynamic images',
'description' => 'Pre-renders common self-serve dynamic image variants into Redis.',
'module' => 'dynamicimages',
'handler' => 'PreRenderDynamicImagesCron',
'schedule' => ['type' => 'interval', 'seconds' => 900],
'timeout_seconds' => 900,
'estimated_duration_ms' => 30000,
'priority' => 90,
],
];
@@ -0,0 +1,64 @@
<?php
return [
[
'id' => 'economic.sync_user_customer_discounts',
'legacy_name' => 'SyncUserEconomicCustomerDiscounts',
'name' => 'Sync e-conomic customer discounts',
'description' => 'Clears cached customer discounts so e-conomic discount data can refresh.',
'module' => 'economic',
'handler' => 'SyncUserEconomicCustomerDiscounts',
'schedule' => ['type' => 'interval', 'seconds' => 180],
'timeout_seconds' => 300,
'estimated_duration_ms' => 2000,
'priority' => 30,
],
[
'id' => 'economic.sync_user_customer_details',
'legacy_name' => 'SyncUserEconomicCustomerDetails',
'name' => 'Sync e-conomic customer details',
'description' => 'Refreshes customer details and search documents from e-conomic.',
'module' => 'economic',
'handler' => 'SyncUserEconomicCustomerDetails',
'schedule' => ['type' => 'interval', 'seconds' => 43200],
'timeout_seconds' => 1800,
'estimated_duration_ms' => 30000,
'priority' => 110,
],
[
'id' => 'economic.sync_system_search_customer_index',
'legacy_name' => 'SyncSystemSearchEconomicCustomerIndex',
'name' => 'Sync e-conomic customer search index',
'description' => 'Refreshes the system search e-conomic customer index.',
'module' => 'economic',
'handler' => 'SyncSystemSearchEconomicCustomerIndex',
'schedule' => ['type' => 'interval', 'seconds' => 900],
'timeout_seconds' => 900,
'estimated_duration_ms' => 10000,
'priority' => 75,
],
[
'id' => 'economic.sync_invoice_status',
'legacy_name' => 'SyncEconomicInvoiceStatus',
'name' => 'Sync e-conomic invoice status',
'description' => 'Checks e-conomic invoice errors and draft status.',
'module' => 'economic',
'handler' => 'SyncEconomicInvoiceStatus',
'schedule' => ['type' => 'interval', 'seconds' => 120],
'timeout_seconds' => 300,
'estimated_duration_ms' => 3000,
'priority' => 35,
],
[
'id' => 'economic.transfer_queue',
'legacy_name' => 'EconomicTransferQueueCron',
'name' => 'Process e-conomic transfer queue',
'description' => 'Processes pending e-conomic transfer queue jobs in bounded batches.',
'module' => 'economic',
'handler' => 'EconomicTransferQueueCron',
'schedule' => ['type' => 'interval', 'seconds' => 30],
'timeout_seconds' => 300,
'estimated_duration_ms' => 3000,
'priority' => 20,
],
];
@@ -0,0 +1,16 @@
<?php
return [
[
'id' => 'failover.replica_monitor',
'legacy_name' => 'ReplicaFailoverMonitorCron',
'name' => 'Replica failover monitor',
'description' => 'Checks replicated services and promotes eligible replicas during automatic failover.',
'module' => 'failover',
'handler' => 'ReplicaFailoverMonitorCron',
'schedule' => ['type' => 'interval', 'seconds' => 60],
'timeout_seconds' => 300,
'estimated_duration_ms' => 3000,
'priority' => 24,
],
];
@@ -0,0 +1,16 @@
<?php
return [
[
'id' => 'goals.progress_alerts',
'legacy_name' => 'GoalsProgressAlertsCron',
'name' => 'Goals progress alerts',
'description' => 'Evaluates department goal alert schedules and sends due progress alerts.',
'module' => 'goals',
'handler' => 'GoalsProgressAlertsCron',
'schedule' => ['type' => 'interval', 'seconds' => 60],
'timeout_seconds' => 600,
'estimated_duration_ms' => 10000,
'priority' => 45,
],
];
@@ -0,0 +1,16 @@
<?php
return [
[
'id' => 'selfserve.activate_opening_cleaner_relays',
'legacy_name' => 'SelfserveOpeningRelayActivationCron',
'name' => 'Activate self-serve opening cleaner relays',
'description' => 'Turns configured self-serve cleaner relays on when a department enters opening hours.',
'module' => 'selfserve',
'handler' => 'SelfserveOpeningRelayActivationCron',
'schedule' => ['type' => 'interval', 'seconds' => 60],
'timeout_seconds' => 300,
'estimated_duration_ms' => 5000,
'priority' => 45,
],
];
@@ -0,0 +1,64 @@
<?php
return [
[
'id' => 'system.sync_logs',
'legacy_name' => 'SyncLogs',
'name' => 'Sync logs',
'description' => 'Flush Redis-backed application logs into the database.',
'module' => 'system',
'handler' => 'syncLogsToDatabase',
'schedule' => ['type' => 'interval', 'seconds' => 300],
'timeout_seconds' => 120,
'estimated_duration_ms' => 1000,
'priority' => 10,
],
[
'id' => 'system.search_cache_maintenance',
'legacy_name' => 'SystemSearchCacheMaintenanceCron',
'name' => 'System search cache maintenance',
'description' => 'Processes dirty-table and full rebuild requests for system search caches.',
'module' => 'system',
'handler' => 'SystemSearchCacheMaintenanceCron',
'schedule' => ['type' => 'interval', 'seconds' => 300],
'timeout_seconds' => 600,
'estimated_duration_ms' => 3000,
'priority' => 70,
],
[
'id' => 'system.prune_session_activity',
'legacy_name' => 'PruneSystemSessionActivityCron',
'name' => 'Prune system session activity',
'description' => 'Deletes stale system session activity rows.',
'module' => 'system',
'handler' => 'PruneSystemSessionActivityCron',
'schedule' => ['type' => 'interval', 'seconds' => 86400],
'timeout_seconds' => 300,
'estimated_duration_ms' => 1000,
'priority' => 120,
],
[
'id' => 'system.invoice_flags_manual_cache',
'legacy_name' => 'WarmInvoicePeriodManualFlagsCron',
'name' => 'Warm manual invoice flag cache',
'description' => 'Warms superuser invoice-period manual flag counters.',
'module' => 'system',
'handler' => 'WarmInvoicePeriodManualFlagsCron',
'schedule' => ['type' => 'interval', 'seconds' => 300],
'timeout_seconds' => 600,
'estimated_duration_ms' => 2000,
'priority' => 80,
],
[
'id' => 'system.invoice_flags_automatic_cache',
'legacy_name' => 'WarmInvoicePeriodAutomaticFlagsCron',
'name' => 'Warm automatic invoice flag cache',
'description' => 'Warms current, previous, and queued invoice-period automatic flag caches.',
'module' => 'system',
'handler' => 'WarmInvoicePeriodAutomaticFlagsCron',
'schedule' => ['type' => 'interval', 'seconds' => 300],
'timeout_seconds' => 900,
'estimated_duration_ms' => 5000,
'priority' => 85,
],
];
@@ -0,0 +1,16 @@
<?php
return [
[
'id' => 'weatherapi.preload_department_responses',
'legacy_name' => 'PreloadDepartmentWeatherResponsesCron',
'name' => 'Preload department weather responses',
'description' => 'Warms department weather timeline responses for visible and active departments.',
'module' => 'weatherapi',
'handler' => 'PreloadDepartmentWeatherResponsesCron',
'schedule' => ['type' => 'interval', 'seconds' => 60],
'timeout_seconds' => 300,
'estimated_duration_ms' => 5000,
'priority' => 40,
],
];
@@ -0,0 +1,16 @@
<?php
return [
[
'id' => 'workfeed.warm_employee_names',
'legacy_name' => 'WarmWorkfeedEmployeeNamesCron',
'name' => 'Warm Workfeed employee names',
'description' => 'Warms Redis mappings for Workfeed employee identifiers and display names.',
'module' => 'workfeed',
'handler' => 'WarmWorkfeedEmployeeNamesCron',
'schedule' => ['type' => 'interval', 'seconds' => 21600],
'timeout_seconds' => 600,
'estimated_duration_ms' => 5000,
'priority' => 100,
],
];
@@ -0,0 +1,16 @@
<?php
return [
[
'id' => 'xlvask.sync_module',
'legacy_name' => 'SyncXLVaskModuleCron',
'name' => 'Sync XL Vask module',
'description' => 'Runs scheduled XL Vask synchronization tasks when the module is enabled.',
'module' => 'xlvask',
'handler' => 'SyncXLVaskModuleCron',
'schedule' => ['type' => 'interval', 'seconds' => 3600],
'timeout_seconds' => 900,
'estimated_duration_ms' => 10000,
'priority' => 95,
],
];
@@ -490,18 +490,19 @@ class customer_vehicles_o extends db
return [];
}
//print_r($transaction_ids);
// Convert the array of transaction ids to a comma separated string
$orders = '';
$orders = [];
foreach ($transaction_ids as $transaction_id) {
// Check if the transaction is included in the invoicing.
$tmp = (new orders_o())->select((int)$transaction_id);
if (!$tmp->isIncludedInInvoicing()) {
continue; // The transaction is not included in the invoicing, skip it
}
$orders .= (int)$transaction_id . ',';
$orders[] = (int)$transaction_id;
}
// Remove the last comma
$orders = rtrim($orders, ',');
if (empty($orders)) {
return [];
}
$order_ids = implode(',', $orders);
// Get the first two transactions that are not deleted and contains at least one order item with the 'product_id' of the vehicle type for the vehicle
$query = "
SELECT o.id
@@ -509,7 +510,7 @@ class customer_vehicles_o extends db
JOIN order_items oi ON oi.order_id = o.id
WHERE o.deleted_at IS NULL
AND oi.deleted_at IS NULL
AND o.id IN ($orders)
AND o.id IN ($order_ids)
AND oi.product_id = " . (int)$this->type->value() . "
GROUP BY o.id
ORDER BY o.created_at ASC
+141 -26
View File
@@ -3,7 +3,9 @@
namespace routes;
use classes\authentication;
use classes\cron_scheduler;
use objects\logs_o;
use Throwable;
use traits\route_t;
class cronRoute
@@ -12,39 +14,152 @@ class cronRoute
public function run(): void
{
$this->post('/superuser/cron', function () {
// Get the post data
$this->get('/superuser/cron', function () {
global $response;
// Make sure the user has the SUPERUSER_RUN_CRON permission
$this->requirePermission('SUPERUSER_RUN_CRON');
// Get the user object
$user = (new authentication())->get_user();
// Get the post data
$data = json_decode(file_get_contents('php://input'), true);
// Check if a specific cron job is requested
if (isset($data['job'])) {
// Check if the cron job exists
if (!file_exists(WD . '/cron/' . $data['job'] . '.php')) {
$response->error('Cron job not found', 404);
}
// Include the cron job
require_once WD . '/cron/' . $data['job'] . '.php';
// Log the incident
(new logs_o())->add('cron', 'global', 1, $user->id, 'CRON_JOB_RUN', 'Ran cron job: ' . $data['job']);
$response->success('Cron job ran successfully');
}
// If no specific cron job is requested, run all cron jobs (through the cron.php file)
require_once WD . '/cron/Cron.php';
// Log the incident
(new logs_o())->add('cron', 'global', 1, $user->id, 'CRON_RUN', 'Ran all cron jobs');
$this->requireClassicSuperuserPermission('superuser_cron_view');
$response->success((new cron_scheduler())->listTasks());
}, [
'superuser_cron_view' => 'View cron task schedule, run status, estimates, and history',
]);
$this->get('/superuser/cron/runs', function () {
global $response;
$this->requireClassicSuperuserPermission('superuser_cron_view');
$task_id = $this->getParameter('task_id');
$limit = (int)($this->getParameter('limit') ?? 50);
$response->success([
'message' => 'All cron jobs ran successfully',
'data' => $response_cron ?? []
'runs' => (new cron_scheduler())->listRuns(is_string($task_id) ? $task_id : null, $limit),
]);
}, [
'superuser_cron_view' => 'View cron task schedule, run status, estimates, and history',
]);
$this->post('/superuser/cron/run', function () {
global $response;
$this->requireClassicSuperuserPermission('SUPERUSER_RUN_CRON');
$parameters = $this->getParametersAsArray();
$task_id = trim((string)($parameters['task_id'] ?? ''));
if ($task_id === '') {
$response->error('Missing cron task id.', 400);
}
try {
$run = (new cron_scheduler())->runTask(
$task_id,
'manual',
$this->actorUserId(),
$this->toBool($parameters['force'] ?? false, false)
);
(new logs_o())->add('cron', 'global', 1, $this->actorUserId() ?? 0, 'CRON_JOB_RUN', 'Ran cron task: ' . $task_id);
$response->success($run);
} catch (Throwable $throwable) {
$response->error(['message' => $throwable->getMessage()], 409);
}
}, [
'SUPERUSER_RUN_CRON' => 'Run cron jobs',
]);
$this->patch('/superuser/cron/config', function () {
global $response;
$this->requireClassicSuperuserPermission('superuser_cron_manage');
$parameters = $this->getParametersAsArray();
$task_id = trim((string)($parameters['task_id'] ?? ''));
if ($task_id === '') {
$response->error('Missing cron task id.', 400);
}
try {
$config = [];
if (array_key_exists('enabled', $parameters)) {
$config['enabled'] = $this->toBool($parameters['enabled'], true);
}
if (array_key_exists('schedule', $parameters)) {
$config['schedule'] = $parameters['schedule'];
}
$result = (new cron_scheduler())->updateTaskConfig($task_id, $config);
(new logs_o())->add('cron', 'global', 1, $this->actorUserId() ?? 0, 'CRON_TASK_CONFIG_UPDATED', 'Updated cron task: ' . $task_id);
$response->success($result);
} catch (Throwable $throwable) {
$response->error(['message' => $throwable->getMessage()], 409);
}
}, [
'superuser_cron_manage' => 'Configure cron task schedules and enabled state',
]);
$this->post('/superuser/cron', function () {
global $response;
$this->requireClassicSuperuserPermission('SUPERUSER_RUN_CRON');
$parameters = $this->getParametersAsArray();
$scheduler = new cron_scheduler();
try {
if (isset($parameters['job']) && trim((string)$parameters['job']) !== '') {
$job = trim((string)$parameters['job']);
$run = $scheduler->runTask($job, 'manual', $this->actorUserId(), true);
(new logs_o())->add('cron', 'global', 1, $this->actorUserId() ?? 0, 'CRON_JOB_RUN', 'Ran cron job: ' . $job);
$response->success([
'message' => 'Cron job ran successfully',
'run' => $run,
]);
}
$result = $scheduler->runDue('manual');
(new logs_o())->add('cron', 'global', 1, $this->actorUserId() ?? 0, 'CRON_RUN', 'Ran due cron jobs');
$response->success([
'message' => 'Due cron jobs ran successfully',
'data' => $result,
]);
} catch (Throwable $throwable) {
$response->error(['message' => $throwable->getMessage()], 409);
}
},
[
'SUPERUSER_RUN_CRON' => 'Run cron jobs'
]
);
}
private function requireClassicSuperuserPermission(string $permission): bool
{
global $response;
if ((new authentication())->get_subuser() !== false) {
$response->error('Subuser sessions cannot manage cron tasks.', 403);
}
return $this->requirePermission($permission);
}
private function actorUserId(): ?int
{
try {
$user = (new authentication())->get_user();
return $user !== false && isset($user->id) ? (int)$user->id : null;
} catch (Throwable) {
return null;
}
}
private function toBool(mixed $value, bool $default): bool
{
if (is_bool($value)) {
return $value;
}
if ($value === null) {
return $default;
}
$normalized = strtolower(trim((string)$value));
if (in_array($normalized, ['1', 'true', 'yes', 'on'], true)) {
return true;
}
if (in_array($normalized, ['0', 'false', 'no', 'off'], true)) {
return false;
}
return $default;
}
}
+20 -7
View File
@@ -575,11 +575,6 @@ class departmentsRoute
}
protected function syncDepartmentSelfServeRelayStates(int $departmentId, bool $enabled): void
{
if (!$enabled) {
// Self-serve disabled: do not mutate lane relay states.
return;
}
$selfserve = new selfserve();
$lanes = (new department_lanes_o())->getDepartmentLanes($departmentId);
@@ -595,12 +590,30 @@ class departmentsRoute
continue;
}
// Self-serve enabled: keep machine stack off.
if (!$enabled) {
// Self-serve disabled: restore normal/manual relay operation.
$this->setOptionalLaneRelayState($lane, 'relay_machine_program_picker_id', static function () use ($lane): void {
$lane->setMachineProgramPickerRelayStatusHard(true);
});
$this->setOptionalLaneRelayState($lane, 'relay_machine_cleaner_id', static function () use ($lane): void {
$lane->setMachineCleanerRelayStatusHard(true);
});
$this->setOptionalLaneRelayState($lane, 'relay_machine_id', static function () use ($lane): void {
$lane->setMachineRelayStatusHard(true);
});
continue;
}
if (!$department_lane->isSelfServeEnabled()) {
continue;
}
// Self-serve enabled: lances must be usable; machine-only relays stay off.
$this->setOptionalLaneRelayState($lane, 'relay_machine_program_picker_id', static function () use ($lane): void {
$lane->setMachineProgramPickerRelayStatus(false);
});
$this->setOptionalLaneRelayState($lane, 'relay_machine_cleaner_id', static function () use ($lane): void {
$lane->setMachineCleanerRelayStatus(false);
$lane->setMachineCleanerRelayStatusHard(true);
});
try {
$lane->setMachineRelayStatus(false);
@@ -0,0 +1,19 @@
<?php
it('delegates automatic cron execution to the db-backed scheduler', function (): void {
$content = file_get_contents(app_path('cron/Cron.php'));
expect($content)->not->toBeFalse();
expect($content)->toContain('CRON_LOAD_LEGACY_FUNCTIONS_ONLY');
expect($content)->toContain("new \\classes\\cron_scheduler())->runDue('automatic')");
expect($content)->toContain('function EconomicTransferQueueCron(): void');
expect($content)->toContain('function GoalsProgressAlertsCron(): void');
});
it('only rewrites htaccess when the compatibility file changes', function (): void {
$content = file_get_contents(app_path('cron.php'));
expect($content)->not->toBeFalse();
expect($content)->toContain('file_get_contents($htaccessPath) !== $htaccess');
expect($content)->toContain('file_put_contents($htaccessPath, $htaccess)');
});
@@ -0,0 +1,31 @@
<?php
it('exposes superuser cron status, history, manual run, and config routes', function (): void {
$content = file_get_contents(app_path('routes/cronRoute.php'));
expect($content)->not->toBeFalse();
expect($content)->toContain('/superuser/cron');
expect($content)->toContain('/superuser/cron/runs');
expect($content)->toContain('/superuser/cron/run');
expect($content)->toContain('/superuser/cron/config');
expect($content)->toContain('new cron_scheduler()');
expect($content)->toContain("requireClassicSuperuserPermission('superuser_cron_view')");
expect($content)->toContain("requireClassicSuperuserPermission('SUPERUSER_RUN_CRON')");
expect($content)->toContain("requireClassicSuperuserPermission('superuser_cron_manage')");
});
it('does not include caller-controlled cron php files', function (): void {
$content = file_get_contents(app_path('routes/cronRoute.php'));
expect($content)->not->toContain("WD . '/cron/'");
expect($content)->not->toContain("file_exists(WD . '/cron/'");
expect($content)->not->toContain("require_once WD . '/cron/'");
});
it('keeps the legacy cron post endpoint as a scheduler bridge', function (): void {
$content = file_get_contents(app_path('routes/cronRoute.php'));
expect($content)->toContain('runTask($job, \'manual\'');
expect($content)->toContain("runDue('manual')");
expect($content)->toContain('Cron job ran successfully');
});
@@ -0,0 +1,56 @@
<?php
use classes\cron_schedule;
use classes\cron_task_registry;
it('discovers module-owned cron task definitions', function (): void {
$registry = new cron_task_registry(app_path('modules'));
$definitions = $registry->definitions();
expect($definitions)->toHaveCount(19);
expect(array_keys($definitions))->toContain(
'system.sync_logs',
'economic.transfer_queue',
'dynamicimages.pre_render',
'weatherapi.preload_department_responses',
'goals.progress_alerts'
);
$transferQueue = $registry->get('EconomicTransferQueueCron');
expect($transferQueue)->not->toBeNull();
expect($transferQueue->id)->toBe('economic.transfer_queue');
expect($transferQueue->module)->toBe('economic');
expect($transferQueue->schedule)->toBe(['type' => 'interval', 'seconds' => 30]);
});
it('keeps every discovered cron task in a module cron folder', function (): void {
$files = glob(app_path('modules/*/cron/tasks.php')) ?: [];
$modules = array_map(
static fn(string $file): string => basename(dirname(dirname($file))),
$files
);
$registry = new cron_task_registry(app_path('modules'));
foreach ($registry->definitions() as $definition) {
expect($modules)->toContain($definition->module);
}
});
it('normalizes and advances interval schedules without tight loops', function (): void {
expect(cron_schedule::normalize(['type' => 'interval', 'seconds' => 60]))
->toBe(['type' => 'interval', 'seconds' => 60]);
$now = strtotime('2026-07-09 12:10:00');
$next = cron_schedule::nextRunAt(
['type' => 'interval', 'seconds' => 300],
'2026-07-09 12:00:00',
$now
);
expect($next)->toBe('2026-07-09 12:15:00');
});
it('rejects unsafe cron intervals', function (): void {
expect(fn() => cron_schedule::normalize(['type' => 'interval', 'seconds' => 5]))
->toThrow(InvalidArgumentException::class);
});
@@ -0,0 +1,15 @@
<?php
it('does not query subscription-applied transactions with an empty eligible order list', function (): void {
$content = file_get_contents(dirname(__DIR__, 3) . '/objects/customer_vehicles_o.php');
expect($content)->not->toBeFalse();
$content = (string)$content;
expect($content)
->toContain('$orders = [];')
->toMatch('/\$orders\[\] = \(int\)\$transaction_id;\s*}\s*if \(empty\(\$orders\)\) {\s*return \[\];\s*}\s*\$order_ids = implode/s')
->toContain('AND o.id IN ($order_ids)')
->not->toContain('rtrim($orders, \',\')')
->not->toContain('AND o.id IN ($orders)');
});
@@ -7,11 +7,15 @@ it('syncs lane relay states when department self-serve enabled flag changes', fu
expect($routeContent)->toContain('/departments/self-serve/enabled');
expect($routeContent)->toContain('$this->syncDepartmentSelfServeRelayStates((int)$department->id, $enabled);');
expect($routeContent)->toContain('if (!$enabled) {');
expect($routeContent)->toContain('// Self-serve disabled: do not mutate lane relay states.');
expect($routeContent)->toContain('// Self-serve disabled: restore normal/manual relay operation.');
expect($routeContent)->toContain('setMachineProgramPickerRelayStatusHard(true)');
expect($routeContent)->toContain('setMachineRelayStatusHard(true)');
expect($routeContent)->toContain('!$department_lane->isSelfServeEnabled()');
expect($routeContent)->toContain('setMachineProgramPickerRelayStatus(false)');
expect($routeContent)->toContain('setMachineCleanerRelayStatus(false)');
expect($routeContent)->toContain('setMachineCleanerRelayStatusHard(true)');
expect($routeContent)->toContain('setMachineRelayStatus(false)');
expect($routeContent)->not->toContain('setMachineProgramPickerRelayStatusHard(false)');
expect($routeContent)->not->toContain('setMachineCleanerRelayStatusHard(false)');
expect($routeContent)->not->toContain('setMachineRelayStatusHard(false)');
expect($routeContent)->not->toContain('setMachineCleanerRelayStatus(false)');
});
@@ -0,0 +1,28 @@
<?php
it('registers an opening-time self-serve cleaner relay activation cron', function (): void {
$tasks = require app_path('modules/selfserve/cron/tasks.php');
expect($tasks)->toHaveCount(1);
expect($tasks[0]['id'])->toBe('selfserve.activate_opening_cleaner_relays');
expect($tasks[0]['legacy_name'])->toBe('SelfserveOpeningRelayActivationCron');
expect($tasks[0]['handler'])->toBe('SelfserveOpeningRelayActivationCron');
expect($tasks[0]['schedule'])->toBe(['type' => 'interval', 'seconds' => 60]);
});
it('activates only configured cleaner relays after department opening time', function (): void {
$cronContent = file_get_contents(app_path('cron/Cron.php'));
expect($cronContent)->not->toBeFalse();
expect($cronContent)->toContain('function SelfserveOpeningRelayActivationCron(): array');
expect($cronContent)->toContain("new DateTimeZone('Europe/Copenhagen')");
expect($cronContent)->toContain('department_time_bookings_opening_hours');
expect($cronContent)->toContain("dv.variable = 'selfserve_enabled'");
expect($cronContent)->toContain('dl.selfserve_enabled');
expect($cronContent)->toContain('relay_machine_cleaner_id');
expect($cronContent)->toContain('$nowSeconds >= $opensAtSeconds && $nowSeconds < $closesAtSeconds');
expect($cronContent)->toContain('setMachineCleanerRelayStatusHard(true)');
expect($cronContent)->toContain('selfserve:opening-cleaner-relays:');
expect($cronContent)->not->toContain('setMachineRelayStatusHard(true)');
expect($cronContent)->not->toContain('setMachineProgramPickerRelayStatusHard(true)');
});