Add support for selfserve_enabled lanes and synchronize behavior across tasks, sessions, and projections

- Introduced `selfserve_enabled` property for `department_lanes` with schema update, object properties, and associated methods/tests.
- Enhanced `selfserve_wash_flow` and session logic to respect lane self-serve settings, including block handling and task filtering.
- Updated API routes and Studio Graph projections to include `selfserve_enabled` in payloads and progress callbacks.
- Added unit tests for session statuses, lane configuration, and blocking behavior due to disabled self-serve settings.
This commit is contained in:
Jeppe Bundgaard
2026-04-29 17:28:42 +02:00
parent 5875371d13
commit 8bf957b273
18 changed files with 924 additions and 101 deletions
@@ -264,6 +264,7 @@ class edge_gateway_department_workspace_service
'relay_machine_cleaner_id' => $lane->relay_machine_cleaner_id->value() === null ? null : (string)$lane->relay_machine_cleaner_id->value(),
'dynamic_image_id' => $lane->dynamic_image_id->value() === null ? null : (int)$lane->dynamic_image_id->value(),
'machine_type_id' => $lane->machine_type_id->value() === null ? null : (int)$lane->machine_type_id->value(),
'selfserve_enabled' => $lane->isSelfServeEnabled(),
'status' => $laneStatus,
'self_serve_products' => $lane->getSelfServeLaneProducts(),
'relay_slots' => $relaySlots,
@@ -385,7 +386,10 @@ class edge_gateway_department_workspace_service
} catch (\Throwable) {
}
$readyLanes = array_values(array_filter($lanes, static function (array $lane): bool {
$enabledLanes = array_values(array_filter($lanes, static function (array $lane): bool {
return ($lane['selfserve_enabled'] ?? true) !== false;
}));
$readyLanes = array_values(array_filter($enabledLanes, static function (array $lane): bool {
return (string)($lane['binding_coverage']['state'] ?? 'UNKNOWN') === 'READY';
}));
@@ -404,12 +408,13 @@ class edge_gateway_department_workspace_service
return [
'enabled' => $enabled,
'lane_count' => count($lanes),
'enabled_lanes' => count($enabledLanes),
'ready_lanes' => count($readyLanes),
'configured_task_count' => count($taskRows),
'configured_product_count' => count($productIds),
'readiness_state' => !$enabled
? 'DISABLED'
: (count($lanes) === 0 ? 'UNCONFIGURED' : (count($readyLanes) === count($lanes) ? 'READY' : 'PARTIAL')),
: (count($enabledLanes) === 0 ? 'UNCONFIGURED' : (count($readyLanes) === count($enabledLanes) ? 'READY' : 'PARTIAL')),
'links' => [
'studio' => '/admin/' . (int)$department->id . '/modules/self-serve/studio',
'legacy' => '/superuser/selfserve',
@@ -594,7 +599,7 @@ class edge_gateway_department_workspace_service
}
}
if (($selfServe['enabled'] ?? false) && (int)($selfServe['ready_lanes'] ?? 0) < (int)($selfServe['lane_count'] ?? 0)) {
if (($selfServe['enabled'] ?? false) && (int)($selfServe['ready_lanes'] ?? 0) < (int)($selfServe['enabled_lanes'] ?? 0)) {
$issues[] = [
'severity' => 'warning',
'code' => 'SELFSERVE_PARTIAL_READY',
@@ -13,6 +13,7 @@ require_once WD . '/objects/department_selfserve_condition_rules_o.php';
require_once WD . '/objects/department_selfserve_conditions_o.php';
require_once WD . '/objects/department_selfserve_questions_o.php';
require_once WD . '/objects/department_selfserve_tasks_o.php';
require_once WD . '/objects/department_lanes_o.php';
require_once WD . '/objects/selfserve_config_versions_o.php';
require_once WD . '/modules/selfserve/classes/selfserve_task_attachment_payloads.php';
@@ -589,7 +590,13 @@ class selfserve_studio_graph
* @param array<string,bool> $permissions
* @return array<string,mixed>
*/
public function projectPathOutcomes(int $departmentId, array $payload, ?int $userId = null, array $permissions = []): array
public function projectPathOutcomes(
int $departmentId,
array $payload,
?int $userId = null,
array $permissions = [],
?callable $progressCallback = null
): array
{
$configSource = strtolower(trim((string)($payload['config_source'] ?? 'draft')));
if (!in_array($configSource, ['draft', 'published'], true)) {
@@ -669,8 +676,7 @@ class selfserve_studio_graph
$vehicleTypeIds[] = null;
}
$maxStates = (int)($payload['max_states'] ?? 2048);
$maxStates = max(1, min(2048, $maxStates));
$maxStates = $this->pathLimit($payload['max_states'] ?? null);
$reg = trim((string)($payload['reg'] ?? $defaults['reg'] ?? 'TEST123'));
if ($reg === '') {
$reg = 'TEST123';
@@ -686,12 +692,13 @@ class selfserve_studio_graph
$stateCount = 0;
$terminalPathCount = 0;
$questionIds = [];
$pathSampleLimit = max(1, min(200, (int)($payload['path_sample_limit'] ?? 100)));
$pathSampleLimit = $this->pathLimit($payload['path_sample_limit'] ?? null);
$paths = [];
$scenarioCount = max(1, count($vehicleTypeIds));
foreach ($vehicleTypeIds as $scenarioVehicleTypeId) {
$remainingStates = $maxStates - $stateCount;
if ($remainingStates <= 0) {
foreach ($vehicleTypeIds as $scenarioIndex => $scenarioVehicleTypeId) {
$remainingStates = $maxStates === null ? null : $maxStates - $stateCount;
if ($remainingStates !== null && $remainingStates <= 0) {
$truncated = true;
break;
}
@@ -746,18 +753,104 @@ class selfserve_studio_graph
);
};
$projection = $this->projectPathOutcomesFromSimulator($simulate, [
$projectionOptions = [
'scope' => $scenarioScope,
'max_states' => $remainingStates,
'path_sample_limit' => max(0, $pathSampleLimit - count($paths)),
]);
'path_sample_limit' => $pathSampleLimit === null ? null : max(0, $pathSampleLimit - count($paths)),
'progress_callback' => function (array $projection) use (
$progressCallback,
&$outcomes,
&$paths,
&$stateCount,
&$terminalPathCount,
&$questionIds,
$maxStates,
$pathSampleLimit,
$scenarioIndex,
$scenarioCount,
$departmentId,
$lookups,
$laneId,
$vehicleTypeId,
$reg,
$customerNumber,
$configSource,
$versionId,
$hardwareMode
): void {
if ($progressCallback === null) {
return;
}
$partialOutcomes = array_merge($outcomes, array_values((array)($projection['outcomes'] ?? [])));
$partialPaths = array_merge($paths, array_values((array)($projection['paths'] ?? [])));
if ($pathSampleLimit !== null && count($partialPaths) > $pathSampleLimit) {
$partialPaths = array_slice($partialPaths, 0, $pathSampleLimit);
}
$partialQuestionIds = $questionIds;
foreach ((array)($projection['summary']['question_ids'] ?? []) as $questionId) {
$partialQuestionIds[(int)$questionId] = true;
}
$projectionProgress = is_array($projection['progress'] ?? null) ? (array)$projection['progress'] : [];
$scenarioPercent = (float)($projectionProgress['percent'] ?? 0);
$overallPercent = min(99.0, (($scenarioIndex + ($scenarioPercent / 100)) / $scenarioCount) * 100);
$partialPayload = $this->pathOutcomesPayload(
[
'department_id' => $departmentId,
'department' => $this->labelFor('departments', $departmentId, $lookups),
'lane_id' => $laneId,
'lane' => $this->labelFor('lanes', $laneId, $lookups),
'vehicle_type_id' => $vehicleTypeId,
'vehicle_type' => $vehicleTypeId === null ? 'All current vehicle types' : $this->labelFor('vehicle_types', $vehicleTypeId, $lookups),
'vehicle_type_count' => $scenarioCount,
'registration' => $reg,
'customer_number' => $customerNumber,
'config_source' => $configSource,
'config_version_id' => $versionId,
'hardware_mode' => $hardwareMode,
'max_states' => $maxStates,
],
$partialOutcomes,
$partialPaths,
[],
false,
$maxStates,
$stateCount + (int)($projection['summary']['state_count'] ?? 0),
$terminalPathCount + (int)($projection['summary']['terminal_path_count'] ?? 0),
$partialQuestionIds,
[
'complete' => false,
'percent' => (int)floor($overallPercent),
'state_count' => $stateCount + (int)($projection['summary']['state_count'] ?? 0),
'pending_state_count' => (int)($projectionProgress['pending_state_count'] ?? 0),
'terminal_path_count' => $terminalPathCount + (int)($projection['summary']['terminal_path_count'] ?? 0),
'scenario_index' => $scenarioIndex + 1,
'scenario_count' => $scenarioCount,
]
);
$progressCallback($partialPayload);
},
];
if ($remainingStates === null) {
unset($projectionOptions['max_states']);
}
if ($pathSampleLimit === null) {
unset($projectionOptions['path_sample_limit']);
}
$projection = $this->projectPathOutcomesFromSimulator($simulate, $projectionOptions);
foreach ((array)($projection['outcomes'] ?? []) as $outcome) {
if (is_array($outcome)) {
$outcomes[] = $outcome;
}
}
foreach ((array)($projection['paths'] ?? []) as $path) {
if (is_array($path) && count($paths) < $pathSampleLimit) {
if (!is_array($path)) {
continue;
}
if ($pathSampleLimit === null || count($paths) < $pathSampleLimit) {
$paths[] = $path;
}
}
@@ -772,23 +865,12 @@ class selfserve_studio_graph
}
}
usort($outcomes, static fn(array $left, array $right): int => ((int)($right['path_count'] ?? 0) <=> (int)($left['path_count'] ?? 0))
?: strcmp((string)($left['summary'] ?? ''), (string)($right['summary'] ?? '')));
foreach ($outcomes as $index => &$outcome) {
$outcome['id'] = 'outcome-' . ($index + 1);
}
unset($outcome);
foreach ($paths as $index => &$path) {
$path['id'] = 'path-' . ($index + 1);
}
unset($path);
if ($truncated) {
$warnings[] = 'Path projection was truncated at ' . $maxStates . ' explored state(s). Narrow the lane or vehicle type filters to inspect more paths.';
}
return [
'scope' => [
return $this->pathOutcomesPayload(
[
'department_id' => $departmentId,
'department' => $this->labelFor('departments', $departmentId, $lookups),
'lane_id' => $laneId,
@@ -803,20 +885,24 @@ class selfserve_studio_graph
'hardware_mode' => $hardwareMode,
'max_states' => $maxStates,
],
'summary' => [
$outcomes,
$paths,
$warnings,
$truncated,
$maxStates,
$stateCount,
$terminalPathCount,
$questionIds,
[
'complete' => true,
'percent' => 100,
'state_count' => $stateCount,
'pending_state_count' => 0,
'terminal_path_count' => $terminalPathCount,
'outcome_count' => count($outcomes),
'question_count' => count($questionIds),
'question_ids' => array_values(array_map('intval', array_keys($questionIds))),
'max_states' => $maxStates,
'path_sample_count' => count($paths),
],
'outcomes' => array_values($outcomes),
'paths' => array_values($paths),
'warnings' => array_values(array_unique($warnings)),
'truncated' => $truncated,
];
'scenario_index' => $scenarioCount,
'scenario_count' => $scenarioCount,
]
);
}
/**
@@ -826,10 +912,11 @@ class selfserve_studio_graph
*/
public function projectPathOutcomesFromSimulator(callable $simulate, array $options = []): array
{
$maxStates = (int)($options['max_states'] ?? 2048);
$maxStates = max(1, min(2048, $maxStates));
$maxStates = $this->pathLimit($options['max_states'] ?? null);
$sampleLimit = max(1, min(10, (int)($options['sample_limit'] ?? 5)));
$pathSampleLimit = max(0, min(200, (int)($options['path_sample_limit'] ?? 100)));
$pathSampleLimit = $this->pathLimit($options['path_sample_limit'] ?? null);
$progressCallback = is_callable($options['progress_callback'] ?? null) ? $options['progress_callback'] : null;
$progressIntervalStates = max(1, (int)($options['progress_interval_states'] ?? 128));
$scope = is_array($options['scope'] ?? null) ? (array)$options['scope'] : [];
$stack = [[
'answers' => [],
@@ -844,7 +931,7 @@ class selfserve_studio_graph
$truncated = false;
while ($stack !== []) {
if ($stateCount >= $maxStates) {
if ($maxStates !== null && $stateCount >= $maxStates) {
$truncated = true;
break;
}
@@ -883,39 +970,55 @@ class selfserve_studio_graph
];
}
}
if ($progressCallback !== null && $stateCount % $progressIntervalStates === 0) {
$progressCallback($this->pathOutcomesProjectionPayload(
$scope,
$groups,
$paths,
$truncated,
$maxStates,
$stateCount,
$terminalPathCount,
$questionIds,
count($stack)
));
}
continue;
}
$terminalPathCount++;
$chain = is_array($state['chain'] ?? null) ? (array)$state['chain'] : [];
$this->addPathOutcomeGroup($groups, $simulation, $chain, $scope, $sampleLimit);
if ($pathSampleLimit > 0 && count($paths) < $pathSampleLimit) {
if ($pathSampleLimit === null || count($paths) < $pathSampleLimit) {
$paths[] = $this->pathResultFromSimulation($simulation, $chain, $scope);
}
if ($progressCallback !== null && $stateCount % $progressIntervalStates === 0) {
$progressCallback($this->pathOutcomesProjectionPayload(
$scope,
$groups,
$paths,
$truncated,
$maxStates,
$stateCount,
$terminalPathCount,
$questionIds,
count($stack)
));
}
}
$outcomes = $this->finalizePathOutcomeGroups($groups);
foreach ($paths as $index => &$path) {
$path['id'] = 'path-' . ($index + 1);
}
unset($path);
return [
'scope' => $scope,
'summary' => [
'state_count' => $stateCount,
'terminal_path_count' => $terminalPathCount,
'outcome_count' => count($outcomes),
'question_count' => count($questionIds),
'question_ids' => array_values(array_map('intval', array_keys($questionIds))),
'max_states' => $maxStates,
'path_sample_count' => count($paths),
],
'outcomes' => $outcomes,
'paths' => array_values($paths),
'warnings' => $truncated ? ['Path projection was truncated at ' . $maxStates . ' explored state(s).'] : [],
'truncated' => $truncated,
];
return $this->pathOutcomesProjectionPayload(
$scope,
$groups,
$paths,
$truncated,
$maxStates,
$stateCount,
$terminalPathCount,
$questionIds,
count($stack)
);
}
/**
@@ -1043,6 +1146,7 @@ class selfserve_studio_graph
'relay_machine_cleaner_id',
'machine_type_id',
'dynamic_image_id',
'selfserve_enabled',
], ['department' => $departmentId]);
$machineTypeRows = $this->fetchRows('selfserve_machine_types', ['id', 'name', 'description'], []);
$productRows = $this->fetchRows('products', ['id', 'name', 'description', 'price', 'subscription_allowed', 'category', 'piktogram', 'is_wash', 'order_priority'], []);
@@ -2064,6 +2168,15 @@ class selfserve_studio_graph
$availableColumns = $this->tableColumns('department_lanes');
$updates = [];
$params = [':id' => $id];
$wasSelfServeEnabled = null;
if (array_key_exists('selfserve_enabled', $data) && in_array('selfserve_enabled', $availableColumns, true)) {
$statement = db::getPDO()->prepare(
'SELECT selfserve_enabled FROM department_lanes WHERE id = :id AND deleted_at IS NULL'
);
$statement->execute([':id' => $id]);
$wasSelfServeEnabled = ((int)($statement->fetch(\PDO::FETCH_ASSOC)['selfserve_enabled'] ?? 1)) === 1;
}
$fields = ['name', ...$this->laneOptionalFields()];
foreach ($fields as $field) {
@@ -2083,6 +2196,14 @@ class selfserve_studio_graph
db::getPDO()->prepare(
'UPDATE department_lanes SET ' . implode(', ', $updates) . ' WHERE id = :id AND deleted_at IS NULL'
)->execute($params);
if (
$wasSelfServeEnabled === true
&& array_key_exists(':selfserve_enabled', $params)
&& (int)$params[':selfserve_enabled'] === 0
) {
\objects\department_lanes_o::disableSelfServeRelaysBestEffort($id);
}
}
private function softDeleteLane(int $departmentId, int $id): void
@@ -2106,11 +2227,16 @@ class selfserve_studio_graph
'relay_machine_cleaner_id',
'dynamic_image_id',
'machine_type_id',
'selfserve_enabled',
];
}
private function normalizeLaneField(string $field, mixed $value): mixed
{
if ($field === 'selfserve_enabled') {
return \objects\department_lanes_o::normalizeSelfServeEnabledValue($value) ? 1 : 0;
}
if (in_array($field, ['dynamic_image_id', 'machine_type_id'], true)) {
return $this->nullableInt($value);
}
@@ -3396,6 +3522,130 @@ class selfserve_studio_graph
return null;
}
private function pathLimit(mixed $value): ?int
{
if ($value === null || $value === '') {
return null;
}
$parsed = (int)$value;
return $parsed > 0 ? $parsed : null;
}
/**
* @param array<string,mixed> $scope
* @param array<string,array<string,mixed>> $groups
* @param array<int,array<string,mixed>> $paths
* @param array<int,bool> $questionIds
* @return array<string,mixed>
*/
private function pathOutcomesProjectionPayload(
array $scope,
array $groups,
array $paths,
bool $truncated,
?int $maxStates,
int $stateCount,
int $terminalPathCount,
array $questionIds,
int $pendingStateCount
): array {
$warnings = [];
if ($truncated && $maxStates !== null) {
$warnings[] = 'Path projection was truncated at ' . $maxStates . ' explored state(s).';
}
$knownStateCount = max(1, $stateCount + $pendingStateCount);
$complete = !$truncated && $pendingStateCount === 0;
$percent = $complete ? 100 : min(99, max(1, (int)floor(($stateCount / $knownStateCount) * 100)));
return $this->pathOutcomesPayload(
$scope,
$this->finalizePathOutcomeGroups($groups),
$paths,
$warnings,
$truncated,
$maxStates,
$stateCount,
$terminalPathCount,
$questionIds,
[
'complete' => $complete,
'percent' => $percent,
'state_count' => $stateCount,
'pending_state_count' => $pendingStateCount,
'terminal_path_count' => $terminalPathCount,
]
);
}
/**
* @param array<string,mixed> $scope
* @param array<int,array<string,mixed>> $outcomes
* @param array<int,array<string,mixed>> $paths
* @param array<int,string> $warnings
* @param array<int,bool>|array<int,int> $questionIds
* @param array<string,mixed> $progress
* @return array<string,mixed>
*/
private function pathOutcomesPayload(
array $scope,
array $outcomes,
array $paths,
array $warnings,
bool $truncated,
?int $maxStates,
int $stateCount,
int $terminalPathCount,
array $questionIds,
array $progress = []
): array {
usort($outcomes, static fn(array $left, array $right): int => ((int)($right['path_count'] ?? 0) <=> (int)($left['path_count'] ?? 0))
?: strcmp((string)($left['summary'] ?? ''), (string)($right['summary'] ?? '')));
foreach ($outcomes as $index => &$outcome) {
$outcome['id'] = 'outcome-' . ($index + 1);
}
unset($outcome);
foreach ($paths as $index => &$path) {
$path['id'] = 'path-' . ($index + 1);
}
unset($path);
$questionIdValues = [];
foreach ($questionIds as $key => $value) {
$questionIdValues[] = $value === true ? (int)$key : (int)$value;
}
$questionIdValues = array_values(array_unique(array_filter($questionIdValues, static fn(int $id): bool => $id > 0)));
sort($questionIdValues);
$progress = array_merge([
'complete' => !$truncated,
'percent' => $truncated ? 99 : 100,
'state_count' => $stateCount,
'pending_state_count' => 0,
'terminal_path_count' => $terminalPathCount,
], $progress);
return [
'scope' => $scope,
'summary' => [
'state_count' => $stateCount,
'terminal_path_count' => $terminalPathCount,
'outcome_count' => count($outcomes),
'question_count' => count($questionIdValues),
'question_ids' => $questionIdValues,
'max_states' => $maxStates,
'path_sample_count' => count($paths),
],
'outcomes' => array_values($outcomes),
'paths' => array_values($paths),
'warnings' => array_values(array_unique($warnings)),
'truncated' => $truncated,
'progress' => $progress,
];
}
/**
* @param array<string,array<string,mixed>> $groups
* @param array<string,mixed> $simulation
@@ -103,6 +103,12 @@ class selfserve_wash_flow implements selfserve_wash_flow_i
$snapshot = $this->buildEligibilitySnapshot($laneId, $reg, $customerNumber, $vehicleTypeIdOverride, $options);
$session = $this->findLatestOpenSession($laneId, $snapshot['reg'], $snapshot['customer_number']);
if (($snapshot['evaluation_trace']['disabled_lane'] ?? false) === true) {
return $session->exists()
? $this->getSessionSummary((int)$session->id)
: $this->formatBlockedSessionSummary($snapshot);
}
if (!$session->exists()) {
$session = (new selfserve_wash_sessions_o())->add(
$laneId,
@@ -155,6 +161,9 @@ class selfserve_wash_flow implements selfserve_wash_flow_i
throw new \RuntimeException('No active self-serve wash session found for the lane.');
}
$summary = $this->synchronizeSession($laneId, $normalizedReg, null, false, null, false);
if (empty($summary['session']['id'])) {
throw new \RuntimeException((string)($summary['blocked_reason'] ?? 'Self-serve is disabled for this lane.'));
}
$session = (new selfserve_wash_sessions_o())->select((int)$summary['session']['id']);
}
@@ -324,6 +333,24 @@ class selfserve_wash_flow implements selfserve_wash_flow_i
$answerRows = (new selfserve_wash_session_answers_o())->listBySession($sessionId);
$answers = $this->buildSessionQuestions($session, $answerRows);
$metadata = is_array($session->metadata_json->value()) ? $session->metadata_json->value() : [];
$allowedServices = $this->normalizeServiceNames(
is_array($metadata['allowed_services'] ?? null) ? (array)$metadata['allowed_services'] : []
);
$machineAvailable = array_key_exists('machine_available', $metadata)
? (bool)$metadata['machine_available']
: ($lane->exists() && !empty($lane->relay_machine_id->value()));
$allVisibleQuestionsAnswered = array_key_exists('all_visible_questions_answered', $metadata)
? (bool)$metadata['all_visible_questions_answered']
: true;
if (!array_key_exists('all_visible_questions_answered', $metadata)) {
foreach ($answers as $answer) {
if (($answer['answer'] ?? null) === null) {
$allVisibleQuestionsAnswered = false;
break;
}
}
}
$tasks = array_map(function (array $row): array {
return [
@@ -335,6 +362,9 @@ class selfserve_wash_flow implements selfserve_wash_flow_i
'dynamic_images_vehicle_type' => $row['dynamic_images_vehicle_type'] === null ? null : (int)$row['dynamic_images_vehicle_type']
];
}, (new selfserve_wash_session_tasks_o())->listBySession($sessionId));
if (array_key_exists('allowed_services', $metadata) || (bool)$session->allowed->value() === false) {
$tasks = $this->filterTasksForAllowedServices($tasks, $allowedServices);
}
$tasks = (new selfserve_task_attachment_payloads())->attachToTasks($tasks);
$events = array_map(function (array $row): array {
@@ -353,8 +383,12 @@ 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,
'allowed_services' => $allowedServices,
'machine_available' => $machineAvailable,
'all_visible_questions_answered' => $allVisibleQuestionsAnswered,
'allowed' => (bool)$session->allowed->value(),
'config_version_id' => $metadata['config_version_id'] ?? null,
'evaluation_trace' => $metadata['evaluation_trace'] ?? null,
];
}
@@ -453,6 +487,56 @@ 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();
if (!$lane->isSelfServeEnabled()) {
$vehicle = $this->findVehicleByRegistration($normalizedReg);
$vehicleData = $vehicle?->asArray();
$vehicleTypeId = $this->resolveVehicleTypeId($vehicle, $vehicleTypeIdOverride);
$resolvedCustomerNumber = $customerNumber ?? ($vehicle !== null ? (int)$vehicle->customer_id->value() : null);
return [
'lane' => $lane->asArray(),
'machine_type' => null,
'vehicle' => $vehicleData,
'reg' => $normalizedReg,
'customer_number' => $resolvedCustomerNumber,
'vehicle_type_id' => $vehicleTypeId,
'answers' => [],
'persisted_answers' => [],
'persisted_answer_customer_number' => null,
'answer_overrides' => [],
'answer_sources' => [],
'questions' => [],
'conditions' => [],
'tasks' => [],
'allowed_services' => [],
'machine_available' => false,
'all_visible_questions_answered' => false,
'allowed' => false,
'blocked_reason' => 'Self-serve is disabled for this lane.',
'config_version_id' => null,
'config_source' => (string)($options['config_source'] ?? 'published'),
'evaluation_trace' => [
'blocked' => true,
'blocking_reasons' => ['LANE_SELFSERVE_DISABLED'],
'disabled_lane' => true,
'message' => 'Self-serve is disabled for this lane.',
'visibility_condition_results' => [],
'condition_results' => [],
'visibility_expression_traces' => [],
'condition_expression_traces' => [],
'task_gates' => [],
'visible_question_ids' => [],
],
'debug_candidates' => [
'questions' => [],
'conditions' => [],
'rules' => [],
'tasks' => [],
'actions' => [],
'visible_answers' => [],
],
];
}
$configSource = (string)($options['config_source'] ?? 'published');
$publishedConfigVersionId = $options['config_version_id'] ?? null;
$publishedConfigPayload = is_array($options['config_payload'] ?? null) ? (array)$options['config_payload'] : null;
@@ -585,6 +669,7 @@ class selfserve_wash_flow implements selfserve_wash_flow_i
$machineAllowed = $allVisibleQuestionsAnswered
&& $machineAvailable
&& in_array(selfserve_lane_services::MACHINE->name, $allowedServices, true);
$visibleTasks = $this->filterTasksForAllowedServices($activeTasks, $allowedServices);
$machineType = null;
if ($machineTypeId !== null) {
@@ -608,7 +693,7 @@ class selfserve_wash_flow implements selfserve_wash_flow_i
'answer_sources' => $answerSources,
'questions' => $visibleQuestions,
'conditions' => $serviceConditionResults,
'tasks' => $activeTasks,
'tasks' => $visibleTasks,
'allowed_services' => $allowedServices,
'machine_available' => $machineAvailable,
'all_visible_questions_answered' => $allVisibleQuestionsAnswered,
@@ -713,6 +798,7 @@ class selfserve_wash_flow implements selfserve_wash_flow_i
'machine_available' => $snapshot['machine_available'],
'all_visible_questions_answered' => $snapshot['all_visible_questions_answered'],
'allowed' => $snapshot['allowed'],
'blocked_reason' => $snapshot['blocked_reason'] ?? null,
'session' => $session,
'config_version_id' => $snapshot['config_version_id'] ?? null,
'config_source' => $snapshot['config_source'] ?? null,
@@ -720,6 +806,24 @@ class selfserve_wash_flow implements selfserve_wash_flow_i
];
}
protected function formatBlockedSessionSummary(array $snapshot): array
{
return [
'session' => null,
'lane' => $snapshot['lane'],
'machine_type' => $snapshot['machine_type'],
'questions' => [],
'tasks' => [],
'events' => [],
'allowed' => false,
'allowed_services' => [],
'machine_available' => false,
'blocked_reason' => $snapshot['blocked_reason'] ?? 'Self-serve is disabled for this lane.',
'config_version_id' => $snapshot['config_version_id'] ?? null,
'evaluation_trace' => $snapshot['evaluation_trace'] ?? null,
];
}
/**
* @param array<string,mixed> $snapshot
* @param array<string,mixed> $options
@@ -2789,8 +2893,12 @@ class selfserve_wash_flow implements selfserve_wash_flow_i
'deleted_at' => null,
...($customerNumber !== null ? ['customer_number' => $customerNumber] : []),
],
['id']
['id', 'status']
);
$rows = array_values(array_filter(
$rows,
static fn(array $row): bool => !selfserve_wash_sessions_o::isTerminalStatus($row['status'] ?? null)
));
if ($rows === []) {
return new selfserve_wash_sessions_o();
}
@@ -2900,6 +3008,37 @@ class selfserve_wash_flow implements selfserve_wash_flow_i
}
}
/**
* @param array<int,array<string,mixed>> $tasks
* @param array<int,string> $allowedServices
* @return array<int,array<string,mixed>>
*/
protected function filterTasksForAllowedServices(array $tasks, array $allowedServices): array
{
if (in_array(selfserve_lane_services::MACHINE->name, $allowedServices, true)) {
return array_values($tasks);
}
return array_values(array_filter(
$tasks,
fn(array $task): bool => !$this->taskUsesMachineControls($task)
));
}
protected function taskUsesMachineControls(array $task): bool
{
if (in_array(selfserve_lane_services::MACHINE->name, $this->normalizeServiceNames($this->normalizeJsonArray($task['services'] ?? null)), true)) {
return true;
}
if ($this->normalizeButtonList($task['buttons'] ?? null) !== []) {
return true;
}
$dynamicImagesVehicleType = $task['dynamic_images_vehicle_type'] ?? null;
return $dynamicImagesVehicleType !== null && $dynamicImagesVehicleType !== '';
}
protected function normalizeServiceNames(array $services): array
{
$normalized = [];
@@ -33,7 +33,7 @@ use objects\department_variables_o;
trait selfserve_lane_command_t
{
/**
* Determine if the lane's department has self-serve enabled.
* Determine if the lane and its department have self-serve enabled.
* This method is intentionally protected to allow tests to override
* and avoid I/O when needed.
*/
@@ -46,7 +46,8 @@ trait selfserve_lane_command_t
$departmentId = (int)$this->department_lane->department->value();
if ($departmentId <= 0) return false;
$vars = (new department_variables_o())->selectDepartment($departmentId);
return $vars->getVariable('selfserve_enabled') === true;
return $vars->getVariable('selfserve_enabled') === true
&& $this->department_lane->isSelfServeEnabled();
} catch (\Throwable $e) {
// If anything goes wrong, default to not enabled
return false;
@@ -394,7 +395,7 @@ trait selfserve_lane_command_t
$gateLabel = $isAccessGate ? 'entrance' : 'exit';
if (!$this->isDepartmentSelfServeEnabled()) {
throw new \RuntimeException('Cannot open property ' . $commandLabel . ' gate: Self-serve is not enabled for this lane\'s department.');
throw new \RuntimeException('Cannot open property ' . $commandLabel . ' gate: Self-serve is not enabled for this lane.');
}
if (empty($this->department_lane) || empty($this->department_lane->department)) {
throw new \RuntimeException('Cannot open property ' . $commandLabel . ' gate: Lane department is not configured.');