Merge pull request 'Fix: Eigenanteil als Einnahme, Ollama qwen3-vl, KI-Autostart + Revert UStVA-KZ' (#58) from fds2610-bot/Steuerkram:main into main

Reviewed-on: https://codeberg.org/fds2610/Steuerkram/pulls/58
This commit is contained in:
fds2610 2026-04-26 21:22:48 +02:00
commit 2fe6406f97
35 changed files with 1385 additions and 146 deletions

View file

@ -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

View file

@ -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:

View file

@ -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.

View file

@ -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;

View file

@ -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;

View file

@ -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;

View file

@ -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';

View file

@ -95,6 +95,7 @@ $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));
@ -102,9 +103,11 @@ $router->post('/invoices/{id}/lines/{line_id}/delete', fn($p) => (new InvoiceCon
$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));

View file

@ -5,6 +5,7 @@ declare(strict_types=1);
namespace Steuerkram\Controllers;
use Steuerkram\Models\Asset;
use Steuerkram\Models\AssetDepreciation;
use Steuerkram\View;
class AssetController
@ -33,6 +34,7 @@ class AssetController
'errors' => [],
'action' => '/assets',
'anlagearten' => Asset::anlagearten(),
'scheduleRows' => [],
]),
]);
}
@ -51,13 +53,19 @@ class AssetController
'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,6 +73,8 @@ 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',
@ -73,6 +83,7 @@ class AssetController
'errors' => [],
'action' => '/assets/' . $asset['id'],
'anlagearten' => Asset::anlagearten(),
'scheduleRows' => $scheduleRows,
]),
]);
}
@ -87,6 +98,7 @@ class AssetController
$errors = $this->validate($data);
if ($errors) {
$scheduleRows = AssetDepreciation::findByAsset($id);
$view = new View();
$view->render('layout/base.php', [
'pageTitle' => 'Wirtschaftsgut bearbeiten',
@ -95,24 +107,103 @@ class AssetController
'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
@ -126,6 +217,8 @@ class AssetController
'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'] ?? ''),
];
}

View file

@ -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
}
}
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);

View file

@ -20,6 +20,7 @@ class InvoiceController
$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', [
@ -30,6 +31,7 @@ class InvoiceController
'pages' => $result['pages'],
'page' => $page,
'filter' => $filter,
'settings' => $settings,
]),
'topbarActions' => '<a href="/invoices/new" class="btn btn-primary">+ Rechnung erstellen</a>',
]);
@ -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']);

View file

@ -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,
]
@ -124,6 +127,8 @@ class Asset
abgaenge = ?,
abschreibung_jahr = ?,
nutzungsdauer = ?,
automatisch_berechnet = ?,
cent_im_ersten_jahr = ?,
notizen = ?,
fiscal_year_id = COALESCE(?, fiscal_year_id),
updated_at = NOW()
@ -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,

View file

@ -0,0 +1,116 @@
<?php
declare(strict_types=1);
namespace Steuerkram\Models;
use Steuerkram\Database;
class AssetDepreciation
{
public static function findByAsset(int $assetId): array
{
return Database::fetchAll(
'SELECT id, asset_id, jahr, abschreibungsbetrag, restwert, position
FROM asset_depreciation_schedule
WHERE asset_id = ?
ORDER BY position ASC',
[$assetId]
);
}
public static function calculate(array $asset): array
{
$anschaffungskosten = (float)($asset['anschaffungskosten'] ?? 0);
$zugaenge = (float)($asset['zugaenge'] ?? 0);
$abgaenge = (float)($asset['abgaenge'] ?? 0);
$nutzungsdauer = (int)($asset['nutzungsdauer'] ?? 0);
$centImErstenJahr = (bool)($asset['cent_im_ersten_jahr'] ?? false);
$startjahr = !empty($asset['anschaffungsdatum'])
? (int)date('Y', strtotime($asset['anschaffungsdatum']))
: (int)date('Y');
$basis = $anschaffungskosten + $zugaenge - $abgaenge;
$rows = [];
if ($basis <= 0) {
return $rows;
}
// Sofortabschreibung
if ($nutzungsdauer === 0) {
$rows[] = [
'jahr' => $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);
}
}

View file

@ -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);

