false, 'error' => 'Nicht eingeloggt.']); exit; } header('Content-Type: application/json; charset=utf-8'); define('VISU_ASSET_UPLOAD_DIR', dirname(__DIR__) . '/uploads/visu_assets'); define('VISU_ASSET_UPLOAD_URL', '/uploads/visu_assets'); define('VISU_BG_UPLOAD_DIR', dirname(__DIR__) . '/uploads/visu_backgrounds'); define('VISU_BG_UPLOAD_URL', '/uploads/visu_backgrounds'); define('VISU_INFLUX_URL', 'http://10.99.99.6:8086'); define('VISU_EXPORT_STORAGE_ROOT', rtrim((string)(getenv('VISU_EXPORT_ROOT') ?: '/var/portal_storage'), '/')); function visuJson(array $data, int $status = 200): void { http_response_code($status); echo json_encode($data, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); exit; } function visuCleanString($value): ?string { $value = trim((string)$value); return $value === '' ? null : $value; } function visuSlug(string $value): string { $value = trim(mb_strtolower($value)); $value = preg_replace('/[^a-z0-9äöüß]+/iu', '-', $value); $value = trim((string)$value, '-'); return $value !== '' ? mb_substr($value, 0, 180) : 'visu'; } function visuUserGroups(PDO $pdo, string $username, array $sessionGroups = []): array { $groups = is_array($sessionGroups) ? $sessionGroups : []; if (!empty($groups)) { return array_values(array_unique(array_filter(array_map('trim', $groups), fn($x) => $x !== ''))); } if ($username === '') { return []; } $stmt = $pdo->prepare("SELECT `groups` FROM users WHERE username = ? LIMIT 1"); $stmt->execute([$username]); $raw = (string)($stmt->fetchColumn() ?? ''); if ($raw === '') { return []; } $parts = array_map('trim', explode('|', $raw)); return array_values(array_unique(array_filter($parts, fn($x) => $x !== ''))); } function visuVisibilitySql(int $userId, bool $canViewAll, string $projectExpr, array &$params, string $prefix): string { if ($canViewAll) { return '1=1'; } $params[":{$prefix}_user_id"] = $userId; $params[":{$prefix}_role_user_id"] = $userId; return "( EXISTS ( SELECT 1 FROM project_assignments pa WHERE pa.project_id = {$projectExpr} AND pa.user_id = :{$prefix}_user_id ) OR EXISTS ( SELECT 1 FROM project_groups pg JOIN roles r ON r.key_name = pg.group_name JOIN user_roles ur ON ur.role_id = r.id WHERE pg.project_id = {$projectExpr} AND ur.user_id = :{$prefix}_role_user_id ) )"; } function visuProjectVisible(PDO $pdo, int $projectId, int $userId, bool $canViewAll): bool { if ($projectId <= 0) { return false; } $params = [':project_id' => $projectId]; $visSql = visuVisibilitySql($userId, $canViewAll, 'p.id', $params, 'project_visible'); $stmt = $pdo->prepare(" SELECT 1 FROM projects p WHERE p.id = :project_id AND {$visSql} LIMIT 1 "); $stmt->execute($params); return (bool)$stmt->fetchColumn(); } function visuPageVisible(PDO $pdo, int $pageId, int $userId, bool $canViewAll): ?array { if ($pageId <= 0) { return null; } $params = [':page_id' => $pageId]; $visSql = visuVisibilitySql($userId, $canViewAll, 'vp.project_id', $params, 'page_visible'); $stmt = $pdo->prepare(" SELECT vp.*, p.project_number, p.year, p.name AS project_name, pc.company_name AS customer_name FROM visu_pages vp INNER JOIN projects p ON p.id = vp.project_id LEFT JOIN project_customers pc ON pc.id = p.customer_id WHERE vp.id = :page_id AND vp.is_active = 1 AND {$visSql} LIMIT 1 "); $stmt->execute($params); $page = $stmt->fetch(PDO::FETCH_ASSOC); return $page ?: null; } function visuEnsureDir(string $dir): void { if (!is_dir($dir)) { @mkdir($dir, 0775, true); } if (!is_dir($dir) || !is_writable($dir)) { visuJson([ 'ok' => false, 'error' => 'Upload-Ordner ist nicht beschreibbar: ' . $dir, ], 500); } } function visuSaveUploadedImage(string $fieldName, string $dir, string $urlBase): ?string { if (empty($_FILES[$fieldName]) || !is_array($_FILES[$fieldName])) { return null; } $file = $_FILES[$fieldName]; if (($file['error'] ?? UPLOAD_ERR_NO_FILE) === UPLOAD_ERR_NO_FILE) { return null; } if (($file['error'] ?? UPLOAD_ERR_OK) !== UPLOAD_ERR_OK) { visuJson(['ok' => false, 'error' => 'Uploadfehler bei ' . $fieldName], 400); } $tmp = (string)($file['tmp_name'] ?? ''); $name = (string)($file['name'] ?? ''); if (!is_uploaded_file($tmp)) { visuJson(['ok' => false, 'error' => 'Ungültiger Upload.'], 400); } $ext = strtolower(pathinfo($name, PATHINFO_EXTENSION)); $allowed = ['png', 'jpg', 'jpeg', 'webp', 'svg']; if (!in_array($ext, $allowed, true)) { visuJson(['ok' => false, 'error' => 'Nur PNG, JPG, JPEG, WEBP oder SVG erlaubt.'], 400); } visuEnsureDir($dir); $stored = date('Ymd_His') . '_' . bin2hex(random_bytes(10)) . '.' . $ext; $target = rtrim($dir, '/') . '/' . $stored; if (!move_uploaded_file($tmp, $target)) { visuJson(['ok' => false, 'error' => 'Datei konnte nicht gespeichert werden.'], 500); } @chmod($target, 0664); return rtrim($urlBase, '/') . '/' . $stored; } function visuSaveUploadedImageFile(array $file, string $dir, string $urlBase): ?array { if (($file['error'] ?? UPLOAD_ERR_NO_FILE) === UPLOAD_ERR_NO_FILE) { return null; } if (($file['error'] ?? UPLOAD_ERR_OK) !== UPLOAD_ERR_OK) { visuJson(['ok' => false, 'error' => 'Uploadfehler bei Bild.'], 400); } $tmp = (string)($file['tmp_name'] ?? ''); $name = (string)($file['name'] ?? ''); if (!is_uploaded_file($tmp)) { visuJson(['ok' => false, 'error' => 'Ungueltiger Upload.'], 400); } $ext = strtolower(pathinfo($name, PATHINFO_EXTENSION)); $allowed = ['png', 'jpg', 'jpeg', 'webp', 'svg']; if (!in_array($ext, $allowed, true)) { visuJson(['ok' => false, 'error' => 'Nur PNG, JPG, JPEG, WEBP oder SVG erlaubt.'], 400); } visuEnsureDir($dir); $stored = date('Ymd_His') . '_' . bin2hex(random_bytes(10)) . '.' . $ext; $target = rtrim($dir, '/') . '/' . $stored; if (!move_uploaded_file($tmp, $target)) { visuJson(['ok' => false, 'error' => 'Datei konnte nicht gespeichert werden.'], 500); } @chmod($target, 0664); return [ 'url' => rtrim($urlBase, '/') . '/' . $stored, 'original_name' => $name, 'mime_type' => (string)($file['type'] ?? ''), 'file_size' => (int)($file['size'] ?? 0), ]; } function visuImageLibraryRegister(PDO $pdo, string $url, ?string $originalName, ?string $mimeType, ?int $fileSize, ?int $projectId, int $userId): void { $url = trim($url); if ($url === '') { return; } try { $stmt = $pdo->prepare(" INSERT INTO visu_image_library (project_id, file_url, original_name, mime_type, file_size, created_by, is_active) VALUES (?, ?, ?, ?, ?, ?, 1) ON DUPLICATE KEY UPDATE project_id = COALESCE(VALUES(project_id), project_id), original_name = COALESCE(VALUES(original_name), original_name), mime_type = COALESCE(VALUES(mime_type), mime_type), file_size = COALESCE(VALUES(file_size), file_size), is_active = 1 "); $stmt->execute([$projectId, $url, $originalName, $mimeType, $fileSize, $userId]); } catch (Throwable $e) { // Die Bilderbibliothek ist optional bis die SQL-Tabelle angelegt wurde. } } function visuImageLibraryAllowed(PDO $pdo, string $url, int $projectId, int $userId, bool $canViewAll): bool { $url = trim($url); if ($url === '' || !str_starts_with($url, rtrim(VISU_ASSET_UPLOAD_URL, '/') . '/')) { return false; } try { $stmt = $pdo->prepare(" SELECT project_id FROM visu_image_library WHERE file_url = ? AND is_active = 1 LIMIT 1 "); $stmt->execute([$url]); $row = $stmt->fetch(PDO::FETCH_ASSOC); if (!$row) { return false; } $imageProjectId = (int)($row['project_id'] ?? 0); if ($imageProjectId <= 0) { return true; } if ($projectId > 0 && $imageProjectId === $projectId) { return true; } return visuProjectVisible($pdo, $imageProjectId, $userId, $canViewAll); } catch (Throwable $e) { return false; } } function visuDeleteAssetImageIfUnused(PDO $pdo, ?string $url, int $ignoreStateId = 0): void { $url = trim((string)$url); if ($url === '') { return; } try { $stmt = $pdo->prepare("SELECT COUNT(*) FROM visu_image_library WHERE file_url = ? AND is_active = 1"); $stmt->execute([$url]); if ((int)$stmt->fetchColumn() > 0) { return; } } catch (Throwable $e) { // Ohne Bibliothekstabelle weiter wie bisher. } try { if ($ignoreStateId > 0) { $stmt = $pdo->prepare("SELECT COUNT(*) FROM visu_asset_states WHERE image_file = ? AND id <> ?"); $stmt->execute([$url, $ignoreStateId]); } else { $stmt = $pdo->prepare("SELECT COUNT(*) FROM visu_asset_states WHERE image_file = ?"); $stmt->execute([$url]); } if ((int)$stmt->fetchColumn() > 0) { return; } } catch (Throwable $e) { } visuDeleteUploadedFile($url, VISU_ASSET_UPLOAD_URL, VISU_ASSET_UPLOAD_DIR); } function visuDeleteUploadedFile(?string $url, string $urlBase, string $dir): void { $url = trim((string)$url); if ($url === '') { return; } $prefix = rtrim($urlBase, '/') . '/'; if (!str_starts_with($url, $prefix)) { return; } $file = basename($url); $path = rtrim($dir, '/') . '/' . $file; $base = realpath($dir); $real = realpath($path); if ($base && $real && strncmp($real, $base, strlen($base)) === 0 && is_file($real)) { @unlink($real); } } function visuCallMainApi(string $fn, string $externalId, array $extra = []): array { global $mainApiKey; $apiKey = (string)($mainApiKey ?? ''); if ($apiKey === '') { return ['ok' => false, 'error' => 'mainApiKey fehlt in config.php']; } $data = array_merge([ 'api_key' => $apiKey, 'function' => $fn, 'id' => $externalId, ], $extra); $ch = curl_init('https://main.api.se-inno.de'); curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); curl_setopt($ch, CURLOPT_POST, true); curl_setopt($ch, CURLOPT_POSTFIELDS, $data); curl_setopt($ch, CURLOPT_TIMEOUT, 12); $resp = curl_exec($ch); $err = curl_error($ch); curl_close($ch); if ($err) { return ['ok' => false, 'error' => $err]; } $decoded = json_decode((string)$resp, true); return is_array($decoded) ? $decoded : ['ok' => false, 'error' => 'upstream bad json', 'raw' => (string)$resp]; } function visuPointLabel(array $p, int $ispNumber, string $type): string { $group = trim((string)($p['group'] ?? '')); $label = trim((string)($p['label'] ?? '')); $key = trim((string)($p['key'] ?? '')); $id = (int)($p['id'] ?? 0); if ($label === '' || in_array(mb_strtolower($label), ['value', 'wert'], true)) { $label = $key !== '' ? $key : ('Variable ' . $id); } return trim(implode(' · ', array_filter([ 'ISP ' . $ispNumber, strtoupper($type), $group, $label, ]))); } function visuPointSourceKey(array $point): string { return implode('|', [ (string)($point['type'] ?? ''), (string)($point['external_id'] ?? ''), (string)($point['variable_id'] ?? ''), (string)($point['point_key'] ?? ''), ]); } function visuPointHasDisplayValue(array $point): bool { foreach (['value', 'value_formatted'] as $key) { if (array_key_exists($key, $point) && trim((string)$point[$key]) !== '') { return true; } } return false; } function visuNormalizeScalarValue($value): ?string { if (is_bool($value)) { return $value ? '1' : '0'; } if (is_int($value) || is_float($value) || is_string($value)) { return trim((string)$value); } return null; } function visuPickFirstScalar(array $payload, array $keys): ?string { foreach ($keys as $key) { if (array_key_exists($key, $payload)) { $value = visuNormalizeScalarValue($payload[$key]); if ($value !== null && $value !== '') { return $value; } } } foreach ($payload as $value) { if (is_array($value)) { $nested = visuPickFirstScalar($value, $keys); if ($nested !== null && $nested !== '') { return $nested; } } } return null; } function visuObjectIdFromValue($value): int { $scalar = visuNormalizeScalarValue($value); if ($scalar === null || $scalar === '') { return 0; } $clean = str_replace(['.', ',', ' '], '', $scalar); if (!preg_match('/^\d+$/', $clean)) { return 0; } $id = (int)$clean; return $id > 1000 ? $id : 0; } function visuNormalizeAlarmValue(?string $value, bool $allowObjectIds = false): ?string { if ($value === null) { return null; } $clean = trim($value); if ($clean === '') { return null; } $lower = mb_strtolower($clean); if (in_array($lower, ['ok', 'aus', 'off', 'false', 'nein', 'no', 'normal'], true)) { return '0'; } if (in_array($lower, ['störung', 'stoerung', 'alarm', 'ein', 'on', 'true', 'ja', 'yes', 'active', 'aktiv'], true)) { return '1'; } if (preg_match('/-?\d+(?:[.,]\d+)?/', $clean, $match)) { $numeric = str_replace(',', '.', $match[0]); if (!$allowObjectIds && abs((float)$numeric) > 1) { return null; } return $numeric; } return $clean; } function visuAlarmValueFromResponse(array $response): ?string { $value = visuPickFirstScalar($response, ['value_raw', 'raw_value', 'raw', 'value', 'Value', 'val', 'result', 'formatted', 'state']); return visuNormalizeAlarmValue($value); } function visuAlarmValueFromPointPayload(array $point): ?string { $value = visuPickFirstScalar($point, ['value_raw', 'raw_value', 'raw', 'value', 'Value', 'val', 'result', 'value_formatted', 'formatted', 'state']); return visuNormalizeAlarmValue($value); } function visuMatchingAlarmPoint(array $points, array $needle): ?array { $needleKey = (string)($needle['point_key'] ?? ''); $needleGroup = (string)($needle['group'] ?? ''); $needleLabel = (string)($needle['raw_label'] ?? ''); foreach ($points as $point) { if (!is_array($point)) { continue; } if ($needleKey !== '' && (string)($point['key'] ?? '') === $needleKey) { return $point; } if ( $needleGroup !== '' && $needleLabel !== '' && (string)($point['group'] ?? '') === $needleGroup && (string)($point['label'] ?? '') === $needleLabel ) { return $point; } } return null; } function visuHydrateAlarmPointValue(array $point): array { $variableId = (int)($point['variable_id'] ?? 0); $alarmMasterId = visuObjectIdFromValue($point['value'] ?? null) ?: visuObjectIdFromValue($point['value_formatted'] ?? null) ?: visuObjectIdFromValue($point['alarm_master_id'] ?? null); if ($variableId <= 0 && $alarmMasterId <= 0) { return $point; } $rawValue = null; $sourceResponse = null; if ($alarmMasterId > 0) { $alarmList = visuCallMainApi('getalarms', (string)$alarmMasterId); $alarmPoints = $alarmList['points'] ?? $alarmList['alarms'] ?? []; if (is_array($alarmPoints)) { $match = visuMatchingAlarmPoint($alarmPoints, $point); if (is_array($match)) { $candidate = visuAlarmValueFromPointPayload($match); if ($candidate !== null && $candidate !== '') { $rawValue = $candidate; $sourceResponse = $match; $point['alarm_value_id'] = (int)($match['id'] ?? 0) ?: null; $point['alarm_master_id'] = $alarmMasterId; } } } if ($rawValue === null || $rawValue === '') { $valueResponse = visuCallMainApi('getvalue', (string)$alarmMasterId); $candidate = is_array($valueResponse) ? visuAlarmValueFromResponse($valueResponse) : null; if ($candidate !== null && $candidate !== '') { $rawValue = $candidate; $sourceResponse = $valueResponse; $point['alarm_value_id'] = $alarmMasterId; } } } if ($rawValue === null || $rawValue === '') { $fallback = visuNormalizeAlarmValue(visuNormalizeScalarValue($point['value'] ?? $point['value_formatted'] ?? null)); if ($fallback === null || $fallback === '') { if ($alarmMasterId > 0) { $point['value'] = null; $point['value_formatted'] = null; $point['quality'] = 'unresolved'; $point['alarm_master_id'] = $alarmMasterId; } return $point; } $rawValue = $fallback; } $point['value'] = $rawValue; $point['value_formatted'] = $rawValue; $quality = is_array($sourceResponse) ? visuPickFirstScalar($sourceResponse, ['quality', 'Quality']) : null; if ($quality !== null && $quality !== '') { $point['quality'] = $quality; } $age = is_array($sourceResponse) ? visuPickFirstScalar($sourceResponse, ['age_s', 'age', 'Age']) : null; if ($age !== null && $age !== '') { $point['age_s'] = $age; } return $point; } function visuSourceKeyFromRef(array $ref): string { return implode('|', [ (string)($ref['type'] ?? $ref['variable_type'] ?? ''), (string)($ref['external_id'] ?? ''), (string)($ref['variable_id'] ?? ''), (string)($ref['point_key'] ?? ''), ]); } function visuCollectAlarmRefsFromValue($value, array &$sourceKeys, array &$variableIds): void { if (!is_array($value)) { return; } $type = (string)($value['type'] ?? $value['variable_type'] ?? ''); if ($type === 'alarm') { $sourceKey = visuSourceKeyFromRef($value); if ($sourceKey !== '|||') { $sourceKeys[$sourceKey] = true; } $variableId = (int)($value['variable_id'] ?? 0); if ($variableId > 0) { $variableIds[(string)$variableId] = true; } } foreach ($value as $item) { visuCollectAlarmRefsFromValue($item, $sourceKeys, $variableIds); } } function visuCollectPageAlarmRefs(PDO $pdo, int $pageId): array { $sourceKeys = []; $variableIds = []; $stmt = $pdo->prepare(" SELECT style_json, state_rules FROM visu_elements WHERE page_id = ? "); $stmt->execute([$pageId]); foreach ($stmt->fetchAll(PDO::FETCH_ASSOC) as $row) { foreach (['style_json', 'state_rules'] as $column) { $decoded = json_decode((string)($row[$column] ?? ''), true); if (is_array($decoded)) { visuCollectAlarmRefsFromValue($decoded, $sourceKeys, $variableIds); } } } $stmt = $pdo->prepare(" SELECT r.variable_type, r.external_id, r.variable_id, r.point_key FROM visu_element_state_rules r INNER JOIN visu_elements e ON e.id = r.element_id WHERE e.page_id = ? AND r.variable_type = 'alarm' "); $stmt->execute([$pageId]); foreach ($stmt->fetchAll(PDO::FETCH_ASSOC) as $row) { $sourceKey = visuSourceKeyFromRef($row); if ($sourceKey !== '|||') { $sourceKeys[$sourceKey] = true; } $variableId = (int)($row['variable_id'] ?? 0); if ($variableId > 0) { $variableIds[(string)$variableId] = true; } } return [ 'source_keys' => $sourceKeys, 'variable_ids' => $variableIds, ]; } function visuShouldHydrateAlarmPoint(array $point, array $alarmRefs): bool { if (($point['type'] ?? '') !== 'alarm') { return false; } $sourceKey = (string)($point['source_key'] ?? visuPointSourceKey($point)); $variableId = (string)($point['variable_id'] ?? ''); return isset($alarmRefs['source_keys'][$sourceKey]) || ($variableId !== '' && isset($alarmRefs['variable_ids'][$variableId])); } function visuLoadAssetStatesForAssets(PDO $pdo, array $assetIds): array { $assetIds = array_values(array_unique(array_filter(array_map('intval', $assetIds), fn($id) => $id > 0))); if (empty($assetIds)) { return []; } $ph = implode(',', array_fill(0, count($assetIds), '?')); $stmt = $pdo->prepare(" SELECT * FROM visu_asset_states WHERE asset_id IN ($ph) ORDER BY asset_id ASC, is_default DESC, sort_order ASC, id ASC "); $stmt->execute($assetIds); $statesByAsset = []; foreach ($stmt->fetchAll(PDO::FETCH_ASSOC) as $row) { $statesByAsset[(int)$row['asset_id']][] = $row; } return $statesByAsset; } function visuGetProjectPoints(PDO $pdo, int $projectId, bool $hydrateAlarmValues = false): array { $stmt = $pdo->prepare(" SELECT isp_number, external_id, notes FROM project_isps WHERE project_id = ? ORDER BY isp_number ASC "); $stmt->execute([$projectId]); $isps = $stmt->fetchAll(PDO::FETCH_ASSOC); $allPoints = []; foreach ($isps as $isp) { $externalId = trim((string)($isp['external_id'] ?? '')); if (!preg_match('/^\d{5}$/', $externalId)) { continue; } foreach ([ 'getanalog' => ['type' => 'analog', 'writable' => false], 'getdigital' => ['type' => 'digital', 'writable' => false], 'getalarms' => ['type' => 'alarm', 'writable' => false], 'getsetpoints' => ['type' => 'setpoint', 'writable' => true], ] as $fn => $meta) { $upstream = visuCallMainApi($fn, $externalId); $points = $upstream['points'] ?? $upstream['alarms'] ?? $upstream['setpoints'] ?? []; if (!is_array($points)) { continue; } foreach ($points as $p) { if (!is_array($p)) { continue; } $key = trim((string)($p['key'] ?? '')); $variableId = (int)($p['id'] ?? $p['variable_id'] ?? 0); $alarmMasterId = $meta['type'] === 'alarm' ? ( visuObjectIdFromValue($p['value_raw'] ?? null) ?: visuObjectIdFromValue($p['value'] ?? null) ?: visuObjectIdFromValue($p['value_formatted'] ?? $p['formatted'] ?? null) ) : 0; if ($key === '' && $variableId <= 0) { continue; } $point = [ 'type' => $meta['type'], 'writable' => (bool)$meta['writable'], 'external_id' => $externalId, 'isp_number' => (int)$isp['isp_number'], 'variable_id' => $variableId > 0 ? $variableId : null, 'point_key' => $key, 'label' => visuPointLabel($p, (int)$isp['isp_number'], $meta['type']), 'raw_label' => (string)($p['label'] ?? ''), 'group' => (string)($p['group'] ?? ''), 'unit' => (string)($p['unit'] ?? ''), 'value' => $p['value'] ?? $p['value_raw'] ?? null, 'value_formatted' => $p['value_formatted'] ?? $p['formatted'] ?? $p['value_raw'] ?? $p['value'] ?? null, 'quality' => $p['quality'] ?? null, 'age_s' => $p['age_s'] ?? null, 'alarm_master_id' => $alarmMasterId > 0 ? $alarmMasterId : null, ]; if ($hydrateAlarmValues && $meta['type'] === 'alarm') { $point = visuHydrateAlarmPointValue($point); } $point['source_key'] = visuPointSourceKey($point); $allPoints[] = $point; } } } return $allPoints; } function visuBuildLiveValueMap(array $points): array { $values = []; foreach ($points as $point) { if (!is_array($point)) { continue; } $sourceKey = (string)($point['source_key'] ?? ''); if ($sourceKey !== '') { $values[$sourceKey] = $point; } if (!empty($point['variable_id'])) { $variableKey = (string)$point['variable_id']; if ( !isset($values[$variableKey]) || !visuPointHasDisplayValue($values[$variableKey]) || visuPointHasDisplayValue($point) ) { $values[$variableKey] = $point; } } } return $values; } function visuVirtualPointsTableReady(PDO $pdo): bool { static $ready = null; if ($ready !== null) { return $ready; } try { $pdo->query("SELECT 1 FROM visu_virtual_points LIMIT 1"); $ready = true; } catch (Throwable $e) { $ready = false; } return $ready; } function visuVirtualPointSourceKey(int $projectId, string $pointKey): string { return implode('|', ['virtual', 'vdp:' . $projectId, '', $pointKey]); } function visuVirtualPointNumericValue($value): ?float { if ($value === null || $value === '') { return null; } if (is_bool($value)) { return $value ? 1.0 : 0.0; } if (is_int($value) || is_float($value)) { return (float)$value; } $text = trim((string)$value); if ($text === '') { return null; } $text = str_replace(',', '.', $text); if (preg_match('/-?\d+(?:\.\d+)?/', $text, $match)) { return (float)$match[0]; } return null; } function visuVirtualPointFormat(?float $value, int $decimals, string $unit = ''): ?string { if ($value === null) { return null; } $precision = max(0, min(8, $decimals)); $formatted = number_format($value, $precision, ',', '.'); $unit = trim($unit); return $unit !== '' ? ($formatted . ' ' . $unit) : $formatted; } function visuVirtualPointRows(PDO $pdo, int $projectId): array { if (!visuVirtualPointsTableReady($pdo)) { return []; } try { $stmt = $pdo->prepare(" SELECT * FROM visu_virtual_points WHERE project_id = ? AND is_active = 1 ORDER BY sort_order ASC, name ASC, id ASC "); $stmt->execute([$projectId]); return $stmt->fetchAll(PDO::FETCH_ASSOC); } catch (Throwable $e) { return []; } } function visuVirtualPointRefFromToken(array $token): ?array { $type = (string)($token['type'] ?? ''); if ($type !== 'point' && !is_array($token['ref'] ?? null)) { return null; } $ref = is_array($token['ref'] ?? null) ? $token['ref'] : []; foreach (['type', 'variable_type', 'external_id', 'variable_id', 'point_key', 'point_label', 'label', 'source_key'] as $key) { if (array_key_exists($key, $token) && !array_key_exists($key, $ref)) { $ref[$key] = $token[$key]; } } return !empty($ref) ? $ref : null; } function visuVirtualPointTokensToRpn(array $tokens): array { $output = []; $stack = []; $precedence = ['+' => 1, '-' => 1, '*' => 2, '/' => 2]; foreach ($tokens as $token) { if (!is_array($token)) { continue; } $type = (string)($token['type'] ?? ''); if ($type === 'number') { $value = visuVirtualPointNumericValue($token['value'] ?? null); if ($value === null) { continue; } $output[] = ['kind' => 'number', 'value' => $value]; continue; } if ($type === 'point') { $ref = visuVirtualPointRefFromToken($token); if ($ref) { $output[] = ['kind' => 'point', 'ref' => $ref]; } continue; } if ($type !== 'operator') { continue; } $value = trim((string)($token['value'] ?? '')); if ($value === '') { continue; } if ($value === '(') { $stack[] = $value; continue; } if ($value === ')') { while (!empty($stack) && end($stack) !== '(') { $output[] = ['kind' => 'operator', 'value' => array_pop($stack)]; } if (!empty($stack) && end($stack) === '(') { array_pop($stack); } continue; } if (!isset($precedence[$value])) { continue; } while (!empty($stack)) { $top = end($stack); if ($top === '(' || !isset($precedence[$top]) || $precedence[$top] < $precedence[$value]) { break; } $output[] = ['kind' => 'operator', 'value' => array_pop($stack)]; } $stack[] = $value; } while (!empty($stack)) { $operator = array_pop($stack); if ($operator === '(' || $operator === ')') { continue; } $output[] = ['kind' => 'operator', 'value' => $operator]; } return $output; } function visuEvaluateVirtualPointRow(array $row, array &$context): array { $pointKey = trim((string)($row['point_key'] ?? '')); if ($pointKey === '') { return ['ok' => false, 'value' => null, 'error' => 'VDP ohne Punkt-Key.']; } if (isset($context['cache'][$pointKey])) { return $context['cache'][$pointKey]; } if (!empty($context['visiting'][$pointKey])) { return ['ok' => false, 'value' => null, 'error' => 'Zyklische VDP-Referenz: ' . $pointKey]; } $context['visiting'][$pointKey] = true; $tokens = json_decode((string)($row['formula_json'] ?? '[]'), true); if (!is_array($tokens)) { unset($context['visiting'][$pointKey]); return ['ok' => false, 'value' => null, 'error' => 'Ungültige Formel.']; } $rpn = visuVirtualPointTokensToRpn($tokens); if (empty($rpn)) { unset($context['visiting'][$pointKey]); $result = ['ok' => false, 'value' => null, 'error' => 'Leere Formel.']; $context['cache'][$pointKey] = $result; return $result; } $stack = []; foreach ($rpn as $token) { if (($token['kind'] ?? '') === 'number') { $stack[] = (float)$token['value']; continue; } if (($token['kind'] ?? '') === 'point') { $ref = is_array($token['ref'] ?? null) ? $token['ref'] : null; if (!$ref) { unset($context['visiting'][$pointKey]); return ['ok' => false, 'value' => null, 'error' => 'Ungültige VDP-Quelle.']; } $refType = (string)($ref['type'] ?? $ref['variable_type'] ?? ''); $numericValue = null; if ($refType === 'virtual' || str_starts_with((string)($ref['external_id'] ?? ''), 'vdp:')) { $virtualPointKey = trim((string)($ref['point_key'] ?? '')); $virtualRow = $virtualPointKey !== '' ? ($context['rows'][$virtualPointKey] ?? null) : null; if (!$virtualRow) { unset($context['visiting'][$pointKey]); return ['ok' => false, 'value' => null, 'error' => 'VDP-Quelle nicht gefunden: ' . $virtualPointKey]; } $virtualResult = visuEvaluateVirtualPointRow($virtualRow, $context); if (empty($virtualResult['ok'])) { unset($context['visiting'][$pointKey]); return ['ok' => false, 'value' => null, 'error' => (string)($virtualResult['error'] ?? 'VDP-Fehler')]; } $numericValue = $virtualResult['value']; } else { $sourceKey = !empty($ref['source_key']) ? (string)$ref['source_key'] : visuSourceKeyFromRef($ref); $point = $context['points'][$sourceKey] ?? null; if (!$point && !empty($ref['variable_id'])) { $point = $context['points'][(string)$ref['variable_id']] ?? null; } $numericValue = $point ? visuVirtualPointNumericValue($point['value'] ?? $point['value_formatted'] ?? null) : null; } if ($numericValue === null) { unset($context['visiting'][$pointKey]); return ['ok' => false, 'value' => null, 'error' => 'Quelle ohne numerischen Wert.']; } $stack[] = $numericValue; continue; } if (($token['kind'] ?? '') === 'operator') { if (count($stack) < 2) { unset($context['visiting'][$pointKey]); return ['ok' => false, 'value' => null, 'error' => 'Unvollständige Formel.']; } $right = array_pop($stack); $left = array_pop($stack); switch ((string)($token['value'] ?? '')) { case '+': $stack[] = $left + $right; break; case '-': $stack[] = $left - $right; break; case '*': $stack[] = $left * $right; break; case '/': if ((float)$right === 0.0) { unset($context['visiting'][$pointKey]); return ['ok' => false, 'value' => null, 'error' => 'Division durch 0.']; } $stack[] = $left / $right; break; default: unset($context['visiting'][$pointKey]); return ['ok' => false, 'value' => null, 'error' => 'Unbekannter Operator.']; } } } unset($context['visiting'][$pointKey]); if (count($stack) !== 1) { $result = ['ok' => false, 'value' => null, 'error' => 'Formel konnte nicht ausgewertet werden.']; $context['cache'][$pointKey] = $result; return $result; } $result = ['ok' => true, 'value' => (float)$stack[0], 'error' => '']; $context['cache'][$pointKey] = $result; return $result; } function visuGetProjectVirtualPoints(PDO $pdo, int $projectId, array $basePoints): array { $rows = visuVirtualPointRows($pdo, $projectId); if (empty($rows)) { return []; } $rowsByPointKey = []; foreach ($rows as $row) { $pointKey = trim((string)($row['point_key'] ?? '')); if ($pointKey !== '') { $rowsByPointKey[$pointKey] = $row; } } $context = [ 'rows' => $rowsByPointKey, 'points' => visuBuildLiveValueMap($basePoints), 'cache' => [], 'visiting' => [], ]; $virtualPoints = []; foreach ($rows as $row) { $pointKey = trim((string)($row['point_key'] ?? '')); if ($pointKey === '') { continue; } $result = visuEvaluateVirtualPointRow($row, $context); $value = !empty($result['ok']) ? $result['value'] : null; $unit = trim((string)($row['unit'] ?? '')); $decimals = (int)($row['decimals'] ?? 2); $virtualPoints[] = [ 'type' => 'virtual', 'writable' => false, 'external_id' => 'vdp:' . $projectId, 'isp_number' => 0, 'variable_id' => null, 'point_key' => $pointKey, 'label' => trim((string)($row['name'] ?? $pointKey)), 'raw_label' => trim((string)($row['name'] ?? $pointKey)), 'group' => 'Virtuelle Datenpunkte', 'unit' => $unit, 'value' => $value, 'value_formatted' => visuVirtualPointFormat($value, $decimals, $unit), 'quality' => !empty($result['ok']) ? 'virtual' : 'error', 'age_s' => 0, 'source_key' => visuVirtualPointSourceKey($projectId, $pointKey), 'description' => trim((string)($row['description'] ?? '')), 'virtual_error' => empty($result['ok']) ? (string)($result['error'] ?? 'VDP Fehler') : '', ]; } return $virtualPoints; } function visuGetProjectPointsWithVirtual(PDO $pdo, int $projectId, bool $hydrateAlarmValues = false): array { $points = visuGetProjectPoints($pdo, $projectId, $hydrateAlarmValues); $virtualPoints = visuGetProjectVirtualPoints($pdo, $projectId, $points); return array_merge($points, $virtualPoints); } function visuAllowedInfluxDatabases(PDO $pdo, int $userId, bool $canViewAll): array { if ($canViewAll) { $stmt = $pdo->query(" SELECT DISTINCT database_name FROM role_influx_databases WHERE database_name <> '' ORDER BY database_name ASC "); return array_values(array_filter(array_map('strval', $stmt->fetchAll(PDO::FETCH_COLUMN)))); } $stmt = $pdo->prepare(" SELECT DISTINCT rid.database_name FROM role_influx_databases rid INNER JOIN user_roles ur ON ur.role_id = rid.role_id WHERE ur.user_id = ? AND rid.database_name <> '' ORDER BY rid.database_name ASC "); $stmt->execute([$userId]); return array_values(array_filter(array_map('strval', $stmt->fetchAll(PDO::FETCH_COLUMN)))); } function visuInfluxIdentifier(string $value): string { return '"' . str_replace(['\\', '"'], ['\\\\', '\\"'], $value) . '"'; } function visuInfluxString(string $value): string { return "'" . str_replace(['\\', "'"], ['\\\\', "\\'"], $value) . "'"; } function visuInfluxQuery(string $database, string $query): array { $url = rtrim(VISU_INFLUX_URL, '/') . '/query?' . http_build_query([ 'db' => $database, 'q' => $query, ]); $ch = curl_init($url); curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); curl_setopt($ch, CURLOPT_TIMEOUT, 12); $resp = curl_exec($ch); $err = curl_error($ch); $status = (int)curl_getinfo($ch, CURLINFO_RESPONSE_CODE); curl_close($ch); if ($err) { return ['ok' => false, 'error' => $err]; } $decoded = json_decode((string)$resp, true); if (!is_array($decoded)) { return ['ok' => false, 'error' => 'Influx liefert kein JSON.', 'raw' => (string)$resp]; } if ($status >= 400) { return ['ok' => false, 'error' => $decoded['error'] ?? ('Influx HTTP ' . $status)]; } $result = $decoded['results'][0] ?? []; if (!empty($result['error'])) { return ['ok' => false, 'error' => (string)$result['error']]; } return ['ok' => true, 'result' => $result]; } function visuInfluxSeriesValues(array $response): array { $series = $response['result']['series'][0] ?? null; if (!$series || empty($series['values']) || !is_array($series['values'])) { return []; } return $series['values']; } function visuInfluxListMeasurements(string $database): array { $response = visuInfluxQuery($database, 'SHOW MEASUREMENTS'); if (empty($response['ok'])) { return []; } return array_values(array_filter(array_map( fn($row) => (string)($row[0] ?? ''), visuInfluxSeriesValues($response) ))); } function visuInfluxListTrendPoints(string $database): array { $points = []; $measurements = visuInfluxListMeasurements($database); foreach ($measurements as $measurement) { $tagKeyResponse = visuInfluxQuery($database, 'SHOW TAG KEYS FROM ' . visuInfluxIdentifier($measurement)); if (empty($tagKeyResponse['ok'])) { continue; } foreach (visuInfluxSeriesValues($tagKeyResponse) as $tagKeyRow) { $tagKey = (string)($tagKeyRow[0] ?? ''); if ($tagKey === '') { continue; } $tagValueResponse = visuInfluxQuery( $database, 'SHOW TAG VALUES FROM ' . visuInfluxIdentifier($measurement) . ' WITH KEY = ' . visuInfluxIdentifier($tagKey) ); if (empty($tagValueResponse['ok'])) { continue; } foreach (visuInfluxSeriesValues($tagValueResponse) as $tagValueRow) { $value = (string)($tagValueRow[1] ?? $tagValueRow[0] ?? ''); if ($value === '') { continue; } $points[] = [ 'database' => $database, 'measurement' => $measurement, 'tag_key' => $tagKey, 'tag_value' => $value, 'label' => $measurement . ' · ' . $value, 'source_key' => implode('|', ['influx', $database, $measurement, $tagKey, $value]), ]; } } } usort($points, fn($a, $b) => strcmp($a['label'], $b['label'])); return $points; } function visuTrendRange(string $range): array { $ranges = [ '15m' => ['sql' => '15m', 'seconds' => 900], '1h' => ['sql' => '1h', 'seconds' => 3600], '6h' => ['sql' => '6h', 'seconds' => 21600], '12h' => ['sql' => '12h', 'seconds' => 43200], '24h' => ['sql' => '24h', 'seconds' => 86400], '7d' => ['sql' => '7d', 'seconds' => 604800], '30d' => ['sql' => '30d', 'seconds' => 2592000], '90d' => ['sql' => '90d', 'seconds' => 7776000], '180d' => ['sql' => '180d', 'seconds' => 15552000], ]; return $ranges[$range] ?? $ranges['24h']; } function visuTrendCustomRange($start, $end): ?array { if ($start === null || $end === null || $start === '' || $end === '') { return null; } $startTs = is_numeric($start) ? ((float)$start / 1000) : strtotime((string)$start); $endTs = is_numeric($end) ? ((float)$end / 1000) : strtotime((string)$end); if (!$startTs || !$endTs) { return null; } if ($endTs < $startTs) { [$startTs, $endTs] = [$endTs, $startTs]; } if (($endTs - $startTs) < 1) { return null; } return [ 'sql' => sprintf( "time >= '%s' AND time <= '%s'", gmdate('Y-m-d\TH:i:s\Z', (int)$startTs), gmdate('Y-m-d\TH:i:s\Z', (int)$endTs) ), 'seconds' => max(1, (int)round($endTs - $startTs)), 'start' => gmdate('c', (int)$startTs), 'end' => gmdate('c', (int)$endTs), ]; } function visuTrendInterval(string $interval, int $rangeSeconds): string { $allowed = ['10s', '30s', '1m', '5m', '10m', '15m', '30m', '1h', '6h', '12h', '1d']; if ($interval !== 'auto' && in_array($interval, $allowed, true)) { return $interval; } if ($rangeSeconds <= 900) return '10s'; if ($rangeSeconds <= 3600) return '30s'; if ($rangeSeconds <= 21600) return '5m'; if ($rangeSeconds <= 86400) return '10m'; if ($rangeSeconds <= 604800) return '1h'; return '6h'; } function visuTrendAggregate(string $aggregate): string { return in_array($aggregate, ['mean', 'min', 'max', 'last'], true) ? $aggregate : 'mean'; } function visuInfluxReadTrendSeries(array $series, array $allowedDatabases, string $range, string $interval, string $aggregate, $start = null, $end = null): array { $customRange = visuTrendCustomRange($start, $end); $rangeInfo = $customRange ?: visuTrendRange($range); $bucket = visuTrendInterval($interval, (int)$rangeInfo['seconds']); $agg = visuTrendAggregate($aggregate); $out = []; foreach (array_slice($series, 0, 8) as $index => $item) { if (!is_array($item)) { continue; } $database = (string)($item['database'] ?? ''); $measurement = (string)($item['measurement'] ?? ''); $tagKey = (string)($item['tag_key'] ?? ''); $tagValue = (string)($item['tag_value'] ?? ''); if ($database === '' || $measurement === '' || $tagKey === '' || $tagValue === '') { continue; } if (!in_array($database, $allowedDatabases, true)) { continue; } $query = sprintf( 'SELECT %s("value") FROM %s WHERE %s = %s AND %s GROUP BY time(%s) fill(null)', $agg, visuInfluxIdentifier($measurement), visuInfluxIdentifier($tagKey), visuInfluxString($tagValue), $customRange ? $rangeInfo['sql'] : 'time >= now() - ' . $rangeInfo['sql'], $bucket ); $response = visuInfluxQuery($database, $query); $values = []; if (!empty($response['ok'])) { foreach (visuInfluxSeriesValues($response) as $row) { $values[] = [ 'time' => (string)($row[0] ?? ''), 'value' => $row[1] ?? null, ]; } } $out[] = [ 'id' => (string)($item['id'] ?? ('series-' . $index)), 'label' => (string)($item['label'] ?? $tagValue), 'color' => (string)($item['color'] ?? '#38bdf8'), 'database' => $database, 'measurement' => $measurement, 'tag_key' => $tagKey, 'tag_value' => $tagValue, 'values' => $values, 'error' => empty($response['ok']) ? (string)($response['error'] ?? 'Influx Fehler') : '', ]; } return [ 'range' => $customRange ? 'custom' : $range, 'interval' => $bucket, 'aggregate' => $agg, 'start' => $customRange['start'] ?? null, 'end' => $customRange['end'] ?? null, 'series' => $out, ]; } function visuInfluxReadLatestValues(array $series, array $allowedDatabases): array { $out = []; foreach (array_slice($series, 0, 64) as $index => $item) { if (!is_array($item)) { continue; } $database = (string)($item['database'] ?? ''); $measurement = (string)($item['measurement'] ?? ''); $tagKey = (string)($item['tag_key'] ?? ''); $tagValue = (string)($item['tag_value'] ?? ''); if ($database === '' || $measurement === '' || $tagKey === '' || $tagValue === '') { continue; } if (!in_array($database, $allowedDatabases, true)) { continue; } $query = sprintf( 'SELECT last("value") FROM %s WHERE %s = %s', visuInfluxIdentifier($measurement), visuInfluxIdentifier($tagKey), visuInfluxString($tagValue) ); $response = visuInfluxQuery($database, $query); $value = null; if (!empty($response['ok'])) { $rows = visuInfluxSeriesValues($response); $value = $rows[0][1] ?? null; } $out[] = [ 'id' => (string)($item['id'] ?? ('series-' . $index)), 'database' => $database, 'measurement' => $measurement, 'tag_key' => $tagKey, 'tag_value' => $tagValue, 'value' => $value, 'error' => empty($response['ok']) ? (string)($response['error'] ?? 'Influx Fehler') : '', ]; } return $out; } function visuCsvDelimiter($value): string { $value = (string)$value; return in_array($value, [',', ';', "\t"], true) ? $value : ';'; } function visuCsvExportKind(string $value): string { return in_array($value, ['trend_csv', 'lastvalues_csv'], true) ? $value : 'trend_csv'; } function visuCsvScheduleKind(string $value): string { return in_array($value, ['interval', 'daily', 'weekly', 'monthly'], true) ? $value : 'daily'; } function visuCsvTimeOfDay(string $value): string { $value = trim($value); if (!preg_match('/^(\d{2}):(\d{2})$/', $value, $m)) { return '00:00'; } $hour = max(0, min(23, (int)$m[1])); $minute = max(0, min(59, (int)$m[2])); return sprintf('%02d:%02d', $hour, $minute); } function visuCsvWeekdays(string $value): string { $items = array_filter(array_map('trim', explode(',', $value)), static fn($item) => $item !== ''); $days = []; foreach ($items as $item) { $day = (int)$item; if ($day >= 1 && $day <= 7) { $days[] = $day; } } $days = array_values(array_unique($days)); sort($days, SORT_NUMERIC); return implode(',', $days); } function visuCsvIntervalMinutes($value): int { return max(1, min(525600, (int)$value)); } function visuCsvMonthDay($value): int { return max(1, min(31, (int)$value)); } function visuCsvRetentionDays($value): int { return max(1, min(3650, (int)$value)); } function visuCsvTargetDir(string $value): string { $value = str_replace('\\', '/', trim($value)); $parts = array_filter(explode('/', $value), static function ($part) { return $part !== '' && $part !== '.' && $part !== '..'; }); $clean = []; foreach ($parts as $part) { $cleanPart = preg_replace('/[^a-zA-Z0-9._-]+/', '_', $part); $cleanPart = trim((string)$cleanPart, '._-'); if ($cleanPart !== '') { $clean[] = $cleanPart; } } return !empty($clean) ? implode('/', $clean) : 'csv_exports'; } function visuCsvFilenamePattern(string $value): string { $value = trim($value); return $value !== '' ? mb_substr($value, 0, 255) : '{selection}_{yyyy}-{mm}-{dd}_{hh}-{ii}-{ss}.csv'; } function visuCsvNextRunAt( string $scheduleKind, int $intervalMinutes, string $timeOfDay, string $weekdays, int $monthDay, ?DateTimeImmutable $now = null ): string { $now = $now ?: new DateTimeImmutable('now'); [$hour, $minute] = array_map('intval', explode(':', visuCsvTimeOfDay($timeOfDay))); $scheduleKind = visuCsvScheduleKind($scheduleKind); if ($scheduleKind === 'interval') { return $now->modify('+' . visuCsvIntervalMinutes($intervalMinutes) . ' minutes')->format('Y-m-d H:i:s'); } if ($scheduleKind === 'daily') { $candidate = $now->setTime($hour, $minute, 0); if ($candidate <= $now) { $candidate = $candidate->modify('+1 day'); } return $candidate->format('Y-m-d H:i:s'); } if ($scheduleKind === 'weekly') { $days = array_filter(array_map('intval', explode(',', visuCsvWeekdays($weekdays))), static fn($day) => $day >= 1 && $day <= 7); if (empty($days)) { $days = [(int)$now->format('N')]; } for ($offset = 0; $offset <= 14; $offset++) { $candidateDay = $now->modify('+' . $offset . ' days'); if (!in_array((int)$candidateDay->format('N'), $days, true)) { continue; } $candidate = $candidateDay->setTime($hour, $minute, 0); if ($candidate > $now) { return $candidate->format('Y-m-d H:i:s'); } } } if ($scheduleKind === 'monthly') { $monthDay = visuCsvMonthDay($monthDay); $base = $now; for ($i = 0; $i < 14; $i++) { $monthStart = $base->modify('first day of this month')->setTime($hour, $minute, 0); $daysInMonth = (int)$monthStart->format('t'); $day = min($monthDay, $daysInMonth); $candidate = $monthStart->setDate( (int)$monthStart->format('Y'), (int)$monthStart->format('m'), $day ); if ($candidate > $now) { return $candidate->format('Y-m-d H:i:s'); } $base = $base->modify('first day of next month'); } } $fallback = $now->modify('+1 day')->setTime($hour, $minute, 0); return $fallback->format('Y-m-d H:i:s'); } function visuCsvExportJobsTableReady(PDO $pdo): bool { try { $pdo->query('SELECT 1 FROM visu_csv_export_jobs LIMIT 1'); return true; } catch (Throwable $e) { return false; } } function visuCsvExportFilesTableReady(PDO $pdo): bool { try { $pdo->query('SELECT 1 FROM visu_csv_export_files LIMIT 1'); return true; } catch (Throwable $e) { return false; } } function visuCsvExportAbsolutePath(string $relativePath): string { $relativePath = str_replace('\\', '/', trim($relativePath)); $relativePath = preg_replace('#/+#', '/', $relativePath); $relativePath = ltrim((string)$relativePath, '/'); return VISU_EXPORT_STORAGE_ROOT . '/' . $relativePath; } $username = (string)($_SESSION['username'] ?? ''); $userId = resolveUserId($pdo, (int)($_SESSION['user_id'] ?? 0), $username); $groups = visuUserGroups($pdo, $username, $_SESSION['groups'] ?? []); $action = (string)($_GET['action'] ?? $_POST['action'] ?? ''); $canViewViewer = hasPermission($pdo, $userId, 'page.visu.viewer.view'); $canViewAll = hasPermission($pdo, $userId, 'page.visu.viewer.viewall'); $canViewDesigner = hasPermission($pdo, $userId, 'page.visu.designer.view'); $canEditDesigner = hasPermission($pdo, $userId, 'page.visu.designer.edit'); $canManageAssets = hasPermission($pdo, $userId, 'page.visu.assets.manage'); $canEditAssets = $canManageAssets || hasPermission($pdo, $userId, 'page.visu.assets.edit'); $canDeleteAssets = $canManageAssets || hasPermission($pdo, $userId, 'page.visu.assets.delete'); $canWriteSetpoints = hasPermission($pdo, $userId, 'page.visu.setpoints.write'); if (!$canViewViewer && !$canViewDesigner) { visuJson(['ok' => false, 'error' => 'Kein Zugriff auf SE-Cloud Vis.'], 403); } if ($action === 'projects') { $params = []; $visSql = visuVisibilitySql($userId, $canViewAll, 'p.id', $params, 'projects'); $stmt = $pdo->prepare(" SELECT p.id, p.year, p.project_number, p.name, pc.company_name AS customer_name FROM projects p LEFT JOIN project_customers pc ON pc.id = p.customer_id WHERE {$visSql} AND EXISTS ( SELECT 1 FROM project_isps pi WHERE pi.project_id = p.id ) ORDER BY p.year DESC, p.project_number ASC, p.name ASC "); $stmt->execute($params); visuJson([ 'ok' => true, 'projects' => $stmt->fetchAll(PDO::FETCH_ASSOC), ]); } if ($action === 'virtual_points') { $projectId = (int)($_GET['project_id'] ?? 0); if ($projectId <= 0) { visuJson(['ok' => false, 'error' => 'Projekt fehlt.'], 400); } if (!visuProjectVisible($pdo, $projectId, $userId, $canViewAll)) { visuJson(['ok' => false, 'error' => 'Projekt nicht sichtbar.'], 403); } if (!visuVirtualPointsTableReady($pdo)) { visuJson([ 'ok' => false, 'error' => 'Tabelle visu_virtual_points fehlt. Bitte SQL-Migration ausfuehren.', ], 500); } $rows = visuVirtualPointRows($pdo, $projectId); foreach ($rows as &$row) { $formula = json_decode((string)($row['formula_json'] ?? '[]'), true); $row['formula'] = is_array($formula) ? $formula : []; unset($row['formula_json']); } unset($row); visuJson([ 'ok' => true, 'virtual_points' => $rows, 'can_edit' => $canEditDesigner, ]); } if ($action === 'virtual_point_save') { if (!$canEditDesigner) { visuJson(['ok' => false, 'error' => 'Kein Recht zum Speichern.'], 403); } if (!visuVirtualPointsTableReady($pdo)) { visuJson([ 'ok' => false, 'error' => 'Tabelle visu_virtual_points fehlt. Bitte SQL-Migration ausfuehren.', ], 500); } $pointId = (int)($_POST['point_id'] ?? 0); $projectId = (int)($_POST['project_id'] ?? 0); $name = trim((string)($_POST['name'] ?? '')); $pointKey = trim((string)($_POST['point_key'] ?? '')); $description = visuCleanString($_POST['description'] ?? ''); $unit = trim((string)($_POST['unit'] ?? '')); $decimals = max(0, min(8, (int)($_POST['decimals'] ?? 2))); $sortOrder = (int)($_POST['sort_order'] ?? 100); $formulaRaw = (string)($_POST['formula_json'] ?? '[]'); $formula = json_decode($formulaRaw, true); if ($projectId <= 0 || $name === '') { visuJson(['ok' => false, 'error' => 'Projekt oder Name fehlt.'], 400); } if (!visuProjectVisible($pdo, $projectId, $userId, $canViewAll)) { visuJson(['ok' => false, 'error' => 'Projekt nicht sichtbar.'], 403); } if ($pointKey === '') { $pointKey = visuSlug($name); } else { $pointKey = visuSlug($pointKey); } if ($pointKey === '') { visuJson(['ok' => false, 'error' => 'Punkt-Key fehlt.'], 400); } if (!is_array($formula)) { visuJson(['ok' => false, 'error' => 'Ungültige Formel.'], 400); } $formulaJson = json_encode($formula, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); try { $check = $pdo->prepare(" SELECT id FROM visu_virtual_points WHERE project_id = ? AND point_key = ? AND id <> ? LIMIT 1 "); $check->execute([$projectId, $pointKey, $pointId]); if ($check->fetchColumn()) { visuJson(['ok' => false, 'error' => 'Punkt-Key ist bereits vergeben.'], 400); } if ($pointId > 0) { $stmt = $pdo->prepare(" UPDATE visu_virtual_points SET project_id = ?, point_key = ?, name = ?, description = ?, unit = ?, decimals = ?, sort_order = ?, formula_json = ?, is_active = 1, updated_at = CURRENT_TIMESTAMP WHERE id = ? "); $stmt->execute([ $projectId, $pointKey, $name, $description, $unit, $decimals, $sortOrder, $formulaJson, $pointId, ]); visuJson(['ok' => true, 'point_id' => $pointId]); } $stmt = $pdo->prepare(" INSERT INTO visu_virtual_points ( project_id, point_key, name, description, unit, decimals, sort_order, formula_json, created_by, is_active ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 1) "); $stmt->execute([ $projectId, $pointKey, $name, $description, $unit, $decimals, $sortOrder, $formulaJson, $userId, ]); visuJson(['ok' => true, 'point_id' => (int)$pdo->lastInsertId()]); } catch (Throwable $e) { visuJson(['ok' => false, 'error' => $e->getMessage()], 500); } } if ($action === 'virtual_point_delete') { if (!$canEditDesigner) { visuJson(['ok' => false, 'error' => 'Kein Recht zum Loeschen.'], 403); } if (!visuVirtualPointsTableReady($pdo)) { visuJson([ 'ok' => false, 'error' => 'Tabelle visu_virtual_points fehlt. Bitte SQL-Migration ausfuehren.', ], 500); } $pointId = (int)($_POST['point_id'] ?? 0); if ($pointId <= 0) { visuJson(['ok' => false, 'error' => 'VDP fehlt.'], 400); } $stmt = $pdo->prepare(" UPDATE visu_virtual_points SET is_active = 0, updated_at = CURRENT_TIMESTAMP WHERE id = ? "); $stmt->execute([$pointId]); visuJson(['ok' => true]); } if ($action === 'project_values') { $projectId = (int)($_GET['project_id'] ?? 0); if ($projectId <= 0) { visuJson(['ok' => false, 'error' => 'Projekt fehlt.'], 400); } if (!visuProjectVisible($pdo, $projectId, $userId, $canViewAll)) { visuJson(['ok' => false, 'error' => 'Projekt nicht sichtbar.'], 403); } visuJson([ 'ok' => true, 'values' => visuGetProjectPointsWithVirtual($pdo, $projectId, true), 'can_write_setpoints' => $canWriteSetpoints, ]); } if ($action === 'trend_sources') { $allowedDatabases = visuAllowedInfluxDatabases($pdo, $userId, $canViewAll); $database = visuCleanString($_GET['database'] ?? '') ?: ($allowedDatabases[0] ?? ''); if (empty($allowedDatabases)) { visuJson([ 'ok' => true, 'databases' => [], 'selected_database' => '', 'points' => [], ]); } if (!in_array($database, $allowedDatabases, true)) { visuJson(['ok' => false, 'error' => 'Keine Berechtigung fuer diese Influx-Datenbank.'], 403); } visuJson([ 'ok' => true, 'databases' => $allowedDatabases, 'selected_database' => $database, 'points' => visuInfluxListTrendPoints($database), ]); } if ($action === 'trend_data') { $seriesRaw = (string)($_POST['series_json'] ?? $_GET['series_json'] ?? '[]'); $range = (string)($_POST['range'] ?? $_GET['range'] ?? '24h'); $interval = (string)($_POST['interval'] ?? $_GET['interval'] ?? 'auto'); $aggregate = (string)($_POST['aggregate'] ?? $_GET['aggregate'] ?? 'mean'); $start = $_POST['start'] ?? $_GET['start'] ?? null; $end = $_POST['end'] ?? $_GET['end'] ?? null; $series = json_decode($seriesRaw, true); if (!is_array($series)) { visuJson(['ok' => false, 'error' => 'Ungueltige Trend-Serien.'], 400); } $allowedDatabases = visuAllowedInfluxDatabases($pdo, $userId, $canViewAll); $data = visuInfluxReadTrendSeries($series, $allowedDatabases, $range, $interval, $aggregate, $start, $end); visuJson([ 'ok' => true, 'data' => $data, ]); } if ($action === 'influx_latest_values') { $seriesRaw = (string)($_POST['series_json'] ?? $_GET['series_json'] ?? '[]'); $series = json_decode($seriesRaw, true); if (!is_array($series)) { visuJson(['ok' => false, 'error' => 'Ungueltige Influx-Serien.'], 400); } $allowedDatabases = visuAllowedInfluxDatabases($pdo, $userId, $canViewAll); $data = visuInfluxReadLatestValues($series, $allowedDatabases); visuJson([ 'ok' => true, 'series' => $data, ]); } if ($action === 'monitoring_selections') { try { $stmt = $pdo->prepare(" SELECT id, name, database_name, range_value, interval_value, aggregate_value, delimiter_value, points_json, updated_at FROM visu_monitoring_selections WHERE user_id = ? AND is_active = 1 ORDER BY updated_at DESC, name ASC "); $stmt->execute([$userId]); $rows = $stmt->fetchAll(PDO::FETCH_ASSOC); } catch (Throwable $e) { visuJson(['ok' => false, 'error' => 'Tabelle visu_monitoring_selections fehlt oder ist nicht erreichbar.'], 500); } foreach ($rows as &$row) { $decoded = json_decode((string)($row['points_json'] ?? '[]'), true); $row['points'] = is_array($decoded) ? $decoded : []; $row['csv_delimiter'] = (string)($row['delimiter_value'] ?? ';'); unset($row['points_json']); } unset($row); visuJson([ 'ok' => true, 'selections' => $rows, ]); } if ($action === 'monitoring_selection_save') { $selectionId = (int)($_POST['selection_id'] ?? 0); $name = trim((string)($_POST['name'] ?? '')); $database = visuCleanString($_POST['database_name'] ?? $_POST['database'] ?? ''); $range = (string)($_POST['range_value'] ?? $_POST['range'] ?? '24h'); $interval = (string)($_POST['interval_value'] ?? $_POST['interval'] ?? 'auto'); $aggregate = visuTrendAggregate((string)($_POST['aggregate_value'] ?? $_POST['aggregate'] ?? 'last')); $delimiter = (string)($_POST['csv_delimiter'] ?? $_POST['delimiter_value'] ?? $_POST['delimiter'] ?? ';'); $pointsRaw = (string)($_POST['points_json'] ?? '[]'); $points = json_decode($pointsRaw, true); if ($name === '') { visuJson(['ok' => false, 'error' => 'Name fehlt.'], 400); } if (!is_array($points)) { visuJson(['ok' => false, 'error' => 'Ungueltige Datenpunktliste.'], 400); } $allowedDatabases = visuAllowedInfluxDatabases($pdo, $userId, $canViewAll); if ($database && !in_array($database, $allowedDatabases, true)) { visuJson(['ok' => false, 'error' => 'Keine Berechtigung fuer diese Influx-Datenbank.'], 403); } $range = in_array($range, ['15m', '1h', '6h', '12h', '24h', '7d', '30d', '90d', '180d'], true) ? $range : '24h'; $interval = in_array($interval, ['auto', '10s', '30s', '1m', '5m', '10m', '15m', '30m', '1h', '6h', '12h', '1d'], true) ? $interval : 'auto'; $delimiter = visuCsvDelimiter($delimiter); $pointsJson = json_encode(array_values($points), JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); try { if ($selectionId > 0) { $stmt = $pdo->prepare(" UPDATE visu_monitoring_selections SET name = ?, database_name = ?, range_value = ?, interval_value = ?, aggregate_value = ?, delimiter_value = ?, points_json = ?, is_active = 1 WHERE id = ? AND user_id = ? "); $stmt->execute([$name, $database, $range, $interval, $aggregate, $delimiter, $pointsJson, $selectionId, $userId]); } else { $stmt = $pdo->prepare(" INSERT INTO visu_monitoring_selections (user_id, name, database_name, range_value, interval_value, aggregate_value, delimiter_value, points_json, is_active) VALUES (?, ?, ?, ?, ?, ?, ?, ?, 1) ON DUPLICATE KEY UPDATE database_name = VALUES(database_name), range_value = VALUES(range_value), interval_value = VALUES(interval_value), aggregate_value = VALUES(aggregate_value), delimiter_value = VALUES(delimiter_value), points_json = VALUES(points_json), is_active = 1 "); $stmt->execute([$userId, $name, $database, $range, $interval, $aggregate, $delimiter, $pointsJson]); $selectionId = (int)$pdo->lastInsertId(); } if ($selectionId <= 0) { $stmt = $pdo->prepare(" SELECT id FROM visu_monitoring_selections WHERE user_id = ? AND name = ? LIMIT 1 "); $stmt->execute([$userId, $name]); $selectionId = (int)($stmt->fetchColumn() ?: 0); } } catch (Throwable $e) { visuJson(['ok' => false, 'error' => 'Selection konnte nicht gespeichert werden. Ist die Tabelle angelegt?'], 500); } visuJson([ 'ok' => true, 'selection_id' => $selectionId, ]); } if ($action === 'monitoring_selection_delete') { $selectionId = (int)($_POST['selection_id'] ?? 0); if ($selectionId <= 0) { visuJson(['ok' => false, 'error' => 'Selection fehlt.'], 400); } try { $stmt = $pdo->prepare(" UPDATE visu_monitoring_selections SET is_active = 0 WHERE id = ? AND user_id = ? "); $stmt->execute([$selectionId, $userId]); } catch (Throwable $e) { visuJson(['ok' => false, 'error' => 'Selection konnte nicht geloescht werden.'], 500); } visuJson(['ok' => true]); } if ($action === 'csv_export_jobs') { $projectId = (int)($_GET['project_id'] ?? 0); if ($projectId <= 0) { visuJson(['ok' => false, 'error' => 'Projekt fehlt.'], 400); } if (!visuProjectVisible($pdo, $projectId, $userId, $canViewAll)) { visuJson(['ok' => false, 'error' => 'Projekt nicht sichtbar.'], 403); } if (!visuCsvExportJobsTableReady($pdo)) { visuJson(['ok' => false, 'error' => 'Tabelle visu_csv_export_jobs fehlt. Bitte SQL-Migration ausfuehren.'], 500); } try { $stmt = $pdo->prepare(" SELECT j.*, s.name AS selection_name FROM visu_csv_export_jobs j LEFT JOIN visu_monitoring_selections s ON s.id = j.selection_id AND s.user_id = ? AND s.is_active = 1 WHERE j.project_id = ? ORDER BY j.updated_at DESC, j.name ASC "); $stmt->execute([$userId, $projectId]); $rows = $stmt->fetchAll(PDO::FETCH_ASSOC); } catch (Throwable $e) { visuJson(['ok' => false, 'error' => 'Exportjobs konnten nicht geladen werden.'], 500); } foreach ($rows as &$row) { $row['enabled'] = (int)($row['enabled'] ?? 0); $row['interval_minutes'] = (int)($row['interval_minutes'] ?? 60); $row['month_day'] = (int)($row['month_day'] ?? 1); $row['retention_days'] = (int)($row['retention_days'] ?? 30); $row['project_id'] = (int)($row['project_id'] ?? 0); $row['selection_id'] = (int)($row['selection_id'] ?? 0); } unset($row); visuJson([ 'ok' => true, 'jobs' => $rows, 'can_edit' => $canEditDesigner, ]); } if ($action === 'csv_export_job_save') { if (!$canEditDesigner) { visuJson(['ok' => false, 'error' => 'Kein Recht zum Speichern.'], 403); } if (!visuCsvExportJobsTableReady($pdo)) { visuJson(['ok' => false, 'error' => 'Tabelle visu_csv_export_jobs fehlt. Bitte SQL-Migration ausfuehren.'], 500); } $jobId = (int)($_POST['job_id'] ?? 0); $projectId = (int)($_POST['project_id'] ?? 0); $selectionId = (int)($_POST['selection_id'] ?? 0); $name = trim((string)($_POST['name'] ?? '')); $exportKind = visuCsvExportKind((string)($_POST['export_kind'] ?? 'trend_csv')); $csvDelimiter = visuCsvDelimiter($_POST['csv_delimiter'] ?? ';'); $enabled = !empty($_POST['enabled']) ? 1 : 0; $scheduleKind = visuCsvScheduleKind((string)($_POST['schedule_kind'] ?? 'daily')); $intervalMinutes = visuCsvIntervalMinutes($_POST['interval_minutes'] ?? 60); $timeOfDay = visuCsvTimeOfDay((string)($_POST['time_of_day'] ?? '00:00')); $weekdays = visuCsvWeekdays((string)($_POST['weekdays'] ?? '')); $monthDay = visuCsvMonthDay($_POST['month_day'] ?? 1); $targetDir = visuCsvTargetDir((string)($_POST['target_dir'] ?? 'csv_exports')); $filenamePattern = visuCsvFilenamePattern((string)($_POST['filename_pattern'] ?? '')); $retentionDays = visuCsvRetentionDays($_POST['retention_days'] ?? 30); if ($projectId <= 0 || $selectionId <= 0 || $name === '') { visuJson(['ok' => false, 'error' => 'Projekt, Selection oder Name fehlt.'], 400); } if (!visuProjectVisible($pdo, $projectId, $userId, $canViewAll)) { visuJson(['ok' => false, 'error' => 'Projekt nicht sichtbar.'], 403); } $selectionStmt = $pdo->prepare(" SELECT id, name FROM visu_monitoring_selections WHERE id = ? AND user_id = ? AND is_active = 1 LIMIT 1 "); $selectionStmt->execute([$selectionId, $userId]); $selectionRow = $selectionStmt->fetch(PDO::FETCH_ASSOC); if (!$selectionRow) { visuJson(['ok' => false, 'error' => 'Selection nicht gefunden oder nicht erlaubt.'], 404); } if ($scheduleKind === 'weekly' && $weekdays === '') { $weekdays = (string)(new DateTimeImmutable('now'))->format('N'); } $nextRunAt = $enabled ? visuCsvNextRunAt($scheduleKind, $intervalMinutes, $timeOfDay, $weekdays, $monthDay) : null; try { if ($jobId > 0) { $stmt = $pdo->prepare(" UPDATE visu_csv_export_jobs SET project_id = ?, selection_id = ?, name = ?, export_kind = ?, csv_delimiter = ?, enabled = ?, schedule_kind = ?, interval_minutes = ?, time_of_day = ?, weekdays = ?, month_day = ?, target_dir = ?, filename_pattern = ?, retention_days = ?, next_run_at = ? WHERE id = ? "); $stmt->execute([ $projectId, $selectionId, $name, $exportKind, $csvDelimiter, $enabled, $scheduleKind, $intervalMinutes, $timeOfDay, $weekdays, $monthDay, $targetDir, $filenamePattern, $retentionDays, $nextRunAt, $jobId, ]); } else { $stmt = $pdo->prepare(" INSERT INTO visu_csv_export_jobs ( project_id, selection_id, name, export_kind, csv_delimiter, enabled, schedule_kind, interval_minutes, time_of_day, weekdays, month_day, target_dir, filename_pattern, retention_days, next_run_at, created_by ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) "); $stmt->execute([ $projectId, $selectionId, $name, $exportKind, $csvDelimiter, $enabled, $scheduleKind, $intervalMinutes, $timeOfDay, $weekdays, $monthDay, $targetDir, $filenamePattern, $retentionDays, $nextRunAt, $userId, ]); $jobId = (int)$pdo->lastInsertId(); } } catch (Throwable $e) { visuJson(['ok' => false, 'error' => 'Exportjob konnte nicht gespeichert werden.'], 500); } visuJson([ 'ok' => true, 'job_id' => $jobId, 'next_run_at' => $nextRunAt, ]); } if ($action === 'csv_export_job_delete') { if (!$canEditDesigner) { visuJson(['ok' => false, 'error' => 'Kein Recht zum Loeschen.'], 403); } if (!visuCsvExportJobsTableReady($pdo)) { visuJson(['ok' => false, 'error' => 'Tabelle visu_csv_export_jobs fehlt. Bitte SQL-Migration ausfuehren.'], 500); } $jobId = (int)($_POST['job_id'] ?? 0); if ($jobId <= 0) { visuJson(['ok' => false, 'error' => 'Job fehlt.'], 400); } try { $stmt = $pdo->prepare("DELETE FROM visu_csv_export_jobs WHERE id = ?"); $stmt->execute([$jobId]); } catch (Throwable $e) { visuJson(['ok' => false, 'error' => 'Exportjob konnte nicht geloescht werden.'], 500); } visuJson(['ok' => true]); } if ($action === 'csv_export_files') { $projectId = (int)($_GET['project_id'] ?? 0); $jobId = (int)($_GET['job_id'] ?? 0); if ($projectId <= 0) { visuJson(['ok' => false, 'error' => 'Projekt fehlt.'], 400); } if (!visuProjectVisible($pdo, $projectId, $userId, $canViewAll)) { visuJson(['ok' => false, 'error' => 'Projekt nicht sichtbar.'], 403); } if (!visuCsvExportFilesTableReady($pdo)) { visuJson(['ok' => false, 'error' => 'Tabelle visu_csv_export_files fehlt. Bitte SQL-Migration ausfuehren.'], 500); } try { $sql = " SELECT * FROM visu_csv_export_files WHERE project_id = ? "; $params = [$projectId]; if ($jobId > 0) { $sql .= " AND job_id = ?"; $params[] = $jobId; } $sql .= " ORDER BY created_at DESC, id DESC LIMIT 500"; $stmt = $pdo->prepare($sql); $stmt->execute($params); $rows = $stmt->fetchAll(PDO::FETCH_ASSOC); } catch (Throwable $e) { visuJson(['ok' => false, 'error' => 'Exportdateien konnten nicht geladen werden.'], 500); } foreach ($rows as &$row) { $row['job_id'] = (int)($row['job_id'] ?? 0); $row['project_id'] = (int)($row['project_id'] ?? 0); $row['selection_id'] = (int)($row['selection_id'] ?? 0); $row['file_size'] = (int)($row['file_size'] ?? 0); $row['row_count'] = (int)($row['row_count'] ?? 0); } unset($row); visuJson([ 'ok' => true, 'files' => $rows, 'can_edit' => $canEditDesigner, ]); } if ($action === 'csv_export_file_delete') { if (!$canEditDesigner) { visuJson(['ok' => false, 'error' => 'Kein Recht zum Loeschen.'], 403); } if (!visuCsvExportFilesTableReady($pdo)) { visuJson(['ok' => false, 'error' => 'Tabelle visu_csv_export_files fehlt. Bitte SQL-Migration ausfuehren.'], 500); } $fileId = (int)($_POST['file_id'] ?? 0); if ($fileId <= 0) { visuJson(['ok' => false, 'error' => 'Datei fehlt.'], 400); } try { $stmt = $pdo->prepare("SELECT file_path FROM visu_csv_export_files WHERE id = ? LIMIT 1"); $stmt->execute([$fileId]); $filePath = (string)($stmt->fetchColumn() ?: ''); if ($filePath !== '') { $absolutePath = visuCsvExportAbsolutePath($filePath); if (is_file($absolutePath)) { @unlink($absolutePath); } } $stmt = $pdo->prepare(" UPDATE visu_csv_export_files SET status = 'deleted' WHERE id = ? "); $stmt->execute([$fileId]); } catch (Throwable $e) { visuJson(['ok' => false, 'error' => 'Exportdatei konnte nicht markiert werden.'], 500); } visuJson(['ok' => true]); } if ($action === 'csv_export_file_download') { $fileId = (int)($_GET['file_id'] ?? 0); if ($fileId <= 0) { visuJson(['ok' => false, 'error' => 'Datei fehlt.'], 400); } if (!visuCsvExportFilesTableReady($pdo)) { visuJson(['ok' => false, 'error' => 'Tabelle visu_csv_export_files fehlt. Bitte SQL-Migration ausfuehren.'], 500); } $stmt = $pdo->prepare(" SELECT id, project_id, file_name, file_path, status FROM visu_csv_export_files WHERE id = ? LIMIT 1 "); $stmt->execute([$fileId]); $row = $stmt->fetch(PDO::FETCH_ASSOC); if (!$row) { visuJson(['ok' => false, 'error' => 'Datei nicht gefunden.'], 404); } $projectId = (int)($row['project_id'] ?? 0); if ($projectId <= 0 || !visuProjectVisible($pdo, $projectId, $userId, $canViewAll)) { visuJson(['ok' => false, 'error' => 'Kein Zugriff auf diese Exportdatei.'], 403); } if (($row['status'] ?? '') !== 'success') { visuJson(['ok' => false, 'error' => 'Datei ist noch nicht verfuegbar.'], 409); } $absolutePath = visuCsvExportAbsolutePath((string)($row['file_path'] ?? '')); if (!is_file($absolutePath)) { visuJson(['ok' => false, 'error' => 'Datei fehlt im Export-Speicher.'], 404); } header_remove('Content-Type'); header('Content-Type: text/csv; charset=utf-8'); header('Content-Length: ' . (string)filesize($absolutePath)); header('Content-Disposition: attachment; filename="' . str_replace('"', '', (string)($row['file_name'] ?? 'export.csv')) . '"'); readfile($absolutePath); exit; } if ($action === 'pages') { $params = []; $visSql = visuVisibilitySql($userId, $canViewAll, 'vp.project_id', $params, 'pages'); $stmt = $pdo->prepare(" SELECT vp.id, vp.project_id, vp.name, vp.slug, vp.description, vp.width, vp.height, vp.background_color, vp.background_image, vp.grid_enabled, vp.grid_size, vp.snap_enabled, vp.is_active, vp.created_at, vp.updated_at, p.project_number, p.year, p.name AS project_name, pc.company_name AS customer_name FROM visu_pages vp INNER JOIN projects p ON p.id = vp.project_id LEFT JOIN project_customers pc ON pc.id = p.customer_id WHERE vp.is_active = 1 AND {$visSql} ORDER BY p.year DESC, p.project_number ASC, vp.name ASC "); $stmt->execute($params); visuJson([ 'ok' => true, 'pages' => $stmt->fetchAll(PDO::FETCH_ASSOC), 'can_edit' => $canEditDesigner, 'can_design' => $canViewDesigner, 'can_manage_assets' => $canManageAssets, 'can_edit_assets' => $canEditAssets, 'can_delete_assets' => $canDeleteAssets, 'can_write_setpoints' => $canWriteSetpoints, ]); } if ($action === 'dashboard_widgets') { $params = [':dashboard_user_id' => $userId]; $visSql = visuVisibilitySql($userId, $canViewAll, 'dw.project_id', $params, 'dashboard_widgets'); $stmt = $pdo->prepare(" SELECT dw.*, p.project_number, p.year, p.name AS project_name, pc.company_name AS customer_name FROM visu_dashboard_widgets dw LEFT JOIN projects p ON p.id = dw.project_id LEFT JOIN project_customers pc ON pc.id = p.customer_id WHERE dw.user_id = :dashboard_user_id AND dw.is_active = 1 AND (dw.project_id IS NULL OR {$visSql}) ORDER BY dw.sort_order ASC, dw.grid_y ASC, dw.grid_x ASC, dw.id ASC "); $stmt->execute($params); visuJson([ 'ok' => true, 'widgets' => $stmt->fetchAll(PDO::FETCH_ASSOC), ]); } if ($action === 'dashboard_widget_save') { $widgetId = (int)($_POST['widget_id'] ?? 0); $widgetType = trim((string)($_POST['widget_type'] ?? '')); $title = visuCleanString($_POST['title'] ?? ''); $projectId = (int)($_POST['project_id'] ?? 0); $gridX = max(0, (int)($_POST['grid_x'] ?? 0)); $gridY = max(0, (int)($_POST['grid_y'] ?? 0)); $gridW = max(1, min(4, (int)($_POST['grid_w'] ?? 1))); $gridH = max(1, min(4, (int)($_POST['grid_h'] ?? 1))); $sortOrder = (int)($_POST['sort_order'] ?? 100); $configJson = trim((string)($_POST['config_json'] ?? '{}')); if (!in_array($widgetType, ['simple_value', 'value_table', 'scale_bar', 'dial_gauge'], true)) { visuJson(['ok' => false, 'error' => 'Ungültiger Widget-Typ.'], 400); } $config = json_decode($configJson, true); if (!is_array($config)) { visuJson(['ok' => false, 'error' => 'Ungültige Widget-Konfiguration.'], 400); } if ($projectId > 0 && !visuProjectVisible($pdo, $projectId, $userId, $canViewAll)) { visuJson(['ok' => false, 'error' => 'Projekt nicht sichtbar.'], 403); } $projectValue = $projectId > 0 ? $projectId : null; $configText = json_encode($config, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); if ($widgetId > 0) { $stmt = $pdo->prepare(" UPDATE visu_dashboard_widgets SET widget_type = ?, title = ?, project_id = ?, grid_x = ?, grid_y = ?, grid_w = ?, grid_h = ?, sort_order = ?, config_json = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ? AND user_id = ? "); $stmt->execute([ $widgetType, $title, $projectValue, $gridX, $gridY, $gridW, $gridH, $sortOrder, $configText, $widgetId, $userId, ]); if ($stmt->rowCount() === 0) { $chk = $pdo->prepare("SELECT 1 FROM visu_dashboard_widgets WHERE id = ? AND user_id = ? LIMIT 1"); $chk->execute([$widgetId, $userId]); if (!$chk->fetchColumn()) { visuJson(['ok' => false, 'error' => 'Widget nicht gefunden.'], 404); } } visuJson(['ok' => true, 'widget_id' => $widgetId]); } $stmt = $pdo->prepare(" INSERT INTO visu_dashboard_widgets ( user_id, project_id, widget_type, title, grid_x, grid_y, grid_w, grid_h, sort_order, config_json, is_active ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 1) "); $stmt->execute([ $userId, $projectValue, $widgetType, $title, $gridX, $gridY, $gridW, $gridH, $sortOrder, $configText, ]); visuJson(['ok' => true, 'widget_id' => (int)$pdo->lastInsertId()]); } if ($action === 'dashboard_widget_delete') { $widgetId = (int)($_POST['widget_id'] ?? 0); if ($widgetId <= 0) { visuJson(['ok' => false, 'error' => 'Widget fehlt.'], 400); } $stmt = $pdo->prepare(" UPDATE visu_dashboard_widgets SET is_active = 0, updated_at = CURRENT_TIMESTAMP WHERE id = ? AND user_id = ? "); $stmt->execute([$widgetId, $userId]); if ($stmt->rowCount() === 0) { visuJson(['ok' => false, 'error' => 'Widget nicht gefunden.'], 404); } visuJson(['ok' => true]); } if ($action === 'templates') { if (!$canViewDesigner) { visuJson(['ok' => false, 'error' => 'Kein Zugriff auf Templates.'], 403); } $stmt = $pdo->query(" SELECT id, name, description, width, height, placeholders_json, updated_at FROM visu_templates WHERE is_active = 1 ORDER BY updated_at DESC, name ASC "); $templates = $stmt->fetchAll(PDO::FETCH_ASSOC); foreach ($templates as &$template) { $placeholders = json_decode((string)($template['placeholders_json'] ?? '[]'), true); $template['placeholder_count'] = is_array($placeholders) ? count($placeholders) : 0; unset($template['placeholders_json']); } unset($template); visuJson(['ok' => true, 'templates' => $templates, 'can_edit' => $canEditDesigner]); } if ($action === 'template') { if (!$canViewDesigner) { visuJson(['ok' => false, 'error' => 'Kein Zugriff auf Templates.'], 403); } $templateId = (int)($_GET['id'] ?? 0); $stmt = $pdo->prepare("SELECT * FROM visu_templates WHERE id = ? AND is_active = 1 LIMIT 1"); $stmt->execute([$templateId]); $template = $stmt->fetch(PDO::FETCH_ASSOC); if (!$template) { visuJson(['ok' => false, 'error' => 'Template nicht gefunden.'], 404); } $placeholders = json_decode((string)($template['placeholders_json'] ?? '[]'), true); $elements = json_decode((string)($template['elements_json'] ?? '[]'), true); unset($template['placeholders_json'], $template['elements_json']); visuJson([ 'ok' => true, 'template' => $template, 'placeholders' => is_array($placeholders) ? $placeholders : [], 'elements' => is_array($elements) ? $elements : [], 'can_edit' => $canEditDesigner, ]); } if ($action === 'template_save' || $action === 'template_settings_save') { if (!$canEditDesigner) { visuJson(['ok' => false, 'error' => 'Kein Recht zum Speichern.'], 403); } $templateId = (int)($_POST['template_id'] ?? 0); $name = visuCleanString($_POST['name'] ?? null) ?: 'Template'; $description = visuCleanString($_POST['description'] ?? null); $width = max(320, (int)($_POST['width'] ?? 1920)); $height = max(240, (int)($_POST['height'] ?? 1080)); $backgroundColor = visuCleanString($_POST['background_color'] ?? null) ?: '#0f172a'; $gridEnabled = !empty($_POST['grid_enabled']) ? 1 : 0; $snapEnabled = !empty($_POST['snap_enabled']) ? 1 : 0; $gridSize = max(2, (int)($_POST['grid_size'] ?? 40)); $placeholdersRaw = (string)($_POST['placeholders_json'] ?? '[]'); $elementsRaw = (string)($_POST['elements_json'] ?? ''); $placeholders = json_decode($placeholdersRaw, true); if (!is_array($placeholders)) { visuJson(['ok' => false, 'error' => 'Ungültige Platzhalter.'], 400); } $placeholdersJson = json_encode($placeholders, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); if ($templateId > 0) { $stmt = $pdo->prepare("SELECT elements_json, background_image FROM visu_templates WHERE id = ? AND is_active = 1 LIMIT 1"); $stmt->execute([$templateId]); $oldTemplate = $stmt->fetch(PDO::FETCH_ASSOC); if (!$oldTemplate) { visuJson(['ok' => false, 'error' => 'Template nicht gefunden.'], 404); } $backgroundImage = (string)($oldTemplate['background_image'] ?? ''); $uploaded = visuSaveUploadedImage('background_image', VISU_BG_UPLOAD_DIR, VISU_BG_UPLOAD_URL); if ($uploaded) { visuDeleteUploadedFile($backgroundImage, VISU_BG_UPLOAD_URL, VISU_BG_UPLOAD_DIR); $backgroundImage = $uploaded; } if (!empty($_POST['remove_background_image'])) { visuDeleteUploadedFile($backgroundImage, VISU_BG_UPLOAD_URL, VISU_BG_UPLOAD_DIR); $backgroundImage = null; } if ($elementsRaw !== '') { $elements = json_decode($elementsRaw, true); if (!is_array($elements)) { visuJson(['ok' => false, 'error' => 'Ungültige Elemente.'], 400); } $elementsJson = json_encode($elements, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); } else { $elementsJson = (string)($oldTemplate['elements_json'] ?? '[]'); } $stmt = $pdo->prepare(" UPDATE visu_templates SET name = ?, description = ?, width = ?, height = ?, background_color = ?, background_image = ?, grid_enabled = ?, snap_enabled = ?, grid_size = ?, placeholders_json = ?, elements_json = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ? "); $stmt->execute([ $name, $description, $width, $height, $backgroundColor, $backgroundImage, $gridEnabled, $snapEnabled, $gridSize, $placeholdersJson, $elementsJson, $templateId, ]); } else { $elements = $elementsRaw !== '' ? json_decode($elementsRaw, true) : []; if (!is_array($elements)) { visuJson(['ok' => false, 'error' => 'Ungültige Elemente.'], 400); } $stmt = $pdo->prepare(" INSERT INTO visu_templates (user_id, name, description, width, height, background_color, background_image, grid_enabled, snap_enabled, grid_size, placeholders_json, elements_json) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) "); $stmt->execute([ $userId, $name, $description, $width, $height, $backgroundColor, visuSaveUploadedImage('background_image', VISU_BG_UPLOAD_DIR, VISU_BG_UPLOAD_URL) ?: null, $gridEnabled, $snapEnabled, $gridSize, $placeholdersJson, json_encode($elements, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES), ]); $templateId = (int)$pdo->lastInsertId(); } visuJson(['ok' => true, 'template_id' => $templateId]); } if ($action === 'template_delete') { if (!$canEditDesigner) { visuJson(['ok' => false, 'error' => 'Kein Recht zum Löschen.'], 403); } $templateId = (int)($_POST['template_id'] ?? 0); $stmt = $pdo->prepare("UPDATE visu_templates SET is_active = 0, updated_at = CURRENT_TIMESTAMP WHERE id = ?"); $stmt->execute([$templateId]); visuJson(['ok' => true]); } if ($action === 'template_import') { if (!$canEditDesigner) { visuJson(['ok' => false, 'error' => 'Kein Recht zum Importieren.'], 403); } $payload = json_decode((string)($_POST['import_json'] ?? ''), true); if (!is_array($payload)) { visuJson(['ok' => false, 'error' => 'Ungültige Import-Datei.'], 400); } $template = is_array($payload['template'] ?? null) ? $payload['template'] : []; $placeholders = is_array($payload['placeholders'] ?? null) ? $payload['placeholders'] : []; $elements = is_array($payload['elements'] ?? null) ? $payload['elements'] : []; $stmt = $pdo->prepare(" INSERT INTO visu_templates (user_id, name, description, width, height, background_color, background_image, grid_enabled, snap_enabled, grid_size, placeholders_json, elements_json) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) "); $stmt->execute([ $userId, visuCleanString(($template['name'] ?? 'Importiertes Template') . ' Import') ?: 'Importiertes Template', visuCleanString($template['description'] ?? null), max(320, (int)($template['width'] ?? 1920)), max(240, (int)($template['height'] ?? 1080)), visuCleanString($template['background_color'] ?? null) ?: '#0f172a', visuCleanString($template['background_image'] ?? null), !empty($template['grid_enabled']) ? 1 : 0, !empty($template['snap_enabled']) ? 1 : 0, max(2, (int)($template['grid_size'] ?? 40)), json_encode($placeholders, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES), json_encode($elements, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES), ]); visuJson(['ok' => true, 'template_id' => (int)$pdo->lastInsertId()]); } if ($action === 'page') { $pageId = (int)($_GET['id'] ?? 0); $page = visuPageVisible($pdo, $pageId, $userId, $canViewAll); if (!$page) { visuJson(['ok' => false, 'error' => 'Visualisierung nicht gefunden.'], 404); } $stmt = $pdo->prepare(" SELECT ve.*, va.name AS asset_name, va.asset_type FROM visu_elements ve LEFT JOIN visu_assets va ON va.id = ve.asset_id WHERE ve.page_id = ? ORDER BY ve.z_index ASC, ve.id ASC "); $stmt->execute([$pageId]); $elements = $stmt->fetchAll(PDO::FETCH_ASSOC); $elementIds = array_map(fn($e) => (int)$e['id'], $elements); $assetIds = array_map(fn($e) => (int)($e['asset_id'] ?? 0), $elements); $statesByAsset = visuLoadAssetStatesForAssets($pdo, $assetIds); $rulesByElement = []; if (!empty($elementIds)) { $ph = implode(',', array_fill(0, count($elementIds), '?')); $stmt = $pdo->prepare(" SELECT r.*, s.state_key, s.label AS state_label, s.image_file FROM visu_element_state_rules r INNER JOIN visu_asset_states s ON s.id = r.asset_state_id WHERE r.element_id IN ($ph) ORDER BY r.element_id ASC, r.priority ASC, r.id ASC "); $stmt->execute($elementIds); foreach ($stmt->fetchAll(PDO::FETCH_ASSOC) as $row) { $eid = (int)$row['element_id']; $rulesByElement[$eid][] = $row; } } foreach ($elements as &$el) { $eid = (int)$el['id']; $aid = (int)($el['asset_id'] ?? 0); $el['rules'] = $rulesByElement[$eid] ?? []; $el['asset_states'] = $statesByAsset[$aid] ?? []; } unset($el); visuJson([ 'ok' => true, 'page' => $page, 'elements' => $elements, 'can_edit' => $canEditDesigner, 'can_design' => $canViewDesigner, 'can_manage_assets' => $canManageAssets, 'can_edit_assets' => $canEditAssets, 'can_delete_assets' => $canDeleteAssets, 'can_write_setpoints' => $canWriteSetpoints, ]); } if ($action === 'page_create') { if (!$canEditDesigner) { visuJson(['ok' => false, 'error' => 'Kein Recht zum Erstellen.'], 403); } $projectId = (int)($_POST['project_id'] ?? 0); $name = trim((string)($_POST['name'] ?? '')); $description = visuCleanString($_POST['description'] ?? ''); $width = (int)($_POST['width'] ?? 1920); $height = (int)($_POST['height'] ?? 1080); if ($projectId <= 0 || $name === '') { visuJson(['ok' => false, 'error' => 'Projekt oder Name fehlt.'], 400); } if ($width < 320 || $width > 10000) { $width = 1920; } if ($height < 240 || $height > 10000) { $height = 1080; } if (!visuProjectVisible($pdo, $projectId, $userId, $canViewAll)) { visuJson(['ok' => false, 'error' => 'Projekt nicht sichtbar.'], 403); } $slugBase = visuSlug($name); $slug = $slugBase; $i = 2; while (true) { $chk = $pdo->prepare("SELECT 1 FROM visu_pages WHERE project_id = ? AND slug = ? LIMIT 1"); $chk->execute([$projectId, $slug]); if (!$chk->fetchColumn()) { break; } $slug = $slugBase . '-' . $i; $i++; } $stmt = $pdo->prepare(" INSERT INTO visu_pages (project_id, name, slug, description, width, height, is_active, created_by) VALUES (?, ?, ?, ?, ?, ?, 1, ?) "); $stmt->execute([$projectId, $name, $slug, $description, $width, $height, $userId]); visuJson([ 'ok' => true, 'page_id' => (int)$pdo->lastInsertId(), ]); } if ($action === 'page_settings_save') { if (!$canEditDesigner) { visuJson(['ok' => false, 'error' => 'Kein Recht zum Bearbeiten.'], 403); } $pageId = (int)($_POST['page_id'] ?? 0); $page = visuPageVisible($pdo, $pageId, $userId, $canViewAll); if (!$page) { visuJson(['ok' => false, 'error' => 'Seite nicht gefunden.'], 404); } $backgroundColor = trim((string)($_POST['background_color'] ?? '#0f172a')); if (!preg_match('/^#[0-9a-fA-F]{6}$/', $backgroundColor)) { $backgroundColor = '#0f172a'; } $gridEnabled = !empty($_POST['grid_enabled']) ? 1 : 0; $snapEnabled = !empty($_POST['snap_enabled']) ? 1 : 0; $gridSize = (int)($_POST['grid_size'] ?? 40); $width = (int)($_POST['width'] ?? $page['width'] ?? 1920); $height = (int)($_POST['height'] ?? $page['height'] ?? 1080); if ($gridSize < 5) { $gridSize = 5; } if ($gridSize > 200) { $gridSize = 200; } if ($width < 320 || $width > 10000) { $width = (int)($page['width'] ?? 1920); } if ($height < 240 || $height > 10000) { $height = (int)($page['height'] ?? 1080); } $backgroundImage = (string)($page['background_image'] ?? ''); $uploaded = visuSaveUploadedImage('background_image', VISU_BG_UPLOAD_DIR, VISU_BG_UPLOAD_URL); if ($uploaded) { visuDeleteUploadedFile($backgroundImage, VISU_BG_UPLOAD_URL, VISU_BG_UPLOAD_DIR); $backgroundImage = $uploaded; } if (!empty($_POST['remove_background_image'])) { visuDeleteUploadedFile($backgroundImage, VISU_BG_UPLOAD_URL, VISU_BG_UPLOAD_DIR); $backgroundImage = null; } $stmt = $pdo->prepare(" UPDATE visu_pages SET width = ?, height = ?, background_color = ?, background_image = ?, grid_enabled = ?, grid_size = ?, snap_enabled = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ? "); $stmt->execute([ $width, $height, $backgroundColor, $backgroundImage, $gridEnabled, $gridSize, $snapEnabled, $pageId, ]); visuJson(['ok' => true]); } if ($action === 'page_delete') { if (!$canEditDesigner) { visuJson(['ok' => false, 'error' => 'Kein Recht zum Löschen.'], 403); } $pageId = (int)($_POST['page_id'] ?? 0); $page = visuPageVisible($pdo, $pageId, $userId, $canViewAll); if (!$page) { visuJson(['ok' => false, 'error' => 'Seite nicht gefunden.'], 404); } $stmt = $pdo->prepare("UPDATE visu_pages SET is_active = 0 WHERE id = ?"); $stmt->execute([$pageId]); visuJson(['ok' => true]); } if ($action === 'assets') { $projectId = (int)($_GET['project_id'] ?? 0); $params = []; $where = ["1=1"]; if ($projectId > 0) { if (!visuProjectVisible($pdo, $projectId, $userId, $canViewAll)) { visuJson(['ok' => false, 'error' => 'Projekt nicht sichtbar.'], 403); } $where[] = "(va.is_global = 1 OR va.project_id = :project_id)"; $params[':project_id'] = $projectId; } else { $where[] = "va.is_global = 1"; } $stmt = $pdo->prepare(" SELECT va.id, va.project_id, va.name, va.description, va.asset_type, va.is_global, va.created_at FROM visu_assets va WHERE " . implode(' AND ', $where) . " ORDER BY va.asset_type ASC, va.name ASC "); $stmt->execute($params); $assets = $stmt->fetchAll(PDO::FETCH_ASSOC); $assetIds = array_map(fn($a) => (int)$a['id'], $assets); $statesByAsset = visuLoadAssetStatesForAssets($pdo, $assetIds); foreach ($assets as &$asset) { $asset['states'] = $statesByAsset[(int)$asset['id']] ?? []; } unset($asset); visuJson([ 'ok' => true, 'assets' => $assets, 'can_manage_assets' => $canManageAssets, 'can_edit_assets' => $canEditAssets, 'can_delete_assets' => $canDeleteAssets, ]); } if ($action === 'image_library') { if (!$canViewDesigner && !$canEditAssets) { visuJson(['ok' => false, 'error' => 'Kein Zugriff auf Bilder.'], 403); } $projectId = (int)($_GET['project_id'] ?? 0); if ($projectId > 0 && !visuProjectVisible($pdo, $projectId, $userId, $canViewAll)) { visuJson(['ok' => false, 'error' => 'Projekt nicht sichtbar.'], 403); } try { $stmt = $pdo->prepare(" INSERT IGNORE INTO visu_image_library (project_id, file_url, original_name, created_by, is_active) SELECT MIN(va.project_id) AS project_id, vas.image_file, SUBSTRING_INDEX(vas.image_file, '/', -1) AS original_name, ? AS created_by, 1 AS is_active FROM visu_asset_states vas INNER JOIN visu_assets va ON va.id = vas.asset_id WHERE vas.image_file LIKE ? GROUP BY vas.image_file "); $stmt->execute([$userId, rtrim(VISU_ASSET_UPLOAD_URL, '/') . '/%']); $params = []; $where = ['vil.is_active = 1']; if ($projectId > 0) { $where[] = '(vil.project_id IS NULL OR vil.project_id = :project_id)'; $params[':project_id'] = $projectId; } $stmt = $pdo->prepare(" SELECT vil.id, vil.project_id, vil.file_url, vil.original_name, vil.mime_type, vil.file_size, vil.created_at, ( SELECT COUNT(*) FROM visu_asset_states vas WHERE vas.image_file = vil.file_url ) AS usage_count FROM visu_image_library vil WHERE " . implode(' AND ', $where) . " ORDER BY vil.created_at DESC, vil.id DESC "); $stmt->execute($params); $images = $stmt->fetchAll(PDO::FETCH_ASSOC); } catch (Throwable $e) { visuJson(['ok' => false, 'error' => 'Tabelle visu_image_library fehlt oder ist nicht erreichbar.'], 500); } visuJson([ 'ok' => true, 'images' => $images, 'can_edit' => $canEditAssets, 'can_delete' => $canDeleteAssets, ]); } if ($action === 'image_library_upload') { if (!$canEditAssets) { visuJson(['ok' => false, 'error' => 'Kein Recht zum Hochladen von Bildern.'], 403); } $projectId = (int)($_POST['project_id'] ?? 0); $libraryProjectId = $projectId > 0 ? $projectId : null; if ($projectId > 0 && !visuProjectVisible($pdo, $projectId, $userId, $canViewAll)) { visuJson(['ok' => false, 'error' => 'Projekt nicht sichtbar.'], 403); } $files = $_FILES['images'] ?? null; $uploaded = []; if (!$files || !is_array($files)) { visuJson(['ok' => false, 'error' => 'Keine Bilder uebergeben.'], 400); } if (is_array($files['name'] ?? null)) { $count = count($files['name']); for ($i = 0; $i < $count; $i++) { $file = [ 'name' => $files['name'][$i] ?? '', 'type' => $files['type'][$i] ?? '', 'tmp_name' => $files['tmp_name'][$i] ?? '', 'error' => $files['error'][$i] ?? UPLOAD_ERR_NO_FILE, 'size' => $files['size'][$i] ?? 0, ]; $saved = visuSaveUploadedImageFile($file, VISU_ASSET_UPLOAD_DIR, VISU_ASSET_UPLOAD_URL); if ($saved) { visuImageLibraryRegister($pdo, $saved['url'], $saved['original_name'], $saved['mime_type'], $saved['file_size'], $libraryProjectId, $userId); $uploaded[] = $saved; } } } else { $saved = visuSaveUploadedImageFile($files, VISU_ASSET_UPLOAD_DIR, VISU_ASSET_UPLOAD_URL); if ($saved) { visuImageLibraryRegister($pdo, $saved['url'], $saved['original_name'], $saved['mime_type'], $saved['file_size'], $libraryProjectId, $userId); $uploaded[] = $saved; } } visuJson([ 'ok' => true, 'images' => $uploaded, ]); } if ($action === 'image_library_delete') { if (!$canDeleteAssets) { visuJson(['ok' => false, 'error' => 'Kein Recht zum Loeschen von Bildern.'], 403); } $imageId = (int)($_POST['image_id'] ?? 0); if ($imageId <= 0) { visuJson(['ok' => false, 'error' => 'Bild fehlt.'], 400); } try { $stmt = $pdo->prepare(" SELECT vil.file_url, ( SELECT COUNT(*) FROM visu_asset_states vas WHERE vas.image_file = vil.file_url ) AS usage_count FROM visu_image_library vil WHERE vil.id = ? AND vil.is_active = 1 LIMIT 1 "); $stmt->execute([$imageId]); $image = $stmt->fetch(PDO::FETCH_ASSOC); } catch (Throwable $e) { visuJson(['ok' => false, 'error' => 'Bilderbibliothek ist nicht erreichbar.'], 500); } if (!$image) { visuJson(['ok' => false, 'error' => 'Bild nicht gefunden.'], 404); } if ((int)($image['usage_count'] ?? 0) > 0) { visuJson(['ok' => false, 'error' => 'Bild wird noch verwendet und kann nicht geloescht werden.'], 409); } $stmt = $pdo->prepare("UPDATE visu_image_library SET is_active = 0 WHERE id = ?"); $stmt->execute([$imageId]); visuDeleteUploadedFile((string)$image['file_url'], VISU_ASSET_UPLOAD_URL, VISU_ASSET_UPLOAD_DIR); visuJson(['ok' => true]); } if ($action === 'asset_create') { if (!$canEditAssets) { visuJson(['ok' => false, 'error' => 'Kein Recht zum Erstellen von Symbolen.'], 403); } $name = trim((string)($_POST['name'] ?? '')); $description = visuCleanString($_POST['description'] ?? ''); $assetType = (string)($_POST['asset_type'] ?? 'custom'); $projectId = (int)($_POST['project_id'] ?? 0); $isGlobal = !empty($_POST['is_global']) ? 1 : 0; if ($name === '') { visuJson(['ok' => false, 'error' => 'Name fehlt.'], 400); } if (!in_array($assetType, ['background', 'pump', 'heat_generation', 'cold_generation', 'heat_recovery', 'valve', 'fan', 'sensor', 'text', 'custom'], true)) { $assetType = 'custom'; } if ($projectId > 0 && !visuProjectVisible($pdo, $projectId, $userId, $canViewAll)) { visuJson(['ok' => false, 'error' => 'Projekt nicht sichtbar.'], 403); } if ($projectId <= 0) { $projectId = null; $isGlobal = 1; } $stmt = $pdo->prepare(" INSERT INTO visu_assets (project_id, name, description, asset_type, is_global, created_by) VALUES (?, ?, ?, ?, ?, ?) "); $stmt->execute([$projectId, $name, $description, $assetType, $isGlobal, $userId]); visuJson([ 'ok' => true, 'asset_id' => (int)$pdo->lastInsertId(), ]); } if ($action === 'asset_update') { if (!$canEditAssets) { visuJson(['ok' => false, 'error' => 'Kein Recht zum Bearbeiten von Symbolen.'], 403); } $assetId = (int)($_POST['asset_id'] ?? 0); $name = trim((string)($_POST['name'] ?? '')); $description = visuCleanString($_POST['description'] ?? ''); $assetType = (string)($_POST['asset_type'] ?? 'custom'); $isGlobal = !empty($_POST['is_global']) ? 1 : 0; if ($assetId <= 0 || $name === '') { visuJson(['ok' => false, 'error' => 'Symbol oder Name fehlt.'], 400); } if (!in_array($assetType, ['background', 'pump', 'heat_generation', 'cold_generation', 'heat_recovery', 'valve', 'fan', 'sensor', 'text', 'custom'], true)) { $assetType = 'custom'; } $stmt = $pdo->prepare(" UPDATE visu_assets SET name = ?, description = ?, asset_type = ?, is_global = ? WHERE id = ? "); $stmt->execute([$name, $description, $assetType, $isGlobal, $assetId]); visuJson(['ok' => true, 'asset_id' => $assetId]); } if ($action === 'asset_delete') { if (!$canDeleteAssets) { visuJson(['ok' => false, 'error' => 'Kein Recht zum Löschen von Symbolen.'], 403); } $assetId = (int)($_POST['asset_id'] ?? 0); if ($assetId <= 0) { visuJson(['ok' => false, 'error' => 'Symbol fehlt.'], 400); } $stmt = $pdo->prepare("SELECT image_file FROM visu_asset_states WHERE asset_id = ?"); $stmt->execute([$assetId]); $files = array_map('strval', $stmt->fetchAll(PDO::FETCH_COLUMN)); $stmt = $pdo->prepare("DELETE FROM visu_assets WHERE id = ?"); $stmt->execute([$assetId]); foreach ($files as $file) { visuDeleteAssetImageIfUnused($pdo, $file); } visuJson(['ok' => true]); } if ($action === 'asset_state_create' || $action === 'asset_state_update') { if (!$canEditAssets) { visuJson(['ok' => false, 'error' => 'Kein Recht zum Bearbeiten von Zuständen.'], 403); } $stateId = (int)($_POST['state_id'] ?? 0); $assetId = (int)($_POST['asset_id'] ?? 0); $label = trim((string)($_POST['label'] ?? '')); $stateKey = trim((string)($_POST['state_key'] ?? '')); $sortOrder = (int)($_POST['sort_order'] ?? 100); $isDefault = !empty($_POST['is_default']) ? 1 : 0; $libraryUrl = visuCleanString($_POST['image_library_url'] ?? ''); if ($assetId <= 0 || $label === '') { visuJson(['ok' => false, 'error' => 'Symbol oder Zustandsname fehlt.'], 400); } $stmt = $pdo->prepare("SELECT project_id FROM visu_assets WHERE id = ? LIMIT 1"); $stmt->execute([$assetId]); $assetProjectId = (int)($stmt->fetchColumn() ?: 0); $stateKey = $stateKey === '' ? visuSlug($label) : visuSlug($stateKey); $uploaded = visuSaveUploadedImage('image_file', VISU_ASSET_UPLOAD_DIR, VISU_ASSET_UPLOAD_URL); if ($uploaded) { $uploadFile = $_FILES['image_file'] ?? []; visuImageLibraryRegister( $pdo, $uploaded, (string)($uploadFile['name'] ?? ''), (string)($uploadFile['type'] ?? ''), (int)($uploadFile['size'] ?? 0), $assetProjectId > 0 ? $assetProjectId : null, $userId ); } if (!$uploaded && $libraryUrl && !visuImageLibraryAllowed($pdo, $libraryUrl, $assetProjectId, $userId, $canViewAll)) { visuJson(['ok' => false, 'error' => 'Bild aus der Bibliothek ist nicht erlaubt.'], 403); } $nextImageFile = $uploaded ?: ($libraryUrl ?: ''); if ($action === 'asset_state_create') { $stmt = $pdo->prepare("SELECT COUNT(*) FROM visu_asset_states WHERE asset_id = ?"); $stmt->execute([$assetId]); if ((int)$stmt->fetchColumn() === 0) { $isDefault = 1; } if ($isDefault) { $stmt = $pdo->prepare("UPDATE visu_asset_states SET is_default = 0 WHERE asset_id = ?"); $stmt->execute([$assetId]); } $stmt = $pdo->prepare(" INSERT INTO visu_asset_states (asset_id, state_key, label, image_file, sort_order, is_default) VALUES (?, ?, ?, ?, ?, ?) "); $stmt->execute([$assetId, $stateKey, $label, $nextImageFile, $sortOrder, $isDefault]); visuJson(['ok' => true, 'state_id' => (int)$pdo->lastInsertId()]); } if ($stateId <= 0) { visuJson(['ok' => false, 'error' => 'Zustand fehlt.'], 400); } $stmt = $pdo->prepare("SELECT image_file FROM visu_asset_states WHERE id = ? AND asset_id = ? LIMIT 1"); $stmt->execute([$stateId, $assetId]); $oldState = $stmt->fetch(PDO::FETCH_ASSOC); if (!$oldState) { visuJson(['ok' => false, 'error' => 'Zustand nicht gefunden.'], 404); } $oldFile = (string)($oldState['image_file'] ?? ''); if ($isDefault) { $stmt = $pdo->prepare("UPDATE visu_asset_states SET is_default = 0 WHERE asset_id = ?"); $stmt->execute([$assetId]); } if ($nextImageFile !== '') { $stmt = $pdo->prepare(" UPDATE visu_asset_states SET state_key = ?, label = ?, image_file = ?, sort_order = ?, is_default = ? WHERE id = ? AND asset_id = ? "); $stmt->execute([$stateKey, $label, $nextImageFile, $sortOrder, $isDefault, $stateId, $assetId]); if ($oldFile !== $nextImageFile) { visuDeleteAssetImageIfUnused($pdo, (string)$oldFile, $stateId); } } else { $stmt = $pdo->prepare(" UPDATE visu_asset_states SET state_key = ?, label = ?, sort_order = ?, is_default = ? WHERE id = ? AND asset_id = ? "); $stmt->execute([$stateKey, $label, $sortOrder, $isDefault, $stateId, $assetId]); } visuJson(['ok' => true, 'state_id' => $stateId]); } if ($action === 'asset_state_delete') { if (!$canDeleteAssets) { visuJson(['ok' => false, 'error' => 'Kein Recht zum Löschen von Zuständen.'], 403); } $stateId = (int)($_POST['state_id'] ?? 0); if ($stateId <= 0) { visuJson(['ok' => false, 'error' => 'Zustand fehlt.'], 400); } $stmt = $pdo->prepare("SELECT image_file FROM visu_asset_states WHERE id = ? LIMIT 1"); $stmt->execute([$stateId]); $file = $stmt->fetchColumn(); $stmt = $pdo->prepare("DELETE FROM visu_asset_states WHERE id = ?"); $stmt->execute([$stateId]); visuDeleteAssetImageIfUnused($pdo, (string)$file); visuJson(['ok' => true]); } if ($action === 'elements_save') { if (!$canEditDesigner) { visuJson(['ok' => false, 'error' => 'Kein Recht zum Speichern.'], 403); } $pageId = (int)($_POST['page_id'] ?? 0); $elementsRaw = (string)($_POST['elements_json'] ?? '[]'); $page = visuPageVisible($pdo, $pageId, $userId, $canViewAll); if (!$page) { visuJson(['ok' => false, 'error' => 'Seite nicht sichtbar.'], 403); } $elements = json_decode($elementsRaw, true); if (!is_array($elements)) { visuJson(['ok' => false, 'error' => 'Ungültiges Elements-JSON.'], 400); } $keepIds = []; try { $pdo->beginTransaction(); foreach ($elements as $el) { if (!is_array($el)) { continue; } $id = (int)($el['id'] ?? 0); $elementType = (string)($el['element_type'] ?? 'asset'); if (!in_array($elementType, ['asset', 'value', 'text', 'shape', 'image'], true)) { $elementType = 'asset'; } $name = visuCleanString($el['name'] ?? null); $label = visuCleanString($el['label'] ?? null); $variableId = isset($el['variable_id']) && (int)$el['variable_id'] > 0 ? (int)$el['variable_id'] : null; $assetId = isset($el['asset_id']) && (int)$el['asset_id'] > 0 ? (int)$el['asset_id'] : null; $x = (float)($el['x'] ?? 0); $y = (float)($el['y'] ?? 0); $w = max(5, (float)($el['w'] ?? 120)); $h = max(5, (float)($el['h'] ?? 120)); $rotation = (float)($el['rotation'] ?? 0); $zIndex = (int)($el['z_index'] ?? 10); $opacity = min(1, max(0, (float)($el['opacity'] ?? 1))); $isLocked = !empty($el['is_locked']) ? 1 : 0; $stateRules = $el['state_rules'] ?? null; $styleJson = $el['style_json'] ?? null; $stateRulesJson = $stateRules === null ? null : json_encode($stateRules, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); $styleJsonText = $styleJson === null ? null : json_encode($styleJson, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); if ($id > 0) { $stmt = $pdo->prepare(" UPDATE visu_elements SET asset_id = ?, element_type = ?, name = ?, variable_id = ?, label = ?, x = ?, y = ?, w = ?, h = ?, rotation = ?, z_index = ?, opacity = ?, state_rules = ?, style_json = ?, is_locked = ? WHERE id = ? AND page_id = ? "); $stmt->execute([ $assetId, $elementType, $name, $variableId, $label, $x, $y, $w, $h, $rotation, $zIndex, $opacity, $stateRulesJson, $styleJsonText, $isLocked, $id, $pageId, ]); $dbElementId = $id; } else { $stmt = $pdo->prepare(" INSERT INTO visu_elements ( page_id, asset_id, element_type, name, variable_id, label, x, y, w, h, rotation, z_index, opacity, state_rules, style_json, is_locked ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) "); $stmt->execute([ $pageId, $assetId, $elementType, $name, $variableId, $label, $x, $y, $w, $h, $rotation, $zIndex, $opacity, $stateRulesJson, $styleJsonText, $isLocked, ]); $dbElementId = (int)$pdo->lastInsertId(); } $keepIds[] = $dbElementId; $stmt = $pdo->prepare("DELETE FROM visu_element_state_rules WHERE element_id = ?"); $stmt->execute([$dbElementId]); $rules = $el['rules'] ?? []; if (is_array($rules)) { foreach ($rules as $rule) { if (!is_array($rule)) { continue; } $assetStateId = (int)($rule['asset_state_id'] ?? 0); if ($assetStateId <= 0) { continue; } $variableType = (string)($rule['variable_type'] ?? 'digital'); if (!in_array($variableType, ['analog', 'digital', 'alarm', 'setpoint'], true)) { $variableType = 'digital'; } $operator = (string)($rule['operator'] ?? 'eq'); if (!in_array($operator, ['eq', 'neq', 'gt', 'gte', 'lt', 'lte', 'contains', 'not_contains', 'truthy', 'falsy'], true)) { $operator = 'eq'; } $stmt = $pdo->prepare(" INSERT INTO visu_element_state_rules ( element_id, asset_state_id, variable_type, external_id, variable_id, point_key, point_label, operator, compare_value, priority ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) "); $stmt->execute([ $dbElementId, $assetStateId, $variableType, visuCleanString($rule['external_id'] ?? null), !empty($rule['variable_id']) ? (int)$rule['variable_id'] : null, visuCleanString($rule['point_key'] ?? null), visuCleanString($rule['point_label'] ?? null), $operator, visuCleanString($rule['compare_value'] ?? null), (int)($rule['priority'] ?? 100), ]); } } } if (!empty($keepIds)) { $ph = implode(',', array_fill(0, count($keepIds), '?')); $params = array_merge([$pageId], $keepIds); $stmt = $pdo->prepare("DELETE FROM visu_elements WHERE page_id = ? AND id NOT IN ($ph)"); $stmt->execute($params); } else { $stmt = $pdo->prepare("DELETE FROM visu_elements WHERE page_id = ?"); $stmt->execute([$pageId]); } $stmt = $pdo->prepare("UPDATE visu_pages SET updated_at = CURRENT_TIMESTAMP WHERE id = ?"); $stmt->execute([$pageId]); $pdo->commit(); } catch (Throwable $e) { if ($pdo->inTransaction()) { $pdo->rollBack(); } visuJson(['ok' => false, 'error' => $e->getMessage()], 500); } visuJson(['ok' => true]); } if ($action === 'live_values') { $pageId = (int)($_GET['page_id'] ?? 0); $page = visuPageVisible($pdo, $pageId, $userId, $canViewAll); if (!$page) { visuJson(['ok' => false, 'error' => 'Seite nicht sichtbar.'], 403); } $values = []; foreach (visuGetProjectPointsWithVirtual($pdo, (int)$page['project_id'], true) as $point) { $values[$point['source_key']] = $point; if (!empty($point['variable_id'])) { $variableKey = (string)$point['variable_id']; if ( !isset($values[$variableKey]) || !visuPointHasDisplayValue($values[$variableKey]) || visuPointHasDisplayValue($point) ) { $values[$variableKey] = $point; } } } visuJson([ 'ok' => true, 'values' => $values, 'can_write_setpoints' => $canWriteSetpoints, ]); } function canAccessVisuPage(PDO $pdo, int $pageId, int $userId, bool $canViewAll): bool { if ($pageId <= 0 || $userId <= 0) { return false; } if ($canViewAll) { $stmt = $pdo->prepare(" SELECT 1 FROM visu_pages vp WHERE vp.id = ? AND vp.is_active = 1 LIMIT 1 "); $stmt->execute([$pageId]); return (bool)$stmt->fetchColumn(); } $stmt = $pdo->prepare(" SELECT 1 FROM visu_pages vp INNER JOIN projects p ON p.id = vp.project_id WHERE vp.id = ? AND vp.is_active = 1 AND ( EXISTS ( SELECT 1 FROM project_assignments pa WHERE pa.project_id = p.id AND pa.user_id = ? ) OR EXISTS ( SELECT 1 FROM project_groups pg JOIN roles r ON r.key_name = pg.group_name JOIN user_roles ur ON ur.role_id = r.id WHERE pg.project_id = p.id AND ur.user_id = ? ) ) LIMIT 1 "); $stmt->execute([$pageId, $userId, $userId]); return (bool)$stmt->fetchColumn(); } if ($action === 'set_value') { if (!$canWriteSetpoints) { http_response_code(403); echo json_encode([ 'ok' => false, 'error' => 'Keine Berechtigung zum Schreiben von Sollwerten.' ]); exit; } $pageId = (int)($_POST['page_id'] ?? 0); $externalId = trim((string)($_POST['external_id'] ?? '')); $variableId = (int)($_POST['variable_id'] ?? 0); $pointKey = trim((string)($_POST['point_key'] ?? '')); $value = trim((string)($_POST['value'] ?? '')); if ($pageId <= 0 || $value === '') { http_response_code(400); echo json_encode([ 'ok' => false, 'error' => 'Ungültige Sollwertdaten.' ]); exit; } if (!canAccessVisuPage($pdo, $pageId, $userId, $canViewAll)) { http_response_code(403); echo json_encode([ 'ok' => false, 'error' => 'Kein Zugriff auf diese Visualisierung.' ]); exit; } /* Wichtig: Für Symcon/API schreiben wir bevorzugt über variable_id. Falls deine getsetpoints stattdessen point_key/external_id braucht, bleibt beides im Payload drin. */ $postData = [ 'api_key' => $mainApiKey, 'function' => 'setvalue', 'value' => $value, ]; if ($variableId > 0) { $postData['id'] = (string)$variableId; } if ($externalId !== '') { $postData['external_id'] = $externalId; } if ($pointKey !== '') { $postData['point_key'] = $pointKey; } $ch = curl_init('https://main.api.se-inno.de'); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_POST => true, CURLOPT_POSTFIELDS => $postData, CURLOPT_TIMEOUT => 15, ]); $resp = curl_exec($ch); $err = curl_error($ch); $code = (int)curl_getinfo($ch, CURLINFO_HTTP_CODE); curl_close($ch); if ($err) { http_response_code(502); echo json_encode([ 'ok' => false, 'error' => 'Upstream Fehler: ' . $err ]); exit; } $raw = trim((string)$resp); /* main.api kann JSON liefern, muss aber nicht. Deshalb akzeptieren wir auch: OK, 1, true, saved, success usw. */ $decoded = json_decode($raw, true); if (is_array($decoded)) { $upstreamOk = ($decoded['ok'] ?? null) === true || ($decoded['success'] ?? null) === true || (string)($decoded['status'] ?? '') === 'ok' || (string)($decoded['result'] ?? '') === 'ok'; if (!$upstreamOk && isset($decoded['error'])) { http_response_code(502); echo json_encode([ 'ok' => false, 'error' => 'Upstream: ' . (is_string($decoded['error']) ? $decoded['error'] : json_encode($decoded['error'])), 'raw' => $raw, ]); exit; } echo json_encode([ 'ok' => true, 'upstream' => $decoded, ], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); exit; } $okRawValues = ['ok', '1', 'true', 'success', 'saved', 'gespeichert']; if ( $code >= 200 && $code < 300 && ( $raw === '' || in_array(mb_strtolower($raw), $okRawValues, true) || str_contains(mb_strtolower($raw), 'ok') || str_contains(mb_strtolower($raw), 'success') ) ) { echo json_encode([ 'ok' => true, 'raw' => $raw, ], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); exit; } http_response_code(502); echo json_encode([ 'ok' => false, 'error' => 'Upstream hat keine gültige Erfolgsantwort geliefert.', 'http_code' => $code, 'raw' => $raw, ], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); exit; } visuJson(['ok' => false, 'error' => 'Unbekannte Aktion.'], 400);