> */ private array $columnCache = []; public function __construct() { selfserve_schema_bootstrap::ensureTables(); } /** * @param array $permissions * @return array */ public function buildGraph(int $departmentId, ?int $userId = null, array $permissions = []): array { $versioning = new selfserve_config_versioning(); $draft = $versioning->ensureDraftFromLegacy($departmentId, $userId, false); $config = is_array($draft['config'] ?? null) ? (array)$draft['config'] : $versioning->snapshotLegacyConfig($departmentId); $gatewayWorkspace = ($permissions['modules_shelly_config'] ?? false) ? $this->buildGatewayWorkspace($departmentId) : [ 'gateways' => [], 'relays' => [], 'lanes' => [], 'issues' => [], 'actions' => [], 'restricted' => true, ]; $lookups = $this->buildLookups($departmentId, $config, $gatewayWorkspace); $layout = $this->loadLayout($departmentId, $userId); $configWithAttachments = $this->withTaskAttachments($config); $graph = $this->buildGraphFromConfig($configWithAttachments, [ 'department_id' => $departmentId, 'lookups' => $lookups, 'gateway_workspace' => $gatewayWorkspace, ], $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 [ 'nodes' => $graph['nodes'], 'edges' => $graph['edges'], 'lookups' => $lookups, 'validation' => $validation, 'layout' => $layout, 'versions' => $versioning->listVersions($departmentId), 'active_config' => $versioning->getPublishedConfig($departmentId), 'draft' => [ 'id' => $draft['id'] ?? null, 'status' => $draft['status'] ?? selfserve_config_versioning::STATUS_DRAFT, 'version_number' => $draft['version_number'] ?? null, 'created_at' => $draft['created_at'] ?? null, 'updated_at' => $draft['updated_at'] ?? null, ], 'simulator_defaults' => $this->buildSimulatorDefaults($departmentId, $lookups, $gatewayWorkspace), 'gateway_workspace' => $gatewayWorkspace, 'permissions' => $permissions, 'meta' => [ 'department_id' => $departmentId, 'layout_affects_runtime' => false, 'generated_at' => date('c'), 'path_editor' => is_array($config['v2_meta']['path_editor'] ?? null) ? (array)$config['v2_meta']['path_editor'] : ['paths' => []], ], ]; } /** * Pure graph builder used by unit tests and the API serializer. * * @param array $config * @param array $context * @param array $layout * @return array{nodes:array>,edges:array>} */ public function buildGraphFromConfig(array $config, array $context = [], array $layout = []): array { $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 = []; $nodes[] = $this->node('checkpoint:start', 'input', 'Runtime start', 'runtime_checkpoint', [ 'stage' => 'start', 'subtitle' => 'Vehicle scanned', ], 0, 0); $nodes[] = $this->node('checkpoint:eligible', 'default', 'Eligibility resolved', 'runtime_checkpoint', [ 'stage' => 'eligible', 'subtitle' => 'Questions, rules, and gates evaluated', ], 320, 0); $nodes[] = $this->node('checkpoint:finish', 'output', 'Wash complete', 'runtime_checkpoint', [ 'stage' => 'finish', 'subtitle' => 'Session closed', ], 640, 0); $edges[] = $this->edge('runtime:start-eligible', 'checkpoint:start', 'checkpoint:eligible', 'runtime', 'runtime'); $edges[] = $this->edge('runtime:eligible-finish', 'checkpoint:eligible', 'checkpoint:finish', 'runtime', 'runtime'); foreach ($this->lookupRows($lookups, 'lanes') as $index => $lane) { $id = 'lane:' . (int)($lane['id'] ?? 0); $nodes[] = $this->node($id, 'default', (string)($lane['label'] ?? ('Lane ' . ($lane['id'] ?? ''))), 'lane', [ 'object_id' => (int)($lane['id'] ?? 0), 'raw' => $lane, 'subtitle' => 'Lane scope', ], 0, 180 + ($index * 120)); } foreach ($this->lookupRows($lookups, 'machine_types') as $index => $machineType) { $id = 'machine_type:' . (int)($machineType['id'] ?? 0); $nodes[] = $this->node($id, 'default', (string)($machineType['label'] ?? ('Machine type ' . ($machineType['id'] ?? ''))), 'machine_type', [ 'object_id' => (int)($machineType['id'] ?? 0), 'raw' => $machineType, 'subtitle' => 'Reusable machine setup', ], 0, 560 + ($index * 120)); } foreach ($this->lookupRows($lookups, 'vehicle_types') as $index => $vehicleType) { $id = 'vehicle_type:' . (int)($vehicleType['id'] ?? 0); $nodes[] = $this->node($id, 'default', (string)($vehicleType['label'] ?? ('Vehicle type ' . ($vehicleType['id'] ?? ''))), 'vehicle_type', [ 'object_id' => (int)($vehicleType['id'] ?? 0), 'raw' => $vehicleType, 'subtitle' => 'Vehicle scope', ], 0, 880 + ($index * 120)); } foreach ($this->sortedRows((array)($config['conditions'] ?? []), ['name', 'id']) as $index => $condition) { $id = (int)($condition['id'] ?? 0); $nodes[] = $this->node('condition:' . $id, 'default', $this->entityLabel('condition', $id, $condition, $lookups), 'condition', [ '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)); $parentId = $this->nullableInt($condition['condition_id'] ?? null); 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); } foreach ($this->sortedRows((array)($config['questions'] ?? []), ['order_priority', 'id']) as $index => $question) { $id = (int)($question['id'] ?? 0); $nodes[] = $this->node('question:' . $id, 'default', $this->entityLabel('question', $id, $question, $lookups), 'question', [ 'object_id' => $id, 'raw' => $question, 'scope' => $this->scopeForRow($question, $lookups), 'subtitle' => $this->scopeLabel($question, $lookups), ], 720, 160 + ($index * 130)); $conditionId = $this->nullableInt($question['condition_id'] ?? null); if ($conditionId !== null) { $edges[] = $this->edge('question-gate:' . $conditionId . ':' . $id, 'condition:' . $conditionId, 'question:' . $id, 'visibility_gate', 'show if'); } $this->appendScopeEdges($edges, 'question:' . $id, $question); } 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')); } } } $tasksByScope = []; $taskRows = $this->sortedRows((array)($config['tasks'] ?? []), ['order_priority', 'id']); foreach ($taskRows as $index => $task) { $id = (int)($task['id'] ?? 0); $nodes[] = $this->node('task:' . $id, 'default', $this->entityLabel('task', $id, $task, $lookups), 'task', [ 'object_id' => $id, 'raw' => $this->normalizeTaskPayload($task), 'scope' => $this->scopeForRow($task, $lookups), 'subtitle' => $this->scopeLabel($task, $lookups), ], 1080, 160 + ($index * 130)); $gateType = strtoupper((string)($task['gate_type'] ?? selfserve_task_gate_type::ALWAYS->value)); $gateRefId = $this->nullableInt($task['gate_ref_id'] ?? null); if ($gateType === selfserve_task_gate_type::CONDITION->value && $gateRefId !== null) { $edges[] = $this->edge('task-gate:condition:' . $gateRefId . ':' . $id, 'condition:' . $gateRefId, 'task:' . $id, 'task_gate', 'unlocks'); } elseif ($gateType === selfserve_task_gate_type::QUESTION->value && $gateRefId !== null) { $edges[] = $this->edge('task-gate:question:' . $gateRefId . ':' . $id, 'question:' . $gateRefId, 'task:' . $id, 'task_gate', 'unlocks'); } $scopeKey = implode(':', [ (int)($task['department'] ?? 0), (int)($task['lane'] ?? 0), (int)($task['product'] ?? 0), (int)($task['machine_type_id'] ?? 0), ]); $tasksByScope[$scopeKey][] = $task; $this->appendScopeEdges($edges, 'task:' . $id, $task); } foreach ($tasksByScope as $tasks) { $orderedTasks = $this->sortedRows($tasks, ['order_priority', 'id']); for ($i = 1; $i < count($orderedTasks); $i++) { $sourceId = (int)($orderedTasks[$i - 1]['id'] ?? 0); $targetId = (int)($orderedTasks[$i]['id'] ?? 0); if ($sourceId > 0 && $targetId > 0) { $edges[] = $this->edge('task-order:' . $sourceId . ':' . $targetId, 'task:' . $sourceId, 'task:' . $targetId, 'task_order', 'then'); } } } $actionsByEvent = []; $actionRows = $this->sortedRows((array)($config['actions'] ?? []), ['event', 'order_priority', 'id']); foreach ($actionRows as $index => $action) { $normalizedAction = selfserve_studio_actions::normalize($action); $id = (int)($normalizedAction['id'] ?? 0); if ($id <= 0) { continue; } $actionsByEvent[$normalizedAction['event']][] = $normalizedAction; $nodes[] = $this->node('action:' . $id, 'default', $this->entityLabel('action', $id, $normalizedAction, $lookups), 'action', [ 'object_id' => $id, 'raw' => $normalizedAction, 'scope' => $this->scopeForRow($normalizedAction, $lookups), 'subtitle' => $this->actionSubtitle($normalizedAction, $lookups), 'action_label' => selfserve_studio_actions::operationLabel((string)$normalizedAction['operation'], $normalizedAction['relay_state']), 'event_label' => selfserve_studio_actions::eventLabel((string)$normalizedAction['event']), 'relay_role' => selfserve_studio_actions::relayRoleForOperation((string)$normalizedAction['operation']), ], 1360, 160 + ($index * 130)); $eventSource = match ((string)$normalizedAction['event']) { selfserve_studio_actions::EVENT_WASH_START_COMMAND => 'checkpoint:start', selfserve_studio_actions::EVENT_WASH_STOP_COMMAND => 'checkpoint:finish', selfserve_studio_actions::EVENT_MACHINE_START_TRIGGERED => 'checkpoint:eligible', default => 'checkpoint:start', }; $edges[] = $this->edge('action-event:' . $normalizedAction['event'] . ':' . $id, $eventSource, 'action:' . $id, 'action_event', $normalizedAction['wash_mode']); $conditionId = $this->nullableInt($normalizedAction['condition_id'] ?? null); if ($conditionId !== null) { $edges[] = $this->edge('action-gate:' . $conditionId . ':' . $id, 'condition:' . $conditionId, 'action:' . $id, 'action_gate', 'allows'); } $this->appendScopeEdges($edges, 'action:' . $id, $normalizedAction); } foreach ($actionsByEvent as $event => $actions) { $orderedActions = $this->sortedRows($actions, ['order_priority', 'id']); for ($i = 1; $i < count($orderedActions); $i++) { $sourceId = (int)($orderedActions[$i - 1]['id'] ?? 0); $targetId = (int)($orderedActions[$i]['id'] ?? 0); if ($sourceId > 0 && $targetId > 0) { $edges[] = $this->edge('action-order:' . $event . ':' . $sourceId . ':' . $targetId, 'action:' . $sourceId, 'action:' . $targetId, 'action_order', 'then'); } } } $this->appendGatewayNodesAndEdges($nodes, $edges, $gatewayWorkspace); $this->appendTaskServiceEdges($edges, $taskRows, $gatewayWorkspace); $this->appendActionRelayEdges($edges, $actionRows, $gatewayWorkspace); return [ 'nodes' => $this->applyLayoutToNodes($nodes, $layout), 'edges' => array_values($edges), ]; } /** * @param array $payload * @param array $permissions * @return array */ public function applyGraphSave(int $departmentId, array $payload, ?int $userId = null, array $permissions = []): array { $operations = isset($payload['operations']) && is_array($payload['operations']) ? (array)$payload['operations'] : []; $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, $permissions); } } $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, $permissions); } } } if (isset($payload['layout']) && is_array($payload['layout'])) { $this->saveLayout($departmentId, $userId, (array)$payload['layout']); } elseif (isset($payload['nodes']) && is_array($payload['nodes'])) { $this->saveLayout($departmentId, $userId, [ 'nodes' => $this->extractNodePositions((array)$payload['nodes']), 'viewport' => is_array($payload['viewport'] ?? null) ? (array)$payload['viewport'] : [], ]); } if (!$versioning->isV2Config($config)) { $versioning->syncDraftFromLegacyForDepartment($departmentId); } return $this->buildGraph($departmentId, $userId, $permissions); } /** * @param array $layout * @return array */ public function saveLayout(int $departmentId, ?int $userId, array $layout): array { $normalized = [ 'nodes' => $this->extractNodePositions((array)($layout['nodes'] ?? [])), 'viewport' => is_array($layout['viewport'] ?? null) ? (array)$layout['viewport'] : [], 'saved_at' => date('c'), 'runtime_affecting' => false, ]; $layoutJson = json_encode($normalized, JSON_UNESCAPED_UNICODE); if ($layoutJson === false) { throw new \RuntimeException('Failed to encode studio layout JSON: ' . json_last_error_msg()); } $pdo = db::getPDO(); $statement = $pdo->prepare( "SELECT id FROM department_selfserve_studio_layouts WHERE department_id = :department_id AND " . ($userId === null ? "user_id IS NULL" : "user_id = :user_id") . " AND deleted_at IS NULL ORDER BY id DESC LIMIT 1" ); $params = [':department_id' => $departmentId]; if ($userId !== null) { $params[':user_id'] = $userId; } $statement->execute($params); $row = $statement->fetch(\PDO::FETCH_ASSOC); if (is_array($row) && (int)($row['id'] ?? 0) > 0) { $update = $pdo->prepare( "UPDATE department_selfserve_studio_layouts SET layout_json = :layout_json, updated_at = NOW() WHERE id = :id" ); $update->execute([ ':layout_json' => $layoutJson, ':id' => (int)$row['id'], ]); } else { $insert = $pdo->prepare( "INSERT INTO department_selfserve_studio_layouts (department_id, user_id, layout_json) VALUES (:department_id, :user_id, :layout_json)" ); $insert->execute([ ':department_id' => $departmentId, ':user_id' => $userId, ':layout_json' => $layoutJson, ]); } return $normalized; } /** * @return array */ public function loadLayout(int $departmentId, ?int $userId): array { $pdo = db::getPDO(); $statement = $pdo->prepare( "SELECT layout_json FROM department_selfserve_studio_layouts WHERE department_id = :department_id AND deleted_at IS NULL AND (user_id = :user_id_filter OR user_id IS NULL) ORDER BY CASE WHEN user_id = :user_id_sort THEN 0 ELSE 1 END, updated_at DESC, id DESC LIMIT 1" ); $statement->execute([ ':department_id' => $departmentId, ':user_id_filter' => $userId, ':user_id_sort' => $userId, ]); $row = $statement->fetch(\PDO::FETCH_ASSOC); if (!is_array($row)) { return [ 'nodes' => [], 'viewport' => [], 'runtime_affecting' => false, ]; } $layout = json_decode((string)($row['layout_json'] ?? '{}'), true); if (!is_array($layout)) { $layout = []; } $layout['runtime_affecting'] = false; return $layout; } /** * @param array $payload * @return array */ public function validatePayload(int $departmentId, array $payload = []): array { $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; } /** * @param array $payload * @param array $permissions * @return array */ public function simulateGraph(int $departmentId, array $payload, ?int $userId = null, array $permissions = []): array { $laneId = (int)($payload['lane_id'] ?? 0); if ($laneId <= 0) { throw new \RuntimeException('lane_id is required for studio simulation.'); } $configSource = strtolower(trim((string)($payload['config_source'] ?? 'draft'))); if (!in_array($configSource, ['draft', 'published'], true)) { $configSource = 'draft'; } $versioning = new selfserve_config_versioning(); if ($configSource === 'published') { $version = $versioning->getPublishedV2Config($departmentId); $config = is_array($version['config'] ?? null) ? (array)$version['config'] : null; $versionId = isset($version['version_id']) ? (int)$version['version_id'] : null; } else { $version = $versioning->ensureDraftFromLegacy($departmentId, $userId, false); $config = is_array($version['config'] ?? null) ? (array)$version['config'] : $versioning->snapshotLegacyConfig($departmentId); $versionId = isset($version['id']) ? (int)$version['id'] : null; } $includeHardware = filter_var($payload['include_hardware'] ?? true, FILTER_VALIDATE_BOOLEAN, FILTER_NULL_ON_FAILURE); $includeHardware = $includeHardware !== false; $hardwareMode = strtolower(trim((string)($payload['hardware_mode'] ?? ''))); if ($hardwareMode === '') { $hardwareMode = $includeHardware ? 'studio' : 'none'; } if (!in_array($hardwareMode, ['studio', 'real', 'none'], true)) { $hardwareMode = 'studio'; } if ($hardwareMode === 'none') { $includeHardware = false; } if (!$includeHardware || !($permissions['modules_shelly_config'] ?? false)) { $gatewayWorkspace = [ 'gateways' => [], 'relays' => [], 'lanes' => [], 'issues' => [], 'actions' => [], 'restricted' => !$includeHardware ? false : true, 'virtual' => [ 'enabled' => $hardwareMode === 'studio', 'has_virtual_hardware' => false, 'gateway_count' => 0, 'binding_count' => 0, ], ]; } else { $gatewayWorkspace = $this->buildGatewayWorkspace($departmentId, $hardwareMode !== 'real'); } $graphConfig = is_array($config) ? $config : $versioning->snapshotLegacyConfig($departmentId); $lookups = $this->buildLookups($departmentId, $graphConfig, $gatewayWorkspace); $graph = $this->buildGraphFromConfig($graphConfig, [ 'department_id' => $departmentId, 'lookups' => $lookups, 'gateway_workspace' => $gatewayWorkspace, ]); return (new selfserve_wash_flow())->previewStudioSimulation( $departmentId, $laneId, (string)($payload['reg'] ?? ''), array_key_exists('customer_number', $payload) ? $this->nullableInt($payload['customer_number']) : null, array_key_exists('vehicle_type_id', $payload) ? $this->nullableInt($payload['vehicle_type_id']) : null, [ 'mode' => 'full_dry_run', 'config_source' => $configSource, 'config_payload' => $config, '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, ], ); } /** * @param array $payload * @param array $permissions * @return array */ public function projectPathOutcomes( int $departmentId, array $payload, ?int $userId = null, array $permissions = [], ?callable $progressCallback = null ): array { $configSource = strtolower(trim((string)($payload['config_source'] ?? 'draft'))); if (!in_array($configSource, ['draft', 'published'], true)) { $configSource = 'draft'; } $versioning = new selfserve_config_versioning(); if ($configSource === 'published') { $version = $versioning->getPublishedV2Config($departmentId); $config = is_array($version['config'] ?? null) ? (array)$version['config'] : null; $versionId = isset($version['version_id']) ? (int)$version['version_id'] : null; } else { $version = $versioning->ensureDraftFromLegacy($departmentId, $userId, false); $config = is_array($version['config'] ?? null) ? (array)$version['config'] : $versioning->snapshotLegacyConfig($departmentId); $versionId = isset($version['id']) ? (int)$version['id'] : null; } $includeHardware = filter_var($payload['include_hardware'] ?? true, FILTER_VALIDATE_BOOLEAN, FILTER_NULL_ON_FAILURE); $includeHardware = $includeHardware !== false; $hardwareMode = strtolower(trim((string)($payload['hardware_mode'] ?? ''))); if ($hardwareMode === '') { $hardwareMode = $includeHardware ? 'studio' : 'none'; } if (!in_array($hardwareMode, ['studio', 'real', 'none'], true)) { $hardwareMode = 'studio'; } if ($hardwareMode === 'none') { $includeHardware = false; } if (!$includeHardware || !($permissions['modules_shelly_config'] ?? false)) { $gatewayWorkspace = [ 'gateways' => [], 'relays' => [], 'lanes' => [], 'issues' => [], 'actions' => [], 'restricted' => !$includeHardware ? false : true, 'virtual' => [ 'enabled' => $hardwareMode === 'studio', 'has_virtual_hardware' => false, 'gateway_count' => 0, 'binding_count' => 0, ], ]; } else { $gatewayWorkspace = $this->buildGatewayWorkspace($departmentId, $hardwareMode !== 'real'); } $graphConfig = is_array($config) ? $config : $versioning->snapshotLegacyConfig($departmentId); $lookups = $this->buildLookups($departmentId, $graphConfig, $gatewayWorkspace); $graph = $this->buildGraphFromConfig($graphConfig, [ 'department_id' => $departmentId, 'lookups' => $lookups, 'gateway_workspace' => $gatewayWorkspace, ]); $defaults = $this->buildSimulatorDefaults($departmentId, $lookups, $gatewayWorkspace); $laneId = $this->nullableInt($payload['lane_id'] ?? null) ?? $this->nullableInt($defaults['lane_id'] ?? null); if ($laneId === null) { throw new \RuntimeException('No lane is available for path outcome projection.'); } $vehicleTypeId = $this->nullableInt($payload['vehicle_type_id'] ?? null); $vehicleTypeIds = []; if ($vehicleTypeId !== null) { $vehicleTypeIds[] = $vehicleTypeId; } else { foreach ($this->lookupRows($lookups, 'vehicle_types') as $row) { $id = $this->nullableInt($row['id'] ?? null); if ($id !== null) { $vehicleTypeIds[$id] = $id; } } $vehicleTypeIds = array_values($vehicleTypeIds); } if ($vehicleTypeIds === []) { $vehicleTypeIds[] = null; } $maxStates = $this->pathLimit( $payload['max_states'] ?? null, self::DEFAULT_PATH_MAX_STATES, self::MAX_PATH_MAX_STATES ); $reg = trim((string)($payload['reg'] ?? $defaults['reg'] ?? 'TEST123')); if ($reg === '') { $reg = 'TEST123'; } $customerNumber = array_key_exists('customer_number', $payload) ? $this->nullableInt($payload['customer_number']) : $this->nullableInt($defaults['customer_number'] ?? null); $flow = new selfserve_wash_flow(); $outcomes = []; $warnings = []; $truncated = false; $stateCount = 0; $terminalPathCount = 0; $questionIds = []; $pathSampleLimit = $this->pathLimit( $payload['path_sample_limit'] ?? null, self::DEFAULT_PATH_SAMPLE_LIMIT, self::MAX_PATH_SAMPLE_LIMIT ); $paths = []; $scenarioCount = max(1, count($vehicleTypeIds)); $confirmationRows = $this->loadPathConfirmationRows($departmentId, $versionId, $laneId, $vehicleTypeId, $configSource); foreach ($vehicleTypeIds as $scenarioIndex => $scenarioVehicleTypeId) { $remainingStates = $maxStates - $stateCount; if ($remainingStates <= 0) { $truncated = true; break; } $scenarioScope = [ 'department_id' => $departmentId, 'department' => $this->labelFor('departments', $departmentId, $lookups), 'lane_id' => $laneId, 'lane' => $this->labelFor('lanes', $laneId, $lookups), 'vehicle_type_id' => $scenarioVehicleTypeId, 'vehicle_type' => $scenarioVehicleTypeId === null ? 'Auto' : $this->labelFor('vehicle_types', $scenarioVehicleTypeId, $lookups), 'registration' => $reg, 'customer_number' => $customerNumber, 'config_source' => $configSource, 'config_version_id' => $versionId, 'hardware_mode' => $hardwareMode, ]; $simulate = function (array $answerOverrides) use ( $flow, $departmentId, $laneId, $reg, $customerNumber, $scenarioVehicleTypeId, $configSource, $graphConfig, $versionId, $includeHardware, $hardwareMode, $lookups, $gatewayWorkspace, $graph ): array { return $flow->previewStudioSimulation( $departmentId, $laneId, $reg, $customerNumber, $scenarioVehicleTypeId, [ 'mode' => 'full_dry_run', 'config_source' => $configSource, 'config_payload' => $graphConfig, 'config_version_id' => $versionId, 'answer_overrides' => $answerOverrides, 'include_hardware' => $includeHardware, 'hardware_mode' => $hardwareMode, 'lookups' => $lookups, 'gateway_workspace' => $gatewayWorkspace, 'graph' => $graph, ], ); }; $projectionOptions = [ 'scope' => $scenarioScope, 'max_states' => $remainingStates, 'path_sample_limit' => max(0, $pathSampleLimit - count($paths)), 'progress_callback' => function (array $projection) use ( $progressCallback, &$outcomes, &$paths, &$stateCount, &$terminalPathCount, &$questionIds, $maxStates, $pathSampleLimit, $scenarioIndex, $scenarioCount, $departmentId, $lookups, $laneId, $vehicleTypeId, $reg, $customerNumber, $configSource, $versionId, $hardwareMode ): void { if ($progressCallback === null) { return; } $partialOutcomes = array_merge($outcomes, array_values((array)($projection['outcomes'] ?? []))); $partialPaths = array_merge($paths, array_values((array)($projection['paths'] ?? []))); if (count($partialPaths) > $pathSampleLimit) { $partialPaths = array_slice($partialPaths, 0, $pathSampleLimit); } $partialQuestionIds = $questionIds; foreach ((array)($projection['summary']['question_ids'] ?? []) as $questionId) { $partialQuestionIds[(int)$questionId] = true; } $projectionProgress = is_array($projection['progress'] ?? null) ? (array)$projection['progress'] : []; $scenarioPercent = (float)($projectionProgress['percent'] ?? 0); $overallPercent = min(99.0, (($scenarioIndex + ($scenarioPercent / 100)) / $scenarioCount) * 100); $partialPayload = $this->pathOutcomesPayload( [ 'department_id' => $departmentId, 'department' => $this->labelFor('departments', $departmentId, $lookups), 'lane_id' => $laneId, 'lane' => $this->labelFor('lanes', $laneId, $lookups), 'vehicle_type_id' => $vehicleTypeId, 'vehicle_type' => $vehicleTypeId === null ? 'All current vehicle types' : $this->labelFor('vehicle_types', $vehicleTypeId, $lookups), 'vehicle_type_count' => $scenarioCount, 'registration' => $reg, 'customer_number' => $customerNumber, 'config_source' => $configSource, 'config_version_id' => $versionId, 'hardware_mode' => $hardwareMode, 'max_states' => $maxStates, ], $partialOutcomes, $partialPaths, [], false, $maxStates, $stateCount + (int)($projection['summary']['state_count'] ?? 0), $terminalPathCount + (int)($projection['summary']['terminal_path_count'] ?? 0), $partialQuestionIds, [ 'complete' => false, 'percent' => (int)floor($overallPercent), 'state_count' => $stateCount + (int)($projection['summary']['state_count'] ?? 0), 'pending_state_count' => (int)($projectionProgress['pending_state_count'] ?? 0), 'terminal_path_count' => $terminalPathCount + (int)($projection['summary']['terminal_path_count'] ?? 0), 'scenario_index' => $scenarioIndex + 1, 'scenario_count' => $scenarioCount, ], $confirmationRows ); $progressCallback($partialPayload); }, 'confirmation_rows' => $confirmationRows, ]; $projection = $this->projectPathOutcomesFromSimulator($simulate, $projectionOptions); foreach ((array)($projection['outcomes'] ?? []) as $outcome) { if (is_array($outcome)) { $outcomes[] = $outcome; } } foreach ((array)($projection['paths'] ?? []) as $path) { if (!is_array($path)) { continue; } if (count($paths) < $pathSampleLimit) { $paths[] = $path; } } foreach ((array)($projection['warnings'] ?? []) as $warning) { $warnings[] = (string)$warning; } $truncated = $truncated || (bool)($projection['truncated'] ?? false); $stateCount += (int)($projection['summary']['state_count'] ?? 0); $terminalPathCount += (int)($projection['summary']['terminal_path_count'] ?? 0); foreach ((array)($projection['summary']['question_ids'] ?? []) as $questionId) { $questionIds[(int)$questionId] = true; } } if ($truncated) { $warnings[] = 'Path projection was truncated at ' . $maxStates . ' explored state(s). Narrow the lane or vehicle type filters to inspect more paths.'; } return $this->pathOutcomesPayload( [ 'department_id' => $departmentId, 'department' => $this->labelFor('departments', $departmentId, $lookups), 'lane_id' => $laneId, 'lane' => $this->labelFor('lanes', $laneId, $lookups), 'vehicle_type_id' => $vehicleTypeId, 'vehicle_type' => $vehicleTypeId === null ? 'All current vehicle types' : $this->labelFor('vehicle_types', $vehicleTypeId, $lookups), 'vehicle_type_count' => count($vehicleTypeIds), 'registration' => $reg, 'customer_number' => $customerNumber, 'config_source' => $configSource, 'config_version_id' => $versionId, 'hardware_mode' => $hardwareMode, 'max_states' => $maxStates, ], $outcomes, $paths, $warnings, $truncated, $maxStates, $stateCount, $terminalPathCount, $questionIds, [ 'complete' => true, 'percent' => 100, 'state_count' => $stateCount, 'pending_state_count' => 0, 'terminal_path_count' => $terminalPathCount, 'scenario_index' => $scenarioCount, 'scenario_count' => $scenarioCount, ], $confirmationRows ); } /** * @param callable(array):array $simulate * @param array $options * @return array */ public function projectPathOutcomesFromSimulator(callable $simulate, array $options = []): array { $maxStates = $this->pathLimit( $options['max_states'] ?? null, self::DEFAULT_PATH_MAX_STATES, self::MAX_PATH_MAX_STATES ); $sampleLimit = max(1, min(10, (int)($options['sample_limit'] ?? 5))); $pathSampleLimit = $this->pathLimit( $options['path_sample_limit'] ?? null, self::DEFAULT_PATH_SAMPLE_LIMIT, self::MAX_PATH_SAMPLE_LIMIT, 0 ); $progressCallback = is_callable($options['progress_callback'] ?? null) ? $options['progress_callback'] : null; $progressIntervalStates = max(1, (int)($options['progress_interval_states'] ?? 128)); $scope = is_array($options['scope'] ?? null) ? (array)$options['scope'] : []; $confirmationRows = is_array($options['confirmation_rows'] ?? null) ? (array)$options['confirmation_rows'] : []; $stack = [[ 'answers' => [], 'chain' => [], ]]; $seenStates = []; $groups = []; $paths = []; $stateCount = 0; $terminalPathCount = 0; $questionIds = []; $truncated = false; while ($stack !== []) { if ($stateCount >= $maxStates) { $truncated = true; break; } $state = array_pop($stack); $answers = is_array($state['answers'] ?? null) ? (array)$state['answers'] : []; ksort($answers, SORT_NUMERIC); $stateKey = $this->stableJson($answers); if (isset($seenStates[$stateKey])) { continue; } $seenStates[$stateKey] = true; $stateCount++; $simulation = $simulate($this->pathAnswerOverrides($answers)); $nextQuestion = $this->nextPathQuestion($simulation, $answers); if ($nextQuestion !== null) { $questionId = (int)($nextQuestion['id'] ?? 0); if ($questionId > 0) { $questionIds[$questionId] = true; foreach ([false, true] as $answerValue) { $nextAnswers = $answers; $nextAnswers[$questionId] = $answerValue; ksort($nextAnswers, SORT_NUMERIC); $nextChain = is_array($state['chain'] ?? null) ? array_values((array)$state['chain']) : []; $nextChain[] = [ 'question_id' => $questionId, 'question' => (string)($nextQuestion['label'] ?? $nextQuestion['question'] ?? ('Question ' . $questionId)), 'node_id' => (string)($nextQuestion['node_id'] ?? ('question:' . $questionId)), 'answer' => $answerValue, 'answer_label' => $answerValue ? 'Yes' : 'No', ]; $stack[] = [ 'answers' => $nextAnswers, 'chain' => $nextChain, ]; } } if ($progressCallback !== null && $stateCount % $progressIntervalStates === 0) { $progressCallback($this->pathOutcomesProjectionPayload( $scope, $groups, $paths, $truncated, $maxStates, $stateCount, $terminalPathCount, $questionIds, count($stack), $confirmationRows )); } continue; } $terminalPathCount++; $chain = is_array($state['chain'] ?? null) ? (array)$state['chain'] : []; $this->addPathOutcomeGroup($groups, $simulation, $chain, $scope, $sampleLimit); if (count($paths) < $pathSampleLimit) { $paths[] = $this->pathResultFromSimulation($simulation, $chain, $scope); } if ($progressCallback !== null && $stateCount % $progressIntervalStates === 0) { $progressCallback($this->pathOutcomesProjectionPayload( $scope, $groups, $paths, $truncated, $maxStates, $stateCount, $terminalPathCount, $questionIds, count($stack), $confirmationRows )); } } return $this->pathOutcomesProjectionPayload( $scope, $groups, $paths, $truncated, $maxStates, $stateCount, $terminalPathCount, $questionIds, count($stack), $confirmationRows ); } /** * @param array $payload * @return array */ public function confirmPathOutcome(int $departmentId, array $payload, ?int $userId): array { $pathSignature = trim((string)($payload['path_signature'] ?? '')); $resultSignature = trim((string)($payload['result_signature'] ?? '')); if ($pathSignature === '' || $resultSignature === '') { throw new \RuntimeException('path_signature and result_signature are required.'); } $scope = is_array($payload['scope'] ?? null) ? (array)$payload['scope'] : []; $laneId = $this->nullableInt($payload['lane_id'] ?? $scope['lane_id'] ?? null); $vehicleTypeId = $this->nullableInt($payload['vehicle_type_id'] ?? $scope['vehicle_type_id'] ?? null); $configVersionId = $this->nullableInt($payload['config_version_id'] ?? $scope['config_version_id'] ?? null); $configSource = strtolower(trim((string)($payload['config_source'] ?? $scope['config_source'] ?? 'draft'))); if ($configSource === '') { $configSource = 'draft'; } $answers = is_array($payload['answers'] ?? null) ? array_values((array)$payload['answers']) : []; $result = is_array($payload['result'] ?? null) ? (array)$payload['result'] : []; $answersJson = json_encode($this->sortStableValue($answers), JSON_UNESCAPED_UNICODE); $resultJson = json_encode($this->sortStableValue($result), JSON_UNESCAPED_UNICODE); $scopeJson = json_encode($this->sortStableValue($scope), JSON_UNESCAPED_UNICODE); if ($answersJson === false || $resultJson === false || $scopeJson === false) { throw new \RuntimeException('Could not encode path confirmation payload.'); } $pdo = db::getPDO(); $select = $pdo->prepare( "SELECT id FROM department_selfserve_path_confirmations WHERE department_id = :department_id AND path_signature = :path_signature AND " . ($configVersionId === null ? "config_version_id IS NULL" : "config_version_id = :config_version_id") . " AND deleted_at IS NULL ORDER BY id DESC LIMIT 1" ); $selectParams = [ ':department_id' => $departmentId, ':path_signature' => $pathSignature, ]; if ($configVersionId !== null) { $selectParams[':config_version_id'] = $configVersionId; } $select->execute($selectParams); $row = $select->fetch(\PDO::FETCH_ASSOC); if (is_array($row) && (int)($row['id'] ?? 0) > 0) { $update = $pdo->prepare( "UPDATE department_selfserve_path_confirmations SET lane_id = :lane_id, vehicle_type_id = :vehicle_type_id, config_source = :config_source, result_signature = :result_signature, answers_json = :answers_json, result_json = :result_json, scope_json = :scope_json, confirmed_by = :confirmed_by, confirmed_at = NOW(), stale_reason = NULL, deleted_at = NULL WHERE id = :id" ); $update->execute([ ':lane_id' => $laneId, ':vehicle_type_id' => $vehicleTypeId, ':config_source' => $configSource, ':result_signature' => $resultSignature, ':answers_json' => $answersJson, ':result_json' => $resultJson, ':scope_json' => $scopeJson, ':confirmed_by' => $userId, ':id' => (int)$row['id'], ]); $id = (int)$row['id']; } else { $insert = $pdo->prepare( "INSERT INTO department_selfserve_path_confirmations (department_id, lane_id, vehicle_type_id, config_version_id, config_source, path_signature, result_signature, answers_json, result_json, scope_json, confirmed_by, confirmed_at) VALUES (:department_id, :lane_id, :vehicle_type_id, :config_version_id, :config_source, :path_signature, :result_signature, :answers_json, :result_json, :scope_json, :confirmed_by, NOW())" ); $insert->execute([ ':department_id' => $departmentId, ':lane_id' => $laneId, ':vehicle_type_id' => $vehicleTypeId, ':config_version_id' => $configVersionId, ':config_source' => $configSource, ':path_signature' => $pathSignature, ':result_signature' => $resultSignature, ':answers_json' => $answersJson, ':result_json' => $resultJson, ':scope_json' => $scopeJson, ':confirmed_by' => $userId, ]); $id = (int)$pdo->lastInsertId(); } return [ 'id' => $id, 'department_id' => $departmentId, 'lane_id' => $laneId, 'vehicle_type_id' => $vehicleTypeId, 'config_version_id' => $configVersionId, 'config_source' => $configSource, 'path_signature' => $pathSignature, 'result_signature' => $resultSignature, 'answers' => $answers, 'result' => $result, 'scope' => $scope, 'confirmation_status' => 'confirmed', ]; } /** * @param array $payload * @return array */ public function resetPathConfirmation(int $departmentId, array $payload): array { $pathSignature = trim((string)($payload['path_signature'] ?? '')); if ($pathSignature === '') { throw new \RuntimeException('path_signature is required.'); } $scope = is_array($payload['scope'] ?? null) ? (array)$payload['scope'] : []; $configVersionId = $this->nullableInt($payload['config_version_id'] ?? $scope['config_version_id'] ?? null); $pdo = db::getPDO(); $statement = $pdo->prepare( "UPDATE department_selfserve_path_confirmations SET deleted_at = NOW() WHERE department_id = :department_id AND path_signature = :path_signature AND " . ($configVersionId === null ? "config_version_id IS NULL" : "config_version_id = :config_version_id") . " AND deleted_at IS NULL" ); $params = [ ':department_id' => $departmentId, ':path_signature' => $pathSignature, ]; if ($configVersionId !== null) { $params[':config_version_id'] = $configVersionId; } $statement->execute($params); return [ 'path_signature' => $pathSignature, 'reset' => true, 'affected' => $statement->rowCount(), ]; } /** * @param array $payload * @return array */ public function runGatewayAction(int $departmentId, int $gatewayId, string $action, array $payload, ?int $userId): array { if (!class_exists(edge_gateway_view_service::class)) { throw new \RuntimeException('Edge gateway module is not available.'); } $gateway = (new edge_gateway_view_service())->getGateway($gatewayId); if (!isset($gateway['id'])) { throw new \RuntimeException('Edge gateway not found.'); } if ((int)($gateway['department_id'] ?? 0) !== $departmentId) { throw new \RuntimeException('Edge gateway does not belong to the selected department.'); } $action = strtolower(trim($action)); $operations = new edge_gateway_operation_service(); return match ($action) { 'discovery', 'discover' => [ 'action' => 'discovery', 'operation' => $operations->queueDiscoveryOperation($gatewayId, $userId), ], 'update' => [ 'action' => 'update', 'operation' => $operations->queueOperation($gatewayId, edge_gateway_operation_service::TYPE_UPDATE, (array)($payload['request'] ?? []), $userId), ], 'uninstall' => [ 'action' => 'uninstall', 'operation' => $operations->queueOperation($gatewayId, edge_gateway_operation_service::TYPE_UNINSTALL, (array)($payload['request'] ?? []), $userId), ], 'cancel' => [ 'action' => 'cancel', 'operation' => $operations->cancelOperation($gatewayId, (int)($payload['operation_id'] ?? 0), $userId), ], 'rotate_credentials' => [ 'action' => 'rotate_credentials', 'gateway' => $operations->rotateCredentials($gatewayId, $userId), ], 'bindings' => [ 'action' => 'bindings', 'gateway' => (new edge_gateway_registry_service())->setRelayBindings($gatewayId, (array)($payload['bindings'] ?? []), $userId), ], default => throw new \RuntimeException('Unsupported gateway action: ' . $action), }; } /** * @param array $payload * @param array $permissions * @return 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)) { $workspace = [ 'gateways' => [], 'relays' => [], 'lanes' => [], 'issues' => [], 'actions' => [], 'available' => false, ]; return $includeVirtual ? (new selfserve_virtual_hardware())->mergeWorkspace($workspace, $departmentId) : $workspace; } try { $workspace = (new edge_gateway_department_workspace_service())->getDepartmentWorkspace($departmentId); } catch (\Throwable $exception) { $workspace = [ 'gateways' => [], 'relays' => [], 'lanes' => [], 'issues' => [ [ 'severity' => 'warning', 'message' => $exception->getMessage(), ], ], 'actions' => [], 'available' => false, ]; } return $includeVirtual ? (new selfserve_virtual_hardware())->mergeWorkspace($workspace, $departmentId) : $workspace; } /** * @param array $config * @param array $gatewayWorkspace * @return array */ 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', 'relay_in_id', 'relay_out_id', 'relay_machine_id', 'relay_machine_program_picker_id', 'relay_machine_cleaner_id', 'machine_type_id', 'dynamic_image_id', 'selfserve_enabled', ], ['department' => $departmentId]); $machineTypeRows = $this->fetchRows('selfserve_machine_types', ['id', 'name', 'description'], []); $productRows = $this->fetchRows('products', ['id', 'name', 'description', 'price', 'subscription_allowed', 'category', 'piktogram', 'is_wash', 'order_priority'], []); $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' => $laneLookupRows, 'products' => $this->labelRows($productRows, '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'), 'rules' => $this->configLabelRows((array)($config['rules'] ?? []), 'name'), 'tasks' => $this->configLabelRows((array)($config['tasks'] ?? []), 'task'), 'actions' => $this->configLabelRows((array)($config['actions'] ?? []), 'name'), 'gateways' => $this->gatewayLabelRows((array)($gatewayWorkspace['gateways'] ?? [])), 'relays' => $this->relayLabelRows((array)($gatewayWorkspace['relays'] ?? [])), 'bindings' => $this->bindingLabelRows((array)($gatewayWorkspace['gateways'] ?? [])), 'users' => array_map(static function (array $row): array { $label = trim((string)($row['display_name'] ?? '')); if ($label === '') { $label = 'User ' . (string)($row['customer_number'] ?? $row['id'] ?? ''); } $row['label'] = $label; return $row; }, $users), ]; $labels = []; foreach ($lookups as $type => $rows) { if (!is_array($rows)) { continue; } $labels[$type] = []; foreach ($rows as $row) { if (!is_array($row)) { continue; } $id = (string)($row['id'] ?? ''); if ($id !== '') { $labels[$type][$id] = (string)($row['label'] ?? $id); } } } $lookups['labels'] = $labels; 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', 'actions'] 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 * @param array $workspace */ private function appendGatewayNodesAndEdges(array &$nodes, array &$edges, array $workspace): void { $relayServices = $this->relayServicesFromWorkspace($workspace); foreach ((array)($workspace['gateways'] ?? []) as $index => $gateway) { if (!is_array($gateway)) { continue; } $gatewayId = $this->gatewayIdentifier($gateway); if ($gatewayId === '') { continue; } $nodeId = $this->gatewayNodeId($gateway); $nodes[] = $this->node($nodeId, 'default', (string)($gateway['label'] ?? ('Gateway ' . $gatewayId)), 'edge_gateway', [ 'object_id' => $gatewayId, 'raw' => $gateway, 'subtitle' => (string)($gateway['status'] ?? 'UNKNOWN'), ], 1440, 160 + ($index * 150)); foreach ((array)($gateway['bindings'] ?? []) as $bindingIndex => $binding) { if (!is_array($binding)) { continue; } $relayId = trim((string)($binding['relay_id'] ?? '')); if ($relayId === '') { continue; } $bindingServices = $this->bindingServices($binding, $relayId, $relayServices); if ($bindingServices !== []) { $binding['services'] = $bindingServices; if (trim((string)($binding['role'] ?? '')) === '' && count($bindingServices) === 1) { $binding['role'] = $bindingServices[0]; } } $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, 'subtitle' => (string)($binding['role'] ?? 'Relay binding'), ], 1720, 180 + (($index * 4 + $bindingIndex) * 100)); $edges[] = $this->edge('gateway-binding:' . $gatewayId . ':' . $relayId . ':' . $bindingIndex, $nodeId, $bindingId, 'gateway_binding', 'binds'); $edges[] = $this->edge('binding-relay:' . $gatewayId . ':' . $relayId . ':' . $bindingIndex, $bindingId, 'relay:' . $relayId, 'relay_binding', 'controls'); } } $relayIndex = 0; foreach ((array)($workspace['relays'] ?? []) as $relay) { if (!is_array($relay)) { continue; } $relayId = trim((string)($relay['relay_id'] ?? $relay['id'] ?? '')); if ($relayId === '') { continue; } $nodes[] = $this->node('relay:' . $relayId, 'default', (string)($relay['name'] ?? ('Relay ' . $relayId)), 'relay', [ 'object_id' => $relayId, 'raw' => $relay, 'subtitle' => 'Hardware relay', ], 2020, 180 + ($relayIndex * 100)); $relayIndex++; } foreach ((array)($workspace['lanes'] ?? []) as $lane) { if (!is_array($lane)) { continue; } $laneId = (int)($lane['id'] ?? 0); foreach ((array)($lane['relay_slots'] ?? []) as $slot) { if (!is_array($slot)) { continue; } $relayId = trim((string)($slot['relay_id'] ?? '')); if ($laneId > 0 && $relayId !== '') { $edges[] = $this->edge('relay-lane:' . $relayId . ':' . $laneId . ':' . (string)($slot['slot'] ?? ''), 'relay:' . $relayId, 'lane:' . $laneId, 'lane_relay', (string)($slot['slot'] ?? 'relay')); } } } } /** * @param array> $edges * @param array> $tasks * @param array $workspace */ private function appendTaskServiceEdges(array &$edges, array $tasks, array $workspace): void { $bindingsByService = []; foreach ($this->gatewayBindingReferences($workspace) as $binding) { foreach ((array)$binding['services'] as $service) { $bindingsByService[$service][] = $binding; } } foreach ($tasks as $task) { $taskId = (int)($task['id'] ?? 0); if ($taskId <= 0) { continue; } foreach ($this->normalizeServiceList($task['services'] ?? []) as $service) { foreach ($bindingsByService[$service] ?? [] as $binding) { $edges[] = $this->edge( 'task-service:' . $taskId . ':' . $service . ':' . $binding['gateway_id'] . ':' . $binding['relay_id'] . ':' . $binding['binding_index'], 'task:' . $taskId, (string)$binding['node_id'], 'task_service', $service ); } } } } /** * @param array> $edges * @param array> $actions * @param array $workspace */ private function appendActionRelayEdges(array &$edges, array $actions, array $workspace): void { foreach ($actions as $action) { if (!is_array($action)) { continue; } $normalizedAction = selfserve_studio_actions::normalize($action); $actionId = (int)($normalizedAction['id'] ?? 0); if ($actionId <= 0) { continue; } $relayRole = $this->normalizeServiceName(selfserve_studio_actions::relayRoleForOperation((string)$normalizedAction['operation'])); if ($relayRole === '' || $relayRole === 'ACTION') { continue; } $laneId = (int)($normalizedAction['lane'] ?? 0); foreach ($this->actionRelayTargets($workspace, $relayRole, $laneId) as $target) { $edges[] = $this->edge( 'action-relay:' . $actionId . ':' . $target['relay_id'] . ':' . $relayRole . ':' . $target['lane_id'], 'action:' . $actionId, 'relay:' . $target['relay_id'], 'action_relay', $relayRole ); } } } /** * @param array $workspace * @return array */ private function actionRelayTargets(array $workspace, string $relayRole, int $actionLaneId): array { $targets = []; $seen = []; foreach ((array)($workspace['lanes'] ?? []) as $lane) { if (!is_array($lane)) { continue; } $laneId = (int)($lane['id'] ?? 0); if ($laneId <= 0 || ($actionLaneId > 0 && $laneId !== $actionLaneId)) { continue; } foreach ((array)($lane['relay_slots'] ?? []) as $slot) { if (!is_array($slot)) { continue; } $slotRole = $this->normalizeServiceName($slot['slot'] ?? $slot['role'] ?? $slot['service'] ?? ''); $relayId = trim((string)($slot['relay_id'] ?? '')); if ($relayId === '' || $slotRole !== $relayRole) { continue; } $key = $laneId . ':' . $relayRole . ':' . $relayId; if (isset($seen[$key])) { continue; } $seen[$key] = true; $targets[] = [ 'relay_id' => $relayId, 'lane_id' => $laneId, ]; } } return $targets; } /** * @param array $config * @param array $operation */ private function applyConfigOperation(int $departmentId, array &$config, array $operation, array $permissions = []): 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 ($action === 'upsert_path' || ($entity === 'path' && $action === 'upsert')) { $this->upsertConfigPath($departmentId, $config, $data); return; } if ($entity === '') { throw new \RuntimeException('Studio graph operation is missing entity.'); } if ($entity === 'lane') { $this->applyLaneOperation($departmentId, $action, $id, $data, $permissions); 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 upsertConfigPath(int $departmentId, array &$config, array $data): void { $scope = $this->normalizePathEditorScope($departmentId, is_array($data['scope'] ?? null) ? (array)$data['scope'] : $data); $answers = $this->normalizePathEditorAnswers($data['answers'] ?? []); if ($answers === []) { throw new \RuntimeException('Path editor operation requires at least one answer.'); } $this->assertPathEditorQuestionsExist($config, $answers); $result = $this->normalizePathEditorResult(is_array($data['result'] ?? null) ? (array)$data['result'] : $data); $previousPathKey = trim((string)($data['previous_path_key'] ?? '')); $pathKey = trim((string)($data['path_key'] ?? '')); if ($pathKey === '') { $pathKey = $previousPathKey !== '' ? $previousPathKey : $this->pathEditorPathKey($scope, $answers); } if (!isset($config['v2_meta']) || !is_array($config['v2_meta'])) { $config['v2_meta'] = []; } if (!isset($config['v2_meta']['path_editor']) || !is_array($config['v2_meta']['path_editor'])) { $config['v2_meta']['path_editor'] = []; } if (!isset($config['v2_meta']['path_editor']['paths']) || !is_array($config['v2_meta']['path_editor']['paths'])) { $config['v2_meta']['path_editor']['paths'] = []; } if ($previousPathKey !== '' && $previousPathKey !== $pathKey && isset($config['v2_meta']['path_editor']['paths'][$previousPathKey])) { $config['v2_meta']['path_editor']['paths'][$pathKey] = $config['v2_meta']['path_editor']['paths'][$previousPathKey]; unset($config['v2_meta']['path_editor']['paths'][$previousPathKey]); } $paths = &$config['v2_meta']['path_editor']['paths']; $existing = is_array($paths[$pathKey] ?? null) ? (array)$paths[$pathKey] : []; $conditionId = $this->nullableInt($existing['condition_id'] ?? $data['condition_id'] ?? $data['existing_condition_id'] ?? null); $existingTaskIds = $this->pathEditorExistingTaskIds($existing, $data); $conditionId = $this->upsertPathEditorCondition($departmentId, $config, $pathKey, $scope, $answers, $result, $conditionId); $taskIds = []; if ((bool)$result['machine_allowed']) { $baseOrderPriority = $this->pathEditorTaskBaseOrderPriority($config, $existingTaskIds); foreach (array_values((array)($result['tasks'] ?? [])) as $index => $taskResult) { if (!is_array($taskResult)) { continue; } $taskIds[] = $this->upsertPathEditorTask( $departmentId, $config, $pathKey, $scope, $taskResult, $conditionId, $existingTaskIds[$index] ?? null, $baseOrderPriority + ($index * 10) ); } foreach (array_slice($existingTaskIds, count($taskIds)) as $staleTaskId) { $this->deleteConfigEntity($config, 'task', $staleTaskId); } } else { foreach ($existingTaskIds as $staleTaskId) { $this->deleteConfigEntity($config, 'task', $staleTaskId); } } $taskId = $taskIds[0] ?? null; $pathSignature = $this->pathSignature($scope, $answers); $resultSignature = $this->pathEditorResultSignature($result, $taskIds); $paths[$pathKey] = [ 'path_key' => $pathKey, 'condition_id' => $conditionId, 'task_id' => $taskId, 'task_ids' => $taskIds, 'scope' => $scope, 'answers' => $answers, 'result' => $result, 'path_signature' => $pathSignature, 'result_signature' => $resultSignature, 'updated_at' => date('c'), ]; unset($paths); } /** * @param array $scope * @return array */ private function normalizePathEditorScope(int $departmentId, array $scope): array { return [ 'department_id' => $departmentId, 'lane_id' => $this->nullableInt($scope['lane_id'] ?? $scope['lane'] ?? null), 'vehicle_type_id' => $this->nullableInt($scope['vehicle_type_id'] ?? $scope['product'] ?? $scope['product_id'] ?? null), 'machine_type_id' => $this->nullableInt($scope['machine_type_id'] ?? null), 'config_source' => strtolower(trim((string)($scope['config_source'] ?? 'draft'))) ?: 'draft', 'hardware_mode' => strtolower(trim((string)($scope['hardware_mode'] ?? 'studio'))) ?: 'studio', ]; } /** * @return array */ private function normalizePathEditorAnswers(mixed $answers): array { $rows = []; if (!is_array($answers)) { return $rows; } foreach ($answers as $key => $entry) { if (is_array($entry)) { $questionId = (int)($entry['question_id'] ?? $entry['id'] ?? $key); $rawValue = $entry['value'] ?? $entry['answer'] ?? null; } else { $questionId = (int)$key; $rawValue = $entry; } if ($questionId <= 0) { continue; } $value = filter_var($rawValue, FILTER_VALIDATE_BOOLEAN, FILTER_NULL_ON_FAILURE); if ($value === null) { continue; } $rows[] = [ 'question_id' => $questionId, 'value' => (bool)$value, 'answer' => (bool)$value, 'answer_label' => (bool)$value ? 'Yes' : 'No', ]; } return $rows; } /** * @param array $config * @param array $answers */ private function assertPathEditorQuestionsExist(array $config, array $answers): void { $questionIds = []; foreach ((array)($config['questions'] ?? []) as $question) { if (is_array($question)) { $questionIds[(int)($question['id'] ?? 0)] = true; } } foreach ($answers as $answer) { $questionId = (int)($answer['question_id'] ?? 0); if ($questionId > 0 && !isset($questionIds[$questionId])) { throw new \RuntimeException('Path editor answer references unknown question ' . $questionId . '.'); } } } /** * @param array $result * @return array */ private function normalizePathEditorResult(array $result): array { $machineAllowed = filter_var($result['machine_allowed'] ?? $result['allowed'] ?? true, FILTER_VALIDATE_BOOLEAN, FILTER_NULL_ON_FAILURE); $machineAllowed = $machineAllowed !== false; $services = $machineAllowed ? $this->normalizeServiceList($result['services'] ?? ['MACHINE']) : $this->normalizeServiceList($result['services'] ?? []); if ($machineAllowed && !in_array('MACHINE', $services, true)) { $services[] = 'MACHINE'; sort($services); } try { $buttons = department_selfserve_tasks_o::normalizeButtonsInput($result['buttons'] ?? []); } catch (\Throwable $exception) { throw new \RuntimeException('Invalid path editor buttons: ' . $exception->getMessage()); } $dynamicImagesVehicleType = $this->nullableInt($result['dynamic_images_vehicle_type'] ?? null); $hasTaskList = array_key_exists('tasks', $result) && is_array($result['tasks']); $tasks = []; if ($machineAllowed) { if ($hasTaskList) { foreach (array_values((array)$result['tasks']) as $index => $task) { if (!is_array($task)) { continue; } $tasks[] = $this->normalizePathEditorTaskResult((array)$task, $services, $index); } } else { $tasks[] = $this->normalizePathEditorTaskResult([ 'task' => $result['task'] ?? $result['task_text'] ?? 'Start machine', 'description' => $result['description'] ?? '', 'services' => $services, 'buttons' => $buttons, 'dynamic_images_vehicle_type' => $dynamicImagesVehicleType, ], $services, 0); } } if ($hasTaskList) { $buttons = $this->flattenPathEditorTaskButtons($tasks); $services = $this->mergePathEditorTaskServices($services, $tasks, $machineAllowed); if ($dynamicImagesVehicleType === null) { foreach ($tasks as $task) { $dynamicImagesVehicleType = $this->nullableInt($task['dynamic_images_vehicle_type'] ?? null); if ($dynamicImagesVehicleType !== null) { break; } } } } return [ 'machine_allowed' => $machineAllowed, 'task' => trim((string)($result['task'] ?? $result['task_text'] ?? 'Start machine')) ?: 'Start machine', 'description' => trim((string)($result['description'] ?? '')), 'services' => $services, 'buttons' => $buttons, 'dynamic_images_vehicle_type' => $dynamicImagesVehicleType, 'tasks' => $tasks, 'condition_name' => trim((string)($result['condition_name'] ?? '')), ]; } /** * @param array $task * @param array $fallbackServices * @return array */ private function normalizePathEditorTaskResult(array $task, array $fallbackServices, int $index): array { try { $buttons = department_selfserve_tasks_o::normalizeButtonsInput($task['buttons'] ?? []); } catch (\Throwable $exception) { throw new \RuntimeException('Invalid path editor task buttons: ' . $exception->getMessage()); } $services = $this->normalizeServiceList($task['services'] ?? $fallbackServices); if (!in_array('MACHINE', $services, true)) { $services[] = 'MACHINE'; } $dynamicImagesVehicleType = $this->nullableInt($task['dynamic_images_vehicle_type'] ?? null); if (($dynamicImagesVehicleType !== null || in_array('program_picker', $buttons, true)) && !in_array('PROGRAM_PICKER', $services, true)) { $services[] = 'PROGRAM_PICKER'; } sort($services); return [ 'task' => trim((string)($task['task'] ?? $task['label'] ?? ('Task ' . ($index + 1)))) ?: ('Task ' . ($index + 1)), 'description' => trim((string)($task['description'] ?? '')), 'services' => $services, 'buttons' => $buttons, 'dynamic_images_vehicle_type' => $dynamicImagesVehicleType, ]; } /** * @param array> $tasks * @return array */ private function flattenPathEditorTaskButtons(array $tasks): array { $buttons = []; foreach ($tasks as $task) { if (!is_array($task)) { continue; } foreach ($this->normalizeArrayPayload($task['buttons'] ?? []) as $button) { $buttons[] = $button; } } return department_selfserve_tasks_o::normalizeButtonsInput($buttons); } /** * @param array $services * @param array> $tasks * @return array */ private function mergePathEditorTaskServices(array $services, array $tasks, bool $machineAllowed): array { $merged = []; foreach ($this->normalizeServiceList($services) as $service) { $merged[$service] = true; } foreach ($tasks as $task) { if (!is_array($task)) { continue; } foreach ($this->normalizeServiceList($task['services'] ?? []) as $service) { $merged[$service] = true; } } if ($machineAllowed) { $merged['MACHINE'] = true; } $values = array_keys($merged); sort($values); return $values; } /** * @param array $scope * @param array> $answers */ private function pathEditorPathKey(array $scope, array $answers): string { return 'path_' . substr(hash('sha256', $this->stableJson([ 'scope' => $scope, 'answers' => $answers, ])), 0, 16); } /** * @param array $config * @param array $scope * @param array> $answers * @param array $result */ private function upsertPathEditorCondition(int $departmentId, array &$config, string $pathKey, array $scope, array $answers, array $result, ?int $conditionId): int { if ($conditionId === null || $this->configRowIndex((array)($config['conditions'] ?? []), $conditionId) === null) { $conditionId = $this->allocateConfigId($config, 'condition'); $rows = &$this->configRows($config, 'condition'); $rows[] = [ 'id' => $conditionId, 'department' => $departmentId, 'lane' => 0, 'product' => 0, 'machine_type_id' => null, 'condition_id' => null, 'name' => 'Generated path condition', 'description' => '', 'expression' => $this->emptyV2Expression(), ]; unset($rows); } $name = trim((string)($result['condition_name'] ?? '')); if ($name === '') { $name = 'Path: ' . $this->pathEditorAnswerSummary($answers); } $rows = &$this->configRows($config, 'condition'); $index = $this->configRowIndex($rows, $conditionId); if ($index === null) { throw new \RuntimeException('Generated path condition could not be created.'); } $rows[$index] = $this->mergeConfigEntityData('condition', $rows[$index], [ 'department' => $departmentId, 'lane' => $scope['lane_id'] ?? 0, 'product' => $scope['vehicle_type_id'] ?? 0, 'machine_type_id' => $scope['machine_type_id'] ?? null, 'name' => $name, 'description' => 'Generated by Path Editor for ' . $this->pathEditorAnswerSummary($answers), 'expression' => $this->pathEditorConditionExpression($answers), ]); $rows[$index]['generated_by'] = 'path_editor'; $rows[$index]['path_key'] = $pathKey; unset($rows); return $conditionId; } /** * @param array $config * @param array $scope * @param array $result */ private function upsertPathEditorTask(int $departmentId, array &$config, string $pathKey, array $scope, array $result, int $conditionId, ?int $taskId, int $orderPriority): int { if ($taskId === null || $this->configRowIndex((array)($config['tasks'] ?? []), $taskId) === null) { $taskId = $this->allocateConfigId($config, 'task'); $rows = &$this->configRows($config, 'task'); $rows[] = [ 'id' => $taskId, 'department' => $departmentId, 'lane' => 0, 'product' => 0, 'machine_type_id' => null, 'condition_id' => null, 'gate_type' => selfserve_task_gate_type::CONDITION->value, 'gate_ref_id' => $conditionId, 'task' => 'Start machine', 'description' => '', 'order_priority' => $orderPriority, 'services' => [], 'buttons' => [], 'dynamic_images_vehicle_type' => null, ]; unset($rows); } $rows = &$this->configRows($config, 'task'); $index = $this->configRowIndex($rows, $taskId); if ($index === null) { throw new \RuntimeException('Generated path task could not be created.'); } $rows[$index] = $this->mergeConfigEntityData('task', $rows[$index], [ 'department' => $departmentId, 'lane' => $scope['lane_id'] ?? 0, 'product' => $scope['vehicle_type_id'] ?? 0, 'machine_type_id' => $scope['machine_type_id'] ?? null, 'condition_id' => $conditionId, 'gate_type' => selfserve_task_gate_type::CONDITION->value, 'gate_ref_id' => $conditionId, 'task' => $result['task'], 'description' => $result['description'], 'order_priority' => $orderPriority, 'services' => $result['services'], 'buttons' => $result['buttons'], 'dynamic_images_vehicle_type' => $result['dynamic_images_vehicle_type'], ]); $rows[$index]['condition_id'] = $conditionId; $rows[$index]['generated_by'] = 'path_editor'; $rows[$index]['path_key'] = $pathKey; unset($rows); return $taskId; } /** * @param array $existing * @param array $data * @return array */ private function pathEditorExistingTaskIds(array $existing, array $data): array { $ids = []; foreach ([$existing['task_ids'] ?? null, $data['task_ids'] ?? null] as $taskIds) { if (!is_array($taskIds)) { continue; } foreach ($taskIds as $taskId) { $normalizedTaskId = $this->nullableInt($taskId); if ($normalizedTaskId !== null) { $ids[] = $normalizedTaskId; } } } foreach ([ $existing['task_id'] ?? null, $data['task_id'] ?? null, $data['existing_task_id'] ?? null, ] as $taskId) { $normalizedTaskId = $this->nullableInt($taskId); if ($normalizedTaskId !== null) { $ids[] = $normalizedTaskId; } } return array_values(array_unique(array_filter($ids, static fn(int $taskId): bool => $taskId > 0))); } /** * @param array $config * @param array $taskIds */ private function pathEditorTaskBaseOrderPriority(array $config, array $taskIds): int { $taskRows = (array)($config['tasks'] ?? []); foreach ($taskIds as $taskId) { $index = $this->configRowIndex($taskRows, $taskId); if ($index !== null && is_array($taskRows[$index] ?? null)) { return (int)($taskRows[$index]['order_priority'] ?? 0); } } return $this->nextOrderPriority($taskRows); } /** * @param array> $answers * @return array */ private function pathEditorConditionExpression(array $answers): array { return [ 'type' => 'group', 'operator' => 'ALL', 'children' => array_values(array_map(static fn(array $answer): array => [ 'type' => 'predicate', 'subject_type' => 'question', 'subject_id' => (int)($answer['question_id'] ?? 0), 'operator' => ((bool)($answer['value'] ?? $answer['answer'] ?? false)) ? 'IS_TRUE' : 'IS_FALSE', ], $answers)), ]; } /** * @param array> $answers */ private function pathEditorAnswerSummary(array $answers): string { $parts = []; foreach ($answers as $answer) { $parts[] = 'Q' . (int)($answer['question_id'] ?? 0) . '=' . (((bool)($answer['value'] ?? $answer['answer'] ?? false)) ? 'Yes' : 'No'); } return implode(', ', $parts) ?: 'answers'; } /** * @param array $scope * @param array> $answers */ private function pathSignature(array $scope, array $answers): string { return hash('sha256', $this->stableJson([ 'scope' => $this->pathScopeKey($scope), 'answers' => array_values(array_map(static fn(array $answer): array => [ 'question_id' => (int)($answer['question_id'] ?? 0), 'answer' => (bool)($answer['answer'] ?? $answer['value'] ?? false), ], $answers)), ])); } /** * @param array $result */ private function pathEditorResultSignature(array $result, array $taskIds): string { $tasks = []; if ((bool)($result['machine_allowed'] ?? false)) { foreach (array_values((array)($result['tasks'] ?? [])) as $index => $task) { if (!is_array($task)) { continue; } $tasks[] = [ 'id' => (int)($taskIds[$index] ?? 0), 'label' => (string)($task['task'] ?? $task['label'] ?? ''), 'services' => $this->normalizeServiceList($task['services'] ?? []), 'buttons' => $this->normalizeArrayPayload($task['buttons'] ?? []), 'dynamic_images_vehicle_type' => $this->nullableInt($task['dynamic_images_vehicle_type'] ?? null), ]; } } return hash('sha256', $this->stableJson([ 'allowed' => (bool)($result['machine_allowed'] ?? false), 'services' => $this->normalizeServiceList($result['services'] ?? []), 'tasks' => $tasks, 'buttons' => $this->normalizeArrayPayload($result['buttons'] ?? []), 'signals' => [], ])); } /** * @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, ], 'action' => [ 'id' => $id, 'department' => $departmentId, 'lane' => 0, 'product' => 0, 'machine_type_id' => null, 'condition_id' => null, 'name' => 'Open lane entrance port', 'description' => '', 'event' => selfserve_studio_actions::EVENT_WASH_START_COMMAND, 'wash_mode' => selfserve_studio_actions::MODE_BOTH, 'operation' => selfserve_studio_actions::OP_OPEN_LANE_ENTRANCE_PORT, 'relay_state' => null, 'enabled' => true, 'order_priority' => $this->nextOrderPriority((array)($config['actions'] ?? [])), 'options' => [ 'delay_ms' => 0, 'toggle_after_seconds' => 1, 'retry_count' => 0, 'failure_policy' => selfserve_studio_actions::FAILURE_CONTINUE, 'record_event' => true, ], ], default => throw new \RuntimeException('Unsupported studio entity: ' . $entity), }; $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); $actionRows = &$this->configRows($config, 'action'); foreach ($actionRows as &$action) { if (is_array($action) && (int)($action['condition_id'] ?? 0) === $id) { $action['condition_id'] = null; } } unset($action); } } /** * @param array $config * @param array> $items */ private function applyConfigReorder(array &$config, string $entity, array $items): void { if (!in_array($entity, ['question', 'task', 'action'], true)) { throw new \RuntimeException('Only questions, tasks, and actions 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; } if ($sourceType === 'condition' && $targetType === 'action') { $rows = &$this->configRows($config, 'action'); $index = $this->configRowIndex($rows, $targetId); if ($index !== null && (!$disconnect || (int)($rows[$index]['condition_id'] ?? 0) === $sourceId)) { $rows[$index]['condition_id'] = $disconnect ? null : $sourceId; } return; } } /** * @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], ]; } elseif (($expression['type'] ?? 'group') !== 'group') { $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', 'action' => 'actions', 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', 'action' => $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']; } elseif ($entity === 'action' && !array_key_exists('name', $data)) { $data['name'] = $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'], 'action' => ['department', 'lane', 'product', 'machine_type_id', 'condition_id', 'name', 'description', 'event', 'wash_mode', 'operation', 'relay_state', 'enabled', 'order_priority', 'options'], default => throw new \RuntimeException('Unsupported studio entity: ' . $entity), }; 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; } } if ($entity === 'action') { $row = selfserve_studio_actions::normalize($row); } 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', 'toggle_after_seconds'], true)) { return $this->nullableInt($value); } if (in_array($field, ['department', 'lane', 'product', 'order_priority', 'delay_ms', 'retry_count'], true)) { return (int)$value; } if (in_array($field, ['enabled', 'relay_state', 'record_event'], true)) { return filter_var($value, FILTER_VALIDATE_BOOLEAN, FILTER_NULL_ON_FAILURE); } if ($field === 'services') { return $this->normalizeServiceList($value); } if ($field === 'buttons') { return $this->normalizeArrayPayload($value); } if ($field === 'options') { return is_array($value) ? (array)$value : []; } if ($field === 'gate_type') { return $this->normalizeGateType((string)$value); } return is_array($value) ? $value : (string)$value; } /** * @param array $operation */ private function applyOperation(int $departmentId, array $operation, array $permissions = []): 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->applyConnection($departmentId, (string)($operation['source'] ?? ''), (string)($operation['target'] ?? ''), false); return; } if ($action === 'disconnect') { $this->applyConnection($departmentId, (string)($operation['source'] ?? ''), (string)($operation['target'] ?? ''), true); return; } if ($action === 'reorder') { $this->applyReorder($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, $permissions); return; } if ($action === 'create') { $this->createEntity($departmentId, $entity, $data); return; } if ($id <= 0) { throw new \RuntimeException('Studio graph operation is missing id.'); } if ($action === 'update') { $this->updateEntity($departmentId, $entity, $id, $data); return; } if ($action === 'delete') { $this->softDeleteEntity($departmentId, $entity, $id); return; } throw new \RuntimeException('Unsupported studio graph operation: ' . $action); } /** * @param array $data */ private function applyLaneOperation(int $departmentId, string $action, int $id, array $data, array $permissions = []): void { if (!$this->tableExists('department_lanes')) { throw new \RuntimeException('Department lanes are not available.'); } $this->assertLaneOperationAuthorized($action, $data, $permissions); 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 * @param array $permissions */ private function assertLaneOperationAuthorized(string $action, array $data, array $permissions): void { if ($action === 'create' && !($permissions['can_add_department_lane'] ?? false)) { throw new \RuntimeException('Missing permission: add_department_lane.'); } if (in_array($action, ['update', 'delete'], true) && !($permissions['can_edit_department_lane'] ?? false)) { throw new \RuntimeException('Missing permission: edit_department_lane.'); } if (!in_array($action, ['create', 'update'], true)) { return; } $relayFields = [ 'relay_in_id', 'relay_out_id', 'relay_machine_id', 'relay_machine_program_picker_id', 'relay_machine_cleaner_id', ]; foreach ($relayFields as $field) { if (array_key_exists($field, $data) && !($permissions['modules_shelly_config'] ?? false)) { throw new \RuntimeException('Missing permission: modules_shelly_config.'); } } } /** * @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]; $wasSelfServeEnabled = null; if (array_key_exists('selfserve_enabled', $data) && in_array('selfserve_enabled', $availableColumns, true)) { $statement = db::getPDO()->prepare( 'SELECT selfserve_enabled FROM department_lanes WHERE id = :id AND deleted_at IS NULL' ); $statement->execute([':id' => $id]); $wasSelfServeEnabled = ((int)($statement->fetch(\PDO::FETCH_ASSOC)['selfserve_enabled'] ?? 1)) === 1; } $fields = ['name', ...$this->laneOptionalFields()]; foreach ($fields as $field) { 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); if ( $wasSelfServeEnabled === true && array_key_exists(':selfserve_enabled', $params) && (int)$params[':selfserve_enabled'] === 0 ) { \objects\department_lanes_o::disableSelfServeRelaysBestEffort($id); } } private function softDeleteLane(int $departmentId, int $id): void { 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', 'selfserve_enabled', ]; } private function normalizeLaneField(string $field, mixed $value): mixed { if ($field === 'selfserve_enabled') { return \objects\department_lanes_o::normalizeSelfServeEnabledValue($value) ? 1 : 0; } if (in_array($field, ['dynamic_image_id', 'machine_type_id'], true)) { return $this->nullableInt($value); } $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 */ private function createEntity(int $departmentId, string $entity, array $data): void { $pdo = db::getPDO(); if ($entity === 'question') { $pdo->prepare( "INSERT INTO department_selfserve_questions (department, lane, product, condition_id, question, description, order_priority) VALUES (:department, :lane, :product, :condition_id, :question, :description, :order_priority)" )->execute([ ':department' => $departmentId, ':lane' => (int)($data['lane'] ?? 0), ':product' => (int)($data['product'] ?? 0), ':condition_id' => $this->nullableInt($data['condition_id'] ?? null), ':question' => (string)($data['question'] ?? $data['label'] ?? 'New question'), ':description' => (string)($data['description'] ?? ''), ':order_priority' => (int)($data['order_priority'] ?? 0), ]); return; } if ($entity === 'condition') { $pdo->prepare( "INSERT INTO department_selfserve_conditions (department, lane, product, machine_type_id, condition_id, name, description) VALUES (:department, :lane, :product, :machine_type_id, :condition_id, :name, :description)" )->execute([ ':department' => $departmentId, ':lane' => (int)($data['lane'] ?? 0), ':product' => (int)($data['product'] ?? 0), ':machine_type_id' => $this->nullableInt($data['machine_type_id'] ?? null), ':condition_id' => $this->nullableInt($data['condition_id'] ?? null), ':name' => (string)($data['name'] ?? $data['label'] ?? 'New condition'), ':description' => (string)($data['description'] ?? ''), ]); return; } if ($entity === 'rule') { $conditionId = (int)($data['condition_id'] ?? 0); if (!$this->conditionBelongsToDepartment($conditionId, $departmentId)) { throw new \RuntimeException('Rule condition_id is not available in the selected department.'); } $pdo->prepare( "INSERT INTO department_selfserve_condition_rules (condition_id, type, object_type, object_id, name, description) VALUES (:condition_id, :type, :object_type, :object_id, :name, :description)" )->execute([ ':condition_id' => $conditionId, ':type' => (string)($data['type'] ?? 'IS_TRUE'), ':object_type' => (string)($data['object_type'] ?? 'question'), ':object_id' => (int)($data['object_id'] ?? 0), ':name' => (string)($data['name'] ?? $data['label'] ?? 'New rule'), ':description' => (string)($data['description'] ?? ''), ]); return; } if ($entity === 'task') { $gateType = $this->normalizeGateType((string)($data['gate_type'] ?? selfserve_task_gate_type::ALWAYS->value)); $gateRefId = $gateType === selfserve_task_gate_type::ALWAYS->value ? null : $this->nullableInt($data['gate_ref_id'] ?? null); $pdo->prepare( "INSERT INTO department_selfserve_tasks (department, lane, product, machine_type_id, condition_id, gate_type, gate_ref_id, task, description, order_priority, services, buttons, dynamic_images_vehicle_type) VALUES (:department, :lane, :product, :machine_type_id, :condition_id, :gate_type, :gate_ref_id, :task, :description, :order_priority, :services, :buttons, :dynamic_images_vehicle_type)" )->execute([ ':department' => $departmentId, ':lane' => (int)($data['lane'] ?? 0), ':product' => (int)($data['product'] ?? 0), ':machine_type_id' => $this->nullableInt($data['machine_type_id'] ?? null), ':condition_id' => $gateType === selfserve_task_gate_type::QUESTION->value ? $gateRefId : null, ':gate_type' => $gateType, ':gate_ref_id' => $gateRefId, ':task' => (string)($data['task'] ?? $data['label'] ?? 'New task'), ':description' => (string)($data['description'] ?? ''), ':order_priority' => (int)($data['order_priority'] ?? 0), ':services' => $this->jsonArray($data['services'] ?? []), ':buttons' => $this->jsonArray($data['buttons'] ?? []), ':dynamic_images_vehicle_type' => $this->nullableInt($data['dynamic_images_vehicle_type'] ?? null), ]); return; } throw new \RuntimeException('Unsupported studio entity: ' . $entity); } /** * @param array $data */ private function updateEntity(int $departmentId, string $entity, int $id, array $data): void { $map = [ 'question' => [ 'table' => 'department_selfserve_questions', 'fields' => ['lane', 'product', 'condition_id', 'question', 'description', 'order_priority'], 'department' => 'department', ], 'condition' => [ 'table' => 'department_selfserve_conditions', 'fields' => ['lane', 'product', 'machine_type_id', 'condition_id', 'name', 'description'], 'department' => 'department', ], 'rule' => [ 'table' => 'department_selfserve_condition_rules', 'fields' => ['condition_id', 'type', 'object_type', 'object_id', 'name', 'description'], 'department' => null, ], 'task' => [ 'table' => 'department_selfserve_tasks', 'fields' => ['lane', 'product', 'machine_type_id', 'condition_id', 'gate_type', 'gate_ref_id', 'task', 'description', 'order_priority', 'services', 'buttons', 'dynamic_images_vehicle_type'], 'department' => 'department', ], ]; if (!isset($map[$entity])) { throw new \RuntimeException('Unsupported studio entity: ' . $entity); } if ($entity === 'rule' && array_key_exists('condition_id', $data) && !$this->conditionBelongsToDepartment((int)$data['condition_id'], $departmentId)) { throw new \RuntimeException('Rule condition_id is not available in the selected department.'); } $updates = []; $params = [ ':id' => $id, ]; foreach ($map[$entity]['fields'] as $field) { if (!array_key_exists($field, $data)) { continue; } $updates[] = "`$field` = :$field"; $value = $data[$field]; if (in_array($field, ['condition_id', 'machine_type_id', 'gate_ref_id', 'dynamic_images_vehicle_type'], true)) { $value = $this->nullableInt($value); } elseif (in_array($field, ['services', 'buttons'], true)) { $value = $this->jsonArray($value); } elseif ($field === 'gate_type') { $value = $this->normalizeGateType((string)$value); } $params[':' . $field] = $value; } if (isset($data['label'])) { if ($entity === 'question' && !isset($data['question'])) { $updates[] = '`question` = :label'; $params[':label'] = (string)$data['label']; } elseif ($entity === 'condition' && !isset($data['name'])) { $updates[] = '`name` = :label'; $params[':label'] = (string)$data['label']; } elseif ($entity === 'task' && !isset($data['task'])) { $updates[] = '`task` = :label'; $params[':label'] = (string)$data['label']; } } if ($updates === []) { return; } $where = 'id = :id'; if ($entity === 'rule') { $where .= " AND condition_id IN ( SELECT id FROM department_selfserve_conditions WHERE department IN (0, :department) AND deleted_at IS NULL )"; $params[':department'] = $departmentId; } elseif ($map[$entity]['department'] !== null) { $where .= ' AND `' . $map[$entity]['department'] . '` IN (0, :department)'; $params[':department'] = $departmentId; } db::getPDO()->prepare( 'UPDATE `' . $map[$entity]['table'] . '` SET ' . implode(', ', $updates) . ' WHERE ' . $where )->execute($params); } private function softDeleteEntity(int $departmentId, string $entity, int $id): void { $map = [ 'question' => ['table' => 'department_selfserve_questions', 'department' => 'department'], 'condition' => ['table' => 'department_selfserve_conditions', 'department' => 'department'], 'rule' => ['table' => 'department_selfserve_condition_rules', 'department' => null], 'task' => ['table' => 'department_selfserve_tasks', 'department' => 'department'], ]; if (!isset($map[$entity])) { throw new \RuntimeException('Unsupported studio entity: ' . $entity); } $params = [ ':id' => $id, ]; $where = 'id = :id'; if ($entity === 'rule') { $where .= " AND condition_id IN ( SELECT id FROM department_selfserve_conditions WHERE department IN (0, :department) AND deleted_at IS NULL )"; $params[':department'] = $departmentId; } elseif ($map[$entity]['department'] !== null) { $where .= ' AND `' . $map[$entity]['department'] . '` IN (0, :department)'; $params[':department'] = $departmentId; } db::getPDO()->prepare( 'UPDATE `' . $map[$entity]['table'] . '` SET deleted_at = NOW() WHERE ' . $where )->execute($params); } /** * @param array> $items */ private function applyReorder(string $entity, array $items): void { $table = match ($entity) { 'question' => 'department_selfserve_questions', 'task' => 'department_selfserve_tasks', default => null, }; if ($table === null) { throw new \RuntimeException('Only questions and tasks can be reordered.'); } $statement = db::getPDO()->prepare('UPDATE `' . $table . '` SET order_priority = :order_priority WHERE id = :id'); foreach ($items as $index => $item) { if (!is_array($item)) { continue; } $statement->execute([ ':id' => (int)($item['id'] ?? 0), ':order_priority' => (int)($item['order_priority'] ?? $index), ]); } } private function applyConnection(int $departmentId, string $source, string $target, bool $disconnect): void { [$sourceType, $sourceId] = $this->parseNodeId($source); [$targetType, $targetId] = $this->parseNodeId($target); if ($sourceType === '' || $targetType === '' || $sourceId === '') { 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->updateTaskServiceConnection($departmentId, (int)$sourceId, $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->updateTaskServiceConnection($departmentId, (int)$targetId, $service, $disconnect); return; } if ($sourceType === 'condition' && $targetType === 'question') { db::getPDO()->prepare('UPDATE department_selfserve_questions SET condition_id = :condition_id WHERE id = :id')->execute([ ':condition_id' => $disconnect ? null : (int)$sourceId, ':id' => (int)$targetId, ]); return; } if ($sourceType === 'condition' && $targetType === 'condition') { db::getPDO()->prepare('UPDATE department_selfserve_conditions SET condition_id = :condition_id WHERE id = :id')->execute([ ':condition_id' => $disconnect ? null : (int)$sourceId, ':id' => (int)$targetId, ]); return; } if (in_array($sourceType, ['condition', 'question'], true) && $targetType === 'task') { db::getPDO()->prepare('UPDATE department_selfserve_tasks SET gate_type = :gate_type, gate_ref_id = :gate_ref_id, condition_id = :legacy_question_id WHERE id = :id')->execute([ ':gate_type' => $disconnect ? selfserve_task_gate_type::ALWAYS->value : strtoupper($sourceType), ':gate_ref_id' => $disconnect ? null : (int)$sourceId, ':legacy_question_id' => (!$disconnect && $sourceType === 'question') ? (int)$sourceId : null, ':id' => (int)$targetId, ]); return; } if ($sourceType === 'condition' && $targetType === 'rule') { db::getPDO()->prepare('UPDATE department_selfserve_condition_rules SET condition_id = :condition_id WHERE id = :id')->execute([ ':condition_id' => $disconnect ? 0 : (int)$sourceId, ':id' => (int)$targetId, ]); return; } if (in_array($sourceType, ['question', 'condition', 'task'], true) && $targetType === 'rule') { db::getPDO()->prepare('UPDATE department_selfserve_condition_rules SET object_type = :object_type, object_id = :object_id WHERE id = :id')->execute([ ':object_type' => $disconnect ? '' : $sourceType, ':object_id' => $disconnect ? 0 : (int)$sourceId, ':id' => (int)$targetId, ]); return; } } /** * @param array $row * @param array $lookups * @return array */ private function scopeForRow(array $row, array $lookups): array { return [ 'department' => $this->labelFor('departments', $row['department'] ?? null, $lookups), 'lane' => $this->labelFor('lanes', $row['lane'] ?? null, $lookups), 'product' => $this->labelFor('products', $row['product'] ?? null, $lookups), 'machine_type' => $this->labelFor('machine_types', $row['machine_type_id'] ?? null, $lookups), ]; } /** * @param array $row */ private function scopeLabel(array $row, array $lookups): string { $parts = []; foreach (['lane' => 'lanes', 'product' => 'products', 'machine_type_id' => 'machine_types'] as $field => $lookupType) { $value = $this->nullableInt($row[$field] ?? null); if ($value !== null) { $parts[] = $this->labelFor($lookupType, $value, $lookups); } } return $parts === [] ? 'Shared scope' : implode(' / ', $parts); } /** * @param array $row */ private function ruleSubtitle(array $row, array $lookups): string { $objectType = strtolower((string)($row['object_type'] ?? 'object')); $objectId = (int)($row['object_id'] ?? 0); $lookupType = $objectType . 's'; $label = $objectId > 0 ? $this->labelFor($lookupType, $objectId, $lookups) : 'Unbound object'; 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)); } if (in_array($type, ['branch', 'if', 'if_else'], true)) { $branches = is_array($expression['branches'] ?? null) ? array_values((array)$expression['branches']) : []; if ($branches === []) { return 'No if/else clauses'; } $parts = []; foreach (array_slice($branches, 0, 3) as $index => $branch) { if (!is_array($branch)) { continue; } $kind = strtolower((string)($branch['kind'] ?? $branch['type'] ?? $branch['operator'] ?? ($index === 0 ? 'if' : 'else_if'))); $isElse = (bool)($branch['else'] ?? false) || in_array($kind, ['else', 'default'], true); $label = $isElse ? 'Else' : ($index === 0 ? 'If' : 'Else if'); $when = !$isElse && is_array($branch['when'] ?? null) ? $this->expressionSummary((array)$branch['when']) : ''; $then = is_array($branch['then'] ?? null) ? $this->expressionSummary((array)$branch['then']) : 'No result'; $parts[] = trim($label . ($when === '' ? '' : ' ' . $when) . ' then ' . $then); } if (count($branches) > 3) { $parts[] = '+' . (count($branches) - 3) . ' more'; } return implode('; ', $parts); } if ($type === 'case') { $subjectType = strtolower((string)($expression['subject_type'] ?? $expression['object_type'] ?? '')); $subjectId = (int)($expression['subject_id'] ?? $expression['object_id'] ?? 0); $subjectLabel = match ($subjectType) { 'question' => 'Question ' . $subjectId, 'condition' => 'Condition ' . $subjectId, default => 'Unknown subject', }; $cases = is_array($expression['cases'] ?? null) ? array_values((array)$expression['cases']) : []; if ($cases === []) { return 'Case ' . $subjectLabel . ': no clauses'; } $parts = []; foreach (array_slice($cases, 0, 3) as $case) { if (!is_array($case)) { continue; } $value = $this->caseValueLabel($case['value'] ?? null); $then = is_array($case['then'] ?? null) ? $this->expressionSummary((array)$case['then']) : 'No result'; $parts[] = $value . ' then ' . $then; } if (count($cases) > 3) { $parts[] = '+' . (count($cases) - 3) . ' more'; } return 'Case ' . $subjectLabel . ': ' . implode('; ', $parts); } $operator = strtoupper((string)($expression['operator'] ?? $expression['mode'] ?? 'ALL')); if (!in_array($operator, ['ALL', 'ANY'], true)) { $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; } if (in_array($type, ['branch', 'if', 'if_else'], true)) { foreach ((array)($expression['branches'] ?? []) as $index => $branch) { if (!is_array($branch)) { continue; } if (is_array($branch['when'] ?? null)) { $this->appendExpressionEdges($edges, $targetNodeId, $ownerConditionId, (array)$branch['when'], $path . '.b' . $index . '.when'); } if (is_array($branch['then'] ?? null)) { $this->appendExpressionEdges($edges, $targetNodeId, $ownerConditionId, (array)$branch['then'], $path . '.b' . $index . '.then'); } } if (is_array($expression['default'] ?? null)) { $this->appendExpressionEdges($edges, $targetNodeId, $ownerConditionId, (array)$expression['default'], $path . '.default'); } return; } if ($type === 'case') { $subjectType = strtolower((string)($expression['subject_type'] ?? $expression['object_type'] ?? '')); $subjectId = (int)($expression['subject_id'] ?? $expression['object_id'] ?? 0); if (in_array($subjectType, ['question', 'condition'], true) && $subjectId > 0) { $edge = $this->edge( 'expression:' . $ownerConditionId . ':' . $subjectType . ':' . $subjectId . ':' . substr(md5($path . '.case'), 0, 8), $subjectType . ':' . $subjectId, $targetNodeId, 'condition_expression', 'CASE' ); $edge['data']['subject_type'] = $subjectType; $edge['data']['subject_id'] = $subjectId; $edge['data']['condition_id'] = $ownerConditionId; $edges[] = $edge; } foreach ((array)($expression['cases'] ?? []) as $index => $case) { if (is_array($case) && is_array($case['then'] ?? null)) { $this->appendExpressionEdges($edges, $targetNodeId, $ownerConditionId, (array)$case['then'], $path . '.c' . $index . '.then'); } } if (is_array($expression['default'] ?? null)) { $this->appendExpressionEdges($edges, $targetNodeId, $ownerConditionId, (array)$expression['default'], $path . '.default'); } return; } $children = is_array($expression['children'] ?? null) ? array_values((array)$expression['children']) : []; foreach ($children as $index => $child) { 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, ]; } if (in_array($type, ['branch', 'if', 'if_else'], true)) { $branches = []; foreach ((array)($expression['branches'] ?? []) as $index => $branch) { if (!is_array($branch)) { continue; } $kind = strtolower((string)($branch['kind'] ?? $branch['type'] ?? $branch['operator'] ?? ($index === 0 ? 'if' : 'else_if'))); $isElse = (bool)($branch['else'] ?? false) || in_array($kind, ['else', 'default'], true); $normalized = [ 'kind' => $isElse ? 'else' : ($index === 0 ? 'if' : 'else_if'), 'then' => $this->normalizeExpressionNode($branch['then'] ?? $this->emptyV2Expression()), ]; if ($isElse) { $normalized['else'] = true; } else { $normalized['when'] = $this->normalizeExpressionNode($branch['when'] ?? $this->emptyV2Expression()); } $branches[] = $normalized; } $normalizedExpression = [ 'type' => 'branch', 'operator' => 'IF_ELSE', 'branches' => $branches, ]; if (is_array($expression['default'] ?? null)) { $normalizedExpression['default'] = $this->normalizeExpressionNode((array)$expression['default']); } return $normalizedExpression; } if ($type === 'case') { $subjectType = strtolower((string)($expression['subject_type'] ?? $expression['object_type'] ?? '')); if (!in_array($subjectType, ['question', 'condition'], true)) { $subjectType = 'question'; } $cases = []; foreach ((array)($expression['cases'] ?? []) as $case) { if (!is_array($case)) { continue; } $cases[] = [ 'value' => $case['value'] ?? null, 'then' => $this->normalizeExpressionNode($case['then'] ?? $this->emptyV2Expression()), ]; } $normalizedExpression = [ 'type' => 'case', 'operator' => 'CASE', 'subject_type' => $subjectType, 'subject_id' => (int)($expression['subject_id'] ?? $expression['object_id'] ?? 0), 'cases' => $cases, ]; if (is_array($expression['default'] ?? null)) { $normalizedExpression['default'] = $this->normalizeExpressionNode((array)$expression['default']); } return $normalizedExpression; } $operator = strtoupper((string)($expression['operator'] ?? $expression['mode'] ?? 'ALL')); if (!in_array($operator, ['ALL', 'ANY'], true)) { $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; } if (in_array($type, ['branch', 'if', 'if_else'], true)) { foreach ((array)($expression['branches'] ?? []) as $branch) { if (!is_array($branch)) { continue; } if (is_array($branch['when'] ?? null) && $this->expressionHasPredicate((array)$branch['when'], $subjectType, $subjectId)) { return true; } if (is_array($branch['then'] ?? null) && $this->expressionHasPredicate((array)$branch['then'], $subjectType, $subjectId)) { return true; } } return is_array($expression['default'] ?? null) && $this->expressionHasPredicate((array)$expression['default'], $subjectType, $subjectId); } if ($type === 'case') { if (strtolower((string)($expression['subject_type'] ?? '')) === $subjectType && (int)($expression['subject_id'] ?? 0) === $subjectId) { return true; } foreach ((array)($expression['cases'] ?? []) as $case) { if (is_array($case) && is_array($case['then'] ?? null) && $this->expressionHasPredicate((array)$case['then'], $subjectType, $subjectId)) { return true; } } return is_array($expression['default'] ?? null) && $this->expressionHasPredicate((array)$expression['default'], $subjectType, $subjectId); } foreach ((array)($expression['children'] ?? []) as $child) { if (is_array($child) && $this->expressionHasPredicate((array)$child, $subjectType, $subjectId)) { 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; } if (in_array(strtolower((string)($expression['type'] ?? 'group')), ['branch', 'if', 'if_else'], true)) { foreach ((array)($expression['branches'] ?? []) as $index => $branch) { if (!is_array($branch)) { continue; } if (is_array($branch['when'] ?? null)) { $expression['branches'][$index]['when'] = $this->removeExpressionPredicate((array)$branch['when'], $subjectType, $subjectId); } if (is_array($branch['then'] ?? null)) { $expression['branches'][$index]['then'] = $this->removeExpressionPredicate((array)$branch['then'], $subjectType, $subjectId); } } if (is_array($expression['default'] ?? null)) { $expression['default'] = $this->removeExpressionPredicate((array)$expression['default'], $subjectType, $subjectId); } return $expression; } if (($expression['type'] ?? 'group') === 'case') { if (strtolower((string)($expression['subject_type'] ?? '')) === $subjectType && (int)($expression['subject_id'] ?? 0) === $subjectId) { return $this->emptyV2Expression(); } foreach ((array)($expression['cases'] ?? []) as $index => $case) { if (is_array($case) && is_array($case['then'] ?? null)) { $expression['cases'][$index]['then'] = $this->removeExpressionPredicate((array)$case['then'], $subjectType, $subjectId); } } if (is_array($expression['default'] ?? null)) { $expression['default'] = $this->removeExpressionPredicate((array)$expression['default'], $subjectType, $subjectId); } return $expression; } $children = []; foreach ((array)($expression['children'] ?? []) as $child) { 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' => [], ]; } private function caseValueLabel(mixed $value): string { if ($value === true) { return 'true'; } if ($value === false) { return 'false'; } if ($value === null) { return 'unanswered'; } return (string)$value; } /** * @param array> $edges * @param array $row */ private function appendScopeEdges(array &$edges, string $targetId, array $row): void { $scopes = [ 'lane' => 'lane', 'product' => 'vehicle_type', 'machine_type_id' => 'machine_type', ]; foreach ($scopes as $field => $type) { $scopeId = $this->nullableInt($row[$field] ?? null); if ($scopeId !== null) { $edges[] = $this->edge('scope:' . $type . ':' . $scopeId . ':' . $targetId, $type . ':' . $scopeId, $targetId, 'scope', 'scope'); } } } /** * @param array $layout * @param array> $nodes * @return array> */ private function applyLayoutToNodes(array $nodes, array $layout): array { $positions = $this->extractNodePositions((array)($layout['nodes'] ?? [])); foreach ($nodes as &$node) { $id = (string)($node['id'] ?? ''); if (isset($positions[$id])) { $node['position'] = $positions[$id]; } } unset($node); return array_values($nodes); } /** * @param array $nodes * @return array */ private function extractNodePositions(array $nodes): array { $positions = []; foreach ($nodes as $key => $node) { if (is_array($node) && isset($node['id'], $node['position']) && is_array($node['position'])) { $positions[(string)$node['id']] = [ 'x' => (float)($node['position']['x'] ?? 0), 'y' => (float)($node['position']['y'] ?? 0), ]; continue; } if (is_string($key) && is_array($node)) { $positions[$key] = [ 'x' => (float)($node['x'] ?? $node['position']['x'] ?? 0), 'y' => (float)($node['y'] ?? $node['position']['y'] ?? 0), ]; } } return $positions; } /** * @param array $validation * @return array> */ private function buildValidationItems(array $validation): array { $items = []; foreach ((array)($validation['errors'] ?? []) as $message) { $items[] = [ 'severity' => 'error', 'message' => (string)$message, ]; } foreach ((array)($validation['warnings'] ?? []) as $message) { $items[] = [ 'severity' => 'warning', 'message' => (string)$message, ]; } return $items; } /** * @param array $lookups * @return 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', ]; } /** * @param array $data * @return array */ private function node(string $id, string $type, string $label, string $kind, array $data, int $x, int $y): array { $data['kind'] = $kind; $data['label'] = $label; return [ 'id' => $id, 'type' => $type, 'position' => [ 'x' => $x, 'y' => $y, ], 'data' => $data, ]; } /** * @return array */ private function edge(string $id, string $source, string $target, string $kind, string $label): array { return [ 'id' => $id, 'source' => $source, 'target' => $target, 'type' => 'smoothstep', 'label' => $label, 'data' => [ 'kind' => $kind, ], ]; } /** * @param array $row * @param array $lookups */ private function entityLabel(string $entity, int $id, array $row, array $lookups): string { $field = match ($entity) { 'question' => 'question', 'condition', 'rule' => 'name', 'task' => 'task', 'action' => 'name', default => 'label', }; $label = trim((string)($row[$field] ?? '')); if ($label !== '') { return $label; } return $this->labelFor($entity . 's', $id, $lookups); } /** * @param array $action * @param array $lookups */ private function actionSubtitle(array $action, array $lookups): string { $parts = [ selfserve_studio_actions::eventLabel((string)($action['event'] ?? '')), ucfirst((string)($action['wash_mode'] ?? selfserve_studio_actions::MODE_BOTH)), selfserve_studio_actions::operationLabel((string)($action['operation'] ?? ''), $action['relay_state'] ?? null), ]; $scope = $this->scopeLabel($action, $lookups); if ($scope !== 'Shared scope') { $parts[] = $scope; } return implode(' / ', array_filter($parts, static fn(string $part): bool => $part !== '')); } /** * @param array $lookups */ private function labelFor(string $lookupType, mixed $id, array $lookups): string { $id = $this->nullableInt($id); if ($id === null) { return 'All'; } $labels = is_array($lookups['labels'][$lookupType] ?? null) ? (array)$lookups['labels'][$lookupType] : []; return (string)($labels[(string)$id] ?? ucfirst(str_replace('_', ' ', rtrim($lookupType, 's'))) . ' ' . $id); } /** * @param array $lookups * @return array> */ private function lookupRows(array $lookups, string $type): array { return isset($lookups[$type]) && is_array($lookups[$type]) ? array_values((array)$lookups[$type]) : []; } /** * @param array> $rows * @return array> */ private function sortedRows(array $rows, array $fields): array { usort($rows, static function (array $left, array $right) use ($fields): int { foreach ($fields as $field) { $leftValue = $left[$field] ?? null; $rightValue = $right[$field] ?? null; if (is_numeric($leftValue) && is_numeric($rightValue)) { $comparison = (int)$leftValue <=> (int)$rightValue; } else { $comparison = strcmp((string)$leftValue, (string)$rightValue); } if ($comparison !== 0) { return $comparison; } } return 0; }); return array_values($rows); } /** * @param array $task * @return array */ private function normalizeTaskPayload(array $task): array { $task['services'] = $this->normalizeServiceList($task['services'] ?? []); $task['buttons'] = $this->normalizeArrayPayload($task['buttons'] ?? []); $task['attachments'] = is_array($task['attachments'] ?? null) ? array_values($task['attachments']) : []; return $task; } /** * @param array $config * @return array */ private function withTaskAttachments(array $config): array { if (!is_array($config['tasks'] ?? null)) { return $config; } $config['tasks'] = (new selfserve_task_attachment_payloads())->attachToTasks(array_values((array)$config['tasks'])); return $config; } /** * @param array $workspace * @return array> */ private function relayServicesFromWorkspace(array $workspace): array { $servicesByRelay = []; 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'] ?? '')); $service = $this->normalizeServiceName($slot['slot'] ?? $slot['role'] ?? $slot['service'] ?? ''); if ($relayId === '' || $service === '') { continue; } $servicesByRelay[$relayId][$service] = true; } } return array_map(static fn(array $services): array => array_keys($services), $servicesByRelay); } /** * @param array $binding * @param array> $relayServices * @return array */ private function bindingServices(array $binding, string $relayId, array $relayServices): array { $services = []; foreach (['role', 'service', 'slot'] as $field) { $service = $this->normalizeServiceName($binding[$field] ?? ''); if ($service !== '') { $services[$service] = true; } } foreach ($this->normalizeServiceList($binding['services'] ?? []) as $service) { $services[$service] = true; } foreach ((array)($relayServices[$relayId] ?? []) as $service) { $normalized = $this->normalizeServiceName($service); if ($normalized !== '') { $services[$normalized] = true; } } 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> */ private function gatewayBindingReferences(array $workspace): array { $relayServices = $this->relayServicesFromWorkspace($workspace); $references = []; foreach ((array)($workspace['gateways'] ?? []) as $gateway) { if (!is_array($gateway)) { continue; } $gatewayId = $this->gatewayIdentifier($gateway); if ($gatewayId === '') { continue; } foreach ((array)($gateway['bindings'] ?? []) as $bindingIndex => $binding) { if (!is_array($binding)) { continue; } $relayId = trim((string)($binding['relay_id'] ?? '')); if ($relayId === '') { continue; } $services = $this->bindingServices($binding, $relayId, $relayServices); if ($services === []) { continue; } $references[] = [ 'gateway_id' => $gatewayId, 'relay_id' => $relayId, 'binding_index' => (int)$bindingIndex, 'node_id' => $this->bindingNodeId($gatewayId, $relayId, (int)$bindingIndex, $binding), 'services' => $services, 'virtual' => (bool)($gateway['virtual'] ?? $binding['virtual'] ?? false), ]; } } return $references; } private function serviceForBindingNode(int $departmentId, string $nodeId): string { $workspace = $this->buildGatewayWorkspace($departmentId); foreach ($this->gatewayBindingReferences($workspace) as $binding) { if ((string)$binding['node_id'] === $nodeId) { return (string)($binding['services'][0] ?? ''); } } return ''; } private function updateTaskServiceConnection(int $departmentId, int $taskId, string $service, bool $disconnect): void { if ($taskId <= 0 || $service === '') { return; } $pdo = db::getPDO(); $statement = $pdo->prepare( 'SELECT services FROM department_selfserve_tasks WHERE id = :id AND department IN (0, :department) LIMIT 1' ); $statement->execute([ ':id' => $taskId, ':department' => $departmentId, ]); $row = $statement->fetch(\PDO::FETCH_ASSOC); if (!is_array($row)) { throw new \RuntimeException('Task is not available in the selected department.'); } $services = $this->normalizeServiceList($row['services'] ?? []); $serviceSet = array_fill_keys($services, true); if ($disconnect) { unset($serviceSet[$service]); } else { $serviceSet[$service] = true; } $pdo->prepare( 'UPDATE department_selfserve_tasks SET services = :services WHERE id = :id AND department IN (0, :department)' )->execute([ ':services' => $this->jsonArray(array_keys($serviceSet)), ':id' => $taskId, ':department' => $departmentId, ]); } /** * @return array */ private function normalizeArrayPayload(mixed $value): array { if (is_string($value)) { $decoded = json_decode($value, true); $value = is_array($decoded) ? $decoded : array_filter(array_map('trim', explode(',', $value)), static fn(string $item): bool => $item !== ''); } return is_array($value) ? array_values($value) : []; } /** * @return array */ private function normalizeServiceList(mixed $value): array { $services = []; foreach ($this->normalizeArrayPayload($value) as $entry) { $service = $this->normalizeServiceName($entry); if ($service !== '') { $services[$service] = true; } } return array_keys($services); } private function normalizeServiceName(mixed $value): string { return strtoupper(trim((string)$value)); } /** * @param array $answers * @return array */ private function pathAnswerOverrides(array $answers): array { ksort($answers, SORT_NUMERIC); $overrides = []; foreach ($answers as $questionId => $answer) { if ($answer !== true && $answer !== false) { continue; } $overrides[] = [ 'question_id' => (int)$questionId, 'value' => $answer, ]; } return $overrides; } /** * @param array $simulation * @param array $answers * @return array|null */ private function nextPathQuestion(array $simulation, array $answers): ?array { $debugQuestions = is_array($simulation['debug']['questions'] ?? null) ? (array)$simulation['debug']['questions'] : []; foreach ($debugQuestions as $question) { if (!is_array($question)) { continue; } $questionId = (int)($question['id'] ?? 0); if ($questionId <= 0 || array_key_exists($questionId, $answers)) { continue; } $visible = !array_key_exists('visible', $question) || (bool)$question['visible'] === true; $answer = $question['answer'] ?? null; if ($visible && $answer !== true && $answer !== false) { return $question; } } $visibleQuestions = is_array($simulation['questions'] ?? null) ? (array)$simulation['questions'] : []; foreach ($visibleQuestions as $question) { if (!is_array($question)) { continue; } $questionId = (int)($question['id'] ?? 0); if ($questionId <= 0 || array_key_exists($questionId, $answers)) { continue; } $answer = $question['answer'] ?? null; if ($answer !== true && $answer !== false) { return [ 'id' => $questionId, 'label' => (string)($question['question'] ?? ('Question ' . $questionId)), 'node_id' => 'question:' . $questionId, 'answer' => $answer, 'visible' => true, ]; } } return null; } private function pathLimit(mixed $value, int $default, int $max, int $min = 1): int { if ($value === null || $value === '') { return max($min, min($max, $default)); } $parsed = (int)$value; if ($parsed < $min) { return max($min, min($max, $default)); } return min($max, $parsed); } /** * @param array $scope * @param array> $groups * @param array> $paths * @param array $questionIds * @return array */ private function pathOutcomesProjectionPayload( array $scope, array $groups, array $paths, bool $truncated, ?int $maxStates, int $stateCount, int $terminalPathCount, array $questionIds, int $pendingStateCount, array $confirmationRows = [] ): array { $warnings = []; if ($truncated && $maxStates !== null) { $warnings[] = 'Path projection was truncated at ' . $maxStates . ' explored state(s).'; } $knownStateCount = max(1, $stateCount + $pendingStateCount); $complete = !$truncated && $pendingStateCount === 0; $percent = $complete ? 100 : min(99, max(1, (int)floor(($stateCount / $knownStateCount) * 100))); return $this->pathOutcomesPayload( $scope, $this->finalizePathOutcomeGroups($groups), $paths, $warnings, $truncated, $maxStates, $stateCount, $terminalPathCount, $questionIds, [ 'complete' => $complete, 'percent' => $percent, 'state_count' => $stateCount, 'pending_state_count' => $pendingStateCount, 'terminal_path_count' => $terminalPathCount, ], $confirmationRows ); } /** * @param array $scope * @param array> $outcomes * @param array> $paths * @param array $warnings * @param array|array $questionIds * @param array $progress * @return array */ private function pathOutcomesPayload( array $scope, array $outcomes, array $paths, array $warnings, bool $truncated, ?int $maxStates, int $stateCount, int $terminalPathCount, array $questionIds, array $progress = [], ?array $confirmationRows = null ): array { $confirmationRows = $confirmationRows ?? []; usort($outcomes, static fn(array $left, array $right): int => ((int)($right['path_count'] ?? 0) <=> (int)($left['path_count'] ?? 0)) ?: strcmp((string)($left['summary'] ?? ''), (string)($right['summary'] ?? ''))); foreach ($outcomes as $index => &$outcome) { $outcome['id'] = 'outcome-' . ($index + 1); } unset($outcome); foreach ($paths as $index => &$path) { $path['id'] = 'path-' . ($index + 1); } unset($path); $confirmations = $this->applyPathConfirmations($paths, $confirmationRows); $questionIdValues = []; foreach ($questionIds as $key => $value) { $questionIdValues[] = $value === true ? (int)$key : (int)$value; } $questionIdValues = array_values(array_unique(array_filter($questionIdValues, static fn(int $id): bool => $id > 0))); sort($questionIdValues); $progress = array_merge([ 'complete' => !$truncated, 'percent' => $truncated ? 99 : 100, 'state_count' => $stateCount, 'pending_state_count' => 0, 'terminal_path_count' => $terminalPathCount, ], $progress); return [ 'scope' => $scope, 'summary' => [ 'state_count' => $stateCount, 'terminal_path_count' => $terminalPathCount, 'outcome_count' => count($outcomes), 'question_count' => count($questionIdValues), 'question_ids' => $questionIdValues, 'max_states' => $maxStates, 'path_sample_count' => count($paths), 'confirmations' => $confirmations['summary'], ], 'outcomes' => array_values($outcomes), 'paths' => array_values($paths), 'confirmations' => $confirmations, 'warnings' => array_values(array_unique($warnings)), 'truncated' => $truncated, 'progress' => $progress, ]; } /** * @param array> $paths * @param array> $confirmationRows * @return array{summary:array,removed:array>} */ private function applyPathConfirmations(array &$paths, array $confirmationRows): array { $rowsBySignature = []; foreach ($confirmationRows as $row) { if (!is_array($row)) { continue; } $signature = trim((string)($row['path_signature'] ?? '')); if ($signature !== '') { $rowsBySignature[$signature] = $row; } } $matched = []; $summary = [ 'confirmed' => 0, 'unconfirmed' => 0, 'stale' => 0, 'removed' => 0, 'total' => count($paths), ]; foreach ($paths as &$path) { if (!is_array($path)) { continue; } $pathSignature = $this->pathSignature( is_array($path['scope'] ?? null) ? (array)$path['scope'] : [], is_array($path['answers'] ?? null) ? (array)$path['answers'] : [] ); $resultSignature = $this->pathResultSignature($path); $path['path_signature'] = $pathSignature; $path['result_signature'] = $resultSignature; $path['confirmation_status'] = 'unconfirmed'; $path['confirmed_at'] = null; $path['confirmed_by'] = null; $path['stale_reason'] = null; $row = $rowsBySignature[$pathSignature] ?? null; if (is_array($row)) { $matched[$pathSignature] = true; $path['confirmed_at'] = $row['confirmed_at'] ?? null; $path['confirmed_by'] = $row['confirmed_by'] ?? null; if ((string)($row['result_signature'] ?? '') === $resultSignature) { $path['confirmation_status'] = 'confirmed'; } else { $path['confirmation_status'] = 'stale'; $path['stale_reason'] = 'Result changed since confirmation.'; } } $summary[(string)$path['confirmation_status']]++; } unset($path); $removed = []; foreach ($rowsBySignature as $signature => $row) { if (isset($matched[$signature])) { continue; } $removed[] = [ 'id' => $row['id'] ?? null, 'path_signature' => $signature, 'result_signature' => (string)($row['result_signature'] ?? ''), 'confirmation_status' => 'stale', 'stale_reason' => 'Path no longer appears in the projected cases.', 'answers' => is_array($row['answers'] ?? null) ? $row['answers'] : [], 'result' => is_array($row['result'] ?? null) ? $row['result'] : [], 'scope' => is_array($row['scope'] ?? null) ? $row['scope'] : [], 'confirmed_at' => $row['confirmed_at'] ?? null, 'confirmed_by' => $row['confirmed_by'] ?? null, ]; } $summary['removed'] = count($removed); return [ 'summary' => $summary, 'removed' => $removed, ]; } /** * @param array> $groups * @param array $simulation * @param array> $chain * @param array $scope */ private function addPathOutcomeGroup(array &$groups, array $simulation, array $chain, array $scope, int $sampleLimit): void { $tasks = $this->pathActiveTasks($simulation); $services = $this->pathServices($simulation, $tasks); $signals = $this->pathSignals($simulation); $allowed = (bool)($simulation['allowed'] ?? false); $key = $this->stableJson([ 'scope' => $this->pathScopeKey($scope), 'allowed' => $allowed, 'services' => $services, 'tasks' => array_map(static fn(array $task): array => [ 'id' => (int)($task['id'] ?? 0), 'services' => (array)($task['services'] ?? []), ], $tasks), 'signals' => $signals, ]); if (!isset($groups[$key])) { $groups[$key] = [ 'path_count' => 0, 'allowed' => $allowed, 'services' => $services, 'tasks' => $tasks, 'signals' => $signals, 'sample_chains' => [], 'scopes' => [], 'node_ids' => [], 'summary' => $this->pathOutcomeSummary($allowed, $services, $tasks, $signals), ]; } $groups[$key]['path_count'] = (int)$groups[$key]['path_count'] + 1; $scopeKey = $this->stableJson($this->pathScopeKey($scope)); $groups[$key]['scopes'][$scopeKey] = $scope; if (count((array)$groups[$key]['sample_chains']) < $sampleLimit) { $groups[$key]['sample_chains'][] = [ 'scope' => $scope, 'answers' => array_values($chain), ]; } foreach ($this->pathNodeIds($chain, $tasks, $signals) as $nodeId) { $groups[$key]['node_ids'][$nodeId] = true; } } /** * @param array $simulation * @param array> $chain * @param array $scope * @return array */ private function pathResultFromSimulation(array $simulation, array $chain, array $scope): array { $tasks = $this->pathActiveTasks($simulation); $services = $this->pathServices($simulation, $tasks); $signals = $this->pathSignals($simulation); $allowed = (bool)($simulation['allowed'] ?? false); return [ 'id' => '', 'result' => $allowed ? 'Allowed' : 'Blocked', 'summary' => $this->pathOutcomeSummary($allowed, $services, $tasks, $signals), 'allowed' => $allowed, 'services' => $services, 'tasks' => $tasks, 'signals' => $signals, 'task_count' => count($tasks), 'signal_count' => count($signals), 'answers' => array_values($chain), 'scope' => $scope, 'node_ids' => $this->pathNodeIds($chain, $tasks, $signals), ]; } /** * @param array> $chain * @param array> $tasks * @param array> $signals * @return array */ private function pathNodeIds(array $chain, array $tasks, array $signals): array { $nodeIds = []; foreach ($chain as $answer) { if (is_array($answer) && trim((string)($answer['node_id'] ?? '')) !== '') { $nodeIds[(string)$answer['node_id']] = true; } } foreach ($tasks as $task) { if (trim((string)($task['node_id'] ?? '')) !== '') { $nodeIds[(string)$task['node_id']] = true; } } foreach ($signals as $signal) { foreach (['target_binding', 'target_relay_node_id', 'target_gateway_node_id'] as $field) { if (trim((string)($signal[$field] ?? '')) !== '') { $nodeIds[(string)$signal[$field]] = true; } } } $nodeIds = array_keys($nodeIds); sort($nodeIds); return $nodeIds; } /** * @param array> $groups * @return array> */ private function finalizePathOutcomeGroups(array $groups): array { $outcomes = []; foreach ($groups as $group) { $scopes = array_values((array)($group['scopes'] ?? [])); $nodeIds = array_values(array_keys((array)($group['node_ids'] ?? []))); sort($nodeIds); $outcomes[] = [ 'id' => '', 'summary' => (string)($group['summary'] ?? ''), 'path_count' => (int)($group['path_count'] ?? 0), 'allowed' => (bool)($group['allowed'] ?? false), 'services' => array_values((array)($group['services'] ?? [])), 'tasks' => array_values((array)($group['tasks'] ?? [])), 'signals' => array_values((array)($group['signals'] ?? [])), 'sample_chains' => array_values((array)($group['sample_chains'] ?? [])), 'scopes' => $scopes, 'node_ids' => $nodeIds, ]; } usort($outcomes, static fn(array $left, array $right): int => ((int)($right['path_count'] ?? 0) <=> (int)($left['path_count'] ?? 0)) ?: strcmp((string)($left['summary'] ?? ''), (string)($right['summary'] ?? ''))); foreach ($outcomes as $index => &$outcome) { $outcome['id'] = 'outcome-' . ($index + 1); } unset($outcome); return $outcomes; } /** * @param array $simulation * @return array> */ private function pathActiveTasks(array $simulation): array { $tasks = []; $debugTasks = is_array($simulation['debug']['tasks'] ?? null) ? (array)$simulation['debug']['tasks'] : []; foreach ($debugTasks as $task) { if (!is_array($task) || (bool)($task['active'] ?? false) !== true) { continue; } $tasks[] = [ 'id' => (int)($task['id'] ?? 0), 'node_id' => (string)($task['node_id'] ?? ('task:' . (int)($task['id'] ?? 0))), 'label' => (string)($task['label'] ?? $task['task'] ?? ('Task ' . (int)($task['id'] ?? 0))), 'services' => $this->normalizeServiceList($task['services'] ?? []), 'buttons' => $this->normalizeArrayPayload($task['buttons'] ?? []), 'order_priority' => (int)($task['order_priority'] ?? 0), ]; } if ($tasks !== []) { return $tasks; } foreach ((array)($simulation['tasks'] ?? []) as $task) { if (!is_array($task)) { continue; } $taskId = (int)($task['id'] ?? $task['task_id'] ?? 0); $tasks[] = [ 'id' => $taskId, 'node_id' => 'task:' . $taskId, 'label' => (string)($task['task'] ?? $task['label'] ?? ('Task ' . $taskId)), 'services' => $this->normalizeServiceList($task['services'] ?? []), 'buttons' => $this->normalizeArrayPayload($task['buttons'] ?? []), 'order_priority' => (int)($task['order_priority'] ?? 0), ]; } usort($tasks, static fn(array $a, array $b): int => ((int)($a['order_priority'] ?? 0) <=> (int)($b['order_priority'] ?? 0)) ?: ((int)($a['id'] ?? 0) <=> (int)($b['id'] ?? 0))); return $tasks; } /** * @param array $simulation * @param array> $tasks * @return array */ private function pathServices(array $simulation, array $tasks): array { $services = []; foreach ((array)($simulation['allowed_services'] ?? []) as $service) { $normalized = $this->normalizeServiceName($service); if ($normalized !== '') { $services[$normalized] = true; } } foreach ($tasks as $task) { foreach ($this->normalizeServiceList($task['services'] ?? []) as $service) { $services[$service] = true; } } $values = array_keys($services); sort($values); return $values; } /** * @param array $simulation * @return array> */ private function pathSignals(array $simulation): array { $timeline = is_array($simulation['debug']['signal_timeline'] ?? null) ? (array)$simulation['debug']['signal_timeline'] : (is_array($simulation['debug']['hardware']['signal_timeline'] ?? null) ? (array)$simulation['debug']['hardware']['signal_timeline'] : []); $signals = []; foreach ($timeline as $index => $signal) { if (!is_array($signal)) { continue; } $signals[] = [ 'sequence' => (int)($signal['sequence'] ?? ($index + 1)), 'runtime_stage' => (string)($signal['runtime_stage'] ?? ''), 'signal_type' => (string)($signal['signal_type'] ?? ''), 'relay_role' => (string)($signal['relay_role'] ?? ''), 'relay_id' => $signal['relay_id'] ?? null, 'target_gateway_label' => $signal['target_gateway_label'] ?? null, 'target_binding' => $signal['target_binding'] ?? null, 'target_gateway_node_id' => $signal['target_gateway_node_id'] ?? null, 'target_relay_node_id' => $signal['target_relay_node_id'] ?? null, 'source' => (string)($signal['source'] ?? ''), 'virtual' => (bool)($signal['virtual'] ?? false), 'predicted_status' => (string)($signal['predicted_status'] ?? ''), 'payload' => $this->sortStableValue(is_array($signal['payload'] ?? null) ? (array)$signal['payload'] : []), 'skip_block_reason' => $signal['skip_block_reason'] ?? null, ]; } return $signals; } /** * @param array $scope * @return array */ private function pathScopeKey(array $scope): array { return [ 'department_id' => $scope['department_id'] ?? null, 'lane_id' => $scope['lane_id'] ?? null, 'vehicle_type_id' => $scope['vehicle_type_id'] ?? null, 'machine_type_id' => $scope['machine_type_id'] ?? null, 'config_source' => $scope['config_source'] ?? null, 'config_version_id' => $scope['config_version_id'] ?? null, 'hardware_mode' => $scope['hardware_mode'] ?? null, ]; } /** * @param array $path */ private function pathResultSignature(array $path): string { return hash('sha256', $this->stableJson([ 'allowed' => (bool)($path['allowed'] ?? false), 'services' => $this->normalizeServiceList($path['services'] ?? []), 'tasks' => array_values(array_map(function (array $task): array { return [ 'id' => (int)($task['id'] ?? 0), 'label' => (string)($task['label'] ?? $task['task'] ?? ''), 'services' => $this->normalizeServiceList($task['services'] ?? []), 'buttons' => $this->normalizeArrayPayload($task['buttons'] ?? []), 'order_priority' => (int)($task['order_priority'] ?? 0), ]; }, array_values((array)($path['tasks'] ?? [])))), 'buttons' => array_values(array_reduce( array_values((array)($path['tasks'] ?? [])), function (array $carry, mixed $task): array { if (!is_array($task)) { return $carry; } foreach ($this->normalizeArrayPayload($task['buttons'] ?? []) as $button) { $key = (is_int($button) ? 'int:' : 'string:') . (string)$button; $carry[$key] = $button; } return $carry; }, [] )), 'signals' => array_values(array_map(static fn(array $signal): array => [ 'runtime_stage' => (string)($signal['runtime_stage'] ?? ''), 'signal_type' => (string)($signal['signal_type'] ?? ''), 'relay_role' => (string)($signal['relay_role'] ?? ''), 'relay_id' => $signal['relay_id'] ?? null, 'target_binding' => $signal['target_binding'] ?? null, 'source' => (string)($signal['source'] ?? ''), 'predicted_status' => (string)($signal['predicted_status'] ?? ''), 'payload' => is_array($signal['payload'] ?? null) ? (array)$signal['payload'] : [], ], array_values((array)($path['signals'] ?? [])))), ])); } /** * @return array> */ private function loadPathConfirmationRows(int $departmentId, ?int $configVersionId, int $laneId, ?int $vehicleTypeId, string $configSource): array { if (!$this->tableExists('department_selfserve_path_confirmations')) { return []; } $where = [ 'department_id = :department_id', 'lane_id = :lane_id', 'config_source = :config_source', 'deleted_at IS NULL', ]; $params = [ ':department_id' => $departmentId, ':lane_id' => $laneId, ':config_source' => $configSource, ]; if ($configVersionId === null) { $where[] = 'config_version_id IS NULL'; } else { $where[] = 'config_version_id = :config_version_id'; $params[':config_version_id'] = $configVersionId; } if ($vehicleTypeId !== null) { $where[] = 'vehicle_type_id = :vehicle_type_id'; $params[':vehicle_type_id'] = $vehicleTypeId; } $statement = db::getPDO()->prepare( 'SELECT * FROM department_selfserve_path_confirmations WHERE ' . implode(' AND ', $where) . ' ORDER BY confirmed_at DESC, id DESC' ); $statement->execute($params); $rows = $statement->fetchAll(\PDO::FETCH_ASSOC) ?: []; return array_values(array_map(function (array $row): array { $row['answers'] = $this->decodeJsonArray($row['answers_json'] ?? null); $row['result'] = $this->decodeJsonArray($row['result_json'] ?? null); $row['scope'] = $this->decodeJsonArray($row['scope_json'] ?? null); return $row; }, $rows)); } /** * @return array|array */ private function decodeJsonArray(mixed $value): array { if (is_array($value)) { return $value; } $decoded = json_decode((string)($value ?? '[]'), true); return is_array($decoded) ? $decoded : []; } /** * @param array $services * @param array> $tasks * @param array> $signals */ private function pathOutcomeSummary(bool $allowed, array $services, array $tasks, array $signals): string { $serviceLabel = $services === [] ? 'No services' : implode(', ', $services); $taskText = count($tasks) === 1 ? '1 task' : count($tasks) . ' tasks'; $signalText = count($signals) === 1 ? '1 signal' : count($signals) . ' signals'; return ($allowed ? 'Allowed' : 'Blocked') . ' / ' . $serviceLabel . ' / ' . $taskText . ' / ' . $signalText; } private function stableJson(mixed $value): string { $json = json_encode($this->sortStableValue($value), JSON_UNESCAPED_UNICODE); if ($json === false) { return ''; } return $json; } private function sortStableValue(mixed $value): mixed { if (!is_array($value)) { return $value; } $isList = array_keys($value) === range(0, count($value) - 1); if (!$isList) { ksort($value); } foreach ($value as $key => $item) { $value[$key] = $this->sortStableValue($item); } return $value; } /** * @param array> $rows * @return array> */ private function vehicleTypeRowsFromProducts(array $rows): array { $vehicleTypes = []; foreach ($this->labelRows($rows, 'name') as $row) { $productId = (int)($row['id'] ?? 0); if ($productId <= 0 || (int)($row['is_wash'] ?? 0) !== 1 || (int)($row['subscription_allowed'] ?? 0) !== 1) { continue; } $row['id'] = $productId; $row['product'] = $productId; $row['product_id'] = $productId; $row['source'] = 'products'; $vehicleTypes[] = $row; } return $vehicleTypes; } /** * @param array> $rows * @return array> */ private function labelRows(array $rows, string $labelField): array { return array_map(static function (array $row) use ($labelField): array { $row['label'] = trim((string)($row[$labelField] ?? '')) ?: (string)($row['id'] ?? ''); return $row; }, $rows); } /** * @param array> $rows * @return array> */ private function configLabelRows(array $rows, string $labelField): array { return array_map(static function (array $row) use ($labelField): array { $row['label'] = trim((string)($row[$labelField] ?? '')) ?: (string)($row['id'] ?? ''); return [ 'id' => (int)($row['id'] ?? 0), 'label' => $row['label'], 'raw' => $row, ]; }, $rows); } /** * @param array> $gateways * @return array> */ private function gatewayLabelRows(array $gateways): array { $rows = []; foreach ($gateways as $gateway) { if (is_array($gateway)) { $gatewayId = $this->gatewayIdentifier($gateway); if ($gatewayId === '') { continue; } $rows[] = [ 'id' => $gatewayId, 'label' => (string)($gateway['label'] ?? ('Gateway ' . $gatewayId)), 'status' => (string)($gateway['status'] ?? 'UNKNOWN'), 'virtual' => (bool)($gateway['virtual'] ?? false), 'raw' => $gateway, ]; } } return $rows; } /** * @param array> $relays * @return array> */ private function relayLabelRows(array $relays): array { $rows = []; foreach ($relays as $relay) { if (!is_array($relay)) { continue; } $relayId = trim((string)($relay['relay_id'] ?? $relay['id'] ?? '')); if ($relayId === '') { continue; } $rows[] = [ 'id' => $relayId, 'label' => (string)($relay['name'] ?? ('Relay ' . $relayId)), 'raw' => $relay, ]; } return $rows; } /** * @param array> $gateways * @return array> */ private function bindingLabelRows(array $gateways): array { $rows = []; foreach ($gateways as $gateway) { if (!is_array($gateway)) { continue; } $gatewayId = $this->gatewayIdentifier($gateway); foreach ((array)($gateway['bindings'] ?? []) as $index => $binding) { if (!is_array($binding)) { continue; } $relayId = trim((string)($binding['relay_id'] ?? '')); if ($gatewayId === '' || $relayId === '') { continue; } $services = $this->bindingServices($binding, $relayId, []); $rows[] = [ 'id' => $gatewayId . ':' . $relayId . ':' . $index, 'label' => (string)($binding['label'] ?? ('Gateway ' . $gatewayId . ' relay ' . $relayId)), 'gateway_id' => $gatewayId, 'relay_id' => $relayId, 'role' => (string)($binding['role'] ?? ''), 'services' => $services, 'virtual' => (bool)($gateway['virtual'] ?? $binding['virtual'] ?? false), ]; } } return $rows; } /** * @return array> */ private function fetchRows(string $table, array $columns, array $where): array { if (!$this->tableExists($table)) { return []; } $availableColumns = $this->tableColumns($table); $columns = array_values(array_filter($columns, static fn(string $column): bool => in_array($column, $availableColumns, true))); if ($columns === []) { return []; } $conditions = []; $params = []; foreach ($where as $field => $value) { if (!in_array($field, $availableColumns, true)) { continue; } $conditions[] = '`' . $field . '` = :' . $field; $params[':' . $field] = $value; } if (in_array('deleted_at', $availableColumns, true)) { $conditions[] = '`deleted_at` IS NULL'; } $sql = 'SELECT `' . implode('`, `', $columns) . '` FROM `' . $table . '`'; if ($conditions !== []) { $sql .= ' WHERE ' . implode(' AND ', $conditions); } if (in_array('order_priority', $availableColumns, true)) { $sql .= ' ORDER BY `order_priority` ASC, `id` ASC'; } elseif (in_array('id', $availableColumns, true)) { $sql .= ' ORDER BY `id` ASC'; } $statement = db::getPDO()->prepare($sql); $statement->execute($params); return $statement->fetchAll(\PDO::FETCH_ASSOC) ?: []; } private function tableExists(string $table): bool { $statement = db::getPDO()->prepare( 'SELECT COUNT(*) AS c FROM information_schema.TABLES WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = :table' ); $statement->execute([':table' => $table]); return (int)($statement->fetch(\PDO::FETCH_ASSOC)['c'] ?? 0) > 0; } /** * @return array */ private function tableColumns(string $table): array { if (isset($this->columnCache[$table])) { return $this->columnCache[$table]; } $statement = db::getPDO()->prepare( 'SELECT COLUMN_NAME FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = :table' ); $statement->execute([':table' => $table]); $this->columnCache[$table] = array_map( static fn(array $row): string => (string)$row['COLUMN_NAME'], $statement->fetchAll(\PDO::FETCH_ASSOC) ?: [] ); return $this->columnCache[$table]; } private function normalizeEntity(string $entity): string { $entity = strtolower(trim($entity)); return match ($entity) { 'questions' => 'question', 'conditions' => 'condition', 'rules' => 'rule', 'tasks' => 'task', 'actions' => 'action', 'lanes' => 'lane', 'paths' => 'path', default => $entity, }; } private function normalizeGateType(string $gateType): string { $gateType = strtoupper(trim($gateType)); return in_array($gateType, [ selfserve_task_gate_type::ALWAYS->value, selfserve_task_gate_type::CONDITION->value, selfserve_task_gate_type::QUESTION->value, ], true) ? $gateType : selfserve_task_gate_type::ALWAYS->value; } private function conditionBelongsToDepartment(int $conditionId, int $departmentId): bool { if ($conditionId <= 0) { return false; } $statement = db::getPDO()->prepare( "SELECT COUNT(*) AS c FROM department_selfserve_conditions WHERE id = :id AND department IN (0, :department) AND deleted_at IS NULL" ); $statement->execute([ ':id' => $conditionId, ':department' => $departmentId, ]); 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} */ private function parseNodeId(string $nodeId): array { $parts = explode(':', $nodeId, 2); return [ strtolower((string)($parts[0] ?? '')), (string)($parts[1] ?? ''), ]; } private function jsonArray(mixed $value): string { if (is_string($value)) { $decoded = json_decode($value, true); $value = is_array($decoded) ? $decoded : array_filter(array_map('trim', explode(',', $value))); } if (!is_array($value)) { $value = []; } $json = json_encode(array_values($value), JSON_UNESCAPED_UNICODE); if ($json === false) { throw new \RuntimeException('Failed to encode JSON array: ' . json_last_error_msg()); } return $json; } private function nullableInt(mixed $value): ?int { if ($value === null || $value === '' || $value === 'null') { return null; } $intValue = (int)$value; return $intValue <= 0 ? null : $intValue; } }