From 8bf957b27339cd7e39ab5d686cbfa04260a3f1eb Mon Sep 17 00:00:00 2001 From: Jeppe Bundgaard Date: Wed, 29 Apr 2026 17:28:42 +0200 Subject: [PATCH] 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. --- openapi.yaml | 10 + .../classes/selfserve_schema_bootstrap.php | 5 + ...e_gateway_department_workspace_service.php | 11 +- .../classes/selfserve_studio_graph.php | 376 +++++++++++++++--- .../selfserve/classes/selfserve_wash_flow.php | 147 ++++++- .../traits/selfserve_lane_command_t.php | 7 +- .../nginx/app/objects/department_lanes_o.php | 82 +++- .../app/objects/selfserve_wash_sessions_o.php | 44 +- services/nginx/app/openapi.yaml | 78 +++- .../nginx/app/routes/departmentLanesRoute.php | 24 +- .../routes/departmentSelfserveStudioRoute.php | 40 ++ services/nginx/app/routes/guestRoute.php | 5 +- .../nginx/app/routes/moduleSelfServeRoute.php | 4 +- .../Selfserve/SelfserveOpenApiSpecTest.php | 6 + .../Selfserve/SelfserveRouteWiringTest.php | 88 ++++ ...fserveSchemaBootstrapCompatibilityTest.php | 9 + .../Selfserve/SelfserveStudioGraphTest.php | 42 +- .../SelfserveWashSessionStateTest.php | 47 +++ 18 files changed, 924 insertions(+), 101 deletions(-) create mode 100644 services/nginx/app/tests/Unit/Selfserve/SelfserveWashSessionStateTest.php diff --git a/openapi.yaml b/openapi.yaml index 25880e20..fb139162 100644 --- a/openapi.yaml +++ b/openapi.yaml @@ -15206,6 +15206,16 @@ components: type: array items: $ref: '#/components/schemas/SelfserveWashTaskSnapshot' + allowed_services: + type: array + items: + $ref: '#/components/schemas/SelfserveLaneService' + machine_available: + type: boolean + all_visible_questions_answered: + type: boolean + allowed: + type: boolean events: type: array items: diff --git a/services/nginx/app/classes/selfserve_schema_bootstrap.php b/services/nginx/app/classes/selfserve_schema_bootstrap.php index bae32d22..8f632ec4 100644 --- a/services/nginx/app/classes/selfserve_schema_bootstrap.php +++ b/services/nginx/app/classes/selfserve_schema_bootstrap.php @@ -150,6 +150,11 @@ class selfserve_schema_bootstrap 'machine_type_id', 'ALTER TABLE department_lanes ADD COLUMN machine_type_id INT NULL AFTER dynamic_image_id' ); + self::ensureColumn( + 'department_lanes', + 'selfserve_enabled', + 'ALTER TABLE department_lanes ADD COLUMN selfserve_enabled TINYINT(1) NOT NULL DEFAULT 1 AFTER machine_type_id' + ); self::ensureColumn( 'department_selfserve_conditions', 'machine_type_id', diff --git a/services/nginx/app/modules/edgegateway/classes/edge_gateway_department_workspace_service.php b/services/nginx/app/modules/edgegateway/classes/edge_gateway_department_workspace_service.php index 3e3c3266..fe05f23b 100644 --- a/services/nginx/app/modules/edgegateway/classes/edge_gateway_department_workspace_service.php +++ b/services/nginx/app/modules/edgegateway/classes/edge_gateway_department_workspace_service.php @@ -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', diff --git a/services/nginx/app/modules/selfserve/classes/selfserve_studio_graph.php b/services/nginx/app/modules/selfserve/classes/selfserve_studio_graph.php index 2ef48a34..935605fa 100644 --- a/services/nginx/app/modules/selfserve/classes/selfserve_studio_graph.php +++ b/services/nginx/app/modules/selfserve/classes/selfserve_studio_graph.php @@ -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 $permissions * @return array */ - 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 $scope + * @param array> $groups + * @param array> $paths + * @param array $questionIds + * @return array + */ + 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 $scope + * @param array> $outcomes + * @param array> $paths + * @param array $warnings + * @param array|array $questionIds + * @param array $progress + * @return array + */ + 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> $groups * @param array $simulation diff --git a/services/nginx/app/modules/selfserve/classes/selfserve_wash_flow.php b/services/nginx/app/modules/selfserve/classes/selfserve_wash_flow.php index 99d722ce..e4e1cb81 100644 --- a/services/nginx/app/modules/selfserve/classes/selfserve_wash_flow.php +++ b/services/nginx/app/modules/selfserve/classes/selfserve_wash_flow.php @@ -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 $snapshot * @param array $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> $tasks + * @param array $allowedServices + * @return array> + */ + 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 = []; diff --git a/services/nginx/app/modules/selfserve/traits/selfserve_lane_command_t.php b/services/nginx/app/modules/selfserve/traits/selfserve_lane_command_t.php index daacc3c8..f7134399 100644 --- a/services/nginx/app/modules/selfserve/traits/selfserve_lane_command_t.php +++ b/services/nginx/app/modules/selfserve/traits/selfserve_lane_command_t.php @@ -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.'); diff --git a/services/nginx/app/objects/department_lanes_o.php b/services/nginx/app/objects/department_lanes_o.php index 5b3db847..db55e3a0 100644 --- a/services/nginx/app/objects/department_lanes_o.php +++ b/services/nginx/app/objects/department_lanes_o.php @@ -22,6 +22,7 @@ class department_lanes_o extends db public object_property $relay_machine_cleaner_id; // The Shelly relay for the machine cleaner (if applicable) public object_property $dynamic_image_id; // The dynamic image id for the lane (if applicable) public object_property $machine_type_id; // The reusable self-serve machine type for the lane (if applicable) + public object_property $selfserve_enabled; // Whether this lane can be used for self-serve when department self-serve is enabled public object_property $created_at; public object_property $updated_at; public object_property $deleted_at; @@ -52,7 +53,7 @@ class department_lanes_o extends db * @return department_lanes_o * @throws Exception If the object was not created successfully */ - public function add(int $department, string $name, string $relay_in_id = null, string $relay_out_id = null, string $relay_machine_id = null, string $relay_machine_program_picker_id = null, string $relay_machine_cleaner_id = null, int $dynamic_image_id = null, ?int $machine_type_id = null): department_lanes_o + public function add(int $department, string $name, string $relay_in_id = null, string $relay_out_id = null, string $relay_machine_id = null, string $relay_machine_program_picker_id = null, string $relay_machine_cleaner_id = null, int $dynamic_image_id = null, ?int $machine_type_id = null, bool $selfserve_enabled = true): department_lanes_o { global /** @var db $db */ $db; @@ -97,6 +98,7 @@ class department_lanes_o extends db ...(!is_null($relay_machine_cleaner_id) ? ['relay_machine_cleaner_id' => $relay_machine_cleaner_id] : []), ...(!is_null($dynamic_image_id) ? ['dynamic_image_id' => $dynamic_image_id] : []), // If the dynamic_image_id is null, it will be set to null in the database ...(!is_null($machine_type_id) ? ['machine_type_id' => $machine_type_id] : []), + 'selfserve_enabled' => $selfserve_enabled ? 1 : 0, ]); $this->id = $tmp_id; self::getObjectProperties(); @@ -115,6 +117,7 @@ class department_lanes_o extends db $this->relay_machine_cleaner_id = new object_property($this->table, $this->id, 'relay_machine_cleaner_id', 'string', false); $this->dynamic_image_id = new object_property($this->table, $this->id, 'dynamic_image_id', 'int', false); $this->machine_type_id = new object_property($this->table, $this->id, 'machine_type_id', 'int', false); + $this->selfserve_enabled = new object_property($this->table, $this->id, 'selfserve_enabled', 'bool', false, true); $this->created_at = new object_property($this->table, $this->id, 'created_at', 'timestamp', false); $this->updated_at = new object_property($this->table, $this->id, 'updated_at', 'timestamp', false); $this->deleted_at = new object_property($this->table, $this->id, 'deleted_at', 'timestamp', false); @@ -138,6 +141,7 @@ class department_lanes_o extends db 'relay_machine_cleaner_id' => (string)$this->relay_machine_cleaner_id->value(), 'dynamic_image_id' => (function($v){ return $v === null ? null : (int)$v; })($this->dynamic_image_id->value()), 'machine_type_id' => (function($v){ return $v === null ? null : (int)$v; })($this->machine_type_id->value()), + 'selfserve_enabled' => $this->isSelfServeEnabled(), // Status of the lane 'status' => (string)$this->getLaneStatus()->name, // Timestamps @@ -146,6 +150,82 @@ class department_lanes_o extends db ]; } + public function isSelfServeEnabled(): bool + { + self::requireSelected(); + try { + $value = $this->selfserve_enabled->value(); + } catch (\Throwable) { + return true; + } + + if ($value === null || $value === '') { + return true; + } + if (is_bool($value)) { + return $value; + } + if (is_numeric($value)) { + return (int)$value === 1; + } + + return in_array(strtolower(trim((string)$value)), ['1', 'true', 'yes', 'on'], true); + } + + public static function normalizeSelfServeEnabledValue(mixed $value): bool + { + if (is_bool($value)) { + return $value; + } + if (is_numeric($value)) { + return (int)$value === 1; + } + + return in_array(strtolower(trim((string)$value)), ['1', 'true', 'yes', 'on'], true); + } + + public static function disableSelfServeRelaysBestEffort(int $lane_id): void + { + if ($lane_id <= 0) { + return; + } + + try { + $lane = (new selfserve())->lane($lane_id); + } catch (\Throwable) { + return; + } + + self::setLaneRelayOffIfConfigured($lane, 'relay_machine_program_picker_id', static function () use ($lane): void { + $lane->setMachineProgramPickerRelayStatusHard(false); + }); + self::setLaneRelayOffIfConfigured($lane, 'relay_machine_cleaner_id', static function () use ($lane): void { + $lane->setMachineCleanerRelayStatusHard(false); + }); + self::setLaneRelayOffIfConfigured($lane, 'relay_machine_id', static function () use ($lane): void { + $lane->setMachineRelayStatusHard(false); + }); + } + + private static function setLaneRelayOffIfConfigured(object $lane, string $relay_property, callable $callback): void + { + if ( + empty($lane->department_lane) + || !isset($lane->department_lane->{$relay_property}) + || !is_object($lane->department_lane->{$relay_property}) + || !method_exists($lane->department_lane->{$relay_property}, 'value') + || trim((string)$lane->department_lane->{$relay_property}->value()) === '' + ) { + return; + } + + try { + $callback(); + } catch (\Throwable) { + // Best effort only; toggling lane self-serve should not fail on relay I/O. + } + } + /** * Get the self-serve lane products available for this lane * @return array An array of product ids available for this lane diff --git a/services/nginx/app/objects/selfserve_wash_sessions_o.php b/services/nginx/app/objects/selfserve_wash_sessions_o.php index c23047c9..88a7ee6a 100644 --- a/services/nginx/app/objects/selfserve_wash_sessions_o.php +++ b/services/nginx/app/objects/selfserve_wash_sessions_o.php @@ -14,6 +14,11 @@ class selfserve_wash_sessions_o extends db { use db_object_t; + public const TERMINAL_STATUSES = [ + 'COMPLETED', + 'FORCE_STOPPED', + ]; + public object_property $lane_id; public object_property $department_id; public object_property $machine_type_id; @@ -105,6 +110,25 @@ class selfserve_wash_sessions_o extends db $this->status->set($status->value); } + public static function isTerminalStatus(?string $status): bool + { + return in_array(strtoupper(trim((string)$status)), self::TERMINAL_STATUSES, true); + } + + public static function terminalStatusSqlList(): string + { + return "'" . implode("','", array_map( + static fn(string $status): string => str_replace("'", "''", $status), + self::TERMINAL_STATUSES + )) . "'"; + } + + public function isOpen(): bool + { + return $this->completed_at->value() === null + && !self::isTerminalStatus((string)$this->status->value()); + } + public function markRelayEnabled(): void { $now = date('Y-m-d H:i:s'); @@ -167,7 +191,11 @@ class selfserve_wash_sessions_o extends db if ($customerNumber !== null) { $filters['customer_number'] = $customerNumber; } - $rows = $this->getFieldsWhere($filters, ['id']); + $rows = $this->getFieldsWhere($filters, ['id', 'status']); + $rows = array_values(array_filter( + $rows, + static fn(array $row): bool => !self::isTerminalStatus($row['status'] ?? null) + )); if ($rows === []) { return $this; } @@ -187,7 +215,11 @@ class selfserve_wash_sessions_o extends db if ($customerNumber !== null) { $filters['customer_number'] = $customerNumber; } - $rows = $this->getFieldsWhere($filters, ['id']); + $rows = $this->getFieldsWhere($filters, ['id', 'status']); + $rows = array_values(array_filter( + $rows, + static fn(array $row): bool => !self::isTerminalStatus($row['status'] ?? null) + )); if ($rows === []) { return $this; } @@ -232,6 +264,7 @@ class selfserve_wash_sessions_o extends db 'order_id' => $this->order_id->value() === null ? null : (int)$this->order_id->value(), 'completed_at' => $this->completed_at->value() === null ? null : (string)$this->completed_at->value(), 'metadata' => (array)($this->metadata_json->value() ?? []), + 'open' => $this->isOpen(), 'created_at' => (string)$this->created_at->value(), 'updated_at' => $this->updated_at->value() === null ? null : (string)$this->updated_at->value(), ]; @@ -246,16 +279,17 @@ class selfserve_wash_sessions_o extends db try { $start = new DateTime((string)$startAt); - $now = new DateTime(); + $endAt = $this->completed_at->value() ?? date('Y-m-d H:i:s'); + $end = new DateTime((string)$endAt); } catch (\Throwable) { return 0; } - if ($start > $now) { + if ($start > $end) { return 0; } - $diff = $start->diff($now); + $diff = $start->diff($end); return (int)(($diff->days * 24 * 60) + ($diff->h * 60) + $diff->i); } } diff --git a/services/nginx/app/openapi.yaml b/services/nginx/app/openapi.yaml index ffcd98e5..9ac51a0a 100644 --- a/services/nginx/app/openapi.yaml +++ b/services/nginx/app/openapi.yaml @@ -5389,6 +5389,29 @@ paths: '422': $ref: '#/components/responses/BadRequest' + /department/selfserve/studio/path-outcomes/stream: + post: + tags: + - Self-Serve + summary: Stream self-serve studio question path outcome progress + description: Streams newline-delimited JSON progress events while enumerating the complete feasible yes/no answer path space. Progress events contain the same response shape as the final result with partial outcomes and paths. + operationId: streamSelfserveStudioPathOutcomes + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/SelfserveStudioPathOutcomesRequest' + responses: + '200': + description: Newline-delimited path outcome progress events + content: + application/x-ndjson: + schema: + type: string + '422': + $ref: '#/components/responses/BadRequest' + /department/selfserve/studio/publish: post: tags: @@ -15638,12 +15661,17 @@ components: max_states: type: integer minimum: 1 - maximum: 2048 - default: 2048 + nullable: true + description: Optional debug cap. Omit for complete path projection. + path_sample_limit: + type: integer + minimum: 1 + nullable: true + description: Optional debug cap for returned path rows. Omit to return every terminal path row. SelfserveStudioPathOutcomesResponse: type: object - required: [scope, summary, outcomes, paths, warnings, truncated] + required: [scope, summary, outcomes, paths, warnings, truncated, progress] properties: scope: type: object @@ -15674,6 +15702,27 @@ components: items: { type: string } truncated: type: boolean + progress: + $ref: '#/components/schemas/SelfserveStudioPathProgress' + + SelfserveStudioPathProgress: + type: object + required: [complete, percent, state_count, pending_state_count, terminal_path_count] + properties: + complete: { type: boolean } + percent: + type: integer + minimum: 0 + maximum: 100 + state_count: { type: integer } + pending_state_count: { type: integer } + terminal_path_count: { type: integer } + scenario_index: + type: integer + nullable: true + scenario_count: + type: integer + nullable: true SelfserveStudioPathOutcome: type: object @@ -15841,6 +15890,9 @@ components: type: boolean allowed: type: boolean + blocked_reason: + type: string + nullable: true session: allOf: - $ref: '#/components/schemas/SelfserveWashSession' @@ -15874,6 +15926,16 @@ components: type: array items: $ref: '#/components/schemas/SelfserveWashTaskSnapshot' + allowed_services: + type: array + items: + $ref: '#/components/schemas/SelfserveLaneService' + machine_available: + type: boolean + all_visible_questions_answered: + type: boolean + allowed: + type: boolean events: type: array items: @@ -16266,6 +16328,8 @@ components: type: integer machine_available: type: boolean + selfserve_enabled: + type: boolean DepartmentCreate: type: object @@ -16334,6 +16398,9 @@ components: machine_type_id: type: integer nullable: true + selfserve_enabled: + type: boolean + default: true status: type: string created_at: @@ -16370,6 +16437,9 @@ components: machine_type_id: type: integer nullable: true + selfserve_enabled: + type: boolean + default: true DepartmentLaneUpdate: type: object @@ -16399,6 +16469,8 @@ components: machine_type_id: type: integer nullable: true + selfserve_enabled: + type: boolean DepartmentGate: type: object diff --git a/services/nginx/app/routes/departmentLanesRoute.php b/services/nginx/app/routes/departmentLanesRoute.php index 29906ed1..841a2640 100644 --- a/services/nginx/app/routes/departmentLanesRoute.php +++ b/services/nginx/app/routes/departmentLanesRoute.php @@ -60,6 +60,7 @@ class departmentLanesRoute 'relay_machine_cleaner_id', 'dynamic_image_id', 'machine_type_id', + 'selfserve_enabled', ]) ->listObjectsWithPaginationIfSet( function ($department_lane) use ($user) { @@ -317,6 +318,9 @@ class departmentLanesRoute $dynamic_image_id = $response->getRequestParameter('dynamic_image_id') ?? null; $machine_type_id = $response->getRequestParameter('machine_type_id') ?? null; + $selfserve_enabled = self::isParametersSet(['selfserve_enabled']) + ? department_lanes_o::normalizeSelfServeEnabledValue($response->getRequestParameter('selfserve_enabled')) + : true; if ($dynamic_image_id !== null) { $did = (int)$dynamic_image_id; $this->requireType($did, $this->type_int()); @@ -333,9 +337,12 @@ class departmentLanesRoute // Check if the required fields are set if ($name && $department) { // Add the department lane - (new department_lanes_o())->add((int)$department, (string)$name, $relay_in_id, $relay_out_id, $relay_machine_id, $relay_machine_program_picker_id, $relay_machine_cleaner_id, $dynamic_image_id, $machine_type_id); + $created_lane = (new department_lanes_o())->add((int)$department, (string)$name, $relay_in_id, $relay_out_id, $relay_machine_id, $relay_machine_program_picker_id, $relay_machine_cleaner_id, $dynamic_image_id, $machine_type_id, $selfserve_enabled); // Return a success message - $response->success('Department lane added'); + $response->success([ + 'message' => 'Department lane added', + 'lane' => $created_lane->asArray(), + ]); } else { // Return an error $response->error('Missing required fields', 400); @@ -385,6 +392,7 @@ class departmentLanesRoute // Return an error $response->error('Department lane not found', 404); } + $was_selfserve_enabled = $department_lane->isSelfServeEnabled(); // Update the department lane fields that are set if (self::isParametersSet(['name'])) { $department_lane->name->set($name); @@ -429,8 +437,18 @@ class departmentLanesRoute $department_lane->machine_type_id->set($machineTypeId); } } + if (self::isParametersSet(['selfserve_enabled'])) { + $next_selfserve_enabled = department_lanes_o::normalizeSelfServeEnabledValue($response->getRequestParameter('selfserve_enabled')); + $department_lane->selfserve_enabled->set($next_selfserve_enabled); + if ($was_selfserve_enabled && !$next_selfserve_enabled) { + department_lanes_o::disableSelfServeRelaysBestEffort((int)$department_lane->id); + } + } // Return a success message - $response->success('Department lane updated'); + $response->success([ + 'message' => 'Department lane updated', + 'lane' => $department_lane->asArray(), + ]); } else { // Log the incident (new logs_o())->add('department_lanes', 'global', 1, 0, 'EDIT_DEPARTMENT_LANE', 'User tried to edit a department lane without being logged in'); diff --git a/services/nginx/app/routes/departmentSelfserveStudioRoute.php b/services/nginx/app/routes/departmentSelfserveStudioRoute.php index 5bdf5674..49a23599 100644 --- a/services/nginx/app/routes/departmentSelfserveStudioRoute.php +++ b/services/nginx/app/routes/departmentSelfserveStudioRoute.php @@ -132,6 +132,46 @@ class departmentSelfserveStudioRoute 'list_department_selfserve_vehicle_conditions' => 'Run the self-serve studio simulator', ]); + $this->post('/department/selfserve/studio/path-outcomes/stream', function (): void { + $user = $this->requireStudioUser('list_department_selfserve_vehicle_conditions'); + self::requireParameters(['department']); + $departmentId = (int)self::getParameter('department'); + $this->assertDepartmentAccess($user, $departmentId); + + ini_set('display_errors', '0'); + ini_set('html_errors', '0'); + header('Content-Type: application/x-ndjson; charset=utf-8'); + header('Cache-Control: no-cache, no-transform'); + header('X-Accel-Buffering: no'); + + $emit = static function (array $event): void { + echo json_encode($event, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) . "\n"; + if (function_exists('ob_flush')) { + @ob_flush(); + } + @flush(); + }; + + try { + $result = (new selfserve_studio_graph())->projectPathOutcomes( + $departmentId, + self::getParametersAsArray(), + (int)$user->id, + $this->studioPermissions(), + static function (array $partial) use ($emit): void { + $emit(['type' => 'progress', 'data' => $partial]); + } + ); + (new logs_o())->add('selfserve_studio', $departmentId, 1, $user->id, 'PROJECT_STUDIO_PATH_OUTCOMES', 'Projected self-serve studio path outcomes'); + $emit(['type' => 'complete', 'data' => $result]); + } catch (\Throwable $exception) { + $emit(['type' => 'error', 'message' => $exception->getMessage()]); + } + exit; + }, [ + 'list_department_selfserve_vehicle_conditions' => 'Stream grouped self-serve studio question path outcome progress', + ]); + $this->post('/department/selfserve/studio/path-outcomes', function (): void { global $response; $user = $this->requireStudioUser('list_department_selfserve_vehicle_conditions'); diff --git a/services/nginx/app/routes/guestRoute.php b/services/nginx/app/routes/guestRoute.php index 4259bdd7..13fd7de0 100644 --- a/services/nginx/app/routes/guestRoute.php +++ b/services/nginx/app/routes/guestRoute.php @@ -76,7 +76,8 @@ class guestRoute 'name' => (string)$lane->name->value(), 'status' => (string)$lane->getLaneStatus()->name, 'products' => $lane->getSelfServeLaneProducts(), - 'machine_available' => !empty($lane->relay_machine_id->value()), + 'selfserve_enabled' => $lane->isSelfServeEnabled(), + 'machine_available' => $lane->isSelfServeEnabled() && !empty($lane->relay_machine_id->value()), 'dynamic_image_id' => $lane->dynamic_image_id->value() ? (int)$lane->dynamic_image_id->value() : null, ]; }, $department->getLanes()); @@ -96,4 +97,4 @@ class guestRoute ])), 200); }); } -} \ No newline at end of file +} diff --git a/services/nginx/app/routes/moduleSelfServeRoute.php b/services/nginx/app/routes/moduleSelfServeRoute.php index 1900c3e0..2f148457 100644 --- a/services/nginx/app/routes/moduleSelfServeRoute.php +++ b/services/nginx/app/routes/moduleSelfServeRoute.php @@ -300,7 +300,7 @@ class moduleSelfServeRoute $additional_where = null; if ($this->requestedBoolean('open_only', false) || $this->requestedBoolean('active_only', false)) { - $additional_where = '`completed_at` IS NULL'; + $additional_where = '`completed_at` IS NULL AND `status` NOT IN (' . selfserve_wash_sessions_o::terminalStatusSqlList() . ')'; } $response->success($sessions->listObjectsWithPaginationIfSet( @@ -313,7 +313,7 @@ class moduleSelfServeRoute return [ ...$session->asArray(), 'elapsed_minutes' => $session->getElapsedMinutes(), - 'open' => $session->completed_at->value() === null, + 'open' => $session->isOpen(), ]; }, null, diff --git a/services/nginx/app/tests/Unit/Selfserve/SelfserveOpenApiSpecTest.php b/services/nginx/app/tests/Unit/Selfserve/SelfserveOpenApiSpecTest.php index de705646..fbf73147 100644 --- a/services/nginx/app/tests/Unit/Selfserve/SelfserveOpenApiSpecTest.php +++ b/services/nginx/app/tests/Unit/Selfserve/SelfserveOpenApiSpecTest.php @@ -75,6 +75,7 @@ it('documents the all-in-one self-serve studio replacement API', function (): vo expect($content)->toContain('/department/selfserve/studio/validate:'); expect($content)->toContain('/department/selfserve/studio/simulate:'); expect($content)->toContain('/department/selfserve/studio/path-outcomes:'); + expect($content)->toContain('/department/selfserve/studio/path-outcomes/stream:'); expect($content)->toContain('/department/selfserve/studio/publish:'); expect($content)->toContain('/department/selfserve/studio/rollback:'); expect($content)->toContain('/department/selfserve/studio/gateway-action:'); @@ -84,7 +85,9 @@ it('documents the all-in-one self-serve studio replacement API', function (): vo expect($content)->toContain('SelfserveStudioPathOutcomesRequest:'); expect($content)->toContain('SelfserveStudioPathOutcomesResponse:'); expect($content)->toContain('SelfserveStudioPathResult:'); + expect($content)->toContain('SelfserveStudioPathProgress:'); expect($content)->toContain('projectSelfserveStudioPathOutcomes'); + expect($content)->toContain('streamSelfserveStudioPathOutcomes'); expect($content)->toContain('runSelfserveStudioGatewayAction'); }); @@ -96,6 +99,9 @@ it('defines reusable self-serve wash and machine type schemas', function (): voi expect($content)->toContain('SelfserveWashSummary:'); expect($content)->toContain('MachineButtonPressWebhookResponse:'); expect($content)->toContain('DepartmentSelfserveVehicleConditionMutationResponse:'); + expect($content)->toContain('DepartmentLane:'); + expect($content)->toContain('selfserve_enabled:'); + expect($content)->toContain('blocked_reason:'); expect($content)->toContain('machine_type_id:'); expect($content)->toContain('SelfServeLaneMachineRelayStatus:'); expect($content)->toContain(' wash_started_at:'); diff --git a/services/nginx/app/tests/Unit/Selfserve/SelfserveRouteWiringTest.php b/services/nginx/app/tests/Unit/Selfserve/SelfserveRouteWiringTest.php index 0450b490..14e6f81b 100644 --- a/services/nginx/app/tests/Unit/Selfserve/SelfserveRouteWiringTest.php +++ b/services/nginx/app/tests/Unit/Selfserve/SelfserveRouteWiringTest.php @@ -49,6 +49,39 @@ it('keeps machine type support wired into lanes, tasks, and conditions routes', ->toContain('product'); }); +it('wires lane-level self-serve toggles through lane APIs, guest payloads, and edge workspace readiness', function (): void { + $lanesRoute = file_get_contents(app_path('routes/departmentLanesRoute.php')); + $laneObject = file_get_contents(app_path('objects/department_lanes_o.php')); + $guestRoute = file_get_contents(app_path('routes/guestRoute.php')); + $edgeWorkspace = file_get_contents(app_path('modules/edgegateway/classes/edge_gateway_department_workspace_service.php')); + + expect($lanesRoute)->not->toBeFalse() + ->and($lanesRoute)->toContain("'selfserve_enabled'") + ->and($lanesRoute)->toContain('normalizeSelfServeEnabledValue') + ->and($lanesRoute)->toContain('disableSelfServeRelaysBestEffort') + ->and($lanesRoute)->toContain("'lane' => \$created_lane->asArray()") + ->and($lanesRoute)->toContain("'lane' => \$department_lane->asArray()"); + + expect($laneObject)->not->toBeFalse() + ->and($laneObject)->toContain('public object_property $selfserve_enabled') + ->and($laneObject)->toContain('public function isSelfServeEnabled(): bool') + ->and($laneObject)->toContain('public static function normalizeSelfServeEnabledValue') + ->and($laneObject)->toContain('public static function disableSelfServeRelaysBestEffort') + ->and($laneObject)->toContain('setMachineRelayStatusHard(false)') + ->and($laneObject)->toContain('setMachineProgramPickerRelayStatusHard(false)') + ->and($laneObject)->toContain('setMachineCleanerRelayStatusHard(false)'); + + expect($guestRoute)->not->toBeFalse() + ->and($guestRoute)->toContain("'selfserve_enabled' => \$lane->isSelfServeEnabled()") + ->and($guestRoute)->toContain("'machine_available' => \$lane->isSelfServeEnabled() && !empty(\$lane->relay_machine_id->value())"); + + expect($edgeWorkspace)->not->toBeFalse() + ->and($edgeWorkspace)->toContain("'selfserve_enabled' => \$lane->isSelfServeEnabled()") + ->and($edgeWorkspace)->toContain("'enabled_lanes' => count(\$enabledLanes)") + ->and($edgeWorkspace)->toContain("'ready_lanes' => count(\$readyLanes)") + ->and($edgeWorkspace)->toContain("(int)(\$selfServe['ready_lanes'] ?? 0) < (int)(\$selfServe['enabled_lanes'] ?? 0)"); +}); + it('wires self-serve config draft/publish/rollback lifecycle endpoints', function (): void { $configRoute = file_get_contents(app_path('routes/departmentSelfserveConfigVersionsRoute.php')); @@ -74,6 +107,8 @@ it('wires the all-in-one self-serve studio replacement endpoints', function (): expect($studioRoute)->toContain('/department/selfserve/studio/validate'); expect($studioRoute)->toContain('/department/selfserve/studio/simulate'); expect($studioRoute)->toContain('/department/selfserve/studio/path-outcomes'); + expect($studioRoute)->toContain('/department/selfserve/studio/path-outcomes/stream'); + expect($studioRoute)->toContain("ini_set('display_errors', '0')"); expect($studioRoute)->toContain('/department/selfserve/studio/publish'); expect($studioRoute)->toContain('/department/selfserve/studio/rollback'); expect($studioRoute)->toContain('/department/selfserve/studio/gateway-action'); @@ -251,6 +286,39 @@ it('wires self-serve session management endpoints and OpenAPI coverage', functio } }); +it('returns authoritative allowed service state from self-serve session summaries', function (): void { + $washFlow = file_get_contents(app_path('modules/selfserve/classes/selfserve_wash_flow.php')); + + expect($washFlow)->not->toBeFalse(); + $start = strpos($washFlow, 'public function getSessionSummary'); + $end = strpos($washFlow, 'public function getLatestSessionSummary', $start); + expect($start)->not->toBeFalse(); + expect($end)->not->toBeFalse(); + $summarySource = substr($washFlow, $start, $end - $start); + + expect($summarySource) + ->toContain('$metadata = is_array($session->metadata_json->value())') + ->toContain('$allowedServices = $this->normalizeServiceNames(') + ->toContain('$tasks = $this->filterTasksForAllowedServices($tasks, $allowedServices)') + ->toContain("'allowed_services' => \$allowedServices") + ->toContain("'machine_available' => \$machineAvailable") + ->toContain("'all_visible_questions_answered' => \$allVisibleQuestionsAnswered") + ->toContain("'allowed' => (bool)\$session->allowed->value()"); +}); + +it('filters machine button tasks out of self-serve snapshots when MACHINE is not allowed', function (): void { + $washFlow = file_get_contents(app_path('modules/selfserve/classes/selfserve_wash_flow.php')); + + expect($washFlow)->not->toBeFalse() + ->and($washFlow)->toContain('$visibleTasks = $this->filterTasksForAllowedServices($activeTasks, $allowedServices)') + ->and($washFlow)->toContain("'tasks' => \$visibleTasks") + ->and($washFlow)->toContain('protected function filterTasksForAllowedServices') + ->and($washFlow)->toContain('protected function taskUsesMachineControls') + ->and($washFlow)->toContain("in_array(selfserve_lane_services::MACHINE->name, \$allowedServices, true)") + ->and($washFlow)->toContain("\$this->normalizeButtonList(\$task['buttons'] ?? null) !== []") + ->and($washFlow)->toContain("\$task['dynamic_images_vehicle_type'] ?? null"); +}); + it('keeps self-serve force stop distinct from normal STOP relay and gate behavior', function (): void { $washFlow = file_get_contents(app_path('modules/selfserve/classes/selfserve_wash_flow.php')); $sessionObject = file_get_contents(app_path('objects/selfserve_wash_sessions_o.php')); @@ -311,6 +379,26 @@ it('wires vehicle type override into self-serve preview and synchronization rout expect($washFlow)->toContain("\$session->vehicle_id->set(\$snapshot['vehicle']['id'] ?? null);"); }); +it('blocks new self-serve eligibility and session sync for disabled lanes', function (): void { + $washFlow = file_get_contents(app_path('modules/selfserve/classes/selfserve_wash_flow.php')); + $commandTrait = file_get_contents(app_path('modules/selfserve/traits/selfserve_lane_command_t.php')); + + expect($washFlow)->not->toBeFalse() + ->and($washFlow)->toContain('if (!$lane->isSelfServeEnabled())') + ->and($washFlow)->toContain("'allowed_services' => []") + ->and($washFlow)->toContain("'machine_available' => false") + ->and($washFlow)->toContain("'allowed' => false") + ->and($washFlow)->toContain("'blocked_reason' => 'Self-serve is disabled for this lane.'") + ->and($washFlow)->toContain("'blocking_reasons' => ['LANE_SELFSERVE_DISABLED']") + ->and($washFlow)->toContain("'disabled_lane' => true") + ->and($washFlow)->toContain('formatBlockedSessionSummary') + ->and($washFlow)->toContain('empty($summary[\'session\'][\'id\'])'); + + expect($commandTrait)->not->toBeFalse() + ->and($commandTrait)->toContain('$this->department_lane->isSelfServeEnabled()') + ->and($commandTrait)->toContain('Self-serve is not enabled for this lane.'); +}); + it('keeps read-only self-serve preview and summary refreshes from touching relay hardware', function (): void { $vehicleConditionsRoute = file_get_contents(app_path('routes/departmentSelfserveVehicleConditionsRoute.php')); diff --git a/services/nginx/app/tests/Unit/Selfserve/SelfserveSchemaBootstrapCompatibilityTest.php b/services/nginx/app/tests/Unit/Selfserve/SelfserveSchemaBootstrapCompatibilityTest.php index 4848e932..ec0d77b6 100644 --- a/services/nginx/app/tests/Unit/Selfserve/SelfserveSchemaBootstrapCompatibilityTest.php +++ b/services/nginx/app/tests/Unit/Selfserve/SelfserveSchemaBootstrapCompatibilityTest.php @@ -18,6 +18,15 @@ it('adds wash_started_at column for legacy selfserve wash session schemas', func expect($bootstrapContent)->toContain('ALTER TABLE selfserve_wash_sessions ADD COLUMN wash_started_at DATETIME NULL AFTER machine_start_triggered_at'); }); +it('adds lane-level self-serve enablement for existing department lanes', function (): void { + $bootstrapContent = file_get_contents(app_path('classes/selfserve_schema_bootstrap.php')); + + expect($bootstrapContent)->not->toBeFalse(); + expect($bootstrapContent)->toContain("'department_lanes'"); + expect($bootstrapContent)->toContain("'selfserve_enabled'"); + expect($bootstrapContent)->toContain('ALTER TABLE department_lanes ADD COLUMN selfserve_enabled TINYINT(1) NOT NULL DEFAULT 1 AFTER machine_type_id'); +}); + it('creates canvas-only self-serve studio layout storage', function (): void { $bootstrapContent = file_get_contents(app_path('classes/selfserve_schema_bootstrap.php')); diff --git a/services/nginx/app/tests/Unit/Selfserve/SelfserveStudioGraphTest.php b/services/nginx/app/tests/Unit/Selfserve/SelfserveStudioGraphTest.php index 1ff6aeed..466cb380 100644 --- a/services/nginx/app/tests/Unit/Selfserve/SelfserveStudioGraphTest.php +++ b/services/nginx/app/tests/Unit/Selfserve/SelfserveStudioGraphTest.php @@ -461,6 +461,7 @@ it('keeps lane management fields on lane scope nodes', function (): void { 'relay_machine_id' => 'M-7', 'machine_type_id' => 1001, 'dynamic_image_id' => 1, + 'selfserve_enabled' => true, ], ], 'machine_types' => [['id' => 1001, 'label' => 'Portal']], @@ -481,7 +482,8 @@ it('keeps lane management fields on lane scope nodes', function (): void { expect($laneNode)->not->toBeNull() ->and($laneNode['data']['raw']['relay_machine_id'])->toBe('M-7') ->and($laneNode['data']['raw']['machine_type_id'])->toBe(1001) - ->and($laneNode['data']['raw']['dynamic_image_id'])->toBe(1); + ->and($laneNode['data']['raw']['dynamic_image_id'])->toBe(1) + ->and($laneNode['data']['raw']['selfserve_enabled'])->toBeTrue(); }); it('routes studio lane graph operations through department_lanes', function (): void { @@ -492,7 +494,11 @@ it('routes studio lane graph operations through department_lanes', function (): ->and($source)->toContain('private function createLane') ->and($source)->toContain('private function updateLane') ->and($source)->toContain('INSERT INTO department_lanes') - ->and($source)->toContain("'dynamic_image_id'"); + ->and($source)->toContain("'dynamic_image_id'") + ->and($source)->toContain("'selfserve_enabled'") + ->and($source)->toContain('normalizeLaneField') + ->and($source)->toContain('normalizeSelfServeEnabledValue') + ->and($source)->toContain('disableSelfServeRelaysBestEffort'); }); it('applies saved layout without changing graph semantics', function (): void { @@ -793,7 +799,7 @@ it('truncates path outcome projection when the state cap is reached', function ( ->and($projection['warnings'][0])->toContain('truncated at 2 explored state'); }); -it('returns terminal path results before exhausting the state cap on wide question trees', function (): void { +it('returns complete terminal path results for wide question trees and reports progress', function (): void { $service = selfserve_studio_graph_without_constructor(); $simulate = function (array $overrides): array { $answers = []; @@ -840,20 +846,32 @@ it('returns terminal path results before exhausting the state cap on wide questi ]; }; + $progressEvents = []; $projection = $service->projectPathOutcomesFromSimulator($simulate, [ - 'max_states' => 2048, - 'path_sample_limit' => 20, + 'progress_interval_states' => 512, + 'progress_callback' => static function (array $partial) use (&$progressEvents): void { + $progressEvents[] = [ + 'percent' => (int)($partial['progress']['percent'] ?? 0), + 'terminal_path_count' => (int)($partial['summary']['terminal_path_count'] ?? 0), + 'path_sample_count' => (int)($partial['summary']['path_sample_count'] ?? 0), + ]; + }, ]); - expect($projection['truncated'])->toBeTrue() - ->and($projection['summary']['state_count'])->toBe(2048) + expect($projection['truncated'])->toBeFalse() + ->and($projection['summary']['state_count'])->toBe(8191) ->and($projection['summary']['question_count'])->toBe(12) - ->and($projection['summary']['terminal_path_count'])->toBeGreaterThan(0) - ->and($projection['summary']['outcome_count'])->toBeGreaterThan(0) - ->and($projection['summary']['path_sample_count'])->toBe(20) - ->and($projection['paths'])->toHaveCount(20) + ->and($projection['summary']['terminal_path_count'])->toBe(4096) + ->and($projection['summary']['outcome_count'])->toBe(2) + ->and($projection['summary']['path_sample_count'])->toBe(4096) + ->and($projection['progress']['complete'])->toBeTrue() + ->and($projection['progress']['percent'])->toBe(100) + ->and($projection['paths'])->toHaveCount(4096) ->and($projection['paths'][0]['answers'])->toHaveCount(12) - ->and($projection['paths'][0]['result'])->toBe('Allowed'); + ->and($projection['paths'][0]['result'])->toBe('Allowed') + ->and($progressEvents)->not->toBeEmpty() + ->and($progressEvents[0]['terminal_path_count'])->toBeGreaterThan(0) + ->and($progressEvents[0]['path_sample_count'])->toBeGreaterThan(0); }); it('resolves simulator gateway service bindings from lane relay slots', function (): void { diff --git a/services/nginx/app/tests/Unit/Selfserve/SelfserveWashSessionStateTest.php b/services/nginx/app/tests/Unit/Selfserve/SelfserveWashSessionStateTest.php new file mode 100644 index 00000000..52730b56 --- /dev/null +++ b/services/nginx/app/tests/Unit/Selfserve/SelfserveWashSessionStateTest.php @@ -0,0 +1,47 @@ +set($value); + + return $property; +} + +function selfserve_session_harness( + string $status, + ?string $completedAt, + ?string $washStartedAt = '2026-04-28 10:00:00', + ?string $machineStartTriggeredAt = null +): selfserve_wash_sessions_o { + $session = (new ReflectionClass(selfserve_wash_sessions_o::class))->newInstanceWithoutConstructor(); + $session->status = selfserve_session_property('status', 'string', $status); + $session->completed_at = selfserve_session_property('completed_at', 'datetime', $completedAt); + $session->wash_started_at = selfserve_session_property('wash_started_at', 'datetime', $washStartedAt); + $session->machine_start_triggered_at = selfserve_session_property('machine_start_triggered_at', 'datetime', $machineStartTriggeredAt); + + return $session; +} + +it('treats terminal self-serve wash session statuses as closed', function (): void { + expect(selfserve_wash_sessions_o::isTerminalStatus('COMPLETED'))->toBeTrue() + ->and(selfserve_wash_sessions_o::isTerminalStatus('FORCE_STOPPED'))->toBeTrue() + ->and(selfserve_wash_sessions_o::isTerminalStatus('MACHINE_STARTED'))->toBeFalse() + ->and(selfserve_wash_sessions_o::terminalStatusSqlList())->toBe("'COMPLETED','FORCE_STOPPED'"); + + expect(selfserve_session_harness('MACHINE_STARTED', null)->isOpen())->toBeTrue() + ->and(selfserve_session_harness('COMPLETED', null)->isOpen())->toBeFalse() + ->and(selfserve_session_harness('MACHINE_STARTED', '2026-04-28 10:30:00')->isOpen())->toBeFalse(); +}); + +it('freezes elapsed self-serve wash minutes at completion time', function (): void { + expect(selfserve_session_harness('COMPLETED', '2026-04-28 10:42:00')->getElapsedMinutes())->toBe(42) + ->and(selfserve_session_harness('COMPLETED', '2026-04-28 10:35:00', null, '2026-04-28 10:05:00')->getElapsedMinutes())->toBe(30) + ->and(selfserve_session_harness('COMPLETED', '2026-04-28 09:59:00')->getElapsedMinutes())->toBe(0); +});