Steuerkram/src/Services/NextcloudImportService.php
2026-06-29 18:21:05 +02:00

389 lines
13 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<?php
declare(strict_types=1);
namespace Steuerkram\Services;
use Steuerkram\Database;
use Steuerkram\Models\Beleg;
use Steuerkram\Models\EventLog;
use Steuerkram\Models\Setting;
/**
* Importiert Belege aus einem Nextcloud-Ordner via WebDAV.
*
* Konfiguration über Einstellungen → Nextcloud-Belegimport (gespeichert in company_settings):
* 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;
private array $pendingEvents = [];
public function __construct()
{
$cfg = Setting::get();
$this->baseUrl = rtrim($cfg['nextcloud_url'] ?? '', '/');
$this->user = $cfg['nextcloud_user'] ?? '';
$this->pass = $cfg['nextcloud_pass'] ?? '';
$this->folder = '/' . trim($cfg['nextcloud_folder'] ?: 'Belege/Import', '/');
$doneRaw = $cfg['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
{
$start = microtime(true);
$result = ['imported' => 0, 'skipped' => 0, 'errors' => 0, 'details' => [], 'imported_ids' => []];
if (!$this->baseUrl || !$this->user || !$this->pass) {
$msg = 'FEHLER: NEXTCLOUD_URL, NEXTCLOUD_USER und NEXTCLOUD_PASS müssen gesetzt sein.';
$result['details'][] = $msg;
$result['errors']++;
$this->persistEvent($msg);
return $result;
}
$files = $this->listFiles();
if ($files === null) {
$msg = 'FEHLER: PROPFIND fehlgeschlagen. URL/Zugangsdaten prüfen.';
$result['details'][] = $msg;
$result['errors']++;
$this->persistEvent($msg);
return $result;
}
$result['details'][] = count($files) . ' Datei(en) im Import-Ordner gefunden.';
foreach ($files as $file) {
$this->processFile($file, $result);
}
if ($result['imported'] + $result['skipped'] + $result['errors'] > 0) {
$elapsed = round(microtime(true) - $start, 2);
$summary = sprintf(
'Fertig in %ss – importiert: %d, übersprungen: %d, Fehler: %d',
$elapsed, $result['imported'], $result['skipped'], $result['errors']
);
$this->persistEvent($summary);
$result['details'][] = $summary;
}
return $result;
}
private function persistEvent(string $message): void
{
EventLog::add('nextcloud-import', $message);
}
/**
* 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])) {
$msg = "Übersprungen (unbekannter Typ «{$contentType}»): {$filename}";
$result['details'][] = $msg;
$this->persistEvent($msg);
$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)) {
$msg = "FEHLER beim Download: {$filename}";
$result['details'][] = $msg;
$this->persistEvent($msg);
$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) {
$msg = "Duplikat (Beleg {$existing['beleg_nr']}): {$filename}";
$result['details'][] = $msg;
$this->persistEvent($msg);
$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)) {
$msg = "FEHLER: Verzeichnis konnte nicht erstellt werden: {$absDir}";
$result['details'][] = $msg;
$this->persistEvent($msg);
$result['errors']++;
unlink($tmpPath);
return;
}
$storagePath = "{$relDir}/original.{$ext}";
$absPath = dirname(__DIR__, 2) . "/public/{$storagePath}";
if (!rename($tmpPath, $absPath)) {
$msg = "FEHLER: Datei konnte nicht verschoben werden: {$filename}";
$result['details'][] = $msg;
$this->persistEvent($msg);
$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');
$belegId = Beleg::create([
'beleg_nr' => $belegNr,
'filename' => $filename,
'storage_path' => $storagePath,
'mime_type' => $contentType,
'file_hash' => $hash,
'beleg_datum' => null,
'rechnungs_nr' => null,
'betrag_brutto' => null,
'belegtyp' => 'Eingangsrechnung',
'notes' => 'Nextcloud-Import',
]);
// Datei in Nextcloud verschieben/löschen
$this->moveFile($file['href'], $filename);
$msg = "Importiert: {$filename} → Beleg {$belegNr}";
$result['details'][] = $msg;
$this->persistEvent($msg);
$result['imported']++;
$result['imported_ids'][] = $belegId;
}
/**
* 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;
}
}