diff --git a/CLAUDE.md b/CLAUDE.md index f7bfd21..118018c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -37,6 +37,13 @@ $this->assertGreaterThan($min, $wert, 'Beschreibung'); $this->assertNotEmpty($wert, 'Beschreibung'); ``` +## Dokumentation aktuell halten + +| Datei | Wann aktualisieren | +|---|---| +| `Changes.md` | Bei jedem Feature, Bugfix oder strukturellen Änderung (nicht bei reinen Test- oder Doku-Commits) | +| `README.md` | Wenn sich der Funktionsumfang, Tech Stack oder die Installationsanleitung ändert | + ## Commits ``` Fix #NR: Kurze Beschreibung der Änderung diff --git a/Changes.md b/Changes.md index 0e21399..a1fa175 100644 --- a/Changes.md +++ b/Changes.md @@ -2,6 +2,24 @@ --- +## 2026-03-26 – Lieferantenmanagement (#7) + +### Neu +- **Lieferantenverwaltung**: Neue Seite `/suppliers` zum Anlegen, Bearbeiten und Löschen von Lieferanten +- **Lieferant am Beleg**: Im Metadaten-Formular der Belegdetailseite kann ein Lieferant per Dropdown/Freitexteingabe ausgewählt werden; unbekannte Namen werden beim Speichern automatisch angelegt +- **Datenbank**: Tabelle `suppliers` (id, name) und Fremdschlüssel `supplier_id` in `belege` +- **Navigation**: Neuer Menüpunkt "Lieferanten" im Bereich Belege & Buchungen + +### Betrifft +- Migration: `007_suppliers.sql` +- Model: `Supplier.php` (neu), `Beleg.php` (JOIN + updateMeta) +- Controller: `SupplierController.php` (neu), `BelegController.php` +- Views: `suppliers/list.php`, `suppliers/form.php` (neu), `belege/detail.php`, `layout/nav.php` +- Routes: `public/index.php` +- Test: `08_LieferantTest.php` + +--- + ## 2026-03-26 – EÜR-Buchungsmodell (Soll/Haben → Einnahme/Ausgabe) ### Geändert diff --git a/migrations/007_suppliers.sql b/migrations/007_suppliers.sql new file mode 100644 index 0000000..9ff3154 --- /dev/null +++ b/migrations/007_suppliers.sql @@ -0,0 +1,20 @@ +-- Steuerkram - Lieferantenmanagement + +SET NAMES utf8mb4; + +-- ============================================================ +-- Lieferanten +-- ============================================================ +CREATE TABLE IF NOT EXISTS suppliers ( + id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY, + name VARCHAR(255) NOT NULL, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + UNIQUE KEY uq_supplier_name (name) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +-- Lieferant-Referenz in Belege +ALTER TABLE belege + ADD COLUMN supplier_id INT UNSIGNED NULL DEFAULT NULL AFTER belegtyp, + ADD CONSTRAINT fk_belege_supplier + FOREIGN KEY (supplier_id) REFERENCES suppliers (id) ON DELETE SET NULL; diff --git a/public/index.php b/public/index.php index 9a174e2..2e302da 100644 --- a/public/index.php +++ b/public/index.php @@ -14,6 +14,7 @@ use Steuerkram\Controllers\BelegController; use Steuerkram\Controllers\BookingController; use Steuerkram\Controllers\CustomerController; use Steuerkram\Controllers\InvoiceController; +use Steuerkram\Controllers\SupplierController; use Steuerkram\Controllers\UstvaController; use Steuerkram\Controllers\EuerController; use Steuerkram\Controllers\SettingsController; @@ -58,6 +59,15 @@ $router->post('/belege/{beleg_id}/bookings', fn($p) => (new BookingController()) $router->post('/bookings/{id}/reverse', fn($p) => (new BookingController())->reverse($p)); $router->post('/bookings/{id}/delete', fn($p) => (new BookingController())->destroy($p)); +// ── Lieferanten ────────────────────────────────────────────── +$router->get('/suppliers', fn($p) => (new SupplierController())->index($p)); +$router->get('/suppliers/new', fn($p) => (new SupplierController())->create($p)); +$router->post('/suppliers', fn($p) => (new SupplierController())->store($p)); +$router->get('/suppliers/search', fn($p) => (new SupplierController())->search($p)); +$router->get('/suppliers/{id}/edit', fn($p) => (new SupplierController())->edit($p)); +$router->post('/suppliers/{id}', fn($p) => (new SupplierController())->update($p)); +$router->post('/suppliers/{id}/delete', fn($p) => (new SupplierController())->destroy($p)); + // ── Kunden ─────────────────────────────────────────────────── $router->get('/customers', fn($p) => (new CustomerController())->index($p)); $router->get('/customers/new', fn($p) => (new CustomerController())->create($p)); diff --git a/src/Controllers/BelegController.php b/src/Controllers/BelegController.php index 97ac0dd..18a843b 100644 --- a/src/Controllers/BelegController.php +++ b/src/Controllers/BelegController.php @@ -7,6 +7,7 @@ namespace Steuerkram\Controllers; use Steuerkram\Models\Beleg; use Steuerkram\Models\Booking; use Steuerkram\Models\Account; +use Steuerkram\Models\Supplier; use Steuerkram\Config; use Steuerkram\View; @@ -140,12 +141,14 @@ class BelegController $bookings = Booking::forBeleg((int)$params['id']); $integrity = Beleg::verifyIntegrity((int)$params['id']); $accounts = Account::all(['aktiv' => 1]); + $suppliers = Supplier::all(); $view = new View(); $view->render('layout/base.php', [ 'pageTitle' => 'Beleg ' . $beleg['beleg_nr'], 'content' => $this->renderView('belege/detail', [ 'beleg' => $beleg, + 'suppliers' => $suppliers, 'bookings' => $bookings, 'integrity' => $integrity, 'accounts' => $accounts, @@ -183,11 +186,19 @@ class BelegController return; } + // Lieferant: Name → ID (ggf. neu anlegen) + $supplierId = null; + $supplierName = trim($_POST['supplier_name'] ?? ''); + if ($supplierName !== '') { + $supplierId = Supplier::findOrCreate($supplierName); + } + Beleg::updateMeta($id, [ 'rechnungs_nr' => trim($_POST['rechnungs_nr'] ?? ''), 'belegtyp' => $_POST['belegtyp'] ?? $beleg['belegtyp'], 'beleg_datum' => $_POST['beleg_datum'] ?: null, 'betrag_brutto' => ($_POST['betrag_brutto'] ?? '') !== '' ? $_POST['betrag_brutto'] : null, + 'supplier_id' => $supplierId, ]); if ($isAjax) { diff --git a/src/Controllers/SupplierController.php b/src/Controllers/SupplierController.php new file mode 100644 index 0000000..df1ab2f --- /dev/null +++ b/src/Controllers/SupplierController.php @@ -0,0 +1,144 @@ +render('layout/base.php', [ + 'pageTitle' => 'Lieferanten', + 'content' => $this->renderView('suppliers/list', [ + 'suppliers' => $suppliers, + ]), + 'topbarActions' => '+ Lieferant anlegen', + ]); + } + + public function create(array $params): void + { + $view = new View(); + $view->render('layout/base.php', [ + 'pageTitle' => 'Lieferant anlegen', + 'content' => $this->renderView('suppliers/form', [ + 'supplier' => [], + 'errors' => [], + 'action' => '/suppliers', + ]), + ]); + } + + public function store(array $params): void + { + $name = trim($_POST['name'] ?? ''); + $errors = []; + + if ($name === '') { + $errors['name'] = 'Pflichtfeld'; + } elseif (Supplier::findByName($name)) { + $errors['name'] = 'Dieser Lieferantenname existiert bereits.'; + } + + if ($errors) { + $view = new View(); + $view->render('layout/base.php', [ + 'pageTitle' => 'Lieferant anlegen', + 'content' => $this->renderView('suppliers/form', [ + 'supplier' => ['name' => $name], + 'errors' => $errors, + 'action' => '/suppliers', + ]), + ]); + return; + } + + Supplier::create($name); + View::redirect('/suppliers', 'Lieferant erfolgreich angelegt.'); + } + + public function edit(array $params): void + { + $supplier = Supplier::find((int)$params['id']); + if (!$supplier) { http_response_code(404); return; } + + $view = new View(); + $view->render('layout/base.php', [ + 'pageTitle' => 'Lieferant bearbeiten', + 'content' => $this->renderView('suppliers/form', [ + 'supplier' => $supplier, + 'errors' => [], + 'action' => '/suppliers/' . $supplier['id'], + ]), + ]); + } + + public function update(array $params): void + { + $id = (int)$params['id']; + $supplier = Supplier::find($id); + if (!$supplier) { http_response_code(404); return; } + + $name = trim($_POST['name'] ?? ''); + $errors = []; + + if ($name === '') { + $errors['name'] = 'Pflichtfeld'; + } else { + $existing = Supplier::findByName($name); + if ($existing && (int)$existing['id'] !== $id) { + $errors['name'] = 'Dieser Lieferantenname existiert bereits.'; + } + } + + if ($errors) { + $view = new View(); + $view->render('layout/base.php', [ + 'pageTitle' => 'Lieferant bearbeiten', + 'content' => $this->renderView('suppliers/form', [ + 'supplier' => array_merge($supplier, ['name' => $name]), + 'errors' => $errors, + 'action' => '/suppliers/' . $id, + ]), + ]); + return; + } + + Supplier::update($id, $name); + View::redirect('/suppliers', 'Lieferant aktualisiert.'); + } + + public function destroy(array $params): void + { + if (!Supplier::delete((int)$params['id'])) { + View::redirect('/suppliers', 'Lieferant kann nicht gelöscht werden (noch in Belegen verwendet).', 'error'); + return; + } + View::redirect('/suppliers', 'Lieferant gelöscht.'); + } + + /** + * AJAX: Autocomplete-Liste aller Lieferanten als JSON. + */ + public function search(array $params): void + { + $suppliers = Supplier::all(); + header('Content-Type: application/json'); + echo json_encode(array_map(fn($s) => ['id' => $s['id'], 'name' => $s['name']], $suppliers)); + } + + private function renderView(string $template, array $data): string + { + extract($data, EXTR_SKIP); + ob_start(); + include dirname(__DIR__) . '/Views/' . $template . '.php'; + return ob_get_clean(); + } +} diff --git a/src/Models/Beleg.php b/src/Models/Beleg.php index 1f48f58..31164f8 100644 --- a/src/Models/Beleg.php +++ b/src/Models/Beleg.php @@ -69,9 +69,11 @@ class Beleg (SELECT SUM(bk.betrag_brutto) FROM bookings bk WHERE bk.beleg_id = b.id AND bk.is_reversal = 0) ) AS betrag_brutto, - fy.year as fiscal_year_nr + fy.year as fiscal_year_nr, + s.name as supplier_name FROM belege b LEFT JOIN fiscal_years fy ON b.fiscal_year_id = fy.id + LEFT JOIN suppliers s ON b.supplier_id = s.id WHERE b.id = ?', [$id] ); @@ -156,6 +158,7 @@ class Beleg belegtyp = ?, beleg_datum = ?, betrag_brutto = ?, + supplier_id = ?, fiscal_year_id = COALESCE(?, fiscal_year_id), updated_at = NOW() WHERE id = ? AND locked = 0', @@ -164,6 +167,7 @@ class Beleg $data['belegtyp'], $data['beleg_datum'] ?: null, $data['betrag_brutto'] !== null && $data['betrag_brutto'] !== '' ? (float)$data['betrag_brutto'] : null, + isset($data['supplier_id']) && $data['supplier_id'] !== '' ? (int)$data['supplier_id'] : null, $fyId, $id, ] diff --git a/src/Models/Supplier.php b/src/Models/Supplier.php new file mode 100644 index 0000000..7a05c8e --- /dev/null +++ b/src/Models/Supplier.php @@ -0,0 +1,63 @@ + 0) { + return false; + } + Database::execute('DELETE FROM suppliers WHERE id = ?', [$id]); + return true; + } + + /** + * Gibt ID eines vorhandenen Lieferanten zurück oder legt ihn neu an. + */ + public static function findOrCreate(string $name): int + { + $name = trim($name); + if ($name === '') { + throw new \InvalidArgumentException('Lieferantenname darf nicht leer sein.'); + } + $existing = self::findByName($name); + if ($existing) { + return (int)$existing['id']; + } + return self::create($name); + } +} diff --git a/src/Views/belege/detail.php b/src/Views/belege/detail.php index c529759..c9f6a7d 100644 --- a/src/Views/belege/detail.php +++ b/src/Views/belege/detail.php @@ -4,6 +4,7 @@ use Steuerkram\Models\Setting; $h = fn($v) => View::h($v); $fmt = fn($v) => View::formatMoney($v); $testMode = Setting::isTestMode(); +$suppliers = $suppliers ?? []; ?> @@ -23,6 +24,10 @@ $testMode = Setting::isTestMode(); Typ
+
+ Lieferant
+ –' ?> +
Belegdatum✎' : '' ?>
@@ -108,6 +113,19 @@ $testMode = Setting::isTestMode();
+
+ + + + + +
diff --git a/src/Views/layout/nav.php b/src/Views/layout/nav.php index 06216b0..d105c5b 100644 --- a/src/Views/layout/nav.php +++ b/src/Views/layout/nav.php @@ -28,6 +28,10 @@ function navActive(string $prefix, string $current): string { Buchungsjournal + + + Lieferanten + diff --git a/src/Views/suppliers/form.php b/src/Views/suppliers/form.php new file mode 100644 index 0000000..cd1bccf --- /dev/null +++ b/src/Views/suppliers/form.php @@ -0,0 +1,24 @@ + View::h($v); ?> + +
+
+
+ +
+ + + +
+ +
+ +
+ + Abbrechen +
+
+
+
diff --git a/src/Views/suppliers/list.php b/src/Views/suppliers/list.php new file mode 100644 index 0000000..eeeea61 --- /dev/null +++ b/src/Views/suppliers/list.php @@ -0,0 +1,45 @@ + View::h($v); ?> + +
+
+
+
+ Lieferanten +
+
+ +
+
+ + + + + + + + + + + + + + + + + + + + + + +
IDNameAngelegt am
+
+ Bearbeiten +
+ +
+
+
Noch keine Lieferanten angelegt.
+
+
diff --git a/tests/08_LieferantTest.php b/tests/08_LieferantTest.php new file mode 100644 index 0000000..fa99656 --- /dev/null +++ b/tests/08_LieferantTest.php @@ -0,0 +1,95 @@ +assertGreaterThan(0, $id, 'Lieferant wurde angelegt (ID > 0)'); + + // 2. find() liefert den angelegten Lieferanten + $supplier = \Steuerkram\Models\Supplier::find($id); + $this->assertNotEmpty($supplier, 'Supplier::find() liefert Datensatz'); + $this->assertEquals($name, $supplier['name'], 'Lieferantenname stimmt überein'); + + // 3. findByName() findet den Lieferanten + $found = \Steuerkram\Models\Supplier::findByName($name); + $this->assertNotEmpty($found, 'Supplier::findByName() findet den Lieferanten'); + $this->assertEquals($id, (int)$found['id'], 'ID stimmt überein'); + + // 4. all() enthält den Lieferanten + $all = \Steuerkram\Models\Supplier::all(); + $inList = false; + foreach ($all as $s) { + if ((int)$s['id'] === $id) { $inList = true; break; } + } + $this->assert($inList, 'Lieferant erscheint in Supplier::all()'); + + // 5. findOrCreate() gibt vorhandenen zurück + $id2 = \Steuerkram\Models\Supplier::findOrCreate($name); + $this->assertEquals($id, $id2, 'findOrCreate() gibt vorhandene ID zurück'); + + // 6. findOrCreate() legt neuen an + $newName = 'Neuer Lieferant ' . uniqid(); + $id3 = \Steuerkram\Models\Supplier::findOrCreate($newName); + $this->assertGreaterThan(0, $id3, 'findOrCreate() legt neuen Lieferanten an'); + $this->assert($id3 !== $id, 'Neue ID ist verschieden von der ersten'); + + // 7. update() + $updatedName = 'Umbenannt ' . uniqid(); + \Steuerkram\Models\Supplier::update($id, $updatedName); + $updated = \Steuerkram\Models\Supplier::find($id); + $this->assertEquals($updatedName, $updated['name'], 'Name nach update() korrekt geändert'); + + // 8. Lieferant einem Beleg zuweisen via Beleg::create() + updateMeta() + $belegNr = 'TEST-SUP-' . uniqid(); + $datum = date('Y-m-d'); + $belegId = \Steuerkram\Models\Beleg::create([ + 'beleg_nr' => $belegNr, + 'filename' => 'test-lieferant.pdf', + 'storage_path' => 'static/uploads/test/test-lieferant.pdf', + 'mime_type' => 'application/pdf', + 'file_hash' => hash('sha256', $belegNr), + 'beleg_datum' => $datum, + 'eingangs_datum' => $datum, + 'rechnungs_nr' => 'RE-SUP-001', + 'betrag_brutto' => 59.50, + 'belegtyp' => 'Eingangsrechnung', + ]); + + \Steuerkram\Models\Beleg::updateMeta($belegId, [ + 'rechnungs_nr' => 'RE-SUP-001', + 'belegtyp' => 'Eingangsrechnung', + 'beleg_datum' => $datum, + 'betrag_brutto' => 59.50, + 'supplier_id' => $id3, + ]); + + $beleg = \Steuerkram\Models\Beleg::find($belegId); + $this->assertEquals($id3, (int)$beleg['supplier_id'], 'supplier_id korrekt am Beleg gespeichert'); + $this->assertNotEmpty($beleg['supplier_name'], 'supplier_name via JOIN abrufbar'); + + // 9. Löschen: Lieferant der noch verwendet wird kann nicht gelöscht werden + $deleted = \Steuerkram\Models\Supplier::delete($id3); + $this->assert(!$deleted, 'Lieferant in Verwendung kann nicht gelöscht werden'); + + // 10. Nicht verwendeten Lieferanten löschen + $unusedName = 'Ungenutzt ' . uniqid(); + $unusedId = \Steuerkram\Models\Supplier::create($unusedName); + $deletedOk = \Steuerkram\Models\Supplier::delete($unusedId); + $this->assert($deletedOk, 'Nicht verwendeter Lieferant kann gelöscht werden'); + $this->assert(!\Steuerkram\Models\Supplier::find($unusedId), 'Gelöschter Lieferant nicht mehr auffindbar'); + } +}