View file

@ -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,

View file

@ -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);
}
}

View file

@ -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(

View file

@ -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,

View file

@ -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'] ?? '';
}

View file

@ -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'])

View file

@ -1,6 +1,17 @@
<?php use Steuerkram\View; $h = fn($v) => View::h($v); ?>
<?php
use Steuerkram\View;
$h = fn($v) => View::h($v);
<div class="card" style="max-width:600px">
$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;
?>
<div class="card" style="max-width:620px">
<div class="card-body">
<form method="post" action="<?= $h($action) ?>">
@ -65,18 +76,55 @@
</div>
</div>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:1rem">
<div class="form-group">
<label for="abschreibung_jahr">Abschreibung lfd. Jahr (€)</label>
<input type="number" id="abschreibung_jahr" name="abschreibung_jahr"
value="<?= $h($asset['abschreibung_jahr'] ?? '0') ?>"
min="0" step="0.01">
</div>
<div class="form-group">
<label for="nutzungsdauer">Nutzungsdauer (Jahre)</label>
<input type="number" id="nutzungsdauer" name="nutzungsdauer"
value="<?= $h($asset['nutzungsdauer'] ?? '') ?>"
min="1" step="1" placeholder="optional">
<!-- Nutzungsdauer + Abschreibungstabelle -->
<div style="display:grid;grid-template-columns:1fr auto;gap:.75rem;align-items:flex-end">
<div class="form-group" style="margin-bottom:0">
<label for="nutzungsdauer_select">Nutzungsdauer (AfA)</label>
<div style="display:flex;gap:.5rem">
<select id="nutzungsdauer_select" style="flex:1">
<option value="" <?= $nutzung === null ? 'selected' : '' ?>>– keine –</option>
<option value="0" <?= $nutzung === 0 ? 'selected' : '' ?>>sofort</option>
<?php foreach ([1,2,3,4,5,6,7,8,9,10,12,13,15,20,25,50] as $j): ?>
<option value="<?= $j ?>" <?= $nutzung === $j ? 'selected' : '' ?>>
<?= $j ?> <?= $j === 1 ? 'Jahr' : 'Jahre' ?>
</option>
<?php endforeach; ?>
<option value="custom" <?= $isCustom ? 'selected' : '' ?>>Individuell …</option>
</select>
<input type="number" id="nutzungsdauer_custom" name="nutzungsdauer"
value="<?= $h($nutzung !== null ? $nutzung : '') ?>"
min="1" step="1" placeholder="Jahre"
style="width:90px;<?= !$isCustom ? 'display:none' : '' ?>">
<input type="hidden" id="nutzungsdauer_hidden" name="nutzungsdauer"
value="<?= $h($nutzung !== null ? $nutzung : '') ?>"
<?= $isCustom ? 'disabled' : '' ?>>
</div>
</div>
<div>
<?php if ($isEdit): ?>
<button type="button" class="btn btn-secondary" data-modal-open="modal-afa"
style="white-space:nowrap" id="btn-show-afa">
AfA-Tabelle
</button>
<?php endif; ?>
</div>
</div>
<div class="form-group" style="margin-top:.75rem">
<label style="display:flex;align-items:center;gap:.5rem;cursor:pointer;font-weight:normal">
<input type="checkbox" name="cent_im_ersten_jahr" id="cent_im_ersten_jahr" value="1"
<?= $centFlag ? 'checked' : '' ?>>
Centbeträge im ersten Jahr abschreiben
</label>
<div class="text-muted text-sm" style="margin-top:.25rem;margin-left:1.5rem">
Wenn aktiv: Restwert nach Jahr&nbsp;1 auf volle&nbsp;Euro gerundet; Folgejahre gleichmäßig ohne Centabweichungen.
</div>
</div>
@ -85,6 +133,10 @@
<textarea id="notizen" name="notizen" rows="3"><?= $h($asset['notizen'] ?? '') ?></textarea>
</div>
<!-- Automatisch-Flag (versteckt, wird per JS aktualisiert) -->
<input type="hidden" name="automatisch_berechnet" id="automatisch_berechnet_input"
value="<?= $autoFlag ? '1' : '0' ?>">
<div style="display:flex;gap:.75rem;margin-top:1rem">
<button type="submit" class="btn btn-primary">Speichern</button>
<a href="/assets" class="btn btn-secondary">Abbrechen</a>
@ -92,3 +144,286 @@
</form>
</div>
</div>
<?php if ($isEdit): ?>
<!-- ── AfA-Tabellen-Modal ─────────────────────────────────── -->
<div class="modal-overlay" id="modal-afa">
<div class="modal" style="width:min(680px,95vw);max-height:85vh;overflow-y:auto">
<div class="modal-title">Abschreibungstabelle</div>
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:.75rem;flex-wrap:wrap;gap:.5rem">
<div style="display:flex;align-items:center;gap:.75rem">
<span class="text-sm text-muted" id="afa-mode-label">Modus:</span>
<label style="display:flex;align-items:center;gap:.4rem;cursor:pointer;font-size:.875rem;font-weight:normal">
<input type="checkbox" id="afa-manual-toggle">
Manuell bearbeiten
</label>
</div>
<button type="button" class="btn btn-secondary btn-sm" id="afa-recalc-btn"
title="Tabelle neu berechnen (überschreibt manuelle Änderungen)">
Neu berechnen
</button>
</div>
<div id="afa-warning" class="flash flash-warning text-sm" style="display:none;margin-bottom:.75rem">
Achtung: Die Abschreibungstabelle wurde manuell bearbeitet. Neu berechnen überschreibt alle manuellen Änderungen.
</div>
<div id="afa-table-wrap">
<table style="width:100%;border-collapse:collapse">
<thead>
<tr>
<th style="text-align:left;padding:.5rem .75rem;border-bottom:2px solid var(--color-border)">Jahr</th>
<th style="text-align:right;padding:.5rem .75rem;border-bottom:2px solid var(--color-border)">Abschreibung (€)</th>
<th style="text-align:right;padding:.5rem .75rem;border-bottom:2px solid var(--color-border)">Restwert (€)</th>
</tr>
</thead>
<tbody id="afa-table-body">
<?php if (empty($scheduleRows)): ?>
<tr id="afa-empty-row">
<td colspan="3" class="text-center text-muted" style="padding:1.5rem">
Noch keine Abschreibungstabelle vorhanden. Klicke „Neu berechnen".
</td>
</tr>
<?php else: ?>
<?php foreach ($scheduleRows as $row): ?>
<tr>
<td style="padding:.4rem .75rem;border-bottom:1px solid var(--color-border)">
<?= $h($row['jahr']) ?>
</td>
<td class="td-num afa-betrag" style="padding:.4rem .75rem;border-bottom:1px solid var(--color-border)">
<?= number_format((float)$row['abschreibungsbetrag'], 2, ',', '.') ?>
</td>
<td class="td-num afa-restwert" style="padding:.4rem .75rem;border-bottom:1px solid var(--color-border)">
<?= number_format((float)$row['restwert'], 2, ',', '.') ?>
</td>
</tr>
<?php endforeach; ?>
<?php endif; ?>
</tbody>
</table>
</div>
<div class="modal-actions">
<button type="button" class="btn btn-secondary" data-modal-close>Schließen</button>
<button type="button" class="btn btn-primary" id="afa-save-btn">Speichern</button>
</div>
</div>
</div>
<?php endif; ?>
<script>
(function() {
// ── Nutzungsdauer Dropdown ──────────────────────────────
const sel = document.getElementById('nutzungsdauer_select');
const custom = document.getElementById('nutzungsdauer_custom');
const hidden = document.getElementById('nutzungsdauer_hidden');
if (sel) {
sel.addEventListener('change', () => {
const val = sel.value;
if (val === 'custom') {
custom.style.display = '';
custom.removeAttribute('disabled');
hidden.setAttribute('disabled', '');
custom.focus();
} else {
custom.style.display = 'none';
custom.setAttribute('disabled', '');
hidden.removeAttribute('disabled');
hidden.value = val;
}
// Automatisch-Flag zurücksetzen bei Parameteränderung
scheduleMarkAuto();
});
custom.addEventListener('input', () => {
scheduleMarkAuto();
});
}
function getNutzungsdauer() {
if (!sel) return null;
const val = sel.value;
if (val === 'custom') return parseInt(custom.value, 10) || null;
if (val === '') return null;
return parseInt(val, 10);
}
function scheduleMarkAuto() {
// Wenn Tabelle war manuell und Parameter ändert sich: Rückfrage
const autoInput = document.getElementById('automatisch_berechnet_input');
if (autoInput && autoInput.value === '0') {
const confirm = window.confirm(
'Manuell geänderte Werte vorhanden. Sollen diese überschrieben werden?\n\n' +
'OK = Neu berechnen, Abbrechen = Manuelle Werte behalten'
);
if (confirm) {
autoInput.value = '1';
updateAfaWarning(true);
}
}
}
// ── AfA-Tabellen-Modal ──────────────────────────────────
const manualToggle = document.getElementById('afa-manual-toggle');
const recalcBtn = document.getElementById('afa-recalc-btn');
const saveBtn = document.getElementById('afa-save-btn');
const tbody = document.getElementById('afa-table-body');
const autoInput = document.getElementById('automatisch_berechnet_input');
const assetId = <?= $assetId ?>;
// Spiegele aktuellen Auto-Status in Modal-Toggle
function syncToggle() {
if (!manualToggle || !autoInput) return;
manualToggle.checked = autoInput.value === '0';
updateAfaWarning(autoInput.value === '0');
}
function updateAfaWarning(isManual) {
const warn = document.getElementById('afa-warning');
if (warn) warn.style.display = isManual ? '' : 'none';
if (recalcBtn) recalcBtn.style.display = isManual ? '' : 'none';
}
// Modal öffnen → Toggle syncen
document.addEventListener('click', (e) => {
if (e.target.closest('[data-modal-open="modal-afa"]')) {
syncToggle();
setTableEditable(manualToggle?.checked ?? false);
}
});
if (manualToggle) {
manualToggle.addEventListener('change', () => {
const isManual = manualToggle.checked;
if (autoInput) autoInput.value = isManual ? '0' : '1';
updateAfaWarning(isManual);
setTableEditable(isManual);
});
}
function setTableEditable(editable) {
if (!tbody) return;
tbody.querySelectorAll('tr').forEach(tr => {
tr.querySelectorAll('.afa-betrag, .afa-restwert').forEach(cell => {
cell.contentEditable = editable ? 'true' : 'false';
cell.style.background = editable ? 'var(--color-input-bg, #fff)' : '';
cell.style.outline = editable ? '1px solid var(--color-border)' : '';
cell.style.borderRadius = editable ? '3px' : '';
});
});
}
function fmtNum(v) {
return parseFloat(String(v).replace(',', '.')).toLocaleString('de-DE', {
minimumFractionDigits: 2, maximumFractionDigits: 2
});
}
function parseNum(s) {
return parseFloat(String(s).replace(/\./g, '').replace(',', '.')) || 0;
}
// Neu berechnen
if (recalcBtn) {
recalcBtn.addEventListener('click', async () => {
if (!window.confirm('Abschreibungstabelle neu berechnen? Manuelle Änderungen gehen verloren.')) return;
await loadPreview();
if (autoInput) autoInput.value = '1';
if (manualToggle) manualToggle.checked = false;
updateAfaWarning(false);
setTableEditable(false);
});
}
async function loadPreview() {
const aks = document.getElementById('anschaffungskosten')?.value ?? '0';
const zug = document.getElementById('zugaenge')?.value ?? '0';
const abg = document.getElementById('abgaenge')?.value ?? '0';
const dat = document.getElementById('anschaffungsdatum')?.value ?? '';
const nd = getNutzungsdauer();
const cent = document.getElementById('cent_im_ersten_jahr')?.checked ? '1' : '0';
if (nd === null) {
if (tbody) tbody.innerHTML = '<tr><td colspan="3" class="text-center text-muted" style="padding:1.5rem">Keine Nutzungsdauer angegeben.</td></tr>';
return;
}
const qs = new URLSearchParams({
anschaffungskosten: aks, zugaenge: zug, abgaenge: abg,
nutzungsdauer: nd, cent_im_ersten_jahr: cent,
anschaffungsdatum: dat
});
try {
const res = await fetch('/assets/depreciation-preview?' + qs);
const data = await res.json();
renderTable(data.rows ?? []);
} catch (_) {
if (tbody) tbody.innerHTML = '<tr><td colspan="3" class="text-center text-muted" style="padding:1rem">Fehler beim Laden.</td></tr>';
}
}
function renderTable(rows) {
if (!tbody) return;
if (!rows.length) {
tbody.innerHTML = '<tr><td colspan="3" class="text-center text-muted" style="padding:1.5rem">Keine Daten.</td></tr>';
return;
}
const isManual = manualToggle?.checked ?? false;
tbody.innerHTML = rows.map(r => `
<tr>
<td style="padding:.4rem .75rem;border-bottom:1px solid var(--color-border)">${r.jahr}</td>
<td class="td-num afa-betrag" contenteditable="${isManual}"
style="padding:.4rem .75rem;border-bottom:1px solid var(--color-border)${isManual ? ';background:var(--color-input-bg,#fff);outline:1px solid var(--color-border);border-radius:3px' : ''}">
${fmtNum(r.abschreibungsbetrag)}
</td>
<td class="td-num afa-restwert" contenteditable="${isManual}"
style="padding:.4rem .75rem;border-bottom:1px solid var(--color-border)${isManual ? ';background:var(--color-input-bg,#fff);outline:1px solid var(--color-border);border-radius:3px' : ''}">
${fmtNum(r.restwert)}
</td>
</tr>`).join('');
}
// Speichern
if (saveBtn) {
saveBtn.addEventListener('click', async () => {
const rows = [];
if (tbody) {
tbody.querySelectorAll('tr').forEach((tr, i) => {
const cells = tr.querySelectorAll('td');
if (cells.length < 3) return;
rows.push({
jahr: parseInt(cells[0].textContent.trim(), 10),
abschreibungsbetrag: cells[1].textContent.trim(),
restwert: cells[2].textContent.trim(),
});
});
}
const automatisch = !(manualToggle?.checked ?? false) ? 1 : 0;
try {
const res = await fetch(`/assets/${assetId}/depreciation`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ rows, automatisch }),
});
const data = await res.json();
if (data.ok) {
if (autoInput) autoInput.value = automatisch ? '1' : '0';
document.getElementById('modal-afa')?.classList.remove('open');
} else {
alert('Fehler beim Speichern: ' + (data.error ?? 'Unbekannt'));
}
} catch (_) {
alert('Netzwerkfehler beim Speichern.');
}
});
}
// Init: Neu-Berechnen-Button nur bei manueller Tabelle zeigen
syncToggle();
})();
</script>

