From 7a1c444df09a7b2d030ae20f708a39e1609f8262 Mon Sep 17 00:00:00 2001 From: Jeppe B <2jepp9350@gmail.com> Date: Thu, 9 Jul 2026 11:27:32 +0200 Subject: [PATCH] Activate self-serve opening relays Activate self-serve opening relays --- services/nginx/app/cron/Cron.php | 276 ++++++++++++++++++ .../app/modules/selfserve/cron/tasks.php | 16 + .../nginx/app/routes/departmentsRoute.php | 27 +- ...entSelfServeEnabledRelaySyncWiringTest.php | 8 +- ...elfserveOpeningRelayActivationCronTest.php | 28 ++ 5 files changed, 346 insertions(+), 9 deletions(-) create mode 100644 services/nginx/app/modules/selfserve/cron/tasks.php create mode 100644 services/nginx/app/tests/Unit/Selfserve/SelfserveOpeningRelayActivationCronTest.php diff --git a/services/nginx/app/cron/Cron.php b/services/nginx/app/cron/Cron.php index 162c3683..67d05188 100644 --- a/services/nginx/app/cron/Cron.php +++ b/services/nginx/app/cron/Cron.php @@ -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; @@ -1474,6 +1475,281 @@ function goalsProgressAlertDue(goals_criteria $criteria, DateTimeImmutable $nowU } } +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; } diff --git a/services/nginx/app/modules/selfserve/cron/tasks.php b/services/nginx/app/modules/selfserve/cron/tasks.php new file mode 100644 index 00000000..4a3f452a --- /dev/null +++ b/services/nginx/app/modules/selfserve/cron/tasks.php @@ -0,0 +1,16 @@ + '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, + ], +]; diff --git a/services/nginx/app/routes/departmentsRoute.php b/services/nginx/app/routes/departmentsRoute.php index d28d128e..e0bfcc79 100644 --- a/services/nginx/app/routes/departmentsRoute.php +++ b/services/nginx/app/routes/departmentsRoute.php @@ -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); diff --git a/services/nginx/app/tests/Unit/Selfserve/DepartmentSelfServeEnabledRelaySyncWiringTest.php b/services/nginx/app/tests/Unit/Selfserve/DepartmentSelfServeEnabledRelaySyncWiringTest.php index 2abfeb12..0b2fe852 100644 --- a/services/nginx/app/tests/Unit/Selfserve/DepartmentSelfServeEnabledRelaySyncWiringTest.php +++ b/services/nginx/app/tests/Unit/Selfserve/DepartmentSelfServeEnabledRelaySyncWiringTest.php @@ -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)'); }); diff --git a/services/nginx/app/tests/Unit/Selfserve/SelfserveOpeningRelayActivationCronTest.php b/services/nginx/app/tests/Unit/Selfserve/SelfserveOpeningRelayActivationCronTest.php new file mode 100644 index 00000000..5a6fc362 --- /dev/null +++ b/services/nginx/app/tests/Unit/Selfserve/SelfserveOpeningRelayActivationCronTest.php @@ -0,0 +1,28 @@ +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)'); +});