Feature: Nextcloud-Belegimport via WebDAV
This commit is contained in:
parent
bd9d0c670d
commit
880c479024
4 changed files with 465 additions and 0 deletions
|
|
@ -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
|
||||
|
|
|
|||
33
README.md
33
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 |
|
||||
|
|
|
|||
71
scripts/nextcloud-import.php
Normal file
71
scripts/nextcloud-import.php
Normal 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);
|
||||
353
src/Services/NextcloudImportService.php
Normal file
353
src/Services/NextcloudImportService.php
Normal 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;
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue