Auto-disable department self-serve at opening (#323)

Auto-disable department self-serve at opening
This commit is contained in:
Jeppe B
2026-07-28 18:03:05 +02:00
committed by GitHub
parent 3c13892366
commit da0113e3ed
10 changed files with 541 additions and 70 deletions
+28
View File
@@ -4157,6 +4157,19 @@ paths:
properties: properties:
enabled: enabled:
type: boolean type: boolean
auto_deactivation:
type: object
required: [at, timezone, label]
properties:
at:
type: string
format: date-time
nullable: true
timezone:
type: string
example: Europe/Copenhagen
label:
type: string
'404': '404':
$ref: '#/components/responses/NotFound' $ref: '#/components/responses/NotFound'
put: put:
@@ -4189,6 +4202,21 @@ paths:
properties: properties:
message: message:
type: string type: string
enabled:
type: boolean
auto_deactivation:
type: object
required: [at, timezone, label]
properties:
at:
type: string
format: date-time
nullable: true
timezone:
type: string
example: Europe/Copenhagen
label:
type: string
'404': '404':
$ref: '#/components/responses/NotFound' $ref: '#/components/responses/NotFound'
+176 -48
View File
@@ -30,6 +30,7 @@ use goals\helpers\goals_criteria_progress_alert_frequency as Freq;
use objects\department_lanes_o; use objects\department_lanes_o;
use objects\department_goals_o; use objects\department_goals_o;
use objects\department_selfserve_tasks_o; use objects\department_selfserve_tasks_o;
use objects\department_variables_o;
use objects\departments_o; use objects\departments_o;
use objects\bookings_o; use objects\bookings_o;
use objects\logs_o; use objects\logs_o;
@@ -1524,6 +1525,7 @@ function SelfserveOpeningRelayActivationCron(): array
$candidates = selfserveOpeningCleanerRelayActivationCandidates($now); $candidates = selfserveOpeningCleanerRelayActivationCandidates($now);
$summary = [ $summary = [
'checked_departments' => count($candidates), 'checked_departments' => count($candidates),
'disabled_departments' => 0,
'activated_departments' => 0, 'activated_departments' => 0,
'skipped_departments' => 0, 'skipped_departments' => 0,
'failed_departments' => 0, 'failed_departments' => 0,
@@ -1535,18 +1537,45 @@ function SelfserveOpeningRelayActivationCron(): array
foreach ($candidates as $candidate) { foreach ($candidates as $candidate) {
$departmentId = (int)($candidate['department_id'] ?? 0); $departmentId = (int)($candidate['department_id'] ?? 0);
$opensAt = (string)($candidate['opens_at'] ?? ''); $opensAt = (string)($candidate['opens_at'] ?? '');
$openingDate = (string)($candidate['opening_date'] ?? $now->format('Y-m-d'));
if ($departmentId <= 0 || $opensAt === '') { if ($departmentId <= 0 || $opensAt === '') {
$summary['skipped_departments']++; $summary['skipped_departments']++;
continue; continue;
} }
$cacheKey = selfserveOpeningCleanerRelayActivationKey($departmentId, $opensAt, $now); $cacheKey = selfserveOpeningCleanerRelayActivationKey($departmentId, $opensAt, $openingDate);
if (selfserveOpeningCleanerRelayActivationAlreadyHandled($cacheKey)) {
$summary['skipped_departments']++; try {
$transition = department_variables_o::withSelfServeTransitionLock(
$departmentId,
static function () use ($departmentId): array {
$departmentSummary = selfserveActivateStaffedDefaultRelaysForDepartment($departmentId);
if ((int)$departmentSummary['failed'] > 0) {
return [
'department_summary' => $departmentSummary,
'department_disabled' => false,
];
}
return [
'department_summary' => $departmentSummary,
'department_disabled' => selfserveDisableDepartmentSelfServeForOpening($departmentId),
];
},
0
);
} catch (Throwable $throwable) {
$summary['failed_departments']++;
warn(
'SelfserveOpeningRelayActivationCron could not serialize transition for department '
. $departmentId
. ': '
. $throwable->getMessage()
);
continue; continue;
} }
$departmentSummary = selfserveActivateOpeningCleanerRelaysForDepartment($departmentId); $departmentSummary = $transition['department_summary'];
$summary['activated_relays'] += (int)$departmentSummary['activated']; $summary['activated_relays'] += (int)$departmentSummary['activated'];
$summary['skipped_relays'] += (int)$departmentSummary['skipped']; $summary['skipped_relays'] += (int)$departmentSummary['skipped'];
$summary['failed_relays'] += (int)$departmentSummary['failed']; $summary['failed_relays'] += (int)$departmentSummary['failed'];
@@ -1556,6 +1585,13 @@ function SelfserveOpeningRelayActivationCron(): array
continue; continue;
} }
$departmentDisabled = (bool)$transition['department_disabled'];
if (!$departmentDisabled) {
$summary['failed_departments']++;
continue;
}
$summary['disabled_departments']++;
selfserveMarkOpeningCleanerRelayActivationHandled($cacheKey); selfserveMarkOpeningCleanerRelayActivationHandled($cacheKey);
if ((int)$departmentSummary['activated'] > 0) { if ((int)$departmentSummary['activated'] > 0) {
$summary['activated_departments']++; $summary['activated_departments']++;
@@ -1565,7 +1601,8 @@ function SelfserveOpeningRelayActivationCron(): array
} }
echo "[" . date('Y-m-d H:i:s') . "][CRON] SelfserveOpeningRelayActivationCron: " echo "[" . date('Y-m-d H:i:s') . "][CRON] SelfserveOpeningRelayActivationCron: "
. $summary['activated_relays'] . " cleaner relays activated across " . $summary['disabled_departments'] . " departments disabled self-serve, "
. $summary['activated_relays'] . " staffed-default relays activated across "
. $summary['activated_departments'] . " departments.\n"; . $summary['activated_departments'] . " departments.\n";
return $summary; return $summary;
@@ -1583,23 +1620,32 @@ function selfserveOpeningCleanerRelayActivationCandidates(DateTimeImmutable $now
$startColumn = $weekday . '_start'; $startColumn = $weekday . '_start';
$endColumn = $weekday . '_end'; $endColumn = $weekday . '_end';
$previousDate = $now->modify('-1 day');
$previousWeekday = strtolower($previousDate->format('l'));
$previousStartColumn = $previousWeekday . '_start';
$previousEndColumn = $previousWeekday . '_end';
$sql = " $sql = "
SELECT SELECT
oh.department AS department_id, oh.department AS department_id,
TIME_FORMAT(oh.`$startColumn`, '%H:%i:%s') AS opens_at, TIME_FORMAT(oh.`$startColumn`, '%H:%i:%s') AS current_opens_at,
TIME_FORMAT(oh.`$endColumn`, '%H:%i:%s') AS closes_at TIME_FORMAT(oh.`$endColumn`, '%H:%i:%s') AS current_closes_at,
TIME_FORMAT(oh.`$previousStartColumn`, '%H:%i:%s') AS previous_opens_at,
TIME_FORMAT(oh.`$previousEndColumn`, '%H:%i:%s') AS previous_closes_at
FROM department_time_bookings_opening_hours oh FROM department_time_bookings_opening_hours oh
INNER JOIN department_variables dv ON dv.department_id = oh.department 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' WHERE dv.variable = 'selfserve_enabled'
AND LOWER(TRIM(COALESCE(dv.value, ''))) IN ('true', '1', 'yes', 'on') AND LOWER(TRIM(COALESCE(dv.value, ''))) IN ('true', '1', 'yes', 'on')
AND oh.`$startColumn` IS NOT NULL AND (
AND oh.`$endColumn` IS NOT NULL (oh.`$startColumn` IS NOT NULL AND oh.`$endColumn` IS NOT NULL)
AND dl.deleted_at IS NULL OR
AND COALESCE(dl.selfserve_enabled, 1) = 1 (oh.`$previousStartColumn` IS NOT NULL AND oh.`$previousEndColumn` IS NOT NULL)
AND dl.relay_machine_cleaner_id IS NOT NULL )
AND TRIM(dl.relay_machine_cleaner_id) <> '' GROUP BY
GROUP BY oh.department, oh.`$startColumn`, oh.`$endColumn` oh.department,
oh.`$startColumn`,
oh.`$endColumn`,
oh.`$previousStartColumn`,
oh.`$previousEndColumn`
"; ";
$result = $db->query($sql); $result = $db->query($sql);
@@ -1607,14 +1653,33 @@ function selfserveOpeningCleanerRelayActivationCandidates(DateTimeImmutable $now
return []; return [];
} }
return array_values(array_filter( $candidates = [];
$db->fetch_all($result), foreach ($db->fetch_all($result) as $row) {
static fn(array $row): bool => selfserveOpeningCleanerRelayWindowActive( $currentOpensAt = (string)($row['current_opens_at'] ?? '');
$now, $currentClosesAt = (string)($row['current_closes_at'] ?? '');
(string)($row['opens_at'] ?? ''), if (selfserveOpeningCleanerRelayWindowActive($now, $currentOpensAt, $currentClosesAt)) {
(string)($row['closes_at'] ?? '') $candidates[] = [
) 'department_id' => $row['department_id'] ?? null,
)); 'opens_at' => $currentOpensAt,
'closes_at' => $currentClosesAt,
'opening_date' => $now->format('Y-m-d'),
];
continue;
}
$previousOpensAt = (string)($row['previous_opens_at'] ?? '');
$previousClosesAt = (string)($row['previous_closes_at'] ?? '');
if (selfserveOpeningCleanerRelayOvernightCarryoverActive($now, $previousOpensAt, $previousClosesAt)) {
$candidates[] = [
'department_id' => $row['department_id'] ?? null,
'opens_at' => $previousOpensAt,
'closes_at' => $previousClosesAt,
'opening_date' => $previousDate->format('Y-m-d'),
];
}
}
return $candidates;
} }
function selfserveOpeningCleanerRelayWindowActive( function selfserveOpeningCleanerRelayWindowActive(
@@ -1639,6 +1704,24 @@ function selfserveOpeningCleanerRelayWindowActive(
return $nowSeconds >= $opensAtSeconds; return $nowSeconds >= $opensAtSeconds;
} }
function selfserveOpeningCleanerRelayOvernightCarryoverActive(
DateTimeImmutable $now,
?string $opensAt,
?string $closesAt
): bool {
$opensAtSeconds = selfserveOpeningCleanerRelayTimeToSeconds($opensAt);
$closesAtSeconds = selfserveOpeningCleanerRelayTimeToSeconds($closesAt);
if ($opensAtSeconds === null || $closesAtSeconds === null || $opensAtSeconds <= $closesAtSeconds) {
return false;
}
$nowSeconds = ((int)$now->format('G') * 3600)
+ ((int)$now->format('i') * 60)
+ (int)$now->format('s');
return $nowSeconds < $closesAtSeconds;
}
function selfserveOpeningCleanerRelayTimeToSeconds(?string $time): ?int function selfserveOpeningCleanerRelayTimeToSeconds(?string $time): ?int
{ {
$time = trim((string)$time); $time = trim((string)$time);
@@ -1661,7 +1744,27 @@ function selfserveOpeningCleanerRelayTimeToSeconds(?string $time): ?int
return ($hours * 3600) + ($minutes * 60) + $seconds; return ($hours * 3600) + ($minutes * 60) + $seconds;
} }
function selfserveActivateOpeningCleanerRelaysForDepartment(int $departmentId): array function selfserveDisableDepartmentSelfServeForOpening(int $departmentId): bool
{
try {
(new department_variables_o())
->selectDepartment($departmentId)
->set('selfserve_enabled', 'false');
return true;
} catch (Throwable $throwable) {
warn(
'SelfserveOpeningRelayActivationCron failed to disable self-serve for department '
. $departmentId
. ': '
. $throwable->getMessage()
);
return false;
}
}
function selfserveActivateStaffedDefaultRelaysForDepartment(int $departmentId): array
{ {
$summary = [ $summary = [
'activated' => 0, 'activated' => 0,
@@ -1677,17 +1780,7 @@ function selfserveActivateOpeningCleanerRelaysForDepartment(int $departmentId):
continue; continue;
} }
try { if (!selfserveLaneHasAnyConfiguredStaffedDefaultRelay($departmentLane)) {
if (!$departmentLane->isSelfServeEnabled()) {
$summary['skipped']++;
continue;
}
} catch (Throwable) {
$summary['skipped']++;
continue;
}
if (!selfserveLaneHasConfiguredCleanerRelay($departmentLane)) {
$summary['skipped']++; $summary['skipped']++;
continue; continue;
} }
@@ -1700,13 +1793,15 @@ function selfserveActivateOpeningCleanerRelaysForDepartment(int $departmentId):
try { try {
$lane = $selfserve->lane($laneId); $lane = $selfserve->lane($laneId);
if (!selfserveLaneHasConfiguredCleanerRelay($lane->department_lane)) { $summary['activated'] += selfserveSetOptionalLaneRelayState($lane, 'relay_machine_program_picker_id', static function () use ($lane): void {
$summary['skipped']++; $lane->setMachineProgramPickerRelayStatusForDepartmentOperation(true);
continue; });
} $summary['activated'] += selfserveSetOptionalLaneRelayState($lane, 'relay_machine_cleaner_id', static function () use ($lane): void {
$lane->setMachineCleanerRelayStatusForDepartmentOperation(true);
$lane->setMachineCleanerRelayStatusHard(true); });
$summary['activated']++; $summary['activated'] += selfserveSetOptionalLaneRelayState($lane, 'relay_machine_id', static function () use ($lane): void {
$lane->setMachineRelayStatusForDepartmentOperation(true);
});
} catch (Throwable $throwable) { } catch (Throwable $throwable) {
$summary['failed']++; $summary['failed']++;
warn( warn(
@@ -1723,19 +1818,31 @@ function selfserveActivateOpeningCleanerRelaysForDepartment(int $departmentId):
return $summary; return $summary;
} }
function selfserveLaneHasAnyConfiguredStaffedDefaultRelay(?object $departmentLane): bool
{
return selfserveLaneHasConfiguredRelay($departmentLane, 'relay_machine_program_picker_id')
|| selfserveLaneHasConfiguredRelay($departmentLane, 'relay_machine_cleaner_id')
|| selfserveLaneHasConfiguredRelay($departmentLane, 'relay_machine_id');
}
function selfserveLaneHasConfiguredCleanerRelay(?object $departmentLane): bool function selfserveLaneHasConfiguredCleanerRelay(?object $departmentLane): bool
{
return selfserveLaneHasConfiguredRelay($departmentLane, 'relay_machine_cleaner_id');
}
function selfserveLaneHasConfiguredRelay(?object $departmentLane, string $relayProperty): bool
{ {
if ( if (
$departmentLane === null $departmentLane === null
|| !isset($departmentLane->relay_machine_cleaner_id) || !isset($departmentLane->{$relayProperty})
|| !is_object($departmentLane->relay_machine_cleaner_id) || !is_object($departmentLane->{$relayProperty})
|| !method_exists($departmentLane->relay_machine_cleaner_id, 'value') || !method_exists($departmentLane->{$relayProperty}, 'value')
) { ) {
return false; return false;
} }
try { try {
$relayId = trim((string)$departmentLane->relay_machine_cleaner_id->value()); $relayId = trim((string)$departmentLane->{$relayProperty}->value());
} catch (Throwable) { } catch (Throwable) {
return false; return false;
} }
@@ -1743,14 +1850,35 @@ function selfserveLaneHasConfiguredCleanerRelay(?object $departmentLane): bool
return $relayId !== '' && $relayId !== '0' && strtolower($relayId) !== 'null'; return $relayId !== '' && $relayId !== '0' && strtolower($relayId) !== 'null';
} }
function selfserveSetOptionalLaneRelayState(object $lane, string $relayProperty, callable $callback): int
{
if (!selfserveLaneHasConfiguredRelay($lane->department_lane ?? null, $relayProperty)) {
return 0;
}
try {
$callback();
return 1;
} catch (Throwable $throwable) {
warn(
'SelfserveOpeningRelayActivationCron failed to activate relay '
. $relayProperty
. ': '
. $throwable->getMessage()
);
throw $throwable;
}
}
function selfserveOpeningCleanerRelayActivationKey( function selfserveOpeningCleanerRelayActivationKey(
int $departmentId, int $departmentId,
string $opensAt, string $opensAt,
DateTimeImmutable $now string $openingDate
): string { ): string {
$normalizedOpensAt = preg_replace('/[^0-9]/', '', $opensAt) ?: 'unknown'; $normalizedOpensAt = preg_replace('/[^0-9]/', '', $opensAt) ?: 'unknown';
return 'selfserve:opening-cleaner-relays:' return 'selfserve:opening-cleaner-relays:'
. $now->format('Y-m-d') . $openingDate
. ':' . ':'
. $departmentId . $departmentId
. ':' . ':'
@@ -4,8 +4,8 @@ return [
[ [
'id' => 'selfserve.activate_opening_cleaner_relays', 'id' => 'selfserve.activate_opening_cleaner_relays',
'legacy_name' => 'SelfserveOpeningRelayActivationCron', 'legacy_name' => 'SelfserveOpeningRelayActivationCron',
'name' => 'Activate self-serve opening cleaner relays', 'name' => 'Restore staffed relays and disable department self-serve at opening',
'description' => 'Turns configured self-serve cleaner relays on when a department enters opening hours.', 'description' => 'Restores every configured lane relay to its staffed default before disabling department self-serve when staffed opening hours begin.',
'module' => 'selfserve', 'module' => 'selfserve',
'handler' => 'SelfserveOpeningRelayActivationCron', 'handler' => 'SelfserveOpeningRelayActivationCron',
'schedule' => ['type' => 'interval', 'seconds' => 60], 'schedule' => ['type' => 'interval', 'seconds' => 60],
@@ -130,6 +130,11 @@ trait selfserve_lane_relay_controller_t
return $this->setRelayStatusHard(selfserve_lane_relay::MACHINE, $on); return $this->setRelayStatusHard(selfserve_lane_relay::MACHINE, $on);
} }
public function setMachineRelayStatusForDepartmentOperation(bool $on): bool
{
return $this->sendRelaySwitchCommandForDepartmentOperation(selfserve_lane_relay::MACHINE, $on);
}
/** /**
* Set PROGRAM SELECTOR relay status directly, bypassing lane status guards. * Set PROGRAM SELECTOR relay status directly, bypassing lane status guards.
* Intended for department-level operational toggles. * Intended for department-level operational toggles.
@@ -161,6 +166,14 @@ trait selfserve_lane_relay_controller_t
return $this->setRelayStatusHard(selfserve_lane_relay::MACHINE_PROGRAM_PICKER, $on); return $this->setRelayStatusHard(selfserve_lane_relay::MACHINE_PROGRAM_PICKER, $on);
} }
public function setMachineProgramPickerRelayStatusForDepartmentOperation(bool $on): bool
{
return $this->sendRelaySwitchCommandForDepartmentOperation(
selfserve_lane_relay::MACHINE_PROGRAM_PICKER,
$on
);
}
/** /**
* Set MACHINE_CLEANER relay status directly. * Set MACHINE_CLEANER relay status directly.
* @param bool $on true to turn on, false to turn off * @param bool $on true to turn on, false to turn off
@@ -182,6 +195,14 @@ trait selfserve_lane_relay_controller_t
return $this->setRelayStatusHard(selfserve_lane_relay::MACHINE_CLEANER, $on); return $this->setRelayStatusHard(selfserve_lane_relay::MACHINE_CLEANER, $on);
} }
public function setMachineCleanerRelayStatusForDepartmentOperation(bool $on): bool
{
return $this->sendRelaySwitchCommandForDepartmentOperation(
selfserve_lane_relay::MACHINE_CLEANER,
$on
);
}
/** /**
* Set relay status directly for a specific relay type. * Set relay status directly for a specific relay type.
* @param selfserve_lane_relay $relay * @param selfserve_lane_relay $relay
@@ -1033,6 +1054,20 @@ trait selfserve_lane_relay_controller_t
throw new \Exception("Cannot change relay state: Self-serve is not enabled for this lane's department."); throw new \Exception("Cannot change relay state: Self-serve is not enabled for this lane's department.");
} }
return $this->sendRelaySwitchCommandForDepartmentOperation($relay, $on, $duration);
}
/**
* Department-level transitions deliberately bypass lane self-serve guards.
* Callers must serialize the transition before using this path.
*
* @throws \Exception
*/
private function sendRelaySwitchCommandForDepartmentOperation(
selfserve_lane_relay $relay,
bool $on,
?int $duration = null
): bool {
$relay_id = $this->getRelayId($relay); $relay_id = $this->getRelayId($relay);
$payload = [ $payload = [
'id' => $relay_id, 'id' => $relay_id,
@@ -4,6 +4,7 @@ namespace objects;
use classes\db; use classes\db;
use classes\object_property; use classes\object_property;
use DateTimeImmutable;
use Exception; use Exception;
use traits\db_object_t; use traits\db_object_t;
@@ -81,6 +82,22 @@ class department_time_bookings_opening_hours_o extends db
} }
} }
public function selectExistingByDepartment(int $department_id): bool
{
$matches = self::getFieldsWhere([
'department' => $department_id
], [
'id'
]);
if (count($matches) === 0) {
return false;
}
$this->id = $matches[0]['id'];
self::getObjectProperties();
return true;
}
public function getObjectProperties(): void public function getObjectProperties(): void
{ {
$this->department = new object_property($this->table, $this->id, 'department', 'int', true); $this->department = new object_property($this->table, $this->id, 'department', 'int', true);
@@ -197,6 +214,90 @@ class department_time_bookings_opening_hours_o extends db
return ($startTime >= $openingStartTime && $endTime <= $openingEndTime); return ($startTime >= $openingStartTime && $endTime <= $openingEndTime);
} }
public function getNextOpeningStart(DateTimeImmutable $now): ?DateTimeImmutable
{
self::requireSelected();
$previousDay = $now->modify('-1 day');
$previousWeekdayName = self::getWeekdayName((int)$previousDay->format('N'));
$previousOpeningStart = $this->normalizeOpeningTime(
$this->{"{$previousWeekdayName}_start"}->value()
);
$previousOpeningEnd = $this->normalizeOpeningTime(
$this->{"{$previousWeekdayName}_end"}->value()
);
if (
$previousOpeningStart !== null
&& $previousOpeningEnd !== null
&& $previousOpeningStart > $previousOpeningEnd
) {
$carryoverEnd = $now->setTime(
$previousOpeningEnd[0],
$previousOpeningEnd[1],
$previousOpeningEnd[2]
);
if ($now < $carryoverEnd) {
return $now;
}
}
for ($dayOffset = 0; $dayOffset <= 7; $dayOffset++) {
$candidateDay = $now->modify('+' . $dayOffset . ' days');
$weekdayName = self::getWeekdayName((int)$candidateDay->format('N'));
$openingStart = $this->normalizeOpeningTime($this->{"{$weekdayName}_start"}->value());
$openingEnd = $this->normalizeOpeningTime($this->{"{$weekdayName}_end"}->value());
if (
$openingStart === null
|| $openingEnd === null
|| $openingStart === $openingEnd
) {
continue;
}
$candidateStart = $candidateDay->setTime($openingStart[0], $openingStart[1], $openingStart[2]);
if ($dayOffset === 0) {
$candidateEnd = $candidateDay->setTime($openingEnd[0], $openingEnd[1], $openingEnd[2]);
if ($candidateEnd <= $candidateStart) {
$candidateEnd = $candidateEnd->modify('+1 day');
}
if ($now >= $candidateStart && $now < $candidateEnd) {
return $now;
}
if ($candidateStart < $now) {
continue;
}
}
return $candidateStart;
}
return null;
}
private function normalizeOpeningTime(mixed $value): ?array
{
$value = trim((string)$value);
if ($value === '') {
return null;
}
if (!preg_match('/^(\d{1,2}):(\d{2})(?::(\d{2}))?$/', $value, $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, $minutes, $seconds];
}
/** /**
* Get the name of the day of the week * Get the name of the day of the week
* @param int $dayOfWeek The day of the week (1 = Monday, 7 = Sunday) * @param int $dayOfWeek The day of the week (1 = Monday, 7 = Sunday)
@@ -59,6 +59,68 @@ class department_variables_o extends db
} }
} }
public static function withSelfServeTransitionLock(
int $departmentId,
callable $callback,
int $timeoutSeconds = 5
): mixed {
if ($departmentId <= 0) {
throw new \InvalidArgumentException('A valid department is required for the self-serve transition lock.');
}
$lockName = 'selfserve:department-transition:' . $departmentId;
if (!self::acquireSelfServeTransitionLock($lockName, max(0, $timeoutSeconds))) {
throw new \RuntimeException('The department self-serve transition is already in progress.');
}
try {
return $callback();
} finally {
try {
self::releaseSelfServeTransitionLock($lockName);
} catch (\Throwable) {
}
}
}
private static function acquireSelfServeTransitionLock(string $lockName, int $timeoutSeconds): bool
{
global $db;
$statement = $db->prepare('SELECT GET_LOCK(?, ?) AS acquired');
if ($statement === false) {
throw new \RuntimeException('Could not prepare the department self-serve transition lock.');
}
try {
$statement->bind_param('si', $lockName, $timeoutSeconds);
$statement->execute();
$result = $statement->get_result();
$row = $result instanceof \mysqli_result ? $result->fetch_assoc() : null;
return (int)($row['acquired'] ?? 0) === 1;
} finally {
$statement->close();
}
}
private static function releaseSelfServeTransitionLock(string $lockName): void
{
global $db;
$statement = $db->prepare('SELECT RELEASE_LOCK(?) AS released');
if ($statement === false) {
throw new \RuntimeException('Could not prepare the department self-serve transition lock release.');
}
try {
$statement->bind_param('s', $lockName);
$statement->execute();
} finally {
$statement->close();
}
}
/** /**
* Require the department ID to be set, and throw an exception if it is not * Require the department ID to be set, and throw an exception if it is not
* @throws Exception If the department ID is not set. * @throws Exception If the department ID is not set.
+28
View File
@@ -4600,6 +4600,19 @@ paths:
properties: properties:
enabled: enabled:
type: boolean type: boolean
auto_deactivation:
type: object
required: [at, timezone, label]
properties:
at:
type: string
format: date-time
nullable: true
timezone:
type: string
example: Europe/Copenhagen
label:
type: string
'404': '404':
$ref: '#/components/responses/NotFound' $ref: '#/components/responses/NotFound'
put: put:
@@ -4632,6 +4645,21 @@ paths:
properties: properties:
message: message:
type: string type: string
enabled:
type: boolean
auto_deactivation:
type: object
required: [at, timezone, label]
properties:
at:
type: string
format: date-time
nullable: true
timezone:
type: string
example: Europe/Copenhagen
label:
type: string
'404': '404':
$ref: '#/components/responses/NotFound' $ref: '#/components/responses/NotFound'
+52 -7
View File
@@ -4,9 +4,12 @@ namespace routes;
use classes\authentication; use classes\authentication;
use classes\selfserve; use classes\selfserve;
use DateTimeImmutable;
use DateTimeZone;
use objects\categories_o; use objects\categories_o;
use objects\department_categories_o; use objects\department_categories_o;
use objects\department_lanes_o; use objects\department_lanes_o;
use objects\department_time_bookings_opening_hours_o;
use objects\department_variables_o; use objects\department_variables_o;
use objects\departments_o; use objects\departments_o;
use objects\logs_o; use objects\logs_o;
@@ -330,9 +333,9 @@ class departmentsRoute
$enabled = $department_variables->getVariable('selfserve_enabled'); $enabled = $department_variables->getVariable('selfserve_enabled');
// Return the status // Return the status
$response->success([ $response->success(
'enabled' => $enabled === true $this->buildDepartmentSelfServeEnabledPayload((int)$department->id, $enabled === true)
]); );
} else { } else {
// Return an error // Return an error
$response->error('Invalid session', 400); $response->error('Invalid session', 400);
@@ -368,14 +371,22 @@ class departmentsRoute
// Set the department variable // Set the department variable
$department_variables = (new department_variables_o())->selectDepartment($department->id); $department_variables = (new department_variables_o())->selectDepartment($department->id);
$enabled = self::getParameter('enabled') === 'true' || self::getParameter('enabled') === true || self::getParameter('enabled') === 1 || self::getParameter('enabled') === '1'; $enabled = self::getParameter('enabled') === 'true' || self::getParameter('enabled') === true || self::getParameter('enabled') === 1 || self::getParameter('enabled') === '1';
department_variables_o::withSelfServeTransitionLock(
(int)$department->id,
function () use ($department_variables, $department, $enabled): void {
$department_variables->set('selfserve_enabled', $enabled ? 'true' : 'false'); $department_variables->set('selfserve_enabled', $enabled ? 'true' : 'false');
$this->syncDepartmentSelfServeRelayStates((int)$department->id, $enabled); $this->syncDepartmentSelfServeRelayStates((int)$department->id, $enabled);
}
);
// Log the incident // Log the incident
(new logs_o())->add('departments', $department->id, 1, $user->id, 'EDIT_DEPARTMENT_SELFSERVE_ENABLED', 'Successfully edited department self-serve enabled status to ' . ($enabled ? 'true' : 'false')); (new logs_o())->add('departments', $department->id, 1, $user->id, 'EDIT_DEPARTMENT_SELFSERVE_ENABLED', 'Successfully edited department self-serve enabled status to ' . ($enabled ? 'true' : 'false'));
// Return a success message // Return a success message
$response->success(['message' => 'Department self-serve enabled status updated successfully']); $response->success([
'message' => 'Department self-serve enabled status updated successfully',
...$this->buildDepartmentSelfServeEnabledPayload((int)$department->id, $enabled)
]);
} else { } else {
// Return an error // Return an error
$response->error('Invalid session', 400); $response->error('Invalid session', 400);
@@ -593,13 +604,13 @@ class departmentsRoute
if (!$enabled) { if (!$enabled) {
// Self-serve disabled: restore normal/manual relay operation. // Self-serve disabled: restore normal/manual relay operation.
$this->setOptionalLaneRelayState($lane, 'relay_machine_program_picker_id', static function () use ($lane): void { $this->setOptionalLaneRelayState($lane, 'relay_machine_program_picker_id', static function () use ($lane): void {
$lane->setMachineProgramPickerRelayStatusHard(true); $lane->setMachineProgramPickerRelayStatusForDepartmentOperation(true);
}); });
$this->setOptionalLaneRelayState($lane, 'relay_machine_cleaner_id', static function () use ($lane): void { $this->setOptionalLaneRelayState($lane, 'relay_machine_cleaner_id', static function () use ($lane): void {
$lane->setMachineCleanerRelayStatusHard(true); $lane->setMachineCleanerRelayStatusForDepartmentOperation(true);
}); });
$this->setOptionalLaneRelayState($lane, 'relay_machine_id', static function () use ($lane): void { $this->setOptionalLaneRelayState($lane, 'relay_machine_id', static function () use ($lane): void {
$lane->setMachineRelayStatusHard(true); $lane->setMachineRelayStatusForDepartmentOperation(true);
}); });
continue; continue;
} }
@@ -643,4 +654,38 @@ class departmentsRoute
// Best effort only; this endpoint should still update the department variable. // Best effort only; this endpoint should still update the department variable.
} }
} }
private function buildDepartmentSelfServeEnabledPayload(int $departmentId, bool $enabled): array
{
$nextDeactivationAt = $enabled
? $this->getDepartmentSelfServeAutoDeactivationAt($departmentId)
: null;
return [
'enabled' => $enabled,
'auto_deactivation' => [
'at' => $nextDeactivationAt?->format(DATE_ATOM),
'timezone' => 'Europe/Copenhagen',
'label' => $nextDeactivationAt === null
? 'NEVER'
: $nextDeactivationAt->format('Y-m-d H:i:s'),
],
];
}
private function getDepartmentSelfServeAutoDeactivationAt(int $departmentId): ?DateTimeImmutable
{
try {
$openingHours = new department_time_bookings_opening_hours_o();
if (!$openingHours->selectExistingByDepartment($departmentId)) {
return null;
}
return $openingHours->getNextOpeningStart(
new DateTimeImmutable('now', new DateTimeZone('Europe/Copenhagen'))
);
} catch (\Throwable) {
return null;
}
}
} }
@@ -6,16 +6,28 @@ it('syncs lane relay states when department self-serve enabled flag changes', fu
expect($routeContent)->not->toBeFalse(); expect($routeContent)->not->toBeFalse();
expect($routeContent)->toContain('/departments/self-serve/enabled'); expect($routeContent)->toContain('/departments/self-serve/enabled');
expect($routeContent)->toContain('$this->syncDepartmentSelfServeRelayStates((int)$department->id, $enabled);'); expect($routeContent)->toContain('$this->syncDepartmentSelfServeRelayStates((int)$department->id, $enabled);');
expect($routeContent)->toContain('department_variables_o::withSelfServeTransitionLock');
expect($routeContent)->toContain('if (!$enabled) {'); expect($routeContent)->toContain('if (!$enabled) {');
expect($routeContent)->toContain('// Self-serve disabled: restore normal/manual relay operation.'); expect($routeContent)->toContain('// Self-serve disabled: restore normal/manual relay operation.');
expect($routeContent)->toContain('setMachineProgramPickerRelayStatusHard(true)'); expect($routeContent)->toContain('setMachineProgramPickerRelayStatusForDepartmentOperation(true)');
expect($routeContent)->toContain('setMachineRelayStatusHard(true)'); expect($routeContent)->toContain('setMachineRelayStatusForDepartmentOperation(true)');
expect($routeContent)->toContain('!$department_lane->isSelfServeEnabled()'); expect($routeContent)->toContain('!$department_lane->isSelfServeEnabled()');
expect($routeContent)->toContain('setMachineProgramPickerRelayStatus(false)'); expect($routeContent)->toContain('setMachineProgramPickerRelayStatus(false)');
expect($routeContent)->toContain('setMachineCleanerRelayStatusHard(true)'); expect($routeContent)->toContain('setMachineCleanerRelayStatusForDepartmentOperation(true)');
expect($routeContent)->toContain('setMachineRelayStatus(false)'); expect($routeContent)->toContain('setMachineRelayStatus(false)');
expect($routeContent)->not->toContain('setMachineProgramPickerRelayStatusHard(false)'); expect($routeContent)->not->toContain('setMachineProgramPickerRelayStatusHard(false)');
expect($routeContent)->not->toContain('setMachineCleanerRelayStatusHard(false)'); expect($routeContent)->not->toContain('setMachineCleanerRelayStatusHard(false)');
expect($routeContent)->not->toContain('setMachineRelayStatusHard(false)'); expect($routeContent)->not->toContain('setMachineRelayStatusHard(false)');
expect($routeContent)->not->toContain('setMachineCleanerRelayStatus(false)'); expect($routeContent)->not->toContain('setMachineCleanerRelayStatus(false)');
}); });
it('uses the supported prepared database API for the department transition lock', function (): void {
$variableObject = file_get_contents(app_path('objects/department_variables_o.php'));
expect($variableObject)->not->toBeFalse()
->and($variableObject)->not->toContain('$database->selectOne')
->and($variableObject)->toContain("\$db->prepare('SELECT GET_LOCK(?, ?) AS acquired')")
->and($variableObject)->toContain("\$statement->bind_param('si', \$lockName, \$timeoutSeconds)")
->and($variableObject)->toContain("\$db->prepare('SELECT RELEASE_LOCK(?) AS released')")
->and($variableObject)->toContain("\$statement->bind_param('s', \$lockName)");
});
@@ -7,10 +7,12 @@ it('registers an opening-time self-serve cleaner relay activation cron', functio
expect($tasks[0]['id'])->toBe('selfserve.activate_opening_cleaner_relays'); expect($tasks[0]['id'])->toBe('selfserve.activate_opening_cleaner_relays');
expect($tasks[0]['legacy_name'])->toBe('SelfserveOpeningRelayActivationCron'); expect($tasks[0]['legacy_name'])->toBe('SelfserveOpeningRelayActivationCron');
expect($tasks[0]['handler'])->toBe('SelfserveOpeningRelayActivationCron'); expect($tasks[0]['handler'])->toBe('SelfserveOpeningRelayActivationCron');
expect($tasks[0]['name'])->toContain('disable department self-serve');
expect($tasks[0]['description'])->toContain('every configured lane relay');
expect($tasks[0]['schedule'])->toBe(['type' => 'interval', 'seconds' => 60]); expect($tasks[0]['schedule'])->toBe(['type' => 'interval', 'seconds' => 60]);
}); });
it('activates only configured cleaner relays after department opening time', function (): void { it('disables department self-serve and restores staffed relay defaults after department opening time', function (): void {
$cronContent = file_get_contents(app_path('cron/Cron.php')); $cronContent = file_get_contents(app_path('cron/Cron.php'));
expect($cronContent)->not->toBeFalse(); expect($cronContent)->not->toBeFalse();
@@ -18,11 +20,41 @@ it('activates only configured cleaner relays after department opening time', fun
expect($cronContent)->toContain("new DateTimeZone('Europe/Copenhagen')"); expect($cronContent)->toContain("new DateTimeZone('Europe/Copenhagen')");
expect($cronContent)->toContain('department_time_bookings_opening_hours'); expect($cronContent)->toContain('department_time_bookings_opening_hours');
expect($cronContent)->toContain("dv.variable = 'selfserve_enabled'"); expect($cronContent)->toContain("dv.variable = 'selfserve_enabled'");
expect($cronContent)->toContain('dl.selfserve_enabled'); expect($cronContent)->toContain("->set('selfserve_enabled', 'false')");
expect($cronContent)->toContain('relay_machine_cleaner_id'); expect($cronContent)->toContain('use objects\department_variables_o;');
expect($cronContent)->toContain('selfserveActivateStaffedDefaultRelaysForDepartment');
expect($cronContent)->toContain('$nowSeconds >= $opensAtSeconds && $nowSeconds < $closesAtSeconds'); expect($cronContent)->toContain('$nowSeconds >= $opensAtSeconds && $nowSeconds < $closesAtSeconds');
expect($cronContent)->toContain('setMachineCleanerRelayStatusHard(true)'); expect($cronContent)->toContain('selfserveOpeningCleanerRelayOvernightCarryoverActive');
expect($cronContent)->toContain('$previousDate = $now->modify(\'-1 day\')');
expect($cronContent)->not->toContain('if (selfserveOpeningCleanerRelayActivationAlreadyHandled($cacheKey))');
expect($cronContent)->not->toContain('if (!$departmentLane->isSelfServeEnabled())');
expect($cronContent)->toContain('throw $throwable;');
expect($cronContent)->toContain('department_variables_o::withSelfServeTransitionLock');
expect(strpos($cronContent, 'selfserveActivateStaffedDefaultRelaysForDepartment($departmentId)'))
->toBeLessThan(strpos($cronContent, 'selfserveDisableDepartmentSelfServeForOpening($departmentId)'));
expect($cronContent)->toContain('setMachineProgramPickerRelayStatusForDepartmentOperation(true)');
expect($cronContent)->toContain('setMachineCleanerRelayStatusForDepartmentOperation(true)');
expect($cronContent)->toContain('setMachineRelayStatusForDepartmentOperation(true)');
expect($cronContent)->toContain('selfserve:opening-cleaner-relays:'); expect($cronContent)->toContain('selfserve:opening-cleaner-relays:');
expect($cronContent)->not->toContain('setMachineRelayStatusHard(true)'); });
expect($cronContent)->not->toContain('setMachineProgramPickerRelayStatusHard(true)');
it('exposes next self-serve auto deactivation metadata from the department endpoint', function (): void {
$routeContent = file_get_contents(app_path('routes/departmentsRoute.php'));
$openingHoursContent = file_get_contents(app_path('objects/department_time_bookings_opening_hours_o.php'));
expect($routeContent)->not->toBeFalse();
expect($openingHoursContent)->not->toBeFalse();
expect($routeContent)->toContain('buildDepartmentSelfServeEnabledPayload');
expect($routeContent)->toContain('selectExistingByDepartment($departmentId)');
expect($routeContent)->toContain("'auto_deactivation'");
expect($routeContent)->toContain("'label' => \$nextDeactivationAt === null");
expect($routeContent)->toContain("'NEVER'");
expect($openingHoursContent)->toContain('function getNextOpeningStart(DateTimeImmutable $now): ?DateTimeImmutable');
expect($openingHoursContent)->toContain('$openingStart === null');
expect($openingHoursContent)->toContain('$openingEnd === null');
expect($openingHoursContent)->toContain('$openingStart === $openingEnd');
expect($openingHoursContent)->toContain('$dayOffset <= 7');
expect($openingHoursContent)->toContain('$previousWeekdayName');
expect($openingHoursContent)->toContain('return $now;');
expect(file_get_contents(app_path('openapi.yaml')))->toContain('auto_deactivation:');
}); });