Merge pull request 'Fix #108: Lieferant in Belegliste inline bearbeiten' (#110) from fds2610-bot/Steuerkram:main into main

Reviewed-on: https://codeberg.org/fds2610/Steuerkram/pulls/110
This commit is contained in:
fds2610 2026-06-29 18:25:17 +02:00
commit 2de894ccd0
14 changed files with 364 additions and 12 deletions

View file

@ -2,6 +2,26 @@
--- ---
## 2026-06-29 – Feature: Ereignisse-Log für Nextcloud-Import (Issue #109)
- **`migrations/083_system_events.sql`:** Neue Tabelle `system_events` (id, channel, message, created_at)
- **`src/Models/EventLog.php`:** Neues Model mit `add()` und `findByChannel()`
- **`src/Services/NextcloudImportService.php`:** Persistiert relevante Ereignisse (Import, Duplikat, Fehler) automatisch; stille Läufe ohne Dateiaktivität erzeugen keine Einträge; Zusammenfassung wird nur bei tatsächlicher Aktivität geloggt
- **`src/Controllers/EventController.php`:** Neuer Controller für `/ereignisse` und `/ereignisse/nextcloud-import`
- **`src/Views/ereignisse/nextcloud_import.php`:** Log-Ansicht mit Zeitstempel, Kanal und farbiger Meldung
- **`src/Views/layout/nav.php`:** Neuer Menüpunkt „Ereignisse" unter Verwaltung mit aufklappbarem Unterpunkt „Nextcloud-Import"
---
## 2026-06-29 – Feature: Lieferant in Belegliste inline bearbeiten (Issue #108)
- **`src/Views/belege/list.php`:** Lieferant-Spalte ist direkt in der Liste klickbar; zeigt ein Inline-Textfeld zur Eingabe, speichert mit Enter, bricht mit Escape ab; andere Spalten öffnen weiterhin die Vorschau
- **`src/Models/Beleg.php`:** Neue Methode `updateSupplier()` aktualisiert nur die `supplier_id` ohne andere Metadaten zu verändern
- **`src/Controllers/BelegController.php`:** Neue Methode `updateSupplier()` als AJAX-Endpunkt
- **`public/index.php`:** Route `POST /belege/{id}/supplier` registriert
---
## 2026-06-11 – Fix: Undefined-Array-Key-Warning bei Buchungsvorlagen ohne Standard-Lieferant ## 2026-06-11 – Fix: Undefined-Array-Key-Warning bei Buchungsvorlagen ohne Standard-Lieferant
- **`src/Models/Buchungsvorlage.php`:** `create()`/`update()` prüften `$data['standard_lieferant']` direkt ohne Null-Coalescing, was bei fehlendem Schlüssel eine PHP-Warning auslöste - **`src/Models/Buchungsvorlage.php`:** `create()`/`update()` prüften `$data['standard_lieferant']` direkt ohne Null-Coalescing, was bei fehlendem Schlüssel eine PHP-Warning auslöste

View file

@ -0,0 +1,7 @@
CREATE TABLE IF NOT EXISTS system_events (
id INT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
channel VARCHAR(50) NOT NULL,
message TEXT NOT NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
INDEX idx_channel_created (channel, created_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

View file

@ -28,6 +28,7 @@ use Steuerkram\Controllers\AssetController;
use Steuerkram\Controllers\UserAdminController; use Steuerkram\Controllers\UserAdminController;
use Steuerkram\Controllers\BankController; use Steuerkram\Controllers\BankController;
use Steuerkram\Controllers\BuchungsvorlageController; use Steuerkram\Controllers\BuchungsvorlageController;
use Steuerkram\Controllers\EventController;
Config::load(); Config::load();
@ -60,8 +61,9 @@ $router->post('/belege/upload', fn($p) => (new BelegController())->upload($p));
$router->get('/belege/{id}', fn($p) => (new BelegController())->show($p)); $router->get('/belege/{id}', fn($p) => (new BelegController())->show($p));
$router->get('/belege/{id}/file', fn($p) => (new BelegController())->serveFile($p)); $router->get('/belege/{id}/file', fn($p) => (new BelegController())->serveFile($p));
$router->post('/belege/{id}/delete', fn($p) => (new BelegController())->destroy($p)); $router->post('/belege/{id}/delete', fn($p) => (new BelegController())->destroy($p));
$router->post('/belege/{id}/meta', fn($p) => (new BelegController())->updateMeta($p)); $router->post('/belege/{id}/meta', fn($p) => (new BelegController())->updateMeta($p));
$router->post('/belege/{id}/crop', fn($p) => (new BelegController())->cropImage($p)); $router->post('/belege/{id}/supplier', fn($p) => (new BelegController())->updateSupplier($p));
$router->post('/belege/{id}/crop', fn($p) => (new BelegController())->cropImage($p));
$router->post('/belege/{id}/vorschlag/generate', fn($p) => (new BelegController())->generateVorschlag($p)); $router->post('/belege/{id}/vorschlag/generate', fn($p) => (new BelegController())->generateVorschlag($p));
$router->post('/belege/{id}/vorschlag/reset', fn($p) => (new BelegController())->resetVorschlag($p)); $router->post('/belege/{id}/vorschlag/reset', fn($p) => (new BelegController())->resetVorschlag($p));
@ -183,6 +185,10 @@ $router->post('/einstellungen/buchungsvorlagen/{id}/loeschen', fn($p) =
$router->get('/api/buchungsvorlagen/{id}/modal', fn($p) => (new BuchungsvorlageController())->modal($p)); $router->get('/api/buchungsvorlagen/{id}/modal', fn($p) => (new BuchungsvorlageController())->modal($p));
$router->post('/belege/{belegId}/vorlage/{vorlageId}/buchen', fn($p) => (new BuchungsvorlageController())->buchen($p)); $router->post('/belege/{belegId}/vorlage/{vorlageId}/buchen', fn($p) => (new BuchungsvorlageController())->buchen($p));
// ── Ereignisse ───────────────────────────────────────────────
$router->get('/ereignisse', fn($p) => (new EventController())->index($p));
$router->get('/ereignisse/nextcloud-import', fn($p) => (new EventController())->nextcloudImport($p));
// ── Login / Logout ──────────────────────────────────────────── // ── Login / Logout ────────────────────────────────────────────
$router->get('/login', fn($p) => (new LoginController())->showLogin($p)); $router->get('/login', fn($p) => (new LoginController())->showLogin($p));
$router->post('/login', fn($p) => (new LoginController())->handleLogin($p)); $router->post('/login', fn($p) => (new LoginController())->handleLogin($p));

View file

@ -366,6 +366,30 @@ class BelegController extends BaseController
View::redirect("/belege/{$id}", 'Metadaten gespeichert.'); View::redirect("/belege/{$id}", 'Metadaten gespeichert.');
} }
public function updateSupplier(array $params): void
{
header('Content-Type: application/json');
$id = (int)$params['id'];
$beleg = Beleg::find($id);
if (!$beleg) {
http_response_code(404);
echo json_encode(['error' => 'Nicht gefunden.']);
return;
}
if ($beleg['locked']) {
http_response_code(403);
echo json_encode(['error' => 'GoBD: Gesperrter Beleg.']);
return;
}
$supplierName = trim($_POST['supplier_name'] ?? '');
$supplierId = $supplierName !== '' ? Supplier::findOrCreate($supplierName) : null;
Beleg::updateSupplier($id, $supplierId);
echo json_encode(['ok' => true, 'supplier_name' => $supplierName !== '' ? $supplierName : null]);
}
public function generateVorschlag(array $params): void public function generateVorschlag(array $params): void
{ {
header('Content-Type: application/json'); header('Content-Type: application/json');

View file

@ -0,0 +1,29 @@
<?php
declare(strict_types=1);
namespace Steuerkram\Controllers;
use Steuerkram\Models\EventLog;
use Steuerkram\View;
class EventController extends BaseController
{
public function index(array $params): void
{
View::redirect('/ereignisse/nextcloud-import');
}
public function nextcloudImport(array $params): void
{
$events = EventLog::findByChannel('nextcloud-import', 500);
$view = new View();
$view->render('layout/base.php', [
'pageTitle' => 'Ereignisse – Nextcloud-Import',
'content' => $this->renderView('ereignisse/nextcloud_import', [
'events' => $events,
]),
]);
}
}

View file

@ -231,6 +231,14 @@ class Beleg
); );
} }
public static function updateSupplier(int $id, ?int $supplierId): void
{
Database::execute(
'UPDATE belege SET supplier_id=?, updated_at=NOW() WHERE id=? AND locked=0',
[$supplierId, $id]
);
}
public static function delete(int $id): bool public static function delete(int $id): bool
{ {
$beleg = self::find($id); $beleg = self::find($id);

30
src/Models/EventLog.php Normal file
View file

@ -0,0 +1,30 @@
<?php
declare(strict_types=1);
namespace Steuerkram\Models;
use Steuerkram\Database;
class EventLog
{
public static function add(string $channel, string $message): void
{
Database::execute(
'INSERT INTO system_events (channel, message) VALUES (?, ?)',
[$channel, $message]
);
}
public static function findByChannel(string $channel, int $limit = 500): array
{
return Database::fetchAll(
'SELECT id, channel, message, created_at
FROM system_events
WHERE channel = ?
ORDER BY created_at DESC, id DESC
LIMIT ?',
[$channel, $limit]
);
}
}

View file

@ -6,6 +6,7 @@ namespace Steuerkram\Services;
use Steuerkram\Database; use Steuerkram\Database;
use Steuerkram\Models\Beleg; use Steuerkram\Models\Beleg;
use Steuerkram\Models\EventLog;
use Steuerkram\Models\Setting; use Steuerkram\Models\Setting;
/** /**
@ -45,6 +46,7 @@ class NextcloudImportService
private string $folder; private string $folder;
private string|null $doneFolder; private string|null $doneFolder;
private string $davBase; private string $davBase;
private array $pendingEvents = [];
public function __construct() public function __construct()
{ {
@ -69,18 +71,23 @@ class NextcloudImportService
*/ */
public function import(): array public function import(): array
{ {
$start = microtime(true);
$result = ['imported' => 0, 'skipped' => 0, 'errors' => 0, 'details' => [], 'imported_ids' => []]; $result = ['imported' => 0, 'skipped' => 0, 'errors' => 0, 'details' => [], 'imported_ids' => []];
if (!$this->baseUrl || !$this->user || !$this->pass) { if (!$this->baseUrl || !$this->user || !$this->pass) {
$result['details'][] = 'FEHLER: NEXTCLOUD_URL, NEXTCLOUD_USER und NEXTCLOUD_PASS müssen gesetzt sein.'; $msg = 'FEHLER: NEXTCLOUD_URL, NEXTCLOUD_USER und NEXTCLOUD_PASS müssen gesetzt sein.';
$result['details'][] = $msg;
$result['errors']++; $result['errors']++;
$this->persistEvent($msg);
return $result; return $result;
} }
$files = $this->listFiles(); $files = $this->listFiles();
if ($files === null) { if ($files === null) {
$result['details'][] = 'FEHLER: PROPFIND fehlgeschlagen. URL/Zugangsdaten prüfen.'; $msg = 'FEHLER: PROPFIND fehlgeschlagen. URL/Zugangsdaten prüfen.';
$result['details'][] = $msg;
$result['errors']++; $result['errors']++;
$this->persistEvent($msg);
return $result; return $result;
} }
@ -90,9 +97,24 @@ class NextcloudImportService
$this->processFile($file, $result); $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; return $result;
} }
private function persistEvent(string $message): void
{
EventLog::add('nextcloud-import', $message);
}
/** /**
* Listet alle Dateien im konfigurierten Ordner via PROPFIND. * Listet alle Dateien im konfigurierten Ordner via PROPFIND.
* Gibt null zurück bei Verbindungsfehler. * Gibt null zurück bei Verbindungsfehler.
@ -198,7 +220,9 @@ class NextcloudImportService
// MIME prüfen // MIME prüfen
if (!isset(self::ALLOWED_MIME[$contentType])) { if (!isset(self::ALLOWED_MIME[$contentType])) {
$result['details'][] = "Übersprungen (unbekannter Typ «{$contentType}»): {$filename}"; $msg = "Übersprungen (unbekannter Typ «{$contentType}»): {$filename}";
$result['details'][] = $msg;
$this->persistEvent($msg);
$result['skipped']++; $result['skipped']++;
return; return;
} }
@ -210,7 +234,9 @@ class NextcloudImportService
$tmpPath = sys_get_temp_dir() . '/nextcloud_import_' . uniqid() . '.' . $ext; $tmpPath = sys_get_temp_dir() . '/nextcloud_import_' . uniqid() . '.' . $ext;
if (!$this->downloadFile($fileUrl, $tmpPath)) { if (!$this->downloadFile($fileUrl, $tmpPath)) {
$result['details'][] = "FEHLER beim Download: {$filename}"; $msg = "FEHLER beim Download: {$filename}";
$result['details'][] = $msg;
$this->persistEvent($msg);
$result['errors']++; $result['errors']++;
return; return;
} }
@ -222,7 +248,9 @@ class NextcloudImportService
[$rawHash] [$rawHash]
); );
if ($existing) { if ($existing) {
$result['details'][] = "Duplikat (Beleg {$existing['beleg_nr']}): {$filename}"; $msg = "Duplikat (Beleg {$existing['beleg_nr']}): {$filename}";
$result['details'][] = $msg;
$this->persistEvent($msg);
$result['skipped']++; $result['skipped']++;
unlink($tmpPath); unlink($tmpPath);
$this->moveFile($file['href'], $filename); $this->moveFile($file['href'], $filename);
@ -236,7 +264,9 @@ class NextcloudImportService
$absDir = dirname(__DIR__, 2) . "/public/{$relDir}"; $absDir = dirname(__DIR__, 2) . "/public/{$relDir}";
if (!is_dir($absDir) && !mkdir($absDir, 0755, true)) { if (!is_dir($absDir) && !mkdir($absDir, 0755, true)) {
$result['details'][] = "FEHLER: Verzeichnis konnte nicht erstellt werden: {$absDir}"; $msg = "FEHLER: Verzeichnis konnte nicht erstellt werden: {$absDir}";
$result['details'][] = $msg;
$this->persistEvent($msg);
$result['errors']++; $result['errors']++;
unlink($tmpPath); unlink($tmpPath);
return; return;
@ -246,7 +276,9 @@ class NextcloudImportService
$absPath = dirname(__DIR__, 2) . "/public/{$storagePath}"; $absPath = dirname(__DIR__, 2) . "/public/{$storagePath}";
if (!rename($tmpPath, $absPath)) { if (!rename($tmpPath, $absPath)) {
$result['details'][] = "FEHLER: Datei konnte nicht verschoben werden: {$filename}"; $msg = "FEHLER: Datei konnte nicht verschoben werden: {$filename}";
$result['details'][] = $msg;
$this->persistEvent($msg);
$result['errors']++; $result['errors']++;
@unlink($tmpPath); @unlink($tmpPath);
return; return;
@ -278,7 +310,9 @@ class NextcloudImportService
// Datei in Nextcloud verschieben/löschen // Datei in Nextcloud verschieben/löschen
$this->moveFile($file['href'], $filename); $this->moveFile($file['href'], $filename);
$result['details'][] = "Importiert: {$filename} → Beleg {$belegNr}"; $msg = "Importiert: {$filename} → Beleg {$belegNr}";
$result['details'][] = $msg;
$this->persistEvent($msg);
$result['imported']++; $result['imported']++;
$result['imported_ids'][] = $belegId; $result['imported_ids'][] = $belegId;
} }

View file

@ -162,7 +162,13 @@
</div> </div>
</td> </td>
<td class="td-muted"><?= View::formatDate($b['beleg_datum']) ?></td> <td class="td-muted"><?= View::formatDate($b['beleg_datum']) ?></td>
<td class="td-muted"><?= $h($b['supplier_name'] ?? '–') ?></td> <td class="td-muted bl-supplier-cell"
data-beleg-id="<?= (int)$b['id'] ?>"
data-supplier="<?= $h($b['supplier_name'] ?? '') ?>"
onclick="event.stopPropagation(); blInlineSupplier(this)"
style="cursor:text;min-width:80px"
title="Klicken zum Lieferant bearbeiten"
><?= $h($b['supplier_name'] ?? '–') ?></td>
<td class="td-muted"><?= $h($b['rechnungs_nr'] ?? '–') ?></td> <td class="td-muted"><?= $h($b['rechnungs_nr'] ?? '–') ?></td>
<td class="td-right td-mono"><?= $b['betrag_brutto'] !== null ? View::formatMoney($b['betrag_brutto']) : '–' ?></td> <td class="td-right td-mono"><?= $b['betrag_brutto'] !== null ? View::formatMoney($b['betrag_brutto']) : '–' ?></td>
<td onclick="event.stopPropagation()"> <td onclick="event.stopPropagation()">
@ -245,4 +251,49 @@
}); });
}); });
})(); })();
function blInlineSupplier(td) {
if (td.querySelector('input')) return;
const current = td.dataset.supplier || '';
const belegId = td.dataset.belegId;
let committed = false;
td.textContent = '';
const input = document.createElement('input');
input.type = 'text';
input.value = current;
input.style.cssText = 'width:100%;border:1px solid var(--color-primary,#3b82f6);border-radius:3px;padding:.1rem .3rem;font-size:inherit;background:#fff;box-sizing:border-box;color:inherit';
function commit(val) {
if (committed) return;
committed = true;
const fd = new FormData();
fd.append('supplier_name', val);
fetch('/belege/' + belegId + '/supplier', {
method: 'POST',
headers: { 'X-Requested-With': 'XMLHttpRequest' },
body: fd,
})
.then(r => r.json())
.then(data => {
td.dataset.supplier = val;
td.textContent = val || '–';
})
.catch(() => { td.textContent = current || '–'; });
}
input.addEventListener('keydown', function (e) {
if (e.key === 'Enter') { e.preventDefault(); commit(input.value.trim()); }
if (e.key === 'Escape') { committed = true; td.textContent = current || '–'; }
});
input.addEventListener('blur', function () {
commit(input.value.trim());
});
td.appendChild(input);
input.focus();
input.select();
}
</script> </script>

View file

@ -0,0 +1,35 @@
<?php use Steuerkram\View; $h = fn($v) => View::h($v); ?>
<div class="card">
<div style="display:flex;justify-content:space-between;align-items:center;padding:.75rem 1rem;border-bottom:1px solid var(--color-border)">
<strong>Nextcloud-Import</strong>
<span class="text-muted text-sm"><?= count($events) ?> Einträge (neueste zuerst)</span>
</div>
<?php if (empty($events)): ?>
<div style="padding:2rem;text-align:center" class="text-muted">Keine Ereignisse vorhanden. Einträge entstehen, sobald der Nextcloud-Import Dateien importiert, überspringt oder auf Fehler stößt.</div>
<?php else: ?>
<div style="overflow-x:auto">
<table style="width:100%;border-collapse:collapse;font-size:.875rem">
<tbody>
<?php foreach ($events as $e): ?>
<?php
$msg = $e['message'];
$isError = str_starts_with($msg, 'FEHLER');
$isDup = str_starts_with($msg, 'Duplikat');
$isOk = str_starts_with($msg, 'Importiert');
$color = $isError ? 'var(--color-danger,#dc2626)'
: ($isDup ? 'var(--color-warning,#d97706)'
: ($isOk ? 'var(--color-success,#16a34a)'
: 'inherit'));
?>
<tr style="border-bottom:1px solid var(--color-border,#e2e8f0)">
<td style="padding:.35rem 1rem;white-space:nowrap;color:var(--color-muted);font-family:monospace"><?= $h(date('Y-m-d H:i:s', strtotime($e['created_at']))) ?></td>
<td style="padding:.35rem .5rem;white-space:nowrap;color:var(--color-muted);font-family:monospace">[<?= $h($e['channel']) ?>]</td>
<td style="padding:.35rem 1rem;color:<?= $color ?>;word-break:break-word"><?= $h($msg) ?></td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</div>
<?php endif; ?>
</div>

View file

@ -84,6 +84,16 @@ function navActive(string $prefix, string $current): string {
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><line x1="19" y1="8" x2="19" y2="14"/><line x1="22" y1="11" x2="16" y2="11"/></svg> <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><line x1="19" y1="8" x2="19" y2="14"/><line x1="22" y1="11" x2="16" y2="11"/></svg>
Benutzer Benutzer
</a> </a>
<a href="/ereignisse" class="<?= navActive('/ereignisse', $path) ?>">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 22V12M12 12L8 16M12 12l4 4"/><circle cx="12" cy="5" r="3"/><path d="M5.5 8.5A7 7 0 0 0 5 12"/><path d="M18.5 8.5A7 7 0 0 1 19 12"/></svg>
Ereignisse
</a>
<?php if (str_starts_with($path, '/ereignisse')): ?>
<a href="/ereignisse/nextcloud-import" class="<?= navActive('/ereignisse/nextcloud-import', $path) ?>" style="padding-left:2.25rem;font-size:.875rem">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M22 16.92V20a2 2 0 0 1-2.18 2A19.79 19.79 0 0 1 11.39 19a19.5 19.5 0 0 1-4.9-4.9 19.79 19.79 0 0 1-3-9.43A2 2 0 0 1 5.32 3H8.4a2 2 0 0 1 2 1.72c.13.96.36 1.9.7 2.81a2 2 0 0 1-.45 2.11L9.4 10.9a16 16 0 0 0 3.71 3.71l1.26-1.26a2 2 0 0 1 2.11-.45c.91.34 1.85.57 2.81.7A2 2 0 0 1 22 16.92z"/></svg>
Nextcloud-Import
</a>
<?php endif; ?>
<a href="/settings" class="<?= navActive('/settings', $path) ?>"> <a href="/settings" class="<?= navActive('/settings', $path) ?>">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="3"/><path d="M19.07 4.93l-1.41 1.41M4.93 4.93l1.41 1.41M19.07 19.07l-1.41-1.41M4.93 19.07l1.41-1.41M12 2v2M12 20v2M2 12h2M20 12h2"/></svg> <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="3"/><path d="M19.07 4.93l-1.41 1.41M4.93 4.93l1.41 1.41M19.07 19.07l-1.41-1.41M4.93 19.07l1.41-1.41M12 2v2M12 20v2M2 12h2M20 12h2"/></svg>
Einstellungen Einstellungen

View file

@ -1 +1 @@
1-260611 1-260629

View file

@ -0,0 +1,55 @@
<?php
declare(strict_types=1);
use Steuerkram\Models\Beleg;
use Steuerkram\Models\Supplier;
class BelegLieferantInlineTest extends TestCase
{
protected string $testName = 'Test 46: Beleg-Lieferant Inline-Update via updateSupplier()';
public function run(): void
{
$belegId = Beleg::create([
'beleg_nr' => 'TEST-SUPP-' . uniqid(),
'filename' => 'inline-supplier-test.pdf',
'storage_path' => '',
'mime_type' => 'application/pdf',
'file_hash' => hash('sha256', 'inline-supplier-' . uniqid()),
'beleg_datum' => date('Y-m-d'),
'belegtyp' => 'Eingangsrechnung',
]);
$this->assertGreaterThan(0, $belegId, 'Beleg angelegt');
// Kein Lieferant initial
$beleg = Beleg::find($belegId);
$this->assert($beleg['supplier_name'] === null || $beleg['supplier_name'] === '', 'Kein Lieferant initial');
// Lieferant setzen
$supplierId = Supplier::findOrCreate('Testfirma GmbH');
$this->assertGreaterThan(0, $supplierId, 'Supplier angelegt oder gefunden');
Beleg::updateSupplier($belegId, $supplierId);
$beleg = Beleg::find($belegId);
$this->assertEquals('Testfirma GmbH', $beleg['supplier_name'], 'Lieferant nach updateSupplier gesetzt');
// Lieferant auf null zurücksetzen (löschen)
Beleg::updateSupplier($belegId, null);
$beleg = Beleg::find($belegId);
$this->assert(
$beleg['supplier_name'] === null || $beleg['supplier_name'] === '',
'Lieferant nach Nullsetzung leer'
);
// updateSupplier darf bei gesperrtem Beleg nichts ändern
\Steuerkram\Database::execute('UPDATE belege SET locked=1 WHERE id=?', [$belegId]);
$supplierId2 = Supplier::findOrCreate('Gesperrte Firma');
Beleg::updateSupplier($belegId, $supplierId2);
$beleg = Beleg::find($belegId);
$this->assert(
$beleg['supplier_name'] === null || $beleg['supplier_name'] === '',
'Lieferant bei gesperrtem Beleg unverändert'
);
}
}

View file

@ -0,0 +1,43 @@
<?php
declare(strict_types=1);
use Steuerkram\Models\EventLog;
class SystemEventsTest extends TestCase
{
protected string $testName = 'Test 47: EventLog – Einträge schreiben und lesen';
public function run(): void
{
$channel = 'test-' . uniqid();
// Kein Eintrag initial
$before = EventLog::findByChannel($channel);
$this->assertEquals(0, count($before), 'Kanal initial leer');
// Eintrag anlegen
EventLog::add($channel, 'Importiert: test.pdf → Beleg TST-0001');
EventLog::add($channel, 'Duplikat (Beleg TST-0001): test.pdf');
EventLog::add($channel, 'FEHLER beim Download: fehler.pdf');
$entries = EventLog::findByChannel($channel);
$this->assertEquals(3, count($entries), 'Drei Einträge vorhanden');
// Neueste zuerst (höchste id zuletzt hinzugefügt)
$this->assertEquals('FEHLER beim Download: fehler.pdf', $entries[0]['message'], 'Neuester Eintrag zuerst');
$this->assertEquals($channel, $entries[0]['channel'], 'Kanal korrekt gesetzt');
// created_at vorhanden und parseable
$ts = strtotime($entries[0]['created_at']);
$this->assert($ts > 0, 'created_at ist ein gültiges Datum');
// Limit wird eingehalten
$limited = EventLog::findByChannel($channel, 2);
$this->assertEquals(2, count($limited), 'Limit 2 wird eingehalten');
// Anderer Kanal bleibt unberührt
$other = EventLog::findByChannel('other-channel-' . uniqid());
$this->assertEquals(0, count($other), 'Anderer Kanal ist leer');
}
}