From 5875371d138cd45a098392156efa1196f2cb7be9 Mon Sep 17 00:00:00 2001 From: Jeppe Bundgaard Date: Wed, 29 Apr 2026 16:03:03 +0200 Subject: [PATCH] Add attachment payload handling and tests for self-serve tasks - Introduced `selfserve_task_attachment_payloads` class for managing task attachments, including formatting and download URL generation. - Added unit and API tests to validate attachment handling in self-serve tasks and customer-scoped workflows. - Enhanced wash start simulation and studio graph projections to integrate task attachment data. --- .../classes/selfserve_condition_evaluator.php | 258 +++- .../classes/selfserve_config_versioning.php | 240 +++- .../selfserve_studio_action_runner.php | 2 +- .../classes/selfserve_studio_actions.php | 6 +- .../classes/selfserve_studio_graph.php | 1033 ++++++++++++++++- .../selfserve_task_attachment_payloads.php | 118 ++ .../selfserve/classes/selfserve_wash_flow.php | 127 +- .../traits/selfserve_lane_command_t.php | 103 +- services/nginx/app/openapi.yaml | 213 ++++ .../routes/departmentSelfserveStudioRoute.php | 23 + .../nginx/app/routes/moduleSelfServeRoute.php | 92 +- .../SelfserveLaneWashInProgressApiTest.php | 76 ++ .../app/tests/Support/Api/ApiFixtures.php | 2 +- .../Bird/DepartmentGatesRelayOpenTest.php | 6 +- .../SelfserveConditionEvaluatorTest.php | 106 ++ .../SelfserveConfigVersioningTest.php | 119 ++ .../SelfserveInProgressWashAccessTest.php | 76 ++ .../Selfserve/SelfserveLaneStopFlowTest.php | 42 + .../Selfserve/SelfserveOpenApiSpecTest.php | 5 + .../Selfserve/SelfserveRouteWiringTest.php | 5 + .../Selfserve/SelfserveStudioGraphTest.php | 441 +++++++ ...fserveWashFlowMachineAllowedWiringTest.php | 30 + 22 files changed, 3058 insertions(+), 65 deletions(-) create mode 100644 services/nginx/app/modules/selfserve/classes/selfserve_task_attachment_payloads.php create mode 100644 services/nginx/app/tests/Api/SelfserveLaneWashInProgressApiTest.php create mode 100644 services/nginx/app/tests/Unit/Selfserve/SelfserveInProgressWashAccessTest.php diff --git a/services/nginx/app/modules/selfserve/classes/selfserve_condition_evaluator.php b/services/nginx/app/modules/selfserve/classes/selfserve_condition_evaluator.php index 7d22f609..dc18db0c 100644 --- a/services/nginx/app/modules/selfserve/classes/selfserve_condition_evaluator.php +++ b/services/nginx/app/modules/selfserve/classes/selfserve_condition_evaluator.php @@ -152,12 +152,16 @@ class selfserve_condition_evaluator implements selfserve_condition_evaluator_i unset($resolving[$conditionId]); $results[$conditionId] = (bool)($evaluated['result'] ?? false); + $previousTrace = $trace[$conditionId] ?? null; $trace[$conditionId] = [ 'type' => 'condition', 'condition_id' => $conditionId, 'result' => $results[$conditionId], 'expression' => $evaluated, ]; + if (is_array($previousTrace) && ($previousTrace['type'] ?? null) === 'cycle') { + $trace[$conditionId]['cycle'] = $previousTrace; + } return $results[$conditionId]; }; @@ -242,6 +246,12 @@ class selfserve_condition_evaluator implements selfserve_condition_evaluator_i if ($type === 'predicate') { return $this->evaluateExpressionPredicate($node, $answers, $conditionResolver); } + if (in_array($type, ['branch', 'if', 'if_else'], true)) { + return $this->evaluateBranchExpression($node, $answers, $conditionResolver); + } + if ($type === 'case') { + return $this->evaluateCaseExpression($node, $answers, $conditionResolver); + } $operator = strtoupper((string)($node['operator'] ?? $node['mode'] ?? 'ALL')); if (!in_array($operator, ['ALL', 'ANY'], true)) { @@ -308,14 +318,7 @@ class selfserve_condition_evaluator implements selfserve_condition_evaluator_i ]; } - if ($subjectType === 'condition') { - $actual = $subjectId > 0 ? $conditionResolver($subjectId) : null; - } elseif ($subjectType === 'question') { - $actual = $answers[$subjectId] ?? null; - } else { - $actual = null; - } - + $actual = $this->expressionSubjectValue($subjectType, $subjectId, $answers, $conditionResolver); $result = match ($operator) { 'IS_TRUE' => $actual === true, 'IS_FALSE' => $actual === false, @@ -336,6 +339,245 @@ class selfserve_condition_evaluator implements selfserve_condition_evaluator_i ]; } + /** + * @param array $node + * @param array $answers + * @param callable(int):bool $conditionResolver + * @return array + */ + private function evaluateBranchExpression(array $node, array $answers, callable $conditionResolver): array + { + $branches = is_array($node['branches'] ?? null) ? array_values((array)$node['branches']) : []; + if ($branches === []) { + return [ + 'type' => 'branch', + 'operator' => 'IF_ELSE', + 'result' => false, + 'branches' => [], + 'reason' => 'Branch has no clauses.', + ]; + } + + $branchTraces = []; + foreach ($branches as $index => $branch) { + if (!is_array($branch)) { + $branchTraces[] = [ + 'index' => $index, + 'kind' => 'invalid', + 'matched' => false, + 'result' => false, + 'reason' => 'Branch clause is invalid.', + ]; + continue; + } + + $kind = strtolower((string)($branch['kind'] ?? $branch['type'] ?? $branch['operator'] ?? ($index === 0 ? 'if' : 'else_if'))); + $isElse = (bool)($branch['else'] ?? false) || in_array($kind, ['else', 'default'], true); + $whenTrace = null; + $matched = $isElse; + if (!$isElse) { + $when = is_array($branch['when'] ?? null) ? (array)$branch['when'] : $this->emptyExpression(); + $whenTrace = $this->evaluateExpressionNode($when, $answers, $conditionResolver); + $matched = (bool)($whenTrace['result'] ?? false); + } + + if (!$matched) { + $branchTraces[] = [ + 'index' => $index, + 'kind' => $isElse ? 'else' : $kind, + 'matched' => false, + 'result' => false, + 'when' => $whenTrace, + 'reason' => $isElse ? 'Else branch was not reached.' : 'Branch condition did not pass.', + ]; + continue; + } + + $then = is_array($branch['then'] ?? null) + ? (array)$branch['then'] + : (is_array($branch['result_expression'] ?? null) ? (array)$branch['result_expression'] : $this->emptyExpression()); + $thenTrace = $this->evaluateExpressionNode($then, $answers, $conditionResolver); + $result = (bool)($thenTrace['result'] ?? false); + $branchTraces[] = [ + 'index' => $index, + 'kind' => $isElse ? 'else' : $kind, + 'matched' => true, + 'result' => $result, + 'when' => $whenTrace, + 'then' => $thenTrace, + 'reason' => $result ? 'Branch matched and passed.' : 'Branch matched and did not pass.', + ]; + + return [ + 'type' => 'branch', + 'operator' => 'IF_ELSE', + 'result' => $result, + 'selected_index' => $index, + 'branches' => $branchTraces, + 'reason' => $result ? 'Selected branch passed.' : 'Selected branch did not pass.', + ]; + } + + if (is_array($node['default'] ?? null)) { + $defaultTrace = $this->evaluateExpressionNode((array)$node['default'], $answers, $conditionResolver); + $result = (bool)($defaultTrace['result'] ?? false); + return [ + 'type' => 'branch', + 'operator' => 'IF_ELSE', + 'result' => $result, + 'branches' => $branchTraces, + 'default' => $defaultTrace, + 'reason' => $result ? 'Default branch passed.' : 'Default branch did not pass.', + ]; + } + + return [ + 'type' => 'branch', + 'operator' => 'IF_ELSE', + 'result' => false, + 'branches' => $branchTraces, + 'reason' => 'No branch matched.', + ]; + } + + /** + * @param array $node + * @param array $answers + * @param callable(int):bool $conditionResolver + * @return array + */ + private function evaluateCaseExpression(array $node, array $answers, callable $conditionResolver): array + { + $subjectType = strtolower((string)($node['subject_type'] ?? $node['object_type'] ?? '')); + $subjectId = (int)($node['subject_id'] ?? $node['object_id'] ?? 0); + $actual = $this->expressionSubjectValue($subjectType, $subjectId, $answers, $conditionResolver); + $cases = is_array($node['cases'] ?? null) ? array_values((array)$node['cases']) : []; + + if (!in_array($subjectType, ['question', 'condition'], true) || $subjectId <= 0) { + return [ + 'type' => 'case', + 'operator' => 'CASE', + 'subject_type' => $subjectType, + 'subject_id' => $subjectId, + 'actual_value' => null, + 'result' => false, + 'cases' => [], + 'reason' => 'Case subject is invalid.', + ]; + } + + $caseTraces = []; + foreach ($cases as $index => $case) { + if (!is_array($case)) { + $caseTraces[] = [ + 'index' => $index, + 'matched' => false, + 'result' => false, + 'reason' => 'Case clause is invalid.', + ]; + continue; + } + + $expected = $case['value'] ?? null; + $matched = $this->caseValueMatches($expected, $actual); + if (!$matched) { + $caseTraces[] = [ + 'index' => $index, + 'value' => $expected, + 'matched' => false, + 'result' => false, + 'reason' => 'Case value did not match.', + ]; + continue; + } + + $then = is_array($case['then'] ?? null) + ? (array)$case['then'] + : (is_array($case['result_expression'] ?? null) ? (array)$case['result_expression'] : $this->emptyExpression()); + $thenTrace = $this->evaluateExpressionNode($then, $answers, $conditionResolver); + $result = (bool)($thenTrace['result'] ?? false); + $caseTraces[] = [ + 'index' => $index, + 'value' => $expected, + 'matched' => true, + 'result' => $result, + 'then' => $thenTrace, + 'reason' => $result ? 'Case matched and passed.' : 'Case matched and did not pass.', + ]; + + return [ + 'type' => 'case', + 'operator' => 'CASE', + 'subject_type' => $subjectType, + 'subject_id' => $subjectId, + 'actual_value' => $actual, + 'result' => $result, + 'selected_index' => $index, + 'cases' => $caseTraces, + 'reason' => $result ? 'Selected case passed.' : 'Selected case did not pass.', + ]; + } + + if (is_array($node['default'] ?? null)) { + $defaultTrace = $this->evaluateExpressionNode((array)$node['default'], $answers, $conditionResolver); + $result = (bool)($defaultTrace['result'] ?? false); + return [ + 'type' => 'case', + 'operator' => 'CASE', + 'subject_type' => $subjectType, + 'subject_id' => $subjectId, + 'actual_value' => $actual, + 'result' => $result, + 'cases' => $caseTraces, + 'default' => $defaultTrace, + 'reason' => $result ? 'Default case passed.' : 'Default case did not pass.', + ]; + } + + return [ + 'type' => 'case', + 'operator' => 'CASE', + 'subject_type' => $subjectType, + 'subject_id' => $subjectId, + 'actual_value' => $actual, + 'result' => false, + 'cases' => $caseTraces, + 'reason' => 'No case matched.', + ]; + } + + /** + * @param array $answers + * @param callable(int):bool $conditionResolver + */ + private function expressionSubjectValue(string $subjectType, int $subjectId, array $answers, callable $conditionResolver): ?bool + { + if ($subjectType === 'condition') { + return $subjectId > 0 ? $conditionResolver($subjectId) : null; + } + if ($subjectType === 'question') { + return $answers[$subjectId] ?? null; + } + return null; + } + + private function caseValueMatches(mixed $expected, ?bool $actual): bool + { + if (is_string($expected)) { + $normalized = strtolower(trim($expected)); + return match ($normalized) { + 'true', '1', 'yes' => $actual === true, + 'false', '0', 'no' => $actual === false, + 'null', 'unset', 'not_set', 'unanswered' => $actual === null, + 'set' => $actual !== null, + 'any', '*' => true, + default => false, + }; + } + + return $expected === $actual; + } + /** * @return array */ diff --git a/services/nginx/app/modules/selfserve/classes/selfserve_config_versioning.php b/services/nginx/app/modules/selfserve/classes/selfserve_config_versioning.php index 13ebb780..ae3977a8 100644 --- a/services/nginx/app/modules/selfserve/classes/selfserve_config_versioning.php +++ b/services/nginx/app/modules/selfserve/classes/selfserve_config_versioning.php @@ -68,6 +68,7 @@ class selfserve_config_versioning return null; } + $published['config'] = $this->normalizeV2Config((array)$published['config']); return $published; } @@ -91,6 +92,12 @@ class selfserve_config_versioning $nextConfig = $forceRefresh ? $config : $this->migrateLegacyConfigToV2($existingConfig + ['department_id' => $departmentId]); $versionObject->config_json->set($nextConfig); $versionObject->validation_result_json->set($this->validateConfig($nextConfig)); + } else { + $normalizedConfig = $this->normalizeV2Config($existingConfig + ['department_id' => $departmentId]); + if ($normalizedConfig !== $existingConfig) { + $versionObject->config_json->set($normalizedConfig); + $versionObject->validation_result_json->set($this->validateConfig($normalizedConfig)); + } } return $versionObject->asArray(); } @@ -266,17 +273,8 @@ class selfserve_config_versioning 'deleted_at' => null, ], ['id', 'condition_id', 'type', 'object_type', 'object_id', 'name', 'description']); - $tasks = array_map(function (array $task): array { - $gateType = selfserve_task_gate_type::tryFrom((string)($task['gate_type'] ?? '')); - if ($gateType === null) { - $legacyGateId = $this->nullableInt($task['condition_id'] ?? null); - $task['gate_type'] = $legacyGateId === null - ? selfserve_task_gate_type::ALWAYS->value - : selfserve_task_gate_type::CONDITION->value; - $task['gate_ref_id'] = $legacyGateId; - } - return $task; - }, $tasks); + $conditionIdMap = array_fill_keys($conditionIds, true); + $tasks = array_map(fn(array $task): array => $this->normalizeTaskGate($task, $conditionIdMap), $tasks); usort($questions, static fn(array $a, array $b): int => (int)$a['id'] <=> (int)$b['id']); usort($conditions, static fn(array $a, array $b): int => (int)$a['id'] <=> (int)$b['id']); @@ -357,7 +355,7 @@ class selfserve_config_versioning public function validateConfig(array $config): array { if ($this->isV2Config($config)) { - return $this->validateV2Config($config); + return $this->validateV2Config($this->normalizeV2Config($config)); } $errors = []; @@ -421,14 +419,14 @@ class selfserve_config_versioning } foreach ($tasks as $task) { + $resolvedGate = $this->resolveTaskGate($task, $conditionIds); $gateTypeRaw = (string)($task['gate_type'] ?? ''); - $gateType = selfserve_task_gate_type::tryFrom($gateTypeRaw); - if ($gateType === null) { + $gateType = $resolvedGate['gate_type']; + if (selfserve_task_gate_type::tryFrom($gateTypeRaw) === null && $gateTypeRaw !== '') { $warnings[] = 'Task ' . (int)($task['id'] ?? 0) . ' has invalid gate_type `' . $gateTypeRaw . '`, falling back to legacy handling.'; - continue; } - $gateRefId = $this->nullableInt($task['gate_ref_id'] ?? null); + $gateRefId = $resolvedGate['gate_ref_id']; if ($gateType === selfserve_task_gate_type::ALWAYS) { continue; } @@ -696,6 +694,127 @@ class selfserve_config_versioning ]; } + if (in_array($type, ['branch', 'if', 'if_else'], true)) { + $branches = is_array($expression['branches'] ?? null) ? array_values((array)$expression['branches']) : []; + if ($branches === []) { + $errors[] = 'Condition ' . $ownerConditionId . ' has an empty if/else expression.'; + } + + $hasPredicate = false; + foreach ($branches as $index => $branch) { + if (!is_array($branch)) { + $errors[] = 'Condition ' . $ownerConditionId . ' has an invalid if/else clause.'; + continue; + } + + $kind = strtolower((string)($branch['kind'] ?? $branch['type'] ?? $branch['operator'] ?? ($index === 0 ? 'if' : 'else_if'))); + $isElse = (bool)($branch['else'] ?? false) || in_array($kind, ['else', 'default'], true); + if (!$isElse) { + if (!is_array($branch['when'] ?? null)) { + $errors[] = 'Condition ' . $ownerConditionId . ' has an if/else clause without a when expression.'; + } else { + $whenValidation = $this->validateExpressionNode((array)$branch['when'], $ownerConditionId, $questionIds, $conditionIds, $usedConditionIds, $conditionEdges); + $hasPredicate = $hasPredicate || $whenValidation['has_predicate']; + foreach ($whenValidation['errors'] as $message) { + $errors[] = $message; + } + } + } + + if (!is_array($branch['then'] ?? null)) { + $errors[] = 'Condition ' . $ownerConditionId . ' has an if/else clause without a then expression.'; + continue; + } + + $thenValidation = $this->validateExpressionNode((array)$branch['then'], $ownerConditionId, $questionIds, $conditionIds, $usedConditionIds, $conditionEdges); + $hasPredicate = $hasPredicate || $thenValidation['has_predicate']; + foreach ($thenValidation['errors'] as $message) { + $errors[] = $message; + } + } + + if (array_key_exists('default', $expression)) { + if (!is_array($expression['default'])) { + $errors[] = 'Condition ' . $ownerConditionId . ' has an invalid if/else default expression.'; + } else { + $defaultValidation = $this->validateExpressionNode((array)$expression['default'], $ownerConditionId, $questionIds, $conditionIds, $usedConditionIds, $conditionEdges); + $hasPredicate = $hasPredicate || $defaultValidation['has_predicate']; + foreach ($defaultValidation['errors'] as $message) { + $errors[] = $message; + } + } + } + + return [ + 'errors' => $errors, + 'has_predicate' => $hasPredicate, + ]; + } + + if ($type === 'case') { + $subjectType = strtolower((string)($expression['subject_type'] ?? $expression['object_type'] ?? '')); + $subjectId = (int)($expression['subject_id'] ?? $expression['object_id'] ?? 0); + if ($subjectType === 'question') { + if ($subjectId <= 0 || !isset($questionIds[$subjectId])) { + $errors[] = 'Condition ' . $ownerConditionId . ' references unknown question case subject_id ' . $subjectId; + } + } elseif ($subjectType === 'condition') { + $usedConditionIds[$subjectId] = true; + $conditionEdges[$ownerConditionId][] = $subjectId; + if ($subjectId === $ownerConditionId) { + $errors[] = 'Condition ' . $ownerConditionId . ' cannot reference itself in a case expression.'; + } + if ($subjectId <= 0 || !isset($conditionIds[$subjectId])) { + $errors[] = 'Condition ' . $ownerConditionId . ' references unknown condition case subject_id ' . $subjectId; + } + } else { + $errors[] = 'Condition ' . $ownerConditionId . ' has unsupported case subject_type `' . $subjectType . '`.'; + } + + $cases = is_array($expression['cases'] ?? null) ? array_values((array)$expression['cases']) : []; + if ($cases === []) { + $errors[] = 'Condition ' . $ownerConditionId . ' has an empty case expression.'; + } + + $hasPredicate = false; + foreach ($cases as $case) { + if (!is_array($case)) { + $errors[] = 'Condition ' . $ownerConditionId . ' has an invalid case clause.'; + continue; + } + if (!array_key_exists('value', $case)) { + $errors[] = 'Condition ' . $ownerConditionId . ' has a case clause without a value.'; + } + if (!is_array($case['then'] ?? null)) { + $errors[] = 'Condition ' . $ownerConditionId . ' has a case clause without a then expression.'; + continue; + } + + $thenValidation = $this->validateExpressionNode((array)$case['then'], $ownerConditionId, $questionIds, $conditionIds, $usedConditionIds, $conditionEdges); + $hasPredicate = $hasPredicate || $thenValidation['has_predicate']; + foreach ($thenValidation['errors'] as $message) { + $errors[] = $message; + } + } + + if (array_key_exists('default', $expression)) { + if (!is_array($expression['default'])) { + $errors[] = 'Condition ' . $ownerConditionId . ' has an invalid case default expression.'; + } else { + $defaultValidation = $this->validateExpressionNode((array)$expression['default'], $ownerConditionId, $questionIds, $conditionIds, $usedConditionIds, $conditionEdges); + $hasPredicate = $hasPredicate || $defaultValidation['has_predicate']; + foreach ($defaultValidation['errors'] as $message) { + $errors[] = $message; + } + } + } + + return [ + 'errors' => $errors, + 'has_predicate' => $hasPredicate, + ]; + } + $operator = strtoupper((string)($expression['operator'] ?? $expression['mode'] ?? 'ALL')); if (!in_array($operator, ['ALL', 'ANY'], true)) { $errors[] = 'Condition ' . $ownerConditionId . ' has invalid group operator `' . $operator . '`.'; @@ -939,7 +1058,17 @@ class selfserve_config_versioning return $condition; }, (array)($config['conditions'] ?? []))); $config['rules'] = []; - $config['tasks'] = array_values((array)($config['tasks'] ?? [])); + $conditionIds = []; + foreach ($config['conditions'] as $condition) { + $id = (int)($condition['id'] ?? 0); + if ($id > 0) { + $conditionIds[$id] = true; + } + } + $config['tasks'] = array_values(array_map( + fn($task): array => $this->normalizeTaskGate(is_array($task) ? (array)$task : [], $conditionIds), + (array)($config['tasks'] ?? []) + )); $config['actions'] = array_values(array_map( static fn($action): array => selfserve_studio_actions::normalize(is_array($action) ? (array)$action : []), (array)($config['actions'] ?? []) @@ -949,6 +1078,83 @@ class selfserve_config_versioning return $config; } + /** + * @param array $task + * @param array $conditionIds + * @return array + */ + protected function normalizeTaskGate(array $task, array $conditionIds): array + { + $resolvedGate = $this->resolveTaskGate($task, $conditionIds); + $task['gate_type'] = $resolvedGate['gate_type']->value; + $task['gate_ref_id'] = $resolvedGate['gate_ref_id']; + + $task['condition_id'] = $resolvedGate['gate_type'] === selfserve_task_gate_type::ALWAYS + ? null + : $resolvedGate['gate_ref_id']; + + return $task; + } + + /** + * @param array $task + * @param array $conditionIds + * @return array{gate_type:selfserve_task_gate_type,gate_ref_id:int|null} + */ + protected function resolveTaskGate(array $task, array $conditionIds): array + { + $gateType = selfserve_task_gate_type::tryFrom(strtoupper(trim((string)($task['gate_type'] ?? '')))); + $gateRefId = $this->nullableInt($task['gate_ref_id'] ?? null); + $legacyGateId = $this->nullableInt($task['condition_id'] ?? null); + + if ( + $gateType === selfserve_task_gate_type::CONDITION + || $gateType === selfserve_task_gate_type::QUESTION + ) { + return [ + 'gate_type' => $gateType, + 'gate_ref_id' => $gateRefId ?? $legacyGateId, + ]; + } + + $shouldInferLegacyGate = $gateType === null + || ( + $gateType === selfserve_task_gate_type::ALWAYS + && $gateRefId === null + && $legacyGateId !== null + ); + + if ($shouldInferLegacyGate) { + $fallbackGateId = $gateRefId ?? $legacyGateId; + if ($fallbackGateId === null) { + return [ + 'gate_type' => selfserve_task_gate_type::ALWAYS, + 'gate_ref_id' => null, + ]; + } + + return [ + 'gate_type' => $this->containsIntegerId($conditionIds, $fallbackGateId) + ? selfserve_task_gate_type::CONDITION + : selfserve_task_gate_type::QUESTION, + 'gate_ref_id' => $fallbackGateId, + ]; + } + + return [ + 'gate_type' => selfserve_task_gate_type::ALWAYS, + 'gate_ref_id' => null, + ]; + } + + /** + * @param array $ids + */ + protected function containsIntegerId(array $ids, int $id): bool + { + return isset($ids[$id]) || in_array($id, $ids, true); + } + /** * @param array $config * @return array diff --git a/services/nginx/app/modules/selfserve/classes/selfserve_studio_action_runner.php b/services/nginx/app/modules/selfserve/classes/selfserve_studio_action_runner.php index 0896128f..f1ac192e 100644 --- a/services/nginx/app/modules/selfserve/classes/selfserve_studio_action_runner.php +++ b/services/nginx/app/modules/selfserve/classes/selfserve_studio_action_runner.php @@ -54,7 +54,7 @@ class selfserve_studio_action_runner $washMode = strtolower(trim($washMode)); $laneId = (int)($lane->id ?? 0); $departmentId = $this->departmentIdForLane($lane); - $machineTypeId = $this->machineTypeIdForLane($lane); + $machineTypeId = $this->nullableInt($context['machine_type_id'] ?? null) ?? $this->machineTypeIdForLane($lane); $productId = $this->nullableInt($context['product'] ?? $context['product_id'] ?? $context['vehicle_type_id'] ?? null); $conditionResults = is_array($context['condition_results'] ?? null) ? (array)$context['condition_results'] : null; diff --git a/services/nginx/app/modules/selfserve/classes/selfserve_studio_actions.php b/services/nginx/app/modules/selfserve/classes/selfserve_studio_actions.php index a849722c..7101a325 100644 --- a/services/nginx/app/modules/selfserve/classes/selfserve_studio_actions.php +++ b/services/nginx/app/modules/selfserve/classes/selfserve_studio_actions.php @@ -110,9 +110,9 @@ class selfserve_studio_actions public static function eventLabel(string $event): string { return match ($event) { - self::EVENT_WASH_START_COMMAND => 'On self-serve wash start command', - self::EVENT_WASH_STOP_COMMAND => 'On self-serve wash stop command', - self::EVENT_MACHINE_START_TRIGGERED => 'On self-serve wash machine start triggered', + self::EVENT_WASH_START_COMMAND => 'When wash starts', + self::EVENT_WASH_STOP_COMMAND => 'When wash stops', + self::EVENT_MACHINE_START_TRIGGERED => 'When the machine start button is triggered', default => 'On action event', }; } 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 3dc0c6d8..2ef48a34 100644 --- a/services/nginx/app/modules/selfserve/classes/selfserve_studio_graph.php +++ b/services/nginx/app/modules/selfserve/classes/selfserve_studio_graph.php @@ -14,6 +14,7 @@ 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/selfserve_config_versions_o.php'; +require_once WD . '/modules/selfserve/classes/selfserve_task_attachment_payloads.php'; if (is_file(WD . '/modules/edgegateway/classes/edge_gateway_department_workspace_service.php')) { require_once WD . '/modules/edgegateway/classes/edge_gateway_department_workspace_service.php'; @@ -72,7 +73,8 @@ class selfserve_studio_graph ]; $lookups = $this->buildLookups($departmentId, $config, $gatewayWorkspace); $layout = $this->loadLayout($departmentId, $userId); - $graph = $this->buildGraphFromConfig($config, [ + $configWithAttachments = $this->withTaskAttachments($config); + $graph = $this->buildGraphFromConfig($configWithAttachments, [ 'department_id' => $departmentId, 'lookups' => $lookups, 'gateway_workspace' => $gatewayWorkspace, @@ -313,6 +315,7 @@ class selfserve_studio_graph $this->appendGatewayNodesAndEdges($nodes, $edges, $gatewayWorkspace); $this->appendTaskServiceEdges($edges, $taskRows, $gatewayWorkspace); + $this->appendActionRelayEdges($edges, $actionRows, $gatewayWorkspace); return [ 'nodes' => $this->applyLayoutToNodes($nodes, $layout), @@ -581,6 +584,340 @@ class selfserve_studio_graph ); } + /** + * @param array $payload + * @param array $permissions + * @return array + */ + public function projectPathOutcomes(int $departmentId, array $payload, ?int $userId = null, array $permissions = []): array + { + $configSource = strtolower(trim((string)($payload['config_source'] ?? 'draft'))); + if (!in_array($configSource, ['draft', 'published'], true)) { + $configSource = 'draft'; + } + + $versioning = new selfserve_config_versioning(); + if ($configSource === 'published') { + $version = $versioning->getPublishedV2Config($departmentId); + $config = is_array($version['config'] ?? null) ? (array)$version['config'] : null; + $versionId = isset($version['version_id']) ? (int)$version['version_id'] : null; + } else { + $version = $versioning->ensureDraftFromLegacy($departmentId, $userId, false); + $config = is_array($version['config'] ?? null) ? (array)$version['config'] : $versioning->snapshotLegacyConfig($departmentId); + $versionId = isset($version['id']) ? (int)$version['id'] : null; + } + + $includeHardware = filter_var($payload['include_hardware'] ?? true, FILTER_VALIDATE_BOOLEAN, FILTER_NULL_ON_FAILURE); + $includeHardware = $includeHardware !== false; + $hardwareMode = strtolower(trim((string)($payload['hardware_mode'] ?? ''))); + if ($hardwareMode === '') { + $hardwareMode = $includeHardware ? 'studio' : 'none'; + } + if (!in_array($hardwareMode, ['studio', 'real', 'none'], true)) { + $hardwareMode = 'studio'; + } + if ($hardwareMode === 'none') { + $includeHardware = false; + } + + if (!$includeHardware || !($permissions['modules_shelly_config'] ?? false)) { + $gatewayWorkspace = [ + 'gateways' => [], + 'relays' => [], + 'lanes' => [], + 'issues' => [], + 'actions' => [], + 'restricted' => !$includeHardware ? false : true, + 'virtual' => [ + 'enabled' => $hardwareMode === 'studio', + 'has_virtual_hardware' => false, + 'gateway_count' => 0, + 'binding_count' => 0, + ], + ]; + } else { + $gatewayWorkspace = $this->buildGatewayWorkspace($departmentId, $hardwareMode !== 'real'); + } + + $graphConfig = is_array($config) ? $config : $versioning->snapshotLegacyConfig($departmentId); + $lookups = $this->buildLookups($departmentId, $graphConfig, $gatewayWorkspace); + $graph = $this->buildGraphFromConfig($graphConfig, [ + 'department_id' => $departmentId, + 'lookups' => $lookups, + 'gateway_workspace' => $gatewayWorkspace, + ]); + $defaults = $this->buildSimulatorDefaults($departmentId, $lookups, $gatewayWorkspace); + $laneId = $this->nullableInt($payload['lane_id'] ?? null) ?? $this->nullableInt($defaults['lane_id'] ?? null); + if ($laneId === null) { + throw new \RuntimeException('No lane is available for path outcome projection.'); + } + + $vehicleTypeId = $this->nullableInt($payload['vehicle_type_id'] ?? null); + $vehicleTypeIds = []; + if ($vehicleTypeId !== null) { + $vehicleTypeIds[] = $vehicleTypeId; + } else { + foreach ($this->lookupRows($lookups, 'vehicle_types') as $row) { + $id = $this->nullableInt($row['id'] ?? null); + if ($id !== null) { + $vehicleTypeIds[$id] = $id; + } + } + $vehicleTypeIds = array_values($vehicleTypeIds); + } + if ($vehicleTypeIds === []) { + $vehicleTypeIds[] = null; + } + + $maxStates = (int)($payload['max_states'] ?? 2048); + $maxStates = max(1, min(2048, $maxStates)); + $reg = trim((string)($payload['reg'] ?? $defaults['reg'] ?? 'TEST123')); + if ($reg === '') { + $reg = 'TEST123'; + } + $customerNumber = array_key_exists('customer_number', $payload) + ? $this->nullableInt($payload['customer_number']) + : $this->nullableInt($defaults['customer_number'] ?? null); + + $flow = new selfserve_wash_flow(); + $outcomes = []; + $warnings = []; + $truncated = false; + $stateCount = 0; + $terminalPathCount = 0; + $questionIds = []; + $pathSampleLimit = max(1, min(200, (int)($payload['path_sample_limit'] ?? 100))); + $paths = []; + + foreach ($vehicleTypeIds as $scenarioVehicleTypeId) { + $remainingStates = $maxStates - $stateCount; + if ($remainingStates <= 0) { + $truncated = true; + break; + } + + $scenarioScope = [ + 'department_id' => $departmentId, + 'department' => $this->labelFor('departments', $departmentId, $lookups), + 'lane_id' => $laneId, + 'lane' => $this->labelFor('lanes', $laneId, $lookups), + 'vehicle_type_id' => $scenarioVehicleTypeId, + 'vehicle_type' => $scenarioVehicleTypeId === null ? 'Auto' : $this->labelFor('vehicle_types', $scenarioVehicleTypeId, $lookups), + 'registration' => $reg, + 'customer_number' => $customerNumber, + 'config_source' => $configSource, + 'config_version_id' => $versionId, + 'hardware_mode' => $hardwareMode, + ]; + $simulate = function (array $answerOverrides) use ( + $flow, + $departmentId, + $laneId, + $reg, + $customerNumber, + $scenarioVehicleTypeId, + $configSource, + $graphConfig, + $versionId, + $includeHardware, + $hardwareMode, + $lookups, + $gatewayWorkspace, + $graph + ): array { + return $flow->previewStudioSimulation( + $departmentId, + $laneId, + $reg, + $customerNumber, + $scenarioVehicleTypeId, + [ + 'mode' => 'full_dry_run', + 'config_source' => $configSource, + 'config_payload' => $graphConfig, + 'config_version_id' => $versionId, + 'answer_overrides' => $answerOverrides, + 'include_hardware' => $includeHardware, + 'hardware_mode' => $hardwareMode, + 'lookups' => $lookups, + 'gateway_workspace' => $gatewayWorkspace, + 'graph' => $graph, + ], + ); + }; + + $projection = $this->projectPathOutcomesFromSimulator($simulate, [ + 'scope' => $scenarioScope, + 'max_states' => $remainingStates, + 'path_sample_limit' => max(0, $pathSampleLimit - count($paths)), + ]); + 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) { + $paths[] = $path; + } + } + foreach ((array)($projection['warnings'] ?? []) as $warning) { + $warnings[] = (string)$warning; + } + $truncated = $truncated || (bool)($projection['truncated'] ?? false); + $stateCount += (int)($projection['summary']['state_count'] ?? 0); + $terminalPathCount += (int)($projection['summary']['terminal_path_count'] ?? 0); + foreach ((array)($projection['summary']['question_ids'] ?? []) as $questionId) { + $questionIds[(int)$questionId] = true; + } + } + + 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' => [ + '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' => count($vehicleTypeIds), + 'registration' => $reg, + 'customer_number' => $customerNumber, + 'config_source' => $configSource, + 'config_version_id' => $versionId, + 'hardware_mode' => $hardwareMode, + 'max_states' => $maxStates, + ], + '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' => array_values($outcomes), + 'paths' => array_values($paths), + 'warnings' => array_values(array_unique($warnings)), + 'truncated' => $truncated, + ]; + } + + /** + * @param callable(array):array $simulate + * @param array $options + * @return array + */ + public function projectPathOutcomesFromSimulator(callable $simulate, array $options = []): array + { + $maxStates = (int)($options['max_states'] ?? 2048); + $maxStates = max(1, min(2048, $maxStates)); + $sampleLimit = max(1, min(10, (int)($options['sample_limit'] ?? 5))); + $pathSampleLimit = max(0, min(200, (int)($options['path_sample_limit'] ?? 100))); + $scope = is_array($options['scope'] ?? null) ? (array)$options['scope'] : []; + $stack = [[ + 'answers' => [], + 'chain' => [], + ]]; + $seenStates = []; + $groups = []; + $paths = []; + $stateCount = 0; + $terminalPathCount = 0; + $questionIds = []; + $truncated = false; + + while ($stack !== []) { + if ($stateCount >= $maxStates) { + $truncated = true; + break; + } + + $state = array_pop($stack); + $answers = is_array($state['answers'] ?? null) ? (array)$state['answers'] : []; + ksort($answers, SORT_NUMERIC); + $stateKey = $this->stableJson($answers); + if (isset($seenStates[$stateKey])) { + continue; + } + $seenStates[$stateKey] = true; + $stateCount++; + + $simulation = $simulate($this->pathAnswerOverrides($answers)); + $nextQuestion = $this->nextPathQuestion($simulation, $answers); + if ($nextQuestion !== null) { + $questionId = (int)($nextQuestion['id'] ?? 0); + if ($questionId > 0) { + $questionIds[$questionId] = true; + foreach ([false, true] as $answerValue) { + $nextAnswers = $answers; + $nextAnswers[$questionId] = $answerValue; + ksort($nextAnswers, SORT_NUMERIC); + $nextChain = is_array($state['chain'] ?? null) ? array_values((array)$state['chain']) : []; + $nextChain[] = [ + 'question_id' => $questionId, + 'question' => (string)($nextQuestion['label'] ?? $nextQuestion['question'] ?? ('Question ' . $questionId)), + 'node_id' => (string)($nextQuestion['node_id'] ?? ('question:' . $questionId)), + 'answer' => $answerValue, + 'answer_label' => $answerValue ? 'Yes' : 'No', + ]; + $stack[] = [ + 'answers' => $nextAnswers, + 'chain' => $nextChain, + ]; + } + } + continue; + } + + $terminalPathCount++; + $chain = is_array($state['chain'] ?? null) ? (array)$state['chain'] : []; + $this->addPathOutcomeGroup($groups, $simulation, $chain, $scope, $sampleLimit); + if ($pathSampleLimit > 0 && count($paths) < $pathSampleLimit) { + $paths[] = $this->pathResultFromSimulation($simulation, $chain, $scope); + } + } + + $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, + ]; + } + /** * @param array $payload * @return array @@ -989,6 +1326,83 @@ class selfserve_studio_graph } } + /** + * @param array> $edges + * @param array> $actions + * @param array $workspace + */ + private function appendActionRelayEdges(array &$edges, array $actions, array $workspace): void + { + foreach ($actions as $action) { + if (!is_array($action)) { + continue; + } + $normalizedAction = selfserve_studio_actions::normalize($action); + $actionId = (int)($normalizedAction['id'] ?? 0); + if ($actionId <= 0) { + continue; + } + + $relayRole = $this->normalizeServiceName(selfserve_studio_actions::relayRoleForOperation((string)$normalizedAction['operation'])); + if ($relayRole === '' || $relayRole === 'ACTION') { + continue; + } + + $laneId = (int)($normalizedAction['lane'] ?? 0); + foreach ($this->actionRelayTargets($workspace, $relayRole, $laneId) as $target) { + $edges[] = $this->edge( + 'action-relay:' . $actionId . ':' . $target['relay_id'] . ':' . $relayRole . ':' . $target['lane_id'], + 'action:' . $actionId, + 'relay:' . $target['relay_id'], + 'action_relay', + $relayRole + ); + } + } + } + + /** + * @param array $workspace + * @return array + */ + private function actionRelayTargets(array $workspace, string $relayRole, int $actionLaneId): array + { + $targets = []; + $seen = []; + foreach ((array)($workspace['lanes'] ?? []) as $lane) { + if (!is_array($lane)) { + continue; + } + $laneId = (int)($lane['id'] ?? 0); + if ($laneId <= 0 || ($actionLaneId > 0 && $laneId !== $actionLaneId)) { + continue; + } + + foreach ((array)($lane['relay_slots'] ?? []) as $slot) { + if (!is_array($slot)) { + continue; + } + $slotRole = $this->normalizeServiceName($slot['slot'] ?? $slot['role'] ?? $slot['service'] ?? ''); + $relayId = trim((string)($slot['relay_id'] ?? '')); + if ($relayId === '' || $slotRole !== $relayRole) { + continue; + } + + $key = $laneId . ':' . $relayRole . ':' . $relayId; + if (isset($seen[$key])) { + continue; + } + $seen[$key] = true; + $targets[] = [ + 'relay_id' => $relayId, + 'lane_id' => $laneId, + ]; + } + } + + return $targets; + } + /** * @param array $config * @param array $operation @@ -1324,6 +1738,12 @@ class selfserve_studio_graph 'operator' => 'ALL', 'children' => [$expression], ]; + } elseif (($expression['type'] ?? 'group') !== 'group') { + $expression = [ + 'type' => 'group', + 'operator' => 'ALL', + 'children' => [$expression], + ]; } if ($disconnect) { $rows[$index]['expression'] = $this->removeExpressionPredicate($expression, $subjectType, $subjectId); @@ -2067,6 +2487,58 @@ class selfserve_studio_graph }; return $subjectLabel . ' ' . strtolower(str_replace('_', ' ', $operator)); } + if (in_array($type, ['branch', 'if', 'if_else'], true)) { + $branches = is_array($expression['branches'] ?? null) ? array_values((array)$expression['branches']) : []; + if ($branches === []) { + return 'No if/else clauses'; + } + + $parts = []; + foreach (array_slice($branches, 0, 3) as $index => $branch) { + if (!is_array($branch)) { + continue; + } + $kind = strtolower((string)($branch['kind'] ?? $branch['type'] ?? $branch['operator'] ?? ($index === 0 ? 'if' : 'else_if'))); + $isElse = (bool)($branch['else'] ?? false) || in_array($kind, ['else', 'default'], true); + $label = $isElse ? 'Else' : ($index === 0 ? 'If' : 'Else if'); + $when = !$isElse && is_array($branch['when'] ?? null) ? $this->expressionSummary((array)$branch['when']) : ''; + $then = is_array($branch['then'] ?? null) ? $this->expressionSummary((array)$branch['then']) : 'No result'; + $parts[] = trim($label . ($when === '' ? '' : ' ' . $when) . ' then ' . $then); + } + if (count($branches) > 3) { + $parts[] = '+' . (count($branches) - 3) . ' more'; + } + + return implode('; ', $parts); + } + if ($type === 'case') { + $subjectType = strtolower((string)($expression['subject_type'] ?? $expression['object_type'] ?? '')); + $subjectId = (int)($expression['subject_id'] ?? $expression['object_id'] ?? 0); + $subjectLabel = match ($subjectType) { + 'question' => 'Question ' . $subjectId, + 'condition' => 'Condition ' . $subjectId, + default => 'Unknown subject', + }; + $cases = is_array($expression['cases'] ?? null) ? array_values((array)$expression['cases']) : []; + if ($cases === []) { + return 'Case ' . $subjectLabel . ': no clauses'; + } + + $parts = []; + foreach (array_slice($cases, 0, 3) as $case) { + if (!is_array($case)) { + continue; + } + $value = $this->caseValueLabel($case['value'] ?? null); + $then = is_array($case['then'] ?? null) ? $this->expressionSummary((array)$case['then']) : 'No result'; + $parts[] = $value . ' then ' . $then; + } + if (count($cases) > 3) { + $parts[] = '+' . (count($cases) - 3) . ' more'; + } + + return 'Case ' . $subjectLabel . ': ' . implode('; ', $parts); + } $operator = strtoupper((string)($expression['operator'] ?? $expression['mode'] ?? 'ALL')); if (!in_array($operator, ['ALL', 'ANY'], true)) { @@ -2117,6 +2589,49 @@ class selfserve_studio_graph $edges[] = $edge; return; } + if (in_array($type, ['branch', 'if', 'if_else'], true)) { + foreach ((array)($expression['branches'] ?? []) as $index => $branch) { + if (!is_array($branch)) { + continue; + } + if (is_array($branch['when'] ?? null)) { + $this->appendExpressionEdges($edges, $targetNodeId, $ownerConditionId, (array)$branch['when'], $path . '.b' . $index . '.when'); + } + if (is_array($branch['then'] ?? null)) { + $this->appendExpressionEdges($edges, $targetNodeId, $ownerConditionId, (array)$branch['then'], $path . '.b' . $index . '.then'); + } + } + if (is_array($expression['default'] ?? null)) { + $this->appendExpressionEdges($edges, $targetNodeId, $ownerConditionId, (array)$expression['default'], $path . '.default'); + } + return; + } + if ($type === 'case') { + $subjectType = strtolower((string)($expression['subject_type'] ?? $expression['object_type'] ?? '')); + $subjectId = (int)($expression['subject_id'] ?? $expression['object_id'] ?? 0); + if (in_array($subjectType, ['question', 'condition'], true) && $subjectId > 0) { + $edge = $this->edge( + 'expression:' . $ownerConditionId . ':' . $subjectType . ':' . $subjectId . ':' . substr(md5($path . '.case'), 0, 8), + $subjectType . ':' . $subjectId, + $targetNodeId, + 'condition_expression', + 'CASE' + ); + $edge['data']['subject_type'] = $subjectType; + $edge['data']['subject_id'] = $subjectId; + $edge['data']['condition_id'] = $ownerConditionId; + $edges[] = $edge; + } + foreach ((array)($expression['cases'] ?? []) as $index => $case) { + if (is_array($case) && is_array($case['then'] ?? null)) { + $this->appendExpressionEdges($edges, $targetNodeId, $ownerConditionId, (array)$case['then'], $path . '.c' . $index . '.then'); + } + } + if (is_array($expression['default'] ?? null)) { + $this->appendExpressionEdges($edges, $targetNodeId, $ownerConditionId, (array)$expression['default'], $path . '.default'); + } + return; + } $children = is_array($expression['children'] ?? null) ? array_values((array)$expression['children']) : []; foreach ($children as $index => $child) { @@ -2149,6 +2664,64 @@ class selfserve_studio_graph 'operator' => $operator, ]; } + if (in_array($type, ['branch', 'if', 'if_else'], true)) { + $branches = []; + foreach ((array)($expression['branches'] ?? []) as $index => $branch) { + if (!is_array($branch)) { + continue; + } + $kind = strtolower((string)($branch['kind'] ?? $branch['type'] ?? $branch['operator'] ?? ($index === 0 ? 'if' : 'else_if'))); + $isElse = (bool)($branch['else'] ?? false) || in_array($kind, ['else', 'default'], true); + $normalized = [ + 'kind' => $isElse ? 'else' : ($index === 0 ? 'if' : 'else_if'), + 'then' => $this->normalizeExpressionNode($branch['then'] ?? $this->emptyV2Expression()), + ]; + if ($isElse) { + $normalized['else'] = true; + } else { + $normalized['when'] = $this->normalizeExpressionNode($branch['when'] ?? $this->emptyV2Expression()); + } + $branches[] = $normalized; + } + + $normalizedExpression = [ + 'type' => 'branch', + 'operator' => 'IF_ELSE', + 'branches' => $branches, + ]; + if (is_array($expression['default'] ?? null)) { + $normalizedExpression['default'] = $this->normalizeExpressionNode((array)$expression['default']); + } + return $normalizedExpression; + } + if ($type === 'case') { + $subjectType = strtolower((string)($expression['subject_type'] ?? $expression['object_type'] ?? '')); + if (!in_array($subjectType, ['question', 'condition'], true)) { + $subjectType = 'question'; + } + $cases = []; + foreach ((array)($expression['cases'] ?? []) as $case) { + if (!is_array($case)) { + continue; + } + $cases[] = [ + 'value' => $case['value'] ?? null, + 'then' => $this->normalizeExpressionNode($case['then'] ?? $this->emptyV2Expression()), + ]; + } + + $normalizedExpression = [ + 'type' => 'case', + 'operator' => 'CASE', + 'subject_type' => $subjectType, + 'subject_id' => (int)($expression['subject_id'] ?? $expression['object_id'] ?? 0), + 'cases' => $cases, + ]; + if (is_array($expression['default'] ?? null)) { + $normalizedExpression['default'] = $this->normalizeExpressionNode((array)$expression['default']); + } + return $normalizedExpression; + } $operator = strtoupper((string)($expression['operator'] ?? $expression['mode'] ?? 'ALL')); if (!in_array($operator, ['ALL', 'ANY'], true)) { @@ -2178,6 +2751,34 @@ class selfserve_studio_graph return strtolower((string)($expression['subject_type'] ?? '')) === $subjectType && (int)($expression['subject_id'] ?? 0) === $subjectId; } + if (in_array($type, ['branch', 'if', 'if_else'], true)) { + foreach ((array)($expression['branches'] ?? []) as $branch) { + if (!is_array($branch)) { + continue; + } + if (is_array($branch['when'] ?? null) && $this->expressionHasPredicate((array)$branch['when'], $subjectType, $subjectId)) { + return true; + } + if (is_array($branch['then'] ?? null) && $this->expressionHasPredicate((array)$branch['then'], $subjectType, $subjectId)) { + return true; + } + } + return is_array($expression['default'] ?? null) + && $this->expressionHasPredicate((array)$expression['default'], $subjectType, $subjectId); + } + if ($type === 'case') { + if (strtolower((string)($expression['subject_type'] ?? '')) === $subjectType + && (int)($expression['subject_id'] ?? 0) === $subjectId) { + return true; + } + foreach ((array)($expression['cases'] ?? []) as $case) { + if (is_array($case) && is_array($case['then'] ?? null) && $this->expressionHasPredicate((array)$case['then'], $subjectType, $subjectId)) { + return true; + } + } + return is_array($expression['default'] ?? null) + && $this->expressionHasPredicate((array)$expression['default'], $subjectType, $subjectId); + } foreach ((array)($expression['children'] ?? []) as $child) { if (is_array($child) && $this->expressionHasPredicate((array)$child, $subjectType, $subjectId)) { @@ -2199,6 +2800,38 @@ class selfserve_studio_graph ? $this->emptyV2Expression() : $expression; } + if (in_array(strtolower((string)($expression['type'] ?? 'group')), ['branch', 'if', 'if_else'], true)) { + foreach ((array)($expression['branches'] ?? []) as $index => $branch) { + if (!is_array($branch)) { + continue; + } + if (is_array($branch['when'] ?? null)) { + $expression['branches'][$index]['when'] = $this->removeExpressionPredicate((array)$branch['when'], $subjectType, $subjectId); + } + if (is_array($branch['then'] ?? null)) { + $expression['branches'][$index]['then'] = $this->removeExpressionPredicate((array)$branch['then'], $subjectType, $subjectId); + } + } + if (is_array($expression['default'] ?? null)) { + $expression['default'] = $this->removeExpressionPredicate((array)$expression['default'], $subjectType, $subjectId); + } + return $expression; + } + if (($expression['type'] ?? 'group') === 'case') { + if (strtolower((string)($expression['subject_type'] ?? '')) === $subjectType + && (int)($expression['subject_id'] ?? 0) === $subjectId) { + return $this->emptyV2Expression(); + } + foreach ((array)($expression['cases'] ?? []) as $index => $case) { + if (is_array($case) && is_array($case['then'] ?? null)) { + $expression['cases'][$index]['then'] = $this->removeExpressionPredicate((array)$case['then'], $subjectType, $subjectId); + } + } + if (is_array($expression['default'] ?? null)) { + $expression['default'] = $this->removeExpressionPredicate((array)$expression['default'], $subjectType, $subjectId); + } + return $expression; + } $children = []; foreach ((array)($expression['children'] ?? []) as $child) { @@ -2231,6 +2864,20 @@ class selfserve_studio_graph ]; } + private function caseValueLabel(mixed $value): string + { + if ($value === true) { + return 'true'; + } + if ($value === false) { + return 'false'; + } + if ($value === null) { + return 'unanswered'; + } + return (string)$value; + } + /** * @param array> $edges * @param array $row @@ -2463,9 +3110,24 @@ class selfserve_studio_graph { $task['services'] = $this->normalizeServiceList($task['services'] ?? []); $task['buttons'] = $this->normalizeArrayPayload($task['buttons'] ?? []); + $task['attachments'] = is_array($task['attachments'] ?? null) ? array_values($task['attachments']) : []; return $task; } + /** + * @param array $config + * @return array + */ + private function withTaskAttachments(array $config): array + { + if (!is_array($config['tasks'] ?? null)) { + return $config; + } + + $config['tasks'] = (new selfserve_task_attachment_payloads())->attachToTasks(array_values((array)$config['tasks'])); + return $config; + } + /** * @param array $workspace * @return array> @@ -2667,6 +3329,375 @@ class selfserve_studio_graph return strtoupper(trim((string)$value)); } + /** + * @param array $answers + * @return array + */ + private function pathAnswerOverrides(array $answers): array + { + ksort($answers, SORT_NUMERIC); + $overrides = []; + foreach ($answers as $questionId => $answer) { + if ($answer !== true && $answer !== false) { + continue; + } + $overrides[] = [ + 'question_id' => (int)$questionId, + 'value' => $answer, + ]; + } + return $overrides; + } + + /** + * @param array $simulation + * @param array $answers + * @return array|null + */ + private function nextPathQuestion(array $simulation, array $answers): ?array + { + $debugQuestions = is_array($simulation['debug']['questions'] ?? null) ? (array)$simulation['debug']['questions'] : []; + foreach ($debugQuestions as $question) { + if (!is_array($question)) { + continue; + } + $questionId = (int)($question['id'] ?? 0); + if ($questionId <= 0 || array_key_exists($questionId, $answers)) { + continue; + } + $visible = !array_key_exists('visible', $question) || (bool)$question['visible'] === true; + $answer = $question['answer'] ?? null; + if ($visible && $answer !== true && $answer !== false) { + return $question; + } + } + + $visibleQuestions = is_array($simulation['questions'] ?? null) ? (array)$simulation['questions'] : []; + foreach ($visibleQuestions as $question) { + if (!is_array($question)) { + continue; + } + $questionId = (int)($question['id'] ?? 0); + if ($questionId <= 0 || array_key_exists($questionId, $answers)) { + continue; + } + $answer = $question['answer'] ?? null; + if ($answer !== true && $answer !== false) { + return [ + 'id' => $questionId, + 'label' => (string)($question['question'] ?? ('Question ' . $questionId)), + 'node_id' => 'question:' . $questionId, + 'answer' => $answer, + 'visible' => true, + ]; + } + } + + return null; + } + + /** + * @param array> $groups + * @param array $simulation + * @param array> $chain + * @param array $scope + */ + private function addPathOutcomeGroup(array &$groups, array $simulation, array $chain, array $scope, int $sampleLimit): void + { + $tasks = $this->pathActiveTasks($simulation); + $services = $this->pathServices($simulation, $tasks); + $signals = $this->pathSignals($simulation); + $allowed = (bool)($simulation['allowed'] ?? false); + $key = $this->stableJson([ + 'scope' => $this->pathScopeKey($scope), + 'allowed' => $allowed, + 'services' => $services, + 'tasks' => array_map(static fn(array $task): array => [ + 'id' => (int)($task['id'] ?? 0), + 'services' => (array)($task['services'] ?? []), + ], $tasks), + 'signals' => $signals, + ]); + + if (!isset($groups[$key])) { + $groups[$key] = [ + 'path_count' => 0, + 'allowed' => $allowed, + 'services' => $services, + 'tasks' => $tasks, + 'signals' => $signals, + 'sample_chains' => [], + 'scopes' => [], + 'node_ids' => [], + 'summary' => $this->pathOutcomeSummary($allowed, $services, $tasks, $signals), + ]; + } + + $groups[$key]['path_count'] = (int)$groups[$key]['path_count'] + 1; + $scopeKey = $this->stableJson($this->pathScopeKey($scope)); + $groups[$key]['scopes'][$scopeKey] = $scope; + if (count((array)$groups[$key]['sample_chains']) < $sampleLimit) { + $groups[$key]['sample_chains'][] = [ + 'scope' => $scope, + 'answers' => array_values($chain), + ]; + } + + foreach ($this->pathNodeIds($chain, $tasks, $signals) as $nodeId) { + $groups[$key]['node_ids'][$nodeId] = true; + } + } + + /** + * @param array $simulation + * @param array> $chain + * @param array $scope + * @return array + */ + private function pathResultFromSimulation(array $simulation, array $chain, array $scope): array + { + $tasks = $this->pathActiveTasks($simulation); + $services = $this->pathServices($simulation, $tasks); + $signals = $this->pathSignals($simulation); + $allowed = (bool)($simulation['allowed'] ?? false); + + return [ + 'id' => '', + 'result' => $allowed ? 'Allowed' : 'Blocked', + 'summary' => $this->pathOutcomeSummary($allowed, $services, $tasks, $signals), + 'allowed' => $allowed, + 'services' => $services, + 'tasks' => $tasks, + 'signals' => $signals, + 'task_count' => count($tasks), + 'signal_count' => count($signals), + 'answers' => array_values($chain), + 'scope' => $scope, + 'node_ids' => $this->pathNodeIds($chain, $tasks, $signals), + ]; + } + + /** + * @param array> $chain + * @param array> $tasks + * @param array> $signals + * @return array + */ + private function pathNodeIds(array $chain, array $tasks, array $signals): array + { + $nodeIds = []; + foreach ($chain as $answer) { + if (is_array($answer) && trim((string)($answer['node_id'] ?? '')) !== '') { + $nodeIds[(string)$answer['node_id']] = true; + } + } + foreach ($tasks as $task) { + if (trim((string)($task['node_id'] ?? '')) !== '') { + $nodeIds[(string)$task['node_id']] = true; + } + } + foreach ($signals as $signal) { + foreach (['target_binding', 'target_relay_node_id', 'target_gateway_node_id'] as $field) { + if (trim((string)($signal[$field] ?? '')) !== '') { + $nodeIds[(string)$signal[$field]] = true; + } + } + } + + $nodeIds = array_keys($nodeIds); + sort($nodeIds); + return $nodeIds; + } + + /** + * @param array> $groups + * @return array> + */ + private function finalizePathOutcomeGroups(array $groups): array + { + $outcomes = []; + foreach ($groups as $group) { + $scopes = array_values((array)($group['scopes'] ?? [])); + $nodeIds = array_values(array_keys((array)($group['node_ids'] ?? []))); + sort($nodeIds); + $outcomes[] = [ + 'id' => '', + 'summary' => (string)($group['summary'] ?? ''), + 'path_count' => (int)($group['path_count'] ?? 0), + 'allowed' => (bool)($group['allowed'] ?? false), + 'services' => array_values((array)($group['services'] ?? [])), + 'tasks' => array_values((array)($group['tasks'] ?? [])), + 'signals' => array_values((array)($group['signals'] ?? [])), + 'sample_chains' => array_values((array)($group['sample_chains'] ?? [])), + 'scopes' => $scopes, + 'node_ids' => $nodeIds, + ]; + } + + 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); + return $outcomes; + } + + /** + * @param array $simulation + * @return array> + */ + private function pathActiveTasks(array $simulation): array + { + $tasks = []; + $debugTasks = is_array($simulation['debug']['tasks'] ?? null) ? (array)$simulation['debug']['tasks'] : []; + foreach ($debugTasks as $task) { + if (!is_array($task) || (bool)($task['active'] ?? false) !== true) { + continue; + } + $tasks[] = [ + 'id' => (int)($task['id'] ?? 0), + 'node_id' => (string)($task['node_id'] ?? ('task:' . (int)($task['id'] ?? 0))), + 'label' => (string)($task['label'] ?? $task['task'] ?? ('Task ' . (int)($task['id'] ?? 0))), + 'services' => $this->normalizeServiceList($task['services'] ?? []), + 'buttons' => $this->normalizeArrayPayload($task['buttons'] ?? []), + 'order_priority' => (int)($task['order_priority'] ?? 0), + ]; + } + if ($tasks !== []) { + return $tasks; + } + + foreach ((array)($simulation['tasks'] ?? []) as $task) { + if (!is_array($task)) { + continue; + } + $taskId = (int)($task['id'] ?? $task['task_id'] ?? 0); + $tasks[] = [ + 'id' => $taskId, + 'node_id' => 'task:' . $taskId, + 'label' => (string)($task['task'] ?? $task['label'] ?? ('Task ' . $taskId)), + 'services' => $this->normalizeServiceList($task['services'] ?? []), + 'buttons' => $this->normalizeArrayPayload($task['buttons'] ?? []), + 'order_priority' => (int)($task['order_priority'] ?? 0), + ]; + } + usort($tasks, static fn(array $a, array $b): int => ((int)($a['order_priority'] ?? 0) <=> (int)($b['order_priority'] ?? 0)) + ?: ((int)($a['id'] ?? 0) <=> (int)($b['id'] ?? 0))); + return $tasks; + } + + /** + * @param array $simulation + * @param array> $tasks + * @return array + */ + private function pathServices(array $simulation, array $tasks): array + { + $services = []; + foreach ((array)($simulation['allowed_services'] ?? []) as $service) { + $normalized = $this->normalizeServiceName($service); + if ($normalized !== '') { + $services[$normalized] = true; + } + } + foreach ($tasks as $task) { + foreach ($this->normalizeServiceList($task['services'] ?? []) as $service) { + $services[$service] = true; + } + } + $values = array_keys($services); + sort($values); + return $values; + } + + /** + * @param array $simulation + * @return array> + */ + private function pathSignals(array $simulation): array + { + $timeline = is_array($simulation['debug']['signal_timeline'] ?? null) + ? (array)$simulation['debug']['signal_timeline'] + : (is_array($simulation['debug']['hardware']['signal_timeline'] ?? null) ? (array)$simulation['debug']['hardware']['signal_timeline'] : []); + $signals = []; + foreach ($timeline as $index => $signal) { + if (!is_array($signal)) { + continue; + } + $signals[] = [ + 'sequence' => (int)($signal['sequence'] ?? ($index + 1)), + 'runtime_stage' => (string)($signal['runtime_stage'] ?? ''), + 'signal_type' => (string)($signal['signal_type'] ?? ''), + 'relay_role' => (string)($signal['relay_role'] ?? ''), + 'relay_id' => $signal['relay_id'] ?? null, + 'target_gateway_label' => $signal['target_gateway_label'] ?? null, + 'target_binding' => $signal['target_binding'] ?? null, + 'target_gateway_node_id' => $signal['target_gateway_node_id'] ?? null, + 'target_relay_node_id' => $signal['target_relay_node_id'] ?? null, + 'source' => (string)($signal['source'] ?? ''), + 'virtual' => (bool)($signal['virtual'] ?? false), + 'predicted_status' => (string)($signal['predicted_status'] ?? ''), + 'payload' => $this->sortStableValue(is_array($signal['payload'] ?? null) ? (array)$signal['payload'] : []), + 'skip_block_reason' => $signal['skip_block_reason'] ?? null, + ]; + } + return $signals; + } + + /** + * @param array $scope + * @return array + */ + private function pathScopeKey(array $scope): array + { + return [ + 'department_id' => $scope['department_id'] ?? null, + 'lane_id' => $scope['lane_id'] ?? null, + 'vehicle_type_id' => $scope['vehicle_type_id'] ?? null, + 'config_source' => $scope['config_source'] ?? null, + 'hardware_mode' => $scope['hardware_mode'] ?? null, + ]; + } + + /** + * @param array $services + * @param array> $tasks + * @param array> $signals + */ + private function pathOutcomeSummary(bool $allowed, array $services, array $tasks, array $signals): string + { + $serviceLabel = $services === [] ? 'No services' : implode(', ', $services); + $taskText = count($tasks) === 1 ? '1 task' : count($tasks) . ' tasks'; + $signalText = count($signals) === 1 ? '1 signal' : count($signals) . ' signals'; + return ($allowed ? 'Allowed' : 'Blocked') . ' / ' . $serviceLabel . ' / ' . $taskText . ' / ' . $signalText; + } + + private function stableJson(mixed $value): string + { + $json = json_encode($this->sortStableValue($value), JSON_UNESCAPED_UNICODE); + if ($json === false) { + return ''; + } + return $json; + } + + private function sortStableValue(mixed $value): mixed + { + if (!is_array($value)) { + return $value; + } + + $isList = array_keys($value) === range(0, count($value) - 1); + if (!$isList) { + ksort($value); + } + foreach ($value as $key => $item) { + $value[$key] = $this->sortStableValue($item); + } + return $value; + } + /** * @param array> $rows * @return array> diff --git a/services/nginx/app/modules/selfserve/classes/selfserve_task_attachment_payloads.php b/services/nginx/app/modules/selfserve/classes/selfserve_task_attachment_payloads.php new file mode 100644 index 00000000..4a6b2ea7 --- /dev/null +++ b/services/nginx/app/modules/selfserve/classes/selfserve_task_attachment_payloads.php @@ -0,0 +1,118 @@ +> $tasks + * @return array> + */ + public function attachToTasks(array $tasks): array + { + if ($tasks === []) { + return []; + } + + $taskIds = []; + foreach ($tasks as $task) { + if (!is_array($task)) { + continue; + } + $taskId = $this->taskObjectId($task); + if ($taskId !== null) { + $taskIds[] = $taskId; + } + } + + $attachmentsByTask = []; + if ($taskIds !== []) { + try { + $attachmentsByTask = (new attachments())->listMany(self::OBJECT_TYPE, $taskIds); + } catch (\Throwable) { + $attachmentsByTask = []; + } + } + + $store = new attachment_store(); + return array_values(array_map(function (array $task) use ($attachmentsByTask, $store): array { + $taskId = $this->taskObjectId($task); + $attachments = []; + + if ($taskId !== null && isset($attachmentsByTask[$taskId])) { + foreach ((array)$attachmentsByTask[$taskId] as $attachment) { + if (is_object($attachment)) { + $attachments[] = $this->formatAttachment($attachment, $store); + } + } + } elseif (isset($task['attachments']) && is_array($task['attachments'])) { + $attachments = array_values($task['attachments']); + } + + $task['attachments'] = $attachments; + return $task; + }, $tasks)); + } + + /** + * @param array $task + */ + private function taskObjectId(array $task): ?int + { + $taskId = (int)($task['id'] ?? $task['task_id'] ?? 0); + return $taskId > 0 ? $taskId : null; + } + + /** + * @return array + */ + private function formatAttachment(object $attachment, attachment_store $store): array + { + $content = $this->contentPayload($attachment->content ?? null); + $fileName = $content['document'] ?: $content['image'] ?: null; + + return [ + 'id' => isset($attachment->id) ? (int)$attachment->id : null, + 'object_type' => isset($attachment->object_type) ? (string)$attachment->object_type : self::OBJECT_TYPE, + 'object_id' => isset($attachment->object_id) ? (int)$attachment->object_id : null, + 'content' => $content, + 'download_link' => is_string($fileName) && trim($fileName) !== '' + ? $store->generateDirectDownloadUrl($fileName) + : null, + 'created_at' => isset($attachment->created_at) ? (string)$attachment->created_at : null, + 'updated_at' => isset($attachment->updated_at) ? (string)$attachment->updated_at : null, + ]; + } + + /** + * @return array{image:?string,document:?string,relation:mixed,other:mixed} + */ + private function contentPayload(mixed $content): array + { + $payload = is_object($content) && method_exists($content, 'toArray') + ? $content->toArray() + : (is_array($content) ? $content : (array)$content); + + $relation = $payload['relation'] ?? null; + if (is_object($relation) && method_exists($relation, 'toArray')) { + $relation = $relation->toArray(); + } elseif (is_object($relation)) { + $relation = (array)$relation; + } + + return [ + 'image' => isset($payload['image']) && is_string($payload['image']) ? $payload['image'] : null, + 'document' => isset($payload['document']) && is_string($payload['document']) ? $payload['document'] : null, + 'relation' => $relation, + 'other' => $payload['other'] ?? null, + ]; + } +} 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 c392b331..99d722ce 100644 --- a/services/nginx/app/modules/selfserve/classes/selfserve_wash_flow.php +++ b/services/nginx/app/modules/selfserve/classes/selfserve_wash_flow.php @@ -9,6 +9,7 @@ require_once WD . '/modules/selfserve/classes/selfserve_lane_command_arguments.p require_once WD . '/modules/selfserve/classes/selfserve_config_versioning.php'; require_once WD . '/modules/selfserve/classes/selfserve_studio_action_runner.php'; require_once WD . '/modules/selfserve/classes/selfserve_studio_actions.php'; +require_once WD . '/modules/selfserve/classes/selfserve_task_attachment_payloads.php'; require_once WD . '/modules/selfserve/helpers/selfserve_lane_command.php'; require_once WD . '/modules/selfserve/helpers/selfserve_lane_relay.php'; require_once WD . '/modules/selfserve/helpers/selfserve_lane_services.php'; @@ -186,17 +187,41 @@ class selfserve_wash_flow implements selfserve_wash_flow_i 'reg' => $effectiveReg, 'customer_number' => $customerNumber, ]); + $actionContext = [ + 'lane_id' => $laneId, + 'reg' => $effectiveReg, + 'customer_number' => $customerNumber, + 'session_id' => (int)$session->id, + 'source_payload' => $payload, + ]; + try { + if ($effectiveReg !== '') { + $actionSnapshot = $this->buildEligibilitySnapshot( + $laneId, + $effectiveReg, + $customerNumber, + $session->vehicle_type_id->value() === null ? null : (int)$session->vehicle_type_id->value(), + ['config_source' => 'published'] + ); + if (is_array($actionSnapshot['evaluation_trace']['condition_results'] ?? null)) { + $actionContext['condition_results'] = (array)$actionSnapshot['evaluation_trace']['condition_results']; + } + if (is_array($actionSnapshot['evaluation_trace']['visibility_condition_results'] ?? null)) { + $actionContext['visibility_condition_results'] = (array)$actionSnapshot['evaluation_trace']['visibility_condition_results']; + } + $actionContext['allowed_services'] = (array)($actionSnapshot['allowed_services'] ?? []); + $actionContext['vehicle_type_id'] = $actionSnapshot['vehicle_type_id'] ?? null; + $actionContext['product'] = $actionSnapshot['vehicle_type_id'] ?? null; + $actionContext['machine_type_id'] = $actionSnapshot['machine_type']['id'] ?? null; + } + } catch (\Throwable) { + // Action execution should stay best-effort even when preview context cannot be rebuilt. + } (new selfserve_studio_action_runner())->executeForLaneEvent( $lane, selfserve_studio_actions::EVENT_MACHINE_START_TRIGGERED, selfserve_studio_actions::MODE_MACHINE, - [ - 'lane_id' => $laneId, - 'reg' => $effectiveReg, - 'customer_number' => $customerNumber, - 'session_id' => (int)$session->id, - 'source_payload' => $payload, - ] + $actionContext ); $this->enableCleanerRelayForStartedWash($lane); @@ -310,6 +335,7 @@ 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)); + $tasks = (new selfserve_task_attachment_payloads())->attachToTasks($tasks); $events = array_map(function (array $row): array { return [ @@ -491,27 +517,17 @@ class selfserve_wash_flow implements selfserve_wash_flow_i $serviceExpressionTrace = []; } - $tasks = $this->loadTasks($departmentId, $laneId, $vehicleTypeId, $machineTypeId, $publishedConfigPayload); + $tasks = (new selfserve_task_attachment_payloads())->attachToTasks( + $this->loadTasks($departmentId, $laneId, $vehicleTypeId, $machineTypeId, $publishedConfigPayload) + ); $activeTasks = []; $taskGateTrace = []; $conditionIds = array_map(static fn(array $condition): int => (int)($condition['id'] ?? 0), $conditions); foreach ($tasks as $task) { $gateId = $this->nullableInt($task['condition_id'] ?? null); - $typedGateType = selfserve_task_gate_type::tryFrom((string)($task['gate_type'] ?? '')); - $typedGateRefId = $this->nullableInt($task['gate_ref_id'] ?? null); - - if ($typedGateType === null) { - if ($gateId === null) { - $typedGateType = selfserve_task_gate_type::ALWAYS; - $typedGateRefId = null; - } elseif (in_array($gateId, $conditionIds, true)) { - $typedGateType = selfserve_task_gate_type::CONDITION; - $typedGateRefId = $gateId; - } else { - $typedGateType = selfserve_task_gate_type::QUESTION; - $typedGateRefId = $gateId; - } - } + $resolvedGate = $this->resolveTaskGate($task, $conditionIds); + $typedGateType = $resolvedGate['gate_type']; + $typedGateRefId = $resolvedGate['gate_ref_id']; $gateSatisfied = $this->conditionEvaluator->taskGateSatisfiedTyped( $typedGateType->value, @@ -543,6 +559,7 @@ class selfserve_wash_flow implements selfserve_wash_flow_i 'services' => $this->normalizeServiceNames($this->normalizeJsonArray($task['services'] ?? null)), 'buttons' => $this->normalizeButtonList($task['buttons'] ?? null), 'dynamic_images_vehicle_type' => ($task['dynamic_images_vehicle_type'] ?? null) === null ? null : (int)$task['dynamic_images_vehicle_type'], + 'attachments' => $task['attachments'] ?? [], ]; } usort($activeTasks, static fn(array $a, array $b): int => $a['order_priority'] <=> $b['order_priority']); @@ -991,6 +1008,16 @@ class selfserve_wash_flow implements selfserve_wash_flow_i protected function buildDebugTasks(array $snapshot, array $tasks, array $lookups, array $gatewayWorkspace): array { $activeIds = array_flip(array_map(static fn(array $task): int => (int)($task['id'] ?? 0), (array)($snapshot['tasks'] ?? []))); + $activeAttachments = []; + foreach ((array)($snapshot['tasks'] ?? []) as $task) { + if (!is_array($task)) { + continue; + } + $taskId = (int)($task['id'] ?? $task['task_id'] ?? 0); + if ($taskId > 0 && is_array($task['attachments'] ?? null)) { + $activeAttachments[$taskId] = array_values($task['attachments']); + } + } $gateTrace = []; foreach ((array)($snapshot['evaluation_trace']['task_gates'] ?? []) as $trace) { if (is_array($trace)) { @@ -1016,10 +1043,12 @@ class selfserve_wash_flow implements selfserve_wash_flow_i $active = isset($activeIds[$taskId]); $gateType = (string)($trace['gate_type'] ?? $task['gate_type'] ?? selfserve_task_gate_type::ALWAYS->value); $gateRefId = $this->nullableInt($trace['gate_ref_id'] ?? $task['gate_ref_id'] ?? $task['condition_id'] ?? null); + $taskAttachments = is_array($task['attachments'] ?? null) ? array_values($task['attachments']) : ($activeAttachments[$taskId] ?? []); $items[] = [ 'id' => $taskId, 'node_id' => 'task:' . $taskId, 'label' => (string)($task['task'] ?? $this->debugLabel($lookups, 'tasks', $taskId, 'Task ' . $taskId)), + 'description' => (string)($task['description'] ?? ''), 'active' => $active, 'state' => $active ? 'active' : 'blocked', 'gate_type' => $gateType, @@ -1029,6 +1058,7 @@ class selfserve_wash_flow implements selfserve_wash_flow_i 'services' => $services, 'buttons' => $this->normalizeButtonList($task['buttons'] ?? null), 'dynamic_images_vehicle_type' => ($task['dynamic_images_vehicle_type'] ?? null) === null ? null : (int)$task['dynamic_images_vehicle_type'], + 'attachments' => $taskAttachments, 'relay_bindings' => $bindings, 'order_priority' => (int)($task['order_priority'] ?? 0), 'reason' => $active ? 'Task gate passed.' : 'Task gate did not pass.', @@ -2468,6 +2498,57 @@ class selfserve_wash_flow implements selfserve_wash_flow_i return $tasksObject->getLegacyTasksForLaneProduct($departmentId, $laneId, $vehicleTypeId); } + /** + * @param array $task + * @param array $conditionIds + * @return array{gate_type:selfserve_task_gate_type,gate_ref_id:int|null} + */ + protected function resolveTaskGate(array $task, array $conditionIds): array + { + $gateType = selfserve_task_gate_type::tryFrom(strtoupper(trim((string)($task['gate_type'] ?? '')))); + $gateRefId = $this->nullableInt($task['gate_ref_id'] ?? null); + $legacyGateId = $this->nullableInt($task['condition_id'] ?? null); + + if ( + $gateType === selfserve_task_gate_type::CONDITION + || $gateType === selfserve_task_gate_type::QUESTION + ) { + return [ + 'gate_type' => $gateType, + 'gate_ref_id' => $gateRefId ?? $legacyGateId, + ]; + } + + $shouldInferLegacyGate = $gateType === null + || ( + $gateType === selfserve_task_gate_type::ALWAYS + && $gateRefId === null + && $legacyGateId !== null + ); + + if ($shouldInferLegacyGate) { + $fallbackGateId = $gateRefId ?? $legacyGateId; + if ($fallbackGateId === null) { + return [ + 'gate_type' => selfserve_task_gate_type::ALWAYS, + 'gate_ref_id' => null, + ]; + } + + return [ + 'gate_type' => in_array($fallbackGateId, $conditionIds, true) + ? selfserve_task_gate_type::CONDITION + : selfserve_task_gate_type::QUESTION, + 'gate_ref_id' => $fallbackGateId, + ]; + } + + return [ + 'gate_type' => selfserve_task_gate_type::ALWAYS, + 'gate_ref_id' => null, + ]; + } + /** * @param array|null $publishedConfig */ 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 76bcd2bf..daacc3c8 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 @@ -192,6 +192,35 @@ trait selfserve_lane_command_t } } + protected function openExitPortForWashStop(): void + { + try { + $this->open(selfserve_lane_port::EXIT); + } catch (\Throwable $e) { + if ($this->isAmbiguousGatewayTimeout($e)) { + $this->reportWashStopExitTimeout($e); + return; + } + + throw $e; + } + } + + protected function reportWashStopExitTimeout(\Throwable $e): void + { + try { + $laneId = isset($this->id) ? (string)$this->id : 'unknown'; + error_log( + 'Self-serve STOP exit gate dispatch timed out for lane ' . + $laneId . + '; continuing wash stop because the gateway command may already have reached the relay: ' . + $e->getMessage() + ); + } catch (\Throwable) { + // Diagnostics must not block the user wash stop flow. + } + } + protected function runRelaySideEffectsForWashStart(selfserve_lane_command_arguments $arguments): void { if ($arguments->defer_relay_side_effects) { @@ -232,7 +261,74 @@ trait selfserve_lane_command_t */ protected function runPublishedStudioActions(string $event, string $washMode, array $context = []): array { - return (new selfserve_studio_action_runner())->executeForLaneEvent($this, $event, $washMode, $context); + return (new selfserve_studio_action_runner())->executeForLaneEvent( + $this, + $event, + $washMode, + $this->buildPublishedStudioActionContext($context) + ); + } + + /** + * @param array $context + * @return array + */ + protected function buildPublishedStudioActionContext(array $context): array + { + if (!array_key_exists('lane_id', $context)) { + $context['lane_id'] = (int)$this->id; + } + + $reg = trim((string)($context['reg'] ?? '')); + if ($reg === '' && method_exists($this, 'getLicensePlate')) { + $reg = trim((string)$this->getLicensePlate()); + if ($reg !== '') { + $context['reg'] = $reg; + } + } + + $customerNumber = $context['customer_number'] ?? null; + if (($customerNumber === null || (int)$customerNumber <= 0) && method_exists($this, 'getCustomerNumber')) { + $resolvedCustomerNumber = (int)$this->getCustomerNumber(); + if ($resolvedCustomerNumber > 0) { + $customerNumber = $resolvedCustomerNumber; + $context['customer_number'] = $resolvedCustomerNumber; + } + } + + if ($reg === '') { + return $context; + } + + try { + $preview = (new selfserve_wash_flow())->previewVehicleEligibility( + (int)$this->id, + $reg, + $customerNumber === null || (int)$customerNumber <= 0 ? null : (int)$customerNumber + ); + if (!isset($context['condition_results']) && is_array($preview['evaluation_trace']['condition_results'] ?? null)) { + $context['condition_results'] = (array)$preview['evaluation_trace']['condition_results']; + } + if (!isset($context['visibility_condition_results']) && is_array($preview['evaluation_trace']['visibility_condition_results'] ?? null)) { + $context['visibility_condition_results'] = (array)$preview['evaluation_trace']['visibility_condition_results']; + } + if (!isset($context['allowed_services']) && is_array($preview['allowed_services'] ?? null)) { + $context['allowed_services'] = (array)$preview['allowed_services']; + } + if (!isset($context['vehicle_type_id']) && array_key_exists('vehicle_type_id', $preview)) { + $context['vehicle_type_id'] = $preview['vehicle_type_id']; + } + if (!isset($context['product']) && array_key_exists('vehicle_type_id', $preview)) { + $context['product'] = $preview['vehicle_type_id']; + } + if (!isset($context['machine_type_id']) && is_array($preview['machine_type'] ?? null)) { + $context['machine_type_id'] = $preview['machine_type']['id'] ?? null; + } + } catch (\Throwable) { + // Studio actions remain best-effort for legacy command flows. + } + + return $context; } /** @@ -456,8 +552,9 @@ trait selfserve_lane_command_t 'machine_start_triggered' => $machine_start_triggered, ] ); - // Open the exit port - $this->open(selfserve_lane_port::EXIT); + // Open the exit port. Gateway timeouts are ambiguous because + // the relay may already have received the pulse. + $this->openExitPortForWashStop(); // Turn off relays in deterministic order after STOP $this->turnOffRelaysAfterStop(); // Log the lane stop event diff --git a/services/nginx/app/openapi.yaml b/services/nginx/app/openapi.yaml index 8af4bd93..ffcd98e5 100644 --- a/services/nginx/app/openapi.yaml +++ b/services/nginx/app/openapi.yaml @@ -5366,6 +5366,29 @@ paths: schema: $ref: '#/components/schemas/SelfserveStudioSimulationResponse' + /department/selfserve/studio/path-outcomes: + post: + tags: + - Self-Serve + summary: Project grouped self-serve studio question path outcomes + description: Enumerates feasible yes/no answer paths for the selected studio scope and groups terminal paths by resulting tasks, services, and dry-run signal timeline. No live hardware commands are sent. + operationId: projectSelfserveStudioPathOutcomes + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/SelfserveStudioPathOutcomesRequest' + responses: + '200': + description: Grouped path outcomes returned + content: + application/json: + schema: + $ref: '#/components/schemas/SelfserveStudioPathOutcomesResponse' + '422': + $ref: '#/components/responses/BadRequest' + /department/selfserve/studio/publish: post: tags: @@ -15589,6 +15612,196 @@ components: debug: $ref: '#/components/schemas/SelfserveStudioSimulationDebug' + SelfserveStudioPathOutcomesRequest: + type: object + required: [department] + properties: + department: + type: integer + lane_id: + type: integer + nullable: true + vehicle_type_id: + type: integer + nullable: true + config_source: + type: string + enum: [draft, published] + default: draft + hardware_mode: + type: string + enum: [studio, real, none] + default: studio + include_hardware: + type: boolean + default: true + max_states: + type: integer + minimum: 1 + maximum: 2048 + default: 2048 + + SelfserveStudioPathOutcomesResponse: + type: object + required: [scope, summary, outcomes, paths, warnings, truncated] + properties: + scope: + type: object + additionalProperties: true + summary: + type: object + required: [state_count, terminal_path_count, outcome_count, question_count, max_states, path_sample_count] + properties: + state_count: { type: integer } + terminal_path_count: { type: integer } + outcome_count: { type: integer } + question_count: { type: integer } + question_ids: + type: array + items: { type: integer } + max_states: { type: integer } + path_sample_count: { type: integer } + outcomes: + type: array + items: + $ref: '#/components/schemas/SelfserveStudioPathOutcome' + paths: + type: array + items: + $ref: '#/components/schemas/SelfserveStudioPathResult' + warnings: + type: array + items: { type: string } + truncated: + type: boolean + + SelfserveStudioPathOutcome: + type: object + required: [id, summary, path_count, allowed, services, tasks, signals, sample_chains, node_ids] + properties: + id: { type: string } + summary: { type: string } + path_count: { type: integer } + allowed: { type: boolean } + services: + type: array + items: + $ref: '#/components/schemas/SelfserveLaneService' + tasks: + type: array + items: + $ref: '#/components/schemas/SelfserveStudioPathTask' + signals: + type: array + items: + $ref: '#/components/schemas/SelfserveStudioPathSignal' + sample_chains: + type: array + items: + $ref: '#/components/schemas/SelfserveStudioPathSampleChain' + scopes: + type: array + items: + type: object + additionalProperties: true + node_ids: + type: array + items: { type: string } + + SelfserveStudioPathResult: + type: object + required: [id, result, summary, allowed, services, tasks, signals, task_count, signal_count, answers, scope, node_ids] + properties: + id: { type: string } + result: { type: string } + summary: { type: string } + allowed: { type: boolean } + services: + type: array + items: + $ref: '#/components/schemas/SelfserveLaneService' + tasks: + type: array + items: + $ref: '#/components/schemas/SelfserveStudioPathTask' + signals: + type: array + items: + $ref: '#/components/schemas/SelfserveStudioPathSignal' + task_count: { type: integer } + signal_count: { type: integer } + answers: + type: array + items: + $ref: '#/components/schemas/SelfserveStudioPathAnswer' + scope: + type: object + additionalProperties: true + node_ids: + type: array + items: { type: string } + + SelfserveStudioPathTask: + type: object + properties: + id: { type: integer } + node_id: { type: string } + label: { type: string } + services: + type: array + items: + $ref: '#/components/schemas/SelfserveLaneService' + buttons: + type: array + items: {} + order_priority: { type: integer } + + SelfserveStudioPathSignal: + type: object + properties: + sequence: { type: integer } + runtime_stage: { type: string } + signal_type: { type: string } + relay_role: { type: string } + relay_id: + type: string + nullable: true + target_gateway_label: + type: string + nullable: true + target_binding: + type: string + nullable: true + source: { type: string } + virtual: { type: boolean } + predicted_status: { type: string } + payload: + type: object + additionalProperties: true + skip_block_reason: + type: string + nullable: true + + SelfserveStudioPathSampleChain: + type: object + properties: + scope: + type: object + additionalProperties: true + answers: + type: array + items: + $ref: '#/components/schemas/SelfserveStudioPathAnswer' + + SelfserveStudioPathAnswer: + type: object + properties: + question_id: { type: integer } + question: { type: string } + node_id: { type: string } + answer: { type: boolean } + answer_label: { type: string } + SelfserveVehicleAllowedResponse: type: object properties: diff --git a/services/nginx/app/routes/departmentSelfserveStudioRoute.php b/services/nginx/app/routes/departmentSelfserveStudioRoute.php index e9d651b1..5bdf5674 100644 --- a/services/nginx/app/routes/departmentSelfserveStudioRoute.php +++ b/services/nginx/app/routes/departmentSelfserveStudioRoute.php @@ -132,6 +132,29 @@ class departmentSelfserveStudioRoute 'list_department_selfserve_vehicle_conditions' => 'Run the self-serve studio simulator', ]); + $this->post('/department/selfserve/studio/path-outcomes', function (): void { + global $response; + $user = $this->requireStudioUser('list_department_selfserve_vehicle_conditions'); + self::requireParameters(['department']); + $departmentId = (int)self::getParameter('department'); + $this->assertDepartmentAccess($user, $departmentId); + + try { + $result = (new selfserve_studio_graph())->projectPathOutcomes( + $departmentId, + self::getParametersAsArray(), + (int)$user->id, + $this->studioPermissions() + ); + (new logs_o())->add('selfserve_studio', $departmentId, 1, $user->id, 'PROJECT_STUDIO_PATH_OUTCOMES', 'Projected self-serve studio path outcomes'); + $response->success($result); + } catch (\Throwable $exception) { + $response->error($exception->getMessage(), 422); + } + }, [ + 'list_department_selfserve_vehicle_conditions' => 'Project grouped self-serve studio question path outcomes', + ]); + $this->post('/department/selfserve/studio/publish', function (): void { global $response; $user = $this->requireStudioUser('publish_department_selfserve_config_versions'); diff --git a/services/nginx/app/routes/moduleSelfServeRoute.php b/services/nginx/app/routes/moduleSelfServeRoute.php index 2b201a8f..1900c3e0 100644 --- a/services/nginx/app/routes/moduleSelfServeRoute.php +++ b/services/nginx/app/routes/moduleSelfServeRoute.php @@ -63,11 +63,11 @@ class moduleSelfServeRoute /** Modules > Self Serve > Lane > Wash > In-progress details */ $this->get('/modules/self-serve/lane/wash/in-progress', function () { global $response; - self::requirePermission('modules_selfserve_lane_wash_in_progress_view'); self::requireParameters(['lane_id']); $lane_id = (int)$this->getParameter('lane_id'); self::requireType($lane_id, self::type_int()); self::requireMinValue($lane_id, 1); + $customer_scope = $this->requireInProgressWashDetailsAccess(); $build_customer = static function (?int $customer_number): ?array { if ($customer_number === null || $customer_number <= 0) { @@ -170,13 +170,13 @@ class moduleSelfServeRoute || $lane_state->equals(\modules\selfserve\helpers\selfserve_lane_state::IN_WASH); if (!$in_progress) { - $response->success([ + $response->success($this->scopeInProgressWashResponseForCustomer([ 'lane_id' => $lane_id, 'in_progress' => false, 'session' => null, 'customer' => null, 'vehicle' => null, - ]); + ], $customer_scope)); return; } @@ -191,7 +191,7 @@ class moduleSelfServeRoute $customer = $build_customer($runtime_customer_number); $vehicle = $build_vehicle(null, $runtime_reg); - $response->success([ + $response->success($this->scopeInProgressWashResponseForCustomer([ 'lane_id' => $lane_id, 'in_progress' => true, 'session' => [ @@ -213,7 +213,7 @@ class moduleSelfServeRoute ], 'customer' => $customer, 'vehicle' => $vehicle, - ]); + ], $customer_scope)); return; } @@ -245,7 +245,7 @@ class moduleSelfServeRoute ]; $in_progress = in_array($status, $in_progress_statusses); - $response->success([ + $response->success($this->scopeInProgressWashResponseForCustomer([ 'lane_id' => $lane_id, 'status' => (string)$session->status->value(), 'in_progress' => $in_progress, @@ -269,10 +269,11 @@ class moduleSelfServeRoute ], 'customer' => $customer, 'vehicle' => $vehicle, - ]); + ], $customer_scope)); }, [ 'modules_selfserve_lane_wash_in_progress_view' => 'View customer and vehicle details for an in-progress self-serve wash on a lane', + 'list_own_department_selfserve_vehicle_conditions' => 'View in-progress self-serve wash details for the authenticated customer', ] ); @@ -1142,6 +1143,83 @@ class moduleSelfServeRoute ]); } + private function requireInProgressWashDetailsAccess(): ?int + { + global $response; + + $user = (new authentication())->get_user(); + if (!$user) { + $response->error('Authentication failed. Invalid or missing token.', 401); + } + + if (self::hasPermission('modules_selfserve_lane_wash_in_progress_view')) { + return null; + } + + if (self::hasPermission('list_own_department_selfserve_vehicle_conditions')) { + $customer_number = $this->resolveEffectiveCustomerNumber(); + if ($customer_number !== null && $customer_number > 0) { + return (int)$customer_number; + } + } + + $this->emitForbidden([ + 'modules_selfserve_lane_wash_in_progress_view', + 'list_own_department_selfserve_vehicle_conditions', + ]); + return null; + } + + /** + * Customers poll all visible lanes to restore their own active wash. Keep that + * poll successful without exposing another customer's session details. + * + * @param array $payload + * @return array + */ + protected function scopeInProgressWashResponseForCustomer(array $payload, ?int $customer_number): array + { + if ($customer_number === null || $customer_number <= 0 || ($payload['in_progress'] ?? false) !== true) { + return $payload; + } + + $session_customer_number = $this->extractInProgressWashCustomerNumber($payload); + if ($session_customer_number === $customer_number) { + return $payload; + } + + return [ + 'lane_id' => (int)($payload['lane_id'] ?? 0), + 'in_progress' => true, + 'session' => null, + 'customer' => null, + 'vehicle' => null, + ]; + } + + /** + * @param array $payload + */ + private function extractInProgressWashCustomerNumber(array $payload): ?int + { + $candidates = [ + $payload['session']['customer_number'] ?? null, + $payload['customer']['customer_number'] ?? null, + ]; + + foreach ($candidates as $candidate) { + if ($candidate === null || $candidate === '') { + continue; + } + $customer_number = (int)$candidate; + if ($customer_number > 0) { + return $customer_number; + } + } + + return null; + } + /** * @param array $status * @param array $extra diff --git a/services/nginx/app/tests/Api/SelfserveLaneWashInProgressApiTest.php b/services/nginx/app/tests/Api/SelfserveLaneWashInProgressApiTest.php new file mode 100644 index 00000000..8e6cd284 --- /dev/null +++ b/services/nginx/app/tests/Api/SelfserveLaneWashInProgressApiTest.php @@ -0,0 +1,76 @@ +get('/modules/self-serve/lane/wash/in-progress?lane_id=1'); + + $response + ->assertStatus(401) + ->assertMessage('Authentication failed. Invalid or missing token.'); +}); + +it('reports both elevated and customer self-serve permissions when lane polling is not allowed', function (): void { + $session = api_fixtures()->createUserSession([]); + + $response = api_client()->get( + '/modules/self-serve/lane/wash/in-progress?lane_id=1', + $session['headers'] + ); + + $response + ->assertStatus(403) + ->assertMissingPermissions([ + 'modules_selfserve_lane_wash_in_progress_view', + 'list_own_department_selfserve_vehicle_conditions', + ]); +}); + +it('allows customer self-serve permission to view their own in-progress wash details', function (): void { + $group = api_fixtures()->createGroup([], [ + 'list_own_department_selfserve_vehicle_conditions', + ]); + $scenario = api_fixtures()->createSelfServeScenario([ + 'customer' => [ + 'group_id' => $group['id'], + ], + ]); + $token = api_fixtures()->createAuthToken((int)$scenario['customer']['id']); + + $response = api_client()->get( + '/modules/self-serve/lane/wash/in-progress?lane_id=' . (int)$scenario['lane']['id'], + api_fixtures()->bearerHeaders($token) + ); + + $response + ->assertStatus(200) + ->assertSuccess(true); + + expect($response->data()['in_progress'] ?? null)->toBeTrue() + ->and($response->data()['session']['customer_number'] ?? null)->toBe((int)$scenario['customer']['customer_number']) + ->and($response->data()['vehicle']['reg'] ?? null)->toBe($scenario['vehicle']['reg']); +}); + +it('redacts another customers in-progress wash from customer self-serve lane polling', function (): void { + $scenario = api_fixtures()->createSelfServeScenario(); + $otherSession = api_fixtures()->createUserSession([ + 'list_own_department_selfserve_vehicle_conditions', + ]); + + $response = api_client()->get( + '/modules/self-serve/lane/wash/in-progress?lane_id=' . (int)$scenario['lane']['id'], + $otherSession['headers'] + ); + + $response + ->assertStatus(200) + ->assertSuccess(true); + + expect($response->data())->toMatchArray([ + 'lane_id' => (int)$scenario['lane']['id'], + 'in_progress' => true, + 'session' => null, + 'customer' => null, + 'vehicle' => null, + ]); +}); diff --git a/services/nginx/app/tests/Support/Api/ApiFixtures.php b/services/nginx/app/tests/Support/Api/ApiFixtures.php index 0db4f7bf..741179d7 100644 --- a/services/nginx/app/tests/Support/Api/ApiFixtures.php +++ b/services/nginx/app/tests/Support/Api/ApiFixtures.php @@ -1928,6 +1928,6 @@ final class ApiFixtures private function uniqueSuffix(): string { - return strtoupper(dechex(++self::$sequence)); + return strtoupper(dechex(time()) . dechex(getmypid()) . dechex(++self::$sequence)); } } diff --git a/services/nginx/app/tests/Unit/Bird/DepartmentGatesRelayOpenTest.php b/services/nginx/app/tests/Unit/Bird/DepartmentGatesRelayOpenTest.php index 086b9df2..74271a1c 100644 --- a/services/nginx/app/tests/Unit/Bird/DepartmentGatesRelayOpenTest.php +++ b/services/nginx/app/tests/Unit/Bird/DepartmentGatesRelayOpenTest.php @@ -16,7 +16,7 @@ final class DepartmentGatesRelayManagerFake extends edge_gateway_manager { } - public function dispatchRelaySwitch(int $departmentId, string $logicalRelayId, bool $on): array + public function dispatchRelaySwitch(int $departmentId, string $logicalRelayId, bool $on, array $actionContext = []): array { $this->switchCalls[] = [ 'department_id' => $departmentId, @@ -44,6 +44,10 @@ final class DepartmentGatesRelayOpenHarness extends department_gates_o $departmentProperty->set($departmentId); $this->department = $departmentProperty; + $nameProperty = new object_property('department_gates', -1, 'name', 'string'); + $nameProperty->set('Entry gate'); + $this->name = $nameProperty; + $configProperty = new object_property('department_gates', -1, 'config', 'json'); $configProperty->set($config); $this->config = $configProperty; diff --git a/services/nginx/app/tests/Unit/Selfserve/SelfserveConditionEvaluatorTest.php b/services/nginx/app/tests/Unit/Selfserve/SelfserveConditionEvaluatorTest.php index 6729cee9..22a75fd9 100644 --- a/services/nginx/app/tests/Unit/Selfserve/SelfserveConditionEvaluatorTest.php +++ b/services/nginx/app/tests/Unit/Selfserve/SelfserveConditionEvaluatorTest.php @@ -128,6 +128,112 @@ it('evaluates nested v2 ALL and ANY expression trees with trace output', functio expect($evaluation['trace'][20]['expression']['children'][0]['subject_type'])->toBe('condition'); }); +it('evaluates v2 if, else if, and else condition branches in order', function (): void { + $evaluator = new selfserve_condition_evaluator(); + + $evaluation = $evaluator->evaluateExpressionsWithTrace([ + [ + 'id' => 10, + 'expression' => [ + 'type' => 'branch', + 'branches' => [ + [ + 'kind' => 'if', + 'when' => [ + 'type' => 'predicate', + 'subject_type' => 'question', + 'subject_id' => 1, + 'operator' => 'IS_TRUE', + ], + 'then' => [ + 'type' => 'predicate', + 'subject_type' => 'question', + 'subject_id' => 2, + 'operator' => 'IS_TRUE', + ], + ], + [ + 'kind' => 'else_if', + 'when' => [ + 'type' => 'predicate', + 'subject_type' => 'question', + 'subject_id' => 3, + 'operator' => 'IS_TRUE', + ], + 'then' => [ + 'type' => 'predicate', + 'subject_type' => 'question', + 'subject_id' => 4, + 'operator' => 'IS_FALSE', + ], + ], + [ + 'kind' => 'else', + 'else' => true, + 'then' => [ + 'type' => 'predicate', + 'subject_type' => 'question', + 'subject_id' => 5, + 'operator' => 'IS_TRUE', + ], + ], + ], + ], + ], + ], [ + 1 => false, + 3 => true, + 4 => false, + 5 => false, + ]); + + expect($evaluation['results'][10])->toBeTrue(); + expect($evaluation['trace'][10]['expression']['selected_index'])->toBe(1); + expect($evaluation['trace'][10]['expression']['branches'][1]['kind'])->toBe('else_if'); +}); + +it('evaluates v2 case expressions against question values', function (): void { + $evaluator = new selfserve_condition_evaluator(); + + $evaluation = $evaluator->evaluateExpressionsWithTrace([ + [ + 'id' => 10, + 'expression' => [ + 'type' => 'case', + 'subject_type' => 'question', + 'subject_id' => 1, + 'cases' => [ + [ + 'value' => true, + 'then' => [ + 'type' => 'predicate', + 'subject_type' => 'question', + 'subject_id' => 2, + 'operator' => 'IS_TRUE', + ], + ], + [ + 'value' => false, + 'then' => [ + 'type' => 'predicate', + 'subject_type' => 'question', + 'subject_id' => 3, + 'operator' => 'IS_FALSE', + ], + ], + ], + ], + ], + ], [ + 1 => false, + 3 => false, + ]); + + expect($evaluation['results'][10])->toBeTrue(); + expect($evaluation['trace'][10]['expression']['selected_index'])->toBe(1); + expect($evaluation['trace'][10]['expression']['actual_value'])->toBeFalse(); +}); + it('returns false and traces v2 condition expression cycles', function (): void { $evaluator = new selfserve_condition_evaluator(); diff --git a/services/nginx/app/tests/Unit/Selfserve/SelfserveConfigVersioningTest.php b/services/nginx/app/tests/Unit/Selfserve/SelfserveConfigVersioningTest.php index 8405844a..450290e7 100644 --- a/services/nginx/app/tests/Unit/Selfserve/SelfserveConfigVersioningTest.php +++ b/services/nginx/app/tests/Unit/Selfserve/SelfserveConfigVersioningTest.php @@ -136,6 +136,68 @@ it('migrates legacy AND and OR rules into grouped v2 condition expressions', fun expect(array_column($expression['children'][1]['children'], 'subject_id'))->toBe([2, 3]); }); +it('repairs legacy-defaulted always task gates during v2 normalization', function (): void { + $service = selfserve_config_versioning_without_constructor(); + + $config = $service->migrateLegacyConfigToV2([ + 'department_id' => 6, + 'questions' => [ + ['id' => 1], + ], + 'conditions' => [ + ['id' => 20, 'name' => 'Machine wash allowed'], + ], + 'rules' => [ + ['id' => 100, 'condition_id' => 20, 'type' => 'IS_TRUE', 'object_type' => 'question', 'object_id' => 1], + ], + 'tasks' => [ + [ + 'id' => 200, + 'condition_id' => 20, + 'gate_type' => 'ALWAYS', + 'gate_ref_id' => null, + ], + ], + ]); + + expect($config['tasks'][0]['gate_type'])->toBe('CONDITION'); + expect($config['tasks'][0]['gate_ref_id'])->toBe(20); + expect($service->validateConfig($config)['valid'])->toBeTrue(); +}); + +it('does not let legacy-defaulted always task gates bypass validation', function (): void { + $service = selfserve_config_versioning_without_constructor(); + + $validation = $service->validateConfig([ + 'schema_version' => 2, + 'questions' => [ + ['id' => 1], + ], + 'conditions' => [ + [ + 'id' => 20, + 'expression' => [ + 'type' => 'predicate', + 'subject_type' => 'question', + 'subject_id' => 1, + 'operator' => 'IS_TRUE', + ], + ], + ], + 'tasks' => [ + [ + 'id' => 201, + 'condition_id' => 999, + 'gate_type' => 'ALWAYS', + 'gate_ref_id' => null, + ], + ], + ]); + + expect($validation['valid'])->toBeFalse(); + expect(implode("\n", $validation['errors']))->toContain('unknown question gate_ref_id 999'); +}); + it('rejects unsupported legacy task-target rules after migration', function (): void { $service = selfserve_config_versioning_without_constructor(); @@ -200,6 +262,63 @@ it('validates v2 expressions for empty used conditions, missing refs, invalid op expect($errors)->toContain('Condition cycle detected'); }); +it('validates nested v2 branch and case expressions', function (): void { + $service = selfserve_config_versioning_without_constructor(); + + $validation = $service->validateConfig([ + 'schema_version' => 2, + 'questions' => [ + ['id' => 1], + ['id' => 2], + ['id' => 3], + ], + 'conditions' => [ + [ + 'id' => 10, + 'expression' => [ + 'type' => 'branch', + 'branches' => [ + [ + 'kind' => 'if', + 'when' => ['type' => 'predicate', 'subject_type' => 'question', 'subject_id' => 1, 'operator' => 'IS_TRUE'], + 'then' => ['type' => 'predicate', 'subject_type' => 'question', 'subject_id' => 2, 'operator' => 'IS_TRUE'], + ], + [ + 'kind' => 'else', + 'else' => true, + 'then' => ['type' => 'predicate', 'subject_type' => 'question', 'subject_id' => 3, 'operator' => 'IS_FALSE_OR_NOT_SET'], + ], + ], + ], + ], + [ + 'id' => 20, + 'expression' => [ + 'type' => 'case', + 'subject_type' => 'condition', + 'subject_id' => 10, + 'cases' => [ + [ + 'value' => true, + 'then' => ['type' => 'predicate', 'subject_type' => 'question', 'subject_id' => 2, 'operator' => 'IS_TRUE'], + ], + [ + 'value' => false, + 'then' => ['type' => 'predicate', 'subject_type' => 'question', 'subject_id' => 3, 'operator' => 'IS_FALSE'], + ], + ], + ], + ], + ], + 'tasks' => [ + ['id' => 100, 'gate_type' => 'CONDITION', 'gate_ref_id' => 20], + ], + ]); + + expect($validation['valid'])->toBeTrue(); + expect($validation['errors'])->toBe([]); +}); + it('encodes config json payloads with apostrophes before persistence', function (): void { $version = new class extends selfserve_config_versions_o { /** @var array */ diff --git a/services/nginx/app/tests/Unit/Selfserve/SelfserveInProgressWashAccessTest.php b/services/nginx/app/tests/Unit/Selfserve/SelfserveInProgressWashAccessTest.php new file mode 100644 index 00000000..6f150e7b --- /dev/null +++ b/services/nginx/app/tests/Unit/Selfserve/SelfserveInProgressWashAccessTest.php @@ -0,0 +1,76 @@ +scopeInProgressWashResponseForCustomer($payload, $customer_number); + } + } +} + +it('keeps own in-progress self-serve wash details visible to the customer', function (): void { + $route = new SelfserveInProgressWashAccessHarness(); + + $payload = [ + 'lane_id' => 7, + 'in_progress' => true, + 'session' => [ + 'id' => 704, + 'customer_number' => 12345679, + 'reg' => 'AB12345', + ], + 'customer' => [ + 'customer_number' => 12345679, + 'display_name' => 'Example Customer', + ], + 'vehicle' => [ + 'id' => 55, + 'reg' => 'AB12345', + ], + ]; + + expect($route->scopeForCustomer($payload, 12345679))->toBe($payload); +}); + +it('redacts another customers in-progress wash details during customer lane polling', function (): void { + $route = new SelfserveInProgressWashAccessHarness(); + + $scoped = $route->scopeForCustomer([ + 'lane_id' => 9, + 'status' => 'MACHINE_STARTED', + 'in_progress' => true, + 'elapsed_minutes' => 4, + 'session' => [ + 'id' => 804, + 'customer_number' => 99999999, + 'reg' => 'CD67890', + ], + 'customer' => [ + 'customer_number' => 99999999, + 'display_name' => 'Other Customer', + 'email' => 'other@example.test', + ], + 'vehicle' => [ + 'id' => 77, + 'reg' => 'CD67890', + ], + ], 12345679); + + expect($scoped)->toBe([ + 'lane_id' => 9, + 'in_progress' => true, + 'session' => null, + 'customer' => null, + 'vehicle' => null, + ]); +}); + diff --git a/services/nginx/app/tests/Unit/Selfserve/SelfserveLaneStopFlowTest.php b/services/nginx/app/tests/Unit/Selfserve/SelfserveLaneStopFlowTest.php index dd93aefb..f0993c7b 100644 --- a/services/nginx/app/tests/Unit/Selfserve/SelfserveLaneStopFlowTest.php +++ b/services/nginx/app/tests/Unit/Selfserve/SelfserveLaneStopFlowTest.php @@ -57,6 +57,8 @@ class SelfserveLaneStopFlowHarness public int $vehicleTypeProductAddCalls = 0; public int $programSelectorStatusReads = 0; public ?bool $lastVehicleTypeProductDecision = null; + public ?\Throwable $openThrowable = null; + public ?\Throwable $reportedStopTimeout = null; /** @var selfserve_lane_port[] */ public array $openedPorts = []; /** @var selfserve_lane_relay[] */ @@ -180,6 +182,10 @@ class SelfserveLaneStopFlowHarness public function open(selfserve_lane_port $port): bool { $this->openedPorts[] = $port; + if ($this->openThrowable !== null) { + throw $this->openThrowable; + } + return true; } @@ -216,6 +222,11 @@ class SelfserveLaneStopFlowHarness $this->vehicleTypeProductAddCalls++; } } + + protected function reportWashStopExitTimeout(\Throwable $e): void + { + $this->reportedStopTimeout = $e; + } } it('adds vehicle type product on STOP when the physical machine ON signal was recorded, then turns off cleaner and machine relays', function (): void { @@ -275,3 +286,34 @@ it('does not use selector relay online status as machine-wash billing evidence', expect($lane->lastVehicleTypeProductDecision)->toBeFalse(); expect($lane->programSelectorStatusReads)->toBe(0); }); + +it('continues STOP when exit relay dispatch times out ambiguously', function (): void { + $lane = new SelfserveLaneStopFlowHarness(machineStartTriggered: true); + $timeout = new \RuntimeException('Edge gateway command timed out'); + $lane->openThrowable = $timeout; + $args = (new selfserve_lane_command_arguments())->setCustomerNumber(1234); + + $lane->execute(selfserve_lane_command::STOP, $args); + + expect($lane->openedPorts)->toBe([selfserve_lane_port::EXIT]); + expect($lane->reportedStopTimeout)->toBe($timeout); + expect($lane->invoiceCalls)->toBe(1); + expect($lane->vehicleTypeProductAddCalls)->toBe(1); + expect($lane->turnedOffRelays)->toBe([ + selfserve_lane_relay::MACHINE_CLEANER, + selfserve_lane_relay::MACHINE, + ]); + expect($lane->getLaneStatus())->toBe(selfserve_lane_status::AVAILABLE); +}); + +it('still fails STOP for non-timeout exit relay errors', function (): void { + $lane = new SelfserveLaneStopFlowHarness(machineStartTriggered: true); + $lane->openThrowable = new \RuntimeException('Invalid relay ID for port EXIT'); + $args = (new selfserve_lane_command_arguments())->setCustomerNumber(1234); + + expect(fn() => $lane->execute(selfserve_lane_command::STOP, $args)) + ->toThrow(\RuntimeException::class, 'Invalid relay ID'); + expect($lane->reportedStopTimeout)->toBeNull(); + expect($lane->invoiceCalls)->toBe(0); + expect($lane->getLaneStatus())->toBe(selfserve_lane_status::OCCUPIED); +}); diff --git a/services/nginx/app/tests/Unit/Selfserve/SelfserveOpenApiSpecTest.php b/services/nginx/app/tests/Unit/Selfserve/SelfserveOpenApiSpecTest.php index 80fdb28d..de705646 100644 --- a/services/nginx/app/tests/Unit/Selfserve/SelfserveOpenApiSpecTest.php +++ b/services/nginx/app/tests/Unit/Selfserve/SelfserveOpenApiSpecTest.php @@ -74,12 +74,17 @@ it('documents the all-in-one self-serve studio replacement API', function (): vo expect($content)->toContain('/department/selfserve/studio/layout:'); 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/publish:'); expect($content)->toContain('/department/selfserve/studio/rollback:'); expect($content)->toContain('/department/selfserve/studio/gateway-action:'); expect($content)->toContain('SelfserveStudioGraph:'); expect($content)->toContain('SelfserveStudioGraphSaveRequest:'); expect($content)->toContain('SelfserveStudioLayout:'); + expect($content)->toContain('SelfserveStudioPathOutcomesRequest:'); + expect($content)->toContain('SelfserveStudioPathOutcomesResponse:'); + expect($content)->toContain('SelfserveStudioPathResult:'); + expect($content)->toContain('projectSelfserveStudioPathOutcomes'); expect($content)->toContain('runSelfserveStudioGatewayAction'); }); diff --git a/services/nginx/app/tests/Unit/Selfserve/SelfserveRouteWiringTest.php b/services/nginx/app/tests/Unit/Selfserve/SelfserveRouteWiringTest.php index 7cf015a8..0450b490 100644 --- a/services/nginx/app/tests/Unit/Selfserve/SelfserveRouteWiringTest.php +++ b/services/nginx/app/tests/Unit/Selfserve/SelfserveRouteWiringTest.php @@ -73,9 +73,11 @@ it('wires the all-in-one self-serve studio replacement endpoints', function (): expect($studioRoute)->toContain('/department/selfserve/studio/layout'); 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/publish'); expect($studioRoute)->toContain('/department/selfserve/studio/rollback'); expect($studioRoute)->toContain('/department/selfserve/studio/gateway-action'); + expect($studioRoute)->toContain('projectPathOutcomes'); expect($studioRoute)->toContain('modules_shelly_config'); expect($studioRoute)->toContain('modules_selfserve_sessions_force_stop'); @@ -198,6 +200,9 @@ it('wires in-progress self-serve wash details endpoint', function (): void { expect($moduleSelfServeRoute)->not->toBeFalse(); expect($moduleSelfServeRoute)->toContain('/modules/self-serve/lane/wash/in-progress'); expect($moduleSelfServeRoute)->toContain('modules_selfserve_lane_wash_in_progress_view'); + expect($moduleSelfServeRoute)->toContain('list_own_department_selfserve_vehicle_conditions'); + expect($moduleSelfServeRoute)->toContain('requireInProgressWashDetailsAccess'); + expect($moduleSelfServeRoute)->toContain('scopeInProgressWashResponseForCustomer'); expect($moduleSelfServeRoute)->toContain("'in_progress' => true"); expect($moduleSelfServeRoute)->toContain("'in_progress' => false"); expect($moduleSelfServeRoute)->toContain('selfserve_lane_status::OCCUPIED'); diff --git a/services/nginx/app/tests/Unit/Selfserve/SelfserveStudioGraphTest.php b/services/nginx/app/tests/Unit/Selfserve/SelfserveStudioGraphTest.php index bf653571..1ff6aeed 100644 --- a/services/nginx/app/tests/Unit/Selfserve/SelfserveStudioGraphTest.php +++ b/services/nginx/app/tests/Unit/Selfserve/SelfserveStudioGraphTest.php @@ -157,6 +157,32 @@ it('serializes configurable studio actions with event, gate, scope, and ordering 'actions' => ['80' => 'Open lane entry', '81' => 'Cleaner off'], ], ], + 'gateway_workspace' => [ + 'gateways' => [ + [ + 'id' => 50, + 'label' => 'Gateway A', + 'status' => 'ONLINE', + 'bindings' => [ + ['relay_id' => 'ENTRY-7', 'label' => 'Entry relay', 'role' => 'ENTRY', 'services' => ['ENTRY']], + ['relay_id' => 'CLEAN-7', 'label' => 'Cleaner relay', 'role' => 'CLEANER', 'services' => ['CLEANER']], + ], + ], + ], + 'relays' => [ + ['relay_id' => 'ENTRY-7', 'name' => 'Entry relay'], + ['relay_id' => 'CLEAN-7', 'name' => 'Cleaner relay'], + ], + 'lanes' => [ + [ + 'id' => 7, + 'relay_slots' => [ + ['relay_id' => 'ENTRY-7', 'slot' => 'ENTRY'], + ['relay_id' => 'CLEAN-7', 'slot' => 'CLEANER'], + ], + ], + ], + ], ]); $nodeIds = array_column($graph['nodes'], 'id'); @@ -171,6 +197,8 @@ it('serializes configurable studio actions with event, gate, scope, and ordering ->and($edgeIds)->toContain('action-event:wash_start_command:80') ->and($edgeIds)->toContain('action-gate:10:80') ->and($edgeIds)->toContain('action-order:wash_start_command:80:81') + ->and($edgeIds)->toContain('action-relay:80:ENTRY-7:ENTRY:7') + ->and($edgeIds)->toContain('action-relay:81:CLEAN-7:CLEANER:7') ->and($edgeIds)->toContain('scope:lane:7:action:80') ->and($actionNode['data']['action_label'])->toBe('Turn OFF CLEANER') ->and($actionNode['data']['relay_role'])->toBe('CLEANER'); @@ -272,6 +300,68 @@ it('serializes v2 condition expressions without standalone rule nodes', function expect($conditionNode['data']['expression_summary'] ?? null)->toContain('Question 1'); }); +it('serializes branch and case condition expression dependencies', function (): void { + $service = selfserve_studio_graph_without_constructor(); + + $graph = $service->buildGraphFromConfig([ + 'schema_version' => 2, + 'questions' => [ + ['id' => 1, 'question' => 'Has booking?'], + ['id' => 2, 'question' => 'Allowed?'], + ], + 'conditions' => [ + [ + 'id' => 10, + 'name' => 'Branch condition', + 'expression' => [ + 'type' => 'branch', + 'branches' => [ + [ + 'kind' => 'if', + 'when' => ['type' => 'predicate', 'subject_type' => 'question', 'subject_id' => 1, 'operator' => 'IS_TRUE'], + 'then' => ['type' => 'predicate', 'subject_type' => 'question', 'subject_id' => 2, 'operator' => 'IS_TRUE'], + ], + [ + 'kind' => 'else', + 'else' => true, + 'then' => ['type' => 'predicate', 'subject_type' => 'question', 'subject_id' => 2, 'operator' => 'IS_FALSE'], + ], + ], + ], + ], + [ + 'id' => 20, + 'name' => 'Case condition', + 'expression' => [ + 'type' => 'case', + 'subject_type' => 'condition', + 'subject_id' => 10, + 'cases' => [ + [ + 'value' => true, + 'then' => ['type' => 'predicate', 'subject_type' => 'question', 'subject_id' => 2, 'operator' => 'IS_TRUE'], + ], + ], + ], + ], + ], + 'rules' => [], + 'tasks' => [], + ]); + + $edgeIds = array_column($graph['edges'], 'id'); + $conditionNode = array_values(array_filter( + $graph['nodes'], + static fn(array $node): bool => ($node['id'] ?? null) === 'condition:20' + ))[0] ?? null; + + expect($edgeIds)->toContain('expression:10:question:1:' . substr(md5('0.b0.when'), 0, 8)) + ->and($edgeIds)->toContain('expression:10:question:2:' . substr(md5('0.b0.then'), 0, 8)) + ->and($edgeIds)->toContain('expression:20:condition:10:' . substr(md5('0.case'), 0, 8)) + ->and($edgeIds)->toContain('expression:20:question:2:' . substr(md5('0.c0.then'), 0, 8)) + ->and($conditionNode['data']['expression_summary'] ?? null)->toContain('Case Condition 10'); +}); + it('keeps runtime on published v2 configs and leaves draft JSON as the studio edit surface', function (): void { $washFlowSource = file_get_contents((new ReflectionClass(selfserve_wash_flow::class))->getFileName()); $studioGraphSource = file_get_contents((new ReflectionClass(selfserve_studio_graph::class))->getFileName()); @@ -282,6 +372,22 @@ it('keeps runtime on published v2 configs and leaves draft JSON as the studio ed expect($studioGraphSource)->toContain('Standalone rule operations are not supported in self-serve rules v2.'); }); +it('surfaces task attachments in studio graph, simulator, and flow responses', function (): void { + $washFlowSource = file_get_contents((new ReflectionClass(selfserve_wash_flow::class))->getFileName()); + $studioGraphSource = file_get_contents((new ReflectionClass(selfserve_studio_graph::class))->getFileName()); + $attachmentPayloadSource = file_get_contents(WD . '/modules/selfserve/classes/selfserve_task_attachment_payloads.php'); + + expect($washFlowSource)->toContain("require_once WD . '/modules/selfserve/classes/selfserve_task_attachment_payloads.php';") + ->and($washFlowSource)->toContain('(new selfserve_task_attachment_payloads())->attachToTasks(') + ->and($washFlowSource)->toContain("'attachments' => \$task['attachments'] ?? []") + ->and($washFlowSource)->toContain("'attachments' => \$taskAttachments") + ->and($studioGraphSource)->toContain('$configWithAttachments = $this->withTaskAttachments($config);') + ->and($studioGraphSource)->toContain('$task[\'attachments\'] = is_array($task[\'attachments\'] ?? null) ? array_values($task[\'attachments\']) : [];') + ->and($attachmentPayloadSource)->toContain("private const OBJECT_TYPE = 'department_selfserve_tasks';") + ->and($attachmentPayloadSource)->toContain('listMany(self::OBJECT_TYPE, $taskIds)') + ->and($attachmentPayloadSource)->toContain('generateDirectDownloadUrl($fileName)'); +}); + it('derives studio vehicle type lookup rows from selectable wash products', function (): void { $service = selfserve_studio_graph_without_constructor(); $method = new ReflectionMethod(selfserve_studio_graph::class, 'vehicleTypeRowsFromProducts'); @@ -517,6 +623,239 @@ it('builds guided simulator debug payload with blockers and canvas annotations', ->and($debug['graph_annotations']['nodes']['lane:7']['state'])->toBe('error'); }); +it('projects visible question answer paths into grouped task service and signal outcomes', function (): void { + $service = selfserve_studio_graph_without_constructor(); + $simulate = function (array $overrides): array { + $answers = []; + foreach ($overrides as $entry) { + $answers[(int)($entry['question_id'] ?? 0)] = $entry['value'] ?? null; + } + + $mirrorAnswer = $answers[11] ?? null; + $liftAnswer = $answers[12] ?? null; + $liftVisible = $mirrorAnswer === true; + $allowed = $mirrorAnswer === true && $liftAnswer === true; + + return [ + 'allowed' => $allowed, + 'questions' => array_values(array_filter([ + ['id' => 11, 'question' => 'Are mirrors folded?', 'answer' => $mirrorAnswer], + $liftVisible ? ['id' => 12, 'question' => 'Is the lift lowered?', 'answer' => $liftAnswer] : null, + ])), + 'tasks' => $allowed ? [ + ['id' => 41, 'task' => 'Start machine', 'services' => ['MACHINE'], 'buttons' => ['start']], + ] : [], + 'allowed_services' => $allowed ? ['MACHINE'] : [], + 'debug' => [ + 'questions' => [ + [ + 'id' => 11, + 'node_id' => 'question:11', + 'label' => 'Are mirrors folded?', + 'visible' => true, + 'answer' => $mirrorAnswer, + ], + [ + 'id' => 12, + 'node_id' => 'question:12', + 'label' => 'Is the lift lowered?', + 'visible' => $liftVisible, + 'answer' => $liftVisible ? $liftAnswer : null, + ], + ], + 'tasks' => [ + [ + 'id' => 41, + 'node_id' => 'task:41', + 'label' => 'Start machine', + 'active' => $allowed, + 'services' => ['MACHINE'], + 'buttons' => ['start'], + 'order_priority' => 1, + ], + ], + 'signal_timeline' => [ + [ + 'sequence' => 1, + 'runtime_stage' => 'eligibility_sync', + 'signal_type' => 'session_event', + 'relay_role' => 'SESSION', + 'source' => 'none', + 'predicted_status' => $allowed ? 'sent' : 'skipped', + 'payload' => ['allowed' => $allowed], + ], + [ + 'sequence' => 2, + 'runtime_stage' => 'machine_start_signal', + 'signal_type' => 'shelly_event', + 'relay_role' => 'MACHINE', + 'relay_id' => 'M-7', + 'target_binding' => 'binding:701:M-7:0', + 'target_gateway_label' => 'Roskilde Edge', + 'source' => 'real', + 'predicted_status' => $allowed ? 'sent' : 'skipped', + 'payload' => ['event' => 'input.toggle_on'], + ], + ], + ], + ]; + }; + + $projection = $service->projectPathOutcomesFromSimulator($simulate, [ + 'max_states' => 20, + 'scope' => [ + 'department_id' => 6, + 'lane_id' => 7, + 'vehicle_type_id' => 2, + 'config_source' => 'draft', + 'hardware_mode' => 'studio', + ], + ]); + + $allowedOutcome = array_values(array_filter( + $projection['outcomes'], + static fn(array $outcome): bool => ($outcome['allowed'] ?? false) === true + ))[0] ?? null; + $blockedOutcome = array_values(array_filter( + $projection['outcomes'], + static fn(array $outcome): bool => ($outcome['allowed'] ?? false) === false + ))[0] ?? null; + + expect($projection['truncated'])->toBeFalse() + ->and($projection['summary']['state_count'])->toBe(5) + ->and($projection['summary']['terminal_path_count'])->toBe(3) + ->and($projection['summary']['outcome_count'])->toBe(2) + ->and($projection['summary']['path_sample_count'])->toBe(3) + ->and($projection['summary']['question_ids'])->toBe([11, 12]) + ->and($projection['paths'])->toHaveCount(3) + ->and($allowedOutcome)->not->toBeNull() + ->and($allowedOutcome['path_count'])->toBe(1) + ->and($allowedOutcome['services'])->toBe(['MACHINE']) + ->and($allowedOutcome['tasks'][0]['label'])->toBe('Start machine') + ->and($allowedOutcome['signals'][1]['relay_role'])->toBe('MACHINE') + ->and($allowedOutcome['node_ids'])->toContain('binding:701:M-7:0') + ->and($blockedOutcome)->not->toBeNull() + ->and($blockedOutcome['path_count'])->toBe(2); + + $oneAnswerBlockedSample = array_values(array_filter( + $blockedOutcome['sample_chains'], + static fn(array $chain): bool => count((array)($chain['answers'] ?? [])) === 1 + ))[0] ?? null; + + expect($oneAnswerBlockedSample)->not->toBeNull() + ->and($oneAnswerBlockedSample['answers'][0]['question_id'])->toBe(11) + ->and($oneAnswerBlockedSample['answers'][0]['answer'])->toBeFalse(); + + $allowedPath = array_values(array_filter( + $projection['paths'], + static fn(array $path): bool => ($path['allowed'] ?? false) === true + ))[0] ?? null; + + expect($allowedPath)->not->toBeNull() + ->and($allowedPath['result'])->toBe('Allowed') + ->and($allowedPath['answers'])->toHaveCount(2) + ->and($allowedPath['answers'][0]['question'])->toBe('Are mirrors folded?') + ->and($allowedPath['services'])->toBe(['MACHINE']) + ->and($allowedPath['node_ids'])->toContain('question:11') + ->and($allowedPath['node_ids'])->toContain('task:41'); +}); + +it('truncates path outcome projection when the state cap is reached', function (): void { + $service = selfserve_studio_graph_without_constructor(); + $simulate = function (array $overrides): array { + $answers = []; + foreach ($overrides as $entry) { + $answers[(int)($entry['question_id'] ?? 0)] = $entry['value'] ?? null; + } + $first = $answers[1] ?? null; + $secondVisible = $first === true; + + return [ + 'allowed' => false, + 'questions' => [], + 'tasks' => [], + 'allowed_services' => [], + 'debug' => [ + 'questions' => [ + ['id' => 1, 'node_id' => 'question:1', 'label' => 'First', 'visible' => true, 'answer' => $first], + ['id' => 2, 'node_id' => 'question:2', 'label' => 'Second', 'visible' => $secondVisible, 'answer' => $answers[2] ?? null], + ], + 'tasks' => [], + 'signal_timeline' => [], + ], + ]; + }; + + $projection = $service->projectPathOutcomesFromSimulator($simulate, ['max_states' => 2]); + + expect($projection['truncated'])->toBeTrue() + ->and($projection['summary']['state_count'])->toBe(2) + ->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 { + $service = selfserve_studio_graph_without_constructor(); + $simulate = function (array $overrides): array { + $answers = []; + foreach ($overrides as $entry) { + $answers[(int)($entry['question_id'] ?? 0)] = $entry['value'] ?? null; + } + + $questions = []; + foreach (range(1, 12) as $questionId) { + $questions[] = [ + 'id' => $questionId, + 'node_id' => 'question:' . $questionId, + 'label' => 'Question ' . $questionId, + 'visible' => true, + 'answer' => $answers[$questionId] ?? null, + ]; + } + + $complete = count($answers) === 12; + $allowed = $complete && !in_array(false, $answers, true); + + return [ + 'allowed' => $allowed, + 'questions' => [], + 'tasks' => $allowed ? [ + ['id' => 41, 'task' => 'Start machine', 'services' => ['MACHINE'], 'buttons' => ['start']], + ] : [], + 'allowed_services' => $allowed ? ['MACHINE'] : [], + 'debug' => [ + 'questions' => $questions, + 'tasks' => [ + [ + 'id' => 41, + 'node_id' => 'task:41', + 'label' => 'Start machine', + 'active' => $allowed, + 'services' => ['MACHINE'], + 'buttons' => ['start'], + 'order_priority' => 1, + ], + ], + 'signal_timeline' => [], + ], + ]; + }; + + $projection = $service->projectPathOutcomesFromSimulator($simulate, [ + 'max_states' => 2048, + 'path_sample_limit' => 20, + ]); + + expect($projection['truncated'])->toBeTrue() + ->and($projection['summary']['state_count'])->toBe(2048) + ->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['paths'][0]['answers'])->toHaveCount(12) + ->and($projection['paths'][0]['result'])->toBe('Allowed'); +}); + it('resolves simulator gateway service bindings from lane relay slots', function (): void { $service = selfserve_wash_flow_without_constructor(); @@ -833,6 +1172,108 @@ it('inserts configured action signals into the simulator timeline in runtime ord ->and($debug['graph_annotations']['nodes']['action:81']['state'])->toBe('active'); }); +it('simulates lane-scoped wash start actions for property gates and lane entrance ports', function (): void { + $service = selfserve_wash_flow_without_constructor(); + + $debug = $service->buildStudioDebugPayload(6, [ + 'lane' => [ + 'id' => 7, + 'department' => 6, + 'name' => 'Lane 7', + 'relay_in_id' => 'ENTRY-7', + 'relay_out_id' => 'EXIT-7', + 'relay_machine_id' => 'M-7', + 'relay_machine_cleaner_id' => 'CLEAN-7', + ], + 'machine_type' => ['id' => 1001, 'name' => 'Portal'], + 'vehicle' => null, + 'reg' => 'TEST123', + 'customer_number' => 123, + 'vehicle_type_id' => 2, + 'answers' => [], + 'answer_sources' => [], + 'questions' => [], + 'tasks' => [['id' => 41, 'task' => 'Start machine', 'services' => ['MACHINE']]], + 'allowed_services' => ['MACHINE'], + 'machine_available' => true, + 'all_visible_questions_answered' => true, + 'allowed' => true, + 'config_version_id' => 90, + 'config_source' => 'draft', + 'evaluation_trace' => [ + 'visible_question_ids' => [], + 'visibility_condition_results' => [], + 'condition_results' => [], + 'task_gates' => [], + ], + 'debug_candidates' => [ + 'questions' => [], + 'conditions' => [], + 'rules' => [], + 'tasks' => [['id' => 41, 'task' => 'Start machine', 'gate_type' => 'ALWAYS', 'gate_ref_id' => null, 'services' => ['MACHINE']]], + 'actions' => [ + [ + 'id' => 80, + 'name' => 'Open property entrance on lane start', + 'event' => 'wash_start_command', + 'wash_mode' => 'both', + 'operation' => 'open_property_entrance_gate', + 'lane' => 7, + 'order_priority' => 1, + ], + [ + 'id' => 81, + 'name' => 'Open lane entrance on lane start', + 'event' => 'wash_start_command', + 'wash_mode' => 'both', + 'operation' => 'open_lane_entrance_port', + 'lane' => 7, + 'order_priority' => 2, + 'options' => ['toggle_after_seconds' => 3], + ], + ], + ], + ], [ + 'gateway_workspace' => [ + 'gateways' => [ + [ + 'id' => 701, + 'label' => 'Roskilde Edge', + 'status' => 'ONLINE', + 'bindings' => [ + ['relay_id' => 'ENTRY-7', 'label' => 'Entry', 'role' => 'ENTRY', 'services' => ['ENTRY']], + ['relay_id' => 'M-7', 'label' => 'Machine', 'role' => 'MACHINE', 'services' => ['MACHINE']], + ['relay_id' => 'CLEAN-7', 'label' => 'Cleaner', 'role' => 'CLEANER', 'services' => ['CLEANER']], + ['relay_id' => 'EXIT-7', 'label' => 'Exit', 'role' => 'EXIT', 'services' => ['EXIT']], + ], + ], + ], + 'lanes' => [ + [ + 'id' => 7, + 'relay_slots' => [ + ['slot' => 'ENTRY', 'relay_id' => 'ENTRY-7'], + ['slot' => 'MACHINE', 'relay_id' => 'M-7'], + ['slot' => 'CLEANER', 'relay_id' => 'CLEAN-7'], + ['slot' => 'EXIT', 'relay_id' => 'EXIT-7'], + ], + ], + ], + ], + ]); + + $startSignals = array_values(array_filter( + $debug['signal_timeline'], + static fn(array $row): bool => str_starts_with((string)($row['signal_type'] ?? ''), 'studio_action_') + && ($row['payload']['event'] ?? null) === 'wash_start_command' + )); + + expect(array_column($startSignals, 'relay_role'))->toBe(['PROPERTY_ENTRANCE', 'ENTRY']) + ->and(array_column($startSignals, 'predicted_status'))->toBe(['sent', 'sent']) + ->and($startSignals[0]['payload'])->toMatchArray(['action_id' => 80, 'command' => 'OPEN_PROPERTY_ACCESS_GATE']) + ->and($startSignals[1]['payload'])->toMatchArray(['action_id' => 81, 'id' => 'ENTRY-7', 'toggle_after' => 3]); +}); + it('adds ordered simulator signal timeline rows for virtual hardware dry runs', function (): void { $service = selfserve_wash_flow_without_constructor(); diff --git a/services/nginx/app/tests/Unit/Selfserve/SelfserveWashFlowMachineAllowedWiringTest.php b/services/nginx/app/tests/Unit/Selfserve/SelfserveWashFlowMachineAllowedWiringTest.php index 289acd9b..51ad5fc3 100644 --- a/services/nginx/app/tests/Unit/Selfserve/SelfserveWashFlowMachineAllowedWiringTest.php +++ b/services/nginx/app/tests/Unit/Selfserve/SelfserveWashFlowMachineAllowedWiringTest.php @@ -1,5 +1,10 @@ toContain('$this->syncMachineRelayFromVisibleServices($snapshot, $session, $activateMachine);'); expect($washFlow)->toContain('$this->synchronizeSession($laneId, $normalizedReg, null, false, null, false);'); }); + +it('infers legacy-defaulted always task gates from condition_id at runtime', function (): void { + $reflection = new ReflectionClass(selfserve_wash_flow::class); + $flow = $reflection->newInstanceWithoutConstructor(); + $method = $reflection->getMethod('resolveTaskGate'); + $method->setAccessible(true); + + $conditionGate = $method->invoke($flow, [ + 'condition_id' => 22, + 'gate_type' => 'ALWAYS', + 'gate_ref_id' => null, + ], [22, 45]); + + expect($conditionGate['gate_type'])->toBe(selfserve_task_gate_type::CONDITION); + expect($conditionGate['gate_ref_id'])->toBe(22); + + $questionGate = $method->invoke($flow, [ + 'condition_id' => 11, + 'gate_type' => 'ALWAYS', + 'gate_ref_id' => null, + ], [22, 45]); + + expect($questionGate['gate_type'])->toBe(selfserve_task_gate_type::QUESTION); + expect($questionGate['gate_ref_id'])->toBe(11); +});