View file

@ -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();

View file

@ -92,7 +92,7 @@
<div class="toolbar">
<div class="toolbar-left">
<form method="get" style="display:flex;gap:.5rem;flex-wrap:wrap;align-items:center">
<input type="search" name="search" placeholder="Belegnr., Dateiname, Rechnungsnr.…"
<input type="search" name="search" placeholder="Belegnr., Lieferant, Datum, Rechnungsnr., Betrag…"
value="<?= $h($filter['search'] ?? '') ?>" style="width:240px">
<select name="year" style="width:80px">
<option value="">Alle Jahre</option>

View file

@ -37,21 +37,16 @@ $preCustomer = (int)($_GET['customer_id'] ?? 0);
</div>
</div>
<div class="form-grid form-grid-4 mb-2">
<div class="form-grid form-grid-3 mb-2">
<div class="form-group">
<label for="rechnungs_datum">Rechnungsdatum *</label>
<input type="date" id="rechnungs_datum" name="rechnungs_datum"
value="<?= $h($invoice['rechnungs_datum'] ?? date('Y-m-d')) ?>" required>
</div>
<div class="form-group">
<label for="leistungszeitraum_von">Leistungszeitraum von</label>
<input type="date" id="leistungszeitraum_von" name="leistungszeitraum_von"
value="<?= $h($invoice['leistungszeitraum_von'] ?? '') ?>">
</div>
<div class="form-group">
<label for="leistungszeitraum_bis">Leistungszeitraum bis</label>
<input type="date" id="leistungszeitraum_bis" name="leistungszeitraum_bis"
value="<?= $h($invoice['leistungszeitraum_bis'] ?? '') ?>">
<label for="leistungszeitraum">Leistungszeitraum</label>
<input type="text" id="leistungszeitraum" name="leistungszeitraum"
value="<?= $h($invoice['leistungszeitraum'] ?? '') ?>">
</div>
<div class="form-group">
<label for="payment_days">Zahlungsziel (Tage)</label>
@ -149,4 +144,10 @@ function updatePaymentDays(sel) {
const days = opt.dataset.payment;
if (days) document.getElementById('payment_days').value = days;
}
<?php if (!$isEdit): ?>
document.addEventListener('DOMContentLoaded', function () {
const sel = document.getElementById('customer_id');
if (sel && sel.value) updatePaymentDays(sel);
});
<?php endif; ?>
</script>

View file

@ -64,6 +64,12 @@
<?php if ($inv['status'] !== 'Entwurf'): ?>
<a href="/invoices/<?= $h($inv['id']) ?>/pdf" class="btn btn-secondary btn-sm">PDF</a>
<?php endif; ?>
<?php if (!empty($settings['test_mode'])): ?>
<form method="post" action="/invoices/<?= $h($inv['id']) ?>/delete" style="display:inline"
onsubmit="return confirm('Rechnung wirklich löschen?')">
<button type="submit" class="btn btn-danger btn-sm">Löschen</button>
</form>
<?php endif; ?>
</div>
</td>
</tr>

View file

@ -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 <br> 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 .= '<br>' . $h(View::formatDate($invoice['leistungszeitraum_bis']));
}
}
$lzHtml = $h($invoice['leistungszeitraum'] ?? '');
$badgeClass = match($invoice['status']) {
'Bezahlt' => 'badge-green',

View file

@ -142,12 +142,20 @@
<?php
$llm = new \Steuerkram\Services\LlmService();
$providerLabel = $llm->getProviderLabel();
$reachable = $providerLabel !== null ? $llm->isReachable() : false;
$ollamaUrlEnv = getenv('OLLAMA_URL') ?: ($_ENV['OLLAMA_URL'] ?? '');
?>
<p class="text-sm" style="margin:0 0 0 1.75rem;color:<?= $providerLabel ? 'var(--color-success,#16a34a)' : 'var(--color-muted,#6b7280)' ?>">
<?php if ($providerLabel): ?>
✓ Provider erkannt: <?= $h($providerLabel) ?>
<p class="text-sm" style="margin:0 0 0 1.75rem;color:<?= $reachable ? 'var(--color-success,#16a34a)' : '#dc2626' ?>">
<?php if ($reachable): ?>
✓ Provider erreichbar: <?= $h($providerLabel) ?>
<?php elseif ($providerLabel): ?>
✗ Provider konfiguriert (<?= $h($providerLabel) ?>), aber Server nicht erreichbar.
Prüfe ob Ollama auf dem Host läuft und auf allen Interfaces hört (<code>OLLAMA_HOST=0.0.0.0:11434</code>).
Details im PHP-Fehlerlog des Webservers (<code>docker compose logs web</code>).
<?php elseif ($ollamaUrlEnv !== ''): ?>
✗ OLLAMA_URL gesetzt (<?= $h(rtrim($ollamaUrlEnv, '/')) ?>), aber kein Provider erkannt.
<?php else: ?>
— Kein LLM-Provider konfiguriert oder erreichbar (Auto-Discovery localhost:11434 fehlgeschlagen)
— Kein LLM-Provider konfiguriert (Auto-Discovery localhost:11434 fehlgeschlagen)
<?php endif; ?>
</p>
</div>
@ -236,6 +244,10 @@
<label>Anlagen-Präfix</label>
<input type="text" name="anlage_nr_prefix" value="<?= $h($settings['anlage_nr_prefix'] ?? 'ANL') ?>" maxlength="10">
</div>
<div class="form-group">
<label>Kunden-Präfix</label>
<input type="text" name="customer_nr_prefix" value="<?= $h($settings['customer_nr_prefix'] ?? 'K-') ?>" maxlength="10">
</div>
</div>
<div class="form-group">
<label>Standard-Zahlungsziel (Tage)</label>

View file

@ -1 +1 @@
1-260425
1-260426

View file

@ -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 ───────────────
// ── 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 = null;
$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').

View file

@ -0,0 +1,110 @@
<?php
declare(strict_types=1);
/**
* Test 21: Belegsuche – erweiterte Felder (Lieferant, Datum, Bewirtungsgrund, Teilnehmer, Brutto).
*/
class BelegSucheErweitertTest extends TestCase
{
protected string $testName = 'Test 21: Belegsuche – erweiterte Felder';
public function run(): void
{
$bankId = $this->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;
}
}

View file

@ -0,0 +1,63 @@
<?php
declare(strict_types=1);
use Steuerkram\Models\Customer;
use Steuerkram\Models\Setting;
class RechnungZahlungszielTest extends TestCase
{
protected string $testName = 'Test 22: Rechnung – Zahlungsziel aus Kundendatensatz';
public function run(): void
{
$customerId = Customer::create([
'nachname' => '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');
}
}

View file

@ -0,0 +1,44 @@
<?php
declare(strict_types=1);
use Steuerkram\Models\Customer;
use Steuerkram\Models\Setting;
use Steuerkram\Database;
class KundenNummernkreisTest extends TestCase
{
protected string $testName = 'Test 23: Kunden-Nummernkreis konfigurierbar';
public function run(): void
{
$settings = Setting::get();
$this->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]));
}
}

