Feature: Nextcloud-Belegimport via WebDAV

This commit is contained in:
fds2610-bot 2026-04-10 21:30:32 +02:00
parent bd9d0c670d
commit 880c479024
4 changed files with 465 additions and 0 deletions

View file

@ -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

View file

@ -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 |

View file

@ -0,0 +1,71 @@
<?php
/**
* Nextcloud-Belegimport – CLI-Einstiegspunkt.
*
* Liest Belege aus dem konfigurierten Nextcloud-Ordner (WebDAV) und legt
* sie in Steuerkram an. Danach werden die Dateien in Nextcloud in den
* Erledigte-Ordner verschoben (oder gelöscht, wenn kein DONE-Ordner gesetzt).
*
* Aufruf:
* docker compose exec web php scripts/nextcloud-import.php
*
* Für Cronjob (im Kontext des Containers):
* * * * * * docker compose -f /path/to/Steuerkram/docker-compose.yml exec -T web \
* php scripts/nextcloud-import.php >> /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);

View file

@ -0,0 +1,353 @@
<?php
declare(strict_types=1);
namespace Steuerkram\Services;
use Steuerkram\Database;
use Steuerkram\Models\Beleg;
/**
* Importiert Belege aus einem Nextcloud-Ordner via WebDAV.
*
* Konfiguration über Umgebungsvariablen:
* NEXTCLOUD_URL – Basis-URL der Nextcloud, z.B. https://cloud.example.com
* NEXTCLOUD_USER – Benutzername
* NEXTCLOUD_PASS – Passwort oder App-Passwort
* NEXTCLOUD_FOLDER – Pfad zum Import-Ordner, z.B. /Belege/Import
* NEXTCLOUD_DONE – Pfad für verarbeitete Dateien (optional), z.B. /Belege/Importiert
*
* Ablauf pro Datei:
* 1. PROPFIND → Dateiliste
* 2. GET → Datei herunterladen in temporäre Datei
* 3. Duplikat-Check via SHA-256 gegen belege.file_hash
* 4. In upload-Verzeichnis ablegen
* 5. JPEG/PNG: weißen Rand wegschneiden
* 6. Hash nach Crop neu berechnen
* 7. Beleg in Datenbank anlegen
* 8. MOVE → Datei in erledigte-Ordner verschieben (oder löschen falls kein DONE-Ordner)
*/
class NextcloudImportService
{
private const ALLOWED_MIME = [
'application/pdf' => '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 = '<?xml version="1.0" encoding="UTF-8"?>'
. '<d:propfind xmlns:d="DAV:">'
. '<d:prop><d:displayname/><d:getcontenttype/><d:getcontentlength/><d:resourcetype/></d:prop>'
. '</d:propfind>';
$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;
}
}