Fix #57: Lineare Abschreibungstabelle für Anlagegüter

This commit is contained in:
fds2610-bot 2026-04-26 18:07:41 +02:00
parent bbfb41232e
commit 41d64dc6f6
10 changed files with 804 additions and 62 deletions

View file

@ -2,6 +2,22 @@
--- ---
## 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 – Fix UStVA: Kennziffern auf offizielle ELSTER-Definitionen korrigiert ## 2026-04-26 – Fix UStVA: Kennziffern auf offizielle ELSTER-Definitionen korrigiert
### Geändert ### Geändert

View file

@ -65,5 +65,36 @@ Folgende Felder können nachträglich angepasst werden:
- Anschaffungsdatum und -kosten - Anschaffungsdatum und -kosten
- Zugänge und Abgänge - Zugänge und Abgänge
- Abschreibung laufendes Jahr - 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 - 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,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

@ -103,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', fn($p) => (new AssetController())->index($p));
$router->get('/assets/new', fn($p) => (new AssetController())->create($p)); $router->get('/assets/new', fn($p) => (new AssetController())->create($p));
$router->post('/assets', fn($p) => (new AssetController())->store($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->get('/assets/{id}/edit', fn($p) => (new AssetController())->edit($p));
$router->post('/assets/{id}', fn($p) => (new AssetController())->update($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}/delete', fn($p) => (new AssetController())->destroy($p));
$router->post('/assets/{id}/depreciation', fn($p) => (new AssetController())->saveDepreciation($p));
// ── UStVA ──────────────────────────────────────────────────── // ── UStVA ────────────────────────────────────────────────────
$router->get('/tax/ustva', fn($p) => (new UstvaController())->index($p)); $router->get('/tax/ustva', fn($p) => (new UstvaController())->index($p));

View file

@ -5,6 +5,7 @@ declare(strict_types=1);
namespace Steuerkram\Controllers; namespace Steuerkram\Controllers;
use Steuerkram\Models\Asset; use Steuerkram\Models\Asset;
use Steuerkram\Models\AssetDepreciation;
use Steuerkram\View; use Steuerkram\View;
class AssetController class AssetController
@ -33,6 +34,7 @@ class AssetController
'errors' => [], 'errors' => [],
'action' => '/assets', 'action' => '/assets',
'anlagearten' => Asset::anlagearten(), 'anlagearten' => Asset::anlagearten(),
'scheduleRows' => [],
]), ]),
]); ]);
} }
@ -51,13 +53,19 @@ class AssetController
'errors' => $errors, 'errors' => $errors,
'action' => '/assets', 'action' => '/assets',
'anlagearten' => Asset::anlagearten(), 'anlagearten' => Asset::anlagearten(),
'scheduleRows' => [],
]), ]),
]); ]);
return; return;
} }
Asset::create($data); $id = Asset::create($data);
View::redirect('/assets', 'Wirtschaftsgut erfolgreich angelegt.'); $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 public function edit(array $params): void
@ -65,6 +73,8 @@ class AssetController
$asset = Asset::find((int)$params['id']); $asset = Asset::find((int)$params['id']);
if (!$asset) { http_response_code(404); return; } if (!$asset) { http_response_code(404); return; }
$scheduleRows = AssetDepreciation::findByAsset((int)$asset['id']);
$view = new View(); $view = new View();
$view->render('layout/base.php', [ $view->render('layout/base.php', [
'pageTitle' => 'Wirtschaftsgut bearbeiten', 'pageTitle' => 'Wirtschaftsgut bearbeiten',
@ -73,6 +83,7 @@ class AssetController
'errors' => [], 'errors' => [],
'action' => '/assets/' . $asset['id'], 'action' => '/assets/' . $asset['id'],
'anlagearten' => Asset::anlagearten(), 'anlagearten' => Asset::anlagearten(),
'scheduleRows' => $scheduleRows,
]), ]),
]); ]);
} }
@ -87,6 +98,7 @@ class AssetController
$errors = $this->validate($data); $errors = $this->validate($data);
if ($errors) { if ($errors) {
$scheduleRows = AssetDepreciation::findByAsset($id);
$view = new View(); $view = new View();
$view->render('layout/base.php', [ $view->render('layout/base.php', [
'pageTitle' => 'Wirtschaftsgut bearbeiten', 'pageTitle' => 'Wirtschaftsgut bearbeiten',
@ -95,24 +107,103 @@ class AssetController
'errors' => $errors, 'errors' => $errors,
'action' => '/assets/' . $id, 'action' => '/assets/' . $id,
'anlagearten' => Asset::anlagearten(), 'anlagearten' => Asset::anlagearten(),
'scheduleRows' => $scheduleRows,
]), ]),
]); ]);
return; return;
} }
// Wenn Parameter geändert und Tabelle war manuell: Flag beibehalten
$wasAutomatisch = (bool)(int)($asset['automatisch_berechnet'] ?? 1);
Asset::update($id, $data); 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 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'); View::redirect('/assets', 'Wirtschaftsgut konnte nicht gelöscht werden.', 'error');
return; return;
} }
View::redirect('/assets', 'Wirtschaftsgut gelöscht.'); 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 helpers ──────────────────────────────────────────
private function extractPost(): array private function extractPost(): array
@ -126,6 +217,8 @@ class AssetController
'abgaenge' => $_POST['abgaenge'] ?? '0', 'abgaenge' => $_POST['abgaenge'] ?? '0',
'abschreibung_jahr' => $_POST['abschreibung_jahr'] ?? '0', 'abschreibung_jahr' => $_POST['abschreibung_jahr'] ?? '0',
'nutzungsdauer' => trim($_POST['nutzungsdauer'] ?? ''), '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'] ?? ''), 'notizen' => trim($_POST['notizen'] ?? ''),
]; ];
} }

