Files
api/services/nginx/app/modules/selfserve/classes/selfserve_studio_graph.php
T
Jeppe Bundgaard acdff75311 Add requestBooleanFlag helper and enhance session synchronization logic
- Introduce `requestBooleanFlag` method for consistent boolean parameter handling with default values.
- Add `activate_machine` and `sync_relay_state` parameters to `synchronizeSession` for more flexible relay and machine activation control.
- Update methods, routes, and tests to integrate the new session synchronization parameters effectively.
- Enhance debugging support with additional metadata in simulation and payload captures.
2026-04-28 16:54:57 +02:00

1771 lines
72 KiB
PHP

<?php
namespace modules\selfserve\classes;
require_once WD . '/classes/db.php';
require_once WD . '/classes/selfserve_schema_bootstrap.php';
require_once WD . '/modules/selfserve/classes/selfserve_config_versioning.php';
require_once WD . '/modules/selfserve/classes/selfserve_wash_flow.php';
require_once WD . '/modules/selfserve/helpers/selfserve_task_gate_type.php';
require_once WD . '/objects/department_selfserve_condition_rules_o.php';
require_once WD . '/objects/department_selfserve_conditions_o.php';
require_once WD . '/objects/department_selfserve_questions_o.php';
require_once WD . '/objects/department_selfserve_tasks_o.php';
if (is_file(WD . '/modules/edgegateway/classes/edge_gateway_department_workspace_service.php')) {
require_once WD . '/modules/edgegateway/classes/edge_gateway_department_workspace_service.php';
}
if (is_file(WD . '/modules/edgegateway/classes/edge_gateway_manager.php')) {
require_once WD . '/modules/edgegateway/classes/edge_gateway_manager.php';
}
if (is_file(WD . '/modules/edgegateway/classes/edge_gateway_operation_service.php')) {
require_once WD . '/modules/edgegateway/classes/edge_gateway_operation_service.php';
}
if (is_file(WD . '/modules/edgegateway/classes/edge_gateway_registry_service.php')) {
require_once WD . '/modules/edgegateway/classes/edge_gateway_registry_service.php';
}
if (is_file(WD . '/modules/edgegateway/classes/edge_gateway_view_service.php')) {
require_once WD . '/modules/edgegateway/classes/edge_gateway_view_service.php';
}
use classes\db;
use classes\edge_gateway_department_workspace_service;
use classes\edge_gateway_operation_service;
use classes\edge_gateway_registry_service;
use classes\edge_gateway_view_service;
use classes\selfserve_schema_bootstrap;
use modules\selfserve\helpers\selfserve_task_gate_type;
class selfserve_studio_graph
{
/** @var array<string,array<int,string>> */
private array $columnCache = [];
public function __construct()
{
selfserve_schema_bootstrap::ensureTables();
}
/**
* @param array<string,bool> $permissions
* @return array<string,mixed>
*/
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);
$graph = $this->buildGraphFromConfig($config, [
'department_id' => $departmentId,
'lookups' => $lookups,
'gateway_workspace' => $gatewayWorkspace,
], $layout);
$validation = $versioning->validateConfig($config);
$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),
'gateway_workspace' => $gatewayWorkspace,
'permissions' => $permissions,
'meta' => [
'department_id' => $departmentId,
'layout_affects_runtime' => false,
'generated_at' => date('c'),
],
];
}
/**
* Pure graph builder used by unit tests and the API serializer.
*
* @param array<string,mixed> $config
* @param array<string,mixed> $context
* @param array<string,mixed> $layout
* @return array{nodes:array<int,array<string,mixed>>,edges:array<int,array<string,mixed>>}
*/
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'] : [];
$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),
'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');
}
$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);
}
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');
}
}
}
$this->appendGatewayNodesAndEdges($nodes, $edges, $gatewayWorkspace);
$this->appendTaskServiceEdges($edges, $taskRows, $gatewayWorkspace);
return [
'nodes' => $this->applyLayoutToNodes($nodes, $layout),
'edges' => array_values($edges),
];
}
/**
* @param array<string,mixed> $payload
* @param array<string,bool> $permissions
* @return array<string,mixed>
*/
public function applyGraphSave(int $departmentId, array $payload, ?int $userId = null, array $permissions = []): array
{
$operations = isset($payload['operations']) && is_array($payload['operations']) ? (array)$payload['operations'] : [];
foreach ($operations as $operation) {
if (is_array($operation)) {
$this->applyOperation($departmentId, $operation);
}
}
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'] : [],
]);
}
(new selfserve_config_versioning())->syncDraftFromLegacyForDepartment($departmentId);
return $this->buildGraph($departmentId, $userId, $permissions);
}
/**
* @param array<string,mixed> $layout
* @return array<string,mixed>
*/
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<string,mixed>
*/
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<string,mixed> $payload
* @return array<string,mixed>
*/
public function validatePayload(int $departmentId, array $payload = []): array
{
$config = (new selfserve_config_versioning())->snapshotLegacyConfig($departmentId);
$validation = (new selfserve_config_versioning())->validateConfig($config);
$validation['items'] = $this->buildValidationItems($validation);
return $validation;
}
/**
* @param array<string,mixed> $payload
* @param array<string,bool> $permissions
* @return array<string,mixed>
*/
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->getPublishedConfig($departmentId);
$config = is_array($version['config'] ?? null) ? (array)$version['config'] : $versioning->snapshotLegacyConfig($departmentId);
$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;
$gatewayWorkspace = ($includeHardware && ($permissions['modules_shelly_config'] ?? false))
? $this->buildGatewayWorkspace($departmentId)
: [
'gateways' => [],
'relays' => [],
'lanes' => [],
'issues' => [],
'actions' => [],
'restricted' => !$includeHardware ? false : true,
];
$lookups = $this->buildLookups($departmentId, $config, $gatewayWorkspace);
$graph = $this->buildGraphFromConfig($config, [
'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,
'lookups' => $lookups,
'gateway_workspace' => $gatewayWorkspace,
'graph' => $graph,
],
);
}
/**
* @param array<string,mixed> $payload
* @return array<string,mixed>
*/
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),
};
}
/**
* @return array<string,mixed>
*/
private function buildGatewayWorkspace(int $departmentId): array
{
if (!class_exists(edge_gateway_department_workspace_service::class)) {
return [
'gateways' => [],
'relays' => [],
'lanes' => [],
'issues' => [],
'actions' => [],
'available' => false,
];
}
try {
return (new edge_gateway_department_workspace_service())->getDepartmentWorkspace($departmentId);
} catch (\Throwable $exception) {
return [
'gateways' => [],
'relays' => [],
'lanes' => [],
'issues' => [
[
'severity' => 'warning',
'message' => $exception->getMessage(),
],
],
'actions' => [],
'available' => false,
];
}
}
/**
* @param array<string,mixed> $config
* @param array<string,mixed> $gatewayWorkspace
* @return array<string,mixed>
*/
private function buildLookups(int $departmentId, array $config, array $gatewayWorkspace): array
{
$departmentRows = $this->fetchRows('departments', ['id', 'name', 'description'], ['id' => $departmentId]);
$laneRows = $this->fetchRows('department_lanes', ['id', 'department', 'name', 'machine_type_id'], ['department' => $departmentId]);
$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'], []);
$lookups = [
'departments' => $this->labelRows($departmentRows, 'name'),
'lanes' => $this->labelRows($laneRows, 'name'),
'products' => $this->labelRows($productRows, 'name'),
'machine_types' => $this->labelRows($machineTypeRows, 'name'),
'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'),
'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<int,array<string,mixed>> $nodes
* @param array<int,array<string,mixed>> $edges
* @param array<string,mixed> $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 = (int)($gateway['id'] ?? 0);
if ($gatewayId <= 0) {
continue;
}
$nodeId = 'gateway:' . $gatewayId;
$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 = 'binding:' . $gatewayId . ':' . $relayId . ':' . $bindingIndex;
$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<int,array<string,mixed>> $edges
* @param array<int,array<string,mixed>> $tasks
* @param array<string,mixed> $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<string,mixed> $operation
*/
private function applyOperation(int $departmentId, array $operation): void
{
$action = strtolower((string)($operation['action'] ?? ''));
$entity = $this->normalizeEntity((string)($operation['entity'] ?? $operation['type'] ?? ''));
$data = is_array($operation['data'] ?? null) ? (array)$operation['data'] : [];
$id = (int)($operation['id'] ?? $data['id'] ?? 0);
if ($action === 'connect') {
$this->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 ($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<string,mixed> $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<string,mixed> $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<int,array<string,mixed>> $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<string,mixed> $row
* @param array<string,mixed> $lookups
* @return array<string,mixed>
*/
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<string,mixed> $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<string,mixed> $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<int,array<string,mixed>> $edges
* @param array<string,mixed> $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<string,mixed> $layout
* @param array<int,array<string,mixed>> $nodes
* @return array<int,array<string,mixed>>
*/
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<int|string,mixed> $nodes
* @return array<string,array{x:float|int,y:float|int}>
*/
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<string,mixed> $validation
* @return array<int,array<string,mixed>>
*/
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<string,mixed> $lookups
* @return array<string,mixed>
*/
private function buildSimulatorDefaults(int $departmentId, array $lookups): array
{
$lane = $this->lookupRows($lookups, 'lanes')[0] ?? null;
$vehicleType = $this->lookupRows($lookups, 'vehicle_types')[0] ?? null;
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,
];
}
/**
* @param array<string,mixed> $data
* @return array<string,mixed>
*/
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<string,mixed>
*/
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<string,mixed> $row
* @param array<string,mixed> $lookups
*/
private function entityLabel(string $entity, int $id, array $row, array $lookups): string
{
$field = match ($entity) {
'question' => 'question',
'condition', 'rule' => 'name',
'task' => 'task',
default => 'label',
};
$label = trim((string)($row[$field] ?? ''));
if ($label !== '') {
return $label;
}
return $this->labelFor($entity . 's', $id, $lookups);
}
/**
* @param array<string,mixed> $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<string,mixed> $lookups
* @return array<int,array<string,mixed>>
*/
private function lookupRows(array $lookups, string $type): array
{
return isset($lookups[$type]) && is_array($lookups[$type]) ? array_values((array)$lookups[$type]) : [];
}
/**
* @param array<int,array<string,mixed>> $rows
* @return array<int,array<string,mixed>>
*/
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<string,mixed> $task
* @return array<string,mixed>
*/
private function normalizeTaskPayload(array $task): array
{
$task['services'] = $this->normalizeServiceList($task['services'] ?? []);
$task['buttons'] = $this->normalizeArrayPayload($task['buttons'] ?? []);
return $task;
}
/**
* @param array<string,mixed> $workspace
* @return array<string,array<int,string>>
*/
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<string,mixed> $binding
* @param array<string,array<int,string>> $relayServices
* @return array<int,string>
*/
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<string,mixed> $workspace
* @return array<int,array<string,mixed>>
*/
private function gatewayBindingReferences(array $workspace): array
{
$relayServices = $this->relayServicesFromWorkspace($workspace);
$references = [];
foreach ((array)($workspace['gateways'] ?? []) as $gateway) {
if (!is_array($gateway)) {
continue;
}
$gatewayId = (int)($gateway['id'] ?? 0);
if ($gatewayId <= 0) {
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' => 'binding:' . $gatewayId . ':' . $relayId . ':' . $bindingIndex,
'services' => $services,
];
}
}
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<int,mixed>
*/
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<int,string>
*/
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<int,array<string,mixed>> $rows
* @return array<int,array<string,mixed>>
*/
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<int,array<string,mixed>> $rows
* @return array<int,array<string,mixed>>
*/
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<int,array<string,mixed>> $rows
* @return array<int,array<string,mixed>>
*/
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<int,array<string,mixed>> $gateways
* @return array<int,array<string,mixed>>
*/
private function gatewayLabelRows(array $gateways): array
{
$rows = [];
foreach ($gateways as $gateway) {
if (is_array($gateway) && (int)($gateway['id'] ?? 0) > 0) {
$rows[] = [
'id' => (int)$gateway['id'],
'label' => (string)($gateway['label'] ?? ('Gateway ' . $gateway['id'])),
'status' => (string)($gateway['status'] ?? 'UNKNOWN'),
];
}
}
return $rows;
}
/**
* @param array<int|string,array<string,mixed>> $relays
* @return array<int,array<string,mixed>>
*/
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<int,array<string,mixed>> $gateways
* @return array<int,array<string,mixed>>
*/
private function bindingLabelRows(array $gateways): array
{
$rows = [];
foreach ($gateways as $gateway) {
if (!is_array($gateway)) {
continue;
}
$gatewayId = (int)($gateway['id'] ?? 0);
foreach ((array)($gateway['bindings'] ?? []) as $index => $binding) {
if (!is_array($binding)) {
continue;
}
$relayId = trim((string)($binding['relay_id'] ?? ''));
if ($gatewayId <= 0 || $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,
];
}
}
return $rows;
}
/**
* @return array<int,array<string,mixed>>
*/
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<int,string>
*/
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',
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;
}
/**
* @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;
}
}