diff --git a/Changes.md b/Changes.md index 6aa5117..e9e3489 100644 --- a/Changes.md +++ b/Changes.md @@ -2,6 +2,14 @@ --- +## 2026-04-10 – Nextcloud-Belegimport via WebDAV + +### Neu +- `NextcloudImportService`: importiert Belege aus einem Nextcloud-Ordner via WebDAV (PROPFIND → GET → MOVE/DELETE); Duplikat-Erkennung via SHA-256; JPEG/PNG werden nach dem Download automatisch zugeschnitten +- `scripts/nextcloud-import.php`: CLI-Einstiegspunkt für Cron-Betrieb; Konfiguration über `.env`-Variablen `NEXTCLOUD_URL`, `NEXTCLOUD_USER`, `NEXTCLOUD_PASS`, `NEXTCLOUD_FOLDER`, `NEXTCLOUD_DONE` + +--- + ## 2026-04-10 – UStVA Vorsteuer-Kz-Aufteilung (Migration 022) ### Fix diff --git a/README.md b/README.md index 6225f48..47a2643 100644 --- a/README.md +++ b/README.md @@ -162,6 +162,39 @@ post_max_size = 20M docker compose up -d --build ``` +## Belege automatisch aus Nextcloud importieren + +Belege können automatisch aus einem Nextcloud-Ordner via WebDAV importiert werden. Das CLI-Skript `scripts/nextcloud-import.php` liest alle unterstützten Dateien (PDF, JPEG, PNG, TIFF, XML) aus dem konfigurierten Ordner, legt sie als Belege an und verschiebt sie anschließend in einen Erledigte-Ordner (oder löscht sie, wenn kein Ziel-Ordner konfiguriert ist). + +### Konfiguration + +Umgebungsvariablen in der `.env`-Datei: + +```env +NEXTCLOUD_URL=https://cloud.example.com +NEXTCLOUD_USER=meinbenutzername +NEXTCLOUD_PASS=mein-app-passwort +NEXTCLOUD_FOLDER=/Belege/Import # Ordner in Nextcloud, der überwacht wird +NEXTCLOUD_DONE=/Belege/Importiert # Ziel nach Import (leer lassen = Datei löschen) +``` + +**Empfehlung:** Ein Nextcloud-App-Passwort verwenden (Nextcloud → Einstellungen → Sicherheit → App-Passwörter). + +### Manueller Aufruf + +```bash +docker compose exec web php scripts/nextcloud-import.php +``` + +### Automatisch per Cron (auf dem Host) + +```cron +*/15 * * * * docker compose -f /opt/steuerkram/docker-compose.yml exec -T web \ + php scripts/nextcloud-import.php >> /var/log/steuerkram-import.log 2>&1 +``` + +Das Skript gibt beim Start eine Zusammenfassung aus (importiert / übersprungen / Fehler) und beendet sich mit Exit-Code 1 bei Fehlern. + ## Datenpersistenz | Pfad | Inhalt | diff --git a/scripts/nextcloud-import.php b/scripts/nextcloud-import.php new file mode 100644 index 0000000..06c53ad --- /dev/null +++ b/scripts/nextcloud-import.php @@ -0,0 +1,71 @@ +> /var/log/steuerkram-import.log 2>&1 + * + * Umgebungsvariablen (in .env oder Systemumgebung): + * NEXTCLOUD_URL Basis-URL, z.B. https://cloud.example.com + * NEXTCLOUD_USER Benutzername + * NEXTCLOUD_PASS Passwort oder App-Passwort + * NEXTCLOUD_FOLDER Import-Ordner, z.B. /Belege/Import (Standard: /Belege/Import) + * NEXTCLOUD_DONE Erledigte-Ordner, z.B. /Belege/Importiert (leer = Datei löschen) + */ + +declare(strict_types=1); + +chdir(dirname(__DIR__)); + +// .env laden falls vorhanden +if (file_exists('.env')) { + foreach (file('.env', FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES) as $line) { + if (str_starts_with(trim($line), '#') || !str_contains($line, '=')) { + continue; + } + [$key, $value] = explode('=', $line, 2); + $key = trim($key); + $value = trim($value, " \t\n\r\0\x0B\"'"); + if (!array_key_exists($key, $_ENV)) { + $_ENV[$key] = $value; + putenv("{$key}={$value}"); + } + } +} + +require_once 'vendor/autoload.php'; + +use Steuerkram\Config; +use Steuerkram\Services\NextcloudImportService; + +Config::load(); // Database verbindet sich lazy über Config::get() + +$start = microtime(true); +echo '[nextcloud-import] Start: ' . date('Y-m-d H:i:s') . PHP_EOL; + +$svc = new NextcloudImportService(); +$result = $svc->import(); + +foreach ($result['details'] as $line) { + echo '[nextcloud-import] ' . $line . PHP_EOL; +} + +$elapsed = round(microtime(true) - $start, 2); +echo sprintf( + '[nextcloud-import] Fertig in %ss – importiert: %d, übersprungen: %d, Fehler: %d' . PHP_EOL, + $elapsed, + $result['imported'], + $result['skipped'], + $result['errors'], +); + +exit($result['errors'] > 0 ? 1 : 0); diff --git a/src/Services/NextcloudImportService.php b/src/Services/NextcloudImportService.php new file mode 100644 index 0000000..961f4b4 --- /dev/null +++ b/src/Services/NextcloudImportService.php @@ -0,0 +1,353 @@ + 'pdf', + 'image/png' => 'png', + 'image/jpeg' => 'jpg', + 'image/tiff' => 'tif', + 'application/xml' => 'xml', + 'text/xml' => 'xml', + ]; + + private string $baseUrl; + private string $user; + private string $pass; + private string $folder; + private string|null $doneFolder; + private string $davBase; + + public function __construct() + { + $this->baseUrl = rtrim(getenv('NEXTCLOUD_URL') ?: '', '/'); + $this->user = getenv('NEXTCLOUD_USER') ?: ''; + $this->pass = getenv('NEXTCLOUD_PASS') ?: ''; + $this->folder = '/' . trim(getenv('NEXTCLOUD_FOLDER') ?: 'Belege/Import', '/'); + $doneRaw = getenv('NEXTCLOUD_DONE') ?: ''; + $this->doneFolder = $doneRaw !== '' ? ('/' . trim($doneRaw, '/')) : null; + $this->davBase = $this->baseUrl . '/remote.php/dav/files/' . rawurlencode($this->user); + } + + /** + * Führt den Import durch. Gibt ein Array mit Ergebnissen zurück: + * [ + * 'imported' => int, // neu importierte Belege + * 'skipped' => int, // übersprungene (Duplikat oder falscher Typ) + * 'errors' => int, // Fehler beim Verarbeiten + * 'details' => string[], // Log-Meldungen + * ] + */ + public function import(): array + { + $result = ['imported' => 0, 'skipped' => 0, 'errors' => 0, 'details' => []]; + + if (!$this->baseUrl || !$this->user || !$this->pass) { + $result['details'][] = 'FEHLER: NEXTCLOUD_URL, NEXTCLOUD_USER und NEXTCLOUD_PASS müssen gesetzt sein.'; + $result['errors']++; + return $result; + } + + $files = $this->listFiles(); + if ($files === null) { + $result['details'][] = 'FEHLER: PROPFIND fehlgeschlagen. URL/Zugangsdaten prüfen.'; + $result['errors']++; + return $result; + } + + $result['details'][] = count($files) . ' Datei(en) im Import-Ordner gefunden.'; + + foreach ($files as $file) { + $this->processFile($file, $result); + } + + return $result; + } + + /** + * Listet alle Dateien im konfigurierten Ordner via PROPFIND. + * Gibt null zurück bei Verbindungsfehler. + * + * @return array{href:string, filename:string, contenttype:string, size:int}[]|null + */ + private function listFiles(): ?array + { + $url = $this->davBase . $this->folder; + $body = '' + . '' + . '' + . ''; + + $ch = $this->newCurl($url); + curl_setopt($ch, CURLOPT_CUSTOMREQUEST, 'PROPFIND'); + curl_setopt($ch, CURLOPT_POSTFIELDS, $body); + curl_setopt($ch, CURLOPT_HTTPHEADER, [ + 'Content-Type: application/xml; charset=utf-8', + 'Depth: 1', + ]); + + $response = curl_exec($ch); + $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE); + curl_close($ch); + + if ($response === false || $httpCode < 200 || $httpCode >= 300) { + return null; + } + + return $this->parsePropfind((string)$response); + } + + /** + * Parst die PROPFIND-Antwort und gibt Datei-Einträge zurück + * (ohne den Ordner selbst, nur Nicht-Collection-Einträge). + * + * @return array{href:string, filename:string, contenttype:string, size:int}[] + */ + private function parsePropfind(string $xml): array + { + $files = []; + + $dom = new \DOMDocument(); + if (!@$dom->loadXML($xml)) { + return $files; + } + + $xpath = new \DOMXPath($dom); + $xpath->registerNamespace('d', 'DAV:'); + + $responses = $xpath->query('//d:response'); + if ($responses === false) { + return $files; + } + + foreach ($responses as $resp) { + // Ordner überspringen + $collection = $xpath->query('.//d:resourcetype/d:collection', $resp); + if ($collection !== false && $collection->length > 0) { + continue; + } + + $hrefNode = $xpath->query('.//d:href', $resp); + $contentTypeNode = $xpath->query('.//d:getcontenttype', $resp); + $sizeNode = $xpath->query('.//d:getcontentlength', $resp); + + if ($hrefNode === false || $hrefNode->length === 0) { + continue; + } + + $href = $hrefNode->item(0)->textContent; + $contentType = $contentTypeNode !== false && $contentTypeNode->length > 0 + ? strtolower(trim($contentTypeNode->item(0)->textContent)) + : ''; + $size = $sizeNode !== false && $sizeNode->length > 0 + ? (int)$sizeNode->item(0)->textContent + : 0; + + // MIME: nur den Typ, ohne charset etc. + if (str_contains($contentType, ';')) { + $contentType = trim(explode(';', $contentType)[0]); + } + + $files[] = [ + 'href' => $href, + 'filename' => rawurldecode(basename($href)), + 'contenttype' => $contentType, + 'size' => $size, + ]; + } + + return $files; + } + + /** + * Verarbeitet eine einzelne Datei: Download, Duplikat-Check, Import, Verschieben. + */ + private function processFile(array $file, array &$result): void + { + $filename = $file['filename']; + $contentType = $file['contenttype']; + + // MIME prüfen + if (!isset(self::ALLOWED_MIME[$contentType])) { + $result['details'][] = "Übersprungen (unbekannter Typ «{$contentType}»): {$filename}"; + $result['skipped']++; + return; + } + + $ext = self::ALLOWED_MIME[$contentType]; + + // Datei herunterladen + $fileUrl = $this->baseUrl . $file['href']; + $tmpPath = sys_get_temp_dir() . '/nextcloud_import_' . uniqid() . '.' . $ext; + + if (!$this->downloadFile($fileUrl, $tmpPath)) { + $result['details'][] = "FEHLER beim Download: {$filename}"; + $result['errors']++; + return; + } + + // Duplikat-Check via Hash (vor Crop, da sonst Hash-Vergleich sinnlos) + $rawHash = hash_file('sha256', $tmpPath); + $existing = Database::fetchOne( + 'SELECT id, beleg_nr FROM belege WHERE file_hash = ?', + [$rawHash] + ); + if ($existing) { + $result['details'][] = "Duplikat (Beleg {$existing['beleg_nr']}): {$filename}"; + $result['skipped']++; + unlink($tmpPath); + $this->moveFile($file['href'], $filename); + return; + } + + // In Upload-Verzeichnis ablegen + $belegNr = Beleg::generateBelegNr(); + $year = date('Y'); + $relDir = "static/uploads/{$year}/{$belegNr}"; + $absDir = dirname(__DIR__, 2) . "/public/{$relDir}"; + + if (!is_dir($absDir) && !mkdir($absDir, 0755, true)) { + $result['details'][] = "FEHLER: Verzeichnis konnte nicht erstellt werden: {$absDir}"; + $result['errors']++; + unlink($tmpPath); + return; + } + + $storagePath = "{$relDir}/original.{$ext}"; + $absPath = dirname(__DIR__, 2) . "/public/{$storagePath}"; + + if (!rename($tmpPath, $absPath)) { + $result['details'][] = "FEHLER: Datei konnte nicht verschoben werden: {$filename}"; + $result['errors']++; + @unlink($tmpPath); + return; + } + + // JPEG/PNG: weißen Rand wegschneiden + if (in_array($contentType, ['image/jpeg', 'image/png'], true)) { + (new ImageCropService())->cropWhitespace($absPath); + } + + // Hash nach Crop neu berechnen + $hash = hash_file('sha256', $absPath); + + // In Datenbank anlegen + $today = date('Y-m-d'); + Beleg::create([ + 'beleg_nr' => $belegNr, + 'filename' => $filename, + 'storage_path' => $storagePath, + 'mime_type' => $contentType, + 'file_hash' => $hash, + 'beleg_datum' => null, + 'eingangs_datum' => $today, + 'rechnungs_nr' => null, + 'betrag_brutto' => null, + 'belegtyp' => 'Eingangsrechnung', + 'notes' => 'Nextcloud-Import', + ]); + + // Datei in Nextcloud verschieben/löschen + $this->moveFile($file['href'], $filename); + + $result['details'][] = "Importiert: {$filename} → Beleg {$belegNr}"; + $result['imported']++; + } + + /** + * Lädt eine Datei per GET in eine lokale Datei herunter. + */ + private function downloadFile(string $url, string $targetPath): bool + { + $fh = fopen($targetPath, 'wb'); + if ($fh === false) { + return false; + } + + $ch = $this->newCurl($url); + curl_setopt($ch, CURLOPT_FILE, $fh); + + $ok = curl_exec($ch); + $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE); + curl_close($ch); + fclose($fh); + + if ($ok === false || $httpCode < 200 || $httpCode >= 300) { + @unlink($targetPath); + return false; + } + + return true; + } + + /** + * Verschiebt eine Nextcloud-Datei in den DONE-Ordner (MOVE). + * Falls kein DONE-Ordner konfiguriert, wird die Datei gelöscht (DELETE). + */ + private function moveFile(string $href, string $filename): void + { + $srcUrl = $this->baseUrl . $href; + + if ($this->doneFolder !== null) { + $destHref = $this->doneFolder . '/' . rawurlencode($filename); + $destUrl = $this->davBase . $destHref; + + $ch = $this->newCurl($srcUrl); + curl_setopt($ch, CURLOPT_CUSTOMREQUEST, 'MOVE'); + curl_setopt($ch, CURLOPT_HTTPHEADER, [ + 'Destination: ' . $destUrl, + 'Overwrite: T', + ]); + curl_exec($ch); + curl_close($ch); + } else { + $ch = $this->newCurl($srcUrl); + curl_setopt($ch, CURLOPT_CUSTOMREQUEST, 'DELETE'); + curl_exec($ch); + curl_close($ch); + } + } + + /** + * Erstellt einen vorkonfigurierten cURL-Handle mit Basis-Auth und TLS-Einstellungen. + * + * @return \CurlHandle + */ + private function newCurl(string $url): \CurlHandle + { + $ch = curl_init($url); + curl_setopt($ch, CURLOPT_USERPWD, $this->user . ':' . $this->pass); + curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); + curl_setopt($ch, CURLOPT_FOLLOWLOCATION, true); + curl_setopt($ch, CURLOPT_TIMEOUT, 60); + curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, true); + return $ch; + } +}