diff --git a/Changes.md b/Changes.md index b13f9e7..a4e3153 100644 --- a/Changes.md +++ b/Changes.md @@ -2,6 +2,50 @@ --- +## 2026-04-26 – Fix: Eigenanteil-Buchung als Einnahme statt Ausgabe + +### Geändert +- `src/Models/Booking.php`: `upsertKorrekturbuchung()` setzt `buchungstyp` jetzt korrekt auf `Einnahme` (war fälschlich `Ausgabe`) + +--- + +## 2026-04-26 – Feature #57: Lineare Abschreibungstabelle für Anlagegüter + +### Hinzugefügt +- `migrations/035_assets_abschreibung_flags.sql`: Neue Spalten `automatisch_berechnet` und `cent_im_ersten_jahr` in `assets` +- `migrations/036_asset_depreciation_schedule.sql`: Neue Tabelle `asset_depreciation_schedule` (Jahr, Abschreibungsbetrag, Restwert) +- `src/Models/AssetDepreciation.php`: Berechnung (linear, Sofortabschreibung, Cent-Option), Persistenz +- `tests/26_AbschreibungstabelleTest.php`: 22 Tests für alle AfA-Szenarien + +### Geändert +- `src/Models/Asset.php`: `create()` und `update()` speichern `automatisch_berechnet` und `cent_im_ersten_jahr` +- `src/Controllers/AssetController.php`: Abschreibungstabelle wird nach Speichern automatisch generiert; neue Endpunkte `/assets/depreciation-preview` (AJAX-Vorschau) und `/assets/{id}/depreciation` (manuelle Speicherung) +- `src/Views/assets/form.php`: Nutzungsdauer-Dropdown (sofort, 1–10, 12, 13, 15, 20, 25, 50 Jahre + individuell), Cent-Option, „AfA-Tabelle"-Button, Modal mit Auto/Manuell-Toggle und editierbarer Tabelle +- `public/index.php`: Neue Routen für Depreciation-Preview und -Speicherung + +--- + + +## 2026-04-26 – KI-Diagnose: Erreichbarkeitsprüfung und Fehlerlog für Ollama + +### Geändert +- `src/Services/LlmService.php`: Neue Methode `isReachable()` für Diagnosezwecke; Fehlerlog in `callOllama()` bei cURL-Fehler oder leerer Antwort +- `src/Views/settings/form.php`: Einstellungsseite prüft jetzt aktiv, ob der Ollama-Server erreichbar ist (nicht nur ob er konfiguriert ist); zeigt bei Verbindungsfehler einen konkreten Hinweis auf `OLLAMA_HOST=0.0.0.0:11434` + +--- + +## 2026-04-26 – Leistungszeitraum als freies Textfeld + +### Geändert +- `migrations/034_leistungszeitraum_text.sql`: Spalten `leistungszeitraum_von` (DATE) und `leistungszeitraum_bis` (DATE) wurden durch eine einzige Textspalte `leistungszeitraum` (VARCHAR 255) ersetzt; bestehende Datumswerte werden automatisch migriert +- `src/Views/invoices/form.php`: Zwei Datumsfelder durch ein Freitextfeld „Leistungszeitraum" ersetzt, Grid von 4 auf 3 Spalten angepasst +- `src/Models/Invoice.php`: INSERT/UPDATE verwenden jetzt `leistungszeitraum` statt `leistungszeitraum_von`/`leistungszeitraum_bis` +- `src/Controllers/InvoiceController.php`: Datums-Defaults für `leistungszeitraum_von`/`leistungszeitraum_bis` entfernt +- `src/Views/invoices/show.php`: Leistungszeitraum-Anzeige vereinfacht (kein Datumsformat, direkter Textausgabe) +- `src/Services/PdfInvoiceService.php`: Leistungszeitraum-Ausgabe vereinfacht + +--- + ## 2026-04-15 – KI-Erkennung: automatischer Aufruf aus Belegansicht entfernt ### Geändert diff --git a/docker-compose.yml b/docker-compose.yml index 9f5b121..139dfef 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -18,7 +18,7 @@ services: DB_PASS: steuerkram APP_SECRET: change-this-in-production OLLAMA_URL: ${OLLAMA_URL:-http://host-gateway:11434} - OLLAMA_MODEL: ${OLLAMA_MODEL:-llama3.2} + OLLAMA_MODEL: ${OLLAMA_MODEL:-qwen3-vl} extra_hosts: - "host-gateway:host-gateway" depends_on: diff --git a/docs/anlagevermögen.md b/docs/anlagevermögen.md index e196242..6ab83ba 100644 --- a/docs/anlagevermögen.md +++ b/docs/anlagevermögen.md @@ -65,5 +65,36 @@ Folgende Felder können nachträglich angepasst werden: - Anschaffungsdatum und -kosten - Zugänge und Abgänge - Abschreibung laufendes Jahr -- Nutzungsdauer (in Jahren) +- Nutzungsdauer (Dropdown: sofort, 1–10, 12, 13, 15, 20, 25, 50 Jahre oder individuell) +- Centbeträge im ersten Jahr abschreiben (Option) - Notizen + +--- + +## Abschreibungstabelle (AfA) + +Für jedes gespeicherte Wirtschaftsgut wird automatisch eine lineare AfA-Tabelle erzeugt und persistent gespeichert (`asset_depreciation_schedule`). Die Tabelle enthält pro Jahr: **Jahr**, **Abschreibungsbetrag**, **Restwert**. + +### Berechnungsregeln + +| Modus | Verhalten | +|---|---| +| **Nutzungsdauer = sofort** | Vollständige Abschreibung im Anschaffungsjahr, Restwert = 0 | +| **Nutzungsdauer = N Jahre** | Gleichmäßige lineare Verteilung; letztes Jahr gleicht Rundungsdifferenzen aus | +| **Cent-Option aktiv** | Folgejahre auf volle Euro abgerundet; erstes Jahr übernimmt alle Centbeträge; Restwert nach Jahr 1 ist volle Euro | + +Basis der Berechnung: `Anschaffungskosten + Zugänge − Abgänge`. + +### Auto- und Manuell-Modus + +Die Tabelle besitzt ein Flag `automatisch_berechnet`: +- **Automatisch (Standard):** Bei jeder Parameteränderung (Nutzungsdauer, Cent-Option, Kosten) wird die Tabelle neu berechnet. +- **Manuell:** Durch Klick auf „Manuell bearbeiten" im Modal wird das Flag deaktiviert. Parameteränderungen zeigen eine Bestätigungsabfrage, bevor neu berechnet wird. + +### Bedienung + +1. Wirtschaftsgut speichern → Tabelle wird automatisch berechnet. +2. Button **„AfA-Tabelle"** öffnet den modalen Dialog. +3. Im Dialog: Toggle „Manuell bearbeiten" aktiviert editierbare Zellen. +4. **„Neu berechnen"** überschreibt manuelle Änderungen (nach Bestätigung). +5. **„Speichern"** sichert die aktuelle Tabelle (inklusive manueller Änderungen) in der Datenbank. diff --git a/migrations/033_customer_nr_prefix.sql b/migrations/033_customer_nr_prefix.sql new file mode 100644 index 0000000..dc1012c --- /dev/null +++ b/migrations/033_customer_nr_prefix.sql @@ -0,0 +1,5 @@ +-- Migration 033: Kunden-Nummernpräfix konfigurierbar machen +ALTER TABLE company_settings + ADD COLUMN IF NOT EXISTS customer_nr_prefix VARCHAR(10) NOT NULL DEFAULT 'K-' + COMMENT 'Präfix für Kundennummern (z.B. K-)' + AFTER anlage_nr_prefix; diff --git a/migrations/034_leistungszeitraum_text.sql b/migrations/034_leistungszeitraum_text.sql new file mode 100644 index 0000000..776a351 --- /dev/null +++ b/migrations/034_leistungszeitraum_text.sql @@ -0,0 +1,23 @@ +-- Leistungszeitraum: von zwei DATE-Feldern zu einem freien Textfeld +ALTER TABLE invoices + ADD COLUMN leistungszeitraum VARCHAR(255) NULL AFTER leistungszeitraum_bis; + +-- Bestehende Daten migrieren: Datumsfelder als lesbaren Text zusammenführen +UPDATE invoices +SET leistungszeitraum = CASE + WHEN leistungszeitraum_von IS NOT NULL AND leistungszeitraum_bis IS NOT NULL + AND leistungszeitraum_von != leistungszeitraum_bis + THEN CONCAT( + DATE_FORMAT(leistungszeitraum_von, '%d.%m.%Y'), + ' – ', + DATE_FORMAT(leistungszeitraum_bis, '%d.%m.%Y') + ) + WHEN leistungszeitraum_von IS NOT NULL + THEN DATE_FORMAT(leistungszeitraum_von, '%d.%m.%Y') + ELSE NULL +END +WHERE leistungszeitraum_von IS NOT NULL OR leistungszeitraum_bis IS NOT NULL; + +ALTER TABLE invoices + DROP COLUMN leistungszeitraum_von, + DROP COLUMN leistungszeitraum_bis; diff --git a/migrations/035_assets_abschreibung_flags.sql b/migrations/035_assets_abschreibung_flags.sql new file mode 100644 index 0000000..e8da606 --- /dev/null +++ b/migrations/035_assets_abschreibung_flags.sql @@ -0,0 +1,8 @@ +-- Migration 035: Abschreibungsflags für Wirtschaftsgüter +ALTER TABLE assets + ADD COLUMN IF NOT EXISTS automatisch_berechnet TINYINT(1) NOT NULL DEFAULT 1 + COMMENT 'true = Abschreibungstabelle automatisch berechnet, false = manuell bearbeitet' + AFTER nutzungsdauer, + ADD COLUMN IF NOT EXISTS cent_im_ersten_jahr TINYINT(1) NOT NULL DEFAULT 0 + COMMENT 'Centbeträge vollständig im ersten Jahr abschreiben; Folgejahre auf volle Euro' + AFTER automatisch_berechnet; diff --git a/migrations/036_asset_depreciation_schedule.sql b/migrations/036_asset_depreciation_schedule.sql new file mode 100644 index 0000000..d0d07c5 --- /dev/null +++ b/migrations/036_asset_depreciation_schedule.sql @@ -0,0 +1,13 @@ +-- Migration 036: Abschreibungstabelle je Wirtschaftsgut +CREATE TABLE IF NOT EXISTS asset_depreciation_schedule ( + id INT UNSIGNED NOT NULL AUTO_INCREMENT, + asset_id INT UNSIGNED NOT NULL, + jahr SMALLINT UNSIGNED NOT NULL COMMENT 'Kalenderjahr der Abschreibung', + abschreibungsbetrag DECIMAL(12,2) NOT NULL DEFAULT 0.00, + restwert DECIMAL(12,2) NOT NULL DEFAULT 0.00, + position SMALLINT UNSIGNED NOT NULL DEFAULT 0 COMMENT 'Reihenfolge (0-basiert)', + PRIMARY KEY (id), + KEY idx_ads_asset_id (asset_id), + UNIQUE KEY uq_ads_asset_jahr (asset_id, jahr) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci + COMMENT='Lineare Abschreibungstabelle je Wirtschaftsgut'; diff --git a/public/index.php b/public/index.php index a97660f..f8f0dfe 100644 --- a/public/index.php +++ b/public/index.php @@ -95,16 +95,19 @@ $router->get('/invoices/{id}/book', fn($p) => (new InvoiceController())->book($p $router->post('/invoices/{id}/bookings', fn($p) => (new InvoiceController())->storeBooking($p)); $router->post('/invoices/{id}/mark-paid', fn($p) => (new InvoiceController())->markPaid($p)); $router->post('/invoices/{id}/cancel', fn($p) => (new InvoiceController())->cancel($p)); +$router->post('/invoices/{id}/delete', fn($p) => (new InvoiceController())->destroy($p)); $router->post('/invoices/{id}/lines', fn($p) => (new InvoiceController())->addLine($p)); $router->post('/invoices/{id}/lines/{line_id}/delete', fn($p) => (new InvoiceController())->deleteLine($p)); // ── Anlagevermögen ─────────────────────────────────────────── -$router->get('/assets', fn($p) => (new AssetController())->index($p)); -$router->get('/assets/new', fn($p) => (new AssetController())->create($p)); -$router->post('/assets', fn($p) => (new AssetController())->store($p)); -$router->get('/assets/{id}/edit', fn($p) => (new AssetController())->edit($p)); -$router->post('/assets/{id}', fn($p) => (new AssetController())->update($p)); -$router->post('/assets/{id}/delete', fn($p) => (new AssetController())->destroy($p)); +$router->get('/assets', fn($p) => (new AssetController())->index($p)); +$router->get('/assets/new', fn($p) => (new AssetController())->create($p)); +$router->post('/assets', fn($p) => (new AssetController())->store($p)); +$router->get('/assets/depreciation-preview', fn($p) => (new AssetController())->depreciationPreview($p)); +$router->get('/assets/{id}/edit', fn($p) => (new AssetController())->edit($p)); +$router->post('/assets/{id}', fn($p) => (new AssetController())->update($p)); +$router->post('/assets/{id}/delete', fn($p) => (new AssetController())->destroy($p)); +$router->post('/assets/{id}/depreciation', fn($p) => (new AssetController())->saveDepreciation($p)); // ── UStVA ──────────────────────────────────────────────────── $router->get('/tax/ustva', fn($p) => (new UstvaController())->index($p)); diff --git a/src/Controllers/AssetController.php b/src/Controllers/AssetController.php index dded27f..a743974 100644 --- a/src/Controllers/AssetController.php +++ b/src/Controllers/AssetController.php @@ -5,6 +5,7 @@ declare(strict_types=1); namespace Steuerkram\Controllers; use Steuerkram\Models\Asset; +use Steuerkram\Models\AssetDepreciation; use Steuerkram\View; class AssetController @@ -29,10 +30,11 @@ class AssetController $view->render('layout/base.php', [ 'pageTitle' => 'Wirtschaftsgut anlegen', 'content' => $this->renderView('assets/form', [ - 'asset' => [], - 'errors' => [], - 'action' => '/assets', - 'anlagearten' => Asset::anlagearten(), + 'asset' => [], + 'errors' => [], + 'action' => '/assets', + 'anlagearten' => Asset::anlagearten(), + 'scheduleRows' => [], ]), ]); } @@ -47,17 +49,23 @@ class AssetController $view->render('layout/base.php', [ 'pageTitle' => 'Wirtschaftsgut anlegen', 'content' => $this->renderView('assets/form', [ - 'asset' => $data, - 'errors' => $errors, - 'action' => '/assets', - 'anlagearten' => Asset::anlagearten(), + 'asset' => $data, + 'errors' => $errors, + 'action' => '/assets', + 'anlagearten' => Asset::anlagearten(), + 'scheduleRows' => [], ]), ]); return; } - Asset::create($data); - View::redirect('/assets', 'Wirtschaftsgut erfolgreich angelegt.'); + $id = Asset::create($data); + $asset = Asset::find($id); + if ($asset && (int)($asset['automatisch_berechnet'] ?? 1) === 1) { + AssetDepreciation::generateAndSave($asset); + } + + View::redirect('/assets/' . $id . '/edit', 'Wirtschaftsgut erfolgreich angelegt.'); } public function edit(array $params): void @@ -65,14 +73,17 @@ class AssetController $asset = Asset::find((int)$params['id']); if (!$asset) { http_response_code(404); return; } + $scheduleRows = AssetDepreciation::findByAsset((int)$asset['id']); + $view = new View(); $view->render('layout/base.php', [ 'pageTitle' => 'Wirtschaftsgut bearbeiten', 'content' => $this->renderView('assets/form', [ - 'asset' => $asset, - 'errors' => [], - 'action' => '/assets/' . $asset['id'], - 'anlagearten' => Asset::anlagearten(), + 'asset' => $asset, + 'errors' => [], + 'action' => '/assets/' . $asset['id'], + 'anlagearten' => Asset::anlagearten(), + 'scheduleRows' => $scheduleRows, ]), ]); } @@ -87,46 +98,128 @@ class AssetController $errors = $this->validate($data); if ($errors) { + $scheduleRows = AssetDepreciation::findByAsset($id); $view = new View(); $view->render('layout/base.php', [ 'pageTitle' => 'Wirtschaftsgut bearbeiten', 'content' => $this->renderView('assets/form', [ - 'asset' => array_merge($asset, $data), - 'errors' => $errors, - 'action' => '/assets/' . $id, - 'anlagearten' => Asset::anlagearten(), + 'asset' => array_merge($asset, $data), + 'errors' => $errors, + 'action' => '/assets/' . $id, + 'anlagearten' => Asset::anlagearten(), + 'scheduleRows' => $scheduleRows, ]), ]); return; } + // Wenn Parameter geändert und Tabelle war manuell: Flag beibehalten + $wasAutomatisch = (bool)(int)($asset['automatisch_berechnet'] ?? 1); Asset::update($id, $data); - View::redirect('/assets', 'Wirtschaftsgut aktualisiert.'); + + $updatedAsset = Asset::find($id); + $isAutomatisch = (bool)(int)($updatedAsset['automatisch_berechnet'] ?? 1); + + if ($isAutomatisch) { + AssetDepreciation::generateAndSave($updatedAsset); + } + + View::redirect('/assets/' . $id . '/edit', 'Wirtschaftsgut aktualisiert.'); } public function destroy(array $params): void { - if (!Asset::delete((int)$params['id'])) { + $id = (int)$params['id']; + AssetDepreciation::deleteByAsset($id); + if (!Asset::delete($id)) { View::redirect('/assets', 'Wirtschaftsgut konnte nicht gelöscht werden.', 'error'); return; } View::redirect('/assets', 'Wirtschaftsgut gelöscht.'); } + /** + * AJAX: Abschreibungstabelle vorschau berechnen (ohne DB-Speicherung) + * GET /assets/depreciation-preview?anschaffungskosten=...&nutzungsdauer=...&... + */ + public function depreciationPreview(array $params): void + { + header('Content-Type: application/json; charset=utf-8'); + + $asset = [ + 'anschaffungskosten' => (float)($_GET['anschaffungskosten'] ?? 0), + 'zugaenge' => (float)($_GET['zugaenge'] ?? 0), + 'abgaenge' => (float)($_GET['abgaenge'] ?? 0), + 'nutzungsdauer' => (int)($_GET['nutzungsdauer'] ?? 0), + 'cent_im_ersten_jahr'=> !empty($_GET['cent_im_ersten_jahr']) ? 1 : 0, + 'anschaffungsdatum' => $_GET['anschaffungsdatum'] ?? date('Y-01-01'), + ]; + + $rows = AssetDepreciation::calculate($asset); + echo json_encode(['rows' => $rows]); + } + + /** + * AJAX: Manuell bearbeitete Abschreibungstabelle speichern + * POST /assets/{id}/depreciation + */ + public function saveDepreciation(array $params): void + { + header('Content-Type: application/json; charset=utf-8'); + + $id = (int)$params['id']; + $asset = Asset::find($id); + if (!$asset) { + http_response_code(404); + echo json_encode(['error' => 'Asset nicht gefunden']); + return; + } + + $body = json_decode(file_get_contents('php://input'), true); + if (!is_array($body) || !isset($body['rows'])) { + http_response_code(400); + echo json_encode(['error' => 'Ungültige Daten']); + return; + } + + $rows = []; + foreach ($body['rows'] as $i => $row) { + $rows[] = [ + 'jahr' => (int)($row['jahr'] ?? 0), + 'abschreibungsbetrag' => (float)str_replace(',', '.', $row['abschreibungsbetrag'] ?? 0), + 'restwert' => (float)str_replace(',', '.', $row['restwert'] ?? 0), + 'position' => $i, + ]; + } + + AssetDepreciation::saveForAsset($id, $rows); + + // Flag setzen: wenn Rows manuell geändert + $automatisch = !empty($body['automatisch']) ? 1 : 0; + \Steuerkram\Database::execute( + 'UPDATE assets SET automatisch_berechnet = ? WHERE id = ?', + [$automatisch, $id] + ); + + echo json_encode(['ok' => true]); + } + // ── Private helpers ────────────────────────────────────────── private function extractPost(): array { return [ - 'bezeichnung' => trim($_POST['bezeichnung'] ?? ''), - 'account_id' => trim($_POST['account_id'] ?? ''), - 'anschaffungsdatum' => trim($_POST['anschaffungsdatum'] ?? ''), - 'anschaffungskosten' => $_POST['anschaffungskosten'] ?? '0', - 'zugaenge' => $_POST['zugaenge'] ?? '0', - 'abgaenge' => $_POST['abgaenge'] ?? '0', - 'abschreibung_jahr' => $_POST['abschreibung_jahr'] ?? '0', - 'nutzungsdauer' => trim($_POST['nutzungsdauer'] ?? ''), - 'notizen' => trim($_POST['notizen'] ?? ''), + 'bezeichnung' => trim($_POST['bezeichnung'] ?? ''), + 'account_id' => trim($_POST['account_id'] ?? ''), + 'anschaffungsdatum' => trim($_POST['anschaffungsdatum'] ?? ''), + 'anschaffungskosten' => $_POST['anschaffungskosten'] ?? '0', + 'zugaenge' => $_POST['zugaenge'] ?? '0', + 'abgaenge' => $_POST['abgaenge'] ?? '0', + 'abschreibung_jahr' => $_POST['abschreibung_jahr'] ?? '0', + 'nutzungsdauer' => trim($_POST['nutzungsdauer'] ?? ''), + 'automatisch_berechnet'=> isset($_POST['automatisch_berechnet']) ? (int)(bool)$_POST['automatisch_berechnet'] : 1, + 'cent_im_ersten_jahr' => isset($_POST['cent_im_ersten_jahr']) ? 1 : 0, + 'notizen' => trim($_POST['notizen'] ?? ''), ]; } diff --git a/src/Controllers/BookingController.php b/src/Controllers/BookingController.php index 9712934..abcd054 100644 --- a/src/Controllers/BookingController.php +++ b/src/Controllers/BookingController.php @@ -144,6 +144,7 @@ class BookingController ); $kontonummer = $konto['kontonummer'] ?? ''; + $newAssetId = null; if (($konto['kontenart'] ?? '') === 'Anlagevermögen') { $assetId = isset($data['asset_id']) && $data['asset_id'] !== '' ? (int)$data['asset_id'] : null; if ($assetId === null) { @@ -154,6 +155,7 @@ class BookingController 'anschaffungsdatum' => $data['buchungs_datum'], 'anschaffungskosten' => (float)$data['betrag_netto'], ]); + $newAssetId = $assetId; } $data['asset_id'] = $assetId; } @@ -180,7 +182,11 @@ class BookingController } } - View::redirect("/belege/{$belegId}", 'Buchung erfasst.'); + if ($newAssetId !== null) { + View::redirect("/assets/{$newAssetId}/edit", 'Buchung erfasst. Bitte Wirtschaftsgut-Details ergänzen.'); + } else { + View::redirect("/belege/{$belegId}", 'Buchung erfasst.'); + } } catch (\PDOException $e) { error_log('[Steuerkram] PDO-Fehler in BookingController::store(): ' . $e->getMessage()); $this->renderBelegDetail($belegId, ['_exception' => 'Datenbankfehler beim Speichern der Buchung.'], $data); diff --git a/src/Controllers/InvoiceController.php b/src/Controllers/InvoiceController.php index 6421c16..2eab9b9 100644 --- a/src/Controllers/InvoiceController.php +++ b/src/Controllers/InvoiceController.php @@ -17,19 +17,21 @@ class InvoiceController { public function index(array $params): void { - $filter = $_GET; - $page = max(1, (int)($_GET['page'] ?? 1)); - $result = Invoice::all($filter, $page); + $filter = $_GET; + $page = max(1, (int)($_GET['page'] ?? 1)); + $result = Invoice::all($filter, $page); + $settings = Setting::get(); $view = new View(); $view->render('layout/base.php', [ 'pageTitle' => 'Ausgangsrechnungen', 'content' => $this->renderView('invoices/list', [ - 'rows' => $result['rows'], - 'total' => $result['total'], - 'pages' => $result['pages'], - 'page' => $page, - 'filter' => $filter, + 'rows' => $result['rows'], + 'total' => $result['total'], + 'pages' => $result['pages'], + 'page' => $page, + 'filter' => $filter, + 'settings' => $settings, ]), 'topbarActions' => '+ Rechnung erstellen', ]); @@ -40,15 +42,24 @@ class InvoiceController $customers = Customer::all(); $settings = Setting::get(); + $invoiceDefaults = [ + 'rechnungs_datum' => date('Y-m-d'), + ]; + + $preCustomerId = (int)($_GET['customer_id'] ?? 0); + if ($preCustomerId > 0) { + $customer = Customer::find($preCustomerId); + if ($customer) { + $invoiceDefaults['customer_id'] = $preCustomerId; + $invoiceDefaults['payment_days'] = $customer['payment_days']; + } + } + $view = new View(); $view->render('layout/base.php', [ 'pageTitle' => 'Neue Rechnung', 'content' => $this->renderView('invoices/form', [ - 'invoice' => [ - 'rechnungs_datum' => date('Y-m-d'), - 'leistungszeitraum_von' => date('Y-m-01'), - 'leistungszeitraum_bis' => date('Y-m-t'), - ], + 'invoice' => $invoiceDefaults, 'lines' => [], 'customers' => $customers, 'errors' => [], @@ -336,6 +347,17 @@ class InvoiceController return $belegNr; } + public function destroy(array $params): void + { + if (!Setting::isTestMode()) { + View::redirect('/invoices', 'Löschen ist nur im Testmodus erlaubt.', 'error'); + return; + } + $id = (int)$params['id']; + Invoice::delete($id); + View::redirect('/invoices', 'Rechnung gelöscht.'); + } + public function markPaid(array $params): void { Invoice::markPaid((int)$params['id']); diff --git a/src/Models/Asset.php b/src/Models/Asset.php index 6307cef..7d65e8f 100644 --- a/src/Models/Asset.php +++ b/src/Models/Asset.php @@ -85,8 +85,9 @@ class Asset Database::execute( 'INSERT INTO assets (lfd_nr, anlage_nr, bezeichnung, account_id, anschaffungsdatum, anschaffungskosten, - zugaenge, abgaenge, abschreibung_jahr, nutzungsdauer, notizen, fiscal_year_id) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)', + zugaenge, abgaenge, abschreibung_jahr, nutzungsdauer, automatisch_berechnet, + cent_im_ersten_jahr, notizen, fiscal_year_id) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)', [ $data['lfd_nr'] ?? self::nextLfdNr(), $anlageNr, @@ -98,6 +99,8 @@ class Asset $data['abgaenge'] ?? 0, $data['abschreibung_jahr'] ?? 0, isset($data['nutzungsdauer']) && $data['nutzungsdauer'] !== '' ? (int)$data['nutzungsdauer'] : null, + isset($data['automatisch_berechnet']) ? (int)(bool)$data['automatisch_berechnet'] : 1, + isset($data['cent_im_ersten_jahr']) ? (int)(bool)$data['cent_im_ersten_jahr'] : 0, $data['notizen'] ?? null, $fyId, ] @@ -116,17 +119,19 @@ class Asset Database::execute( 'UPDATE assets SET - bezeichnung = ?, - account_id = ?, - anschaffungsdatum = ?, - anschaffungskosten = ?, - zugaenge = ?, - abgaenge = ?, - abschreibung_jahr = ?, - nutzungsdauer = ?, - notizen = ?, - fiscal_year_id = COALESCE(?, fiscal_year_id), - updated_at = NOW() + bezeichnung = ?, + account_id = ?, + anschaffungsdatum = ?, + anschaffungskosten = ?, + zugaenge = ?, + abgaenge = ?, + abschreibung_jahr = ?, + nutzungsdauer = ?, + automatisch_berechnet = ?, + cent_im_ersten_jahr = ?, + notizen = ?, + fiscal_year_id = COALESCE(?, fiscal_year_id), + updated_at = NOW() WHERE id = ?', [ $data['bezeichnung'], @@ -137,6 +142,8 @@ class Asset $data['abgaenge'] ?? 0, $data['abschreibung_jahr'] ?? 0, isset($data['nutzungsdauer']) && $data['nutzungsdauer'] !== '' ? (int)$data['nutzungsdauer'] : null, + isset($data['automatisch_berechnet']) ? (int)(bool)$data['automatisch_berechnet'] : 1, + isset($data['cent_im_ersten_jahr']) ? (int)(bool)$data['cent_im_ersten_jahr'] : 0, $data['notizen'] ?? null, $fyId, $id, diff --git a/src/Models/AssetDepreciation.php b/src/Models/AssetDepreciation.php new file mode 100644 index 0000000..a96da53 --- /dev/null +++ b/src/Models/AssetDepreciation.php @@ -0,0 +1,116 @@ + $startjahr, + 'abschreibungsbetrag' => round($basis, 2), + 'restwert' => 0.00, + 'position' => 0, + ]; + return $rows; + } + + if ($centImErstenJahr) { + // Folgejahre: auf volle Euro gerundet (abgerundet) + $regelJahr = floor($basis / $nutzungsdauer); + $erstesJahr = round($basis - $regelJahr * ($nutzungsdauer - 1), 2); + } else { + $regelJahr = round($basis / $nutzungsdauer, 2); + $erstesJahr = round($basis - $regelJahr * ($nutzungsdauer - 1), 2); + } + + $restwert = $basis; + for ($i = 0; $i < $nutzungsdauer; $i++) { + $afa = ($i === 0) ? $erstesJahr : (float)$regelJahr; + $restwert = round($restwert - $afa, 2); + // Letztes Jahr: Restwert auf 0 normalisieren (Rundungsfehler) + if ($i === $nutzungsdauer - 1) { + $afa = round($afa + $restwert, 2); + $restwert = 0.00; + } + $rows[] = [ + 'jahr' => $startjahr + $i, + 'abschreibungsbetrag' => $afa, + 'restwert' => $restwert, + 'position' => $i, + ]; + } + + return $rows; + } + + public static function saveForAsset(int $assetId, array $rows): void + { + self::deleteByAsset($assetId); + foreach ($rows as $row) { + Database::execute( + 'INSERT INTO asset_depreciation_schedule + (asset_id, jahr, abschreibungsbetrag, restwert, position) + VALUES (?, ?, ?, ?, ?)', + [ + $assetId, + (int)$row['jahr'], + round((float)$row['abschreibungsbetrag'], 2), + round((float)$row['restwert'], 2), + (int)$row['position'], + ] + ); + } + } + + public static function deleteByAsset(int $assetId): void + { + Database::execute( + 'DELETE FROM asset_depreciation_schedule WHERE asset_id = ?', + [$assetId] + ); + } + + public static function generateAndSave(array $asset): void + { + $id = (int)($asset['id'] ?? 0); + if ($id <= 0) { + return; + } + $rows = self::calculate($asset); + self::saveForAsset($id, $rows); + } +} diff --git a/src/Models/Beleg.php b/src/Models/Beleg.php index cbc8973..4004430 100644 --- a/src/Models/Beleg.php +++ b/src/Models/Beleg.php @@ -14,9 +14,15 @@ class Beleg $params = []; if (!empty($filter['search'])) { - $where[] = '(b.beleg_nr LIKE ? OR b.filename LIKE ? OR b.rechnungs_nr LIKE ? OR b.notes LIKE ?)'; + $where[] = '(b.beleg_nr LIKE ? OR b.filename LIKE ? OR b.rechnungs_nr LIKE ? OR b.notes LIKE ? + OR CAST(b.beleg_datum AS CHAR) LIKE ? + OR s.name LIKE ? + OR b.bewirtungsgrund LIKE ? OR b.teilnehmerliste LIKE ? + OR (SELECT CAST(SUM(bk2s.betrag_brutto) AS CHAR) FROM bookings bk2s + WHERE bk2s.beleg_id = b.id AND bk2s.is_reversal = 0) LIKE ?)'; $s = '%' . $filter['search'] . '%'; - $params = array_merge($params, [$s, $s, $s, $s]); + $sBrutto = '%' . str_replace(',', '.', $filter['search']) . '%'; + $params = array_merge($params, [$s, $s, $s, $s, $s, $s, $s, $s, $sBrutto]); } if (!empty($filter['year'])) { $where[] = '(YEAR(b.beleg_datum) = ? OR b.beleg_datum IS NULL)'; @@ -59,7 +65,7 @@ class Beleg $rows = Database::fetchAll($sql, $params); // Count total - $countSql = 'SELECT COUNT(*) as cnt FROM belege b WHERE ' . implode(' AND ', $where); + $countSql = 'SELECT COUNT(*) as cnt FROM belege b LEFT JOIN suppliers s ON b.supplier_id = s.id WHERE ' . implode(' AND ', $where); $countParams = array_slice($params, 0, -2); $total = (int)(Database::fetchOne($countSql, $countParams)['cnt'] ?? 0); diff --git a/src/Models/Booking.php b/src/Models/Booking.php index bec17b8..5f29771 100644 --- a/src/Models/Booking.php +++ b/src/Models/Booking.php @@ -251,7 +251,7 @@ class Booking self::create([ 'beleg_id' => $belegId, 'buchungs_datum' => $buchungsDatum, - 'buchungstyp' => 'Ausgabe', + 'buchungstyp' => 'Einnahme', 'kategorie_konto_id' => $kategorieId, 'zahlungskonto_id' => $zahlungsId, 'betrag_netto' => $betragNetto, @@ -264,7 +264,7 @@ class Booking self::create([ 'beleg_id' => $belegId, 'buchungs_datum' => $buchungsDatum, - 'buchungstyp' => 'Ausgabe', + 'buchungstyp' => 'Einnahme', 'kategorie_konto_id' => $kategorieId, 'zahlungskonto_id' => $zahlungsId, 'betrag_netto' => $betragNetto, diff --git a/src/Models/Customer.php b/src/Models/Customer.php index 255bab5..1d6f027 100644 --- a/src/Models/Customer.php +++ b/src/Models/Customer.php @@ -108,8 +108,16 @@ class Customer public static function generateKundennummer(): string { - $result = Database::fetchOne('SELECT MAX(CAST(SUBSTRING(kundennummer, 3) AS UNSIGNED)) as max_nr FROM customers WHERE kundennummer REGEXP \'^KD[0-9]+$\''); + $settings = \Steuerkram\Models\Setting::get(); + $prefix = $settings['customer_nr_prefix'] ?? 'K-'; + $prefixLen = strlen($prefix) + 1; + $escaped = preg_quote($prefix, '/'); + $result = Database::fetchOne( + "SELECT MAX(CAST(SUBSTRING(kundennummer, {$prefixLen}) AS UNSIGNED)) as max_nr + FROM customers WHERE kundennummer REGEXP ?", + ['^' . $escaped . '[0-9]+$'] + ); $next = (int)($result['max_nr'] ?? 0) + 1; - return sprintf('KD%04d', $next); + return $prefix . sprintf('%04d', $next); } } diff --git a/src/Models/Invoice.php b/src/Models/Invoice.php index 9dbe016..0fd90d7 100644 --- a/src/Models/Invoice.php +++ b/src/Models/Invoice.php @@ -78,14 +78,13 @@ class Invoice $faellig = date('Y-m-d', strtotime($datum . " +{$payDays} days")); Database::execute( - 'INSERT INTO invoices (customer_id, betreff, leistungszeitraum_von, leistungszeitraum_bis, + 'INSERT INTO invoices (customer_id, betreff, leistungszeitraum, rechnungs_datum, faellig_datum, status, notes) - VALUES (?, ?, ?, ?, ?, ?, \'Entwurf\', ?)', + VALUES (?, ?, ?, ?, ?, \'Entwurf\', ?)', [ (int)$data['customer_id'], $data['betreff'], - $data['leistungszeitraum_von'] ?: null, - $data['leistungszeitraum_bis'] ?: null, + ($data['leistungszeitraum'] ?? '') !== '' ? $data['leistungszeitraum'] : null, $datum, $faellig, $data['notes'] ?? null, @@ -116,14 +115,13 @@ class Invoice $faellig = date('Y-m-d', strtotime($datum . " +{$payDays} days")); Database::execute( - 'UPDATE invoices SET customer_id=?, betreff=?, leistungszeitraum_von=?, leistungszeitraum_bis=?, + 'UPDATE invoices SET customer_id=?, betreff=?, leistungszeitraum=?, rechnungs_datum=?, faellig_datum=?, notes=?, updated_at=NOW() WHERE id=? AND status=\'Entwurf\'', [ (int)$data['customer_id'], $data['betreff'], - $data['leistungszeitraum_von'] ?: null, - $data['leistungszeitraum_bis'] ?: null, + ($data['leistungszeitraum'] ?? '') !== '' ? $data['leistungszeitraum'] : null, $datum, $faellig, $data['notes'] ?? null, @@ -232,6 +230,22 @@ class Invoice ); } + public static function delete(int $id): void + { + $invoice = self::find($id); + if (!$invoice) return; + + $belegId = !empty($invoice['beleg_id']) ? (int)$invoice['beleg_id'] : null; + + Database::execute('DELETE FROM invoice_lines WHERE invoice_id = ?', [$id]); + Database::execute('DELETE FROM invoices WHERE id = ?', [$id]); + + if ($belegId !== null) { + Database::execute('DELETE FROM bookings WHERE beleg_id = ?', [$belegId]); + Database::execute('DELETE FROM belege WHERE id = ?', [$belegId]); + } + } + public static function generateRechnungsNr(): string { $settings = Database::fetchOne( diff --git a/src/Models/Setting.php b/src/Models/Setting.php index e610d29..85339a4 100644 --- a/src/Models/Setting.php +++ b/src/Models/Setting.php @@ -32,7 +32,7 @@ class Setting ust_id=?, steuernummer=?, finanzamt=?, iban=?, bic=?, bank_name=?, ustva_steuernummer=?, ustva_bundesland=?, - invoice_nr_prefix=?, beleg_nr_prefix=?, anlage_nr_prefix=?, + invoice_nr_prefix=?, beleg_nr_prefix=?, anlage_nr_prefix=?, customer_nr_prefix=?, payment_days_default=?, test_mode=?, ki_vorschlag_aktiv=?, nextcloud_url=?, nextcloud_user=?, nextcloud_pass=?, nextcloud_folder=?, nextcloud_done=?, @@ -59,6 +59,7 @@ class Setting $data['invoice_nr_prefix'] ?? 'AR', $data['beleg_nr_prefix'] ?? 'BLG', $data['anlage_nr_prefix'] ?? 'ANL', + $data['customer_nr_prefix'] ?? 'K-', (int)($data['payment_days_default'] ?? 14), isset($data['test_mode']) ? 1 : 0, isset($data['ki_vorschlag_aktiv']) ? 1 : 0, diff --git a/src/Services/LlmService.php b/src/Services/LlmService.php index 22b9007..f144826 100644 --- a/src/Services/LlmService.php +++ b/src/Services/LlmService.php @@ -93,6 +93,17 @@ class LlmService return $data['message']['content'] ?? ''; } + /** + * Prüft ohne Timeout-Kosten, ob der Provider überhaupt erreichbar ist. + * Nur für Diagnosezwecke (Einstellungsseite) – nicht im normalen Request-Pfad nutzen. + */ + public function isReachable(): bool + { + if ($this->mode === null) return false; + if ($this->mode === 'ollama') return $this->probeOllama($this->url); + return true; // OpenAI-Modus: kein schneller Probe verfügbar + } + private function detect(): void { $ollamaUrl = getenv('OLLAMA_URL') ?: ($_ENV['OLLAMA_URL'] ?? ''); @@ -147,7 +158,8 @@ class LlmService 'stream' => false, ]; - $ch = curl_init($this->url . '/api/generate'); + $endpoint = $this->url . '/api/generate'; + $ch = curl_init($endpoint); curl_setopt_array($ch, [ CURLOPT_POST => true, CURLOPT_RETURNTRANSFER => true, @@ -157,9 +169,17 @@ class LlmService ]); $response = curl_exec($ch); $err = curl_error($ch); + $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE); curl_close($ch); - if ($err || !$response) return ''; + if ($err) { + error_log("[Steuerkram] LlmService::callOllama() cURL-Fehler auf {$endpoint}: {$err}"); + return ''; + } + if (!$response) { + error_log("[Steuerkram] LlmService::callOllama() leere Antwort von {$endpoint} (HTTP {$httpCode})"); + return ''; + } return json_decode($response, true)['response'] ?? ''; } diff --git a/src/Services/PdfInvoiceService.php b/src/Services/PdfInvoiceService.php index 718c311..1b02913 100644 --- a/src/Services/PdfInvoiceService.php +++ b/src/Services/PdfInvoiceService.php @@ -68,14 +68,7 @@ class PdfInvoiceService $logoWrapStyle = 'text-align:center'; } - // Leistungszeitraum - $lz = ''; - if (!empty($inv['leistungszeitraum_von'])) { - $lz = $fmtDate($inv['leistungszeitraum_von']); - if (!empty($inv['leistungszeitraum_bis']) && $inv['leistungszeitraum_bis'] !== $inv['leistungszeitraum_von']) { - $lz .= ' – ' . $fmtDate($inv['leistungszeitraum_bis']); - } - } + $lz = $inv['leistungszeitraum'] ?? ''; // Fälligkeitsdatum $fälligText = !empty($inv['faellig_datum']) diff --git a/src/Views/assets/form.php b/src/Views/assets/form.php index e5b5aea..9a115d0 100644 --- a/src/Views/assets/form.php +++ b/src/Views/assets/form.php @@ -1,6 +1,17 @@ - View::h($v); ?> + View::h($v); -
+$assetId = (int)($asset['id'] ?? 0); +$isEdit = $assetId > 0; +$nutzung = isset($asset['nutzungsdauer']) && $asset['nutzungsdauer'] !== '' ? (int)$asset['nutzungsdauer'] : null; +$predefined = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 12, 13, 15, 20, 25, 50]; +$isCustom = $nutzung !== null && !in_array($nutzung, $predefined, true); +$centFlag = !empty($asset['cent_im_ersten_jahr']); +$autoFlag = isset($asset['automatisch_berechnet']) ? (bool)(int)$asset['automatisch_berechnet'] : true; +?> + +
@@ -65,18 +76,55 @@
-
-
- - +
+ + +
+ + +
+
+ +
+ + + > +
-
- - +
+ + + +
+
+ +
+ +
+ Wenn aktiv: Restwert nach Jahr 1 auf volle Euro gerundet; Folgejahre gleichmäßig ohne Centabweichungen.
@@ -85,6 +133,10 @@
+ + +
Abbrechen @@ -92,3 +144,286 @@
+ + + + + + + diff --git a/src/Views/belege/detail.php b/src/Views/belege/detail.php index 26a7a3b..2660bea 100644 --- a/src/Views/belege/detail.php +++ b/src/Views/belege/detail.php @@ -973,7 +973,7 @@ async function _startKiAnalyse() { document.addEventListener('DOMContentLoaded', function () { const arr = _normalizeKiArr(_kiVorschlag); if (arr === null) { - // Noch nicht verarbeitet – nichts anzeigen + if (_kiAktiv) { _startKiAnalyse(); } } else if (arr.length === 0) { // Verarbeitet, aber kein Ergebnis _showKiBadgeNoResult(); diff --git a/src/Views/belege/list.php b/src/Views/belege/list.php index 7f30d08..268f264 100644 --- a/src/Views/belege/list.php +++ b/src/Views/belege/list.php @@ -92,7 +92,7 @@
-
- - -
-
- - + +
@@ -149,4 +144,10 @@ function updatePaymentDays(sel) { const days = opt.dataset.payment; if (days) document.getElementById('payment_days').value = days; } + +document.addEventListener('DOMContentLoaded', function () { + const sel = document.getElementById('customer_id'); + if (sel && sel.value) updatePaymentDays(sel); +}); + diff --git a/src/Views/invoices/list.php b/src/Views/invoices/list.php index 571fd0d..3d79c5e 100644 --- a/src/Views/invoices/list.php +++ b/src/Views/invoices/list.php @@ -64,6 +64,12 @@ PDF + + + + +
diff --git a/src/Views/invoices/show.php b/src/Views/invoices/show.php index 046ca38..eb7a876 100644 --- a/src/Views/invoices/show.php +++ b/src/Views/invoices/show.php @@ -7,22 +7,7 @@ $customerName = !empty($invoice['firma']) ? $invoice['firma'] : trim(($invoice['anrede'] ?? '') . ' ' . ($invoice['vorname'] ?? '') . ' ' . $invoice['nachname']); -$lz = ''; -if (!empty($invoice['leistungszeitraum_von'])) { - $lz = View::formatDate($invoice['leistungszeitraum_von']); - if (!empty($invoice['leistungszeitraum_bis'])) { - $lz .= ' – ' . View::formatDate($invoice['leistungszeitraum_bis']); - } -} - -// Leistungszeitraum mit
für die Vorschau-Tabelle (Datumsangaben untereinander) -$lzHtml = ''; -if (!empty($invoice['leistungszeitraum_von'])) { - $lzHtml = $h(View::formatDate($invoice['leistungszeitraum_von'])); - if (!empty($invoice['leistungszeitraum_bis']) && $invoice['leistungszeitraum_bis'] !== $invoice['leistungszeitraum_von']) { - $lzHtml .= '
' . $h(View::formatDate($invoice['leistungszeitraum_bis'])); - } -} +$lzHtml = $h($invoice['leistungszeitraum'] ?? ''); $badgeClass = match($invoice['status']) { 'Bezahlt' => 'badge-green', diff --git a/src/Views/settings/form.php b/src/Views/settings/form.php index 35d474d..22a0f8e 100644 --- a/src/Views/settings/form.php +++ b/src/Views/settings/form.php @@ -140,14 +140,22 @@ Konfiguration via .env (lokales Ollama oder Remote-API).

getProviderLabel(); + $reachable = $providerLabel !== null ? $llm->isReachable() : false; + $ollamaUrlEnv = getenv('OLLAMA_URL') ?: ($_ENV['OLLAMA_URL'] ?? ''); ?> -

- - ✓ Provider erkannt: +

+ + ✓ Provider erreichbar: + + ✗ Provider konfiguriert (), aber Server nicht erreichbar. + Prüfe ob Ollama auf dem Host läuft und auf allen Interfaces hört (OLLAMA_HOST=0.0.0.0:11434). + Details im PHP-Fehlerlog des Webservers (docker compose logs web). + + ✗ OLLAMA_URL gesetzt (), aber kein Provider erkannt. - — Kein LLM-Provider konfiguriert oder erreichbar (Auto-Discovery localhost:11434 fehlgeschlagen) + — Kein LLM-Provider konfiguriert (Auto-Discovery localhost:11434 fehlgeschlagen)

@@ -236,6 +244,10 @@
+
+ + +
diff --git a/src/version.txt b/src/version.txt index 8c7da58..3ada37a 100644 --- a/src/version.txt +++ b/src/version.txt @@ -1 +1 @@ -1-260425 +1-260426 diff --git a/tests/18_StornoBuchungTest.php b/tests/18_StornoBuchungTest.php index ec56e4a..ec9294e 100644 --- a/tests/18_StornoBuchungTest.php +++ b/tests/18_StornoBuchungTest.php @@ -26,6 +26,17 @@ class StornoBuchungTest extends TestCase return; } + // ── EÜR: Konto-Stand VOR den Test-Buchungen ermitteln ───────── + $euerCalcPre = new \Steuerkram\Services\EuerCalculator(); + $euerPre = $euerCalcPre->calculate($year); + $kontoNettoPre = 0.0; + foreach ($euerPre['ausgaben'] as $row) { + if ((int)$row['id'] === (int)$ausgabeId) { + $kontoNettoPre = (float)$row['netto']; + break; + } + } + // ── Beleg anlegen ────────────────────────────────────────────── $belegId = \Steuerkram\Models\Beleg::create([ 'beleg_nr' => 'STORNO-TEST-' . uniqid(), @@ -63,23 +74,23 @@ class StornoBuchungTest extends TestCase $this->assertEquals(-19.00, round((float)$reversal['betrag_ust'], 2), 'Stornobuchung: betrag_ust = -19'); $this->assertEquals(-119.00, round((float)$reversal['betrag_brutto'], 2), 'Stornobuchung: betrag_brutto = -119'); - // ── EÜR: storniertes Konto hat Netto-Summe = 0 ─────────────── - $euerCalc = new \Steuerkram\Services\EuerCalculator(); - $euer = $euerCalc->calculate($year); - - $kontoNetto = null; + // ── EÜR: Storno + Original heben sich auf (Konto-Saldo unverändert) ─ + // Vergleich mit dem Stand vor den Test-Buchungen, da auf dem Produktivsystem + // bereits echte Buchungen auf dem Konto existieren können. + $euerCalc = new \Steuerkram\Services\EuerCalculator(); + $euer = $euerCalc->calculate($year); + $kontoNetto = 0.0; foreach ($euer['ausgaben'] as $row) { if ((int)$row['id'] === (int)$ausgabeId) { $kontoNetto = (float)$row['netto']; break; } } - // Wenn das Konto nicht mehr auftaucht (alle Buchungen = 0), ist das ebenfalls korrekt. - if ($kontoNetto !== null) { - $this->assertEquals(0.0, round($kontoNetto, 2), 'EÜR: Storno + Original = 0 auf Ausgabe-Konto'); - } else { - $this->assert(true, 'EÜR: Konto taucht bei Summe=0 nicht auf (HAVING buchungsanzahl > 0 greift)'); - } + $this->assertEquals( + round($kontoNettoPre, 2), + round($kontoNetto, 2), + 'EÜR: Storno + Original = 0 auf Ausgabe-Konto' + ); // ── UStVA: Vorsteuer-KZ hebt sich auf ───────────────────────── // Kz 61 enthält Vorsteuer aus dem Ausgabe-Konto (ustva_kz='61'). diff --git a/tests/21_BelegSucheErweitertTest.php b/tests/21_BelegSucheErweitertTest.php new file mode 100644 index 0000000..032b502 --- /dev/null +++ b/tests/21_BelegSucheErweitertTest.php @@ -0,0 +1,110 @@ +findAccountId('1200'); + $ausgabeId = $this->findAccountId('4920'); + + if (!$bankId || !$ausgabeId) { + $this->assert(false, 'Benötigte Konten fehlen – SKR03-Seed vorhanden?'); + return; + } + + $uid = uniqid('suchtest_', true); + + // Lieferant anlegen + $supplierName = 'TestLieferant_' . $uid; + \Steuerkram\Database::execute( + 'INSERT INTO suppliers (name) VALUES (?)', + [$supplierName] + ); + $supplierId = (int)\Steuerkram\Database::fetchOne('SELECT LAST_INSERT_ID() AS id')['id']; + + // Beleg mit allen Suchfeldern anlegen + $belegNr = 'SUCH-' . substr(md5($uid), 0, 8); + $belegDatum = '2025-07-15'; + $bewirtung = 'Kundentermin_' . $uid; + $teilnehmer = 'Alice_' . $uid . ', Bob'; + + $belegId = \Steuerkram\Models\Beleg::create([ + 'beleg_nr' => $belegNr, + 'filename' => 'suchtest.pdf', + 'storage_path' => '', + 'mime_type' => 'application/pdf', + 'file_hash' => hash('sha256', $uid), + 'beleg_datum' => $belegDatum, + 'belegtyp' => 'Eingangsrechnung', + ]); + + // Lieferant, Bewirtungsgrund und Teilnehmerliste setzen + \Steuerkram\Database::execute( + 'UPDATE belege SET supplier_id = ?, bewirtungsgrund = ?, teilnehmerliste = ? WHERE id = ?', + [$supplierId, $bewirtung, $teilnehmer, $belegId] + ); + + // Buchung anlegen → betrag_brutto für Brutto-Suche + \Steuerkram\Models\Booking::create([ + 'beleg_id' => $belegId, + 'buchungs_datum' => $belegDatum, + 'buchungstyp' => 'Ausgabe', + 'kategorie_konto_id' => $ausgabeId, + 'zahlungskonto_id' => $bankId, + 'betrag_netto' => 299.00, + 'ust_satz' => 19.0, + 'buchungstext' => 'Suchtest', + 'is_reversal' => false, + ]); + + // ── Suche nach Lieferantenname ──────────────────────────────────── + $res = \Steuerkram\Models\Beleg::all(['search' => $supplierName], 1, 50); + $found = $this->containsBeleg($res['rows'], $belegId); + $this->assert($found, 'Suche nach Lieferantenname findet den Beleg'); + + // ── Suche nach Datum (YYYY-MM-DD) ──────────────────────────────── + $res = \Steuerkram\Models\Beleg::all(['search' => '2025-07-15'], 1, 50); + $found = $this->containsBeleg($res['rows'], $belegId); + $this->assert($found, 'Suche nach Belegdatum findet den Beleg'); + + // ── Suche nach Bewirtungsgrund ──────────────────────────────────── + $res = \Steuerkram\Models\Beleg::all(['search' => $bewirtung], 1, 50); + $found = $this->containsBeleg($res['rows'], $belegId); + $this->assert($found, 'Suche nach Bewirtungsgrund findet den Beleg'); + + // ── Suche nach Teilnehmerliste ──────────────────────────────────── + $res = \Steuerkram\Models\Beleg::all(['search' => 'Alice_' . $uid], 1, 50); + $found = $this->containsBeleg($res['rows'], $belegId); + $this->assert($found, 'Suche nach Teilnehmer findet den Beleg'); + + // ── Suche nach Bruttobetrag ──────────────────────────────────────── + // 299 * 1.19 = 355.81 + $res = \Steuerkram\Models\Beleg::all(['search' => '355.81'], 1, 50); + $found = $this->containsBeleg($res['rows'], $belegId); + $this->assert($found, 'Suche nach Bruttobetrag findet den Beleg (Dezimalpunkt)'); + + $res = \Steuerkram\Models\Beleg::all(['search' => '355,81'], 1, 50); + $found = $this->containsBeleg($res['rows'], $belegId); + $this->assert($found, 'Suche nach Bruttobetrag findet den Beleg (Dezimalkomma)'); + + // ── Bestehende Felder funktionieren noch ────────────────────────── + $res = \Steuerkram\Models\Beleg::all(['search' => $belegNr], 1, 50); + $found = $this->containsBeleg($res['rows'], $belegId); + $this->assert($found, 'Suche nach Belegnr. funktioniert weiterhin'); + } + + private function containsBeleg(array $rows, int $id): bool + { + foreach ($rows as $row) { + if ((int)$row['id'] === $id) return true; + } + return false; + } +} diff --git a/tests/22_RechnungZahlungszielTest.php b/tests/22_RechnungZahlungszielTest.php new file mode 100644 index 0000000..a1ba63d --- /dev/null +++ b/tests/22_RechnungZahlungszielTest.php @@ -0,0 +1,63 @@ + 'ZahlungszielTestKunde', + 'strasse' => 'Musterstr. 1', + 'plz' => '12345', + 'ort' => 'Musterstadt', + 'land' => 'DE', + 'payment_days' => 30, + ]); + + $customer = Customer::find($customerId); + $this->assert($customer !== false, 'Kunde wurde angelegt'); + $this->assertEquals(30, (int)$customer['payment_days'], 'Kundendatensatz enthält payment_days=30'); + + // Simuliere Controller-Logik: customer_id per GET → payment_days vorbelegen + $preCustomerId = $customerId; + $invoiceDefaults = [ + 'rechnungs_datum' => date('Y-m-d'), + 'leistungszeitraum_von' => date('Y-m-01'), + 'leistungszeitraum_bis' => date('Y-m-t'), + ]; + if ($preCustomerId > 0) { + $c = Customer::find($preCustomerId); + if ($c) { + $invoiceDefaults['customer_id'] = $preCustomerId; + $invoiceDefaults['payment_days'] = $c['payment_days']; + } + } + + $this->assertEquals($customerId, $invoiceDefaults['customer_id'], 'customer_id in Rechnungsdefaults gesetzt'); + $this->assertEquals(30, (int)$invoiceDefaults['payment_days'], 'payment_days aus Kundendaten vorbelegt'); + + // Ohne vorausgewählten Kunden bleibt payment_days ungesetzt + $defaultsOhneKunde = [ + 'rechnungs_datum' => date('Y-m-d'), + ]; + $this->assert(!isset($defaultsOhneKunde['payment_days']), 'payment_days ohne Kunden nicht gesetzt'); + + // Anderer Kunde mit abweichendem Zahlungsziel + $customer2Id = Customer::create([ + 'nachname' => 'ZahlungszielTestKunde2', + 'strasse' => 'Testgasse 2', + 'plz' => '54321', + 'ort' => 'Testdorf', + 'land' => 'DE', + 'payment_days' => 7, + ]); + $c2 = Customer::find($customer2Id); + $this->assertEquals(7, (int)$c2['payment_days'], 'Zweiter Kunde hat payment_days=7'); + } +} diff --git a/tests/23_KundenNummernkreisTest.php b/tests/23_KundenNummernkreisTest.php new file mode 100644 index 0000000..48d920f --- /dev/null +++ b/tests/23_KundenNummernkreisTest.php @@ -0,0 +1,44 @@ +assert(array_key_exists('customer_nr_prefix', $settings), 'customer_nr_prefix-Spalte existiert in company_settings'); + + // Präfix in den Einstellungen speichern und lesen + $originalPrefix = $settings['customer_nr_prefix'] ?? 'K-'; + $testPrefix = 'TEST-'; + Setting::update(array_merge($settings, ['customer_nr_prefix' => $testPrefix])); + $updated = Setting::get(); + $this->assertEquals($testPrefix, $updated['customer_nr_prefix'], 'customer_nr_prefix wird korrekt gespeichert'); + + // Kundennummer mit neuem Präfix generieren + $nr = Customer::generateKundennummer(); + $this->assert(str_starts_with($nr, $testPrefix), 'Generierte Kundennummer beginnt mit neuem Präfix'); + $numericPart = substr($nr, strlen($testPrefix)); + $this->assert(ctype_digit($numericPart), 'Numerischer Teil der Kundennummer enthält nur Ziffern'); + $this->assert((int)$numericPart > 0, 'Numerischer Teil > 0'); + + // Zurücksetzen auf Standard-Präfix + Setting::update(array_merge($updated, ['customer_nr_prefix' => 'K-'])); + $reset = Setting::get(); + $this->assertEquals('K-', $reset['customer_nr_prefix'], 'Präfix auf K- zurückgesetzt'); + + // Kundennummer mit Default-Präfix K- + $nrDefault = Customer::generateKundennummer(); + $this->assert(str_starts_with($nrDefault, 'K-'), 'Generierte Kundennummer beginnt mit K-'); + + // Originalwert wiederherstellen + Setting::update(array_merge($reset, ['customer_nr_prefix' => $originalPrefix])); + } +} diff --git a/tests/24_RechnungLoeschenTestmodusTest.php b/tests/24_RechnungLoeschenTestmodusTest.php new file mode 100644 index 0000000..7eb4b9e --- /dev/null +++ b/tests/24_RechnungLoeschenTestmodusTest.php @@ -0,0 +1,56 @@ + 'LoeschTestKunde', + 'strasse' => 'Teststr. 1', + 'plz' => '12345', + 'ort' => 'Teststadt', + 'land' => 'DE', + ]); + + $invoiceId = Invoice::create([ + 'customer_id' => $customerId, + 'betreff' => 'Lösch-Test-Rechnung', + 'rechnungs_datum' => date('Y-m-d'), + 'lines' => [ + [ + 'bezeichnung' => 'Testposition', + 'menge' => 1, + 'einheit' => 'Stk.', + 'einzelpreis' => 100, + 'ust_satz' => 19, + ], + ], + ]); + + $this->assertGreaterThan(0, $invoiceId, 'Rechnung wurde angelegt'); + $invoice = Invoice::find($invoiceId); + $this->assert($invoice !== false, 'Rechnung per find() auffindbar'); + + // Rechnung löschen + Invoice::delete($invoiceId); + + $afterDelete = Invoice::find($invoiceId); + $this->assert($afterDelete === false, 'Rechnung nach delete() nicht mehr auffindbar'); + + // Positionen ebenfalls gelöscht + $lines = Invoice::lines($invoiceId); + $this->assertEquals(0, count($lines), 'Invoice-Lines nach delete() gelöscht'); + + // test_mode-Flag ist bei Neuinstallation gesetzt + $settings = Setting::get(); + $this->assert(isset($settings['test_mode']), 'test_mode-Feld in company_settings vorhanden'); + } +} diff --git a/tests/25_AnlageAutoRedirectTest.php b/tests/25_AnlageAutoRedirectTest.php new file mode 100644 index 0000000..56ebeb0 --- /dev/null +++ b/tests/25_AnlageAutoRedirectTest.php @@ -0,0 +1,82 @@ +assertNotEmpty($anlageKonto, 'Anlagevermögens-Konto vorhanden'); + + $bankKonto = \Steuerkram\Database::fetchOne( + "SELECT id FROM accounts WHERE kontenart = 'Zahlungskonto' AND aktiv = 1 LIMIT 1" + ); + $this->assertNotEmpty($bankKonto, 'Zahlungskonto vorhanden'); + + if (!$anlageKonto || !$bankKonto) { + return; + } + + // Beleg anlegen + $belegNr = 'ANLAGE-TEST-' . uniqid(); + $datum = date('Y-m-d'); + $belegId = Beleg::create([ + 'beleg_nr' => $belegNr, + 'filename' => 'anlage-test.pdf', + 'storage_path' => 'static/uploads/test/anlage-test.pdf', + 'mime_type' => 'application/pdf', + 'file_hash' => hash('sha256', $belegNr), + 'beleg_datum' => $datum, + 'rechnungs_nr' => 'RE-ANLAGE-001', + 'betrag_brutto' => 1190.00, + 'belegtyp' => 'Eingangsrechnung', + ]); + $this->assertGreaterThan(0, $belegId, 'Beleg angelegt'); + + // Buchung auf Anlagevermögens-Konto, kein bestehendes Asset + $buchungId = Booking::create([ + 'beleg_id' => $belegId, + 'buchungs_datum' => $datum, + 'buchungstyp' => 'Ausgabe', + 'kategorie_konto_id' => (int)$anlageKonto['id'], + 'zahlungskonto_id' => (int)$bankKonto['id'], + 'betrag_netto' => 1000.00, + 'ust_satz' => 19.0, + 'buchungstext' => 'Test-Notebook', + 'asset_id' => null, + ]); + $this->assertGreaterThan(0, $buchungId, 'Buchung auf Anlagekonto angelegt'); + + // Neues Asset muss automatisch existieren und mit Buchungstext befüllt sein + $buchung = Booking::find($buchungId); + $this->assertNotEmpty($buchung, 'Buchung abrufbar'); + + // In der Praxis legt BookingController das Asset an und setzt asset_id. + // Hier testen wir, dass Asset::create() korrekt einen Datensatz erzeugt, + // der die Buchungsdaten spiegelt – so wie BookingController es tut. + $assetId = Asset::create([ + 'bezeichnung' => 'Test-Notebook', + 'account_id' => (int)$anlageKonto['id'], + 'anschaffungsdatum' => $datum, + 'anschaffungskosten' => 1000.00, + ]); + $this->assertGreaterThan(0, $assetId, 'Asset wird aus Buchungsdaten angelegt (ID > 0)'); + + $asset = Asset::find($assetId); + $this->assertNotEmpty($asset, 'Asset abrufbar'); + $this->assertEquals('Test-Notebook', $asset['bezeichnung'], 'Bezeichnung = Buchungstext'); + $this->assertEquals($datum, $asset['anschaffungsdatum'], 'Anschaffungsdatum = Buchungsdatum'); + $this->assertEquals(1000.00, (float)$asset['anschaffungskosten'], 'Anschaffungskosten = Nettobetrag'); + $this->assertEquals((int)$anlageKonto['id'], (int)$asset['account_id'], 'Account-ID verknüpft'); + } +} diff --git a/tests/26_AbschreibungstabelleTest.php b/tests/26_AbschreibungstabelleTest.php new file mode 100644 index 0000000..b6453ff --- /dev/null +++ b/tests/26_AbschreibungstabelleTest.php @@ -0,0 +1,121 @@ + 1000.00, + 'zugaenge' => 0, + 'abgaenge' => 0, + 'nutzungsdauer' => 0, + 'cent_im_ersten_jahr'=> false, + 'anschaffungsdatum' => '2025-03-15', + ]); + + $this->assertEquals(1, count($rows), 'Sofortabschreibung: genau 1 Zeile'); + $this->assertEquals(2025, $rows[0]['jahr'], 'Sofortabschreibung: Startjahr korrekt'); + $this->assertEquals(1000.00, $rows[0]['abschreibungsbetrag'], 'Sofortabschreibung: voller Betrag'); + $this->assertEquals(0.00, $rows[0]['restwert'], 'Sofortabschreibung: Restwert 0'); + + // ── Gleichmäßige lineare AfA ohne Cent-Option ──────────── + $rows = AssetDepreciation::calculate([ + 'anschaffungskosten' => 3000.00, + 'zugaenge' => 0, + 'abgaenge' => 0, + 'nutzungsdauer' => 3, + 'cent_im_ersten_jahr'=> false, + 'anschaffungsdatum' => '2023-01-01', + ]); + + $this->assertEquals(3, count($rows), 'Lineare AfA 3 Jahre: 3 Zeilen'); + $this->assertEquals(1000.00, $rows[0]['abschreibungsbetrag'], 'Jahr 1: 1000,00'); + $this->assertEquals(2000.00, $rows[0]['restwert'], 'Jahr 1 Restwert: 2000,00'); + $this->assertEquals(1000.00, $rows[2]['abschreibungsbetrag'], 'Jahr 3: 1000,00'); + $this->assertEquals(0.00, $rows[2]['restwert'], 'Jahr 3 Restwert: 0,00'); + + // ── Cent-Option: Centbeträge im ersten Jahr ────────────── + $rows = AssetDepreciation::calculate([ + 'anschaffungskosten' => 1000.75, + 'zugaenge' => 0, + 'abgaenge' => 0, + 'nutzungsdauer' => 3, + 'cent_im_ersten_jahr'=> true, + 'anschaffungsdatum' => '2024-01-01', + ]); + + $this->assertEquals(3, count($rows), 'Cent-Option 3 Jahre: 3 Zeilen'); + // Folgejahre: floor(1000.75/3) = 333 € + $this->assertEquals(333.00, $rows[1]['abschreibungsbetrag'], 'Jahr 2 mit Cent-Option: 333,00'); + $this->assertEquals(333.00, $rows[2]['abschreibungsbetrag'], 'Jahr 3 mit Cent-Option: 333,00'); + // Erstes Jahr: 1000.75 - 333*2 = 334.75 + $this->assertEquals(334.75, $rows[0]['abschreibungsbetrag'], 'Jahr 1 mit Cent-Option: 334,75'); + // Restwert nach Jahr 1 auf volle Euro + $restwertJ1 = $rows[0]['restwert']; + $this->assert( + $restwertJ1 == floor($restwertJ1), + 'Restwert nach Jahr 1 ist volle Euro: ' . $restwertJ1 + ); + // Gesamtsumme muss Basis entsprechen + $sumAfa = array_sum(array_column($rows, 'abschreibungsbetrag')); + $this->assertEquals(1000.75, round($sumAfa, 2), 'Summe aller Abschreibungen = Basis'); + + // ── Basis mit Zugängen und Abgängen ───────────────────── + $rows = AssetDepreciation::calculate([ + 'anschaffungskosten' => 5000.00, + 'zugaenge' => 500.00, + 'abgaenge' => 200.00, + 'nutzungsdauer' => 5, + 'cent_im_ersten_jahr'=> false, + 'anschaffungsdatum' => '2022-06-01', + ]); + + $this->assertEquals(5, count($rows), 'AfA mit Zu-/Abgängen: 5 Zeilen'); + $basis = 5000.00 + 500.00 - 200.00; + $sumAfa = array_sum(array_column($rows, 'abschreibungsbetrag')); + $this->assertEquals($basis, round($sumAfa, 2), 'Summe AfA = Basis mit Zu-/Abgängen'); + + // ── Leere Tabelle bei Basis <= 0 ───────────────────────── + $rows = AssetDepreciation::calculate([ + 'anschaffungskosten' => 0, + 'zugaenge' => 0, + 'abgaenge' => 0, + 'nutzungsdauer' => 5, + 'cent_im_ersten_jahr'=> false, + 'anschaffungsdatum' => '2024-01-01', + ]); + $this->assertEquals(0, count($rows), 'Keine Zeilen bei Basis = 0'); + + // ── Persistenz: Speichern und Laden ────────────────────── + // Erst ein Test-Asset anlegen (Rollback durch TestCase) + \Steuerkram\Database::execute( + "INSERT INTO assets (lfd_nr, anlage_nr, bezeichnung, anschaffungsdatum, anschaffungskosten) + VALUES (9999, 'TEST-DEPR', 'Test Abschreibung', '2024-01-01', 2400.00)" + ); + $assetId = (int)\Steuerkram\Database::lastInsertId(); + + $testRows = [ + ['jahr' => 2024, 'abschreibungsbetrag' => 800.00, 'restwert' => 1600.00, 'position' => 0], + ['jahr' => 2025, 'abschreibungsbetrag' => 800.00, 'restwert' => 800.00, 'position' => 1], + ['jahr' => 2026, 'abschreibungsbetrag' => 800.00, 'restwert' => 0.00, 'position' => 2], + ]; + + AssetDepreciation::saveForAsset($assetId, $testRows); + $loaded = AssetDepreciation::findByAsset($assetId); + + $this->assertEquals(3, count($loaded), 'Persistenz: 3 Zeilen gespeichert und geladen'); + $this->assertEquals(2024, (int)$loaded[0]['jahr'], 'Persistenz: Jahr 2024 korrekt'); + $this->assertEquals(800.00, (float)$loaded[0]['abschreibungsbetrag'], 'Persistenz: Betrag korrekt'); + + AssetDepreciation::deleteByAsset($assetId); + $loaded = AssetDepreciation::findByAsset($assetId); + $this->assertEquals(0, count($loaded), 'Persistenz: Nach deleteByAsset keine Zeilen mehr'); + } +}