Files
2026-06-22 15:52:25 +02:00

4219 lines
129 KiB
PHP

<?php
require '../config.php';
require '../auth.php';
if (!isset($_SESSION['username'])) {
http_response_code(401);
header('Content-Type: application/json; charset=utf-8');
echo json_encode(['ok' => 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);