394 lines
15 KiB
PHP
394 lines
15 KiB
PHP
<?php
|
|
// prevent direct access
|
|
|
|
use classes\backup_store;
|
|
use classes\economic;
|
|
use classes\xlvask;
|
|
use classes\slack as Slack;
|
|
use classes\email as Email;
|
|
use classes\gatewayapi as GatewayAPI;
|
|
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_goals_o;
|
|
use objects\departments_o;
|
|
use objects\bookings_o;
|
|
use objects\logs_o;
|
|
use objects\users_o;
|
|
|
|
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 = [
|
|
// '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',
|
|
],
|
|
'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',
|
|
],
|
|
'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',
|
|
],
|
|
'SyncXLVaskModuleCron' => [
|
|
'interval' => 3600, // 1 hour
|
|
'last_run' => 0,
|
|
'next_run' => 0,
|
|
'function' => 'SyncXLVaskModuleCron',
|
|
],
|
|
'GoalsProgressAlertsCron' => [
|
|
'interval' => 60, // check every minute
|
|
'last_run' => 0,
|
|
'next_run' => 0,
|
|
'function' => 'GoalsProgressAlertsCron',
|
|
],
|
|
];
|
|
|
|
function checkUnfulfilledBookings(): void
|
|
{
|
|
// This is deactivated for now, as it is not wanted.
|
|
// I'm saving this for later, as it is a good idea to have this in place.
|
|
return;
|
|
$bookings_o = new bookings_o();
|
|
$bookings_o->checkUnfulfilledBookings();
|
|
}
|
|
|
|
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->createBackup();
|
|
} catch (Exception $e) {
|
|
warn('Backup failed: ' . $e->getMessage());
|
|
}
|
|
}
|
|
|
|
function SyncUserEconomicCustomerDiscounts(): void
|
|
{
|
|
$users_o = new users_o();
|
|
$users_o->clearAllUsersEconomicCustomerDiscountsFromCache();
|
|
}
|
|
|
|
function SyncUserEconomicCustomerDetails(): void
|
|
{
|
|
$users_o = new users_o();
|
|
$users_o->clearAllUsersEconomicCustomerDetailsFromCache();
|
|
//$users_o->syncAllUsersEconomicCustomerDetails();
|
|
}
|
|
|
|
/**
|
|
* @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
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 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.
|
|
*/
|
|
function GoalsProgressAlertsCron(): void
|
|
{
|
|
// 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
|
|
$slotKey = $dueInfo['slot'];
|
|
$redisKey = 'goal_alert_sent:' . $goalId . ':' . $slotKey;
|
|
$already = redis->get($redisKey) ?? null;
|
|
if ($already) { continue; }
|
|
|
|
// Render message
|
|
$message = goals_progress_alert_renderer::render($criteria);
|
|
|
|
// Dispatch according to destination
|
|
$destination = $criteria->progress_alert_destination ?? Dest::SLACK;
|
|
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);
|
|
}
|
|
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);
|
|
}
|
|
|
|
// Mark slot as sent (expire in a reasonable window)
|
|
$ttl = $dueInfo['ttl'];
|
|
redis->set('' . $redisKey, 1);
|
|
if (method_exists(redis, 'expire')) {
|
|
redis->expire($redisKey, $ttl);
|
|
}
|
|
|
|
// 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];
|
|
}
|
|
}
|
|
|
|
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)';
|
|
}
|
|
} |