1474 lines
49 KiB
PHP
1474 lines
49 KiB
PHP
<?php
|
|
// prevent direct access
|
|
|
|
use classes\backup_store;
|
|
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\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 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\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';
|
|
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';
|
|
|
|
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',
|
|
],
|
|
'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 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 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
|
|
{
|
|
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();
|
|
system_search_cache::clearIntentCaches();
|
|
$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,
|
|
];
|
|
|
|
$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();
|
|
|
|
$dataUri = $image->exportAsBase64('png');
|
|
if (!preg_match('/^data:image\/png;base64,(.*)$/', $dataUri, $matches)) {
|
|
return null;
|
|
}
|
|
$imageData = base64_decode($matches[1], true);
|
|
if ($imageData === false) {
|
|
return null;
|
|
}
|
|
return $imageData;
|
|
} 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) {
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* @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];
|
|
}
|
|
}
|
|
|
|
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)';
|
|
}
|
|
}
|