From eeccacb2a70c5b22690b89f52daffc77e191c259 Mon Sep 17 00:00:00 2001 From: Jeppe Bundgaard Date: Wed, 29 Apr 2026 09:52:51 +0200 Subject: [PATCH] Add unit tests for department lane dynamic image overrides and introduce classes for self-serve signal and virtual hardware management - Add `DepartmentLaneDynamicImageRouteTest` to verify dynamic image preview handling for studio lanes. - Introduce `selfserve_machine_signal` class to standardize signal normalization, recording, and gateway signal management workflows. - Add `selfserve_virtual_hardware` class to handle virtual hardware configurations, including gateway and binding management. - Enhance structure with auxiliary methods for payload normalization, workspace merging, and validation warnings. --- openapi.yaml | 94 ++ .../classes/selfserve_schema_bootstrap.php | 13 + services/nginx/app/cron/Cron.php | 32 +- .../dynamicimages/images/machine_1.php | 32 +- .../edgegateway/routes/edgeGatewaysRoute.php | 37 + .../classes/selfserve_condition_evaluator.php | 200 +++ .../classes/selfserve_config_versioning.php | 497 ++++++- .../classes/selfserve_machine_signal.php | 427 ++++++ .../classes/selfserve_studio_graph.php | 1186 ++++++++++++++++- .../classes/selfserve_virtual_hardware.php | 784 +++++++++++ .../selfserve/classes/selfserve_wash_flow.php | 475 ++++++- .../selfserve_condition_evaluator_i.php | 14 + .../traits/selfserve_lane_command_t.php | 25 +- .../objects/department_selfserve_tasks_o.php | 35 +- .../selfserve_wash_session_tasks_o.php | 2 +- services/nginx/app/openapi.yaml | 185 ++- .../resources/edge-gateway-agent/agent.php | 168 +++ .../edge-gateway-agent/lan-worker.php | 40 +- .../nginx/app/routes/departmentLanesRoute.php | 15 +- .../routes/departmentSelfserveStudioRoute.php | 27 + .../app/routes/machineButtonPressRoute.php | 55 + .../DepartmentLaneDynamicImageRouteTest.php | 11 + .../DynamicImagePreRenderCronWiringTest.php | 2 + .../SelfserveConditionEvaluatorTest.php | 80 ++ .../SelfserveConfigVersioningTest.php | 100 ++ .../Selfserve/SelfserveLaneStopFlowTest.php | 41 +- .../Selfserve/SelfserveMachineSignalTest.php | 51 + .../Selfserve/SelfserveOpenApiSpecTest.php | 1 + .../Selfserve/SelfserveRouteWiringTest.php | 17 + ...fserveSchemaBootstrapCompatibilityTest.php | 15 + .../Selfserve/SelfserveStudioGraphTest.php | 327 +++++ .../DepartmentLanesImageTest.php | 10 +- .../selfserve/ButtonsNormalizationTest.php | 22 +- 33 files changed, 4880 insertions(+), 140 deletions(-) create mode 100644 services/nginx/app/modules/selfserve/classes/selfserve_machine_signal.php create mode 100644 services/nginx/app/modules/selfserve/classes/selfserve_virtual_hardware.php create mode 100644 services/nginx/app/tests/Unit/DynamicImages/DepartmentLaneDynamicImageRouteTest.php create mode 100644 services/nginx/app/tests/Unit/Selfserve/SelfserveMachineSignalTest.php diff --git a/openapi.yaml b/openapi.yaml index fecd8d35..25880e20 100644 --- a/openapi.yaml +++ b/openapi.yaml @@ -3857,6 +3857,13 @@ paths: schema: type: integer minimum: 1 + - name: dynamic_image_id + in: query + required: false + description: Dynamic image ID to preview instead of the lane's saved image + schema: + type: integer + minimum: 1 - name: buttons in: query required: false @@ -7803,6 +7810,93 @@ paths: '404': $ref: '#/components/responses/NotFound' + /relay/machine/on/post: + get: + tags: + - Plate Scans + summary: Record Shelly machine ON signal webhook + description: Accepts Shelly Cloud webhook/query parameters for input.toggle_on or switch.on and records the physical machine start signal for self-serve billing. + operationId: recordShellyMachineOnSignal + parameters: + - name: token + in: query + required: false + schema: {type: string} + - name: lane_id + in: query + required: false + schema: + type: integer + - name: relay_id + in: query + required: false + schema: + type: string + - name: event + in: query + required: false + schema: + type: string + enum: [input.toggle_on, switch.on] + - name: reg + in: query + required: false + schema: + type: string + responses: + '201': + description: Machine ON signal recorded and linked to a self-serve wash session + content: + application/json: + schema: + $ref: '#/components/schemas/MachineButtonPressWebhookResponse' + '202': + description: Shelly signal was recognized but ignored + '400': + $ref: '#/components/responses/BadRequest' + post: + tags: + - Plate Scans + summary: Record Shelly machine ON signal webhook + description: Accepts Shelly Cloud JSON webhook payloads for input.toggle_on or switch.on and records the physical machine start signal for self-serve billing. + operationId: recordShellyMachineOnSignalPost + requestBody: + required: false + content: + application/json: + schema: + type: object + properties: + token: + type: string + lane_id: + type: integer + relay_id: + type: string + event: + type: string + enum: [input.toggle_on, switch.on] + component: + type: string + example: input:0 + state: + type: boolean + output: + type: boolean + reg: + type: string + responses: + '201': + description: Machine ON signal recorded and linked to a self-serve wash session + content: + application/json: + schema: + $ref: '#/components/schemas/MachineButtonPressWebhookResponse' + '202': + description: Shelly signal was recognized but ignored + '400': + $ref: '#/components/responses/BadRequest' + # Module - e-conomic Endpoints /economic/customers/import: post: diff --git a/services/nginx/app/classes/selfserve_schema_bootstrap.php b/services/nginx/app/classes/selfserve_schema_bootstrap.php index a4be70dc..bae32d22 100644 --- a/services/nginx/app/classes/selfserve_schema_bootstrap.php +++ b/services/nginx/app/classes/selfserve_schema_bootstrap.php @@ -126,6 +126,19 @@ class selfserve_schema_bootstrap INDEX idx_department_selfserve_studio_layouts_department_user (department_id, user_id), INDEX idx_department_selfserve_studio_layouts_department_updated (department_id, updated_at) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci", + + "CREATE TABLE IF NOT EXISTS department_selfserve_studio_virtual_hardware ( + id INT AUTO_INCREMENT PRIMARY KEY, + department_id INT NOT NULL, + config_json JSON NOT NULL, + created_by INT NULL, + updated_by INT NULL, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP NULL DEFAULT NULL ON UPDATE CURRENT_TIMESTAMP, + deleted_at TIMESTAMP NULL DEFAULT NULL, + UNIQUE KEY uniq_selfserve_vhw_department (department_id), + INDEX idx_selfserve_vhw_dept_updated (department_id, updated_at) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci", ]; foreach ($queries as $sql) { diff --git a/services/nginx/app/cron/Cron.php b/services/nginx/app/cron/Cron.php index 407b72bb..ba454e4c 100644 --- a/services/nginx/app/cron/Cron.php +++ b/services/nginx/app/cron/Cron.php @@ -493,7 +493,7 @@ function collectDynamicImageTaskGroupsForLane(array $laneRow): array /** * @param array> $taskRows - * @return array|null,current_step:int,only_current_step:bool,vehicle_type:int|null}> + * @return array|null,current_step:int,only_current_step:bool,vehicle_type:int|null}> */ function buildDynamicImageVariantsForTaskGroup(int $dynamicImageId, ?int $vehicleType, array $taskRows): array { @@ -515,7 +515,7 @@ function buildDynamicImageVariantsForTaskGroup(int $dynamicImageId, ?int $vehicl $buttons = parseDynamicImageButtons($row['buttons'] ?? null); if ($buttons !== []) { $buttonSets[] = $buttons; - $runningButtons = mergeUniqueIntValues($runningButtons, $buttons); + $runningButtons = mergeUniqueButtonValues($runningButtons, $buttons); $buttonSets[] = $runningButtons; } } @@ -554,7 +554,7 @@ function buildDynamicImageVariantsForTaskGroup(int $dynamicImageId, ?int $vehicl } /** - * @param array|null $buttons + * @param array|null $buttons */ function buildDynamicImageCacheKey(array $variant): string { @@ -575,7 +575,7 @@ function buildDynamicImageCacheKey(array $variant): string } /** - * @param array|null $buttons + * @param array|null $buttons */ function renderDynamicImageVariant(int $dynamicImageId, ?array $buttons, int $currentStep, bool $onlyCurrentStep): ?string { @@ -621,7 +621,7 @@ function renderDynamicImageVariant(int $dynamicImageId, ?array $buttons, int $cu /** * @param mixed $value - * @return array + * @return array */ function parseDynamicImageButtons(mixed $value): array { @@ -630,8 +630,7 @@ function parseDynamicImageButtons(mixed $value): array } try { - $normalized = department_selfserve_tasks_o::normalizeButtonsInput($value); - return array_values(array_map('intval', $normalized)); + return department_selfserve_tasks_o::normalizeButtonsInput($value); } catch (Throwable) { return []; } @@ -662,17 +661,22 @@ function normalizeDynamicImageVehicleType(mixed $value): ?int } /** - * @param array $base - * @param array $append - * @return array + * @param array $base + * @param array $append + * @return array */ -function mergeUniqueIntValues(array $base, array $append): array +function mergeUniqueButtonValues(array $base, array $append): array { $result = $base; + $seen = []; + foreach ($result as $value) { + $seen[(is_int($value) ? 'int:' : 'string:') . (string)$value] = true; + } foreach ($append as $value) { - $intValue = (int)$value; - if (!in_array($intValue, $result, true)) { - $result[] = $intValue; + $key = (is_int($value) ? 'int:' : 'string:') . (string)$value; + if (!isset($seen[$key])) { + $seen[$key] = true; + $result[] = $value; } } return array_values($result); diff --git a/services/nginx/app/modules/dynamicimages/images/machine_1.php b/services/nginx/app/modules/dynamicimages/images/machine_1.php index 434c06a0..2ce9eadf 100644 --- a/services/nginx/app/modules/dynamicimages/images/machine_1.php +++ b/services/nginx/app/modules/dynamicimages/images/machine_1.php @@ -17,6 +17,8 @@ class machine_1 extends dynamicimages_image const IMAGE_BUTTON_HIGHLIGHTED_GREY = 'machine_1_button_highlighted_grey.png'; const IMAGE_BUTTON_HIGHLIGHTED_COMPLETED = 'machine_1_button_highlighted_completed_grey.png'; const IMAGE_BUTTON_HIGHLIGHTED_GREEN = 'machine_1_button_highlighted_green.png'; + const BUTTON_RESET = 'reset'; + const BUTTON_START = 'start'; // Thumb public int $thumb_position = 1; // 0-11 (default: 0 = up = 270 degrees) public int $thumb_size = 1550; // height and width of the thumb @@ -174,11 +176,33 @@ class machine_1 extends dynamicimages_image return self::IMAGE_BUTTON_HIGHLIGHTED_GREY; } + private function isHighlightedButton(int|string $button): bool + { + foreach ($this->highlighted_buttons as $highlightedButton) { + if (is_int($button)) { + if (is_numeric($highlightedButton) && (int)$highlightedButton === $button) { + return true; + } + continue; + } + + if (is_string($highlightedButton) && strtolower(trim($highlightedButton)) === $button) { + return true; + } + } + + return false; + } + /** * @throws \Exception */ public function drawHighlightedStartButton(): void { + if (!$this->isHighlightedButton(self::BUTTON_START)) { + return; + } + $x = 4965; // X position for the start button $y = 1980; // Y position for the start button $tmp = new dynamicimages_asset($this->getAssetPath(self::getHighlightedAssetName())); @@ -200,6 +224,10 @@ class machine_1 extends dynamicimages_image */ public function drawHighlightedResetButton(): void { + if (!$this->isHighlightedButton(self::BUTTON_RESET)) { + return; + } + $x = 1736; // X position for the reset button $y = 599; // Y position for the reset button $tmp = new dynamicimages_asset($this->getAssetPath(self::getHighlightedAssetName())); @@ -404,7 +432,7 @@ class machine_1 extends dynamicimages_image for ($col = 0; $col < $this->button_columns; $col++) { // If the current button position is not in the highlighted buttons array, skip it $buttonIndex = $row * $this->button_columns + $col; - if (!in_array($buttonIndex, $this->highlighted_buttons, true)) { + if (!$this->isHighlightedButton($buttonIndex)) { continue; } // Calculate the position for the current button @@ -529,4 +557,4 @@ class machine_1 extends dynamicimages_image $tmp->clearMemoryImage(); } } -} \ No newline at end of file +} diff --git a/services/nginx/app/modules/edgegateway/routes/edgeGatewaysRoute.php b/services/nginx/app/modules/edgegateway/routes/edgeGatewaysRoute.php index edd14b3f..3fbcd00d 100644 --- a/services/nginx/app/modules/edgegateway/routes/edgeGatewaysRoute.php +++ b/services/nginx/app/modules/edgegateway/routes/edgeGatewaysRoute.php @@ -11,6 +11,7 @@ use classes\edge_gateway_registry_service; use classes\edge_gateway_view_service; use classes\response; use Exception; +use modules\selfserve\classes\selfserve_machine_signal; use traits\route_t; class edgeGatewaysRoute @@ -98,6 +99,8 @@ class edgeGatewaysRoute $this->post('/edge-agent/gateways/{id}/commands/poll', fn() => $this->handleAgentCommandPoll()); $this->post('/edge-agent/gateways/{id}/commands/{jobId}/result', fn() => $this->handleAgentCommandResult()); $this->post('/edge-agent/gateways/{id}/presence', fn() => $this->handleAgentPresence()); + $this->post('/edge-agent/gateways/{id}/selfserve/machine-signal-bindings', fn() => $this->handleAgentSelfserveMachineSignalBindings()); + $this->post('/edge-agent/gateways/{id}/selfserve/machine-signal', fn() => $this->handleAgentSelfserveMachineSignal()); $this->post('/edge-agent/internal/gateways/{id}/validate', fn() => $this->handleBrokerGatewayValidate()); $this->post('/edge-agent/internal/gateways/{id}/presence', fn() => $this->handleBrokerGatewayPresence()); @@ -569,6 +572,40 @@ class edgeGatewaysRoute )); } + private function handleAgentSelfserveMachineSignalBindings(): void + { + global /** @var response $response */ $response; + $gatewayId = (int)$this->fromRoute('id'); + $payload = self::getParametersAsArray(); + + try { + $response->success((new selfserve_machine_signal())->listEdgeGatewayMachineSignalMonitors( + $gatewayId, + $this->requireAgentToken($payload) + )); + } catch (\Throwable $exception) { + $response->error($exception->getMessage(), 400); + } + } + + private function handleAgentSelfserveMachineSignal(): void + { + global /** @var response $response */ $response; + $gatewayId = (int)$this->fromRoute('id'); + $payload = self::getParametersAsArray(); + + try { + $result = (new selfserve_machine_signal())->recordEdgeGatewaySignal( + $gatewayId, + $this->requireAgentToken($payload), + $payload + ); + $response->success($result, !empty($result['recorded']) ? 201 : 202); + } catch (\Throwable $exception) { + $response->error($exception->getMessage(), 400); + } + } + private function handleBrokerGatewayValidate(): void { global /** @var response $response */ $response; 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 5fde45b2..7d22f609 100644 --- a/services/nginx/app/modules/selfserve/classes/selfserve_condition_evaluator.php +++ b/services/nginx/app/modules/selfserve/classes/selfserve_condition_evaluator.php @@ -14,6 +14,14 @@ use modules\selfserve\interfaces\selfserve_condition_evaluator_i; class selfserve_condition_evaluator implements selfserve_condition_evaluator_i { + private const V2_OPERATORS = [ + 'IS_TRUE', + 'IS_FALSE', + 'IS_SET', + 'IS_TRUE_OR_NOT_SET', + 'IS_FALSE_OR_NOT_SET', + ]; + public function evaluate(array $conditions, array $rules, array $answers): array { $rulesByConditionId = []; @@ -89,6 +97,80 @@ class selfserve_condition_evaluator implements selfserve_condition_evaluator_i return $results; } + public function evaluateExpressions(array $conditions, array $answers): array + { + return $this->evaluateExpressionsWithTrace($conditions, $answers)['results']; + } + + public function evaluateExpressionsWithTrace(array $conditions, array $answers): array + { + $conditionsById = []; + foreach ($conditions as $condition) { + $conditionId = (int)($condition['id'] ?? 0); + if ($conditionId > 0) { + $conditionsById[$conditionId] = $condition; + } + } + + $results = []; + $trace = []; + $resolving = []; + + $resolver = function (int $conditionId) use (&$resolver, &$results, &$trace, &$resolving, $conditionsById, $answers): bool { + if (array_key_exists($conditionId, $results)) { + return $results[$conditionId]; + } + if (isset($resolving[$conditionId])) { + $results[$conditionId] = false; + $trace[$conditionId] = [ + 'type' => 'cycle', + 'condition_id' => $conditionId, + 'result' => false, + 'reason' => 'Condition dependency cycle detected.', + ]; + return false; + } + + $condition = $conditionsById[$conditionId] ?? null; + if (!is_array($condition)) { + $results[$conditionId] = false; + $trace[$conditionId] = [ + 'type' => 'missing_condition', + 'condition_id' => $conditionId, + 'result' => false, + 'reason' => 'Condition was not found.', + ]; + return false; + } + + $expression = is_array($condition['expression'] ?? null) + ? (array)$condition['expression'] + : $this->emptyExpression(); + + $resolving[$conditionId] = true; + $evaluated = $this->evaluateExpressionNode($expression, $answers, $resolver); + unset($resolving[$conditionId]); + + $results[$conditionId] = (bool)($evaluated['result'] ?? false); + $trace[$conditionId] = [ + 'type' => 'condition', + 'condition_id' => $conditionId, + 'result' => $results[$conditionId], + 'expression' => $evaluated, + ]; + return $results[$conditionId]; + }; + + foreach (array_keys($conditionsById) as $conditionId) { + $resolver((int)$conditionId); + } + + return [ + 'results' => $results, + 'trace' => $trace, + ]; + } + public function taskGateSatisfied(?int $gateId, array $conditionResults, array $answers): bool { if ($gateId === null || $gateId <= 0) { @@ -147,4 +229,122 @@ class selfserve_condition_evaluator implements selfserve_condition_evaluator_i selfserve_condition_rule_type::IS_FALSE_OR_NOT_SET => $value === false || $value === null, }; } + + /** + * @param array $node + * @param array $answers + * @param callable(int):bool $conditionResolver + * @return array + */ + private function evaluateExpressionNode(array $node, array $answers, callable $conditionResolver): array + { + $type = strtolower((string)($node['type'] ?? $node['kind'] ?? 'group')); + if ($type === 'predicate') { + return $this->evaluateExpressionPredicate($node, $answers, $conditionResolver); + } + + $operator = strtoupper((string)($node['operator'] ?? $node['mode'] ?? 'ALL')); + if (!in_array($operator, ['ALL', 'ANY'], true)) { + $operator = 'ALL'; + } + + $children = is_array($node['children'] ?? null) ? array_values((array)$node['children']) : []; + if ($children === []) { + return [ + 'type' => 'group', + 'operator' => $operator, + 'result' => false, + 'children' => [], + 'reason' => 'Group has no predicates.', + ]; + } + + $childTraces = []; + foreach ($children as $child) { + if (!is_array($child)) { + continue; + } + $childTraces[] = $this->evaluateExpressionNode((array)$child, $answers, $conditionResolver); + } + + if ($childTraces === []) { + $result = false; + } elseif ($operator === 'ANY') { + $result = count(array_filter($childTraces, static fn(array $child): bool => ($child['result'] ?? false) === true)) > 0; + } else { + $result = count(array_filter($childTraces, static fn(array $child): bool => ($child['result'] ?? false) !== true)) === 0; + } + + return [ + 'type' => 'group', + 'operator' => $operator, + 'result' => $result, + 'children' => $childTraces, + 'reason' => $result ? 'Group passed.' : 'Group did not pass.', + ]; + } + + /** + * @param array $node + * @param array $answers + * @param callable(int):bool $conditionResolver + * @return array + */ + private function evaluateExpressionPredicate(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); + $operator = strtoupper((string)($node['operator'] ?? $node['rule_type'] ?? '')); + + if (!in_array($operator, self::V2_OPERATORS, true)) { + return [ + 'type' => 'predicate', + 'subject_type' => $subjectType, + 'subject_id' => $subjectId, + 'operator' => $operator, + 'actual_value' => null, + 'result' => false, + 'reason' => 'Unsupported predicate operator.', + ]; + } + + if ($subjectType === 'condition') { + $actual = $subjectId > 0 ? $conditionResolver($subjectId) : null; + } elseif ($subjectType === 'question') { + $actual = $answers[$subjectId] ?? null; + } else { + $actual = null; + } + + $result = match ($operator) { + 'IS_TRUE' => $actual === true, + 'IS_FALSE' => $actual === false, + 'IS_SET' => $actual !== null, + 'IS_TRUE_OR_NOT_SET' => $actual === true || $actual === null, + 'IS_FALSE_OR_NOT_SET' => $actual === false || $actual === null, + default => false, + }; + + return [ + 'type' => 'predicate', + 'subject_type' => $subjectType, + 'subject_id' => $subjectId, + 'operator' => $operator, + 'actual_value' => $actual, + 'result' => $result, + 'reason' => $result ? 'Predicate passed.' : 'Predicate did not pass.', + ]; + } + + /** + * @return array + */ + private function emptyExpression(): array + { + return [ + 'type' => 'group', + 'operator' => 'ALL', + 'children' => [], + ]; + } } 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 6a9f54a5..be9b1de0 100644 --- a/services/nginx/app/modules/selfserve/classes/selfserve_config_versioning.php +++ b/services/nginx/app/modules/selfserve/classes/selfserve_config_versioning.php @@ -25,6 +25,15 @@ class selfserve_config_versioning public const STATUS_DRAFT = 'DRAFT'; public const STATUS_PUBLISHED = 'PUBLISHED'; public const STATUS_ARCHIVED = 'ARCHIVED'; + public const SCHEMA_VERSION_V2 = 2; + + private const V2_PREDICATE_OPERATORS = [ + 'IS_TRUE', + 'IS_FALSE', + 'IS_SET', + 'IS_TRUE_OR_NOT_SET', + 'IS_FALSE_OR_NOT_SET', + ]; public function __construct() { @@ -47,6 +56,24 @@ class selfserve_config_versioning ]; } + /** + * @return array{version_id:int,config:array}|null + */ + public function getPublishedV2Config(int $departmentId): ?array + { + $published = $this->getPublishedConfig($departmentId); + if (!is_array($published) || !$this->isV2Config((array)($published['config'] ?? []))) { + return null; + } + + return $published; + } + + public function isV2Config(array $config): bool + { + return (int)($config['schema_version'] ?? 0) === self::SCHEMA_VERSION_V2; + } + /** * @return array */ @@ -57,9 +84,11 @@ class selfserve_config_versioning $validation = $this->validateConfig($config); if ($versionObject->exists()) { - if ($forceRefresh) { - $versionObject->config_json->set($config); - $versionObject->validation_result_json->set($validation); + $existingConfig = (array)($versionObject->config_json->value() ?? []); + if ($forceRefresh || !$this->isV2Config($existingConfig)) { + $nextConfig = $forceRefresh ? $config : $this->migrateLegacyConfigToV2($existingConfig + ['department_id' => $departmentId]); + $versionObject->config_json->set($nextConfig); + $versionObject->validation_result_json->set($this->validateConfig($nextConfig)); } return $versionObject->asArray(); } @@ -117,7 +146,13 @@ class selfserve_config_versioning $draft = (new selfserve_config_versions_o())->select((int)$created['id']); } - $validation = $this->validateConfig((array)($draft->config_json->value() ?? [])); + $config = (array)($draft->config_json->value() ?? []); + if (!$this->isV2Config($config)) { + $config = $this->migrateLegacyConfigToV2($config + ['department_id' => $departmentId]); + $draft->config_json->set($config); + } + + $validation = $this->validateConfig($config); $draft->validation_result_json->set($validation); if (($validation['valid'] ?? false) !== true) { throw new \RuntimeException('Draft validation failed. Resolve errors before publishing.'); @@ -132,7 +167,7 @@ class selfserve_config_versioning // Keep editing path open by creating a new draft cloned from newly published version. $publishedArray = $draft->asArray(); - $this->createDraftFromConfig($departmentId, (array)$draft->config_json->value(), (int)$draft->id, $publishedBy); + $this->createDraftFromConfig($departmentId, $config, (int)$draft->id, $publishedBy); return $publishedArray; } @@ -148,6 +183,9 @@ class selfserve_config_versioning } $config = (array)($target->config_json->value() ?? []); + if (!$this->isV2Config($config)) { + $config = $this->migrateLegacyConfigToV2($config + ['department_id' => $departmentId]); + } $validation = $this->validateConfig($config); if (($validation['valid'] ?? false) !== true) { throw new \RuntimeException('Target version cannot be rolled back because validation fails.'); @@ -187,6 +225,14 @@ class selfserve_config_versioning * @return array */ public function snapshotLegacyConfig(int $departmentId): array + { + return $this->migrateLegacyConfigToV2($this->snapshotLegacyTableConfig($departmentId)); + } + + /** + * @return array + */ + protected function snapshotLegacyTableConfig(int $departmentId): array { $questionsObject = new department_selfserve_questions_o(); $conditionsObject = new department_selfserve_conditions_o(); @@ -248,12 +294,68 @@ class selfserve_config_versioning ]; } + /** + * @param array $legacyConfig + * @return array + */ + public function migrateLegacyConfigToV2(array $legacyConfig): array + { + if ($this->isV2Config($legacyConfig)) { + return $this->normalizeV2Config($legacyConfig); + } + + $rulesByCondition = []; + foreach ((array)($legacyConfig['rules'] ?? []) as $rule) { + if (!is_array($rule)) { + continue; + } + $rulesByCondition[(int)($rule['condition_id'] ?? 0)][] = $rule; + } + + $migrationIssues = []; + $conditions = []; + foreach ((array)($legacyConfig['conditions'] ?? []) as $condition) { + if (!is_array($condition)) { + continue; + } + $conditionId = (int)($condition['id'] ?? 0); + $conditionRules = array_values((array)($rulesByCondition[$conditionId] ?? [])); + $condition['expression'] = $this->migrateLegacyRulesToExpression($conditionId, $conditionRules, $migrationIssues); + $conditions[] = $condition; + } + + $config = [ + 'schema_version' => self::SCHEMA_VERSION_V2, + 'department_id' => (int)($legacyConfig['department_id'] ?? 0), + 'questions' => array_values((array)($legacyConfig['questions'] ?? [])), + 'conditions' => array_values($conditions), + 'rules' => [], + 'tasks' => array_values((array)($legacyConfig['tasks'] ?? [])), + 'v2_meta' => [ + 'migrated_from' => (int)($legacyConfig['schema_version'] ?? 1), + 'migrated_at' => date('c'), + 'source' => (string)($legacyConfig['snapshot_meta']['source'] ?? 'legacy_config'), + 'next_ids' => $this->nextIdsForConfig($legacyConfig), + ], + ]; + + if ($migrationIssues !== []) { + $config['migration_issues'] = $migrationIssues; + } + + return $this->normalizeV2Config($config); + } + /** * @param array $config * @return array */ public function validateConfig(array $config): array { + if ($this->isV2Config($config)) { + return $this->validateV2Config($config); + } + $errors = []; $warnings = []; @@ -351,11 +453,223 @@ class selfserve_config_versioning ]; } + /** + * @param array $config + * @return array + */ + protected function validateV2Config(array $config): array + { + $errors = []; + $warnings = []; + + foreach ((array)($config['migration_issues'] ?? []) as $issue) { + if (is_array($issue)) { + $errors[] = (string)($issue['message'] ?? 'Migration issue detected.'); + } else { + $errors[] = (string)$issue; + } + } + + $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']) : []; + + $questionIds = []; + foreach ($questions as $question) { + $id = (int)($question['id'] ?? 0); + if ($id <= 0) { + $errors[] = 'Question without valid id.'; + continue; + } + $questionIds[$id] = true; + } + + $conditionIds = []; + $conditionParents = []; + foreach ($conditions as $condition) { + $id = (int)($condition['id'] ?? 0); + if ($id <= 0) { + $errors[] = 'Condition without valid id.'; + continue; + } + $conditionIds[$id] = true; + $conditionParents[$id] = $this->nullableInt($condition['condition_id'] ?? null); + } + + $usedConditionIds = []; + $conditionEdges = []; + $conditionHasPredicate = []; + foreach ($conditions as $condition) { + $conditionId = (int)($condition['id'] ?? 0); + if ($conditionId <= 0) { + continue; + } + $parentId = $conditionParents[$conditionId] ?? null; + if ($parentId !== null) { + if (!isset($conditionIds[$parentId])) { + $errors[] = 'Condition ' . $conditionId . ' references unknown parent condition_id ' . $parentId; + } + if ($parentId === $conditionId) { + $errors[] = 'Condition ' . $conditionId . ' cannot reference itself as parent condition.'; + } + $conditionEdges[$conditionId][] = $parentId; + } + + $expression = is_array($condition['expression'] ?? null) ? (array)$condition['expression'] : $this->emptyV2Expression(); + $expressionValidation = $this->validateExpressionNode( + $expression, + $conditionId, + $questionIds, + $conditionIds, + $usedConditionIds, + $conditionEdges, + ); + $conditionHasPredicate[$conditionId] = $expressionValidation['has_predicate']; + foreach ($expressionValidation['errors'] as $message) { + $errors[] = $message; + } + } + + foreach ($questions as $question) { + $conditionId = $this->nullableInt($question['condition_id'] ?? null); + if ($conditionId === null) { + continue; + } + $usedConditionIds[$conditionId] = true; + if (!isset($conditionIds[$conditionId])) { + $errors[] = 'Question ' . (int)($question['id'] ?? 0) . ' references unknown visibility condition_id ' . $conditionId; + } + } + + foreach ($tasks as $task) { + $taskId = (int)($task['id'] ?? 0); + $gateTypeRaw = strtoupper((string)($task['gate_type'] ?? selfserve_task_gate_type::ALWAYS->value)); + $gateType = selfserve_task_gate_type::tryFrom($gateTypeRaw); + if ($gateType === null) { + $errors[] = 'Task ' . $taskId . ' has invalid gate_type `' . $gateTypeRaw . '`.'; + continue; + } + + $gateRefId = $this->nullableInt($task['gate_ref_id'] ?? null); + if ($gateType === selfserve_task_gate_type::ALWAYS) { + continue; + } + if ($gateRefId === null) { + $errors[] = 'Task ' . $taskId . ' requires gate_ref_id for gate_type ' . $gateType->value; + continue; + } + if ($gateType === selfserve_task_gate_type::CONDITION) { + $usedConditionIds[$gateRefId] = true; + if (!isset($conditionIds[$gateRefId])) { + $errors[] = 'Task ' . $taskId . ' references unknown condition gate_ref_id ' . $gateRefId; + } + } + if ($gateType === selfserve_task_gate_type::QUESTION && !isset($questionIds[$gateRefId])) { + $errors[] = 'Task ' . $taskId . ' references unknown question gate_ref_id ' . $gateRefId; + } + } + + 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.'; + } + } + + foreach ($this->detectDirectedConditionCycles($conditionEdges) as $cycle) { + $errors[] = 'Condition cycle detected: ' . implode(' -> ', $cycle); + } + + return [ + 'valid' => $errors === [], + 'errors' => array_values(array_unique($errors)), + 'warnings' => $warnings, + 'stats' => [ + 'schema_version' => self::SCHEMA_VERSION_V2, + 'questions' => count($questions), + 'conditions' => count($conditions), + 'rules' => 0, + 'tasks' => count($tasks), + ], + 'validated_at' => date('c'), + ]; + } + + /** + * @param array $expression + * @param array $questionIds + * @param array $conditionIds + * @param array $usedConditionIds + * @param array> $conditionEdges + * @return array{errors:array,has_predicate:bool} + */ + protected function validateExpressionNode(array $expression, int $ownerConditionId, array $questionIds, array $conditionIds, array &$usedConditionIds, array &$conditionEdges): array + { + $errors = []; + $type = strtolower((string)($expression['type'] ?? $expression['kind'] ?? 'group')); + if ($type === 'predicate') { + $subjectType = strtolower((string)($expression['subject_type'] ?? $expression['object_type'] ?? '')); + $subjectId = (int)($expression['subject_id'] ?? $expression['object_id'] ?? 0); + $operator = strtoupper((string)($expression['operator'] ?? $expression['rule_type'] ?? '')); + if (!in_array($operator, self::V2_PREDICATE_OPERATORS, true)) { + $errors[] = 'Condition ' . $ownerConditionId . ' has invalid predicate operator `' . $operator . '`.'; + } + if ($subjectType === 'question') { + if ($subjectId <= 0 || !isset($questionIds[$subjectId])) { + $errors[] = 'Condition ' . $ownerConditionId . ' references unknown question predicate subject_id ' . $subjectId; + } + } elseif ($subjectType === 'condition') { + $usedConditionIds[$subjectId] = true; + $conditionEdges[$ownerConditionId][] = $subjectId; + if ($subjectId === $ownerConditionId) { + $errors[] = 'Condition ' . $ownerConditionId . ' cannot reference itself in an expression.'; + } + if ($subjectId <= 0 || !isset($conditionIds[$subjectId])) { + $errors[] = 'Condition ' . $ownerConditionId . ' references unknown condition predicate subject_id ' . $subjectId; + } + } else { + $errors[] = 'Condition ' . $ownerConditionId . ' has unsupported predicate subject_type `' . $subjectType . '`.'; + } + + return [ + 'errors' => $errors, + 'has_predicate' => true, + ]; + } + + $operator = strtoupper((string)($expression['operator'] ?? $expression['mode'] ?? 'ALL')); + if (!in_array($operator, ['ALL', 'ANY'], true)) { + $errors[] = 'Condition ' . $ownerConditionId . ' has invalid group operator `' . $operator . '`.'; + } + + $children = is_array($expression['children'] ?? null) ? array_values((array)$expression['children']) : []; + $hasPredicate = false; + foreach ($children as $child) { + if (!is_array($child)) { + $errors[] = 'Condition ' . $ownerConditionId . ' has an invalid expression child.'; + continue; + } + $childValidation = $this->validateExpressionNode((array)$child, $ownerConditionId, $questionIds, $conditionIds, $usedConditionIds, $conditionEdges); + $hasPredicate = $hasPredicate || $childValidation['has_predicate']; + foreach ($childValidation['errors'] as $message) { + $errors[] = $message; + } + } + + return [ + 'errors' => $errors, + 'has_predicate' => $hasPredicate, + ]; + } + protected function createDraftFromConfig(int $departmentId, array $config, ?int $sourceVersionId, ?int $createdBy): void { // Remove stale drafts first. $this->deleteAllDrafts($departmentId); + if (!$this->isV2Config($config)) { + $config = $this->migrateLegacyConfigToV2($config + ['department_id' => $departmentId]); + } + $validation = $this->validateConfig($config); $latestVersionNumber = $this->getLatestVersionNumber($departmentId); (new selfserve_config_versions_o())->add( @@ -470,4 +784,177 @@ class selfserve_config_versioning return $cycles; } + + /** + * @param array> $rules + * @param array> $migrationIssues + * @return array + */ + protected function migrateLegacyRulesToExpression(int $conditionId, array $rules, array &$migrationIssues): array + { + $allChildren = []; + $anyChildren = []; + + foreach ($rules as $rule) { + $predicate = $this->legacyRuleToPredicate($conditionId, $rule, $migrationIssues); + if ($predicate === null) { + continue; + } + + if (strtoupper((string)($rule['type'] ?? '')) === 'IS_TRUE_OR_ANY_TRUE') { + $anyChildren[] = $predicate; + } else { + $allChildren[] = $predicate; + } + } + + if ($anyChildren !== []) { + $allChildren[] = [ + 'type' => 'group', + 'operator' => 'ANY', + 'children' => $anyChildren, + ]; + } + + return [ + 'type' => 'group', + 'operator' => 'ALL', + 'children' => $allChildren, + ]; + } + + /** + * @param array $rule + * @param array> $migrationIssues + * @return array|null + */ + protected function legacyRuleToPredicate(int $conditionId, array $rule, array &$migrationIssues): ?array + { + $ruleId = (int)($rule['id'] ?? 0); + $objectType = strtolower((string)($rule['object_type'] ?? '')); + if (!in_array($objectType, ['question', 'condition'], true)) { + $migrationIssues[] = [ + 'severity' => 'error', + 'condition_id' => $conditionId, + 'rule_id' => $ruleId, + 'message' => 'Rule ' . $ruleId . ' uses unsupported object_type `' . $objectType . '` and cannot be migrated to v2.', + ]; + return null; + } + + $legacyType = strtoupper((string)($rule['type'] ?? '')); + $operator = $legacyType === 'IS_TRUE_OR_ANY_TRUE' ? 'IS_TRUE' : $legacyType; + if (!in_array($operator, self::V2_PREDICATE_OPERATORS, true)) { + $migrationIssues[] = [ + 'severity' => 'error', + 'condition_id' => $conditionId, + 'rule_id' => $ruleId, + 'message' => 'Rule ' . $ruleId . ' uses unsupported type `' . $legacyType . '` and cannot be migrated to v2.', + ]; + return null; + } + + return [ + 'type' => 'predicate', + 'subject_type' => $objectType, + 'subject_id' => (int)($rule['object_id'] ?? 0), + 'operator' => $operator, + 'legacy_rule_id' => $ruleId > 0 ? $ruleId : null, + ]; + } + + /** + * @param array $config + * @return array + */ + protected function normalizeV2Config(array $config): array + { + $config['schema_version'] = self::SCHEMA_VERSION_V2; + $config['questions'] = array_values((array)($config['questions'] ?? [])); + $config['conditions'] = array_values(array_map(function ($condition): array { + $condition = is_array($condition) ? $condition : []; + if (!is_array($condition['expression'] ?? null)) { + $condition['expression'] = $this->emptyV2Expression(); + } + return $condition; + }, (array)($config['conditions'] ?? []))); + $config['rules'] = []; + $config['tasks'] = array_values((array)($config['tasks'] ?? [])); + $config['v2_meta'] = is_array($config['v2_meta'] ?? null) ? (array)$config['v2_meta'] : []; + $config['v2_meta']['next_ids'] = $this->nextIdsForConfig($config); + return $config; + } + + /** + * @param array $config + * @return array + */ + protected function nextIdsForConfig(array $config): array + { + $next = []; + foreach (['questions' => 'question', 'conditions' => 'condition', 'tasks' => 'task'] as $key => $name) { + $max = 0; + foreach ((array)($config[$key] ?? []) as $row) { + if (is_array($row)) { + $max = max($max, (int)($row['id'] ?? 0)); + } + } + $next[$name] = $max + 1; + } + return $next; + } + + /** + * @return array + */ + protected function emptyV2Expression(): array + { + return [ + 'type' => 'group', + 'operator' => 'ALL', + 'children' => [], + ]; + } + + /** + * @param array> $edges + * @return array> + */ + protected function detectDirectedConditionCycles(array $edges): array + { + $cycles = []; + $visiting = []; + $visited = []; + $stack = []; + + $walk = function (int $conditionId) use (&$walk, &$cycles, &$visiting, &$visited, &$stack, $edges): void { + if (isset($visited[$conditionId])) { + return; + } + if (isset($visiting[$conditionId])) { + $start = array_search($conditionId, $stack, true); + $cycle = array_slice($stack, $start === false ? 0 : (int)$start); + $cycle[] = $conditionId; + $cycles[] = $cycle; + return; + } + + $visiting[$conditionId] = true; + $stack[] = $conditionId; + foreach (array_unique(array_map('intval', (array)($edges[$conditionId] ?? []))) as $nextId) { + if ($nextId > 0) { + $walk($nextId); + } + } + array_pop($stack); + unset($visiting[$conditionId]); + $visited[$conditionId] = true; + }; + + foreach (array_keys($edges) as $conditionId) { + $walk((int)$conditionId); + } + + return $cycles; + } } diff --git a/services/nginx/app/modules/selfserve/classes/selfserve_machine_signal.php b/services/nginx/app/modules/selfserve/classes/selfserve_machine_signal.php new file mode 100644 index 00000000..2e74ae05 --- /dev/null +++ b/services/nginx/app/modules/selfserve/classes/selfserve_machine_signal.php @@ -0,0 +1,427 @@ + $payload + * @return array + */ + public function normalizeShellyPayload(array $payload): array + { + if (isset($payload['events']) && is_array($payload['events'])) { + foreach ((array)$payload['events'] as $eventPayload) { + if (is_array($eventPayload)) { + $payload = array_replace($payload, (array)$eventPayload); + break; + } + } + } + + $event = $this->firstString($payload, ['event', 'event_type', 'eventType', 'name', 'type']); + $component = $this->normalizeComponent($this->firstString($payload, ['component', 'component_id', 'componentId'])); + $relayId = $this->firstString($payload, ['relay_id', 'logical_relay_id', 'logicalRelayId', 'relayId']); + $deviceId = $this->firstString($payload, ['device_id', 'deviceId', 'device']); + $channel = $this->firstInt($payload, ['channel', 'id', 'input_id', 'switch_id']); + $on = $this->extractOnState($payload); + $eventName = strtolower(trim((string)$event)); + + if ($component === null && str_starts_with($eventName, 'input.')) { + $component = 'input'; + } + if ($component === null && str_starts_with($eventName, 'switch.')) { + $component = 'switch'; + } + + $positiveEvents = [ + 'on', + 'toggle_on', + 'btn_down', + 'single_push', + 'machine.on', + 'switch.on', + 'switch.toggle_on', + 'input.on', + 'input.toggle_on', + 'input.btn_down', + 'input.single_push', + ]; + $negativeEvents = [ + 'off', + 'toggle_off', + 'btn_up', + 'machine.off', + 'switch.off', + 'switch.toggle_off', + 'input.off', + 'input.toggle_off', + 'input.btn_up', + ]; + + $eventIsOn = in_array($eventName, $positiveEvents, true); + $eventIsOff = in_array($eventName, $negativeEvents, true); + $recognized = $eventIsOn || $eventIsOff || $on !== null; + $onState = $eventIsOn || ($on === true && !$eventIsOff); + + return [ + 'recognized' => $recognized, + 'on' => $onState, + 'event' => $event !== null ? (string)$event : null, + 'component' => $component, + 'relay_id' => $relayId, + 'device_id' => $deviceId, + 'channel' => $channel, + 'source' => (string)($payload['source'] ?? 'shelly'), + 'raw_status' => $this->extractStatusPayload($payload), + ]; + } + + /** + * @param array $payload + * @param array $context + * @return array + */ + public function recordCloudShellySignal(int $departmentId, ?int $laneId, array $payload, array $context = []): array + { + $signal = $this->normalizeShellyPayload($payload + ['source' => 'shelly_cloud']); + if (!$signal['recognized']) { + return [ + 'recorded' => false, + 'ignored' => true, + 'reason' => 'Payload does not contain a recognized Shelly ON/OFF signal.', + 'signal' => $signal, + ]; + } + if (!$signal['on']) { + return [ + 'recorded' => false, + 'ignored' => true, + 'reason' => 'Shelly signal was recognized but it was not ON.', + 'signal' => $signal, + ]; + } + + $resolvedLaneId = $this->resolveLaneId($departmentId, $laneId, $signal['relay_id'] ?? null); + $summary = (new selfserve_wash_flow())->recordMachineStartWebhook( + $resolvedLaneId, + $this->extractRegistration($payload), + $payload + [ + 'source' => 'shelly_cloud', + 'shelly_signal' => $signal, + 'context' => $context, + ] + ); + + return [ + 'recorded' => true, + 'ignored' => false, + 'lane_id' => $resolvedLaneId, + 'signal' => $signal, + 'selfserve' => $summary, + ]; + } + + /** + * @param array $payload + * @return array + */ + public function recordEdgeGatewaySignal(int $gatewayId, string $agentToken, array $payload): array + { + $gateway = (new edge_gateway_manager())->authenticateGateway($gatewayId, $agentToken); + $departmentId = (int)$gateway->department_id->value(); + + return $this->recordCloudShellySignal( + $departmentId, + isset($payload['lane_id']) ? (int)$payload['lane_id'] : null, + $payload + [ + 'source' => 'edge_gateway', + 'gateway_id' => $gatewayId, + ], + [ + 'source' => 'edge_gateway', + 'gateway_id' => $gatewayId, + 'agent_instance_id' => $payload['agent_instance_id'] ?? null, + ] + ); + } + + /** + * @return array + */ + public function listEdgeGatewayMachineSignalMonitors(int $gatewayId, string $agentToken): array + { + $manager = new edge_gateway_manager(); + $gateway = $manager->authenticateGateway($gatewayId, $agentToken); + $departmentId = (int)$gateway->department_id->value(); + + $bindings = []; + foreach ($this->bindingRows($gatewayId, $departmentId) as $binding) { + $relayId = trim((string)($binding['relay_id'] ?? '')); + if ($relayId !== '') { + $bindings[$relayId] = $binding; + } + } + + $monitors = []; + foreach ((new department_lanes_o())->getDepartmentLanes($departmentId) as $lane) { + $relayId = trim((string)$lane->relay_machine_id->value()); + if ($relayId === '' || !isset($bindings[$relayId])) { + continue; + } + + $binding = $bindings[$relayId]; + $metadata = isset($binding['metadata']) && is_array($binding['metadata']) ? (array)$binding['metadata'] : []; + $component = $this->normalizeMonitorComponent((string)($metadata['machine_signal_component'] ?? $metadata['signal_component'] ?? 'input')); + $channel = (int)($metadata['machine_signal_channel'] ?? $metadata['input_channel'] ?? $binding['channel'] ?? 0); + + $monitors[] = [ + 'gateway_id' => $gatewayId, + 'department_id' => $departmentId, + 'lane_id' => (int)$lane->id, + 'lane_label' => (string)$lane->name->value(), + 'relay_id' => $relayId, + 'device_id' => (string)($metadata['machine_signal_device_id'] ?? $binding['device_id'] ?? ''), + 'local_ip' => $metadata['machine_signal_local_ip'] ?? $binding['local_ip'] ?? null, + 'channel' => $channel, + 'component' => $component, + 'expected_event' => $component === 'switch' ? 'switch.on' : 'input.toggle_on', + ]; + } + + return [ + 'gateway_id' => $gatewayId, + 'department_id' => $departmentId, + 'monitors' => $monitors, + ]; + } + + public function resolveLaneId(int $departmentId, ?int $laneId, ?string $relayId = null): int + { + if ($laneId !== null && $laneId > 0) { + $lane = (new department_lanes_o())->select($laneId); + if (!$lane->exists()) { + throw new \RuntimeException('Department lane not found.'); + } + if ((int)$lane->department->value() !== $departmentId) { + throw new \RuntimeException('The lane does not belong to the Shelly signal department.'); + } + + return (int)$lane->id; + } + + $relayId = trim((string)$relayId); + if ($relayId !== '') { + $matches = (new department_lanes_o())->getFieldsWhere( + [ + 'department' => $departmentId, + 'relay_machine_id' => $relayId, + 'deleted_at' => null, + ], + ['id'] + ); + if ($matches !== []) { + return (int)$matches[0]['id']; + } + } + + $lanes = (new department_lanes_o())->getDepartmentLanes($departmentId); + if (count($lanes) === 1) { + return (int)$lanes[0]->id; + } + + throw new \RuntimeException('lane_id or relay_id is required when the department has multiple self-serve lanes.'); + } + + /** + * @param array $payload + */ + private function extractRegistration(array $payload): ?string + { + $reg = $this->firstString($payload, ['reg', 'registration', 'license_plate', 'licensePlate', 'plate']); + return $reg === null || trim($reg) === '' ? null : selfserve::standardize_registration($reg); + } + + /** + * @param array $payload + * @param array $keys + */ + private function firstString(array $payload, array $keys): ?string + { + foreach ($keys as $key) { + if (!array_key_exists($key, $payload)) { + continue; + } + $value = $payload[$key]; + if (is_scalar($value) && trim((string)$value) !== '') { + return trim((string)$value); + } + } + + foreach (['params', 'data', 'status'] as $container) { + if (!isset($payload[$container]) || !is_array($payload[$container])) { + continue; + } + $match = $this->firstString((array)$payload[$container], $keys); + if ($match !== null) { + return $match; + } + } + + return null; + } + + /** + * @param array $payload + * @param array $keys + */ + private function firstInt(array $payload, array $keys): ?int + { + foreach ($keys as $key) { + if (array_key_exists($key, $payload) && is_numeric($payload[$key])) { + return (int)$payload[$key]; + } + } + + foreach (['params', 'data', 'status'] as $container) { + if (!isset($payload[$container]) || !is_array($payload[$container])) { + continue; + } + $match = $this->firstInt((array)$payload[$container], $keys); + if ($match !== null) { + return $match; + } + } + + return null; + } + + /** + * @param array $payload + */ + private function extractOnState(array $payload): ?bool + { + foreach (['on', 'output', 'state', 'ison'] as $key) { + if (array_key_exists($key, $payload)) { + return $this->boolValue($payload[$key]); + } + } + + foreach (['input', 'switch', 'params', 'data', 'status'] as $key) { + if (!isset($payload[$key]) || !is_array($payload[$key])) { + continue; + } + $value = $this->extractOnState((array)$payload[$key]); + if ($value !== null) { + return $value; + } + } + + foreach (['input:0', 'switch:0'] as $componentKey) { + if (!isset($payload[$componentKey]) || !is_array($payload[$componentKey])) { + continue; + } + $value = $this->extractOnState((array)$payload[$componentKey]); + if ($value !== null) { + return $value; + } + } + + return null; + } + + private function boolValue(mixed $value): ?bool + { + if (is_bool($value)) { + return $value; + } + if (is_int($value) || is_float($value)) { + return (int)$value === 1; + } + if (is_string($value)) { + $normalized = strtolower(trim($value)); + if (in_array($normalized, ['1', 'true', 'on', 'yes'], true)) { + return true; + } + if (in_array($normalized, ['0', 'false', 'off', 'no'], true)) { + return false; + } + } + + return null; + } + + private function normalizeComponent(?string $component): ?string + { + $component = strtolower(trim((string)$component)); + if ($component === '') { + return null; + } + if (str_starts_with($component, 'input')) { + return 'input'; + } + if (str_starts_with($component, 'switch') || str_starts_with($component, 'relay')) { + return 'switch'; + } + + return null; + } + + private function normalizeMonitorComponent(string $component): string + { + return $this->normalizeComponent($component) === 'switch' ? 'switch' : 'input'; + } + + /** + * @param array $payload + * @return array|null + */ + private function extractStatusPayload(array $payload): ?array + { + if (isset($payload['status']) && is_array($payload['status'])) { + return (array)$payload['status']; + } + if (isset($payload['raw']) && is_array($payload['raw'])) { + return (array)$payload['raw']; + } + + return null; + } + + /** + * @return array> + */ + private function bindingRows(int $gatewayId, int $departmentId): array + { + $rows = (new edge_gateway_relay_bindings_o())->getFieldsWhere( + [ + 'gateway_id' => $gatewayId, + 'department_id' => $departmentId, + 'deleted_at' => null, + ], + ['id'] + ); + + return array_map( + static fn(array $row): array => (new edge_gateway_relay_bindings_o())->select((int)$row['id'])->asArray(), + $rows + ); + } +} 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 1acb3868..f61c643a 100644 --- a/services/nginx/app/modules/selfserve/classes/selfserve_studio_graph.php +++ b/services/nginx/app/modules/selfserve/classes/selfserve_studio_graph.php @@ -5,12 +5,14 @@ namespace modules\selfserve\classes; require_once WD . '/classes/db.php'; 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/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'; 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'; 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'; @@ -35,6 +37,7 @@ use classes\edge_gateway_registry_service; use classes\edge_gateway_view_service; use classes\selfserve_schema_bootstrap; use modules\selfserve\helpers\selfserve_task_gate_type; +use objects\selfserve_config_versions_o; class selfserve_studio_graph { @@ -74,6 +77,10 @@ class selfserve_studio_graph ], $layout); $validation = $versioning->validateConfig($config); + $virtualWarnings = (new selfserve_virtual_hardware())->validationWarnings($gatewayWorkspace); + if ($virtualWarnings !== []) { + $validation['warnings'] = array_values(array_unique(array_merge((array)($validation['warnings'] ?? []), $virtualWarnings))); + } $validation['items'] = $this->buildValidationItems($validation); return [ @@ -91,7 +98,7 @@ class selfserve_studio_graph 'created_at' => $draft['created_at'] ?? null, 'updated_at' => $draft['updated_at'] ?? null, ], - 'simulator_defaults' => $this->buildSimulatorDefaults($departmentId, $lookups), + 'simulator_defaults' => $this->buildSimulatorDefaults($departmentId, $lookups, $gatewayWorkspace), 'gateway_workspace' => $gatewayWorkspace, 'permissions' => $permissions, 'meta' => [ @@ -114,6 +121,7 @@ class selfserve_studio_graph { $lookups = is_array($context['lookups'] ?? null) ? (array)$context['lookups'] : []; $gatewayWorkspace = is_array($context['gateway_workspace'] ?? null) ? (array)$context['gateway_workspace'] : []; + $isV2Config = (int)($config['schema_version'] ?? 0) === selfserve_config_versioning::SCHEMA_VERSION_V2; $nodes = []; $edges = []; @@ -165,6 +173,7 @@ class selfserve_studio_graph 'object_id' => $id, 'raw' => $condition, 'scope' => $this->scopeForRow($condition, $lookups), + 'expression_summary' => $this->expressionSummary(is_array($condition['expression'] ?? null) ? (array)$condition['expression'] : []), 'subtitle' => $this->scopeLabel($condition, $lookups), ], 360, 160 + ($index * 130)); @@ -172,6 +181,9 @@ class selfserve_studio_graph if ($parentId !== null) { $edges[] = $this->edge('condition-parent:' . $parentId . ':' . $id, 'condition:' . $parentId, 'condition:' . $id, 'condition_group', 'parent'); } + if ($isV2Config && is_array($condition['expression'] ?? null)) { + $this->appendExpressionEdges($edges, 'condition:' . $id, $id, (array)$condition['expression']); + } $this->appendScopeEdges($edges, 'condition:' . $id, $condition); } @@ -191,22 +203,24 @@ class selfserve_studio_graph $this->appendScopeEdges($edges, 'question:' . $id, $question); } - foreach ($this->sortedRows((array)($config['rules'] ?? []), ['condition_id', 'id']) as $index => $rule) { - $id = (int)($rule['id'] ?? 0); - $nodes[] = $this->node('rule:' . $id, 'default', $this->entityLabel('rule', $id, $rule, $lookups), 'rule', [ - 'object_id' => $id, - 'raw' => $rule, - 'subtitle' => $this->ruleSubtitle($rule, $lookups), - ], 520, 520 + ($index * 120)); + if (!$isV2Config) { + foreach ($this->sortedRows((array)($config['rules'] ?? []), ['condition_id', 'id']) as $index => $rule) { + $id = (int)($rule['id'] ?? 0); + $nodes[] = $this->node('rule:' . $id, 'default', $this->entityLabel('rule', $id, $rule, $lookups), 'rule', [ + 'object_id' => $id, + 'raw' => $rule, + 'subtitle' => $this->ruleSubtitle($rule, $lookups), + ], 520, 520 + ($index * 120)); - $conditionId = (int)($rule['condition_id'] ?? 0); - if ($conditionId > 0) { - $edges[] = $this->edge('rule-owner:' . $conditionId . ':' . $id, 'rule:' . $id, 'condition:' . $conditionId, 'condition_rule', 'rule of'); - } - $objectType = strtolower((string)($rule['object_type'] ?? '')); - $objectId = (int)($rule['object_id'] ?? 0); - if (in_array($objectType, ['question', 'condition', 'task'], true) && $objectId > 0) { - $edges[] = $this->edge('rule-input:' . $objectType . ':' . $objectId . ':' . $id, $objectType . ':' . $objectId, 'rule:' . $id, 'rule_input', (string)($rule['type'] ?? 'rule')); + $conditionId = (int)($rule['condition_id'] ?? 0); + if ($conditionId > 0) { + $edges[] = $this->edge('rule-owner:' . $conditionId . ':' . $id, 'rule:' . $id, 'condition:' . $conditionId, 'condition_rule', 'rule of'); + } + $objectType = strtolower((string)($rule['object_type'] ?? '')); + $objectId = (int)($rule['object_id'] ?? 0); + if (in_array($objectType, ['question', 'condition', 'task'], true) && $objectId > 0) { + $edges[] = $this->edge('rule-input:' . $objectType . ':' . $objectId . ':' . $id, $objectType . ':' . $objectId, 'rule:' . $id, 'rule_input', (string)($rule['type'] ?? 'rule')); + } } } @@ -267,9 +281,30 @@ class selfserve_studio_graph public function applyGraphSave(int $departmentId, array $payload, ?int $userId = null, array $permissions = []): array { $operations = isset($payload['operations']) && is_array($payload['operations']) ? (array)$payload['operations'] : []; - foreach ($operations as $operation) { - if (is_array($operation)) { - $this->applyOperation($departmentId, $operation); + + $versioning = new selfserve_config_versioning(); + $draft = $versioning->ensureDraftFromLegacy($departmentId, $userId, false); + $config = is_array($draft['config'] ?? null) ? (array)$draft['config'] : $versioning->snapshotLegacyConfig($departmentId); + + if ($versioning->isV2Config($config)) { + foreach ($operations as $operation) { + if (is_array($operation)) { + $this->applyConfigOperation($departmentId, $config, $operation); + } + } + + $validation = $versioning->validateConfig($config); + $draftObject = (new selfserve_config_versions_o())->select((int)($draft['id'] ?? 0)); + if (!$draftObject->exists()) { + throw new \RuntimeException('Self-serve draft version was not found.'); + } + $draftObject->config_json->set($config); + $draftObject->validation_result_json->set($validation); + } else { + foreach ($operations as $operation) { + if (is_array($operation)) { + $this->applyOperation($departmentId, $operation); + } } } @@ -282,7 +317,9 @@ class selfserve_studio_graph ]); } - (new selfserve_config_versioning())->syncDraftFromLegacyForDepartment($departmentId); + if (!$versioning->isV2Config($config)) { + $versioning->syncDraftFromLegacyForDepartment($departmentId); + } return $this->buildGraph($departmentId, $userId, $permissions); } @@ -388,8 +425,24 @@ class selfserve_studio_graph */ public function validatePayload(int $departmentId, array $payload = []): array { - $config = (new selfserve_config_versioning())->snapshotLegacyConfig($departmentId); - $validation = (new selfserve_config_versioning())->validateConfig($config); + $versioning = new selfserve_config_versioning(); + $draft = $versioning->ensureDraftFromLegacy($departmentId, null, false); + $config = is_array($payload['config'] ?? null) + ? (array)$payload['config'] + : (is_array($draft['config'] ?? null) ? (array)$draft['config'] : $versioning->snapshotLegacyConfig($departmentId)); + if (isset($payload['operations']) && is_array($payload['operations']) && $versioning->isV2Config($config)) { + foreach ((array)$payload['operations'] as $operation) { + if (is_array($operation)) { + $this->applyConfigOperation($departmentId, $config, $operation); + } + } + } + $validation = $versioning->validateConfig($config); + $gatewayWorkspace = $this->buildGatewayWorkspace($departmentId); + $virtualWarnings = (new selfserve_virtual_hardware())->validationWarnings($gatewayWorkspace); + if ($virtualWarnings !== []) { + $validation['warnings'] = array_values(array_unique(array_merge((array)($validation['warnings'] ?? []), $virtualWarnings))); + } $validation['items'] = $this->buildValidationItems($validation); return $validation; } @@ -413,8 +466,8 @@ class selfserve_studio_graph $versioning = new selfserve_config_versioning(); if ($configSource === 'published') { - $version = $versioning->getPublishedConfig($departmentId); - $config = is_array($version['config'] ?? null) ? (array)$version['config'] : $versioning->snapshotLegacyConfig($departmentId); + $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); @@ -424,18 +477,37 @@ class selfserve_studio_graph $includeHardware = filter_var($payload['include_hardware'] ?? true, FILTER_VALIDATE_BOOLEAN, FILTER_NULL_ON_FAILURE); $includeHardware = $includeHardware !== false; - $gatewayWorkspace = ($includeHardware && ($permissions['modules_shelly_config'] ?? false)) - ? $this->buildGatewayWorkspace($departmentId) - : [ + $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, + ], ]; - $lookups = $this->buildLookups($departmentId, $config, $gatewayWorkspace); - $graph = $this->buildGraphFromConfig($config, [ + } 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, @@ -454,6 +526,7 @@ class selfserve_studio_graph 'config_version_id' => $versionId, 'answer_overrides' => is_array($payload['answer_overrides'] ?? null) ? (array)$payload['answer_overrides'] : [], 'include_hardware' => $includeHardware, + 'hardware_mode' => $hardwareMode, 'lookups' => $lookups, 'gateway_workspace' => $gatewayWorkspace, 'graph' => $graph, @@ -511,12 +584,31 @@ class selfserve_studio_graph } /** + * @param array $payload + * @param array $permissions * @return array */ - private function buildGatewayWorkspace(int $departmentId): array + public function applyVirtualHardwareOperation(int $departmentId, array $payload, ?int $userId, array $permissions = []): array + { + if (($permissions['can_edit'] ?? false) !== true) { + throw new \RuntimeException('You do not have permission to edit self-serve studio hardware.'); + } + + $operation = strtolower(trim((string)($payload['operation'] ?? $payload['action'] ?? ''))); + $data = is_array($payload['data'] ?? null) ? (array)$payload['data'] : $payload; + $realWorkspace = $this->buildGatewayWorkspace($departmentId, false); + (new selfserve_virtual_hardware())->applyOperation($departmentId, $operation, $data, $userId, $realWorkspace); + + return $this->buildGraph($departmentId, $userId, $permissions); + } + + /** + * @return array + */ + private function buildGatewayWorkspace(int $departmentId, bool $includeVirtual = true): array { if (!class_exists(edge_gateway_department_workspace_service::class)) { - return [ + $workspace = [ 'gateways' => [], 'relays' => [], 'lanes' => [], @@ -524,12 +616,13 @@ class selfserve_studio_graph 'actions' => [], 'available' => false, ]; + return $includeVirtual ? (new selfserve_virtual_hardware())->mergeWorkspace($workspace, $departmentId) : $workspace; } try { - return (new edge_gateway_department_workspace_service())->getDepartmentWorkspace($departmentId); + $workspace = (new edge_gateway_department_workspace_service())->getDepartmentWorkspace($departmentId); } catch (\Throwable $exception) { - return [ + $workspace = [ 'gateways' => [], 'relays' => [], 'lanes' => [], @@ -543,6 +636,8 @@ class selfserve_studio_graph 'available' => false, ]; } + + return $includeVirtual ? (new selfserve_virtual_hardware())->mergeWorkspace($workspace, $departmentId) : $workspace; } /** @@ -553,17 +648,31 @@ class selfserve_studio_graph private function buildLookups(int $departmentId, array $config, array $gatewayWorkspace): array { $departmentRows = $this->fetchRows('departments', ['id', 'name', 'description'], ['id' => $departmentId]); - $laneRows = $this->fetchRows('department_lanes', ['id', 'department', 'name', 'machine_type_id'], ['department' => $departmentId]); + $laneRows = $this->fetchRows('department_lanes', [ + 'id', + 'department', + 'name', + 'relay_in_id', + 'relay_out_id', + 'relay_machine_id', + 'relay_machine_program_picker_id', + 'relay_machine_cleaner_id', + 'machine_type_id', + 'dynamic_image_id', + ], ['department' => $departmentId]); $machineTypeRows = $this->fetchRows('selfserve_machine_types', ['id', 'name', 'description'], []); $productRows = $this->fetchRows('products', ['id', 'name', 'description', 'price', 'subscription_allowed', 'category', 'piktogram', 'is_wash', 'order_priority'], []); $vehicleTypeRows = $this->vehicleTypeRowsFromProducts($productRows); $users = $this->fetchRows('users', ['id', 'customer_number', 'display_name'], []); + $laneLookupRows = $this->labelRows($laneRows, 'name'); + $machineTypeLookupRows = $this->addReferencedMachineTypeRows($this->labelRows($machineTypeRows, 'name'), $laneRows, $config); $lookups = [ 'departments' => $this->labelRows($departmentRows, 'name'), - 'lanes' => $this->labelRows($laneRows, 'name'), + 'lanes' => $laneLookupRows, 'products' => $this->labelRows($productRows, 'name'), - 'machine_types' => $this->labelRows($machineTypeRows, 'name'), + 'machine_types' => $machineTypeLookupRows, + 'dynamic_images' => $this->dynamicImageRowsFromLanes($laneRows), 'vehicle_types' => $vehicleTypeRows, 'questions' => $this->configLabelRows((array)($config['questions'] ?? []), 'question'), 'conditions' => $this->configLabelRows((array)($config['conditions'] ?? []), 'name'), @@ -603,6 +712,117 @@ class selfserve_studio_graph return $lookups; } + /** + * @param array> $rows + * @param array> $laneRows + * @param array $config + * @return array> + */ + private function addReferencedMachineTypeRows(array $rows, array $laneRows, array $config): array + { + $rowsById = []; + foreach ($rows as $row) { + $id = $this->nullableInt($row['id'] ?? null); + if ($id === null) { + continue; + } + $row['id'] = $id; + $row['label'] = trim((string)($row['label'] ?? $row['name'] ?? '')) ?: 'Machine type ' . $id; + $rowsById[$id] = $row; + } + + foreach ($this->referencedMachineTypeIds($laneRows, $config) as $id) { + if (!isset($rowsById[$id])) { + $rowsById[$id] = [ + 'id' => $id, + 'name' => 'Machine type ' . $id, + 'label' => 'Machine type ' . $id, + 'referenced' => true, + ]; + } + } + + ksort($rowsById, SORT_NUMERIC); + return array_values($rowsById); + } + + /** + * @param array> $laneRows + * @param array $config + * @return array + */ + private function referencedMachineTypeIds(array $laneRows, array $config): array + { + $ids = []; + foreach ($laneRows as $row) { + $id = $this->nullableInt($row['machine_type_id'] ?? null); + if ($id !== null) { + $ids[$id] = $id; + } + } + + foreach (['conditions', 'questions', 'tasks'] as $section) { + foreach ((array)($config[$section] ?? []) as $row) { + if (!is_array($row)) { + continue; + } + $id = $this->nullableInt($row['machine_type_id'] ?? null); + if ($id !== null) { + $ids[$id] = $id; + } + } + } + + ksort($ids, SORT_NUMERIC); + return array_values($ids); + } + + /** + * @param array> $laneRows + * @return array> + */ + private function dynamicImageRowsFromLanes(array $laneRows): array + { + $rowsById = []; + foreach ($this->supportedDynamicImageRows() as $row) { + $id = $this->nullableInt($row['id'] ?? null); + if ($id !== null) { + $rowsById[$id] = $row; + } + } + + foreach ($laneRows as $lane) { + $id = $this->nullableInt($lane['dynamic_image_id'] ?? null); + if ($id === null || isset($rowsById[$id])) { + continue; + } + $rowsById[$id] = [ + 'id' => $id, + 'name' => 'Dynamic image ' . $id, + 'label' => 'Dynamic image ' . $id, + 'referenced' => true, + ]; + } + + ksort($rowsById, SORT_NUMERIC); + return array_values($rowsById); + } + + /** + * @return array> + */ + private function supportedDynamicImageRows(): array + { + return [ + [ + 'id' => 1, + 'name' => 'Machine 1', + 'label' => 'Machine 1', + 'class' => 'dynamicimages\\images\\machine_1', + ], + ]; + } + /** * @param array> $nodes * @param array> $edges @@ -616,12 +836,12 @@ class selfserve_studio_graph if (!is_array($gateway)) { continue; } - $gatewayId = (int)($gateway['id'] ?? 0); - if ($gatewayId <= 0) { + $gatewayId = $this->gatewayIdentifier($gateway); + if ($gatewayId === '') { continue; } - $nodeId = 'gateway:' . $gatewayId; + $nodeId = $this->gatewayNodeId($gateway); $nodes[] = $this->node($nodeId, 'default', (string)($gateway['label'] ?? ('Gateway ' . $gatewayId)), 'edge_gateway', [ 'object_id' => $gatewayId, 'raw' => $gateway, @@ -639,11 +859,11 @@ class selfserve_studio_graph $bindingServices = $this->bindingServices($binding, $relayId, $relayServices); if ($bindingServices !== []) { $binding['services'] = $bindingServices; - if (trim((string)($binding['role'] ?? '')) === '' && count($bindingServices) === 1) { + if (trim((string)($binding['role'] ?? '')) === '' && count($bindingServices) === 1) { $binding['role'] = $bindingServices[0]; } } - $bindingId = 'binding:' . $gatewayId . ':' . $relayId . ':' . $bindingIndex; + $bindingId = $this->bindingNodeId($gatewayId, $relayId, (int)$bindingIndex, $binding); $nodes[] = $this->node($bindingId, 'default', (string)($binding['label'] ?? ('Relay ' . $relayId)), 'relay_binding', [ 'object_id' => $relayId, 'raw' => $binding, @@ -721,6 +941,481 @@ class selfserve_studio_graph } } + /** + * @param array $config + * @param array $operation + */ + private function applyConfigOperation(int $departmentId, array &$config, array $operation): void + { + $action = strtolower((string)($operation['action'] ?? '')); + $entity = $this->normalizeEntity((string)($operation['entity'] ?? $operation['type'] ?? '')); + $data = is_array($operation['data'] ?? null) ? (array)$operation['data'] : []; + $id = (int)($operation['id'] ?? $data['id'] ?? 0); + + if ($action === 'connect') { + $this->applyConfigConnection($departmentId, $config, (string)($operation['source'] ?? ''), (string)($operation['target'] ?? ''), false); + return; + } + if ($action === 'disconnect') { + $this->applyConfigConnection($departmentId, $config, (string)($operation['source'] ?? ''), (string)($operation['target'] ?? ''), true); + return; + } + if ($action === 'reorder') { + $this->applyConfigReorder($config, $entity, (array)($operation['items'] ?? [])); + return; + } + if ($entity === '') { + throw new \RuntimeException('Studio graph operation is missing entity.'); + } + if ($entity === 'lane') { + $this->applyLaneOperation($departmentId, $action, $id, $data); + return; + } + if ($entity === 'rule') { + throw new \RuntimeException('Standalone rule operations are not supported in self-serve rules v2.'); + } + + if ($action === 'create') { + $this->createConfigEntity($departmentId, $config, $entity, $data); + return; + } + if ($id <= 0) { + throw new \RuntimeException('Studio graph operation is missing id.'); + } + if ($action === 'update') { + $this->updateConfigEntity($departmentId, $config, $entity, $id, $data); + return; + } + if ($action === 'delete') { + $this->deleteConfigEntity($config, $entity, $id); + return; + } + + throw new \RuntimeException('Unsupported studio graph operation: ' . $action); + } + + /** + * @param array $config + * @param array $data + */ + private function createConfigEntity(int $departmentId, array &$config, string $entity, array $data): void + { + $id = $this->allocateConfigId($config, $entity); + $row = match ($entity) { + 'question' => [ + 'id' => $id, + 'department' => $departmentId, + 'lane' => 0, + 'product' => 0, + 'condition_id' => null, + 'question' => 'New question', + 'description' => '', + 'order_priority' => $this->nextOrderPriority((array)($config['questions'] ?? [])), + ], + 'condition' => [ + 'id' => $id, + 'department' => $departmentId, + 'lane' => 0, + 'product' => 0, + 'machine_type_id' => null, + 'condition_id' => null, + 'name' => 'New condition', + 'description' => '', + 'expression' => $this->emptyV2Expression(), + ], + 'task' => [ + 'id' => $id, + 'department' => $departmentId, + 'lane' => 0, + 'product' => 0, + 'machine_type_id' => null, + 'condition_id' => null, + 'gate_type' => selfserve_task_gate_type::ALWAYS->value, + 'gate_ref_id' => null, + 'task' => 'New task', + 'description' => '', + 'order_priority' => $this->nextOrderPriority((array)($config['tasks'] ?? [])), + 'services' => [], + 'buttons' => [], + 'dynamic_images_vehicle_type' => null, + ], + default => throw new \RuntimeException('Unsupported studio entity: ' . $entity), + }; + + $row = $this->mergeConfigEntityData($entity, $row, $data); + $rows = &$this->configRows($config, $entity); + $rows[] = $row; + } + + /** + * @param array $config + * @param array $data + */ + private function updateConfigEntity(int $departmentId, array &$config, string $entity, int $id, array $data): void + { + unset($departmentId); + $rows = &$this->configRows($config, $entity); + $index = $this->configRowIndex($rows, $id); + if ($index === null) { + throw new \RuntimeException(ucfirst($entity) . ' is not available in the current draft.'); + } + + $rows[$index] = $this->mergeConfigEntityData($entity, $rows[$index], $data); + } + + /** + * @param array $config + */ + private function deleteConfigEntity(array &$config, string $entity, int $id): void + { + $rows = &$this->configRows($config, $entity); + $rows = array_values(array_filter($rows, static fn(array $row): bool => (int)($row['id'] ?? 0) !== $id)); + + if ($entity === 'question') { + $conditionRows = &$this->configRows($config, 'condition'); + foreach ($conditionRows as &$condition) { + if (is_array($condition) && is_array($condition['expression'] ?? null)) { + $condition['expression'] = $this->removeExpressionPredicate((array)$condition['expression'], 'question', $id); + } + } + unset($condition); + $taskRows = &$this->configRows($config, 'task'); + foreach ($taskRows as &$task) { + if (is_array($task) && strtoupper((string)($task['gate_type'] ?? '')) === selfserve_task_gate_type::QUESTION->value && (int)($task['gate_ref_id'] ?? 0) === $id) { + $task['gate_type'] = selfserve_task_gate_type::ALWAYS->value; + $task['gate_ref_id'] = null; + $task['condition_id'] = null; + } + } + unset($task); + } + + if ($entity === 'condition') { + $conditionRows = &$this->configRows($config, 'condition'); + foreach ($conditionRows as &$condition) { + if (!is_array($condition)) { + continue; + } + if ((int)($condition['condition_id'] ?? 0) === $id) { + $condition['condition_id'] = null; + } + if (is_array($condition['expression'] ?? null)) { + $condition['expression'] = $this->removeExpressionPredicate((array)$condition['expression'], 'condition', $id); + } + } + unset($condition); + $questionRows = &$this->configRows($config, 'question'); + foreach ($questionRows as &$question) { + if (is_array($question) && (int)($question['condition_id'] ?? 0) === $id) { + $question['condition_id'] = null; + } + } + unset($question); + $taskRows = &$this->configRows($config, 'task'); + foreach ($taskRows as &$task) { + if (is_array($task) && strtoupper((string)($task['gate_type'] ?? '')) === selfserve_task_gate_type::CONDITION->value && (int)($task['gate_ref_id'] ?? 0) === $id) { + $task['gate_type'] = selfserve_task_gate_type::ALWAYS->value; + $task['gate_ref_id'] = null; + $task['condition_id'] = null; + } + } + unset($task); + } + } + + /** + * @param array $config + * @param array> $items + */ + 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.'); + } + + $priorities = []; + foreach ($items as $index => $item) { + if (is_array($item)) { + $priorities[(int)($item['id'] ?? 0)] = (int)($item['order_priority'] ?? $index); + } + } + + $rows = &$this->configRows($config, $entity); + foreach ($rows as &$row) { + $id = (int)($row['id'] ?? 0); + if (isset($priorities[$id])) { + $row['order_priority'] = $priorities[$id]; + } + } + unset($row); + } + + /** + * @param array $config + */ + private function applyConfigConnection(int $departmentId, array &$config, string $source, string $target, bool $disconnect): void + { + [$sourceType, $sourceIdRaw] = $this->parseNodeId($source); + [$targetType, $targetIdRaw] = $this->parseNodeId($target); + if ($sourceType === '' || $targetType === '' || $sourceIdRaw === '') { + throw new \RuntimeException('Invalid connection endpoints.'); + } + + if ($sourceType === 'task' && $targetType === 'binding') { + $service = $this->serviceForBindingNode($departmentId, $target); + if ($service === '') { + throw new \RuntimeException('Relay binding has no service role to connect to the task.'); + } + $this->updateConfigTaskServiceConnection($config, (int)$sourceIdRaw, $service, $disconnect); + return; + } + if ($sourceType === 'binding' && $targetType === 'task') { + $service = $this->serviceForBindingNode($departmentId, $source); + if ($service === '') { + throw new \RuntimeException('Relay binding has no service role to connect to the task.'); + } + $this->updateConfigTaskServiceConnection($config, (int)$targetIdRaw, $service, $disconnect); + return; + } + + $sourceId = (int)$sourceIdRaw; + $targetId = (int)$targetIdRaw; + if ($sourceId <= 0 || $targetId <= 0) { + throw new \RuntimeException('Invalid connection endpoints.'); + } + + if (in_array($sourceType, ['question', 'condition'], true) && $targetType === 'condition') { + if ($sourceType === 'condition' && $sourceId === $targetId && !$disconnect) { + throw new \RuntimeException('Condition expressions cannot reference themselves.'); + } + $this->updateConditionExpressionConnection($config, $targetId, $sourceType, $sourceId, $disconnect); + return; + } + + if ($sourceType === 'condition' && $targetType === 'question') { + $rows = &$this->configRows($config, 'question'); + $index = $this->configRowIndex($rows, $targetId); + if ($index !== null && (!$disconnect || (int)($rows[$index]['condition_id'] ?? 0) === $sourceId)) { + $rows[$index]['condition_id'] = $disconnect ? null : $sourceId; + } + return; + } + + if (in_array($sourceType, ['condition', 'question'], true) && $targetType === 'task') { + $rows = &$this->configRows($config, 'task'); + $index = $this->configRowIndex($rows, $targetId); + if ($index === null) { + return; + } + $currentType = strtoupper((string)($rows[$index]['gate_type'] ?? selfserve_task_gate_type::ALWAYS->value)); + $currentRef = (int)($rows[$index]['gate_ref_id'] ?? 0); + if ($disconnect && ($currentType !== strtoupper($sourceType) || $currentRef !== $sourceId)) { + return; + } + $rows[$index]['gate_type'] = $disconnect ? selfserve_task_gate_type::ALWAYS->value : strtoupper($sourceType); + $rows[$index]['gate_ref_id'] = $disconnect ? null : $sourceId; + $rows[$index]['condition_id'] = (!$disconnect && $sourceType === 'question') ? $sourceId : null; + return; + } + } + + /** + * @param array $config + */ + private function updateConditionExpressionConnection(array &$config, int $conditionId, string $subjectType, int $subjectId, bool $disconnect): void + { + $rows = &$this->configRows($config, 'condition'); + $index = $this->configRowIndex($rows, $conditionId); + if ($index === null) { + throw new \RuntimeException('Condition is not available in the current draft.'); + } + + $expression = $this->normalizeExpressionNode($rows[$index]['expression'] ?? $this->emptyV2Expression()); + if (($expression['type'] ?? 'group') === 'predicate') { + $expression = [ + 'type' => 'group', + 'operator' => 'ALL', + 'children' => [$expression], + ]; + } + if ($disconnect) { + $rows[$index]['expression'] = $this->removeExpressionPredicate($expression, $subjectType, $subjectId); + return; + } + + if (!$this->expressionHasPredicate($expression, $subjectType, $subjectId)) { + $expression['children'][] = [ + 'type' => 'predicate', + 'subject_type' => $subjectType, + 'subject_id' => $subjectId, + 'operator' => 'IS_TRUE', + ]; + } + $rows[$index]['expression'] = $expression; + } + + /** + * @param array $config + */ + private function updateConfigTaskServiceConnection(array &$config, int $taskId, string $service, bool $disconnect): void + { + if ($taskId <= 0 || $service === '') { + return; + } + + $rows = &$this->configRows($config, 'task'); + $index = $this->configRowIndex($rows, $taskId); + if ($index === null) { + throw new \RuntimeException('Task is not available in the current draft.'); + } + + $services = array_fill_keys($this->normalizeServiceList($rows[$index]['services'] ?? []), true); + if ($disconnect) { + unset($services[$service]); + } else { + $services[$service] = true; + } + $rows[$index]['services'] = array_keys($services); + } + + /** + * @param array $config + * @return array> + */ + private function &configRows(array &$config, string $entity): array + { + $key = match ($entity) { + 'question' => 'questions', + 'condition' => 'conditions', + 'task' => 'tasks', + default => throw new \RuntimeException('Unsupported studio entity: ' . $entity), + }; + if (!isset($config[$key]) || !is_array($config[$key])) { + $config[$key] = []; + } + return $config[$key]; + } + + /** + * @param array> $rows + */ + private function configRowIndex(array $rows, int $id): ?int + { + foreach ($rows as $index => $row) { + if ((int)($row['id'] ?? 0) === $id) { + return (int)$index; + } + } + return null; + } + + /** + * @param array $config + */ + private function allocateConfigId(array &$config, string $entity): int + { + $name = match ($entity) { + 'question', 'condition', 'task' => $entity, + default => throw new \RuntimeException('Unsupported studio entity: ' . $entity), + }; + if (!isset($config['v2_meta']) || !is_array($config['v2_meta'])) { + $config['v2_meta'] = []; + } + if (!isset($config['v2_meta']['next_ids']) || !is_array($config['v2_meta']['next_ids'])) { + $config['v2_meta']['next_ids'] = []; + } + + $rows = &$this->configRows($config, $entity); + $maxId = 0; + foreach ($rows as $row) { + $maxId = max($maxId, (int)($row['id'] ?? 0)); + } + + $nextId = max((int)($config['v2_meta']['next_ids'][$name] ?? 0), $maxId + 1); + $config['v2_meta']['next_ids'][$name] = $nextId + 1; + return $nextId; + } + + /** + * @param array> $rows + */ + private function nextOrderPriority(array $rows): int + { + $max = 0; + foreach ($rows as $row) { + $max = max($max, (int)($row['order_priority'] ?? 0)); + } + return $max + 10; + } + + /** + * @param array $row + * @param array $data + * @return array + */ + private function mergeConfigEntityData(string $entity, array $row, array $data): array + { + if (array_key_exists('label', $data)) { + if ($entity === 'question' && !array_key_exists('question', $data)) { + $data['question'] = $data['label']; + } elseif ($entity === 'condition' && !array_key_exists('name', $data)) { + $data['name'] = $data['label']; + } elseif ($entity === 'task' && !array_key_exists('task', $data)) { + $data['task'] = $data['label']; + } + } + + $fields = match ($entity) { + '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'], + default => throw new \RuntimeException('Unsupported studio entity: ' . $entity), + }; + + foreach ($fields as $field) { + if (!array_key_exists($field, $data)) { + continue; + } + $row[$field] = $this->normalizeConfigField($field, $data[$field]); + } + + if ($entity === 'condition' && !is_array($row['expression'] ?? null)) { + $row['expression'] = $this->emptyV2Expression(); + } + if ($entity === 'task') { + $row['gate_type'] = $this->normalizeGateType((string)($row['gate_type'] ?? selfserve_task_gate_type::ALWAYS->value)); + if ($row['gate_type'] === selfserve_task_gate_type::ALWAYS->value) { + $row['gate_ref_id'] = null; + $row['condition_id'] = null; + } + } + + return $row; + } + + private function normalizeConfigField(string $field, mixed $value): mixed + { + if ($field === 'expression') { + return $this->normalizeExpressionNode($value); + } + if (in_array($field, ['condition_id', 'machine_type_id', 'gate_ref_id', 'dynamic_images_vehicle_type'], true)) { + return $this->nullableInt($value); + } + if (in_array($field, ['department', 'lane', 'product', 'order_priority'], true)) { + return (int)$value; + } + if ($field === 'services') { + return $this->normalizeServiceList($value); + } + if ($field === 'buttons') { + return $this->normalizeArrayPayload($value); + } + if ($field === 'gate_type') { + return $this->normalizeGateType((string)$value); + } + return is_array($value) ? $value : (string)$value; + } + /** * @param array $operation */ @@ -746,6 +1441,10 @@ class selfserve_studio_graph if ($entity === '') { throw new \RuntimeException('Studio graph operation is missing entity.'); } + if ($entity === 'lane') { + $this->applyLaneOperation($departmentId, $action, $id, $data); + return; + } if ($action === 'create') { $this->createEntity($departmentId, $entity, $data); @@ -766,6 +1465,154 @@ class selfserve_studio_graph throw new \RuntimeException('Unsupported studio graph operation: ' . $action); } + /** + * @param array $data + */ + private function applyLaneOperation(int $departmentId, string $action, int $id, array $data): void + { + if (!$this->tableExists('department_lanes')) { + throw new \RuntimeException('Department lanes are not available.'); + } + + if ($action === 'create') { + $this->createLane($departmentId, $data); + return; + } + + if ($id <= 0) { + throw new \RuntimeException('Studio lane operation is missing id.'); + } + + if (!$this->laneBelongsToDepartment($id, $departmentId)) { + throw new \RuntimeException('Lane is not available in the selected department.'); + } + + if ($action === 'update') { + $this->updateLane($departmentId, $id, $data); + return; + } + + if ($action === 'delete') { + $this->softDeleteLane($departmentId, $id); + return; + } + + throw new \RuntimeException('Unsupported studio lane operation: ' . $action); + } + + /** + * @param array $data + */ + private function createLane(int $departmentId, array $data): void + { + $fields = [ + 'department' => $departmentId, + 'name' => $this->normalizeLaneName((string)($data['name'] ?? $data['label'] ?? 'New lane')), + ]; + + foreach ($this->laneOptionalFields() as $field) { + if (array_key_exists($field, $data)) { + $fields[$field] = $this->normalizeLaneField($field, $data[$field]); + } + } + + $availableColumns = $this->tableColumns('department_lanes'); + $fields = array_filter( + $fields, + static fn(mixed $value, string $field): bool => in_array($field, $availableColumns, true), + ARRAY_FILTER_USE_BOTH + ); + + $columns = array_keys($fields); + $placeholders = array_map(static fn(string $field): string => ':' . $field, $columns); + $params = []; + foreach ($fields as $field => $value) { + $params[':' . $field] = $value; + } + + db::getPDO()->prepare( + 'INSERT INTO department_lanes (`' . implode('`, `', $columns) . '`) VALUES (' . implode(', ', $placeholders) . ')' + )->execute($params); + } + + /** + * @param array $data + */ + private function updateLane(int $departmentId, int $id, array $data): void + { + unset($departmentId); + $availableColumns = $this->tableColumns('department_lanes'); + $updates = []; + $params = [':id' => $id]; + + $fields = ['name', ...$this->laneOptionalFields()]; + foreach ($fields as $field) { + if (!array_key_exists($field, $data) || !in_array($field, $availableColumns, true)) { + continue; + } + $updates[] = '`' . $field . '` = :' . $field; + $params[':' . $field] = $field === 'name' + ? $this->normalizeLaneName((string)$data[$field]) + : $this->normalizeLaneField($field, $data[$field]); + } + + if ($updates === []) { + return; + } + + db::getPDO()->prepare( + 'UPDATE department_lanes SET ' . implode(', ', $updates) . ' WHERE id = :id AND deleted_at IS NULL' + )->execute($params); + } + + private function softDeleteLane(int $departmentId, int $id): void + { + unset($departmentId); + db::getPDO()->prepare( + 'UPDATE department_lanes SET deleted_at = NOW() WHERE id = :id AND deleted_at IS NULL' + )->execute([':id' => $id]); + } + + /** + * @return array + */ + private function laneOptionalFields(): array + { + return [ + 'relay_in_id', + 'relay_out_id', + 'relay_machine_id', + 'relay_machine_program_picker_id', + 'relay_machine_cleaner_id', + 'dynamic_image_id', + 'machine_type_id', + ]; + } + + private function normalizeLaneField(string $field, mixed $value): mixed + { + if (in_array($field, ['dynamic_image_id', 'machine_type_id'], true)) { + return $this->nullableInt($value); + } + + $normalized = trim((string)($value ?? '')); + if ($normalized === '' || $normalized === '0' || strtolower($normalized) === 'null') { + return null; + } + + return $normalized; + } + + private function normalizeLaneName(string $name): string + { + $normalized = trim($name); + if ($normalized === '') { + throw new \RuntimeException('Lane name is required.'); + } + + return $normalized; + } + /** * @param array $data */ @@ -1103,6 +1950,187 @@ class selfserve_studio_graph return strtoupper((string)($row['type'] ?? 'RULE')) . ' ' . $label; } + /** + * @param array $expression + */ + private function expressionSummary(array $expression): string + { + $type = strtolower((string)($expression['type'] ?? $expression['kind'] ?? 'group')); + if ($type === 'predicate') { + $subjectType = strtolower((string)($expression['subject_type'] ?? $expression['object_type'] ?? '')); + $subjectId = (int)($expression['subject_id'] ?? $expression['object_id'] ?? 0); + $operator = strtoupper((string)($expression['operator'] ?? $expression['rule_type'] ?? 'IS_TRUE')); + $subjectLabel = match ($subjectType) { + 'question' => 'Question ' . $subjectId, + 'condition' => 'Condition ' . $subjectId, + default => 'Unknown subject', + }; + return $subjectLabel . ' ' . strtolower(str_replace('_', ' ', $operator)); + } + + $operator = strtoupper((string)($expression['operator'] ?? $expression['mode'] ?? 'ALL')); + if (!in_array($operator, ['ALL', 'ANY'], true)) { + $operator = 'ALL'; + } + $children = is_array($expression['children'] ?? null) ? array_values((array)$expression['children']) : []; + if ($children === []) { + return 'No predicates'; + } + + $parts = []; + foreach (array_slice($children, 0, 3) as $child) { + if (is_array($child)) { + $parts[] = $this->expressionSummary((array)$child); + } + } + if (count($children) > 3) { + $parts[] = '+' . (count($children) - 3) . ' more'; + } + + $prefix = $operator === 'ANY' ? 'Any of' : 'All of'; + return $prefix . ': ' . implode('; ', $parts); + } + + /** + * @param array> $edges + * @param array $expression + */ + private function appendExpressionEdges(array &$edges, string $targetNodeId, int $ownerConditionId, array $expression, string $path = '0'): void + { + $type = strtolower((string)($expression['type'] ?? $expression['kind'] ?? 'group')); + if ($type === 'predicate') { + $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) { + return; + } + $edge = $this->edge( + 'expression:' . $ownerConditionId . ':' . $subjectType . ':' . $subjectId . ':' . substr(md5($path), 0, 8), + $subjectType . ':' . $subjectId, + $targetNodeId, + 'condition_expression', + strtoupper((string)($expression['operator'] ?? 'IS_TRUE')) + ); + $edge['data']['subject_type'] = $subjectType; + $edge['data']['subject_id'] = $subjectId; + $edge['data']['condition_id'] = $ownerConditionId; + $edges[] = $edge; + return; + } + + $children = is_array($expression['children'] ?? null) ? array_values((array)$expression['children']) : []; + foreach ($children as $index => $child) { + if (is_array($child)) { + $this->appendExpressionEdges($edges, $targetNodeId, $ownerConditionId, (array)$child, $path . '.' . $index); + } + } + } + + private function normalizeExpressionNode(mixed $expression): array + { + if (!is_array($expression)) { + return $this->emptyV2Expression(); + } + + $type = strtolower((string)($expression['type'] ?? $expression['kind'] ?? 'group')); + if ($type === 'predicate') { + $subjectType = strtolower((string)($expression['subject_type'] ?? $expression['object_type'] ?? '')); + if (!in_array($subjectType, ['question', 'condition'], true)) { + $subjectType = 'question'; + } + $operator = strtoupper((string)($expression['operator'] ?? $expression['rule_type'] ?? 'IS_TRUE')); + if (!in_array($operator, ['IS_TRUE', 'IS_FALSE', 'IS_SET', 'IS_TRUE_OR_NOT_SET', 'IS_FALSE_OR_NOT_SET'], true)) { + $operator = 'IS_TRUE'; + } + return [ + 'type' => 'predicate', + 'subject_type' => $subjectType, + 'subject_id' => (int)($expression['subject_id'] ?? $expression['object_id'] ?? 0), + 'operator' => $operator, + ]; + } + + $operator = strtoupper((string)($expression['operator'] ?? $expression['mode'] ?? 'ALL')); + if (!in_array($operator, ['ALL', 'ANY'], true)) { + $operator = 'ALL'; + } + $children = []; + foreach ((array)($expression['children'] ?? []) as $child) { + if (is_array($child)) { + $children[] = $this->normalizeExpressionNode((array)$child); + } + } + + return [ + 'type' => 'group', + 'operator' => $operator, + 'children' => $children, + ]; + } + + /** + * @param array $expression + */ + private function expressionHasPredicate(array $expression, string $subjectType, int $subjectId): bool + { + $type = strtolower((string)($expression['type'] ?? 'group')); + if ($type === 'predicate') { + return strtolower((string)($expression['subject_type'] ?? '')) === $subjectType + && (int)($expression['subject_id'] ?? 0) === $subjectId; + } + + foreach ((array)($expression['children'] ?? []) as $child) { + if (is_array($child) && $this->expressionHasPredicate((array)$child, $subjectType, $subjectId)) { + return true; + } + } + return false; + } + + /** + * @param array $expression + * @return array + */ + private function removeExpressionPredicate(array $expression, string $subjectType, int $subjectId): array + { + $expression = $this->normalizeExpressionNode($expression); + if (($expression['type'] ?? 'group') === 'predicate') { + return $this->expressionHasPredicate($expression, $subjectType, $subjectId) + ? $this->emptyV2Expression() + : $expression; + } + + $children = []; + foreach ((array)($expression['children'] ?? []) as $child) { + if (!is_array($child)) { + continue; + } + $normalizedChild = $this->normalizeExpressionNode((array)$child); + if (($normalizedChild['type'] ?? '') === 'predicate' && $this->expressionHasPredicate($normalizedChild, $subjectType, $subjectId)) { + continue; + } + if (($normalizedChild['type'] ?? '') === 'group') { + $normalizedChild = $this->removeExpressionPredicate($normalizedChild, $subjectType, $subjectId); + } + $children[] = $normalizedChild; + } + + $expression['children'] = $children; + return $expression; + } + + /** + * @return array + */ + private function emptyV2Expression(): array + { + return [ + 'type' => 'group', + 'operator' => 'ALL', + 'children' => [], + ]; + } + /** * @param array> $edges * @param array $row @@ -1192,16 +2220,18 @@ class selfserve_studio_graph * @param array $lookups * @return array */ - private function buildSimulatorDefaults(int $departmentId, array $lookups): array + private function buildSimulatorDefaults(int $departmentId, array $lookups, array $gatewayWorkspace = []): array { $lane = $this->lookupRows($lookups, 'lanes')[0] ?? null; $vehicleType = $this->lookupRows($lookups, 'vehicle_types')[0] ?? null; + $hasVirtualHardware = (bool)($gatewayWorkspace['virtual']['has_virtual_hardware'] ?? false); return [ 'department' => $departmentId, 'lane_id' => is_array($lane) ? (int)($lane['id'] ?? 0) : null, 'vehicle_type_id' => is_array($vehicleType) ? (int)($vehicleType['id'] ?? 0) : null, 'reg' => 'TEST123', 'customer_number' => null, + 'hardware_mode' => $hasVirtualHardware ? 'studio' : 'real', ]; } @@ -1371,6 +2401,32 @@ class selfserve_studio_graph return array_keys($services); } + /** + * @param array $gateway + */ + private function gatewayIdentifier(array $gateway): string + { + return trim((string)($gateway['key'] ?? $gateway['gateway_key'] ?? $gateway['id'] ?? '')); + } + + /** + * @param array $gateway + */ + private function gatewayNodeId(array $gateway): string + { + $nodeId = trim((string)($gateway['node_id'] ?? '')); + return $nodeId !== '' ? $nodeId : 'gateway:' . $this->gatewayIdentifier($gateway); + } + + /** + * @param array $binding + */ + private function bindingNodeId(string $gatewayId, string $relayId, int $bindingIndex, array $binding): string + { + $nodeId = trim((string)($binding['node_id'] ?? '')); + return $nodeId !== '' ? $nodeId : 'binding:' . $gatewayId . ':' . $relayId . ':' . $bindingIndex; + } + /** * @param array $workspace * @return array> @@ -1383,8 +2439,8 @@ class selfserve_studio_graph if (!is_array($gateway)) { continue; } - $gatewayId = (int)($gateway['id'] ?? 0); - if ($gatewayId <= 0) { + $gatewayId = $this->gatewayIdentifier($gateway); + if ($gatewayId === '') { continue; } foreach ((array)($gateway['bindings'] ?? []) as $bindingIndex => $binding) { @@ -1403,8 +2459,9 @@ class selfserve_studio_graph 'gateway_id' => $gatewayId, 'relay_id' => $relayId, 'binding_index' => (int)$bindingIndex, - 'node_id' => 'binding:' . $gatewayId . ':' . $relayId . ':' . $bindingIndex, + 'node_id' => $this->bindingNodeId($gatewayId, $relayId, (int)$bindingIndex, $binding), 'services' => $services, + 'virtual' => (bool)($gateway['virtual'] ?? $binding['virtual'] ?? false), ]; } } @@ -1550,11 +2607,17 @@ class selfserve_studio_graph { $rows = []; foreach ($gateways as $gateway) { - if (is_array($gateway) && (int)($gateway['id'] ?? 0) > 0) { + if (is_array($gateway)) { + $gatewayId = $this->gatewayIdentifier($gateway); + if ($gatewayId === '') { + continue; + } $rows[] = [ - 'id' => (int)$gateway['id'], - 'label' => (string)($gateway['label'] ?? ('Gateway ' . $gateway['id'])), + 'id' => $gatewayId, + 'label' => (string)($gateway['label'] ?? ('Gateway ' . $gatewayId)), 'status' => (string)($gateway['status'] ?? 'UNKNOWN'), + 'virtual' => (bool)($gateway['virtual'] ?? false), + 'raw' => $gateway, ]; } } @@ -1596,13 +2659,13 @@ class selfserve_studio_graph if (!is_array($gateway)) { continue; } - $gatewayId = (int)($gateway['id'] ?? 0); + $gatewayId = $this->gatewayIdentifier($gateway); foreach ((array)($gateway['bindings'] ?? []) as $index => $binding) { if (!is_array($binding)) { continue; } $relayId = trim((string)($binding['relay_id'] ?? '')); - if ($gatewayId <= 0 || $relayId === '') { + if ($gatewayId === '' || $relayId === '') { continue; } $services = $this->bindingServices($binding, $relayId, []); @@ -1613,6 +2676,7 @@ class selfserve_studio_graph 'relay_id' => $relayId, 'role' => (string)($binding['role'] ?? ''), 'services' => $services, + 'virtual' => (bool)($gateway['virtual'] ?? $binding['virtual'] ?? false), ]; } } @@ -1696,6 +2760,7 @@ class selfserve_studio_graph 'conditions' => 'condition', 'rules' => 'rule', 'tasks' => 'task', + 'lanes' => 'lane', default => $entity, }; } @@ -1731,6 +2796,27 @@ class selfserve_studio_graph return (int)($statement->fetch(\PDO::FETCH_ASSOC)['c'] ?? 0) > 0; } + private function laneBelongsToDepartment(int $laneId, int $departmentId): bool + { + if ($laneId <= 0) { + return false; + } + + $statement = db::getPDO()->prepare( + "SELECT COUNT(*) AS c + FROM department_lanes + WHERE id = :id + AND department = :department + AND deleted_at IS NULL" + ); + $statement->execute([ + ':id' => $laneId, + ':department' => $departmentId, + ]); + + return (int)($statement->fetch(\PDO::FETCH_ASSOC)['c'] ?? 0) > 0; + } + /** * @return array{0:string,1:string} */ diff --git a/services/nginx/app/modules/selfserve/classes/selfserve_virtual_hardware.php b/services/nginx/app/modules/selfserve/classes/selfserve_virtual_hardware.php new file mode 100644 index 00000000..038f8371 --- /dev/null +++ b/services/nginx/app/modules/selfserve/classes/selfserve_virtual_hardware.php @@ -0,0 +1,784 @@ + + */ + public function getConfig(int $departmentId): array + { + $pdo = db::getPDO(); + $statement = $pdo->prepare( + 'SELECT config_json + FROM department_selfserve_studio_virtual_hardware + WHERE department_id = :department_id AND deleted_at IS NULL + LIMIT 1' + ); + $statement->execute([':department_id' => $departmentId]); + $row = $statement->fetch(\PDO::FETCH_ASSOC); + if (!is_array($row)) { + return $this->emptyConfig(); + } + + $decoded = json_decode((string)($row['config_json'] ?? '{}'), true); + return $this->normalizeConfig(is_array($decoded) ? $decoded : []); + } + + /** + * @param array $config + * @return array + */ + public function saveConfig(int $departmentId, array $config, ?int $userId = null): array + { + $normalized = $this->normalizeConfig($config); + $json = json_encode($normalized, JSON_UNESCAPED_SLASHES); + if ($json === false) { + throw new \RuntimeException('Unable to encode virtual hardware config.'); + } + + $pdo = db::getPDO(); + $statement = $pdo->prepare( + 'INSERT INTO department_selfserve_studio_virtual_hardware + (department_id, config_json, created_by, updated_by, deleted_at) + VALUES + (:department_id, :config_json, :created_by, :updated_by, NULL) + ON DUPLICATE KEY UPDATE + config_json = VALUES(config_json), + updated_by = VALUES(updated_by), + deleted_at = NULL' + ); + $statement->execute([ + ':department_id' => $departmentId, + ':config_json' => $json, + ':created_by' => $userId, + ':updated_by' => $userId, + ]); + + return $normalized; + } + + /** + * @param array $payload + * @param array $realWorkspace + * @return array + */ + public function applyOperation(int $departmentId, string $operation, array $payload, ?int $userId, array $realWorkspace): array + { + $config = $this->getConfig($departmentId); + $operation = strtolower(trim($operation)); + + if ($operation === 'generate_from_lanes') { + $config = $this->generateFromLanes($realWorkspace, $config); + } elseif ($operation === 'upsert_gateway') { + $config = $this->upsertGateway($config, $payload); + } elseif ($operation === 'upsert_binding') { + $config = $this->upsertBinding($config, $payload, $realWorkspace); + } elseif ($operation === 'delete_binding') { + $config = $this->deleteBinding($config, $payload); + } elseif ($operation === 'reset') { + $config = $this->emptyConfig(); + } else { + throw new \RuntimeException('Unsupported virtual hardware operation: ' . $operation); + } + + return $this->saveConfig($departmentId, $config, $userId); + } + + /** + * @param array $workspace + * @return array + */ + public function mergeWorkspace(array $workspace, int $departmentId): array + { + return $this->mergeWorkspaceWithConfig($workspace, $this->getConfig($departmentId)); + } + + /** + * Pure merge helper used by graph serialization and tests. + * + * @param array $workspace + * @param array $config + * @return array + */ + public function mergeWorkspaceWithConfig(array $workspace, array $config): array + { + $config = $this->normalizeConfig($config); + $workspace += [ + 'gateways' => [], + 'relays' => [], + 'lanes' => [], + 'issues' => [], + 'actions' => [], + ]; + + $virtualBindings = (array)($config['bindings'] ?? []); + $enabled = (bool)($config['enabled'] ?? true); + if (!$enabled || $virtualBindings === []) { + $workspace['virtual'] = $this->workspaceVirtualSummary($config, 0, 0); + return $workspace; + } + + $realBindingRelayIds = $this->realBindingRelayIds((array)($workspace['gateways'] ?? [])); + $virtualGateways = $this->virtualGatewaysForWorkspace($config); + $virtualRelays = $this->virtualRelaysForWorkspace($config); + $bindingsByRelayId = $this->indexVirtualBindingsByRelayId($virtualGateways); + + $workspace['gateways'] = array_values(array_merge((array)($workspace['gateways'] ?? []), $virtualGateways)); + $workspace['relays'] = $this->mergeRelays((array)($workspace['relays'] ?? []), $virtualRelays); + $workspace['lanes'] = $this->mergeLaneCoverage((array)($workspace['lanes'] ?? []), $bindingsByRelayId); + + $coveredRelayIds = array_fill_keys(array_keys($bindingsByRelayId), true); + $workspace['issues'] = $this->mergeIssues((array)($workspace['issues'] ?? []), $coveredRelayIds, $virtualGateways); + $workspace['virtual'] = $this->workspaceVirtualSummary($config, count($virtualGateways), count($virtualBindings), $realBindingRelayIds); + $workspace['summary'] = $this->mergeSummary((array)($workspace['summary'] ?? []), $workspace); + + return $workspace; + } + + /** + * @param array $workspace + * @return array + */ + public function validationWarnings(array $workspace): array + { + $virtual = is_array($workspace['virtual'] ?? null) ? (array)$workspace['virtual'] : []; + if (($virtual['has_virtual_hardware'] ?? false) !== true) { + return []; + } + + $warnings = [ + 'Studio uses virtual hardware coverage. Publishing is allowed, but live relay dispatch still requires a real edge gateway and real relay bindings.', + ]; + $virtualOnlyRelays = (array)($virtual['virtual_only_relay_ids'] ?? []); + if ($virtualOnlyRelays !== []) { + $warnings[] = 'Virtual coverage only for relay IDs: ' . implode(', ', $virtualOnlyRelays) . '.'; + } + + return $warnings; + } + + /** + * @param array $workspace + * @param array|null $baseConfig + * @return array + */ + public function generateFromLanes(array $workspace, ?array $baseConfig = null): array + { + $config = $this->normalizeConfig($baseConfig ?? $this->emptyConfig()); + $gatewayKey = self::DEFAULT_GATEWAY_KEY; + $config = $this->upsertGateway($config, [ + 'key' => $gatewayKey, + 'label' => 'Virtual Studio Gateway', + 'status' => 'VIRTUAL', + ]); + + foreach ((array)($workspace['lanes'] ?? []) as $lane) { + if (!is_array($lane)) { + continue; + } + foreach ((array)($lane['relay_slots'] ?? []) as $slot) { + if (!is_array($slot)) { + continue; + } + $relayId = trim((string)($slot['relay_id'] ?? '')); + $role = $this->normalizeRole($slot['slot'] ?? $slot['role'] ?? ''); + if ($relayId === '' || $role === '') { + continue; + } + $config = $this->upsertBinding($config, [ + 'gateway_key' => $gatewayKey, + 'relay_id' => $relayId, + 'role' => $role, + 'services' => [$role], + 'label' => trim((string)($lane['name'] ?? ('Lane ' . ($lane['id'] ?? '')))) . ' ' . $role, + 'lane_id' => (int)($lane['id'] ?? 0), + 'slot' => $role, + 'generated' => true, + ], $workspace); + } + } + + return $config; + } + + /** + * @return array + */ + public function emptyConfig(): array + { + return [ + 'schema_version' => self::SCHEMA_VERSION, + 'enabled' => true, + 'gateways' => [], + 'relays' => [], + 'bindings' => [], + ]; + } + + /** + * @param array $config + * @return array + */ + public function normalizeConfig(array $config): array + { + $normalized = $this->emptyConfig(); + $normalized['schema_version'] = (int)($config['schema_version'] ?? self::SCHEMA_VERSION); + $normalized['enabled'] = filter_var($config['enabled'] ?? true, FILTER_VALIDATE_BOOLEAN, FILTER_NULL_ON_FAILURE) !== false; + + foreach ((array)($config['gateways'] ?? []) as $gateway) { + if (!is_array($gateway)) { + continue; + } + $key = $this->normalizeGatewayKey($gateway['key'] ?? $gateway['gateway_key'] ?? $gateway['id'] ?? ''); + if ($key === '') { + continue; + } + $normalized['gateways'][$key] = [ + 'key' => $key, + 'label' => trim((string)($gateway['label'] ?? ('Virtual Gateway ' . $key))), + 'status' => strtoupper(trim((string)($gateway['status'] ?? 'VIRTUAL'))) ?: 'VIRTUAL', + 'metadata' => is_array($gateway['metadata'] ?? null) ? (array)$gateway['metadata'] : [], + ]; + } + + foreach ((array)($config['relays'] ?? []) as $relay) { + if (!is_array($relay)) { + continue; + } + $relayId = trim((string)($relay['relay_id'] ?? $relay['id'] ?? '')); + if ($relayId === '') { + continue; + } + $normalized['relays'][$relayId] = [ + 'relay_id' => $relayId, + 'name' => trim((string)($relay['name'] ?? $relay['label'] ?? ('Virtual relay ' . $relayId))), + 'status' => strtoupper(trim((string)($relay['status'] ?? 'VIRTUAL'))) ?: 'VIRTUAL', + 'virtual' => true, + ]; + } + + $bindings = []; + foreach ((array)($config['bindings'] ?? []) as $binding) { + if (!is_array($binding)) { + continue; + } + $gatewayKey = $this->normalizeGatewayKey($binding['gateway_key'] ?? $binding['gateway_id'] ?? self::DEFAULT_GATEWAY_KEY); + $relayId = trim((string)($binding['relay_id'] ?? '')); + $role = $this->normalizeRole($binding['role'] ?? $binding['slot'] ?? $binding['service'] ?? ''); + if ($gatewayKey === '' || $relayId === '') { + continue; + } + if (!isset($normalized['gateways'][$gatewayKey])) { + $normalized['gateways'][$gatewayKey] = [ + 'key' => $gatewayKey, + 'label' => 'Virtual Gateway ' . $gatewayKey, + 'status' => 'VIRTUAL', + 'metadata' => [], + ]; + } + if (!isset($normalized['relays'][$relayId])) { + $normalized['relays'][$relayId] = [ + 'relay_id' => $relayId, + 'name' => trim((string)($binding['label'] ?? ('Virtual relay ' . $relayId))), + 'status' => 'VIRTUAL', + 'virtual' => true, + ]; + } + $services = $this->normalizeServiceList($binding['services'] ?? ($role !== '' ? [$role] : [])); + if ($services === [] && $role !== '') { + $services = [$role]; + } + $id = $this->bindingId($gatewayKey, $relayId, $role); + $bindings[$id] = [ + 'id' => $id, + 'gateway_key' => $gatewayKey, + 'relay_id' => $relayId, + 'role' => $role, + 'slot' => $role, + 'services' => $services, + 'label' => trim((string)($binding['label'] ?? ('Virtual ' . ($role ?: 'relay') . ' ' . $relayId))), + 'channel' => array_key_exists('channel', $binding) ? (int)$binding['channel'] : 0, + 'lane_id' => isset($binding['lane_id']) ? (int)$binding['lane_id'] : null, + 'generated' => (bool)($binding['generated'] ?? false), + 'virtual' => true, + ]; + } + + $normalized['gateways'] = array_values($normalized['gateways']); + $normalized['relays'] = array_values($normalized['relays']); + $normalized['bindings'] = array_values($bindings); + + return $normalized; + } + + /** + * @param array $config + * @param array $payload + * @return array + */ + private function upsertGateway(array $config, array $payload): array + { + $config = $this->normalizeConfig($config); + $key = $this->normalizeGatewayKey($payload['key'] ?? $payload['gateway_key'] ?? $payload['id'] ?? self::DEFAULT_GATEWAY_KEY); + if ($key === '') { + throw new \RuntimeException('Virtual gateway key is required.'); + } + + $gateways = []; + foreach ((array)$config['gateways'] as $gateway) { + $gateways[$this->normalizeGatewayKey($gateway['key'] ?? $gateway['id'] ?? '')] = $gateway; + } + $gateways[$key] = [ + 'key' => $key, + 'label' => trim((string)($payload['label'] ?? $gateways[$key]['label'] ?? ('Virtual Gateway ' . $key))), + 'status' => strtoupper(trim((string)($payload['status'] ?? $gateways[$key]['status'] ?? 'VIRTUAL'))) ?: 'VIRTUAL', + 'metadata' => is_array($payload['metadata'] ?? null) ? (array)$payload['metadata'] : (array)($gateways[$key]['metadata'] ?? []), + ]; + $config['gateways'] = array_values($gateways); + + return $this->normalizeConfig($config); + } + + /** + * @param array $config + * @param array $payload + * @param array $workspace + * @return array + */ + private function upsertBinding(array $config, array $payload, array $workspace): array + { + $config = $this->normalizeConfig($config); + $gatewayKey = $this->normalizeGatewayKey($payload['gateway_key'] ?? $payload['gateway_id'] ?? self::DEFAULT_GATEWAY_KEY); + $relayId = trim((string)($payload['relay_id'] ?? '')); + $role = $this->normalizeRole($payload['role'] ?? $payload['slot'] ?? $payload['service'] ?? ''); + if ($gatewayKey === '' || $relayId === '') { + throw new \RuntimeException('Virtual gateway key and relay id are required.'); + } + + $config = $this->upsertGateway($config, ['key' => $gatewayKey]); + $services = $this->normalizeServiceList($payload['services'] ?? ($role !== '' ? [$role] : [])); + if ($services === [] && $role !== '') { + $services = [$role]; + } + $binding = [ + 'id' => $this->bindingId($gatewayKey, $relayId, $role), + 'gateway_key' => $gatewayKey, + 'relay_id' => $relayId, + 'role' => $role, + 'slot' => $role, + 'services' => $services, + 'label' => trim((string)($payload['label'] ?? ('Virtual ' . ($role ?: 'relay') . ' ' . $relayId))), + 'channel' => array_key_exists('channel', $payload) ? (int)$payload['channel'] : 0, + 'lane_id' => isset($payload['lane_id']) ? (int)$payload['lane_id'] : $this->laneIdForRelay($workspace, $relayId), + 'generated' => (bool)($payload['generated'] ?? false), + 'virtual' => true, + ]; + + $bindings = []; + foreach ((array)$config['bindings'] as $existing) { + if (!is_array($existing)) { + continue; + } + $bindings[(string)($existing['id'] ?? $this->bindingId((string)($existing['gateway_key'] ?? ''), (string)($existing['relay_id'] ?? ''), (string)($existing['role'] ?? '')))] = $existing; + } + $bindings[$binding['id']] = $binding; + $config['bindings'] = array_values($bindings); + $config['relays'][] = [ + 'relay_id' => $relayId, + 'name' => trim((string)($payload['relay_label'] ?? $payload['label'] ?? ('Virtual relay ' . $relayId))), + 'status' => 'VIRTUAL', + 'virtual' => true, + ]; + + return $this->normalizeConfig($config); + } + + /** + * @param array $config + * @param array $payload + * @return array + */ + private function deleteBinding(array $config, array $payload): array + { + $config = $this->normalizeConfig($config); + $id = trim((string)($payload['id'] ?? '')); + if ($id === '') { + $id = $this->bindingId( + $this->normalizeGatewayKey($payload['gateway_key'] ?? $payload['gateway_id'] ?? self::DEFAULT_GATEWAY_KEY), + trim((string)($payload['relay_id'] ?? '')), + $this->normalizeRole($payload['role'] ?? $payload['slot'] ?? '') + ); + } + + $config['bindings'] = array_values(array_filter((array)$config['bindings'], static function (array $binding) use ($id): bool { + return (string)($binding['id'] ?? '') !== $id; + })); + + return $this->normalizeConfig($config); + } + + /** + * @param array $config + * @return array> + */ + private function virtualGatewaysForWorkspace(array $config): array + { + $bindingsByGateway = []; + foreach ((array)($config['bindings'] ?? []) as $binding) { + if (!is_array($binding)) { + continue; + } + $gatewayKey = $this->normalizeGatewayKey($binding['gateway_key'] ?? self::DEFAULT_GATEWAY_KEY); + $bindingsByGateway[$gatewayKey][] = [ + ...$binding, + 'gateway_id' => $gatewayKey, + 'gateway_key' => $gatewayKey, + 'node_id' => 'binding:' . $gatewayKey . ':' . (string)($binding['relay_id'] ?? '') . ':' . count($bindingsByGateway[$gatewayKey] ?? []), + 'virtual' => true, + ]; + } + + $gateways = []; + foreach ((array)($config['gateways'] ?? []) as $gateway) { + if (!is_array($gateway)) { + continue; + } + $key = $this->normalizeGatewayKey($gateway['key'] ?? self::DEFAULT_GATEWAY_KEY); + $bindings = array_values($bindingsByGateway[$key] ?? []); + if ($bindings === []) { + continue; + } + $gateways[] = [ + 'id' => $key, + 'key' => $key, + 'label' => (string)($gateway['label'] ?? ('Virtual Gateway ' . $key)), + 'status' => (string)($gateway['status'] ?? 'VIRTUAL'), + 'virtual' => true, + 'studio_only' => true, + 'bindings' => $bindings, + 'metadata' => (array)($gateway['metadata'] ?? []), + ]; + } + + return $gateways; + } + + /** + * @param array $config + * @return array> + */ + private function virtualRelaysForWorkspace(array $config): array + { + return array_values(array_map(static function (array $relay): array { + return [ + ...$relay, + 'virtual' => true, + 'studio_only' => true, + ]; + }, (array)($config['relays'] ?? []))); + } + + /** + * @param array> $gateways + * @return array>> + */ + private function indexVirtualBindingsByRelayId(array $gateways): array + { + $bindings = []; + foreach ($gateways as $gateway) { + foreach ((array)($gateway['bindings'] ?? []) as $binding) { + if (!is_array($binding)) { + continue; + } + $relayId = trim((string)($binding['relay_id'] ?? '')); + if ($relayId === '') { + continue; + } + $bindings[$relayId][] = [ + ...$binding, + 'gateway_id' => (string)($gateway['id'] ?? ''), + 'gateway_label' => (string)($gateway['label'] ?? 'Virtual Gateway'), + 'gateway_status' => (string)($gateway['status'] ?? 'VIRTUAL'), + 'virtual' => true, + 'studio_only' => true, + ]; + } + } + return $bindings; + } + + /** + * @param array> $realRelays + * @param array> $virtualRelays + * @return array> + */ + private function mergeRelays(array $realRelays, array $virtualRelays): array + { + $rows = []; + foreach (array_merge($realRelays, $virtualRelays) as $relay) { + if (!is_array($relay)) { + continue; + } + $relayId = trim((string)($relay['relay_id'] ?? $relay['id'] ?? '')); + if ($relayId === '') { + continue; + } + $rows[$relayId . ':' . (!empty($relay['virtual']) ? 'virtual' : 'real')] = $relay; + } + return array_values($rows); + } + + /** + * @param array> $lanes + * @param array>> $bindingsByRelayId + * @return array> + */ + private function mergeLaneCoverage(array $lanes, array $bindingsByRelayId): array + { + foreach ($lanes as $laneIndex => $lane) { + if (!is_array($lane)) { + continue; + } + $required = 0; + $bound = 0; + foreach ((array)($lane['relay_slots'] ?? []) as $slotIndex => $slot) { + if (!is_array($slot)) { + continue; + } + $required += 1; + $relayId = trim((string)($slot['relay_id'] ?? '')); + $covered = (bool)($slot['coverage']['covered'] ?? false); + if (!$covered && $relayId !== '' && isset($bindingsByRelayId[$relayId])) { + $slot['coverage'] = [ + 'relay_id' => $relayId, + 'covered' => true, + 'status' => 'VIRTUAL', + 'binding_count' => count($bindingsByRelayId[$relayId]), + 'primary_binding' => $bindingsByRelayId[$relayId][0] ?? null, + 'bindings' => $bindingsByRelayId[$relayId], + 'virtual' => true, + 'studio_only' => true, + ]; + $slot['virtual'] = true; + $covered = true; + } + if ($covered) { + $bound += 1; + } + $lane['relay_slots'][$slotIndex] = $slot; + } + $lane['binding_coverage'] = [ + 'required' => $required, + 'bound' => $bound, + 'missing' => max(0, $required - $bound), + 'state' => $required === 0 ? 'NOT_REQUIRED' : ($bound === $required ? 'READY' : 'MISSING'), + 'virtual' => $this->laneHasVirtualCoverage($lane), + ]; + $lanes[$laneIndex] = $lane; + } + + return $lanes; + } + + /** + * @param array $lane + */ + private function laneHasVirtualCoverage(array $lane): bool + { + foreach ((array)($lane['relay_slots'] ?? []) as $slot) { + if (is_array($slot) && !empty($slot['coverage']['virtual'])) { + return true; + } + } + return false; + } + + /** + * @param array> $issues + * @param array $coveredRelayIds + * @param array> $virtualGateways + * @return array> + */ + private function mergeIssues(array $issues, array $coveredRelayIds, array $virtualGateways): array + { + $filtered = []; + foreach ($issues as $issue) { + if (!is_array($issue)) { + continue; + } + $code = strtoupper((string)($issue['code'] ?? '')); + if (in_array($code, ['LANE_BINDING_GAP', 'SCANNER_LANE_PARTIAL', 'SELFSERVE_PARTIAL_READY'], true)) { + $filtered[] = [ + ...$issue, + 'severity' => 'info', + 'virtual' => true, + 'message' => (string)($issue['message'] ?? 'Relay coverage is incomplete.') . ' Studio virtual hardware covers this for dry runs only.', + ]; + continue; + } + if (in_array($code, ['NO_GATEWAY', 'NO_ONLINE_GATEWAY'], true) && $virtualGateways !== []) { + $filtered[] = [ + ...$issue, + 'severity' => 'warning', + 'virtual' => true, + 'message' => (string)($issue['message'] ?? 'Real gateway is not ready.') . ' A virtual studio gateway is available for dry runs only.', + ]; + continue; + } + $filtered[] = $issue; + } + + $filtered[] = [ + 'severity' => 'warning', + 'code' => 'VIRTUAL_HARDWARE_ACTIVE', + 'message' => 'Virtual studio hardware is active. It counts for studio validation and simulation only; live dispatch still requires real gateway bindings.', + 'virtual' => true, + 'relay_ids' => array_keys($coveredRelayIds), + ]; + + return $filtered; + } + + /** + * @param array $summary + * @param array $workspace + * @return array + */ + private function mergeSummary(array $summary, array $workspace): array + { + $virtual = (array)($workspace['virtual'] ?? []); + $summary['virtual_gateway_count'] = (int)($virtual['gateway_count'] ?? 0); + $summary['virtual_binding_count'] = (int)($virtual['binding_count'] ?? 0); + $summary['has_virtual_hardware'] = (bool)($virtual['has_virtual_hardware'] ?? false); + return $summary; + } + + /** + * @param array $config + * @return array + */ + /** + * @param array $realBindingRelayIds + */ + private function workspaceVirtualSummary(array $config, int $gatewayCount, int $bindingCount, array $realBindingRelayIds = []): array + { + $relayIds = []; + foreach ((array)($config['bindings'] ?? []) as $binding) { + if (is_array($binding) && trim((string)($binding['relay_id'] ?? '')) !== '') { + $relayId = trim((string)$binding['relay_id']); + if (!isset($realBindingRelayIds[$relayId])) { + $relayIds[] = $relayId; + } + } + } + + return [ + 'schema_version' => (int)($config['schema_version'] ?? self::SCHEMA_VERSION), + 'enabled' => (bool)($config['enabled'] ?? true), + 'has_virtual_hardware' => $bindingCount > 0, + 'gateway_count' => $gatewayCount, + 'binding_count' => $bindingCount, + 'virtual_only_relay_ids' => array_values(array_unique($relayIds)), + 'config' => $config, + ]; + } + + /** + * @param array> $gateways + * @return array + */ + private function realBindingRelayIds(array $gateways): array + { + $relayIds = []; + foreach ($gateways as $gateway) { + if (!is_array($gateway) || !empty($gateway['virtual'])) { + continue; + } + foreach ((array)($gateway['bindings'] ?? []) as $binding) { + if (!is_array($binding)) { + continue; + } + $relayId = trim((string)($binding['relay_id'] ?? '')); + if ($relayId !== '') { + $relayIds[$relayId] = true; + } + } + } + return $relayIds; + } + + /** + * @param array $workspace + */ + private function laneIdForRelay(array $workspace, string $relayId): ?int + { + foreach ((array)($workspace['lanes'] ?? []) as $lane) { + if (!is_array($lane)) { + continue; + } + foreach ((array)($lane['relay_slots'] ?? []) as $slot) { + if (is_array($slot) && trim((string)($slot['relay_id'] ?? '')) === $relayId) { + return (int)($lane['id'] ?? 0) ?: null; + } + } + } + return null; + } + + private function normalizeGatewayKey(mixed $value): string + { + return trim((string)$value); + } + + private function normalizeRole(mixed $value): string + { + return strtoupper(trim((string)$value)); + } + + /** + * @return array + */ + private function normalizeServiceList(mixed $value): array + { + if (is_string($value)) { + $decoded = json_decode($value, true); + $value = json_last_error() === JSON_ERROR_NONE && is_array($decoded) ? $decoded : explode(',', $value); + } + if (!is_array($value)) { + $value = [$value]; + } + + $services = []; + foreach ($value as $entry) { + if (is_array($entry)) { + continue; + } + $service = $this->normalizeRole($entry); + if ($service !== '') { + $services[$service] = true; + } + } + return array_keys($services); + } + + private function bindingId(string $gatewayKey, string $relayId, string $role): string + { + return $gatewayKey . ':' . $relayId . ':' . ($role !== '' ? $role : 'relay'); + } +} 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 adb4346f..b520eaf0 100644 --- a/services/nginx/app/modules/selfserve/classes/selfserve_wash_flow.php +++ b/services/nginx/app/modules/selfserve/classes/selfserve_wash_flow.php @@ -187,6 +187,16 @@ class selfserve_wash_flow implements selfserve_wash_flow_i return $this->getSessionSummary((int)$session->id); } + public function hasMachineStartTriggeredForLane(int $laneId, ?string $reg = null, ?int $customerNumber = null): bool + { + $normalizedReg = $reg === null || trim($reg) === '' ? null : selfserve::standardize_registration($reg); + $session = $normalizedReg !== null + ? $this->findLatestOpenSession($laneId, $normalizedReg, $customerNumber) + : $this->findLatestOpenSessionByLane($laneId, $customerNumber); + + return $session->exists() && (bool)$session->machine_start_triggered->value(); + } + protected function enableCleanerRelayForStartedWash(selfserve_lane $lane): void { try { @@ -404,12 +414,14 @@ class selfserve_wash_flow implements selfserve_wash_flow_i $configSource = (string)($options['config_source'] ?? 'published'); $publishedConfigVersionId = $options['config_version_id'] ?? null; $publishedConfigPayload = is_array($options['config_payload'] ?? null) ? (array)$options['config_payload'] : null; + $versioning = new selfserve_config_versioning(); if ($publishedConfigPayload === null) { - $publishedConfig = (new selfserve_config_versioning())->getPublishedConfig($departmentId); + $publishedConfig = $versioning->getPublishedV2Config($departmentId); $publishedConfigVersionId = $publishedConfig['version_id'] ?? null; $publishedConfigPayload = is_array($publishedConfig['config'] ?? null) ? $publishedConfig['config'] : null; - $configSource = 'published'; + $configSource = $publishedConfigPayload === null ? 'legacy' : 'published'; } + $isV2Config = is_array($publishedConfigPayload) && $versioning->isV2Config($publishedConfigPayload); $vehicle = $this->findVehicleByRegistration($normalizedReg); $vehicleData = $vehicle?->asArray(); $vehicleTypeId = $this->resolveVehicleTypeId($vehicle, $vehicleTypeIdOverride); @@ -423,7 +435,14 @@ class selfserve_wash_flow implements selfserve_wash_flow_i $answerOverrides = $this->normalizeAnswerOverrides($options['answer_overrides'] ?? []); $answers = $this->applyAnswerOverrides($persistedAnswers, $answerOverrides); $answerSources = $this->buildAnswerSources($persistedAnswers, $answerOverrides); - $visibilityConditionResults = $this->conditionEvaluator->evaluate($conditions, $rules, $answers); + if ($isV2Config) { + $visibilityEvaluation = $this->conditionEvaluator->evaluateExpressionsWithTrace($conditions, $answers); + $visibilityConditionResults = (array)($visibilityEvaluation['results'] ?? []); + $visibilityExpressionTrace = (array)($visibilityEvaluation['trace'] ?? []); + } else { + $visibilityConditionResults = $this->conditionEvaluator->evaluate($conditions, $rules, $answers); + $visibilityExpressionTrace = []; + } $visibleQuestions = []; $visibleQuestionIds = []; @@ -447,7 +466,14 @@ class selfserve_wash_flow implements selfserve_wash_flow_i usort($visibleQuestions, static fn(array $a, array $b): int => $a['order_priority'] <=> $b['order_priority']); $visibleAnswers = $this->filterAnswersToVisibleQuestions($answers, $visibleQuestionIds); - $serviceConditionResults = $this->conditionEvaluator->evaluate($conditions, $rules, $visibleAnswers); + if ($isV2Config) { + $serviceEvaluation = $this->conditionEvaluator->evaluateExpressionsWithTrace($conditions, $visibleAnswers); + $serviceConditionResults = (array)($serviceEvaluation['results'] ?? []); + $serviceExpressionTrace = (array)($serviceEvaluation['trace'] ?? []); + } else { + $serviceConditionResults = $this->conditionEvaluator->evaluate($conditions, $rules, $visibleAnswers); + $serviceExpressionTrace = []; + } $tasks = $this->loadTasks($departmentId, $laneId, $vehicleTypeId, $machineTypeId, $publishedConfigPayload); $activeTasks = []; @@ -499,8 +525,8 @@ class selfserve_wash_flow implements selfserve_wash_flow_i 'gate_ref_id' => $typedGateRefId, 'order_priority' => (int)($task['order_priority'] ?? 0), 'services' => $this->normalizeServiceNames($this->normalizeJsonArray($task['services'] ?? null)), - 'buttons' => $this->normalizeIntArray($this->normalizeJsonArray($task['buttons'] ?? null)), - 'dynamic_images_vehicle_type' => (int)($task['dynamic_images_vehicle_type'] ?? 0), + 'buttons' => $this->normalizeButtonList($task['buttons'] ?? null), + 'dynamic_images_vehicle_type' => ($task['dynamic_images_vehicle_type'] ?? null) === null ? null : (int)$task['dynamic_images_vehicle_type'], ]; } usort($activeTasks, static fn(array $a, array $b): int => $a['order_priority'] <=> $b['order_priority']); @@ -559,6 +585,8 @@ class selfserve_wash_flow implements selfserve_wash_flow_i 'evaluation_trace' => [ 'visibility_condition_results' => $visibilityConditionResults, 'condition_results' => $serviceConditionResults, + 'visibility_expression_traces' => $visibilityExpressionTrace, + 'condition_expression_traces' => $serviceExpressionTrace, 'task_gates' => $taskGateTrace, 'visible_question_ids' => $visibleQuestionIds, ], @@ -692,6 +720,7 @@ class selfserve_wash_flow implements selfserve_wash_flow_i 'customer_number' => $snapshot['customer_number'], 'config_source' => $snapshot['config_source'] ?? ($options['config_source'] ?? 'draft'), 'config_version_id' => $snapshot['config_version_id'] ?? null, + 'hardware_mode' => $options['hardware_mode'] ?? 'studio', 'mode' => 'full_dry_run', 'dry_run' => true, ], @@ -701,6 +730,7 @@ class selfserve_wash_flow implements selfserve_wash_flow_i 'rules' => $rules, 'tasks' => $tasks, 'hardware' => $hardware, + 'signal_timeline' => (array)($hardware['signal_timeline'] ?? []), 'graph_annotations' => $annotations, 'recommendations' => $recommendations, ]; @@ -893,6 +923,7 @@ class selfserve_wash_flow implements selfserve_wash_flow_i protected function buildDebugConditions(array $snapshot, array $conditions, array $rules, array $lookups): array { $conditionResults = is_array($snapshot['evaluation_trace']['condition_results'] ?? null) ? (array)$snapshot['evaluation_trace']['condition_results'] : []; + $expressionTraces = is_array($snapshot['evaluation_trace']['condition_expression_traces'] ?? null) ? (array)$snapshot['evaluation_trace']['condition_expression_traces'] : []; $cycleIds = $this->detectConditionCycles($conditions, $rules); $rulesByCondition = []; foreach ($rules as $rule) { @@ -906,6 +937,9 @@ class selfserve_wash_flow implements selfserve_wash_flow_i continue; } $result = ($conditionResults[$conditionId] ?? false) === true; + $expression = is_array($condition['expression'] ?? null) ? (array)$condition['expression'] : []; + $expressionTrace = is_array($expressionTraces[$conditionId] ?? null) ? (array)$expressionTraces[$conditionId] : null; + $nextFix = $expressionTrace === null ? null : $this->nextFixForExpressionTrace($expressionTrace, $lookups); $items[] = [ 'id' => $conditionId, 'node_id' => 'condition:' . $conditionId, @@ -914,10 +948,14 @@ class selfserve_wash_flow implements selfserve_wash_flow_i 'state' => $result ? 'passed' : 'failed', 'parent_condition_id' => $this->nullableInt($condition['condition_id'] ?? null), 'rules' => array_values($rulesByCondition[$conditionId] ?? []), + 'expression' => $expression, + 'expression_summary' => $expression === [] ? 'Legacy rules' : $this->debugExpressionSummary($expression, $lookups), + 'expression_trace' => $expressionTrace, + 'next_fix' => $nextFix, 'has_cycle' => in_array($conditionId, $cycleIds, true), 'reason' => in_array($conditionId, $cycleIds, true) ? 'Condition dependency cycle detected.' - : ($result ? 'Condition passed.' : 'Condition failed or has no passing rules.'), + : ($expressionTrace['expression']['reason'] ?? ($result ? 'Condition passed.' : 'Condition failed or has no passing rules.')), ]; } @@ -970,7 +1008,8 @@ class selfserve_wash_flow implements selfserve_wash_flow_i 'gate_ref_label' => $gateRefId === null ? 'Always' : $this->debugGateReferenceLabel($lookups, $gateType, $gateRefId), 'gate_satisfied' => ($trace['satisfied'] ?? false) === true, 'services' => $services, - 'buttons' => $this->normalizeIntArray($this->normalizeJsonArray($task['buttons'] ?? 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'], 'relay_bindings' => $bindings, 'order_priority' => (int)($task['order_priority'] ?? 0), 'reason' => $active ? 'Task gate passed.' : 'Task gate did not pass.', @@ -1012,6 +1051,7 @@ class selfserve_wash_flow implements selfserve_wash_flow_i } } } + $signalTimeline = $this->buildDebugSignalTimeline($snapshot, $gatewayWorkspace, $bindingsByService, $laneRelaySlots); $dryRunOperations = []; if (($snapshot['allowed'] ?? false) === true) { @@ -1037,11 +1077,276 @@ class selfserve_wash_flow implements selfserve_wash_flow_i 'gateways' => array_values((array)($gatewayWorkspace['gateways'] ?? [])), 'issues' => array_values((array)($gatewayWorkspace['issues'] ?? [])), 'restricted' => (bool)($gatewayWorkspace['restricted'] ?? false), + 'virtual' => is_array($gatewayWorkspace['virtual'] ?? null) ? (array)$gatewayWorkspace['virtual'] : [], + 'signal_timeline' => $signalTimeline, 'dry_run_operations' => $dryRunOperations, 'summary' => $this->debugHardwareSummary($snapshot, $missingBindings, $lookups), ]; } + /** + * @param array $snapshot + * @param array $gatewayWorkspace + * @param array>> $bindingsByService + * @param array> $laneRelaySlots + * @return array> + */ + protected function buildDebugSignalTimeline(array $snapshot, array $gatewayWorkspace, array $bindingsByService, array $laneRelaySlots): array + { + $timeline = []; + $sequence = 1; + $allowed = (bool)($snapshot['allowed'] ?? false); + $machineAvailable = (bool)($snapshot['machine_available'] ?? false); + + $timeline[] = $this->debugSignalTimelineRow( + $sequence++, + 'eligibility_sync', + 'session_event', + 'SESSION', + null, + null, + [ + 'event' => 'SESSION_SYNCED', + 'allowed' => $allowed, + 'allowed_services' => array_values((array)($snapshot['allowed_services'] ?? [])), + ], + 'none', + 'sent', + null + ); + + $machineRelayId = $this->debugRelayIdForRole($laneRelaySlots, 'MACHINE', $snapshot); + $machineBinding = $this->debugBindingForRelayRole($gatewayWorkspace, $bindingsByService, $machineRelayId, 'MACHINE'); + $timeline[] = $this->debugRelaySignalTimelineRow( + $sequence++, + 'eligibility_sync', + 'relay_switch', + 'MACHINE', + $machineRelayId, + $machineBinding, + ['id' => $machineRelayId, 'channel' => 0, 'on' => true], + $allowed && $machineAvailable, + $allowed ? (!$machineAvailable ? 'Lane machine relay is not configured.' : null) : 'Eligibility is blocked, so the machine relay would not be enabled.' + ); + + $timeline[] = $this->debugRelaySignalTimelineRow( + $sequence++, + 'machine_start_signal', + 'shelly_event', + 'MACHINE', + $machineRelayId, + $machineBinding, + [ + 'event' => 'input.toggle_on', + 'alternate_event' => 'switch.on', + 'input' => ['component' => 'input:0', 'state' => true], + 'switch' => ['component' => 'switch:0', 'output' => true], + 'bill_machine_wash' => true, + ], + $allowed && $machineAvailable, + $allowed ? (!$machineAvailable ? 'Lane machine signal relay is not configured.' : null) : 'Machine ON signal would not be accepted before eligibility passes.' + ); + + $cleanerRelayId = $this->debugRelayIdForRole($laneRelaySlots, 'CLEANER', $snapshot); + $cleanerBinding = $this->debugBindingForRelayRole($gatewayWorkspace, $bindingsByService, $cleanerRelayId, 'CLEANER'); + $timeline[] = $this->debugRelaySignalTimelineRow( + $sequence++, + 'machine_start', + 'relay_switch', + 'CLEANER', + $cleanerRelayId, + $cleanerBinding, + ['id' => $cleanerRelayId, 'channel' => 0, 'on' => true], + $allowed && $cleanerRelayId !== null, + $allowed ? 'Lane has no machine start cleaner relay configured.' : 'Machine start would not run because eligibility is blocked.' + ); + + $exitRelayId = $this->debugRelayIdForRole($laneRelaySlots, 'EXIT', $snapshot); + $exitBinding = $this->debugBindingForRelayRole($gatewayWorkspace, $bindingsByService, $exitRelayId, 'EXIT'); + $timeline[] = $this->debugRelaySignalTimelineRow( + $sequence++, + 'stop', + 'relay_pulse', + 'EXIT', + $exitRelayId, + $exitBinding, + ['id' => $exitRelayId, 'on' => true, 'toggle_after' => 1], + $allowed && $exitRelayId !== null, + $allowed ? 'Lane has no STOP exit relay configured.' : 'STOP exit open would not run before a valid wash can start.' + ); + + $timeline[] = $this->debugRelaySignalTimelineRow( + $sequence++, + 'stop', + 'relay_switch', + 'CLEANER', + $cleanerRelayId, + $cleanerBinding, + ['id' => $cleanerRelayId, 'channel' => 0, 'on' => false], + $allowed && $cleanerRelayId !== null, + $allowed ? 'Lane has no cleaner relay to turn off.' : 'Cleaner off would not run before a valid wash can start.' + ); + + $timeline[] = $this->debugRelaySignalTimelineRow( + $sequence++, + 'stop', + 'relay_switch', + 'MACHINE', + $machineRelayId, + $machineBinding, + ['id' => $machineRelayId, 'channel' => 0, 'on' => false], + $machineRelayId !== null, + $machineRelayId === null ? 'Lane machine relay is not configured.' : null + ); + + $timeline[] = $this->debugSignalTimelineRow( + $sequence, + 'session_completion', + 'session_event', + 'SESSION', + null, + null, + [ + 'event' => 'SESSION_COMPLETED', + 'reset_lane_state' => true, + ], + 'none', + $allowed ? 'sent' : 'skipped', + $allowed ? null : 'Session completion/reset only applies after a dry-run wash can start.' + ); + + return $timeline; + } + + /** + * @param array $snapshot + * @param array> $laneRelaySlots + */ + protected function debugRelayIdForRole(array $laneRelaySlots, string $role, array $snapshot): ?string + { + $role = strtoupper(trim($role)); + foreach ($laneRelaySlots as $slot) { + if (!is_array($slot)) { + continue; + } + $slotRole = strtoupper(trim((string)($slot['slot'] ?? $slot['role'] ?? $slot['service'] ?? ''))); + $relayId = trim((string)($slot['relay_id'] ?? '')); + if ($slotRole === $role && $relayId !== '') { + return $relayId; + } + } + + $lane = is_array($snapshot['lane'] ?? null) ? (array)$snapshot['lane'] : []; + $field = match ($role) { + 'ENTRY' => 'relay_in_id', + 'EXIT' => 'relay_out_id', + 'MACHINE' => 'relay_machine_id', + 'PROGRAM_PICKER' => 'relay_machine_program_picker_id', + 'CLEANER' => 'relay_machine_cleaner_id', + default => '', + }; + $relayId = $field !== '' ? trim((string)($lane[$field] ?? '')) : ''; + return $relayId !== '' ? $relayId : null; + } + + /** + * @param array $gatewayWorkspace + * @param array>> $bindingsByService + * @return array|null + */ + protected function debugBindingForRelayRole(array $gatewayWorkspace, array $bindingsByService, ?string $relayId, string $role): ?array + { + if ($relayId === null || trim($relayId) === '') { + return null; + } + $role = strtoupper(trim($role)); + + foreach ((array)($bindingsByService[$role] ?? []) as $binding) { + if (is_array($binding) && (string)($binding['relay_id'] ?? '') === $relayId) { + return $binding; + } + } + + foreach ($this->debugGatewayBindingReferences($gatewayWorkspace) as $binding) { + if ((string)($binding['relay_id'] ?? '') === $relayId) { + return $binding; + } + } + + return null; + } + + /** + * @param array $binding|null + * @param array $payload + * @return array + */ + protected function debugRelaySignalTimelineRow( + int $sequence, + string $runtimeStage, + string $signalType, + string $relayRole, + ?string $relayId, + ?array $binding, + array $payload, + bool $eligible, + ?string $reason + ): array { + if ($relayId === null || trim($relayId) === '') { + return $this->debugSignalTimelineRow($sequence, $runtimeStage, $signalType, $relayRole, null, null, $payload, 'none', 'skipped', $reason); + } + if (!$eligible) { + return $this->debugSignalTimelineRow($sequence, $runtimeStage, $signalType, $relayRole, $relayId, $binding, $payload, $binding === null ? 'none' : ((bool)($binding['virtual'] ?? false) ? 'virtual' : 'real'), 'blocked', $reason); + } + if ($binding === null) { + return $this->debugSignalTimelineRow($sequence, $runtimeStage, $signalType, $relayRole, $relayId, null, $payload, 'none', 'skipped', 'No gateway binding is available for this relay in the selected hardware mode.'); + } + + $source = (bool)($binding['virtual'] ?? false) ? 'virtual' : 'real'; + return $this->debugSignalTimelineRow($sequence, $runtimeStage, $signalType, $relayRole, $relayId, $binding, $payload, $source, $source === 'virtual' ? 'virtual_only' : 'sent', $source === 'virtual' ? 'Virtual studio hardware only; live dispatch would require a real gateway binding.' : null); + } + + /** + * @param array|null $binding + * @param array $payload + * @return array + */ + protected function debugSignalTimelineRow( + int $sequence, + string $runtimeStage, + string $signalType, + string $relayRole, + ?string $relayId, + ?array $binding, + array $payload, + string $source, + string $predictedStatus, + ?string $reason + ): array { + return [ + 'sequence' => $sequence, + 'runtime_stage' => $runtimeStage, + 'signal_type' => $signalType, + 'relay_role' => $relayRole, + 'relay_id' => $relayId, + 'target_gateway' => $binding['gateway_id'] ?? null, + 'target_gateway_label' => $binding['gateway_label'] ?? null, + 'target_binding' => $binding['node_id'] ?? null, + 'target_binding_label' => $binding['relay_label'] ?? null, + 'transport' => match ($signalType) { + 'session_event' => 'selfserve_wash_session_events', + 'shelly_event', 'machine_signal' => 'shelly_webhook_or_edge_gateway_event', + default => '/v2/devices/api/set/switch', + }, + 'payload' => $payload, + 'source' => $source, + 'virtual' => $source === 'virtual', + 'predicted_status' => $predictedStatus, + 'skip_block_reason' => $reason, + 'reason' => $reason, + ]; + } + /** * @param array $snapshot * @param array> $questions @@ -1393,6 +1698,82 @@ class selfserve_wash_flow implements selfserve_wash_flow_i return array_values($cycles); } + /** + * @param array $expression + * @param array $lookups + */ + protected function debugExpressionSummary(array $expression, array $lookups): string + { + $type = strtolower((string)($expression['type'] ?? $expression['kind'] ?? 'group')); + if ($type === 'predicate') { + $subjectType = strtolower((string)($expression['subject_type'] ?? $expression['object_type'] ?? '')); + $subjectId = (int)($expression['subject_id'] ?? $expression['object_id'] ?? 0); + $operator = strtoupper((string)($expression['operator'] ?? $expression['rule_type'] ?? 'IS_TRUE')); + $lookupType = $subjectType === 'condition' ? 'conditions' : 'questions'; + $label = $this->debugLabel($lookups, $lookupType, $subjectId, ucfirst($subjectType) . ' ' . $subjectId); + return $label . ' ' . strtolower(str_replace('_', ' ', $operator)); + } + + $operator = strtoupper((string)($expression['operator'] ?? $expression['mode'] ?? 'ALL')); + $children = is_array($expression['children'] ?? null) ? array_values((array)$expression['children']) : []; + if ($children === []) { + return 'No predicates'; + } + + $parts = []; + foreach (array_slice($children, 0, 3) as $child) { + if (is_array($child)) { + $parts[] = $this->debugExpressionSummary((array)$child, $lookups); + } + } + if (count($children) > 3) { + $parts[] = '+' . (count($children) - 3) . ' more'; + } + + return ($operator === 'ANY' ? 'Any of: ' : 'All of: ') . implode('; ', $parts); + } + + /** + * @param array $trace + * @param array $lookups + */ + protected function nextFixForExpressionTrace(array $trace, array $lookups): ?string + { + $failed = $this->firstFailedPredicateTrace((array)($trace['expression'] ?? $trace)); + if ($failed === null) { + return null; + } + + $subjectType = strtolower((string)($failed['subject_type'] ?? 'question')); + $subjectId = (int)($failed['subject_id'] ?? 0); + $operator = strtoupper((string)($failed['operator'] ?? 'IS_TRUE')); + $lookupType = $subjectType === 'condition' ? 'conditions' : 'questions'; + $label = $this->debugLabel($lookups, $lookupType, $subjectId, ucfirst($subjectType) . ' ' . $subjectId); + + return 'Set ' . $label . ' so it satisfies ' . strtolower(str_replace('_', ' ', $operator)) . '.'; + } + + /** + * @param array $trace + * @return array|null + */ + protected function firstFailedPredicateTrace(array $trace): ?array + { + if (($trace['type'] ?? '') === 'predicate') { + return (($trace['result'] ?? false) === true) ? null : $trace; + } + foreach ((array)($trace['children'] ?? []) as $child) { + if (!is_array($child)) { + continue; + } + $failed = $this->firstFailedPredicateTrace((array)$child); + if ($failed !== null) { + return $failed; + } + } + return null; + } + /** * @param array $workspace * @return array> @@ -1519,40 +1900,71 @@ class selfserve_wash_flow implements selfserve_wash_flow_i protected function debugGatewayBindingsByService(array $workspace): array { $bindings = []; + foreach ($this->debugGatewayBindingReferences($workspace) as $binding) { + foreach ((array)($binding['services'] ?? []) as $service) { + $row = $binding; + $row['service'] = $service; + $bindings[$service][] = $row; + } + } + + return $bindings; + } + + /** + * @param array $workspace + * @return array> + */ + protected function debugGatewayBindingReferences(array $workspace): array + { + $references = []; $relayServices = $this->debugRelayServicesFromWorkspace($workspace); foreach ((array)($workspace['gateways'] ?? []) as $gateway) { if (!is_array($gateway)) { continue; } - $gatewayId = (int)($gateway['id'] ?? 0); + $gatewayId = $this->debugGatewayIdentifier($gateway); + if ($gatewayId === '') { + continue; + } foreach ((array)($gateway['bindings'] ?? []) as $index => $binding) { if (!is_array($binding)) { continue; } $relayId = trim((string)($binding['relay_id'] ?? '')); - if ($gatewayId <= 0 || $relayId === '') { + if ($relayId === '') { continue; } $services = $this->debugBindingServices($binding, $relayId, $relayServices); - foreach ($services as $service) { - $bindings[$service][] = [ - 'gateway_id' => $gatewayId, - 'gateway_label' => (string)($gateway['label'] ?? ('Gateway ' . $gatewayId)), - 'gateway_status' => (string)($gateway['status'] ?? 'UNKNOWN'), - 'gateway_node_id' => 'gateway:' . $gatewayId, - 'relay_id' => $relayId, - 'relay_label' => (string)($binding['label'] ?? ('Relay ' . $relayId)), - 'relay_node_id' => 'relay:' . $relayId, - 'binding_index' => (int)$index, - 'node_id' => 'binding:' . $gatewayId . ':' . $relayId . ':' . (int)$index, - 'service' => $service, - 'channel' => $binding['channel'] ?? null, - ]; + if ($services === []) { + continue; } + $references[] = [ + 'gateway_id' => $gatewayId, + 'gateway_label' => (string)($gateway['label'] ?? ('Gateway ' . $gatewayId)), + 'gateway_status' => (string)($gateway['status'] ?? 'UNKNOWN'), + 'gateway_node_id' => (string)($gateway['node_id'] ?? ('gateway:' . $gatewayId)), + 'relay_id' => $relayId, + 'relay_label' => (string)($binding['label'] ?? ('Relay ' . $relayId)), + 'relay_node_id' => 'relay:' . $relayId, + 'binding_index' => (int)$index, + 'node_id' => (string)($binding['node_id'] ?? ('binding:' . $gatewayId . ':' . $relayId . ':' . (int)$index)), + 'services' => $services, + 'channel' => $binding['channel'] ?? null, + 'virtual' => (bool)($gateway['virtual'] ?? $binding['virtual'] ?? false), + ]; } } - return $bindings; + return $references; + } + + /** + * @param array $gateway + */ + protected function debugGatewayIdentifier(array $gateway): string + { + return trim((string)($gateway['key'] ?? $gateway['gateway_key'] ?? $gateway['id'] ?? '')); } /** @@ -1759,6 +2171,10 @@ class selfserve_wash_flow implements selfserve_wash_flow_i return []; } + if (is_array($publishedConfig) && (int)($publishedConfig['schema_version'] ?? 0) === selfserve_config_versioning::SCHEMA_VERSION_V2) { + return []; + } + $conditionIds = array_map(static fn(array $condition): int => (int)$condition['id'], $conditions); if (is_array($publishedConfig) && isset($publishedConfig['rules']) && is_array($publishedConfig['rules'])) { @@ -2088,6 +2504,15 @@ class selfserve_wash_flow implements selfserve_wash_flow_i return is_array($decoded) ? $decoded : []; } + protected function normalizeButtonList(mixed $value): array + { + try { + return department_selfserve_tasks_o::normalizeButtonsInput($value); + } catch (\Throwable) { + return []; + } + } + protected function normalizeServiceNames(array $services): array { $normalized = []; diff --git a/services/nginx/app/modules/selfserve/interfaces/selfserve_condition_evaluator_i.php b/services/nginx/app/modules/selfserve/interfaces/selfserve_condition_evaluator_i.php index ed59513e..bb21a541 100644 --- a/services/nginx/app/modules/selfserve/interfaces/selfserve_condition_evaluator_i.php +++ b/services/nginx/app/modules/selfserve/interfaces/selfserve_condition_evaluator_i.php @@ -12,6 +12,20 @@ interface selfserve_condition_evaluator_i */ public function evaluate(array $conditions, array $rules, array $answers): array; + /** + * @param array> $conditions + * @param array $answers + * @return array + */ + public function evaluateExpressions(array $conditions, array $answers): array; + + /** + * @param array> $conditions + * @param array $answers + * @return array{results:array,trace:array>} + */ + public function evaluateExpressionsWithTrace(array $conditions, array $answers): array; + /** * @param int|null $gateId * @param array $conditionResults 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 1a276653..9a7a20ff 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 @@ -63,6 +63,23 @@ trait selfserve_lane_command_t } } + /** + * Machine-wash billing is based on the physical machine ON signal, not selector relay status. + */ + protected function hasMachineStartSignalForStop(): bool + { + try { + $customerNumber = method_exists($this, 'getCustomerNumber') ? (int)$this->getCustomerNumber() : null; + return (new selfserve_wash_flow())->hasMachineStartTriggeredForLane( + (int)$this->id, + method_exists($this, 'getLicensePlate') ? ($this->getLicensePlate() ?: null) : null, + $customerNumber !== null && $customerNumber > 0 ? $customerNumber : null + ); + } catch (\Throwable) { + return false; + } + } + /** * Append the lane vehicle-type product to the current invoice order when requested. */ @@ -385,8 +402,8 @@ trait selfserve_lane_command_t if (($this->getCustomerNumber() !== $arguments->customer_number) && !$this->isBypassCustomerNumberValidation()) { throw new \InvalidArgumentException("Customer number mismatch: Lane customer number " . $this->getCustomerNumber() . " does not match argument customer number " . $arguments->customer_number); } - // Snapshot selector relay online state before relay shutdown. - $program_selector_online = $this->isProgramSelectorRelayOnlineForStop(); + // Snapshot the physical machine ON signal before session completion/reset. + $machine_start_triggered = $this->hasMachineStartSignalForStop(); // Open the exit port $this->open(selfserve_lane_port::EXIT); // Turn off relays in deterministic order after STOP @@ -395,8 +412,8 @@ trait selfserve_lane_command_t $this->logLaneAction(selfserve_lane_log_action::STOP_WASH); // Invoice the customer $this->invoice(); - // If program selector relay is online at stop time, bill the primary product. - $this->addVehicleTypeProductToInvoiceIfNeeded($program_selector_online); + // Only bill the machine wash product when the physical machine start signal was recorded. + $this->addVehicleTypeProductToInvoiceIfNeeded($machine_start_triggered); // Finalize any active self-serve wash session for this lane $this->completeLatestSessionForStop(); // Reset the lane diff --git a/services/nginx/app/objects/department_selfserve_tasks_o.php b/services/nginx/app/objects/department_selfserve_tasks_o.php index 16f0fb8b..668775a9 100644 --- a/services/nginx/app/objects/department_selfserve_tasks_o.php +++ b/services/nginx/app/objects/department_selfserve_tasks_o.php @@ -33,7 +33,7 @@ class department_selfserve_tasks_o extends db public object_property $description; // The task description public object_property $order_priority; // The order priority of the task (lower numbers are shown first) public object_property $services; // The services that the task enables (json), this is used to enable machine wash. - public object_property $buttons; // The buttons on the departments machine dynamic image that should be enabled by this task (json array of button ids) + public object_property $buttons; // The buttons on the departments machine dynamic image that should be enabled by this task (json array of mapped button ids) public object_property $dynamic_images_vehicle_type; // The vehicle type selection override on the machine, used by dynamicimages - int or null if not applicable. public object_property $created_at; public object_property $updated_at; @@ -72,7 +72,7 @@ class department_selfserve_tasks_o extends db * @param string $description The task description * @param int $order_priority The order priority of the task (lower numbers are shown first) * @param selfserve_lane_services[]|string[]|null $services The services that the task enables (stored as JSON array of service names). May be an array of enum cases or names. - * @param array|string|null $buttons Optional buttons on the department's machine dynamic image to be enabled by this task (stored as JSON array of button IDs). Accepts array of ints or a parsable string/JSON. + * @param array|string|null $buttons Optional buttons on the department's machine dynamic image to be enabled by this task (stored as JSON array of button IDs). Accepts program integers plus "reset" and "start". * @param int|null $dynamic_images_vehicle_type Optional vehicle type selection override for the machine UI. Integer >= 0 or null. * @return department_selfserve_tasks_o * @throws Exception If the object was not created successfully @@ -358,13 +358,13 @@ class department_selfserve_tasks_o extends db return $val; } /** - * Normalize mixed input for buttons into an array of integer IDs (>= 0). + * Normalize mixed input for buttons into an array of mapped button IDs. * Accepts: * - array of ints/strings * - JSON array string * - comma-separated string * @param mixed $input - * @return array + * @return array * @throws Exception */ public static function normalizeButtonsInput(mixed $input): array @@ -379,14 +379,22 @@ class department_selfserve_tasks_o extends db } } if (!is_array($raw)) { - throw new Exception('Invalid format for buttons. Expected array, JSON array, or comma-separated string of integers.'); + throw new Exception('Invalid format for buttons. Expected array, JSON array, or comma-separated string of mapped button ids.'); } $ids = []; foreach ($raw as $btn) { + if (is_string($btn)) { + $trimmed = trim($btn); + $specialButton = strtolower($trimmed); + if ($specialButton === 'reset' || $specialButton === 'start') { + $ids[] = $specialButton; + continue; + } + } if (is_int($btn)) { $val = $btn; - } elseif (is_string($btn) && ctype_digit($btn)) { - $val = (int)$btn; + } elseif (is_string($btn) && ctype_digit(trim($btn))) { + $val = (int)trim($btn); } elseif (is_numeric($btn) && (int)$btn == $btn) { $val = (int)$btn; } else { @@ -398,7 +406,16 @@ class department_selfserve_tasks_o extends db $ids[] = $val; } // de-duplicate while preserving order - $ids = array_values(array_unique($ids)); - return $ids; + $deduped = []; + $seen = []; + foreach ($ids as $id) { + $key = (is_int($id) ? 'int:' : 'string:') . (string)$id; + if (isset($seen[$key])) { + continue; + } + $seen[$key] = true; + $deduped[] = $id; + } + return $deduped; } } diff --git a/services/nginx/app/objects/selfserve_wash_session_tasks_o.php b/services/nginx/app/objects/selfserve_wash_session_tasks_o.php index a9134b10..fe6d5f53 100644 --- a/services/nginx/app/objects/selfserve_wash_session_tasks_o.php +++ b/services/nginx/app/objects/selfserve_wash_session_tasks_o.php @@ -63,7 +63,7 @@ class selfserve_wash_session_tasks_o extends db 'description' => $description, 'services' => $services, 'buttons' => $buttons, - 'dynamic_images_vehicle_type' => (int)$thumb_position, // The rotations to do on the image. + 'dynamic_images_vehicle_type' => $thumb_position === null ? null : (int)$thumb_position, // The rotations to do on the image. ]); $this->getObjectProperties(); $this->objectChanged(); diff --git a/services/nginx/app/openapi.yaml b/services/nginx/app/openapi.yaml index a27126f1..8af4bd93 100644 --- a/services/nginx/app/openapi.yaml +++ b/services/nginx/app/openapi.yaml @@ -5236,7 +5236,7 @@ paths: tags: - Self-Serve summary: Get all-in-one self-serve studio graph - description: Returns the replacement studio workspace graph with nodes, edges, resolved lookup labels, validation, layout, versioning, simulator defaults, gateway workspace, and permissions. Vehicle type lookups and scope nodes are derived from selectable wash products. + description: Returns the replacement studio workspace graph backed by the schema_version 2 draft config. Conditions own grouped expression trees directly; standalone rule nodes are omitted from v2 graphs. operationId: getSelfserveStudioGraph parameters: - name: department @@ -5255,7 +5255,7 @@ paths: tags: - Self-Serve summary: Bulk save all-in-one self-serve studio graph changes - description: Creates, updates, deletes, connects, disconnects, and reorders questions, conditions, rules, tasks, scopes, attachments metadata, and gateway references while keeping layout separate from runtime behavior. + description: Creates, updates, deletes, connects, disconnects, and reorders questions, conditions, and tasks by editing the schema_version 2 draft config JSON. Condition connections create expression predicates; layout remains separate from runtime behavior. operationId: saveSelfserveStudioGraph requestBody: required: true @@ -8068,6 +8068,93 @@ paths: '404': $ref: '#/components/responses/NotFound' + /relay/machine/on/post: + get: + tags: + - Plate Scans + summary: Record Shelly machine ON signal webhook + description: Accepts Shelly Cloud webhook/query parameters for input.toggle_on or switch.on and records the physical machine start signal for self-serve billing. + operationId: recordShellyMachineOnSignal + parameters: + - name: token + in: query + required: false + schema: {type: string} + - name: lane_id + in: query + required: false + schema: + type: integer + - name: relay_id + in: query + required: false + schema: + type: string + - name: event + in: query + required: false + schema: + type: string + enum: [input.toggle_on, switch.on] + - name: reg + in: query + required: false + schema: + type: string + responses: + '201': + description: Machine ON signal recorded and linked to a self-serve wash session + content: + application/json: + schema: + $ref: '#/components/schemas/MachineButtonPressWebhookResponse' + '202': + description: Shelly signal was recognized but ignored + '400': + $ref: '#/components/responses/BadRequest' + post: + tags: + - Plate Scans + summary: Record Shelly machine ON signal webhook + description: Accepts Shelly Cloud JSON webhook payloads for input.toggle_on or switch.on and records the physical machine start signal for self-serve billing. + operationId: recordShellyMachineOnSignalPost + requestBody: + required: false + content: + application/json: + schema: + type: object + properties: + token: + type: string + lane_id: + type: integer + relay_id: + type: string + event: + type: string + enum: [input.toggle_on, switch.on] + component: + type: string + example: input:0 + state: + type: boolean + output: + type: boolean + reg: + type: string + responses: + '201': + description: Machine ON signal recorded and linked to a self-serve wash session + content: + application/json: + schema: + $ref: '#/components/schemas/MachineButtonPressWebhookResponse' + '202': + description: Shelly signal was recognized but ignored + '400': + $ref: '#/components/responses/BadRequest' + # Module - e-conomic Endpoints /economic/customers/import: post: @@ -15065,8 +15152,7 @@ components: enum: [DRAFT, PUBLISHED, ARCHIVED] version_number: { type: integer } config: - type: object - additionalProperties: true + $ref: '#/components/schemas/SelfserveStudioV2Config' validation_result: $ref: '#/components/schemas/SelfserveStudioValidation' source_version_id: { type: integer, nullable: true } @@ -15074,6 +15160,90 @@ components: published_at: { type: string, nullable: true } created_at: { type: string, nullable: true } updated_at: { type: string, nullable: true } + SelfserveStudioV2Config: + type: object + required: [schema_version, questions, conditions, rules, tasks] + properties: + schema_version: + type: integer + enum: [2] + department_id: + type: integer + questions: + type: array + items: + type: object + additionalProperties: true + conditions: + type: array + items: + $ref: '#/components/schemas/SelfserveStudioV2Condition' + rules: + type: array + description: Empty in schema_version 2; retained only for backward-compatible payload shape. + maxItems: 0 + items: + type: object + tasks: + type: array + items: + type: object + additionalProperties: true + v2_meta: + type: object + additionalProperties: true + migration_issues: + type: array + items: + type: object + additionalProperties: true + SelfserveStudioV2Condition: + type: object + required: [id, expression] + properties: + id: + type: integer + name: + type: string + description: + type: string + nullable: true + expression: + $ref: '#/components/schemas/SelfserveStudioV2Expression' + additionalProperties: true + SelfserveStudioV2Expression: + oneOf: + - $ref: '#/components/schemas/SelfserveStudioV2ExpressionGroup' + - $ref: '#/components/schemas/SelfserveStudioV2ExpressionPredicate' + SelfserveStudioV2ExpressionGroup: + type: object + required: [type, operator, children] + properties: + type: + type: string + enum: [group] + operator: + type: string + enum: [ALL, ANY] + children: + type: array + items: + $ref: '#/components/schemas/SelfserveStudioV2Expression' + SelfserveStudioV2ExpressionPredicate: + type: object + required: [type, subject_type, subject_id, operator] + properties: + type: + type: string + enum: [predicate] + subject_type: + type: string + enum: [question, condition] + subject_id: + type: integer + operator: + type: string + enum: [IS_TRUE, IS_FALSE, IS_SET, IS_TRUE_OR_NOT_SET, IS_FALSE_OR_NOT_SET] SelfserveStudioGraph: type: object required: [nodes, edges, lookups, validation, layout, versions, simulator_defaults, gateway_workspace, permissions] @@ -15098,9 +15268,9 @@ components: items: $ref: '#/components/schemas/SelfserveConfigVersion' active_config: - type: object + allOf: + - $ref: '#/components/schemas/SelfserveStudioV2Config' nullable: true - additionalProperties: true draft: type: object additionalProperties: true @@ -15125,7 +15295,8 @@ components: enum: [create, update, delete, connect, disconnect, reorder] entity: type: string - enum: [question, condition, rule, task] + enum: [question, condition, task] + description: Standalone rule operations are not accepted for schema_version 2 drafts. id: type: integer nullable: true diff --git a/services/nginx/app/resources/edge-gateway-agent/agent.php b/services/nginx/app/resources/edge-gateway-agent/agent.php index 9d1aba5c..149798c9 100644 --- a/services/nginx/app/resources/edge-gateway-agent/agent.php +++ b/services/nginx/app/resources/edge-gateway-agent/agent.php @@ -1016,6 +1016,8 @@ final class TruckwashEdgeAgent private string $controlPlaneStatusPath; private string $stagedUpdatePath; private int $lastHeartbeatAt = 0; + private int $lastMachineSignalPollAt = 0; + private int $lastMachineSignalMonitorRefreshAt = 0; private string $agentInstanceId; public function __construct(string $configPath) @@ -1058,6 +1060,7 @@ final class TruckwashEdgeAgent $this->flushOutbox(); $this->pumpBrokerTransport(); $this->heartbeat(); + $this->pollMachineStartSignals(); if ($this->resumePendingOperationCompletion()) { $this->pumpBrokerTransport(); continue; @@ -1291,6 +1294,171 @@ final class TruckwashEdgeAgent ], JSON_UNESCAPED_SLASHES) . PHP_EOL); } + private function pollMachineStartSignals(): void + { + $interval = max(1, (int)$this->config->get('machineSignalPollIntervalSeconds', 2)); + if ((time() - $this->lastMachineSignalPollAt) < $interval) { + return; + } + $this->lastMachineSignalPollAt = time(); + + $gatewayId = (int)$this->config->get('gatewayId'); + $agentToken = (string)$this->config->get('agentToken'); + if ($gatewayId <= 0 || trim($agentToken) === '') { + return; + } + + foreach ($this->machineSignalMonitors($gatewayId, $agentToken) as $monitor) { + $localIp = trim((string)($monitor['local_ip'] ?? '')); + if ($localIp === '') { + continue; + } + + try { + $component = strtolower(trim((string)($monitor['component'] ?? 'input'))) === 'switch' ? 'switch' : 'input'; + $channel = (int)($monitor['channel'] ?? 0); + $status = $this->machineSignalMonitorStatus($localIp, $channel, $component); + $on = $this->machineSignalOnState($status, $component); + if ($on === null) { + continue; + } + + $stateKey = sprintf( + 'selfserve_machine_signal:%s:%s:%d', + preg_replace('/[^A-Za-z0-9_\-:.]+/', '_', (string)($monitor['relay_id'] ?? 'relay')), + $component, + $channel + ); + $previous = $this->stateStore->getJson($stateKey, null); + $previousOn = is_array($previous) && array_key_exists('on', $previous) ? (bool)$previous['on'] : false; + + if ($on && !$previousOn) { + $event = $component === 'switch' ? 'switch.on' : 'input.toggle_on'; + $payload = [ + 'agent_token' => $agentToken, + 'agent_instance_id' => $this->agentInstanceId, + 'lane_id' => (int)($monitor['lane_id'] ?? 0), + 'relay_id' => (string)($monitor['relay_id'] ?? ''), + 'device_id' => (string)($monitor['device_id'] ?? ''), + 'component' => $component, + 'channel' => $channel, + 'event' => $event, + 'source' => 'edge_gateway_poll', + 'status' => $status, + ]; + $payload[$component === 'switch' ? 'output' : 'state'] = true; + + $this->sendControlPlaneEvent( + '/edge-agent/gateways/' . $gatewayId . '/selfserve/machine-signal', + $payload, + 'machine_signal' + ); + } + + $this->stateStore->setJson($stateKey, [ + 'on' => $on, + 'component' => $component, + 'channel' => $channel, + 'relay_id' => (string)($monitor['relay_id'] ?? ''), + 'updated_at' => date('c'), + ]); + } catch (Throwable $throwable) { + $this->logger->warning('Machine start signal poll failed: ' . $throwable->getMessage()); + } + } + } + + /** + * @return array> + */ + private function machineSignalMonitors(int $gatewayId, string $agentToken): array + { + $cache = $this->stateStore->getJson('selfserve_machine_signal_monitors', []); + if ( + is_array($cache) + && isset($cache['monitors'], $cache['refreshed_at']) + && is_array($cache['monitors']) + && (time() - (int)$cache['refreshed_at']) < 60 + ) { + return array_values(array_filter($cache['monitors'], 'is_array')); + } + + try { + $response = $this->http->post('/edge-agent/gateways/' . $gatewayId . '/selfserve/machine-signal-bindings', [ + 'agent_token' => $agentToken, + 'agent_instance_id' => $this->agentInstanceId, + ], 10); + $payload = is_array($response['data'] ?? null) ? (array)$response['data'] : (is_array($response) ? $response : []); + $monitors = is_array($payload['monitors'] ?? null) ? array_values(array_filter((array)$payload['monitors'], 'is_array')) : []; + $this->lastMachineSignalMonitorRefreshAt = time(); + $this->stateStore->setJson('selfserve_machine_signal_monitors', [ + 'refreshed_at' => $this->lastMachineSignalMonitorRefreshAt, + 'monitors' => $monitors, + ]); + + return $monitors; + } catch (Throwable $throwable) { + if (is_array($cache) && isset($cache['monitors']) && is_array($cache['monitors'])) { + return array_values(array_filter($cache['monitors'], 'is_array')); + } + $this->logger->warning('Machine start signal monitor refresh failed: ' . $throwable->getMessage()); + return []; + } + } + + private function machineSignalMonitorStatus(string $localIp, int $channel, string $component): array + { + if ($component === 'input') { + try { + $input = $this->workerHttp->post('/relay/input-status', [ + 'local_ip' => $localIp, + 'channel' => $channel, + ], 5) ?? []; + return [ + 'input_state' => $input['state'] ?? null, + 'input' => $input, + ]; + } catch (Throwable) { + // Fall back to the combined status endpoint below. + } + } + + return $this->workerHttp->post('/relay/status', [ + 'local_ip' => $localIp, + 'channel' => $channel, + 'include_input' => $component === 'input', + ], 8) ?? []; + } + + private function machineSignalOnState(array $status, string $component): ?bool + { + if ($component === 'switch') { + if (array_key_exists('output', $status)) { + return (bool)$status['output']; + } + if (array_key_exists('on', $status)) { + return (bool)$status['on']; + } + if (isset($status['raw']) && is_array($status['raw']) && array_key_exists('output', $status['raw'])) { + return (bool)$status['raw']['output']; + } + + return null; + } + + if (array_key_exists('input_state', $status)) { + return $status['input_state'] === null ? null : (bool)$status['input_state']; + } + if (isset($status['input']) && is_array($status['input']) && array_key_exists('state', $status['input'])) { + return (bool)$status['input']['state']; + } + if (array_key_exists('state', $status)) { + return (bool)$status['state']; + } + + return null; + } + private function processCommandQueue(): void { $gatewayId = (int)$this->config->get('gatewayId'); diff --git a/services/nginx/app/resources/edge-gateway-agent/lan-worker.php b/services/nginx/app/resources/edge-gateway-agent/lan-worker.php index e3e7ed9e..df542c61 100644 --- a/services/nginx/app/resources/edge-gateway-agent/lan-worker.php +++ b/services/nginx/app/resources/edge-gateway-agent/lan-worker.php @@ -48,18 +48,22 @@ function worker_http_get_json(string $url, int $timeoutSeconds = 8): array return $decoded; } -function worker_fetch_shelly_state(string $localIp, int $channel): array +function worker_fetch_shelly_state(string $localIp, int $channel, bool $includeInput = false): array { if ($localIp === '') { throw new RuntimeException('Missing Shelly IP address'); } + $input = $includeInput ? worker_fetch_shelly_input_state($localIp, $channel) : null; + try { $payload = worker_http_get_json(sprintf('http://%s/rpc/Switch.GetStatus?id=%d', $localIp, $channel)); return [ 'online' => true, 'on' => (bool)($payload['output'] ?? false), 'output' => (bool)($payload['output'] ?? false), + 'input_state' => $input['state'] ?? null, + 'input' => $input, 'raw' => $payload, ]; } catch (Throwable) { @@ -68,11 +72,31 @@ function worker_fetch_shelly_state(string $localIp, int $channel): array 'online' => true, 'on' => (bool)($payload['ison'] ?? false), 'output' => (bool)($payload['ison'] ?? false), + 'input_state' => $input['state'] ?? null, + 'input' => $input, 'raw' => $payload, ]; } } +function worker_fetch_shelly_input_state(string $localIp, int $channel): ?array +{ + if ($localIp === '') { + throw new RuntimeException('Missing Shelly IP address'); + } + + try { + $payload = worker_http_get_json(sprintf('http://%s/rpc/Input.GetStatus?id=%d', $localIp, $channel), 2); + return [ + 'online' => true, + 'state' => (bool)($payload['state'] ?? false), + 'raw' => $payload, + ]; + } catch (Throwable) { + return null; + } +} + function worker_switch_shelly_state(string $localIp, int $channel, bool $on): array { try { @@ -125,7 +149,19 @@ try { if ($method === 'POST' && $path === '/relay/status') { $localIp = trim((string)($body['local_ip'] ?? $body['localIp'] ?? '')); $channel = (int)($body['channel'] ?? 0); - worker_json_response(200, worker_fetch_shelly_state($localIp, $channel)); + $includeInput = (bool)($body['include_input'] ?? $body['includeInput'] ?? false); + worker_json_response(200, worker_fetch_shelly_state($localIp, $channel, $includeInput)); + return; + } + + if ($method === 'POST' && $path === '/relay/input-status') { + $localIp = trim((string)($body['local_ip'] ?? $body['localIp'] ?? '')); + $channel = (int)($body['channel'] ?? 0); + $input = worker_fetch_shelly_input_state($localIp, $channel); + if ($input === null) { + throw new RuntimeException('Shelly input status is not available'); + } + worker_json_response(200, $input); return; } diff --git a/services/nginx/app/routes/departmentLanesRoute.php b/services/nginx/app/routes/departmentLanesRoute.php index 96497cc2..29906ed1 100644 --- a/services/nginx/app/routes/departmentLanesRoute.php +++ b/services/nginx/app/routes/departmentLanesRoute.php @@ -169,6 +169,15 @@ class departmentLanesRoute // Resolve dynamic image id → class (support id=1 for now) $dynamic_image_id = $lane->dynamic_image_id->value(); + if ($response->isRequestParameterSet('dynamic_image_id')) { + $dynamic_image_override = $response->getRequestParameter('dynamic_image_id'); + if ($dynamic_image_override === null || $dynamic_image_override === '' || strtolower((string)$dynamic_image_override) === 'null') { + $dynamic_image_id = null; + } else { + $dynamic_image_id = (int)$dynamic_image_override; + self::requireMinValue($dynamic_image_id, 1); + } + } if ($dynamic_image_id === null) { $response->error('No dynamic image configured for this lane', 404); } @@ -242,11 +251,9 @@ class departmentLanesRoute switch ($dynamic_image_id) { case 1: $image = new machine_1(); - // Require thumb_position for machine_1 - if ($thumb_position === null) { - $response->error('thumb_position parameter is required for this dynamic image', 400); + if ($thumb_position !== null) { + $image->thumb_position = $thumb_position; } - $image->thumb_position = $thumb_position; break; default: $response->error('Unsupported dynamic image id: ' . $dynamic_image_id, 400); diff --git a/services/nginx/app/routes/departmentSelfserveStudioRoute.php b/services/nginx/app/routes/departmentSelfserveStudioRoute.php index f5abad03..e9d651b1 100644 --- a/services/nginx/app/routes/departmentSelfserveStudioRoute.php +++ b/services/nginx/app/routes/departmentSelfserveStudioRoute.php @@ -69,6 +69,30 @@ class departmentSelfserveStudioRoute 'edit_department_selfserve_config_versions' => 'Save canvas-only self-serve studio layout', ]); + $this->put('/department/selfserve/studio/virtual-hardware', function (): void { + global $response; + $user = $this->requireStudioUser('edit_department_selfserve_config_versions'); + self::requireParameters(['department', 'operation']); + $departmentId = (int)self::getParameter('department'); + $this->assertDepartmentAccess($user, $departmentId); + + try { + $payload = self::getParametersAsArray(); + $graph = (new selfserve_studio_graph())->applyVirtualHardwareOperation( + $departmentId, + $payload, + (int)$user->id, + $this->studioPermissions() + ); + (new logs_o())->add('selfserve_studio', $departmentId, 1, $user->id, 'SAVE_STUDIO_VIRTUAL_HARDWARE', 'Saved self-serve studio virtual hardware'); + $response->success($graph); + } catch (\RuntimeException $exception) { + $response->error($exception->getMessage(), 422); + } + }, [ + 'edit_department_selfserve_config_versions' => 'Generate and edit studio-only virtual hardware', + ]); + $this->post('/department/selfserve/studio/validate', function (): void { global $response; $user = $this->requireStudioUser('edit_department_selfserve_config_versions'); @@ -117,6 +141,9 @@ class departmentSelfserveStudioRoute try { $published = (new selfserve_config_versioning())->publishDraft($departmentId, (int)$user->id); + $validation = (new selfserve_studio_graph())->validatePayload($departmentId); + $published['warnings'] = (array)($validation['warnings'] ?? []); + $published['validation'] = $validation; (new logs_o())->add('selfserve_studio', $departmentId, 1, $user->id, 'PUBLISH_STUDIO_GRAPH', 'Published self-serve studio graph'); $response->success($published); } catch (\RuntimeException $exception) { diff --git a/services/nginx/app/routes/machineButtonPressRoute.php b/services/nginx/app/routes/machineButtonPressRoute.php index 79693b66..b423eba7 100644 --- a/services/nginx/app/routes/machineButtonPressRoute.php +++ b/services/nginx/app/routes/machineButtonPressRoute.php @@ -4,6 +4,7 @@ namespace routes; use classes\authentication; use classes\selfserve; +use modules\selfserve\classes\selfserve_machine_signal; use modules\selfserve\classes\selfserve_wash_flow; use objects\department_lanes_o; use objects\logs_o; @@ -52,6 +53,60 @@ class machineButtonPressRoute $this->post('/relay/button/press/post', $handler, [ 'add_button_press' => 'Add a button press' ]); + + $shellyHandler = function () { + global $response; + + self::requirePlateScannerAuth(); + $plate_scanner = (new authentication())->get_plate_scanner(); + if (!$plate_scanner) { + $response->error('Invalid plate scanner session', 403); + } + + $payload = $this->getParametersAsArray(); + unset($payload['token']); + $lane_id = self::isParametersSet(['lane_id']) ? (int)self::getParameter('lane_id') : null; + + try { + $result = (new selfserve_machine_signal())->recordCloudShellySignal( + (int)$plate_scanner->department_id->value(), + $lane_id, + $payload, + [ + 'scanner_id' => (int)$plate_scanner->id, + 'scanner_name' => (string)$plate_scanner->name->value(), + ] + ); + } catch (\Throwable $e) { + $response->error($e->getMessage(), 400); + } + + (new logs_o())->add( + 'relay', + $plate_scanner->department_id->value(), + 1, + 0, + 'TRIGGER_MACHINE_ON_SIGNAL', + 'Shelly machine ON signal received from plate scanner: ' . $plate_scanner->id + ); + $response->success([ + 'message' => !empty($result['recorded']) ? 'Machine ON signal recorded.' : 'Shelly signal ignored.', + 'scanner' => $plate_scanner->name->value(), + 'lane_id' => $result['lane_id'] ?? $lane_id, + 'signal' => $result['signal'] ?? null, + 'selfserve' => $result['selfserve'] ?? null, + 'ignored' => $result['ignored'] ?? false, + 'reason' => $result['reason'] ?? null, + ], !empty($result['recorded']) ? 201 : 202); + }; + + $this->get('/relay/machine/on/post', $shellyHandler, [ + 'add_button_press' => 'Record a Shelly machine ON signal' + ]); + + $this->post('/relay/machine/on/post', $shellyHandler, [ + 'add_button_press' => 'Record a Shelly machine ON signal' + ]); } private function resolveLaneId(plate_scanners_o $plateScanner): int diff --git a/services/nginx/app/tests/Unit/DynamicImages/DepartmentLaneDynamicImageRouteTest.php b/services/nginx/app/tests/Unit/DynamicImages/DepartmentLaneDynamicImageRouteTest.php new file mode 100644 index 00000000..a1fb4015 --- /dev/null +++ b/services/nginx/app/tests/Unit/DynamicImages/DepartmentLaneDynamicImageRouteTest.php @@ -0,0 +1,11 @@ +not->toBeFalse(); + expect($content)->toContain("isRequestParameterSet('dynamic_image_id')"); + expect($content)->toContain("\$dynamic_image_override = \$response->getRequestParameter('dynamic_image_id')"); + expect($content)->toContain("self::requireMinValue(\$dynamic_image_id, 1)"); + expect($content)->toContain("switch (\$dynamic_image_id)"); +}); diff --git a/services/nginx/app/tests/Unit/DynamicImages/DynamicImagePreRenderCronWiringTest.php b/services/nginx/app/tests/Unit/DynamicImages/DynamicImagePreRenderCronWiringTest.php index a701bd57..d206591b 100644 --- a/services/nginx/app/tests/Unit/DynamicImages/DynamicImagePreRenderCronWiringTest.php +++ b/services/nginx/app/tests/Unit/DynamicImages/DynamicImagePreRenderCronWiringTest.php @@ -9,6 +9,8 @@ it('registers dynamic image pre-render cron task and related helpers', function expect($content)->toContain('collectDynamicImageTaskGroupsForLane'); expect($content)->toContain('buildDynamicImageVariantsForTaskGroup'); expect($content)->toContain('renderDynamicImageVariant'); + expect($content)->toContain('mergeUniqueButtonValues'); + expect($content)->toContain('normalizeButtonsInput'); expect($content)->toContain('dynamic_image:'); expect($content)->toContain('machine_1'); }); diff --git a/services/nginx/app/tests/Unit/Selfserve/SelfserveConditionEvaluatorTest.php b/services/nginx/app/tests/Unit/Selfserve/SelfserveConditionEvaluatorTest.php index 9b801c1f..6729cee9 100644 --- a/services/nginx/app/tests/Unit/Selfserve/SelfserveConditionEvaluatorTest.php +++ b/services/nginx/app/tests/Unit/Selfserve/SelfserveConditionEvaluatorTest.php @@ -78,3 +78,83 @@ it('evaluates typed task gates with strict semantics', function (): void { expect($evaluator->taskGateSatisfiedTyped(selfserve_task_gate_type::QUESTION->value, 8, [], [8 => true]))->toBeTrue(); expect($evaluator->taskGateSatisfiedTyped(selfserve_task_gate_type::QUESTION->value, 8, [8 => true], [8 => false]))->toBeFalse(); }); + +it('evaluates nested v2 ALL and ANY expression trees with trace output', function (): void { + $evaluator = new selfserve_condition_evaluator(); + + $conditions = [ + [ + 'id' => 10, + 'expression' => [ + 'type' => 'group', + 'operator' => 'ALL', + 'children' => [ + ['type' => 'predicate', 'subject_type' => 'question', 'subject_id' => 1, 'operator' => 'IS_TRUE'], + [ + 'type' => 'group', + 'operator' => 'ANY', + 'children' => [ + ['type' => 'predicate', 'subject_type' => 'question', 'subject_id' => 2, 'operator' => 'IS_TRUE'], + ['type' => 'predicate', 'subject_type' => 'question', 'subject_id' => 3, 'operator' => 'IS_FALSE_OR_NOT_SET'], + ], + ], + ], + ], + ], + [ + 'id' => 20, + 'expression' => [ + 'type' => 'group', + 'operator' => 'ALL', + 'children' => [ + ['type' => 'predicate', 'subject_type' => 'condition', 'subject_id' => 10, 'operator' => 'IS_TRUE'], + ['type' => 'predicate', 'subject_type' => 'question', 'subject_id' => 4, 'operator' => 'IS_SET'], + ], + ], + ], + ]; + + $evaluation = $evaluator->evaluateExpressionsWithTrace($conditions, [ + 1 => true, + 2 => false, + 4 => false, + ]); + + expect($evaluation['results'])->toBe([ + 10 => true, + 20 => true, + ]); + expect($evaluation['trace'][10]['expression']['children'][1]['operator'])->toBe('ANY'); + expect($evaluation['trace'][20]['expression']['children'][0]['subject_type'])->toBe('condition'); +}); + +it('returns false and traces v2 condition expression cycles', function (): void { + $evaluator = new selfserve_condition_evaluator(); + + $evaluation = $evaluator->evaluateExpressionsWithTrace([ + [ + 'id' => 10, + 'expression' => [ + 'type' => 'group', + 'operator' => 'ALL', + 'children' => [ + ['type' => 'predicate', 'subject_type' => 'condition', 'subject_id' => 20, 'operator' => 'IS_TRUE'], + ], + ], + ], + [ + 'id' => 20, + 'expression' => [ + 'type' => 'group', + 'operator' => 'ALL', + 'children' => [ + ['type' => 'predicate', 'subject_type' => 'condition', 'subject_id' => 10, 'operator' => 'IS_TRUE'], + ], + ], + ], + ], []); + + expect($evaluation['results'][10])->toBeFalse(); + expect($evaluation['results'][20])->toBeFalse(); + expect(json_encode($evaluation['trace']))->toContain('Condition dependency cycle detected'); +}); diff --git a/services/nginx/app/tests/Unit/Selfserve/SelfserveConfigVersioningTest.php b/services/nginx/app/tests/Unit/Selfserve/SelfserveConfigVersioningTest.php index a41a4a58..8405844a 100644 --- a/services/nginx/app/tests/Unit/Selfserve/SelfserveConfigVersioningTest.php +++ b/services/nginx/app/tests/Unit/Selfserve/SelfserveConfigVersioningTest.php @@ -100,6 +100,106 @@ it('fails validation when nested conditions form cycles', function (): void { expect(implode("\n", $validation['errors']))->toContain('Condition cycle detected'); }); +it('migrates legacy AND and OR rules into grouped v2 condition expressions', function (): void { + $service = selfserve_config_versioning_without_constructor(); + + $config = $service->migrateLegacyConfigToV2([ + 'department_id' => 6, + 'questions' => [ + ['id' => 1], + ['id' => 2], + ['id' => 3], + ], + 'conditions' => [ + ['id' => 10, 'name' => 'Ready'], + ], + 'rules' => [ + ['id' => 100, 'condition_id' => 10, 'type' => 'IS_TRUE', 'object_type' => 'question', 'object_id' => 1], + ['id' => 101, 'condition_id' => 10, 'type' => 'IS_TRUE_OR_ANY_TRUE', 'object_type' => 'question', 'object_id' => 2], + ['id' => 102, 'condition_id' => 10, 'type' => 'IS_TRUE_OR_ANY_TRUE', 'object_type' => 'question', 'object_id' => 3], + ], + 'tasks' => [], + ]); + + $expression = $config['conditions'][0]['expression']; + + expect($config['schema_version'])->toBe(2); + expect($config['rules'])->toBe([]); + expect($expression['operator'])->toBe('ALL'); + expect($expression['children'][0])->toMatchArray([ + 'type' => 'predicate', + 'subject_type' => 'question', + 'subject_id' => 1, + 'operator' => 'IS_TRUE', + ]); + expect($expression['children'][1]['operator'])->toBe('ANY'); + expect(array_column($expression['children'][1]['children'], 'subject_id'))->toBe([2, 3]); +}); + +it('rejects unsupported legacy task-target rules after migration', function (): void { + $service = selfserve_config_versioning_without_constructor(); + + $config = $service->migrateLegacyConfigToV2([ + 'department_id' => 6, + 'questions' => [['id' => 1]], + 'conditions' => [['id' => 10, 'name' => 'Ready']], + 'rules' => [ + ['id' => 100, 'condition_id' => 10, 'type' => 'IS_TRUE', 'object_type' => 'task', 'object_id' => 50], + ], + 'tasks' => [['id' => 50]], + ]); + $validation = $service->validateConfig($config); + + expect($validation['valid'])->toBeFalse(); + expect(implode("\n", $validation['errors']))->toContain('unsupported object_type `task`'); +}); + +it('validates v2 expressions for empty used conditions, missing refs, invalid operators, and cycles', function (): void { + $service = selfserve_config_versioning_without_constructor(); + + $validation = $service->validateConfig([ + 'schema_version' => 2, + 'questions' => [ + ['id' => 1, 'condition_id' => 10], + ], + 'conditions' => [ + ['id' => 10, 'expression' => ['type' => 'group', 'operator' => 'ALL', 'children' => []]], + [ + 'id' => 20, + 'expression' => [ + 'type' => 'group', + 'operator' => 'ALL', + 'children' => [ + ['type' => 'predicate', 'subject_type' => 'question', 'subject_id' => 999, 'operator' => 'IS_TRUE'], + ['type' => 'predicate', 'subject_type' => 'condition', 'subject_id' => 30, 'operator' => 'IS_TRUE'], + ], + ], + ], + [ + 'id' => 30, + 'expression' => [ + 'type' => 'group', + 'operator' => 'ALL', + 'children' => [ + ['type' => 'predicate', 'subject_type' => 'condition', 'subject_id' => 20, 'operator' => 'NOPE'], + ], + ], + ], + ], + 'tasks' => [ + ['id' => 100, 'gate_type' => 'CONDITION', 'gate_ref_id' => 10], + ], + ]); + + $errors = implode("\n", $validation['errors']); + + expect($validation['valid'])->toBeFalse(); + expect($errors)->toContain('Condition 10 is used but has an empty expression.'); + expect($errors)->toContain('unknown question predicate subject_id 999'); + expect($errors)->toContain('invalid predicate operator `NOPE`'); + expect($errors)->toContain('Condition cycle detected'); +}); + 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/SelfserveLaneStopFlowTest.php b/services/nginx/app/tests/Unit/Selfserve/SelfserveLaneStopFlowTest.php index 02bc9440..dd93aefb 100644 --- a/services/nginx/app/tests/Unit/Selfserve/SelfserveLaneStopFlowTest.php +++ b/services/nginx/app/tests/Unit/Selfserve/SelfserveLaneStopFlowTest.php @@ -55,6 +55,8 @@ class SelfserveLaneStopFlowHarness public int $invoiceCalls = 0; public int $ensureInvoiceOrderContextCalls = 0; public int $vehicleTypeProductAddCalls = 0; + public int $programSelectorStatusReads = 0; + public ?bool $lastVehicleTypeProductDecision = null; /** @var selfserve_lane_port[] */ public array $openedPorts = []; /** @var selfserve_lane_relay[] */ @@ -70,14 +72,17 @@ class SelfserveLaneStopFlowHarness private bool $bypassCustomerValidation = false; private bool $programSelectorOnline; private bool $programSelectorOn; + private bool $machineStartTriggered; public function __construct( - bool $programSelectorOnline, + bool $machineStartTriggered, + bool $programSelectorOnline = false, bool $programSelectorOn = true, string $machineRelayId = 'relay-machine', string $programRelayId = 'relay-program', string $cleanerRelayId = 'relay-cleaner' ) { + $this->machineStartTriggered = $machineStartTriggered; $this->programSelectorOnline = $programSelectorOnline; $this->programSelectorOn = $programSelectorOn; $this->department_lane = new SelfserveLaneCommandDepartmentLaneFake( @@ -165,6 +170,7 @@ class SelfserveLaneStopFlowHarness public function getMachineProgramPickerRelayStatus(): array { + $this->programSelectorStatusReads++; return [ 'online' => $this->programSelectorOnline, 'on' => $this->programSelectorOn, @@ -197,17 +203,32 @@ class SelfserveLaneStopFlowHarness { // No-op in unit tests. } + + protected function hasMachineStartSignalForStop(): bool + { + return $this->machineStartTriggered; + } + + protected function addVehicleTypeProductToInvoiceIfNeeded(bool $should_add): void + { + $this->lastVehicleTypeProductDecision = $should_add; + if ($should_add) { + $this->vehicleTypeProductAddCalls++; + } + } } -it('adds vehicle type product on STOP when program selector relay is online, then turns off cleaner and machine relays', function (): void { - $lane = new SelfserveLaneStopFlowHarness(programSelectorOnline: true); +it('adds vehicle type product on STOP when the physical machine ON signal was recorded, then turns off cleaner and machine relays', function (): void { + $lane = new SelfserveLaneStopFlowHarness(machineStartTriggered: true); $args = (new selfserve_lane_command_arguments())->setCustomerNumber(1234); $lane->execute(selfserve_lane_command::STOP, $args); expect($lane->invoiceCalls)->toBe(1); expect($lane->ensureInvoiceOrderContextCalls)->toBe(0); - expect($lane->vehicleTypeProductAddCalls)->toBe(0); + expect($lane->vehicleTypeProductAddCalls)->toBe(1); + expect($lane->lastVehicleTypeProductDecision)->toBeTrue(); + expect($lane->programSelectorStatusReads)->toBe(0); expect($lane->openedPorts)->toBe([selfserve_lane_port::EXIT]); expect($lane->turnedOffRelays)->toBe([ selfserve_lane_relay::MACHINE_CLEANER, @@ -216,8 +237,9 @@ it('adds vehicle type product on STOP when program selector relay is online, the expect($lane->getLaneStatus())->toBe(selfserve_lane_status::AVAILABLE); }); -it('skips vehicle type product add when program selector relay is offline and only disables configured relays', function (): void { +it('skips vehicle type product add when no physical machine ON signal was recorded and only disables configured relays', function (): void { $lane = new SelfserveLaneStopFlowHarness( + machineStartTriggered: false, programSelectorOnline: false, machineRelayId: 'relay-machine', programRelayId: 'relay-program', @@ -230,15 +252,18 @@ it('skips vehicle type product add when program selector relay is offline and on expect($lane->invoiceCalls)->toBe(1); expect($lane->ensureInvoiceOrderContextCalls)->toBe(0); expect($lane->vehicleTypeProductAddCalls)->toBe(0); + expect($lane->lastVehicleTypeProductDecision)->toBeFalse(); + expect($lane->programSelectorStatusReads)->toBe(0); expect($lane->turnedOffRelays)->toBe([ selfserve_lane_relay::MACHINE, ]); }); -it('bills primary product when selector relay is online even if relay output is off', function (): void { +it('does not use selector relay online status as machine-wash billing evidence', function (): void { $lane = new SelfserveLaneStopFlowHarness( + machineStartTriggered: false, programSelectorOnline: true, - programSelectorOn: false + programSelectorOn: true ); $args = (new selfserve_lane_command_arguments())->setCustomerNumber(1234); @@ -247,4 +272,6 @@ it('bills primary product when selector relay is online even if relay output is expect($lane->invoiceCalls)->toBe(1); expect($lane->ensureInvoiceOrderContextCalls)->toBe(0); expect($lane->vehicleTypeProductAddCalls)->toBe(0); + expect($lane->lastVehicleTypeProductDecision)->toBeFalse(); + expect($lane->programSelectorStatusReads)->toBe(0); }); diff --git a/services/nginx/app/tests/Unit/Selfserve/SelfserveMachineSignalTest.php b/services/nginx/app/tests/Unit/Selfserve/SelfserveMachineSignalTest.php new file mode 100644 index 00000000..adaad99d --- /dev/null +++ b/services/nginx/app/tests/Unit/Selfserve/SelfserveMachineSignalTest.php @@ -0,0 +1,51 @@ +normalizeShellyPayload([ + 'event' => 'input.toggle_on', + 'component' => 'input:0', + 'state' => true, + 'relay_id' => 'M-1', + ]); + + expect($signal['recognized'])->toBeTrue() + ->and($signal['on'])->toBeTrue() + ->and($signal['component'])->toBe('input') + ->and($signal['relay_id'])->toBe('M-1'); +}); + +it('normalizes Shelly switch ON events and nested status payloads', function (): void { + $service = new selfserve_machine_signal(); + + $switchSignal = $service->normalizeShellyPayload([ + 'event' => 'switch.on', + 'component' => 'switch:0', + 'output' => true, + ]); + $statusSignal = $service->normalizeShellyPayload([ + 'status' => [ + 'input:0' => ['state' => true], + ], + ]); + + expect($switchSignal['recognized'])->toBeTrue() + ->and($switchSignal['on'])->toBeTrue() + ->and($switchSignal['component'])->toBe('switch') + ->and($statusSignal['recognized'])->toBeTrue() + ->and($statusSignal['on'])->toBeTrue(); +}); + +it('recognizes Shelly OFF events but does not treat them as billable machine starts', function (): void { + $signal = (new selfserve_machine_signal())->normalizeShellyPayload([ + 'event' => 'input.toggle_off', + 'component' => 'input:0', + 'state' => false, + ]); + + expect($signal['recognized'])->toBeTrue() + ->and($signal['on'])->toBeFalse(); +}); diff --git a/services/nginx/app/tests/Unit/Selfserve/SelfserveOpenApiSpecTest.php b/services/nginx/app/tests/Unit/Selfserve/SelfserveOpenApiSpecTest.php index 3d5e74f0..80fdb28d 100644 --- a/services/nginx/app/tests/Unit/Selfserve/SelfserveOpenApiSpecTest.php +++ b/services/nginx/app/tests/Unit/Selfserve/SelfserveOpenApiSpecTest.php @@ -50,6 +50,7 @@ it('documents self-serve machine type, eligibility, summary, and webhook endpoin expect($content)->toContain('/department/selfserve/vehicle/allowed:'); expect($content)->toContain('/department/selfserve/washes/summary:'); expect($content)->toContain('/relay/button/press/post:'); + expect($content)->toContain('/relay/machine/on/post:'); expect($content)->toContain('/modules/self-serve/lane/wash/in-progress:'); expect($content)->toContain('/modules/self-serve/lane/relay/machine/status:'); expect($content)->toContain('/modules/self-serve/lane/relay/machine/set:'); diff --git a/services/nginx/app/tests/Unit/Selfserve/SelfserveRouteWiringTest.php b/services/nginx/app/tests/Unit/Selfserve/SelfserveRouteWiringTest.php index 4ef77b6d..7cf015a8 100644 --- a/services/nginx/app/tests/Unit/Selfserve/SelfserveRouteWiringTest.php +++ b/services/nginx/app/tests/Unit/Selfserve/SelfserveRouteWiringTest.php @@ -15,7 +15,24 @@ it('wires self-serve machine types, eligibility, summaries, and machine-start we expect($webhookRoute)->not->toBeFalse(); expect($webhookRoute)->toContain('/relay/button/press/post'); + expect($webhookRoute)->toContain('/relay/machine/on/post'); expect($webhookRoute)->toContain('recordMachineStartWebhook'); + expect($webhookRoute)->toContain('recordCloudShellySignal'); +}); + +it('wires local edge gateway machine ON signal monitor endpoints', function (): void { + $edgeGatewayRoute = file_get_contents(app_path('modules/edgegateway/routes/edgeGatewaysRoute.php')); + $agent = file_get_contents(app_path('resources/edge-gateway-agent/agent.php')); + $worker = file_get_contents(app_path('resources/edge-gateway-agent/lan-worker.php')); + + expect($edgeGatewayRoute)->not->toBeFalse() + ->and($edgeGatewayRoute)->toContain('/edge-agent/gateways/{id}/selfserve/machine-signal-bindings') + ->and($edgeGatewayRoute)->toContain('/edge-agent/gateways/{id}/selfserve/machine-signal') + ->and($edgeGatewayRoute)->toContain('recordEdgeGatewaySignal') + ->and($agent)->toContain('pollMachineStartSignals') + ->and($agent)->toContain('/selfserve/machine-signal') + ->and($worker)->toContain('Input.GetStatus') + ->and($worker)->toContain('/relay/input-status'); }); it('keeps machine type support wired into lanes, tasks, and conditions routes', function (): void { diff --git a/services/nginx/app/tests/Unit/Selfserve/SelfserveSchemaBootstrapCompatibilityTest.php b/services/nginx/app/tests/Unit/Selfserve/SelfserveSchemaBootstrapCompatibilityTest.php index f56ab834..4848e932 100644 --- a/services/nginx/app/tests/Unit/Selfserve/SelfserveSchemaBootstrapCompatibilityTest.php +++ b/services/nginx/app/tests/Unit/Selfserve/SelfserveSchemaBootstrapCompatibilityTest.php @@ -26,3 +26,18 @@ it('creates canvas-only self-serve studio layout storage', function (): void { expect($bootstrapContent)->toContain('layout_json JSON NOT NULL'); expect($bootstrapContent)->toContain('idx_department_selfserve_studio_layouts_department_user'); }); + +it('uses mysql-safe identifiers for self-serve studio virtual hardware storage', function (): void { + $bootstrapContent = file_get_contents(app_path('classes/selfserve_schema_bootstrap.php')); + + expect($bootstrapContent)->not->toBeFalse(); + expect($bootstrapContent)->toContain('CREATE TABLE IF NOT EXISTS department_selfserve_studio_virtual_hardware'); + expect($bootstrapContent)->toContain('UNIQUE KEY uniq_selfserve_vhw_department'); + expect($bootstrapContent)->toContain('INDEX idx_selfserve_vhw_dept_updated'); + expect($bootstrapContent)->not->toContain('idx_department_selfserve_studio_virtual_hardware_department_updated'); + + preg_match_all('/\b(?:UNIQUE\s+KEY|INDEX)\s+([a-zA-Z0-9_]+)/', $bootstrapContent, $matches); + foreach ($matches[1] as $identifier) { + expect(strlen($identifier))->toBeLessThanOrEqual(64); + } +}); diff --git a/services/nginx/app/tests/Unit/Selfserve/SelfserveStudioGraphTest.php b/services/nginx/app/tests/Unit/Selfserve/SelfserveStudioGraphTest.php index 72d95333..671103d7 100644 --- a/services/nginx/app/tests/Unit/Selfserve/SelfserveStudioGraphTest.php +++ b/services/nginx/app/tests/Unit/Selfserve/SelfserveStudioGraphTest.php @@ -1,8 +1,10 @@ newInstanceWithoutConstructor(); } +function selfserve_virtual_hardware_without_constructor(): selfserve_virtual_hardware +{ + $reflection = new ReflectionClass(selfserve_virtual_hardware::class); + return $reflection->newInstanceWithoutConstructor(); +} + it('serializes questions, conditions, tasks, scopes, and gateways into one graph', function (): void { $service = selfserve_studio_graph_without_constructor(); @@ -107,6 +115,66 @@ it('serializes questions, conditions, tasks, scopes, and gateways into one graph expect($bindingNode['data']['raw']['services'] ?? [])->toBe(['MACHINE']); }); +it('serializes v2 condition expressions without standalone rule nodes', function (): void { + $service = selfserve_studio_graph_without_constructor(); + + $graph = $service->buildGraphFromConfig([ + 'schema_version' => 2, + 'questions' => [ + ['id' => 1, 'question' => 'Are mirrors folded?', 'condition_id' => null, 'lane' => 7, 'product' => 3, 'department' => 2, 'order_priority' => 1], + ], + 'conditions' => [ + [ + 'id' => 10, + 'name' => 'Ready', + 'lane' => 7, + 'product' => 3, + 'department' => 2, + 'expression' => [ + 'type' => 'group', + 'operator' => 'ALL', + 'children' => [ + ['type' => 'predicate', 'subject_type' => 'question', 'subject_id' => 1, 'operator' => 'IS_TRUE'], + ], + ], + ], + ], + 'rules' => [ + ['id' => 99, 'condition_id' => 10, 'type' => 'IS_TRUE', 'object_type' => 'question', 'object_id' => 1], + ], + 'tasks' => [], + ], [ + 'lookups' => [ + 'labels' => [ + 'questions' => ['1' => 'Are mirrors folded?'], + 'conditions' => ['10' => 'Ready'], + ], + ], + ]); + + $nodeIds = array_column($graph['nodes'], 'id'); + $edgeIds = array_column($graph['edges'], 'id'); + $conditionNode = array_values(array_filter( + $graph['nodes'], + static fn(array $node): bool => ($node['id'] ?? null) === 'condition:10' + ))[0] ?? null; + + expect($nodeIds)->toContain('condition:10'); + expect($nodeIds)->not->toContain('rule:99'); + expect($edgeIds)->toContain('expression:10:question:1:' . substr(md5('0.0'), 0, 8)); + expect($conditionNode['data']['expression_summary'] ?? null)->toContain('Question 1'); +}); + +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()); + + expect($washFlowSource)->toContain('getPublishedV2Config($departmentId)'); + expect($washFlowSource)->toContain("\$configSource = \$publishedConfigPayload === null ? 'legacy' : 'published';"); + expect($studioGraphSource)->toContain('$draftObject->config_json->set($config);'); + expect($studioGraphSource)->toContain('Standalone rule operations are not supported in self-serve rules v2.'); +}); + 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'); @@ -125,6 +193,95 @@ it('derives studio vehicle type lookup rows from selectable wash products', func expect($vehicleTypes[0]['source'])->toBe('products'); }); +it('exposes dynamic images and referenced machine types as studio lookup choices', function (): void { + $service = selfserve_studio_graph_without_constructor(); + $dynamicImages = new ReflectionMethod(selfserve_studio_graph::class, 'dynamicImageRowsFromLanes'); + $machineTypes = new ReflectionMethod(selfserve_studio_graph::class, 'addReferencedMachineTypeRows'); + + $dynamicImageRows = $dynamicImages->invoke($service, [ + ['id' => 7, 'label' => 'Lane 7', 'dynamic_image_id' => 1], + ['id' => 8, 'label' => 'Lane 8', 'dynamic_image_id' => 9], + ]); + $machineTypeRows = $machineTypes->invoke( + $service, + [ + ['id' => 1001, 'name' => 'Portal', 'label' => 'Portal'], + ], + [ + ['id' => 7, 'machine_type_id' => 2002], + ], + [ + 'conditions' => [ + ['id' => 21, 'machine_type_id' => 3003], + ], + 'tasks' => [ + ['id' => 41, 'machine_type_id' => 1001], + ], + ] + ); + + expect(array_column($dynamicImageRows, 'id'))->toBe([1, 9]); + expect($dynamicImageRows[0]['label'])->toBe('Machine 1'); + expect($dynamicImageRows[1]['label'])->toBe('Dynamic image 9'); + expect(array_column($machineTypeRows, 'id'))->toBe([1001, 2002, 3003]); + expect($machineTypeRows[0]['label'])->toBe('Portal'); + expect($machineTypeRows[1]['label'])->toBe('Machine type 2002'); + expect($machineTypeRows[2]['label'])->toBe('Machine type 3003'); +}); + +it('keeps lane management fields on lane scope nodes', function (): void { + $service = selfserve_studio_graph_without_constructor(); + + $graph = $service->buildGraphFromConfig([ + 'questions' => [], + 'conditions' => [], + 'rules' => [], + 'tasks' => [], + ], [ + 'lookups' => [ + 'lanes' => [ + [ + 'id' => 7, + 'department' => 6, + 'name' => 'Lane 7', + 'label' => 'Lane 7', + 'relay_machine_id' => 'M-7', + 'machine_type_id' => 1001, + 'dynamic_image_id' => 1, + ], + ], + 'machine_types' => [['id' => 1001, 'label' => 'Portal']], + 'dynamic_images' => [['id' => 1, 'label' => 'Machine 1']], + 'labels' => [ + 'lanes' => ['7' => 'Lane 7'], + 'machine_types' => ['1001' => 'Portal'], + 'dynamic_images' => ['1' => 'Machine 1'], + ], + ], + ]); + + $laneNode = array_values(array_filter( + $graph['nodes'], + static fn(array $node): bool => ($node['id'] ?? null) === 'lane:7' + ))[0] ?? null; + + expect($laneNode)->not->toBeNull() + ->and($laneNode['data']['raw']['relay_machine_id'])->toBe('M-7') + ->and($laneNode['data']['raw']['machine_type_id'])->toBe(1001) + ->and($laneNode['data']['raw']['dynamic_image_id'])->toBe(1); +}); + +it('routes studio lane graph operations through department_lanes', function (): void { + $source = file_get_contents((new ReflectionClass(selfserve_studio_graph::class))->getFileName()); + + expect($source)->toContain("if (\$entity === 'lane')") + ->and($source)->toContain('private function applyLaneOperation') + ->and($source)->toContain('private function createLane') + ->and($source)->toContain('private function updateLane') + ->and($source)->toContain('INSERT INTO department_lanes') + ->and($source)->toContain("'dynamic_image_id'"); +}); + it('applies saved layout without changing graph semantics', function (): void { $service = selfserve_studio_graph_without_constructor(); @@ -337,3 +494,173 @@ it('resolves simulator gateway service bindings from lane relay slots', function ->and($debug['tasks'][0]['relay_bindings'][0]['service'])->toBe('MACHINE') ->and(array_column($debug['recommendations'], 'title'))->toBe(['Flow is ready']); }); + +it('generates and merges virtual hardware as studio-only relay coverage', function (): void { + $service = selfserve_virtual_hardware_without_constructor(); + $workspace = [ + 'gateways' => [], + 'relays' => [], + 'lanes' => [ + [ + 'id' => 7, + 'name' => 'Lane 7', + 'relay_slots' => [ + ['slot' => 'MACHINE', 'relay_id' => 'M-7', 'coverage' => ['covered' => false, 'status' => 'MISSING']], + ['slot' => 'EXIT', 'relay_id' => 'EXIT-7', 'coverage' => ['covered' => false, 'status' => 'MISSING']], + ], + 'binding_coverage' => ['required' => 2, 'bound' => 0, 'missing' => 2, 'state' => 'MISSING'], + ], + ], + 'issues' => [ + ['severity' => 'danger', 'code' => 'NO_GATEWAY', 'message' => 'No edge gateway has been claimed for this department.'], + ['severity' => 'warning', 'code' => 'LANE_BINDING_GAP', 'message' => 'Lane 7 is missing relay bindings.', 'target_type' => 'lane', 'target_id' => 7], + ], + 'actions' => [], + ]; + + $config = $service->generateFromLanes($workspace); + $merged = $service->mergeWorkspaceWithConfig($workspace, $config); + + expect($config['bindings'])->toHaveCount(2) + ->and($merged['virtual']['has_virtual_hardware'])->toBeTrue() + ->and($merged['gateways'][0]['virtual'])->toBeTrue() + ->and($merged['gateways'][0]['bindings'][0]['relay_id'])->toBe('M-7') + ->and($merged['lanes'][0]['binding_coverage']['state'])->toBe('READY') + ->and($merged['lanes'][0]['relay_slots'][0]['coverage']['virtual'])->toBeTrue() + ->and(array_column($merged['issues'], 'code'))->toContain('VIRTUAL_HARDWARE_ACTIVE') + ->and($service->validationWarnings($merged)[0])->toContain('live relay dispatch still requires a real edge gateway'); +}); + +it('renders virtual gateway nodes and task service edges in the studio graph', function (): void { + $virtual = selfserve_virtual_hardware_without_constructor(); + $graphService = selfserve_studio_graph_without_constructor(); + $workspace = $virtual->mergeWorkspaceWithConfig([ + 'gateways' => [], + 'relays' => [], + 'lanes' => [ + [ + 'id' => 7, + 'name' => 'Lane 7', + 'relay_slots' => [ + ['slot' => 'MACHINE', 'relay_id' => 'M-7', 'coverage' => ['covered' => false, 'status' => 'MISSING']], + ], + 'binding_coverage' => ['required' => 1, 'bound' => 0, 'missing' => 1, 'state' => 'MISSING'], + ], + ], + 'issues' => [], + 'actions' => [], + ], [ + 'schema_version' => 1, + 'enabled' => true, + 'gateways' => [['key' => 'virtual-main', 'label' => 'Virtual Studio Gateway', 'status' => 'VIRTUAL']], + 'relays' => [['relay_id' => 'M-7', 'name' => 'Lane 7 MACHINE']], + 'bindings' => [['gateway_key' => 'virtual-main', 'relay_id' => 'M-7', 'role' => 'MACHINE', 'services' => ['MACHINE'], 'label' => 'Lane 7 MACHINE']], + ]); + + $graph = $graphService->buildGraphFromConfig([ + 'questions' => [], + 'conditions' => [], + 'rules' => [], + 'tasks' => [ + ['id' => 41, 'task' => 'Start machine', 'services' => ['MACHINE'], 'order_priority' => 1], + ], + ], [ + 'lookups' => [ + 'labels' => [ + 'tasks' => ['41' => 'Start machine'], + 'lanes' => ['7' => 'Lane 7'], + ], + ], + 'gateway_workspace' => $workspace, + ]); + + $nodeIds = array_column($graph['nodes'], 'id'); + $edgeIds = array_column($graph['edges'], 'id'); + $gatewayNode = array_values(array_filter($graph['nodes'], static fn(array $node): bool => ($node['id'] ?? '') === 'gateway:virtual-main'))[0] ?? []; + $bindingNode = array_values(array_filter($graph['nodes'], static fn(array $node): bool => ($node['id'] ?? '') === 'binding:virtual-main:M-7:0'))[0] ?? []; + + expect($nodeIds)->toContain('gateway:virtual-main') + ->and($nodeIds)->toContain('binding:virtual-main:M-7:0') + ->and($edgeIds)->toContain('task-service:41:MACHINE:virtual-main:M-7:0') + ->and($gatewayNode['data']['raw']['virtual'])->toBeTrue() + ->and($bindingNode['data']['raw']['virtual'])->toBeTrue(); +}); + +it('adds ordered simulator signal timeline rows for virtual hardware dry runs', function (): void { + $service = selfserve_wash_flow_without_constructor(); + + $debug = $service->buildStudioDebugPayload(6, [ + 'lane' => ['id' => 7, 'name' => 'Lane 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' => [], + 'task_gates' => [ + ['task_id' => 41, 'gate_type' => 'ALWAYS', 'gate_ref_id' => null, 'satisfied' => true], + ], + ], + 'debug_candidates' => [ + 'questions' => [], + 'conditions' => [], + 'rules' => [], + 'tasks' => [ + ['id' => 41, 'task' => 'Start machine', 'gate_type' => 'ALWAYS', 'gate_ref_id' => null, 'services' => ['MACHINE'], 'order_priority' => 1], + ], + ], + ], [ + 'gateway_workspace' => [ + 'gateways' => [ + [ + 'id' => 'virtual-main', + 'label' => 'Virtual Studio Gateway', + 'status' => 'VIRTUAL', + 'virtual' => true, + 'bindings' => [ + ['node_id' => 'binding:virtual-main:M-7:0', 'relay_id' => 'M-7', 'label' => 'Machine', 'role' => 'MACHINE', 'services' => ['MACHINE'], 'virtual' => true], + ['node_id' => 'binding:virtual-main:CLEAN-7:1', 'relay_id' => 'CLEAN-7', 'label' => 'Cleaner', 'role' => 'CLEANER', 'services' => ['CLEANER'], 'virtual' => true], + ['node_id' => 'binding:virtual-main:EXIT-7:2', 'relay_id' => 'EXIT-7', 'label' => 'Exit', 'role' => 'EXIT', 'services' => ['EXIT'], 'virtual' => true], + ], + ], + ], + 'lanes' => [ + [ + 'id' => 7, + 'relay_slots' => [ + ['slot' => 'MACHINE', 'relay_id' => 'M-7'], + ['slot' => 'CLEANER', 'relay_id' => 'CLEAN-7'], + ['slot' => 'EXIT', 'relay_id' => 'EXIT-7'], + ], + ], + ], + 'virtual' => ['has_virtual_hardware' => true], + ], + 'lookups' => ['labels' => ['lanes' => ['7' => 'Lane 7'], 'tasks' => ['41' => 'Start machine']]], + ]); + + expect(array_column($debug['signal_timeline'], 'sequence'))->toBe([1, 2, 3, 4, 5, 6, 7, 8]) + ->and(array_column($debug['signal_timeline'], 'relay_role'))->toBe(['SESSION', 'MACHINE', 'MACHINE', 'CLEANER', 'EXIT', 'CLEANER', 'MACHINE', 'SESSION']) + ->and($debug['signal_timeline'][1]['predicted_status'])->toBe('virtual_only') + ->and($debug['signal_timeline'][2]['signal_type'])->toBe('shelly_event') + ->and($debug['signal_timeline'][2]['runtime_stage'])->toBe('machine_start_signal') + ->and($debug['signal_timeline'][2]['transport'])->toBe('shelly_webhook_or_edge_gateway_event') + ->and($debug['signal_timeline'][2]['payload'])->toMatchArray(['event' => 'input.toggle_on', 'bill_machine_wash' => true]) + ->and($debug['signal_timeline'][4]['payload'])->toMatchArray(['id' => 'EXIT-7', 'toggle_after' => 1]) + ->and($debug['hardware']['signal_timeline'][6]['payload'])->toMatchArray(['id' => 'M-7', 'on' => false]); +}); diff --git a/services/nginx/app/tests/dynamicimages/DepartmentLanesImageTest.php b/services/nginx/app/tests/dynamicimages/DepartmentLanesImageTest.php index d8a97d93..23dd3478 100644 --- a/services/nginx/app/tests/dynamicimages/DepartmentLanesImageTest.php +++ b/services/nginx/app/tests/dynamicimages/DepartmentLanesImageTest.php @@ -30,13 +30,13 @@ namespace { // 1) Buttons normalization examples try { - $arr = department_selfserve_tasks_o::normalizeButtonsInput('0, 2,3 , 5'); - if ($arr === [0,2,3,5]) { ok('CSV buttons normalization works'); } else { fail('CSV normalization mismatch: '.json_encode($arr)); } + $arr = department_selfserve_tasks_o::normalizeButtonsInput('reset, 0, 2,3 , 5, start'); + if ($arr === ['reset',0,2,3,5,'start']) { ok('CSV mapped buttons normalization works'); } else { fail('CSV normalization mismatch: '.json_encode($arr)); } } catch (\Exception $e) { fail('CSV normalization threw: '.$e->getMessage()); } try { - $arr = department_selfserve_tasks_o::normalizeButtonsInput('[1, 1, "2", 4]'); - if ($arr === [1,2,4]) { ok('JSON buttons normalization with de-dup works'); } else { fail('JSON normalization mismatch: '.json_encode($arr)); } + $arr = department_selfserve_tasks_o::normalizeButtonsInput('[1, 1, "2", 4, "START"]'); + if ($arr === [1,2,4,'start']) { ok('JSON buttons normalization with de-dup works'); } else { fail('JSON normalization mismatch: '.json_encode($arr)); } } catch (\Exception $e) { fail('JSON normalization threw: '.$e->getMessage()); } // 2) Vehicle type normalization examples @@ -47,7 +47,7 @@ namespace { try { $img = new machine_1(); // Set some sample parameters (not used until setup(), which we skip to avoid Imagick requirement) - $img->highlighted_buttons = [0,2,5]; + $img->highlighted_buttons = ['reset',0,2,5,'start']; $img->current_step = 1; $dataUri = $img->exportAsBase64(); if (is_string($dataUri) && str_starts_with($dataUri, 'data:image/')) { diff --git a/services/nginx/app/tests/selfserve/ButtonsNormalizationTest.php b/services/nginx/app/tests/selfserve/ButtonsNormalizationTest.php index 158c7fc3..0dbbb7c2 100644 --- a/services/nginx/app/tests/selfserve/ButtonsNormalizationTest.php +++ b/services/nginx/app/tests/selfserve/ButtonsNormalizationTest.php @@ -53,11 +53,11 @@ namespace { fail('JSON input threw unexpectedly: ' . $e->getMessage()); } - // 3) CSV string input + // 3) CSV string input with mapped reset/start buttons try { - $result = department_selfserve_tasks_o::normalizeButtonsInput('6, 7 ,8'); - if ($result === [6,7,8]) { - ok('CSV input normalized correctly'); + $result = department_selfserve_tasks_o::normalizeButtonsInput('reset, 6, 7 ,8, start'); + if ($result === ['reset',6,7,8,'start']) { + ok('CSV input normalized mapped buttons correctly'); } else { fail('CSV input normalization mismatch: ' . json_encode($result)); } @@ -65,7 +65,19 @@ namespace { fail('CSV input threw unexpectedly: ' . $e->getMessage()); } - // 4) Invalid input should throw + // 4) JSON string input preserves reset/start and removes duplicates + try { + $result = department_selfserve_tasks_o::normalizeButtonsInput('["RESET", 1, "start", "reset"]'); + if ($result === ['reset',1,'start']) { + ok('JSON mapped buttons normalized with duplicates removed'); + } else { + fail('JSON mapped button normalization mismatch: ' . json_encode($result)); + } + } catch (\Exception $e) { + fail('JSON mapped button input threw unexpectedly: ' . $e->getMessage()); + } + + // 5) Invalid input should throw $thrown = false; try { department_selfserve_tasks_o::normalizeButtonsInput('["a", 2]');