View file

@ -85,8 +85,9 @@ class Asset
Database::execute( Database::execute(
'INSERT INTO assets 'INSERT INTO assets
(lfd_nr, anlage_nr, bezeichnung, account_id, anschaffungsdatum, anschaffungskosten, (lfd_nr, anlage_nr, bezeichnung, account_id, anschaffungsdatum, anschaffungskosten,
zugaenge, abgaenge, abschreibung_jahr, nutzungsdauer, notizen, fiscal_year_id) zugaenge, abgaenge, abschreibung_jahr, nutzungsdauer, automatisch_berechnet,
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)', cent_im_ersten_jahr, notizen, fiscal_year_id)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)',
[ [
$data['lfd_nr'] ?? self::nextLfdNr(), $data['lfd_nr'] ?? self::nextLfdNr(),
$anlageNr, $anlageNr,
@ -98,6 +99,8 @@ class Asset
$data['abgaenge'] ?? 0, $data['abgaenge'] ?? 0,
$data['abschreibung_jahr'] ?? 0, $data['abschreibung_jahr'] ?? 0,
isset($data['nutzungsdauer']) && $data['nutzungsdauer'] !== '' ? (int)$data['nutzungsdauer'] : null, 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, $data['notizen'] ?? null,
$fyId, $fyId,
] ]
@ -124,6 +127,8 @@ class Asset
abgaenge = ?, abgaenge = ?,
abschreibung_jahr = ?, abschreibung_jahr = ?,
nutzungsdauer = ?, nutzungsdauer = ?,
automatisch_berechnet = ?,
cent_im_ersten_jahr = ?,
notizen = ?, notizen = ?,
fiscal_year_id = COALESCE(?, fiscal_year_id), fiscal_year_id = COALESCE(?, fiscal_year_id),
updated_at = NOW() updated_at = NOW()
@ -137,6 +142,8 @@ class Asset
$data['abgaenge'] ?? 0, $data['abgaenge'] ?? 0,
$data['abschreibung_jahr'] ?? 0, $data['abschreibung_jahr'] ?? 0,
isset($data['nutzungsdauer']) && $data['nutzungsdauer'] !== '' ? (int)$data['nutzungsdauer'] : null, 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, $data['notizen'] ?? null,
$fyId, $fyId,
$id, $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

@ -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"> <div class="card-body">
<form method="post" action="<?= $h($action) ?>"> <form method="post" action="<?= $h($action) ?>">
@ -65,18 +76,55 @@
</div> </div>
</div> </div>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:1rem">
<div class="form-group"> <div class="form-group">
<label for="abschreibung_jahr">Abschreibung lfd. Jahr (€)</label> <label for="abschreibung_jahr">Abschreibung lfd. Jahr (€)</label>
<input type="number" id="abschreibung_jahr" name="abschreibung_jahr" <input type="number" id="abschreibung_jahr" name="abschreibung_jahr"
value="<?= $h($asset['abschreibung_jahr'] ?? '0') ?>" value="<?= $h($asset['abschreibung_jahr'] ?? '0') ?>"
min="0" step="0.01"> min="0" step="0.01">
</div> </div>
<div class="form-group">
<label for="nutzungsdauer">Nutzungsdauer (Jahre)</label> <!-- Nutzungsdauer + Abschreibungstabelle -->
<input type="number" id="nutzungsdauer" name="nutzungsdauer" <div style="display:grid;grid-template-columns:1fr auto;gap:.75rem;align-items:flex-end">
value="<?= $h($asset['nutzungsdauer'] ?? '') ?>" <div class="form-group" style="margin-bottom:0">
min="1" step="1" placeholder="optional"> <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>
</div> </div>
@ -85,6 +133,10 @@
<textarea id="notizen" name="notizen" rows="3"><?= $h($asset['notizen'] ?? '') ?></textarea> <textarea id="notizen" name="notizen" rows="3"><?= $h($asset['notizen'] ?? '') ?></textarea>
</div> </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"> <div style="display:flex;gap:.75rem;margin-top:1rem">
<button type="submit" class="btn btn-primary">Speichern</button> <button type="submit" class="btn btn-primary">Speichern</button>
<a href="/assets" class="btn btn-secondary">Abbrechen</a> <a href="/assets" class="btn btn-secondary">Abbrechen</a>
@ -92,3 +144,286 @@
</form> </form>
</div> </div>
</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

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