kundenkarte/class/anlagebackup.class.php
data 844e6060c6 feat(pwa): Offline-fähige Progressive Web App für Elektriker
PWA Mobile App für Schaltschrank-Dokumentation vor Ort:
- Token-basierte Authentifizierung (15 Tage gültig)
- Kundensuche mit Offline-Cache
- Anlagen-Auswahl und Offline-Laden
- Felder/Hutschienen/Automaten erfassen
- Automatische Synchronisierung wenn wieder online
- Installierbar auf dem Smartphone Home Screen
- Touch-optimiertes Dark Mode Design
- Quick-Select für Automaten-Werte (B16, C32, etc.)

Schaltplan-Editor Verbesserungen:
- Block Hover-Tooltip mit show_in_hover Feldern
- Produktinfo mit Icon im Tooltip
- Position und Breite in TE

Neue Dateien:
- pwa.php, pwa_auth.php - PWA Einstieg & Auth
- ajax/pwa_api.php - PWA AJAX API
- js/pwa.js, css/pwa.css - PWA App & Styles
- sw.js, manifest.json - Service Worker & Manifest
- img/pwa-icon-192.png, img/pwa-icon-512.png

Version: 5.2.0

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-23 15:27:06 +01:00

558 lines
13 KiB
PHP
Executable file

