Auto-disable department self-serve at opening (#323)
Auto-disable department self-serve at opening
This commit is contained in:
@@ -4157,6 +4157,19 @@ paths:
|
||||
properties:
|
||||
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':
|
||||
$ref: '#/components/responses/NotFound'
|
||||
put:
|
||||
@@ -4189,6 +4202,21 @@ paths:
|
||||
properties:
|
||||
message:
|
||||
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':
|
||||
$ref: '#/components/responses/NotFound'
|
||||
|
||||
|
||||
@@ -30,6 +30,7 @@ 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\department_variables_o;
|
||||
use objects\departments_o;
|
||||
use objects\bookings_o;
|
||||
use objects\logs_o;
|
||||
@@ -1524,6 +1525,7 @@ function SelfserveOpeningRelayActivationCron(): array
|
||||
$candidates = selfserveOpeningCleanerRelayActivationCandidates($now);
|
||||
$summary = [
|
||||
'checked_departments' => count($candidates),
|
||||
'disabled_departments' => 0,
|
||||
'activated_departments' => 0,
|
||||
'skipped_departments' => 0,
|
||||
'failed_departments' => 0,
|
||||
@@ -1535,18 +1537,45 @@ function SelfserveOpeningRelayActivationCron(): array
|
||||
foreach ($candidates as $candidate) {
|
||||
$departmentId = (int)($candidate['department_id'] ?? 0);
|
||||
$opensAt = (string)($candidate['opens_at'] ?? '');
|
||||
$openingDate = (string)($candidate['opening_date'] ?? $now->format('Y-m-d'));
|
||||
if ($departmentId <= 0 || $opensAt === '') {
|
||||
$summary['skipped_departments']++;
|
||||
continue;
|
||||
}
|
||||
|
||||
$cacheKey = selfserveOpeningCleanerRelayActivationKey($departmentId, $opensAt, $now);
|
||||
if (selfserveOpeningCleanerRelayActivationAlreadyHandled($cacheKey)) {
|
||||
$summary['skipped_departments']++;
|
||||
$cacheKey = selfserveOpeningCleanerRelayActivationKey($departmentId, $opensAt, $openingDate);
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
$departmentSummary = selfserveActivateOpeningCleanerRelaysForDepartment($departmentId);
|
||||
$departmentSummary = $transition['department_summary'];
|
||||
$summary['activated_relays'] += (int)$departmentSummary['activated'];
|
||||
$summary['skipped_relays'] += (int)$departmentSummary['skipped'];
|
||||
$summary['failed_relays'] += (int)$departmentSummary['failed'];
|
||||
@@ -1556,6 +1585,13 @@ function SelfserveOpeningRelayActivationCron(): array
|
||||
continue;
|
||||
}
|
||||
|
||||
$departmentDisabled = (bool)$transition['department_disabled'];
|
||||
if (!$departmentDisabled) {
|
||||
$summary['failed_departments']++;
|
||||
continue;
|
||||
}
|
||||
|
||||
$summary['disabled_departments']++;
|
||||
selfserveMarkOpeningCleanerRelayActivationHandled($cacheKey);
|
||||
if ((int)$departmentSummary['activated'] > 0) {
|
||||
$summary['activated_departments']++;
|
||||
@@ -1565,7 +1601,8 @@ function SelfserveOpeningRelayActivationCron(): array
|
||||
}
|
||||
|
||||
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";
|
||||
|
||||
return $summary;
|
||||
@@ -1583,23 +1620,32 @@ function selfserveOpeningCleanerRelayActivationCandidates(DateTimeImmutable $now
|
||||
|
||||
$startColumn = $weekday . '_start';
|
||||
$endColumn = $weekday . '_end';
|
||||
$previousDate = $now->modify('-1 day');
|
||||
$previousWeekday = strtolower($previousDate->format('l'));
|
||||
$previousStartColumn = $previousWeekday . '_start';
|
||||
$previousEndColumn = $previousWeekday . '_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
|
||||
TIME_FORMAT(oh.`$startColumn`, '%H:%i:%s') AS current_opens_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
|
||||
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`
|
||||
AND (
|
||||
(oh.`$startColumn` IS NOT NULL AND oh.`$endColumn` IS NOT NULL)
|
||||
OR
|
||||
(oh.`$previousStartColumn` IS NOT NULL AND oh.`$previousEndColumn` IS NOT NULL)
|
||||
)
|
||||
GROUP BY
|
||||
oh.department,
|
||||
oh.`$startColumn`,
|
||||
oh.`$endColumn`,
|
||||
oh.`$previousStartColumn`,
|
||||
oh.`$previousEndColumn`
|
||||
";
|
||||
|
||||
$result = $db->query($sql);
|
||||
@@ -1607,14 +1653,33 @@ function selfserveOpeningCleanerRelayActivationCandidates(DateTimeImmutable $now
|
||||
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'] ?? '')
|
||||
)
|
||||
));
|
||||
$candidates = [];
|
||||
foreach ($db->fetch_all($result) as $row) {
|
||||
$currentOpensAt = (string)($row['current_opens_at'] ?? '');
|
||||
$currentClosesAt = (string)($row['current_closes_at'] ?? '');
|
||||
if (selfserveOpeningCleanerRelayWindowActive($now, $currentOpensAt, $currentClosesAt)) {
|
||||
$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(
|
||||
@@ -1639,6 +1704,24 @@ function selfserveOpeningCleanerRelayWindowActive(
|
||||
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
|
||||
{
|
||||
$time = trim((string)$time);
|
||||
@@ -1661,7 +1744,27 @@ function selfserveOpeningCleanerRelayTimeToSeconds(?string $time): ?int
|
||||
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 = [
|
||||
'activated' => 0,
|
||||
@@ -1677,17 +1780,7 @@ function selfserveActivateOpeningCleanerRelaysForDepartment(int $departmentId):
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
if (!$departmentLane->isSelfServeEnabled()) {
|
||||
$summary['skipped']++;
|
||||
continue;
|
||||
}
|
||||
} catch (Throwable) {
|
||||
$summary['skipped']++;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!selfserveLaneHasConfiguredCleanerRelay($departmentLane)) {
|
||||
if (!selfserveLaneHasAnyConfiguredStaffedDefaultRelay($departmentLane)) {
|
||||
$summary['skipped']++;
|
||||
continue;
|
||||
}
|
||||
@@ -1700,13 +1793,15 @@ function selfserveActivateOpeningCleanerRelaysForDepartment(int $departmentId):
|
||||
|
||||
try {
|
||||
$lane = $selfserve->lane($laneId);
|
||||
if (!selfserveLaneHasConfiguredCleanerRelay($lane->department_lane)) {
|
||||
$summary['skipped']++;
|
||||
continue;
|
||||
}
|
||||
|
||||
$lane->setMachineCleanerRelayStatusHard(true);
|
||||
$summary['activated']++;
|
||||
$summary['activated'] += selfserveSetOptionalLaneRelayState($lane, 'relay_machine_program_picker_id', static function () use ($lane): void {
|
||||
$lane->setMachineProgramPickerRelayStatusForDepartmentOperation(true);
|
||||
});
|
||||
$summary['activated'] += selfserveSetOptionalLaneRelayState($lane, 'relay_machine_cleaner_id', static function () use ($lane): void {
|
||||
$lane->setMachineCleanerRelayStatusForDepartmentOperation(true);
|
||||
});
|
||||
$summary['activated'] += selfserveSetOptionalLaneRelayState($lane, 'relay_machine_id', static function () use ($lane): void {
|
||||
$lane->setMachineRelayStatusForDepartmentOperation(true);
|
||||
});
|
||||
} catch (Throwable $throwable) {
|
||||
$summary['failed']++;
|
||||
warn(
|
||||
@@ -1723,19 +1818,31 @@ function selfserveActivateOpeningCleanerRelaysForDepartment(int $departmentId):
|
||||
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
|
||||
{
|
||||
return selfserveLaneHasConfiguredRelay($departmentLane, 'relay_machine_cleaner_id');
|
||||
}
|
||||
|
||||
function selfserveLaneHasConfiguredRelay(?object $departmentLane, string $relayProperty): 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')
|
||||
|| !isset($departmentLane->{$relayProperty})
|
||||
|| !is_object($departmentLane->{$relayProperty})
|
||||
|| !method_exists($departmentLane->{$relayProperty}, 'value')
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
$relayId = trim((string)$departmentLane->relay_machine_cleaner_id->value());
|
||||
$relayId = trim((string)$departmentLane->{$relayProperty}->value());
|
||||
} catch (Throwable) {
|
||||
return false;
|
||||
}
|
||||
@@ -1743,14 +1850,35 @@ function selfserveLaneHasConfiguredCleanerRelay(?object $departmentLane): bool
|
||||
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(
|
||||
int $departmentId,
|
||||
string $opensAt,
|
||||
DateTimeImmutable $now
|
||||
string $openingDate
|
||||
): string {
|
||||
$normalizedOpensAt = preg_replace('/[^0-9]/', '', $opensAt) ?: 'unknown';
|
||||
return 'selfserve:opening-cleaner-relays:'
|
||||
. $now->format('Y-m-d')
|
||||
. $openingDate
|
||||
. ':'
|
||||
. $departmentId
|
||||
. ':'
|
||||
|
||||
@@ -4,8 +4,8 @@ return [
|
||||
[
|
||||
'id' => 'selfserve.activate_opening_cleaner_relays',
|
||||
'legacy_name' => 'SelfserveOpeningRelayActivationCron',
|
||||
'name' => 'Activate self-serve opening cleaner relays',
|
||||
'description' => 'Turns configured self-serve cleaner relays on when a department enters opening hours.',
|
||||
'name' => 'Restore staffed relays and disable department self-serve at opening',
|
||||
'description' => 'Restores every configured lane relay to its staffed default before disabling department self-serve when staffed opening hours begin.',
|
||||
'module' => 'selfserve',
|
||||
'handler' => 'SelfserveOpeningRelayActivationCron',
|
||||
'schedule' => ['type' => 'interval', 'seconds' => 60],
|
||||
|
||||
@@ -130,6 +130,11 @@ trait selfserve_lane_relay_controller_t
|
||||
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.
|
||||
* 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);
|
||||
}
|
||||
|
||||
public function setMachineProgramPickerRelayStatusForDepartmentOperation(bool $on): bool
|
||||
{
|
||||
return $this->sendRelaySwitchCommandForDepartmentOperation(
|
||||
selfserve_lane_relay::MACHINE_PROGRAM_PICKER,
|
||||
$on
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Set MACHINE_CLEANER relay status directly.
|
||||
* @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);
|
||||
}
|
||||
|
||||
public function setMachineCleanerRelayStatusForDepartmentOperation(bool $on): bool
|
||||
{
|
||||
return $this->sendRelaySwitchCommandForDepartmentOperation(
|
||||
selfserve_lane_relay::MACHINE_CLEANER,
|
||||
$on
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Set relay status directly for a specific relay type.
|
||||
* @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.");
|
||||
}
|
||||
|
||||
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);
|
||||
$payload = [
|
||||
'id' => $relay_id,
|
||||
|
||||
@@ -4,6 +4,7 @@ namespace objects;
|
||||
|
||||
use classes\db;
|
||||
use classes\object_property;
|
||||
use DateTimeImmutable;
|
||||
use Exception;
|
||||
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
|
||||
{
|
||||
$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);
|
||||
}
|
||||
|
||||
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
|
||||
* @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
|
||||
* @throws Exception If the department ID is not set.
|
||||
|
||||
@@ -4600,6 +4600,19 @@ paths:
|
||||
properties:
|
||||
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':
|
||||
$ref: '#/components/responses/NotFound'
|
||||
put:
|
||||
@@ -4632,6 +4645,21 @@ paths:
|
||||
properties:
|
||||
message:
|
||||
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':
|
||||
$ref: '#/components/responses/NotFound'
|
||||
|
||||
|
||||
@@ -4,9 +4,12 @@ namespace routes;
|
||||
|
||||
use classes\authentication;
|
||||
use classes\selfserve;
|
||||
use DateTimeImmutable;
|
||||
use DateTimeZone;
|
||||
use objects\categories_o;
|
||||
use objects\department_categories_o;
|
||||
use objects\department_lanes_o;
|
||||
use objects\department_time_bookings_opening_hours_o;
|
||||
use objects\department_variables_o;
|
||||
use objects\departments_o;
|
||||
use objects\logs_o;
|
||||
@@ -330,9 +333,9 @@ class departmentsRoute
|
||||
$enabled = $department_variables->getVariable('selfserve_enabled');
|
||||
|
||||
// Return the status
|
||||
$response->success([
|
||||
'enabled' => $enabled === true
|
||||
]);
|
||||
$response->success(
|
||||
$this->buildDepartmentSelfServeEnabledPayload((int)$department->id, $enabled === true)
|
||||
);
|
||||
} else {
|
||||
// Return an error
|
||||
$response->error('Invalid session', 400);
|
||||
@@ -368,14 +371,22 @@ class departmentsRoute
|
||||
// Set the department variable
|
||||
$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';
|
||||
department_variables_o::withSelfServeTransitionLock(
|
||||
(int)$department->id,
|
||||
function () use ($department_variables, $department, $enabled): void {
|
||||
$department_variables->set('selfserve_enabled', $enabled ? 'true' : 'false');
|
||||
$this->syncDepartmentSelfServeRelayStates((int)$department->id, $enabled);
|
||||
}
|
||||
);
|
||||
|
||||
// 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'));
|
||||
|
||||
// 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 {
|
||||
// Return an error
|
||||
$response->error('Invalid session', 400);
|
||||
@@ -593,13 +604,13 @@ class departmentsRoute
|
||||
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);
|
||||
$lane->setMachineProgramPickerRelayStatusForDepartmentOperation(true);
|
||||
});
|
||||
$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 {
|
||||
$lane->setMachineRelayStatusHard(true);
|
||||
$lane->setMachineRelayStatusForDepartmentOperation(true);
|
||||
});
|
||||
continue;
|
||||
}
|
||||
@@ -643,4 +654,38 @@ class departmentsRoute
|
||||
// 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+15
-3
@@ -6,16 +6,28 @@ it('syncs lane relay states when department self-serve enabled flag changes', fu
|
||||
expect($routeContent)->not->toBeFalse();
|
||||
expect($routeContent)->toContain('/departments/self-serve/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('// Self-serve disabled: restore normal/manual relay operation.');
|
||||
expect($routeContent)->toContain('setMachineProgramPickerRelayStatusHard(true)');
|
||||
expect($routeContent)->toContain('setMachineRelayStatusHard(true)');
|
||||
expect($routeContent)->toContain('setMachineProgramPickerRelayStatusForDepartmentOperation(true)');
|
||||
expect($routeContent)->toContain('setMachineRelayStatusForDepartmentOperation(true)');
|
||||
expect($routeContent)->toContain('!$department_lane->isSelfServeEnabled()');
|
||||
expect($routeContent)->toContain('setMachineProgramPickerRelayStatus(false)');
|
||||
expect($routeContent)->toContain('setMachineCleanerRelayStatusHard(true)');
|
||||
expect($routeContent)->toContain('setMachineCleanerRelayStatusForDepartmentOperation(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)');
|
||||
});
|
||||
|
||||
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)");
|
||||
});
|
||||
|
||||
+38
-6
@@ -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]['legacy_name'])->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]);
|
||||
});
|
||||
|
||||
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'));
|
||||
|
||||
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('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("->set('selfserve_enabled', 'false')");
|
||||
expect($cronContent)->toContain('use objects\department_variables_o;');
|
||||
expect($cronContent)->toContain('selfserveActivateStaffedDefaultRelaysForDepartment');
|
||||
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)->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:');
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user