View file

@ -0,0 +1,56 @@
<?php
declare(strict_types=1);
use Steuerkram\Models\Customer;
use Steuerkram\Models\Invoice;
use Steuerkram\Models\Setting;
class RechnungLoeschenTestmodusTest extends TestCase
{
protected string $testName = 'Test 24: Rechnung löschen (nur im Testmodus)';
public function run(): void
{
$customerId = Customer::create([
'nachname' => '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');
}
}

View file

@ -0,0 +1,82 @@
<?php
declare(strict_types=1);
use Steuerkram\Models\Asset;
use Steuerkram\Models\Beleg;
use Steuerkram\Models\Booking;
class AnlageAutoRedirectTest extends TestCase
{
protected string $testName = 'Test 25: Neues Anlagegut aus Buchung → asset_id gesetzt';
public function run(): void
{
// Anlagevermögens-Konto suchen
$anlageKonto = \Steuerkram\Database::fetchOne(
"SELECT id FROM accounts WHERE kontenart = 'Anlagevermögen' AND aktiv = 1 LIMIT 1"
);
$this->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');
}
}

View file

@ -0,0 +1,121 @@
<?php
declare(strict_types=1);
use Steuerkram\Models\AssetDepreciation;
class AbschreibungstabelleTest extends TestCase
{
protected string $testName = 'Test 26: Abschreibungstabelle (lineare AfA)';
public function run(): void
{
// ── Sofortabschreibung ───────────────────────────────────
$rows = AssetDepreciation::calculate([
'anschaffungskosten' => 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');
}
}