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
= $h($beleg['belegtyp']) ?>
+
| ID | +Name | +Angelegt am | ++ |
|---|---|---|---|
| = $h($s['id']) ?> | += $h($s['name']) ?> | += View::formatDate($s['created_at']) ?> | +
+
+ Bearbeiten
+
+
+ |
+
| Noch keine Lieferanten angelegt. | |||