Introduce `department_selfserve_studio_layouts` table for department-specific layouts and implement advanced auto-layout functionality in the DepartmentSelfServeStudio module. Added custom node definitions, updated styling, and integrated new logics for sorting and visualizing nodes in the Vue Flow interface.
1470 lines
60 KiB
PHP
1470 lines
60 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 = [];
|
|
foreach ($this->sortedRows((array)($config['tasks'] ?? []), ['order_priority', 'id']) 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);
|
|
|
|
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
|
|
* @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
|
|
{
|
|
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;
|
|
}
|
|
$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<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((string)($operation['source'] ?? ''), (string)($operation['target'] ?? ''), false);
|
|
return;
|
|
}
|
|
if ($action === 'disconnect') {
|
|
$this->applyConnection((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(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 === '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
|
|
{
|
|
foreach (['services', 'buttons'] as $field) {
|
|
if (isset($task[$field]) && is_string($task[$field])) {
|
|
$decoded = json_decode((string)$task[$field], true);
|
|
$task[$field] = is_array($decoded) ? $decoded : [];
|
|
}
|
|
}
|
|
return $task;
|
|
}
|
|
|
|
/**
|
|
* @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;
|
|
}
|
|
$rows[] = [
|
|
'id' => $gatewayId . ':' . $relayId . ':' . $index,
|
|
'label' => (string)($binding['label'] ?? ('Gateway ' . $gatewayId . ' relay ' . $relayId)),
|
|
'gateway_id' => $gatewayId,
|
|
'relay_id' => $relayId,
|
|
];
|
|
}
|
|
}
|
|
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;
|
|
}
|
|
}
|