Add department self-serve config versioning routes with lifecycle actions (list, validate, publish, rollback) and associated logic for managing config versions.

This commit is contained in:
Jeppe Bundgaard
2026-03-25 17:54:41 +01:00
parent 482a17f093
commit 76729f1b99
24 changed files with 1722 additions and 43 deletions
@@ -5,10 +5,12 @@ namespace modules\selfserve\classes;
require_once WD . '/classes/selfserve.php';
require_once WD . '/classes/selfserve_schema_bootstrap.php';
require_once WD . '/modules/selfserve/classes/selfserve_condition_evaluator.php';
require_once WD . '/modules/selfserve/classes/selfserve_config_versioning.php';
require_once WD . '/modules/selfserve/helpers/selfserve_lane_relay.php';
require_once WD . '/modules/selfserve/helpers/selfserve_lane_services.php';
require_once WD . '/modules/selfserve/helpers/selfserve_lane_state.php';
require_once WD . '/modules/selfserve/helpers/selfserve_lane_status.php';
require_once WD . '/modules/selfserve/helpers/selfserve_task_gate_type.php';
require_once WD . '/modules/selfserve/helpers/selfserve_wash_event_type.php';
require_once WD . '/modules/selfserve/helpers/selfserve_wash_session_status.php';
require_once WD . '/modules/selfserve/interfaces/selfserve_condition_evaluator_i.php';
@@ -28,6 +30,8 @@ require_once WD . '/objects/selfserve_wash_sessions_o.php';
use classes\selfserve;
use classes\selfserve_schema_bootstrap;
use modules\selfserve\classes\selfserve_config_versioning;
use modules\selfserve\helpers\selfserve_task_gate_type;
use modules\selfserve\helpers\selfserve_lane_relay;
use modules\selfserve\helpers\selfserve_lane_services;
use modules\selfserve\helpers\selfserve_lane_state;
@@ -58,17 +62,17 @@ class selfserve_wash_flow implements selfserve_wash_flow_i
$this->conditionEvaluator ??= new selfserve_condition_evaluator();
}
public function previewVehicleEligibility(int $laneId, string $reg, ?int $customerNumber = null): array
public function previewVehicleEligibility(int $laneId, string $reg, ?int $customerNumber = null, ?int $vehicleTypeIdOverride = null): array
{
$snapshot = $this->buildEligibilitySnapshot($laneId, $reg, $customerNumber);
$snapshot = $this->buildEligibilitySnapshot($laneId, $reg, $customerNumber, $vehicleTypeIdOverride);
$session = $this->findLatestOpenSession($laneId, $snapshot['reg'], $snapshot['customer_number']);
return $this->formatSnapshotResponse($snapshot, $session->exists() ? $session->asArray() : null);
}
public function synchronizeSession(int $laneId, string $reg, ?int $customerNumber = null, bool $activateMachine = true): array
public function synchronizeSession(int $laneId, string $reg, ?int $customerNumber = null, bool $activateMachine = true, ?int $vehicleTypeIdOverride = null): array
{
$snapshot = $this->buildEligibilitySnapshot($laneId, $reg, $customerNumber);
$snapshot = $this->buildEligibilitySnapshot($laneId, $reg, $customerNumber, $vehicleTypeIdOverride);
$session = $this->findLatestOpenSession($laneId, $snapshot['reg'], $snapshot['customer_number']);
if (!$session->exists()) {
@@ -85,6 +89,11 @@ class selfserve_wash_flow implements selfserve_wash_flow_i
$this->buildSessionMetadata($snapshot),
);
} else {
$session->machine_type_id->set($snapshot['machine_type']['id'] ?? null);
$session->customer_number->set($snapshot['customer_number']);
$session->vehicle_id->set($snapshot['vehicle']['id'] ?? null);
$session->vehicle_type_id->set($snapshot['vehicle_type_id']);
$session->reg->set($snapshot['reg']);
$session->allowed->set((bool)$snapshot['allowed']);
$session->metadata_json->set($this->buildSessionMetadata($snapshot));
$session->updateStatus($this->deriveCurrentStatus($snapshot, $session));
@@ -172,19 +181,54 @@ class selfserve_wash_flow implements selfserve_wash_flow_i
{
try {
$lane = (new selfserve())->lane($laneId);
if (
empty($lane->department_lane)
|| empty($lane->department_lane->relay_machine_id)
|| trim((string)$lane->department_lane->relay_machine_id->value()) === ''
) {
if (empty($lane->department_lane)) {
return;
}
$lane->setMachineRelayStatusHard(false);
$this->turnOffRelayIfConfiguredAndOn($lane, selfserve_lane_relay::MACHINE);
$this->turnOffRelayIfConfiguredAndOn($lane, selfserve_lane_relay::MACHINE_CLEANER);
} catch (\Throwable) {
// Best effort only; session completion flow must continue.
}
}
protected function turnOffRelayIfConfiguredAndOn(selfserve_lane $lane, selfserve_lane_relay $relay): void
{
if (!$this->isRelayConfiguredForLane($lane, $relay)) {
return;
}
try {
$status = $lane->getRelayStatus($relay);
if ((bool)($status['on'] ?? false) !== true) {
return;
}
} catch (\Throwable) {
// If relay status can't be read, still attempt turn-off as best effort.
}
try {
$lane->setRelayStatusHard($relay, false);
} catch (\Throwable) {
// Best effort only; session completion flow must continue.
}
}
protected function isRelayConfiguredForLane(selfserve_lane $lane, selfserve_lane_relay $relay): bool
{
if (empty($lane->department_lane)) {
return false;
}
$relayId = match ($relay) {
selfserve_lane_relay::MACHINE => (string)$lane->department_lane->relay_machine_id->value(),
selfserve_lane_relay::MACHINE_PROGRAM_PICKER => (string)$lane->department_lane->relay_machine_program_picker_id->value(),
selfserve_lane_relay::MACHINE_CLEANER => (string)$lane->department_lane->relay_machine_cleaner_id->value(),
};
return trim($relayId) !== '';
}
public function getSessionSummary(int $sessionId): array
{
$session = (new selfserve_wash_sessions_o())->select($sessionId);
@@ -230,6 +274,8 @@ class selfserve_wash_flow implements selfserve_wash_flow_i
'questions' => $answers,
'tasks' => $tasks,
'events' => $events,
'config_version_id' => is_array($session->metadata_json->value()) ? ($session->metadata_json->value()['config_version_id'] ?? null) : null,
'evaluation_trace' => is_array($session->metadata_json->value()) ? ($session->metadata_json->value()['evaluation_trace'] ?? null) : null,
];
}
@@ -265,7 +311,7 @@ class selfserve_wash_flow implements selfserve_wash_flow_i
return $this->getSessionSummary((int)$session->id);
}
protected function buildEligibilitySnapshot(int $laneId, string $reg, ?int $customerNumber = null): array
protected function buildEligibilitySnapshot(int $laneId, string $reg, ?int $customerNumber = null, ?int $vehicleTypeIdOverride = null): array
{
$normalizedReg = selfserve::standardize_registration($reg);
$lane = (new department_lanes_o())->select($laneId);
@@ -275,14 +321,17 @@ class selfserve_wash_flow implements selfserve_wash_flow_i
$departmentId = (int)$lane->department->value();
$machineTypeId = $lane->machine_type_id->value() === null ? null : (int)$lane->machine_type_id->value();
$publishedConfig = (new selfserve_config_versioning())->getPublishedConfig($departmentId);
$publishedConfigVersionId = $publishedConfig['version_id'] ?? null;
$publishedConfigPayload = is_array($publishedConfig['config'] ?? null) ? $publishedConfig['config'] : null;
$vehicle = $this->findVehicleByRegistration($normalizedReg);
$vehicleData = $vehicle?->asArray();
$vehicleTypeId = $vehicle !== null ? (int)$vehicle->type->value() : null;
$vehicleTypeId = $this->resolveVehicleTypeId($vehicle, $vehicleTypeIdOverride);
$resolvedCustomerNumber = $customerNumber ?? ($vehicle !== null ? (int)$vehicle->customer_id->value() : null);
$questions = $this->loadQuestions($departmentId, $laneId, $vehicleTypeId);
$conditions = $this->loadConditions($departmentId, $laneId, $vehicleTypeId, $machineTypeId);
$rules = $this->loadConditionRules($conditions);
$questions = $this->loadQuestions($departmentId, $laneId, $vehicleTypeId, $publishedConfigPayload);
$conditions = $this->loadConditions($departmentId, $laneId, $vehicleTypeId, $machineTypeId, $publishedConfigPayload);
$rules = $this->loadConditionRules($conditions, $publishedConfigPayload);
$answers = (new department_selfserve_vehicle_conditions_o())->getAnswerMapForVehicle($departmentId, $laneId, $normalizedReg);
$visibilityConditionResults = $this->conditionEvaluator->evaluate($conditions, $rules, $answers);
@@ -309,11 +358,44 @@ class selfserve_wash_flow implements selfserve_wash_flow_i
$visibleAnswers = $this->filterAnswersToVisibleQuestions($answers, $visibleQuestionIds);
$serviceConditionResults = $this->conditionEvaluator->evaluate($conditions, $rules, $visibleAnswers);
$tasks = $this->loadTasks($departmentId, $laneId, $vehicleTypeId, $machineTypeId);
$tasks = $this->loadTasks($departmentId, $laneId, $vehicleTypeId, $machineTypeId, $publishedConfigPayload);
$activeTasks = [];
$taskGateTrace = [];
$conditionIds = array_map(static fn(array $condition): int => (int)($condition['id'] ?? 0), $conditions);
foreach ($tasks as $task) {
$gateId = $this->nullableInt($task['condition_id'] ?? null);
if (!$this->conditionEvaluator->taskGateSatisfied($gateId, $serviceConditionResults, $visibleAnswers)) {
$typedGateType = selfserve_task_gate_type::tryFrom((string)($task['gate_type'] ?? ''));
$typedGateRefId = $this->nullableInt($task['gate_ref_id'] ?? null);
if ($typedGateType === null) {
if ($gateId === null) {
$typedGateType = selfserve_task_gate_type::ALWAYS;
$typedGateRefId = null;
} elseif (in_array($gateId, $conditionIds, true)) {
$typedGateType = selfserve_task_gate_type::CONDITION;
$typedGateRefId = $gateId;
} else {
$typedGateType = selfserve_task_gate_type::QUESTION;
$typedGateRefId = $gateId;
}
}
$gateSatisfied = $this->conditionEvaluator->taskGateSatisfiedTyped(
$typedGateType->value,
$typedGateRefId,
$serviceConditionResults,
$visibleAnswers
);
$taskGateTrace[] = [
'task_id' => (int)$task['id'],
'legacy_gate_id' => $gateId,
'gate_type' => $typedGateType->value,
'gate_ref_id' => $typedGateRefId,
'satisfied' => $gateSatisfied,
];
if (!$gateSatisfied) {
continue;
}
@@ -322,6 +404,8 @@ class selfserve_wash_flow implements selfserve_wash_flow_i
'task' => (string)$task['task'],
'description' => (string)($task['description'] ?? ''),
'condition_id' => $gateId,
'gate_type' => $typedGateType->value,
'gate_ref_id' => $typedGateRefId,
'order_priority' => (int)($task['order_priority'] ?? 0),
'services' => $this->normalizeServiceNames($this->normalizeJsonArray($task['services'] ?? null)),
'buttons' => $this->normalizeIntArray($this->normalizeJsonArray($task['buttons'] ?? null)),
@@ -374,6 +458,12 @@ class selfserve_wash_flow implements selfserve_wash_flow_i
'machine_available' => $machineAvailable,
'all_visible_questions_answered' => $allVisibleQuestionsAnswered,
'allowed' => $machineAllowed,
'config_version_id' => $publishedConfigVersionId === null ? null : (int)$publishedConfigVersionId,
'evaluation_trace' => [
'condition_results' => $serviceConditionResults,
'task_gates' => $taskGateTrace,
'visible_question_ids' => $visibleQuestionIds,
],
];
}
@@ -387,6 +477,7 @@ class selfserve_wash_flow implements selfserve_wash_flow_i
$lane = (new selfserve())->lane($laneId);
$lane->setLaneCache($laneId, $lane::CACHE_SELFSERVE_LANE_KEY_ALLOWED_SERVICES, $snapshot['allowed_services']);
$lane->turnOnRelay(selfserve_lane_relay::MACHINE);
$this->enableCleanerRelayForStartedWash($lane);
$session->markRelayEnabled();
$this->logSessionEvent((int)$session->id, selfserve_wash_event_type::MACHINE_RELAY_ENABLED, [
@@ -430,6 +521,7 @@ class selfserve_wash_flow implements selfserve_wash_flow_i
'vehicle' => $snapshot['vehicle'],
'reg' => $snapshot['reg'],
'customer_number' => $snapshot['customer_number'],
'vehicle_type_id' => $snapshot['vehicle_type_id'],
'questions' => $snapshot['questions'],
'tasks' => $snapshot['tasks'],
'allowed_services' => $snapshot['allowed_services'],
@@ -437,11 +529,41 @@ class selfserve_wash_flow implements selfserve_wash_flow_i
'all_visible_questions_answered' => $snapshot['all_visible_questions_answered'],
'allowed' => $snapshot['allowed'],
'session' => $session,
'config_version_id' => $snapshot['config_version_id'] ?? null,
'evaluation_trace' => $snapshot['evaluation_trace'] ?? null,
];
}
protected function loadQuestions(int $departmentId, int $laneId, ?int $vehicleTypeId): array
/**
* @param array<string,mixed>|null $publishedConfig
*/
protected function loadQuestions(int $departmentId, int $laneId, ?int $vehicleTypeId, ?array $publishedConfig = null): array
{
if (is_array($publishedConfig) && isset($publishedConfig['questions']) && is_array($publishedConfig['questions'])) {
$questions = array_values(array_filter($publishedConfig['questions'], static function (array $question) use ($departmentId): bool {
return ((int)($question['department'] ?? 0) === 0) || ((int)($question['department'] ?? 0) === $departmentId);
}));
$sharedQuestions = array_values(array_filter($questions, static function (array $question): bool {
return (int)($question['department'] ?? 0) === 0
&& (int)($question['lane'] ?? 0) === 0
&& (int)($question['product'] ?? 0) === 0;
}));
if ($sharedQuestions !== []) {
return $sharedQuestions;
}
if ($vehicleTypeId === null) {
return [];
}
return array_values(array_filter($questions, static function (array $question) use ($departmentId, $laneId, $vehicleTypeId): bool {
return (int)($question['department'] ?? 0) === $departmentId
&& (int)($question['lane'] ?? 0) === $laneId
&& (int)($question['product'] ?? 0) === $vehicleTypeId;
}));
}
$questionsObject = new department_selfserve_questions_o();
$sharedQuestions = $questionsObject->getSharedQuestions();
if ($sharedQuestions !== []) {
@@ -454,8 +576,37 @@ class selfserve_wash_flow implements selfserve_wash_flow_i
return $questionsObject->getLegacyQuestionsForLaneProduct($departmentId, $laneId, $vehicleTypeId);
}
protected function loadConditions(int $departmentId, int $laneId, ?int $vehicleTypeId, ?int $machineTypeId): array
/**
* @param array<string,mixed>|null $publishedConfig
*/
protected function loadConditions(int $departmentId, int $laneId, ?int $vehicleTypeId, ?int $machineTypeId, ?array $publishedConfig = null): array
{
if (is_array($publishedConfig) && isset($publishedConfig['conditions']) && is_array($publishedConfig['conditions'])) {
$conditions = array_values(array_filter($publishedConfig['conditions'], static function (array $condition) use ($departmentId): bool {
return ((int)($condition['department'] ?? 0) === 0) || ((int)($condition['department'] ?? 0) === $departmentId);
}));
if ($machineTypeId !== null) {
$machineTypeConditions = array_values(array_filter($conditions, static function (array $condition) use ($machineTypeId): bool {
return (int)($condition['machine_type_id'] ?? 0) === $machineTypeId;
}));
if ($machineTypeConditions !== []) {
return $machineTypeConditions;
}
}
if ($vehicleTypeId === null) {
return [];
}
return array_values(array_filter($conditions, static function (array $condition) use ($departmentId, $laneId, $vehicleTypeId): bool {
return (int)($condition['machine_type_id'] ?? 0) === 0
&& (int)($condition['department'] ?? 0) === $departmentId
&& (int)($condition['lane'] ?? 0) === $laneId
&& (int)($condition['product'] ?? 0) === $vehicleTypeId;
}));
}
$conditionsObject = new department_selfserve_conditions_o();
if ($machineTypeId !== null) {
$machineTypeConditions = $conditionsObject->getConditionsForMachineType($machineTypeId);
@@ -470,8 +621,36 @@ class selfserve_wash_flow implements selfserve_wash_flow_i
return $conditionsObject->getLegacyConditionsForLaneProduct($departmentId, $laneId, $vehicleTypeId);
}
protected function loadTasks(int $departmentId, int $laneId, ?int $vehicleTypeId, ?int $machineTypeId): array
/**
* @param array<string,mixed>|null $publishedConfig
*/
protected function loadTasks(int $departmentId, int $laneId, ?int $vehicleTypeId, ?int $machineTypeId, ?array $publishedConfig = null): array
{
if (is_array($publishedConfig) && isset($publishedConfig['tasks']) && is_array($publishedConfig['tasks'])) {
$tasks = array_values(array_filter($publishedConfig['tasks'], static function (array $task) use ($departmentId): bool {
return ((int)($task['department'] ?? 0) === 0) || ((int)($task['department'] ?? 0) === $departmentId);
}));
if ($machineTypeId !== null) {
$machineTypeTasks = array_values(array_filter($tasks, static function (array $task) use ($machineTypeId): bool {
return (int)($task['machine_type_id'] ?? 0) === $machineTypeId;
}));
if ($machineTypeTasks !== []) {
return $machineTypeTasks;
}
}
if ($vehicleTypeId === null) {
return [];
}
return array_values(array_filter($tasks, static function (array $task) use ($departmentId, $laneId, $vehicleTypeId): bool {
return (int)($task['machine_type_id'] ?? 0) === 0
&& (int)($task['department'] ?? 0) === $departmentId
&& (int)($task['lane'] ?? 0) === $laneId
&& (int)($task['product'] ?? 0) === $vehicleTypeId;
}));
}
$tasksObject = new department_selfserve_tasks_o();
if ($machineTypeId !== null) {
$machineTypeTasks = $tasksObject->getTasksForMachineType($machineTypeId);
@@ -486,7 +665,10 @@ class selfserve_wash_flow implements selfserve_wash_flow_i
return $tasksObject->getLegacyTasksForLaneProduct($departmentId, $laneId, $vehicleTypeId);
}
protected function loadConditionRules(array $conditions): array
/**
* @param array<string,mixed>|null $publishedConfig
*/
protected function loadConditionRules(array $conditions, ?array $publishedConfig = null): array
{
if ($conditions === []) {
return [];
@@ -494,6 +676,12 @@ class selfserve_wash_flow implements selfserve_wash_flow_i
$conditionIds = array_map(static fn(array $condition): int => (int)$condition['id'], $conditions);
if (is_array($publishedConfig) && isset($publishedConfig['rules']) && is_array($publishedConfig['rules'])) {
return array_values(array_filter($publishedConfig['rules'], static function (array $rule) use ($conditionIds): bool {
return in_array((int)($rule['condition_id'] ?? 0), $conditionIds, true);
}));
}
return (new department_selfserve_condition_rules_o())->getFieldsWhereIn([
'condition_id' => $conditionIds,
'deleted_at' => null,
@@ -547,6 +735,8 @@ class selfserve_wash_flow implements selfserve_wash_flow_i
'allowed_services' => $snapshot['allowed_services'],
'machine_available' => (bool)$snapshot['machine_available'],
'all_visible_questions_answered' => (bool)$snapshot['all_visible_questions_answered'],
'config_version_id' => $snapshot['config_version_id'] ?? null,
'evaluation_trace' => $snapshot['evaluation_trace'] ?? null,
'visible_question_ids' => array_map(static fn(array $question): int => (int)$question['id'], $snapshot['questions']),
'visible_questions' => array_map(static fn(array $question): array => [
'id' => (int)$question['id'],
@@ -681,6 +871,19 @@ class selfserve_wash_flow implements selfserve_wash_flow_i
return $vehicle->exists() ? $vehicle : null;
}
protected function resolveVehicleTypeId(?customer_vehicles_o $vehicle, ?int $vehicleTypeIdOverride = null): ?int
{
if ($vehicleTypeIdOverride !== null && $vehicleTypeIdOverride > 0) {
return $vehicleTypeIdOverride;
}
if ($vehicle === null) {
return null;
}
$vehicleTypeId = (int)$vehicle->type->value();
return $vehicleTypeId > 0 ? $vehicleTypeId : null;
}
protected function findLatestOpenSession(int $laneId, string $reg, ?int $customerNumber = null): selfserve_wash_sessions_o
{
$session = new selfserve_wash_sessions_o();