<?php
/* Copyright (C) 2026 Alles Watt lauft
*
* This program is free software; you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation; either version 3 of the License, or
* (at your option) any later version.
*/
/**
* Class AnlageBackup
* Handles backup and restore of all installation data
*/
class AnlageBackup
{
public $db;
public $error;
public $errors = array();
// Tables to backup (in order for foreign key constraints)
private $tables = array(
'kundenkarte_anlage_system',
'kundenkarte_anlage_type',
'kundenkarte_anlage_type_field',
'kundenkarte_customer_system',
'kundenkarte_anlage',
'kundenkarte_anlage_files',
'kundenkarte_anlage_connection',
'kundenkarte_equipment_panel',
'kundenkarte_equipment_carrier',
'kundenkarte_equipment',
'kundenkarte_medium_type',
'kundenkarte_busbar_type',
'kundenkarte_building_type',
);
/**
* Constructor
*
* @param DoliDB $db Database handler
*/
public function __construct($db)
{
$this->db = $db;
}
/**
* Create a full backup
*
* @param bool $includeFiles Include uploaded files in backup
* @return string|false Path to backup file or false on error
*/
public function createBackup($includeFiles = true)
{
global $conf;
$backupDir = $conf->kundenkarte->dir_output.'/backups';
if (!is_dir($backupDir)) {
dol_mkdir($backupDir);
}
$timestamp = date('Y-m-d_H-i-s');
$backupName = 'kundenkarte_backup_'.$timestamp;
$tempDir = $backupDir.'/'.$backupName;
if (!dol_mkdir($tempDir)) {
$this->error = 'Cannot create backup directory';
return false;
}
// Export database tables
$dbData = $this->exportDatabaseTables();
if ($dbData === false) {
return false;
}
// Save database export as JSON
$dbFile = $tempDir.'/database.json';
if (file_put_contents($dbFile, json_encode($dbData, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE)) === false) {
$this->error = 'Cannot write database file';
return false;
}
// Create metadata file
$metadata = array(
'version' => '3.6.0',
'created' => date('Y-m-d H:i:s'),
'tables' => array_keys($dbData),
'record_counts' => array(),
'includes_files' => $includeFiles,
);
foreach ($dbData as $table => $records) {
$metadata['record_counts'][$table] = count($records);
}
file_put_contents($tempDir.'/metadata.json', json_encode($metadata, JSON_PRETTY_PRINT));
// Copy uploaded files if requested
if ($includeFiles) {
$filesDir = $conf->kundenkarte->dir_output.'/anlagen';
if (is_dir($filesDir)) {
$this->copyDirectory($filesDir, $tempDir.'/files');
}
}
// Create ZIP archive
$zipFile = $backupDir.'/'.$backupName.'.zip';
if (!$this->createZipArchive($tempDir, $zipFile)) {
$this->error = 'Cannot create ZIP archive';
return false;
}
// Clean up temp directory
$this->deleteDirectory($tempDir);
return $zipFile;
}
/**
* Export all database tables
*
* @return array|false Array of table data or false on error
*/
private function exportDatabaseTables()
{
global $conf;
$data = array();
foreach ($this->tables as $table) {
$fullTable = MAIN_DB_PREFIX.$table;
// Check if table exists
$sql = "SHOW TABLES LIKE '".$this->db->escape($fullTable)."'";
$resql = $this->db->query($sql);
if (!$resql || $this->db->num_rows($resql) == 0) {
continue; // Skip non-existent tables
}
$records = array();
$sql = "SELECT * FROM ".$fullTable;
$sql .= " WHERE entity = ".((int) $conf->entity);
$resql = $this->db->query($sql);
if ($resql) {
while ($obj = $this->db->fetch_array($resql)) {
$records[] = $obj;
}
$this->db->free($resql);
}
$data[$table] = $records;
}
return $data;
}
/**
* Restore from a backup file
*
* @param string $backupFile Path to backup ZIP file
* @param bool $clearExisting Clear existing data before restore
* @return bool True on success, false on error
*/
public function restoreBackup($backupFile, $clearExisting = false)
{
global $conf, $user;
if (!file_exists($backupFile)) {
$this->error = 'Backup file not found';
return false;
}
// Create temp directory for extraction
$tempDir = $conf->kundenkarte->dir_output.'/backups/restore_'.uniqid();
if (!dol_mkdir($tempDir)) {
$this->error = 'Cannot create temp directory';
return false;
}
// Extract ZIP
$zip = new ZipArchive();
if ($zip->open($backupFile) !== true) {
$this->error = 'Cannot open backup file';
return false;
}
$zip->extractTo($tempDir);
$zip->close();
// Read metadata
$metadataFile = $tempDir.'/metadata.json';
if (!file_exists($metadataFile)) {
$this->error = 'Invalid backup: metadata.json not found';
$this->deleteDirectory($tempDir);
return false;
}
$metadata = json_decode(file_get_contents($metadataFile), true);
// Read database data
$dbFile = $tempDir.'/database.json';
if (!file_exists($dbFile)) {
$this->error = 'Invalid backup: database.json not found';
$this->deleteDirectory($tempDir);
return false;
}
$dbData = json_decode(file_get_contents($dbFile), true);
$this->db->begin();
try {
// Clear existing data if requested
if ($clearExisting) {
$this->clearExistingData();
}
// Import database tables (in correct order)
foreach ($this->tables as $table) {
if (isset($dbData[$table])) {
$this->importTable($table, $dbData[$table]);
}
}
// Restore files if included
if (!empty($metadata['includes_files']) && is_dir($tempDir.'/files')) {
$filesDir = $conf->kundenkarte->dir_output.'/anlagen';
if (!is_dir($filesDir)) {
dol_mkdir($filesDir);
}
$this->copyDirectory($tempDir.'/files', $filesDir);
}
$this->db->commit();
} catch (Exception $e) {
$this->db->rollback();
$this->error = $e->getMessage();
$this->deleteDirectory($tempDir);
return false;
}
// Clean up
$this->deleteDirectory($tempDir);
return true;
}
/**
* Clear existing data for this entity
*/
private function clearExistingData()
{
global $conf;
// Delete in reverse order to respect foreign keys
$reverseTables = array_reverse($this->tables);
foreach ($reverseTables as $table) {
$fullTable = MAIN_DB_PREFIX.$table;
// Check if table exists
$sql = "SHOW TABLES LIKE '".$this->db->escape($fullTable)."'";
$resql = $this->db->query($sql);
if (!$resql || $this->db->num_rows($resql) == 0) {
continue;
}
$sql = "DELETE FROM ".$fullTable." WHERE entity = ".((int) $conf->entity);
$this->db->query($sql);
}
}
/**
* Import data into a table
*
* @param string $table Table name (without prefix)
* @param array $records Array of records
*/
private function importTable($table, $records)
{
global $conf;
if (empty($records)) {
return;
}
$fullTable = MAIN_DB_PREFIX.$table;
// Check if table exists
$sql = "SHOW TABLES LIKE '".$this->db->escape($fullTable)."'";
$resql = $this->db->query($sql);
if (!$resql || $this->db->num_rows($resql) == 0) {
return;
}
// Get column info
$columns = array();
$sql = "SHOW COLUMNS FROM ".$fullTable;
$resql = $this->db->query($sql);
while ($obj = $this->db->fetch_object($resql)) {
$columns[] = $obj->Field;
}
foreach ($records as $record) {
// Build insert statement
$fields = array();
$values = array();
foreach ($record as $field => $value) {
if (!in_array($field, $columns)) {
continue; // Skip unknown columns
}
$fields[] = $field;
if ($value === null) {
$values[] = 'NULL';
} elseif (is_numeric($value)) {
$values[] = $value;
} else {
$values[] = "'".$this->db->escape($value)."'";
}
}
if (empty($fields)) {
continue;
}
$sql = "INSERT INTO ".$fullTable." (".implode(', ', $fields).") VALUES (".implode(', ', $values).")";
$sql .= " ON DUPLICATE KEY UPDATE ";
$updates = array();
foreach ($fields as $i => $field) {
if ($field != 'rowid') {
$updates[] = $field." = ".$values[$i];
}
}
$sql .= implode(', ', $updates);
if (!$this->db->query($sql)) {
throw new Exception('Error importing '.$table.': '.$this->db->lasterror());
}
}
}
/**
* Get list of available backups
*
* @return array Array of backup info
*/
public function getBackupList()
{
global $conf;
$backups = array();
$backupDir = $conf->kundenkarte->dir_output.'/backups';
if (!is_dir($backupDir)) {
return $backups;
}
$files = glob($backupDir.'/kundenkarte_backup_*.zip');
if ($files) {
foreach ($files as $file) {
$filename = basename($file);
// Extract date from filename
if (preg_match('/kundenkarte_backup_(\d{4}-\d{2}-\d{2}_\d{2}-\d{2}-\d{2})\.zip/', $filename, $matches)) {
$date = str_replace('_', ' ', $matches[1]);
$date = str_replace('-', ':', substr($date, 11));
$date = substr($matches[1], 0, 10).' '.$date;
$backups[] = array(
'file' => $file,
'filename' => $filename,
'date' => $date,
'size' => filesize($file),
);
}
}
}
// Sort by date descending
usort($backups, function ($a, $b) {
return strcmp($b['date'], $a['date']);
});
return $backups;
}
/**
* Delete a backup file
*
* @param string $filename Backup filename
* @return bool
*/
public function deleteBackup($filename)
{
global $conf;
$file = $conf->kundenkarte->dir_output.'/backups/'.basename($filename);
if (file_exists($file) && strpos($filename, 'kundenkarte_backup_') === 0) {
return unlink($file);
}
return false;
}
/**
* Copy directory recursively
*
* @param string $src Source directory
* @param string $dst Destination directory
*/
private function copyDirectory($src, $dst)
{
if (!is_dir($dst)) {
dol_mkdir($dst);
}
$dir = opendir($src);
while (($file = readdir($dir)) !== false) {
if ($file == '.' || $file == '..') {
continue;
}
$srcFile = $src.'/'.$file;
$dstFile = $dst.'/'.$file;
if (is_dir($srcFile)) {
$this->copyDirectory($srcFile, $dstFile);
} else {
copy($srcFile, $dstFile);
}
}
closedir($dir);
}
/**
* Delete directory recursively
*
* @param string $dir Directory path
*/
private function deleteDirectory($dir)
{
if (!is_dir($dir)) {
return;
}
$files = array_diff(scandir($dir), array('.', '..'));
foreach ($files as $file) {
$path = $dir.'/'.$file;
if (is_dir($path)) {
$this->deleteDirectory($path);
} else {
unlink($path);
}
}
rmdir($dir);
}
/**
* Create ZIP archive from directory
*
* @param string $sourceDir Source directory
* @param string $zipFile Target ZIP file
* @return bool
*/
private function createZipArchive($sourceDir, $zipFile)
{
$zip = new ZipArchive();
if ($zip->open($zipFile, ZipArchive::CREATE | ZipArchive::OVERWRITE) !== true) {
return false;
}
$sourceDir = realpath($sourceDir);
$files = new RecursiveIteratorIterator(
new RecursiveDirectoryIterator($sourceDir),
RecursiveIteratorIterator::LEAVES_ONLY
);
foreach ($files as $file) {
if (!$file->isDir()) {
$filePath = $file->getRealPath();
$relativePath = substr($filePath, strlen($sourceDir) + 1);
$zip->addFile($filePath, $relativePath);
}
}
return $zip->close();
}
/**
* Get backup statistics
*
* @return array Statistics array
*/
public function getStatistics()
{
global $conf;
$stats = array(
'total_anlagen' => 0,
'total_files' => 0,
'total_connections' => 0,
'total_customers' => 0,
'files_size' => 0,
);
// Count anlagen
$sql = "SELECT COUNT(*) as cnt FROM ".MAIN_DB_PREFIX."kundenkarte_anlage WHERE entity = ".((int) $conf->entity);
$resql = $this->db->query($sql);
if ($resql && $obj = $this->db->fetch_object($resql)) {
$stats['total_anlagen'] = $obj->cnt;
}
// Count files
$sql = "SELECT COUNT(*) as cnt FROM ".MAIN_DB_PREFIX."kundenkarte_anlage_files WHERE entity = ".((int) $conf->entity);
$resql = $this->db->query($sql);
if ($resql && $obj = $this->db->fetch_object($resql)) {
$stats['total_files'] = $obj->cnt;
}
// Count connections
$sql = "SELECT COUNT(*) as cnt FROM ".MAIN_DB_PREFIX."kundenkarte_anlage_connection WHERE entity = ".((int) $conf->entity);
$resql = $this->db->query($sql);
if ($resql && $obj = $this->db->fetch_object($resql)) {
$stats['total_connections'] = $obj->cnt;
}
// Count customers with anlagen
$sql = "SELECT COUNT(DISTINCT fk_soc) as cnt FROM ".MAIN_DB_PREFIX."kundenkarte_anlage WHERE entity = ".((int) $conf->entity);
$resql = $this->db->query($sql);
if ($resql && $obj = $this->db->fetch_object($resql)) {
$stats['total_customers'] = $obj->cnt;
}
// Calculate files size
$filesDir = $conf->kundenkarte->dir_output.'/anlagen';
if (is_dir($filesDir)) {
$stats['files_size'] = $this->getDirectorySize($filesDir);
}
return $stats;
}
/**
* Get directory size recursively
*
* @param string $dir Directory path
* @return int Size in bytes
*/
private function getDirectorySize($dir)
{
$size = 0;
foreach (new RecursiveIteratorIterator(new RecursiveDirectoryIterator($dir)) as $file) {
if ($file->isFile()) {
$size += $file->getSize();
}
}
return $size;
}
}