From 93cac644a1db2551c09c0bab49b06cdd72643a42 Mon Sep 17 00:00:00 2001 From: Jeppe Bundgaard Date: Wed, 29 Apr 2026 10:57:33 +0200 Subject: [PATCH] Introduce `selfserve_studio_action_runner` and related classes for configurable Studio action workflows - Added `selfserve_studio_action_runner` to manage Studio action execution, including conditional validation, retry mechanisms, and operation dispatching. - Introduced `selfserve_studio_actions` to define action constants, normalize configurations, and validate operations and policies. - Updated `selfserve_config_versioning` to support action nodes, including validation hooks, schema migration normalization, and legacy action parsing. - Enhanced `SelfserveStudioGraphTest` and `SelfserveStudioDebugPayload` tests to validate action serialization and runtime signal processing. - Added test cases for event-driven Studio actions and non-blocking configuration warnings. --- .../classes/selfserve_config_versioning.php | 66 +++- .../selfserve_studio_action_runner.php | 208 ++++++++++++ .../classes/selfserve_studio_actions.php | 217 ++++++++++++ .../classes/selfserve_studio_graph.php | 132 +++++++- .../selfserve/classes/selfserve_wash_flow.php | 320 +++++++++++++++++- .../traits/selfserve_lane_command_t.php | 52 +++ .../Selfserve/SelfserveStudioGraphTest.php | 247 ++++++++++++++ 7 files changed, 1228 insertions(+), 14 deletions(-) create mode 100644 services/nginx/app/modules/selfserve/classes/selfserve_studio_action_runner.php create mode 100644 services/nginx/app/modules/selfserve/classes/selfserve_studio_actions.php 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 be9b1de0..13ebb780 100644 --- a/services/nginx/app/modules/selfserve/classes/selfserve_config_versioning.php +++ b/services/nginx/app/modules/selfserve/classes/selfserve_config_versioning.php @@ -10,9 +10,11 @@ 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/helpers/selfserve_task_gate_type.php'; +require_once WD . '/modules/selfserve/classes/selfserve_studio_actions.php'; use classes\selfserve_schema_bootstrap; use modules\selfserve\helpers\selfserve_task_gate_type; +use modules\selfserve\classes\selfserve_studio_actions; use objects\departments_o; use objects\department_selfserve_condition_rules_o; use objects\department_selfserve_conditions_o; @@ -287,6 +289,7 @@ class selfserve_config_versioning 'conditions' => $conditions, 'rules' => $rules, 'tasks' => $tasks, + 'actions' => [], 'snapshot_meta' => [ 'captured_at' => date('c'), 'source' => 'legacy_tables', @@ -331,6 +334,7 @@ class selfserve_config_versioning 'conditions' => array_values($conditions), 'rules' => [], 'tasks' => array_values((array)($legacyConfig['tasks'] ?? [])), + 'actions' => array_values((array)($legacyConfig['actions'] ?? [])), 'v2_meta' => [ 'migrated_from' => (int)($legacyConfig['schema_version'] ?? 1), 'migrated_at' => date('c'), @@ -363,6 +367,7 @@ class selfserve_config_versioning $conditions = is_array($config['conditions'] ?? null) ? $config['conditions'] : []; $rules = is_array($config['rules'] ?? null) ? $config['rules'] : []; $tasks = is_array($config['tasks'] ?? null) ? $config['tasks'] : []; + $actions = is_array($config['actions'] ?? null) ? $config['actions'] : []; $questionIds = []; foreach ($questions as $question) { @@ -448,6 +453,7 @@ class selfserve_config_versioning 'conditions' => count($conditions), 'rules' => count($rules), 'tasks' => count($tasks), + 'actions' => count($actions), ], 'validated_at' => date('c'), ]; @@ -473,6 +479,7 @@ class selfserve_config_versioning $questions = is_array($config['questions'] ?? null) ? array_values((array)$config['questions']) : []; $conditions = is_array($config['conditions'] ?? null) ? array_values((array)$config['conditions']) : []; $tasks = is_array($config['tasks'] ?? null) ? array_values((array)$config['tasks']) : []; + $actions = is_array($config['actions'] ?? null) ? array_values((array)$config['actions']) : []; $questionIds = []; foreach ($questions as $question) { @@ -569,6 +576,58 @@ class selfserve_config_versioning } } + $actionIds = []; + foreach ($actions as $action) { + if (!is_array($action)) { + $errors[] = 'Action has invalid payload.'; + continue; + } + $actionId = (int)($action['id'] ?? 0); + if ($actionId <= 0) { + $errors[] = 'Action without valid id.'; + continue; + } + if (isset($actionIds[$actionId])) { + $errors[] = 'Duplicate action id ' . $actionId . '.'; + } + $actionIds[$actionId] = true; + + $event = strtolower(trim((string)($action['event'] ?? ''))); + if (!in_array($event, selfserve_studio_actions::events(), true)) { + $errors[] = 'Action ' . $actionId . ' has invalid event `' . $event . '`.'; + } + + $washMode = strtolower(trim((string)($action['wash_mode'] ?? selfserve_studio_actions::MODE_BOTH))); + if (!in_array($washMode, selfserve_studio_actions::washModes(), true)) { + $errors[] = 'Action ' . $actionId . ' has invalid wash_mode `' . $washMode . '`.'; + } + if ($event === selfserve_studio_actions::EVENT_MACHINE_START_TRIGGERED && $washMode === selfserve_studio_actions::MODE_MANUAL) { + $warnings[] = 'Action ' . $actionId . ' uses manual mode for the machine-start event and will never run.'; + } + + $operation = strtolower(trim((string)($action['operation'] ?? ''))); + if (!in_array($operation, selfserve_studio_actions::operations(), true)) { + $errors[] = 'Action ' . $actionId . ' has invalid operation `' . $operation . '`.'; + } + if (selfserve_studio_actions::isRelayOperation($operation) && !array_key_exists('relay_state', $action)) { + $errors[] = 'Action ' . $actionId . ' requires relay_state for operation ' . $operation . '.'; + } + + $options = is_array($action['options'] ?? null) ? (array)$action['options'] : []; + $failurePolicy = strtolower(trim((string)($options['failure_policy'] ?? selfserve_studio_actions::FAILURE_CONTINUE))); + if (!in_array($failurePolicy, selfserve_studio_actions::failurePolicies(), true)) { + $errors[] = 'Action ' . $actionId . ' has invalid failure_policy `' . $failurePolicy . '`.'; + } + + $conditionId = $this->nullableInt($action['condition_id'] ?? null); + if ($conditionId !== null) { + $usedConditionIds[$conditionId] = true; + if (!isset($conditionIds[$conditionId])) { + $errors[] = 'Action ' . $actionId . ' references unknown condition_id ' . $conditionId . '.'; + } + } + } + foreach ($usedConditionIds as $conditionId => $_used) { if (isset($conditionIds[(int)$conditionId]) && (($conditionHasPredicate[(int)$conditionId] ?? false) !== true)) { $errors[] = 'Condition ' . (int)$conditionId . ' is used but has an empty expression.'; @@ -589,6 +648,7 @@ class selfserve_config_versioning 'conditions' => count($conditions), 'rules' => 0, 'tasks' => count($tasks), + 'actions' => count($actions), ], 'validated_at' => date('c'), ]; @@ -880,6 +940,10 @@ class selfserve_config_versioning }, (array)($config['conditions'] ?? []))); $config['rules'] = []; $config['tasks'] = array_values((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'] ?? []) + )); $config['v2_meta'] = is_array($config['v2_meta'] ?? null) ? (array)$config['v2_meta'] : []; $config['v2_meta']['next_ids'] = $this->nextIdsForConfig($config); return $config; @@ -892,7 +956,7 @@ class selfserve_config_versioning protected function nextIdsForConfig(array $config): array { $next = []; - foreach (['questions' => 'question', 'conditions' => 'condition', 'tasks' => 'task'] as $key => $name) { + foreach (['questions' => 'question', 'conditions' => 'condition', 'tasks' => 'task', 'actions' => 'action'] as $key => $name) { $max = 0; foreach ((array)($config[$key] ?? []) as $row) { if (is_array($row)) { 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 new file mode 100644 index 00000000..0896128f --- /dev/null +++ b/services/nginx/app/modules/selfserve/classes/selfserve_studio_action_runner.php @@ -0,0 +1,208 @@ + $context + * @return array> + */ + public function executeForLaneEvent(int|object $lane, string $event, string $washMode = selfserve_studio_actions::MODE_BOTH, array $context = []): array + { + $laneObject = is_int($lane) ? (new selfserve())->lane($lane) : $lane; + $departmentId = $this->departmentIdForLane($laneObject); + if ($departmentId <= 0) { + return []; + } + + $published = (new selfserve_config_versioning())->getPublishedV2Config($departmentId); + $config = is_array($published['config'] ?? null) ? (array)$published['config'] : []; + $actions = $this->matchingActions($config, $laneObject, $event, $washMode, $context); + $results = []; + + foreach ($actions as $action) { + $results[] = $this->executeAction($laneObject, $action); + } + + return $results; + } + + /** + * @param array $config + * @param object $lane + * @param array $context + * @return array> + */ + public function matchingActions(array $config, object $lane, string $event, string $washMode, array $context = []): array + { + $event = strtolower(trim($event)); + $washMode = strtolower(trim($washMode)); + $laneId = (int)($lane->id ?? 0); + $departmentId = $this->departmentIdForLane($lane); + $machineTypeId = $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; + + $actions = []; + foreach ((array)($config['actions'] ?? []) as $row) { + if (!is_array($row)) { + continue; + } + $action = selfserve_studio_actions::normalize((array)$row); + if (!$action['enabled'] || $action['event'] !== $event) { + continue; + } + if (!in_array($action['wash_mode'], [selfserve_studio_actions::MODE_BOTH, $washMode], true)) { + continue; + } + if ((int)$action['department'] !== 0 && (int)$action['department'] !== $departmentId) { + continue; + } + if ((int)$action['lane'] !== 0 && (int)$action['lane'] !== $laneId) { + continue; + } + if ((int)$action['product'] !== 0 && ($productId === null || (int)$action['product'] !== $productId)) { + continue; + } + if ($action['machine_type_id'] !== null && (int)$action['machine_type_id'] !== $machineTypeId) { + continue; + } + $conditionId = $action['condition_id']; + if ($conditionId !== null && $conditionResults !== null && (($conditionResults[$conditionId] ?? false) !== true)) { + continue; + } + $actions[] = $action; + } + + usort($actions, static fn(array $left, array $right): int => ((int)$left['order_priority'] <=> (int)$right['order_priority']) ?: ((int)$left['id'] <=> (int)$right['id'])); + return $actions; + } + + /** + * @param object $lane + * @param array $action + * @return array + */ + private function executeAction(object $lane, array $action): array + { + $options = is_array($action['options'] ?? null) ? (array)$action['options'] : []; + $attempts = max(1, min(4, ((int)($options['retry_count'] ?? 0)) + 1)); + $delayMs = max(0, min(10000, (int)($options['delay_ms'] ?? 0))); + $lastError = null; + + for ($attempt = 1; $attempt <= $attempts; $attempt++) { + try { + if ($delayMs > 0) { + usleep($delayMs * 1000); + } + $this->dispatchAction($lane, $action); + return [ + 'action_id' => (int)$action['id'], + 'name' => (string)$action['name'], + 'event' => (string)$action['event'], + 'operation' => (string)$action['operation'], + 'status' => 'sent', + 'attempts' => $attempt, + ]; + } catch (\Throwable $e) { + $lastError = $e; + } + } + + $result = [ + 'action_id' => (int)$action['id'], + 'name' => (string)$action['name'], + 'event' => (string)$action['event'], + 'operation' => (string)$action['operation'], + 'status' => 'failed', + 'attempts' => $attempts, + 'error' => $lastError?->getMessage(), + ]; + + if (($options['failure_policy'] ?? selfserve_studio_actions::FAILURE_CONTINUE) === selfserve_studio_actions::FAILURE_BLOCK) { + throw new \RuntimeException('Self-serve studio action failed: ' . (string)$action['name'], 0, $lastError); + } + + return $result; + } + + /** + * @param object $lane + * @param array $action + */ + private function dispatchAction(object $lane, array $action): void + { + $toggleAfter = $this->nullableInt($action['options']['toggle_after_seconds'] ?? null); + switch ((string)$action['operation']) { + case selfserve_studio_actions::OP_OPEN_PROPERTY_ENTRANCE_GATE: + $lane->execute(selfserve_lane_command::OPEN_PROPERTY_ACCESS_GATE, new selfserve_lane_command_arguments()); + return; + case selfserve_studio_actions::OP_OPEN_PROPERTY_EXIT_GATE: + $lane->execute(selfserve_lane_command::OPEN_PROPERTY_EXIT_GATE, new selfserve_lane_command_arguments()); + return; + case selfserve_studio_actions::OP_OPEN_LANE_ENTRANCE_PORT: + $lane->open(selfserve_lane_port::ENTRANCE, $toggleAfter); + return; + case selfserve_studio_actions::OP_OPEN_LANE_EXIT_PORT: + $lane->open(selfserve_lane_port::EXIT, $toggleAfter); + return; + case selfserve_studio_actions::OP_SET_CLEANER_RELAY: + $lane->setRelayStatusHard(selfserve_lane_relay::MACHINE_CLEANER, (bool)$action['relay_state']); + return; + case selfserve_studio_actions::OP_SET_MACHINE_RELAY: + $lane->setRelayStatusHard(selfserve_lane_relay::MACHINE, (bool)$action['relay_state']); + return; + case selfserve_studio_actions::OP_SET_PROGRAM_PICKER_RELAY: + $lane->setRelayStatusHard(selfserve_lane_relay::MACHINE_PROGRAM_PICKER, (bool)$action['relay_state']); + return; + } + + throw new \RuntimeException('Unsupported self-serve studio action operation: ' . (string)$action['operation']); + } + + private function departmentIdForLane(object $lane): int + { + try { + return empty($lane->department_lane) || empty($lane->department_lane->department) + ? 0 + : (int)$lane->department_lane->department->value(); + } catch (\Throwable) { + return 0; + } + } + + private function machineTypeIdForLane(object $lane): int + { + try { + return empty($lane->department_lane) || empty($lane->department_lane->machine_type_id) + ? 0 + : (int)$lane->department_lane->machine_type_id->value(); + } catch (\Throwable) { + return 0; + } + } + + private function nullableInt(mixed $value): ?int + { + if ($value === null || $value === '' || $value === 'null') { + return null; + } + $intValue = (int)$value; + return $intValue <= 0 ? null : $intValue; + } +} diff --git a/services/nginx/app/modules/selfserve/classes/selfserve_studio_actions.php b/services/nginx/app/modules/selfserve/classes/selfserve_studio_actions.php new file mode 100644 index 00000000..a849722c --- /dev/null +++ b/services/nginx/app/modules/selfserve/classes/selfserve_studio_actions.php @@ -0,0 +1,217 @@ + + */ + public static function events(): array + { + return [ + self::EVENT_WASH_START_COMMAND, + self::EVENT_WASH_STOP_COMMAND, + self::EVENT_MACHINE_START_TRIGGERED, + ]; + } + + /** + * @return array + */ + public static function washModes(): array + { + return [ + self::MODE_MANUAL, + self::MODE_MACHINE, + self::MODE_BOTH, + ]; + } + + /** + * @return array + */ + public static function operations(): array + { + return [ + self::OP_OPEN_PROPERTY_ENTRANCE_GATE, + self::OP_OPEN_PROPERTY_EXIT_GATE, + self::OP_OPEN_LANE_ENTRANCE_PORT, + self::OP_OPEN_LANE_EXIT_PORT, + self::OP_SET_CLEANER_RELAY, + self::OP_SET_MACHINE_RELAY, + self::OP_SET_PROGRAM_PICKER_RELAY, + ]; + } + + /** + * @return array + */ + public static function failurePolicies(): array + { + return [ + self::FAILURE_CONTINUE, + self::FAILURE_BLOCK, + ]; + } + + public static function isRelayOperation(string $operation): bool + { + return in_array($operation, [ + self::OP_SET_CLEANER_RELAY, + self::OP_SET_MACHINE_RELAY, + self::OP_SET_PROGRAM_PICKER_RELAY, + ], true); + } + + public static function isOpenOperation(string $operation): bool + { + return in_array($operation, [ + self::OP_OPEN_PROPERTY_ENTRANCE_GATE, + self::OP_OPEN_PROPERTY_EXIT_GATE, + self::OP_OPEN_LANE_ENTRANCE_PORT, + self::OP_OPEN_LANE_EXIT_PORT, + ], true); + } + + public static function relayRoleForOperation(string $operation): string + { + return match ($operation) { + self::OP_OPEN_PROPERTY_ENTRANCE_GATE => 'PROPERTY_ENTRANCE', + self::OP_OPEN_PROPERTY_EXIT_GATE => 'PROPERTY_EXIT', + self::OP_OPEN_LANE_ENTRANCE_PORT => 'ENTRY', + self::OP_OPEN_LANE_EXIT_PORT => 'EXIT', + self::OP_SET_CLEANER_RELAY => 'CLEANER', + self::OP_SET_MACHINE_RELAY => 'MACHINE', + self::OP_SET_PROGRAM_PICKER_RELAY => 'PROGRAM_PICKER', + default => 'ACTION', + }; + } + + 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', + default => 'On action event', + }; + } + + public static function operationLabel(string $operation, ?bool $relayState = null): string + { + $state = $relayState === null ? '' : ($relayState ? 'ON ' : 'OFF '); + return match ($operation) { + self::OP_OPEN_PROPERTY_ENTRANCE_GATE => 'Open property entrance gate', + self::OP_OPEN_PROPERTY_EXIT_GATE => 'Open property exit gate', + self::OP_OPEN_LANE_ENTRANCE_PORT => 'Open lane entrance port', + self::OP_OPEN_LANE_EXIT_PORT => 'Open lane exit port', + self::OP_SET_CLEANER_RELAY => 'Turn ' . $state . 'CLEANER', + self::OP_SET_MACHINE_RELAY => 'Turn ' . $state . 'MACHINE', + self::OP_SET_PROGRAM_PICKER_RELAY => 'Turn ' . $state . 'PROGRAM PICKER', + default => 'Action', + }; + } + + public static function runtimeStageForEvent(string $event): string + { + return match ($event) { + self::EVENT_WASH_START_COMMAND => 'start', + self::EVENT_WASH_STOP_COMMAND => 'stop', + self::EVENT_MACHINE_START_TRIGGERED => 'machine_start', + default => 'action', + }; + } + + /** + * @param array $action + * @return array + */ + public static function normalize(array $action): array + { + $event = strtolower(trim((string)($action['event'] ?? self::EVENT_WASH_START_COMMAND))); + if (!in_array($event, self::events(), true)) { + $event = self::EVENT_WASH_START_COMMAND; + } + + $operation = strtolower(trim((string)($action['operation'] ?? self::OP_OPEN_LANE_ENTRANCE_PORT))); + if (!in_array($operation, self::operations(), true)) { + $operation = self::OP_OPEN_LANE_ENTRANCE_PORT; + } + + $washMode = strtolower(trim((string)($action['wash_mode'] ?? self::MODE_BOTH))); + if (!in_array($washMode, self::washModes(), true)) { + $washMode = self::MODE_BOTH; + } + + $options = is_array($action['options'] ?? null) ? (array)$action['options'] : []; + $failurePolicy = strtolower(trim((string)($options['failure_policy'] ?? self::FAILURE_CONTINUE))); + if (!in_array($failurePolicy, self::failurePolicies(), true)) { + $failurePolicy = self::FAILURE_CONTINUE; + } + + $relayState = array_key_exists('relay_state', $action) + ? filter_var($action['relay_state'], FILTER_VALIDATE_BOOLEAN, FILTER_NULL_ON_FAILURE) + : null; + if (self::isRelayOperation($operation) && $relayState === null) { + $relayState = true; + } + + $label = trim((string)($action['name'] ?? $action['label'] ?? '')); + if ($label === '') { + $label = self::operationLabel($operation, $relayState); + } + + return [ + 'id' => (int)($action['id'] ?? 0), + 'department' => (int)($action['department'] ?? 0), + 'lane' => (int)($action['lane'] ?? 0), + 'product' => (int)($action['product'] ?? 0), + 'machine_type_id' => self::nullableInt($action['machine_type_id'] ?? null), + 'condition_id' => self::nullableInt($action['condition_id'] ?? null), + 'name' => $label, + 'description' => (string)($action['description'] ?? ''), + 'event' => $event, + 'wash_mode' => $washMode, + 'operation' => $operation, + 'relay_state' => $relayState, + 'enabled' => filter_var($action['enabled'] ?? true, FILTER_VALIDATE_BOOLEAN), + 'order_priority' => (int)($action['order_priority'] ?? 0), + 'options' => [ + 'delay_ms' => max(0, (int)($options['delay_ms'] ?? 0)), + 'toggle_after_seconds' => self::nullableInt($options['toggle_after_seconds'] ?? null), + 'retry_count' => max(0, min(3, (int)($options['retry_count'] ?? 0))), + 'failure_policy' => $failurePolicy, + 'record_event' => filter_var($options['record_event'] ?? true, FILTER_VALIDATE_BOOLEAN), + ], + ]; + } + + private static function nullableInt(mixed $value): ?int + { + if ($value === null || $value === '' || $value === 'null') { + return null; + } + $intValue = (int)$value; + return $intValue <= 0 ? null : $intValue; + } +} 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 f61c643a..3dc0c6d8 100644 --- a/services/nginx/app/modules/selfserve/classes/selfserve_studio_graph.php +++ b/services/nginx/app/modules/selfserve/classes/selfserve_studio_graph.php @@ -7,6 +7,7 @@ require_once WD . '/classes/selfserve_schema_bootstrap.php'; require_once WD . '/modules/selfserve/classes/selfserve_config_versioning.php'; require_once WD . '/modules/selfserve/classes/selfserve_virtual_hardware.php'; require_once WD . '/modules/selfserve/classes/selfserve_wash_flow.php'; +require_once WD . '/modules/selfserve/classes/selfserve_studio_actions.php'; require_once WD . '/modules/selfserve/helpers/selfserve_task_gate_type.php'; require_once WD . '/objects/department_selfserve_condition_rules_o.php'; require_once WD . '/objects/department_selfserve_conditions_o.php'; @@ -36,6 +37,7 @@ use classes\edge_gateway_operation_service; use classes\edge_gateway_registry_service; use classes\edge_gateway_view_service; use classes\selfserve_schema_bootstrap; +use modules\selfserve\classes\selfserve_studio_actions; use modules\selfserve\helpers\selfserve_task_gate_type; use objects\selfserve_config_versions_o; @@ -264,6 +266,51 @@ class selfserve_studio_graph } } + $actionsByEvent = []; + $actionRows = $this->sortedRows((array)($config['actions'] ?? []), ['event', 'order_priority', 'id']); + foreach ($actionRows as $index => $action) { + $normalizedAction = selfserve_studio_actions::normalize($action); + $id = (int)($normalizedAction['id'] ?? 0); + if ($id <= 0) { + continue; + } + $actionsByEvent[$normalizedAction['event']][] = $normalizedAction; + $nodes[] = $this->node('action:' . $id, 'default', $this->entityLabel('action', $id, $normalizedAction, $lookups), 'action', [ + 'object_id' => $id, + 'raw' => $normalizedAction, + 'scope' => $this->scopeForRow($normalizedAction, $lookups), + 'subtitle' => $this->actionSubtitle($normalizedAction, $lookups), + 'action_label' => selfserve_studio_actions::operationLabel((string)$normalizedAction['operation'], $normalizedAction['relay_state']), + 'event_label' => selfserve_studio_actions::eventLabel((string)$normalizedAction['event']), + 'relay_role' => selfserve_studio_actions::relayRoleForOperation((string)$normalizedAction['operation']), + ], 1360, 160 + ($index * 130)); + + $eventSource = match ((string)$normalizedAction['event']) { + selfserve_studio_actions::EVENT_WASH_START_COMMAND => 'checkpoint:start', + selfserve_studio_actions::EVENT_WASH_STOP_COMMAND => 'checkpoint:finish', + selfserve_studio_actions::EVENT_MACHINE_START_TRIGGERED => 'checkpoint:eligible', + default => 'checkpoint:start', + }; + $edges[] = $this->edge('action-event:' . $normalizedAction['event'] . ':' . $id, $eventSource, 'action:' . $id, 'action_event', $normalizedAction['wash_mode']); + + $conditionId = $this->nullableInt($normalizedAction['condition_id'] ?? null); + if ($conditionId !== null) { + $edges[] = $this->edge('action-gate:' . $conditionId . ':' . $id, 'condition:' . $conditionId, 'action:' . $id, 'action_gate', 'allows'); + } + $this->appendScopeEdges($edges, 'action:' . $id, $normalizedAction); + } + + foreach ($actionsByEvent as $event => $actions) { + $orderedActions = $this->sortedRows($actions, ['order_priority', 'id']); + for ($i = 1; $i < count($orderedActions); $i++) { + $sourceId = (int)($orderedActions[$i - 1]['id'] ?? 0); + $targetId = (int)($orderedActions[$i]['id'] ?? 0); + if ($sourceId > 0 && $targetId > 0) { + $edges[] = $this->edge('action-order:' . $event . ':' . $sourceId . ':' . $targetId, 'action:' . $sourceId, 'action:' . $targetId, 'action_order', 'then'); + } + } + } + $this->appendGatewayNodesAndEdges($nodes, $edges, $gatewayWorkspace); $this->appendTaskServiceEdges($edges, $taskRows, $gatewayWorkspace); @@ -678,6 +725,7 @@ class selfserve_studio_graph 'conditions' => $this->configLabelRows((array)($config['conditions'] ?? []), 'name'), 'rules' => $this->configLabelRows((array)($config['rules'] ?? []), 'name'), 'tasks' => $this->configLabelRows((array)($config['tasks'] ?? []), 'task'), + 'actions' => $this->configLabelRows((array)($config['actions'] ?? []), 'name'), 'gateways' => $this->gatewayLabelRows((array)($gatewayWorkspace['gateways'] ?? [])), 'relays' => $this->relayLabelRows((array)($gatewayWorkspace['relays'] ?? [])), 'bindings' => $this->bindingLabelRows((array)($gatewayWorkspace['gateways'] ?? [])), @@ -761,7 +809,7 @@ class selfserve_studio_graph } } - foreach (['conditions', 'questions', 'tasks'] as $section) { + foreach (['conditions', 'questions', 'tasks', 'actions'] as $section) { foreach ((array)($config[$section] ?? []) as $row) { if (!is_array($row)) { continue; @@ -1039,6 +1087,29 @@ class selfserve_studio_graph 'buttons' => [], 'dynamic_images_vehicle_type' => null, ], + 'action' => [ + 'id' => $id, + 'department' => $departmentId, + 'lane' => 0, + 'product' => 0, + 'machine_type_id' => null, + 'condition_id' => null, + 'name' => 'Open lane entrance port', + 'description' => '', + 'event' => selfserve_studio_actions::EVENT_WASH_START_COMMAND, + 'wash_mode' => selfserve_studio_actions::MODE_BOTH, + 'operation' => selfserve_studio_actions::OP_OPEN_LANE_ENTRANCE_PORT, + 'relay_state' => null, + 'enabled' => true, + 'order_priority' => $this->nextOrderPriority((array)($config['actions'] ?? [])), + 'options' => [ + 'delay_ms' => 0, + 'toggle_after_seconds' => 1, + 'retry_count' => 0, + 'failure_policy' => selfserve_studio_actions::FAILURE_CONTINUE, + 'record_event' => true, + ], + ], default => throw new \RuntimeException('Unsupported studio entity: ' . $entity), }; @@ -1120,6 +1191,13 @@ class selfserve_studio_graph } } unset($task); + $actionRows = &$this->configRows($config, 'action'); + foreach ($actionRows as &$action) { + if (is_array($action) && (int)($action['condition_id'] ?? 0) === $id) { + $action['condition_id'] = null; + } + } + unset($action); } } @@ -1129,8 +1207,8 @@ class selfserve_studio_graph */ private function applyConfigReorder(array &$config, string $entity, array $items): void { - if (!in_array($entity, ['question', 'task'], true)) { - throw new \RuntimeException('Only questions and tasks can be reordered.'); + if (!in_array($entity, ['question', 'task', 'action'], true)) { + throw new \RuntimeException('Only questions, tasks, and actions can be reordered.'); } $priorities = []; @@ -1217,6 +1295,15 @@ class selfserve_studio_graph $rows[$index]['condition_id'] = (!$disconnect && $sourceType === 'question') ? $sourceId : null; return; } + + if ($sourceType === 'condition' && $targetType === 'action') { + $rows = &$this->configRows($config, 'action'); + $index = $this->configRowIndex($rows, $targetId); + if ($index !== null && (!$disconnect || (int)($rows[$index]['condition_id'] ?? 0) === $sourceId)) { + $rows[$index]['condition_id'] = $disconnect ? null : $sourceId; + } + return; + } } /** @@ -1288,6 +1375,7 @@ class selfserve_studio_graph 'question' => 'questions', 'condition' => 'conditions', 'task' => 'tasks', + 'action' => 'actions', default => throw new \RuntimeException('Unsupported studio entity: ' . $entity), }; if (!isset($config[$key]) || !is_array($config[$key])) { @@ -1315,7 +1403,7 @@ class selfserve_studio_graph private function allocateConfigId(array &$config, string $entity): int { $name = match ($entity) { - 'question', 'condition', 'task' => $entity, + 'question', 'condition', 'task', 'action' => $entity, default => throw new \RuntimeException('Unsupported studio entity: ' . $entity), }; if (!isset($config['v2_meta']) || !is_array($config['v2_meta'])) { @@ -1362,6 +1450,8 @@ class selfserve_studio_graph $data['name'] = $data['label']; } elseif ($entity === 'task' && !array_key_exists('task', $data)) { $data['task'] = $data['label']; + } elseif ($entity === 'action' && !array_key_exists('name', $data)) { + $data['name'] = $data['label']; } } @@ -1369,6 +1459,7 @@ class selfserve_studio_graph 'question' => ['department', 'lane', 'product', 'condition_id', 'question', 'description', 'order_priority'], 'condition' => ['department', 'lane', 'product', 'machine_type_id', 'condition_id', 'name', 'description', 'expression'], 'task' => ['department', 'lane', 'product', 'machine_type_id', 'condition_id', 'gate_type', 'gate_ref_id', 'task', 'description', 'order_priority', 'services', 'buttons', 'dynamic_images_vehicle_type'], + 'action' => ['department', 'lane', 'product', 'machine_type_id', 'condition_id', 'name', 'description', 'event', 'wash_mode', 'operation', 'relay_state', 'enabled', 'order_priority', 'options'], default => throw new \RuntimeException('Unsupported studio entity: ' . $entity), }; @@ -1389,6 +1480,9 @@ class selfserve_studio_graph $row['condition_id'] = null; } } + if ($entity === 'action') { + $row = selfserve_studio_actions::normalize($row); + } return $row; } @@ -1398,18 +1492,24 @@ class selfserve_studio_graph if ($field === 'expression') { return $this->normalizeExpressionNode($value); } - if (in_array($field, ['condition_id', 'machine_type_id', 'gate_ref_id', 'dynamic_images_vehicle_type'], true)) { + if (in_array($field, ['condition_id', 'machine_type_id', 'gate_ref_id', 'dynamic_images_vehicle_type', 'toggle_after_seconds'], true)) { return $this->nullableInt($value); } - if (in_array($field, ['department', 'lane', 'product', 'order_priority'], true)) { + if (in_array($field, ['department', 'lane', 'product', 'order_priority', 'delay_ms', 'retry_count'], true)) { return (int)$value; } + if (in_array($field, ['enabled', 'relay_state', 'record_event'], true)) { + return filter_var($value, FILTER_VALIDATE_BOOLEAN, FILTER_NULL_ON_FAILURE); + } if ($field === 'services') { return $this->normalizeServiceList($value); } if ($field === 'buttons') { return $this->normalizeArrayPayload($value); } + if ($field === 'options') { + return is_array($value) ? (array)$value : []; + } if ($field === 'gate_type') { return $this->normalizeGateType((string)$value); } @@ -2281,6 +2381,7 @@ class selfserve_studio_graph 'question' => 'question', 'condition', 'rule' => 'name', 'task' => 'task', + 'action' => 'name', default => 'label', }; $label = trim((string)($row[$field] ?? '')); @@ -2290,6 +2391,24 @@ class selfserve_studio_graph return $this->labelFor($entity . 's', $id, $lookups); } + /** + * @param array $action + * @param array $lookups + */ + private function actionSubtitle(array $action, array $lookups): string + { + $parts = [ + selfserve_studio_actions::eventLabel((string)($action['event'] ?? '')), + ucfirst((string)($action['wash_mode'] ?? selfserve_studio_actions::MODE_BOTH)), + selfserve_studio_actions::operationLabel((string)($action['operation'] ?? ''), $action['relay_state'] ?? null), + ]; + $scope = $this->scopeLabel($action, $lookups); + if ($scope !== 'Shared scope') { + $parts[] = $scope; + } + return implode(' / ', array_filter($parts, static fn(string $part): bool => $part !== '')); + } + /** * @param array $lookups */ @@ -2760,6 +2879,7 @@ class selfserve_studio_graph 'conditions' => 'condition', 'rules' => 'rule', 'tasks' => 'task', + 'actions' => 'action', 'lanes' => 'lane', default => $entity, }; 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 b520eaf0..c392b331 100644 --- a/services/nginx/app/modules/selfserve/classes/selfserve_wash_flow.php +++ b/services/nginx/app/modules/selfserve/classes/selfserve_wash_flow.php @@ -7,6 +7,8 @@ require_once WD . '/classes/selfserve_schema_bootstrap.php'; require_once WD . '/modules/selfserve/classes/selfserve_condition_evaluator.php'; require_once WD . '/modules/selfserve/classes/selfserve_lane_command_arguments.php'; 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/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'; @@ -35,6 +37,8 @@ use classes\selfserve_schema_bootstrap; use modules\selfserve\classes\selfserve_lane_command_arguments; use modules\selfserve\helpers\selfserve_lane_command; use modules\selfserve\classes\selfserve_config_versioning; +use modules\selfserve\classes\selfserve_studio_action_runner; +use modules\selfserve\classes\selfserve_studio_actions; use modules\selfserve\helpers\selfserve_task_gate_type; use modules\selfserve\helpers\selfserve_lane_relay; use modules\selfserve\helpers\selfserve_lane_services; @@ -173,7 +177,6 @@ class selfserve_wash_flow implements selfserve_wash_flow_i $lane->setWashStartTime(time()); } $washStartedAt = (int)$lane->getWashStartTime(); - $this->enableCleanerRelayForStartedWash($lane); $session->markMachineStartTriggered( $washStartedAt > 0 ? date('Y-m-d H:i:s', $washStartedAt) : null @@ -183,6 +186,19 @@ class selfserve_wash_flow implements selfserve_wash_flow_i 'reg' => $effectiveReg, 'customer_number' => $customerNumber, ]); + (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, + ] + ); + $this->enableCleanerRelayForStartedWash($lane); return $this->getSessionSummary((int)$session->id); } @@ -595,6 +611,7 @@ class selfserve_wash_flow implements selfserve_wash_flow_i 'conditions' => $conditions, 'rules' => $rules, 'tasks' => $tasks, + 'actions' => $isV2Config ? array_values((array)($publishedConfigPayload['actions'] ?? [])) : [], 'visible_answers' => $visibleAnswers, ], ]; @@ -699,11 +716,12 @@ class selfserve_wash_flow implements selfserve_wash_flow_i $rules = $this->buildDebugRules($snapshot, (array)($candidates['rules'] ?? []), $lookups); $conditions = $this->buildDebugConditions($snapshot, (array)($candidates['conditions'] ?? []), $rules, $lookups); $tasks = $this->buildDebugTasks($snapshot, (array)($candidates['tasks'] ?? []), $lookups, (array)($options['gateway_workspace'] ?? [])); - $hardware = $this->buildDebugHardware($snapshot, $tasks, (array)($options['gateway_workspace'] ?? []), $lookups); + $actions = $this->buildDebugActions($snapshot, (array)($candidates['actions'] ?? []), $lookups); + $hardware = $this->buildDebugHardware($snapshot, $tasks, (array)($options['gateway_workspace'] ?? []), $lookups, $actions); $recommendations = $this->buildDebugRecommendations($snapshot, $questions, $tasks, $hardware, $conditions, $rules); $summary = $this->buildDebugSummary($snapshot, $recommendations, $hardware); $stages = $this->buildDebugStages($snapshot, $questions, $conditions, $rules, $tasks, $hardware, $summary, $lookups); - $annotations = $this->buildGraphAnnotations($snapshot, $questions, $conditions, $rules, $tasks, $hardware, (array)($options['graph'] ?? [])); + $annotations = $this->buildGraphAnnotations($snapshot, $questions, $conditions, $rules, $tasks, $actions, $hardware, (array)($options['graph'] ?? [])); return [ 'summary' => $summary, @@ -729,6 +747,7 @@ class selfserve_wash_flow implements selfserve_wash_flow_i 'conditions' => $conditions, 'rules' => $rules, 'tasks' => $tasks, + 'actions' => $actions, 'hardware' => $hardware, 'signal_timeline' => (array)($hardware['signal_timeline'] ?? []), 'graph_annotations' => $annotations, @@ -1020,14 +1039,117 @@ class selfserve_wash_flow implements selfserve_wash_flow_i return $items; } + /** + * @param array $snapshot + * @param array> $actions + * @param array $lookups + * @return array> + */ + protected function buildDebugActions(array $snapshot, array $actions, array $lookups): array + { + $conditionResults = is_array($snapshot['evaluation_trace']['condition_results'] ?? null) + ? (array)$snapshot['evaluation_trace']['condition_results'] + : []; + $lane = is_array($snapshot['lane'] ?? null) ? (array)$snapshot['lane'] : []; + $laneId = (int)($lane['id'] ?? 0); + $departmentId = (int)($lane['department'] ?? 0); + $vehicleTypeId = $this->nullableInt($snapshot['vehicle_type_id'] ?? null); + $machineTypeId = $this->nullableInt($snapshot['machine_type']['id'] ?? $lane['machine_type_id'] ?? null); + $eventModes = [ + selfserve_studio_actions::EVENT_WASH_START_COMMAND => $this->debugWashModeForStart($snapshot), + selfserve_studio_actions::EVENT_WASH_STOP_COMMAND => $this->debugWashModeForStop($snapshot), + selfserve_studio_actions::EVENT_MACHINE_START_TRIGGERED => selfserve_studio_actions::MODE_MACHINE, + ]; + $items = []; + + foreach ($actions as $row) { + if (!is_array($row)) { + continue; + } + $action = selfserve_studio_actions::normalize((array)$row); + $actionId = (int)($action['id'] ?? 0); + if ($actionId <= 0) { + continue; + } + + $expectedMode = $eventModes[(string)$action['event']] ?? selfserve_studio_actions::MODE_BOTH; + $modeMatches = in_array((string)$action['wash_mode'], [selfserve_studio_actions::MODE_BOTH, $expectedMode], true); + $scopeMatches = true; + $scopeReason = null; + if ((int)$action['department'] !== 0 && (int)$action['department'] !== $departmentId) { + $scopeMatches = false; + $scopeReason = 'Action department scope does not match the simulated lane.'; + } elseif ((int)$action['lane'] !== 0 && (int)$action['lane'] !== $laneId) { + $scopeMatches = false; + $scopeReason = 'Action lane scope does not match the simulated lane.'; + } elseif ((int)$action['product'] !== 0 && ($vehicleTypeId === null || (int)$action['product'] !== $vehicleTypeId)) { + $scopeMatches = false; + $scopeReason = 'Action vehicle type scope does not match the simulated vehicle.'; + } elseif ($action['machine_type_id'] !== null && (int)$action['machine_type_id'] !== ($machineTypeId ?? 0)) { + $scopeMatches = false; + $scopeReason = 'Action machine type scope does not match the simulated lane.'; + } + + $conditionId = $this->nullableInt($action['condition_id'] ?? null); + $conditionSatisfied = $conditionId === null || (($conditionResults[$conditionId] ?? false) === true); + $enabled = (bool)($action['enabled'] ?? true); + $active = $enabled && $modeMatches && $scopeMatches && $conditionSatisfied; + $reason = 'Action would run for this simulator event.'; + if (!$enabled) { + $reason = 'Action is disabled.'; + } elseif (!$modeMatches) { + $reason = 'Action wash mode ' . (string)$action['wash_mode'] . ' does not match simulated ' . $expectedMode . ' mode.'; + } elseif (!$scopeMatches) { + $reason = $scopeReason ?? 'Action scope does not match the simulated lane.'; + } elseif (!$conditionSatisfied) { + $reason = 'Action condition gate did not pass.'; + } + + $items[] = [ + 'id' => $actionId, + 'node_id' => 'action:' . $actionId, + 'label' => (string)$action['name'], + 'active' => $active, + 'state' => $active ? 'active' : 'skipped', + 'reason' => $reason, + 'event' => (string)$action['event'], + 'event_label' => selfserve_studio_actions::eventLabel((string)$action['event']), + 'wash_mode' => (string)$action['wash_mode'], + 'simulated_wash_mode' => $expectedMode, + 'operation' => (string)$action['operation'], + 'operation_label' => selfserve_studio_actions::operationLabel((string)$action['operation'], $action['relay_state']), + 'relay_role' => selfserve_studio_actions::relayRoleForOperation((string)$action['operation']), + 'relay_state' => $action['relay_state'], + 'condition_id' => $conditionId, + 'condition' => $conditionId === null ? 'Always' : $this->debugLabel($lookups, 'conditions', $conditionId, 'Condition ' . $conditionId), + 'condition_satisfied' => $conditionSatisfied, + 'scope' => [ + 'department' => (int)$action['department'], + 'lane' => (int)$action['lane'], + 'product' => (int)$action['product'], + 'machine_type_id' => $action['machine_type_id'], + ], + 'options' => (array)$action['options'], + 'order_priority' => (int)$action['order_priority'], + 'raw' => $action, + ]; + } + + usort($items, static fn(array $a, array $b): int => strcmp((string)$a['event'], (string)$b['event']) + ?: ((int)$a['order_priority'] <=> (int)$b['order_priority']) + ?: ((int)$a['id'] <=> (int)$b['id'])); + return $items; + } + /** * @param array $snapshot * @param array> $tasks * @param array $gatewayWorkspace * @param array $lookups + * @param array> $actions * @return array */ - protected function buildDebugHardware(array $snapshot, array $tasks, array $gatewayWorkspace, array $lookups): array + protected function buildDebugHardware(array $snapshot, array $tasks, array $gatewayWorkspace, array $lookups, array $actions = []): array { $bindingsByService = $this->debugGatewayBindingsByService($gatewayWorkspace); $allowedServices = (array)($snapshot['allowed_services'] ?? []); @@ -1051,7 +1173,7 @@ class selfserve_wash_flow implements selfserve_wash_flow_i } } } - $signalTimeline = $this->buildDebugSignalTimeline($snapshot, $gatewayWorkspace, $bindingsByService, $laneRelaySlots); + $signalTimeline = $this->buildDebugSignalTimeline($snapshot, $gatewayWorkspace, $bindingsByService, $laneRelaySlots, $actions); $dryRunOperations = []; if (($snapshot['allowed'] ?? false) === true) { @@ -1089,9 +1211,10 @@ class selfserve_wash_flow implements selfserve_wash_flow_i * @param array $gatewayWorkspace * @param array>> $bindingsByService * @param array> $laneRelaySlots + * @param array> $actions * @return array> */ - protected function buildDebugSignalTimeline(array $snapshot, array $gatewayWorkspace, array $bindingsByService, array $laneRelaySlots): array + protected function buildDebugSignalTimeline(array $snapshot, array $gatewayWorkspace, array $bindingsByService, array $laneRelaySlots, array $actions = []): array { $timeline = []; $sequence = 1; @@ -1115,6 +1238,18 @@ class selfserve_wash_flow implements selfserve_wash_flow_i null ); + $this->appendDebugActionSignalRows( + $timeline, + $sequence, + $actions, + selfserve_studio_actions::EVENT_WASH_START_COMMAND, + $allowed, + $gatewayWorkspace, + $bindingsByService, + $laneRelaySlots, + $snapshot + ); + $machineRelayId = $this->debugRelayIdForRole($laneRelaySlots, 'MACHINE', $snapshot); $machineBinding = $this->debugBindingForRelayRole($gatewayWorkspace, $bindingsByService, $machineRelayId, 'MACHINE'); $timeline[] = $this->debugRelaySignalTimelineRow( @@ -1147,6 +1282,18 @@ class selfserve_wash_flow implements selfserve_wash_flow_i $allowed ? (!$machineAvailable ? 'Lane machine signal relay is not configured.' : null) : 'Machine ON signal would not be accepted before eligibility passes.' ); + $this->appendDebugActionSignalRows( + $timeline, + $sequence, + $actions, + selfserve_studio_actions::EVENT_MACHINE_START_TRIGGERED, + $allowed && $machineAvailable, + $gatewayWorkspace, + $bindingsByService, + $laneRelaySlots, + $snapshot + ); + $cleanerRelayId = $this->debugRelayIdForRole($laneRelaySlots, 'CLEANER', $snapshot); $cleanerBinding = $this->debugBindingForRelayRole($gatewayWorkspace, $bindingsByService, $cleanerRelayId, 'CLEANER'); $timeline[] = $this->debugRelaySignalTimelineRow( @@ -1161,6 +1308,18 @@ class selfserve_wash_flow implements selfserve_wash_flow_i $allowed ? 'Lane has no machine start cleaner relay configured.' : 'Machine start would not run because eligibility is blocked.' ); + $this->appendDebugActionSignalRows( + $timeline, + $sequence, + $actions, + selfserve_studio_actions::EVENT_WASH_STOP_COMMAND, + $allowed, + $gatewayWorkspace, + $bindingsByService, + $laneRelaySlots, + $snapshot + ); + $exitRelayId = $this->debugRelayIdForRole($laneRelaySlots, 'EXIT', $snapshot); $exitBinding = $this->debugBindingForRelayRole($gatewayWorkspace, $bindingsByService, $exitRelayId, 'EXIT'); $timeline[] = $this->debugRelaySignalTimelineRow( @@ -1218,6 +1377,146 @@ class selfserve_wash_flow implements selfserve_wash_flow_i return $timeline; } + protected function debugWashModeForStart(array $snapshot): string + { + return in_array(selfserve_lane_services::MACHINE->name, (array)($snapshot['allowed_services'] ?? []), true) + ? selfserve_studio_actions::MODE_MACHINE + : selfserve_studio_actions::MODE_MANUAL; + } + + protected function debugWashModeForStop(array $snapshot): string + { + return ((bool)($snapshot['allowed'] ?? false) === true && (bool)($snapshot['machine_available'] ?? false) === true) + ? selfserve_studio_actions::MODE_MACHINE + : selfserve_studio_actions::MODE_MANUAL; + } + + /** + * @param array> $timeline + * @param array> $actions + * @param array $gatewayWorkspace + * @param array>> $bindingsByService + * @param array> $laneRelaySlots + * @param array $snapshot + */ + protected function appendDebugActionSignalRows( + array &$timeline, + int &$sequence, + array $actions, + string $event, + bool $eventAllowed, + array $gatewayWorkspace, + array $bindingsByService, + array $laneRelaySlots, + array $snapshot + ): void { + $eventActions = array_values(array_filter( + $actions, + static fn(array $action): bool => (string)($action['event'] ?? '') === $event + )); + usort($eventActions, 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))); + + foreach ($eventActions as $action) { + $raw = is_array($action['raw'] ?? null) ? (array)$action['raw'] : selfserve_studio_actions::normalize($action); + $operation = (string)($raw['operation'] ?? ''); + $relayRole = selfserve_studio_actions::relayRoleForOperation($operation); + $runtimeStage = selfserve_studio_actions::runtimeStageForEvent($event); + $isRelayOperation = selfserve_studio_actions::isRelayOperation($operation); + $isPropertyGate = in_array($relayRole, ['PROPERTY_ENTRANCE', 'PROPERTY_EXIT'], true); + $relayId = $isPropertyGate ? null : $this->debugRelayIdForRole($laneRelaySlots, $relayRole, $snapshot); + $binding = $isPropertyGate ? null : $this->debugBindingForRelayRole($gatewayWorkspace, $bindingsByService, $relayId, $relayRole); + $active = (bool)($action['active'] ?? false); + $reason = (string)($action['reason'] ?? 'Action does not match the simulated scenario.'); + $payload = [ + 'action_id' => (int)($raw['id'] ?? $action['id'] ?? 0), + 'action' => (string)($raw['name'] ?? $action['label'] ?? ''), + 'event' => $event, + 'wash_mode' => (string)($raw['wash_mode'] ?? $action['wash_mode'] ?? selfserve_studio_actions::MODE_BOTH), + 'operation' => $operation, + 'operation_label' => selfserve_studio_actions::operationLabel($operation, $raw['relay_state'] ?? null), + 'enabled' => (bool)($raw['enabled'] ?? true), + 'order_priority' => (int)($raw['order_priority'] ?? $action['order_priority'] ?? 0), + 'options' => (array)($raw['options'] ?? []), + ]; + + if ($isRelayOperation) { + $payload['id'] = $relayId; + $payload['channel'] = 0; + $payload['on'] = (bool)($raw['relay_state'] ?? true); + $signalType = 'studio_action_relay_switch'; + } elseif ($isPropertyGate) { + $payload['command'] = $relayRole === 'PROPERTY_ENTRANCE' ? 'OPEN_PROPERTY_ACCESS_GATE' : 'OPEN_PROPERTY_EXIT_GATE'; + $signalType = 'studio_action_gate_open'; + } else { + $payload['id'] = $relayId; + $payload['on'] = true; + $payload['toggle_after'] = $this->nullableInt($raw['options']['toggle_after_seconds'] ?? null) ?? 1; + $signalType = 'studio_action_relay_pulse'; + } + + if (!$active) { + $timeline[] = $this->debugSignalTimelineRow( + $sequence++, + $runtimeStage, + $signalType, + $relayRole, + $relayId, + $binding, + $payload, + $binding === null ? 'none' : ((bool)($binding['virtual'] ?? false) ? 'virtual' : 'real'), + 'skipped', + $reason + ); + continue; + } + + if (!$eventAllowed) { + $timeline[] = $this->debugSignalTimelineRow( + $sequence++, + $runtimeStage, + $signalType, + $relayRole, + $relayId, + $binding, + $payload, + $binding === null ? ($isPropertyGate ? 'real' : 'none') : ((bool)($binding['virtual'] ?? false) ? 'virtual' : 'real'), + 'blocked', + 'Action event would not fire because the dry-run scenario is blocked before this stage.' + ); + continue; + } + + if ($isPropertyGate) { + $timeline[] = $this->debugSignalTimelineRow( + $sequence++, + $runtimeStage, + $signalType, + $relayRole, + null, + null, + $payload, + 'real', + 'sent', + null + ); + continue; + } + + $timeline[] = $this->debugRelaySignalTimelineRow( + $sequence++, + $runtimeStage, + $signalType, + $relayRole, + $relayId, + $binding, + $payload, + true, + 'Action relay is not configured for the simulated lane.' + ); + } + } + /** * @param array $snapshot * @param array> $laneRelaySlots @@ -1545,11 +1844,12 @@ class selfserve_wash_flow implements selfserve_wash_flow_i * @param array> $conditions * @param array> $rules * @param array> $tasks + * @param array> $actions * @param array $hardware * @param array $graph * @return array */ - protected function buildGraphAnnotations(array $snapshot, array $questions, array $conditions, array $rules, array $tasks, array $hardware, array $graph): array + protected function buildGraphAnnotations(array $snapshot, array $questions, array $conditions, array $rules, array $tasks, array $actions, array $hardware, array $graph): array { $nodes = [ 'checkpoint:start' => ['state' => 'visited', 'label' => 'Simulation started'], @@ -1601,6 +1901,12 @@ class selfserve_wash_flow implements selfserve_wash_flow_i } } } + foreach ($actions as $action) { + $nodes[(string)$action['node_id']] = [ + 'state' => ($action['active'] ?? false) ? 'active' : 'not_applicable', + 'label' => (string)($action['reason'] ?? 'Action evaluated.'), + ]; + } $edges = []; foreach ((array)($graph['edges'] ?? []) as $edge) { 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 9a7a20ff..76bcd2bf 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 @@ -10,10 +10,14 @@ require_once WD . '/modules/selfserve/helpers/selfserve_lane_state.php'; require_once WD . '/modules/selfserve/helpers/selfserve_lane_relay.php'; require_once WD . '/modules/selfserve/classes/selfserve_lane_command_arguments.php'; require_once WD . '/modules/selfserve/classes/selfserve_wash_flow.php'; +require_once WD . '/modules/selfserve/classes/selfserve_studio_action_runner.php'; +require_once WD . '/modules/selfserve/classes/selfserve_studio_actions.php'; use Exception; use modules\selfserve\classes\selfserve_lane; use modules\selfserve\classes\selfserve_lane_command_arguments; +use modules\selfserve\classes\selfserve_studio_action_runner; +use modules\selfserve\classes\selfserve_studio_actions; use modules\selfserve\classes\selfserve_wash_flow; use modules\selfserve\helpers\selfserve_lane_command; use modules\selfserve\helpers\selfserve_lane_log_action; @@ -200,6 +204,37 @@ trait selfserve_lane_command_t $this->setMachineRelayStatusForWashStart(); } + protected function resolveSelfServeActionWashModeForStart(): string + { + try { + if (method_exists($this, 'getLaneCache') && defined(self::class . '::CACHE_SELFSERVE_LANE_KEY_ALLOWED_SERVICES')) { + $services = $this->getLaneCache((int)$this->id, self::CACHE_SELFSERVE_LANE_KEY_ALLOWED_SERVICES); + if (is_array($services)) { + foreach ($services as $service) { + if (strtoupper((string)$service) === 'MACHINE') { + return selfserve_studio_actions::MODE_MACHINE; + } + } + } + } + } catch (\Throwable) { + // Fall through to manual mode when the cached service set is unavailable. + } + + return selfserve_studio_actions::MODE_MANUAL; + } + + /** + * Execute configured Studio actions from the published flow for this lane. + * + * @param array $context + * @return array> + */ + protected function runPublishedStudioActions(string $event, string $washMode, array $context = []): array + { + return (new selfserve_studio_action_runner())->executeForLaneEvent($this, $event, $washMode, $context); + } + /** * Disable relays after STOP in deterministic order: * 1. Cleaner relay @@ -391,6 +426,14 @@ trait selfserve_lane_command_t $this->setLaneState(selfserve_lane_state::IN_WASH); // Start the wash timer $this->setWashStartTime(time()); + $this->runPublishedStudioActions( + selfserve_studio_actions::EVENT_WASH_START_COMMAND, + $this->resolveSelfServeActionWashModeForStart(), + [ + 'customer_number' => (int)$customer_number, + 'reg' => $license_plate, + ] + ); $this->runRelaySideEffectsForWashStart($arguments); // Log the lane start event $this->logLaneAction(selfserve_lane_log_action::START_WASH); @@ -404,6 +447,15 @@ trait selfserve_lane_command_t } // Snapshot the physical machine ON signal before session completion/reset. $machine_start_triggered = $this->hasMachineStartSignalForStop(); + $this->runPublishedStudioActions( + selfserve_studio_actions::EVENT_WASH_STOP_COMMAND, + $machine_start_triggered ? selfserve_studio_actions::MODE_MACHINE : selfserve_studio_actions::MODE_MANUAL, + [ + 'customer_number' => $arguments->customer_number, + 'reg' => $this->getLicensePlate(), + 'machine_start_triggered' => $machine_start_triggered, + ] + ); // Open the exit port $this->open(selfserve_lane_port::EXIT); // Turn off relays in deterministic order after STOP diff --git a/services/nginx/app/tests/Unit/Selfserve/SelfserveStudioGraphTest.php b/services/nginx/app/tests/Unit/Selfserve/SelfserveStudioGraphTest.php index 671103d7..bf653571 100644 --- a/services/nginx/app/tests/Unit/Selfserve/SelfserveStudioGraphTest.php +++ b/services/nginx/app/tests/Unit/Selfserve/SelfserveStudioGraphTest.php @@ -4,6 +4,7 @@ app_require('modules/selfserve/classes/selfserve_studio_graph.php'); app_require('modules/selfserve/classes/selfserve_virtual_hardware.php'); use modules\selfserve\classes\selfserve_studio_graph; +use modules\selfserve\classes\selfserve_config_versioning; use modules\selfserve\classes\selfserve_virtual_hardware; use modules\selfserve\classes\selfserve_wash_flow; @@ -115,6 +116,112 @@ it('serializes questions, conditions, tasks, scopes, and gateways into one graph expect($bindingNode['data']['raw']['services'] ?? [])->toBe(['MACHINE']); }); +it('serializes configurable studio actions with event, gate, scope, and ordering edges', function (): void { + $service = selfserve_studio_graph_without_constructor(); + + $graph = $service->buildGraphFromConfig([ + 'schema_version' => 2, + 'questions' => [], + 'conditions' => [ + ['id' => 10, 'name' => 'Machine selected'], + ], + 'rules' => [], + 'tasks' => [], + 'actions' => [ + [ + 'id' => 80, + 'name' => 'Open lane entry', + 'event' => 'wash_start_command', + 'wash_mode' => 'both', + 'operation' => 'open_lane_entrance_port', + 'condition_id' => 10, + 'lane' => 7, + 'order_priority' => 1, + ], + [ + 'id' => 81, + 'name' => 'Cleaner off', + 'event' => 'wash_start_command', + 'wash_mode' => 'machine', + 'operation' => 'set_cleaner_relay', + 'relay_state' => false, + 'lane' => 7, + 'order_priority' => 2, + ], + ], + ], [ + 'lookups' => [ + 'labels' => [ + 'lanes' => ['7' => 'Lane 7'], + 'conditions' => ['10' => 'Machine selected'], + 'actions' => ['80' => 'Open lane entry', '81' => 'Cleaner off'], + ], + ], + ]); + + $nodeIds = array_column($graph['nodes'], 'id'); + $edgeIds = array_column($graph['edges'], 'id'); + $actionNode = array_values(array_filter( + $graph['nodes'], + static fn(array $node): bool => ($node['id'] ?? null) === 'action:81' + ))[0] ?? null; + + expect($nodeIds)->toContain('action:80') + ->and($nodeIds)->toContain('action:81') + ->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('scope:lane:7:action:80') + ->and($actionNode['data']['action_label'])->toBe('Turn OFF CLEANER') + ->and($actionNode['data']['relay_role'])->toBe('CLEANER'); +}); + +it('validates action configuration and keeps warnings non-blocking', function (): void { + $versioning = new class extends selfserve_config_versioning { + public function __construct() + { + } + }; + + $validation = $versioning->validateConfig([ + 'schema_version' => 2, + 'questions' => [ + ['id' => 1, 'question' => 'Machine selected?'], + ], + 'conditions' => [ + [ + 'id' => 10, + 'name' => 'Gate', + 'expression' => [ + 'type' => 'group', + 'operator' => 'ALL', + 'children' => [ + ['type' => 'predicate', 'subject_type' => 'question', 'subject_id' => 1, 'operator' => 'IS_TRUE'], + ], + ], + ], + ], + 'rules' => [], + 'tasks' => [], + 'actions' => [ + [ + 'id' => 80, + 'name' => 'Manual machine start action', + 'event' => 'machine_start_triggered', + 'wash_mode' => 'manual', + 'operation' => 'set_machine_relay', + 'condition_id' => 10, + 'relay_state' => true, + 'options' => ['failure_policy' => 'block'], + ], + ], + ]); + + expect($validation['valid'])->toBeTrue() + ->and($validation['stats']['actions'])->toBe(1) + ->and(implode("\n", $validation['warnings']))->toContain('uses manual mode for the machine-start event'); +}); + it('serializes v2 condition expressions without standalone rule nodes', function (): void { $service = selfserve_studio_graph_without_constructor(); @@ -586,6 +693,146 @@ it('renders virtual gateway nodes and task service edges in the studio graph', f ->and($bindingNode['data']['raw']['virtual'])->toBeTrue(); }); +it('inserts configured action signals into the simulator timeline in runtime order', 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_program_picker_id' => 'PICKER-7', + 'relay_machine_cleaner_id' => 'CLEAN-7', + ], + 'machine_type' => ['id' => 1001, 'name' => 'Portal'], + 'vehicle' => null, + 'reg' => 'TEST123', + 'customer_number' => null, + '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' => [21 => true], + 'task_gates' => [ + ['task_id' => 41, 'gate_type' => 'ALWAYS', 'gate_ref_id' => null, 'satisfied' => true], + ], + ], + 'debug_candidates' => [ + 'questions' => [], + 'conditions' => [ + ['id' => 21, 'name' => 'Gate'], + ], + 'rules' => [], + 'tasks' => [ + ['id' => 41, 'task' => 'Start machine', 'gate_type' => 'ALWAYS', 'gate_ref_id' => null, 'services' => ['MACHINE'], 'order_priority' => 1], + ], + 'actions' => [ + [ + 'id' => 81, + 'name' => 'Open entry on start', + 'event' => 'wash_start_command', + 'wash_mode' => 'both', + 'operation' => 'open_lane_entrance_port', + 'condition_id' => 21, + 'order_priority' => 1, + 'options' => ['toggle_after_seconds' => 2], + ], + [ + 'id' => 82, + 'name' => 'Program picker off when machine starts', + 'event' => 'machine_start_triggered', + 'wash_mode' => 'machine', + 'operation' => 'set_program_picker_relay', + 'relay_state' => false, + 'order_priority' => 1, + ], + [ + 'id' => 83, + 'name' => 'Cleaner off on stop', + 'event' => 'wash_stop_command', + 'wash_mode' => 'machine', + 'operation' => 'set_cleaner_relay', + 'relay_state' => false, + 'order_priority' => 1, + ], + ], + ], + ], [ + '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' => 'PICKER-7', 'label' => 'Program picker', 'role' => 'PROGRAM_PICKER', 'services' => ['PROGRAM_PICKER']], + ['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' => 'PROGRAM_PICKER', 'relay_id' => 'PICKER-7'], + ['slot' => 'CLEANER', 'relay_id' => 'CLEAN-7'], + ['slot' => 'EXIT', 'relay_id' => 'EXIT-7'], + ], + ], + ], + ], + 'lookups' => [ + 'labels' => [ + 'lanes' => ['7' => 'Lane 7'], + 'conditions' => ['21' => 'Gate'], + 'tasks' => ['41' => 'Start machine'], + ], + ], + ]); + + expect(array_column($debug['signal_timeline'], 'sequence'))->toBe(range(1, 11)) + ->and(array_column($debug['signal_timeline'], 'relay_role'))->toBe([ + 'SESSION', + 'ENTRY', + 'MACHINE', + 'MACHINE', + 'PROGRAM_PICKER', + 'CLEANER', + 'CLEANER', + 'EXIT', + 'CLEANER', + 'MACHINE', + 'SESSION', + ]) + ->and($debug['signal_timeline'][1]['signal_type'])->toBe('studio_action_relay_pulse') + ->and($debug['signal_timeline'][1]['payload'])->toMatchArray(['action_id' => 81, 'id' => 'ENTRY-7', 'toggle_after' => 2]) + ->and($debug['signal_timeline'][4]['signal_type'])->toBe('studio_action_relay_switch') + ->and($debug['signal_timeline'][4]['payload'])->toMatchArray(['action_id' => 82, 'id' => 'PICKER-7', 'on' => false]) + ->and($debug['signal_timeline'][6]['payload'])->toMatchArray(['action_id' => 83, 'id' => 'CLEAN-7', 'on' => false]) + ->and($debug['actions'][0]['state'])->toBe('active') + ->and($debug['graph_annotations']['nodes']['action:81']['state'])->toBe('active'); +}); + it('adds ordered simulator signal timeline rows for virtual hardware dry runs', function (): void { $service = selfserve_wash_flow_without_constructor();