Feature: Anlagevermögen – Navigation, Tabelle assets, CRUD und Test

This commit is contained in:
fds2610-bot 2026-04-12 10:40:09 +02:00
parent 36ee8e4f83
commit 921ba57419
9 changed files with 524 additions and 0 deletions

View file

@ -2,6 +2,19 @@
--- ---
## 2026-04-12 – Anlagevermögen (Wirtschaftsgüter)
### Neu
- Navigation: neuer Abschnitt „Betriebsvermögen" mit Menüpunkt „Anlagevermögen"
- `migrations/028_assets_anlagevermoegen.sql`: neue Tabelle `assets` für Wirtschaftsgüter
- `src/Models/Asset.php`: CRUD-Model mit automatischer laufender Nummer und Geschäftsjahrzuordnung
- `src/Controllers/AssetController.php`: Liste, Anlegen, Bearbeiten, Löschen
- `src/Views/assets/list.php`: Anlagenspiegel-Tabelle mit Bleistift- und Papierkorb-Aktionen
- `src/Views/assets/form.php`: Formular für Wirtschaftsgut (Bezeichnung, Anschaffungsdatum, Kosten, Zugänge, Abgänge, AfA, Nutzungsdauer, Notizen)
- `tests/20_AnlagevermoegensTest.php`: 14 Tests für CRUD-Operationen
---
## 2026-04-12 – Backup: smarte Rotation und Lock-Schutz ## 2026-04-12 – Backup: smarte Rotation und Lock-Schutz
### Geändert ### Geändert

View file

@ -0,0 +1,20 @@
-- Migration 028: Tabelle für Anlagevermögen (Wirtschaftsgüter)
CREATE TABLE IF NOT EXISTS assets (
id INT UNSIGNED NOT NULL AUTO_INCREMENT,
lfd_nr INT UNSIGNED NOT NULL COMMENT 'Laufende Nummer im Anlagenspiegel',
bezeichnung VARCHAR(255) NOT NULL COMMENT 'Bezeichnung des Wirtschaftsguts',
anschaffungsdatum DATE NOT NULL,
anschaffungskosten DECIMAL(12,2) NOT NULL DEFAULT 0.00,
zugaenge DECIMAL(12,2) NOT NULL DEFAULT 0.00 COMMENT 'Zugänge im laufenden Wirtschaftsjahr',
abgaenge DECIMAL(12,2) NOT NULL DEFAULT 0.00 COMMENT 'Abgänge im laufenden Wirtschaftsjahr',
abschreibung_jahr DECIMAL(12,2) NOT NULL DEFAULT 0.00 COMMENT 'Abschreibung des laufenden Wirtschaftsjahres',
nutzungsdauer SMALLINT UNSIGNED NULL COMMENT 'Nutzungsdauer in Jahren (für AfA-Berechnung)',
notizen TEXT NULL,
fiscal_year_id INT UNSIGNED NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (id),
KEY idx_assets_fiscal_year (fiscal_year_id),
KEY idx_assets_lfd_nr (lfd_nr)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
COMMENT='Anlagevermögen / Wirtschaftsgüter (Anlagenspiegel)';

View file

@ -19,6 +19,7 @@ use Steuerkram\Controllers\UstvaController;
use Steuerkram\Controllers\EuerController; use Steuerkram\Controllers\EuerController;
use Steuerkram\Controllers\SettingsController; use Steuerkram\Controllers\SettingsController;
use Steuerkram\Controllers\LoginController; use Steuerkram\Controllers\LoginController;
use Steuerkram\Controllers\AssetController;
use Steuerkram\Controllers\UserAdminController; use Steuerkram\Controllers\UserAdminController;
Config::load(); Config::load();
@ -97,6 +98,14 @@ $router->post('/invoices/{id}/cancel', fn($p) => (new InvoiceController())->canc
$router->post('/invoices/{id}/lines', fn($p) => (new InvoiceController())->addLine($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)); $router->post('/invoices/{id}/lines/{line_id}/delete', fn($p) => (new InvoiceController())->deleteLine($p));
// ── Anlagevermögen ───────────────────────────────────────────
$router->get('/assets', fn($p) => (new AssetController())->index($p));
$router->get('/assets/new', fn($p) => (new AssetController())->create($p));
$router->post('/assets', fn($p) => (new AssetController())->store($p));
$router->get('/assets/{id}/edit', fn($p) => (new AssetController())->edit($p));
$router->post('/assets/{id}', fn($p) => (new AssetController())->update($p));
$router->post('/assets/{id}/delete', fn($p) => (new AssetController())->destroy($p));
// ── UStVA ──────────────────────────────────────────────────── // ── UStVA ────────────────────────────────────────────────────
$router->get('/tax/ustva', fn($p) => (new UstvaController())->index($p)); $router->get('/tax/ustva', fn($p) => (new UstvaController())->index($p));
$router->get('/tax/ustva/{year}/{month}', fn($p) => (new UstvaController())->show($p)); $router->get('/tax/ustva/{year}/{month}', fn($p) => (new UstvaController())->show($p));

View file

@ -0,0 +1,154 @@
<?php
declare(strict_types=1);
namespace Steuerkram\Controllers;
use Steuerkram\Models\Asset;
use Steuerkram\View;
class AssetController
{
public function index(array $params): void
{
$assets = Asset::all();
$view = new View();
$view->render('layout/base.php', [
'pageTitle' => 'Anlagevermögen',
'content' => $this->renderView('assets/list', [
'assets' => $assets,
]),
'topbarActions' => '<a href="/assets/new" class="btn btn-primary">+ Wirtschaftsgut anlegen</a>',
]);
}
public function create(array $params): void
{
$view = new View();
$view->render('layout/base.php', [
'pageTitle' => 'Wirtschaftsgut anlegen',
'content' => $this->renderView('assets/form', [
'asset' => [],
'errors' => [],
'action' => '/assets',
]),
]);
}
public function store(array $params): void
{
$data = $this->extractPost();
$errors = $this->validate($data);
if ($errors) {
$view = new View();
$view->render('layout/base.php', [
'pageTitle' => 'Wirtschaftsgut anlegen',
'content' => $this->renderView('assets/form', [
'asset' => $data,
'errors' => $errors,
'action' => '/assets',
]),
]);
return;
}
Asset::create($data);
View::redirect('/assets', 'Wirtschaftsgut erfolgreich angelegt.');
}
public function edit(array $params): void
{
$asset = Asset::find((int)$params['id']);
if (!$asset) { http_response_code(404); return; }
$view = new View();
$view->render('layout/base.php', [
'pageTitle' => 'Wirtschaftsgut bearbeiten',
'content' => $this->renderView('assets/form', [
'asset' => $asset,
'errors' => [],
'action' => '/assets/' . $asset['id'],
]),
]);
}
public function update(array $params): void
{
$id = (int)$params['id'];
$asset = Asset::find($id);
if (!$asset) { http_response_code(404); return; }
$data = $this->extractPost();
$errors = $this->validate($data);
if ($errors) {
$view = new View();
$view->render('layout/base.php', [
'pageTitle' => 'Wirtschaftsgut bearbeiten',
'content' => $this->renderView('assets/form', [
'asset' => array_merge($asset, $data),
'errors' => $errors,
'action' => '/assets/' . $id,
]),
]);
return;
}
Asset::update($id, $data);
View::redirect('/assets', 'Wirtschaftsgut aktualisiert.');
}
public function destroy(array $params): void
{
if (!Asset::delete((int)$params['id'])) {
View::redirect('/assets', 'Wirtschaftsgut konnte nicht gelöscht werden.', 'error');
return;
}
View::redirect('/assets', 'Wirtschaftsgut gelöscht.');
}
// ── Private helpers ──────────────────────────────────────────
private function extractPost(): array
{
return [
'bezeichnung' => trim($_POST['bezeichnung'] ?? ''),
'anschaffungsdatum' => trim($_POST['anschaffungsdatum'] ?? ''),
'anschaffungskosten' => $_POST['anschaffungskosten'] ?? '0',
'zugaenge' => $_POST['zugaenge'] ?? '0',
'abgaenge' => $_POST['abgaenge'] ?? '0',
'abschreibung_jahr' => $_POST['abschreibung_jahr'] ?? '0',
'nutzungsdauer' => trim($_POST['nutzungsdauer'] ?? ''),
'notizen' => trim($_POST['notizen'] ?? ''),
];
}
private function validate(array $data): array
{
$errors = [];
if ($data['bezeichnung'] === '') {
$errors['bezeichnung'] = 'Pflichtfeld';
}
if ($data['anschaffungsdatum'] === '') {
$errors['anschaffungsdatum'] = 'Pflichtfeld';
} elseif (!strtotime($data['anschaffungsdatum'])) {
$errors['anschaffungsdatum'] = 'Ungültiges Datum';
}
if (!is_numeric($data['anschaffungskosten']) || (float)$data['anschaffungskosten'] < 0) {
$errors['anschaffungskosten'] = 'Muss eine positive Zahl sein';
}
return $errors;
}
private function renderView(string $template, array $data): string
{
extract($data, EXTR_SKIP);
ob_start();
include dirname(__DIR__) . '/Views/' . $template . '.php';
return ob_get_clean();
}
}

113
src/Models/Asset.php Normal file
View file

@ -0,0 +1,113 @@
<?php
declare(strict_types=1);
namespace Steuerkram\Models;
use Steuerkram\Database;
class Asset
{
public static function all(?int $fiscalYearId = null): array
{
$where = '1=1';
$params = [];
if ($fiscalYearId !== null) {
$where = 'fiscal_year_id = ?';
$params[] = $fiscalYearId;
}
return Database::fetchAll(
"SELECT * FROM assets WHERE $where ORDER BY lfd_nr ASC, id ASC",
$params
);
}
public static function find(int $id): array|false
{
return Database::fetchOne('SELECT * FROM assets WHERE id = ?', [$id]);
}
public static function nextLfdNr(): int
{
$row = Database::fetchOne('SELECT COALESCE(MAX(lfd_nr), 0) + 1 AS next FROM assets');
return (int)($row['next'] ?? 1);
}
public static function create(array $data): int
{
$fyId = null;
if (!empty($data['anschaffungsdatum'])) {
$year = (int)date('Y', strtotime($data['anschaffungsdatum']));
$fy = FiscalYear::findOrCreateByYear($year);
$fyId = $fy['id'];
}
Database::execute(
'INSERT INTO assets
(lfd_nr, bezeichnung, anschaffungsdatum, anschaffungskosten,
zugaenge, abgaenge, abschreibung_jahr, nutzungsdauer, notizen, fiscal_year_id)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)',
[
$data['lfd_nr'] ?? self::nextLfdNr(),
$data['bezeichnung'],
$data['anschaffungsdatum'],
$data['anschaffungskosten'] ?? 0,
$data['zugaenge'] ?? 0,
$data['abgaenge'] ?? 0,
$data['abschreibung_jahr'] ?? 0,
isset($data['nutzungsdauer']) && $data['nutzungsdauer'] !== '' ? (int)$data['nutzungsdauer'] : null,
$data['notizen'] ?? null,
$fyId,
]
);
return (int)Database::lastInsertId();
}
public static function update(int $id, array $data): void
{
$fyId = null;
if (!empty($data['anschaffungsdatum'])) {
$year = (int)date('Y', strtotime($data['anschaffungsdatum']));
$fy = FiscalYear::findOrCreateByYear($year);
$fyId = $fy['id'];
}
Database::execute(
'UPDATE assets SET
bezeichnung = ?,
anschaffungsdatum = ?,
anschaffungskosten = ?,
zugaenge = ?,
abgaenge = ?,
abschreibung_jahr = ?,
nutzungsdauer = ?,
notizen = ?,
fiscal_year_id = COALESCE(?, fiscal_year_id),
updated_at = NOW()
WHERE id = ?',
[
$data['bezeichnung'],
$data['anschaffungsdatum'],
$data['anschaffungskosten'] ?? 0,
$data['zugaenge'] ?? 0,
$data['abgaenge'] ?? 0,
$data['abschreibung_jahr'] ?? 0,
isset($data['nutzungsdauer']) && $data['nutzungsdauer'] !== '' ? (int)$data['nutzungsdauer'] : null,
$data['notizen'] ?? null,
$fyId,
$id,
]
);
}
public static function delete(int $id): bool
{
$asset = self::find($id);
if (!$asset) return false;
Database::execute('DELETE FROM assets WHERE id = ?', [$id]);
return true;
}
}

81
src/Views/assets/form.php Normal file
View file

@ -0,0 +1,81 @@
<?php use Steuerkram\View; $h = fn($v) => View::h($v); ?>
<div class="card" style="max-width:600px">
<div class="card-body">
<form method="post" action="<?= $h($action) ?>">
<div class="form-group">
<label for="bezeichnung">Bezeichnung des Wirtschaftsguts *</label>
<input type="text" id="bezeichnung" name="bezeichnung"
value="<?= $h($asset['bezeichnung'] ?? '') ?>"
class="<?= isset($errors['bezeichnung']) ? 'input-error' : '' ?>"
required autofocus>
<?php if (isset($errors['bezeichnung'])): ?>
<div class="error-text"><?= $h($errors['bezeichnung']) ?></div>
<?php endif; ?>
</div>
<div class="form-group">
<label for="anschaffungsdatum">Anschaffungsdatum *</label>
<input type="date" id="anschaffungsdatum" name="anschaffungsdatum"
value="<?= $h($asset['anschaffungsdatum'] ?? '') ?>"
class="<?= isset($errors['anschaffungsdatum']) ? 'input-error' : '' ?>"
required>
<?php if (isset($errors['anschaffungsdatum'])): ?>
<div class="error-text"><?= $h($errors['anschaffungsdatum']) ?></div>
<?php endif; ?>
</div>
<div class="form-group">
<label for="anschaffungskosten">Anschaffungskosten (€) *</label>
<input type="number" id="anschaffungskosten" name="anschaffungskosten"
value="<?= $h($asset['anschaffungskosten'] ?? '0') ?>"
class="<?= isset($errors['anschaffungskosten']) ? 'input-error' : '' ?>"
min="0" step="0.01" required>
<?php if (isset($errors['anschaffungskosten'])): ?>
<div class="error-text"><?= $h($errors['anschaffungskosten']) ?></div>
<?php endif; ?>
</div>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:1rem">
<div class="form-group">
<label for="zugaenge">Zugänge (€)</label>
<input type="number" id="zugaenge" name="zugaenge"
value="<?= $h($asset['zugaenge'] ?? '0') ?>"
min="0" step="0.01">
</div>
<div class="form-group">
<label for="abgaenge">Abgänge (€)</label>
<input type="number" id="abgaenge" name="abgaenge"
value="<?= $h($asset['abgaenge'] ?? '0') ?>"
min="0" step="0.01">
</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">
</div>
</div>
<div class="form-group">
<label for="notizen">Notizen</label>
<textarea id="notizen" name="notizen" rows="3"><?= $h($asset['notizen'] ?? '') ?></textarea>
</div>
<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>
</div>
</form>
</div>
</div>

57
src/Views/assets/list.php Normal file
View file

@ -0,0 +1,57 @@
<?php use Steuerkram\View; $h = fn($v) => View::h($v); ?>
<div class="card">
<div class="table-wrap">
<table>
<thead>
<tr>
<th style="width:4rem">Nr.</th>
<th>Anschaffungsdatum</th>
<th>Wirtschaftsgut</th>
<th class="td-num">Anschaffungskosten</th>
<th class="td-num">Zugänge</th>
<th class="td-num">Abgänge</th>
<th class="td-num">Abschreibungen</th>
<th style="width:6rem"></th>
</tr>
</thead>
<tbody>
<?php foreach ($assets as $a): ?>
<tr>
<td class="td-mono text-sm"><?= $h($a['lfd_nr']) ?></td>
<td class="td-muted text-sm"><?= View::formatDate($a['anschaffungsdatum']) ?></td>
<td><?= $h($a['bezeichnung']) ?></td>
<td class="td-num"><?= number_format((float)$a['anschaffungskosten'], 2, ',', '.') ?> €</td>
<td class="td-num"><?= number_format((float)$a['zugaenge'], 2, ',', '.') ?> €</td>
<td class="td-num"><?= number_format((float)$a['abgaenge'], 2, ',', '.') ?> €</td>
<td class="td-num"><?= number_format((float)$a['abschreibung_jahr'], 2, ',', '.') ?> €</td>
<td>
<div style="display:flex;gap:.4rem;justify-content:flex-end">
<a href="/assets/<?= $h($a['id']) ?>/edit"
class="btn btn-secondary btn-sm"
title="Bearbeiten">
<!-- Bleistift -->
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="14" height="14"><path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/><path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"/></svg>
</a>
<form method="post" action="/assets/<?= $h($a['id']) ?>/delete" style="margin:0"
data-confirm="Wirtschaftsgut &quot;<?= $h($a['bezeichnung']) ?>&quot; wirklich löschen?">
<button type="submit" class="btn btn-danger btn-sm" title="Löschen">
<!-- Papierkorb -->
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="14" height="14"><polyline points="3,6 5,6 21,6"/><path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6"/><path d="M10 11v6M14 11v6"/><path d="M9 6V4a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v2"/></svg>
</button>
</form>
</div>
</td>
</tr>
<?php endforeach; ?>
<?php if (empty($assets)): ?>
<tr>
<td colspan="8" class="text-center text-muted" style="padding:2rem">
Noch keine Wirtschaftsgüter angelegt.
</td>
</tr>
<?php endif; ?>
</tbody>
</table>
</div>
</div>

View file

@ -49,6 +49,16 @@ function navActive(string $prefix, string $current): string {
</nav> </nav>
</div> </div>
<div class="sidebar-section">
<div class="sidebar-label">Betriebsvermögen</div>
<nav>
<a href="/assets" class="<?= navActive('/assets', $path) ?>">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="2" y="7" width="20" height="14" rx="2"/><path d="M16 7V5a2 2 0 0 0-2-2h-4a2 2 0 0 0-2 2v2"/><line x1="12" y1="12" x2="12" y2="16"/><line x1="10" y1="14" x2="14" y2="14"/></svg>
Anlagevermögen
</a>
</nav>
</div>
<div class="sidebar-section"> <div class="sidebar-section">
<div class="sidebar-label">Steuerauswertungen</div> <div class="sidebar-label">Steuerauswertungen</div>
<nav> <nav>

View file

@ -0,0 +1,67 @@
<?php
declare(strict_types=1);
use Steuerkram\Models\Asset;
/**
* Test 20: Anlagevermögen – CRUD und Listenfunktion.
*/
class AnlagevermoegensTest extends TestCase
{
protected string $testName = 'Test 20: Anlagevermögen (Assets)';
public function run(): void
{
// ── Anlegen ───────────────────────────────────────────────────
$id = Asset::create([
'bezeichnung' => 'Test-Laptop',
'anschaffungsdatum' => '2026-01-15',
'anschaffungskosten' => 1500.00,
'zugaenge' => 0,
'abgaenge' => 0,
'abschreibung_jahr' => 300.00,
'nutzungsdauer' => 5,
'notizen' => 'Testnotiz',
]);
$this->assertGreaterThan(0, $id, 'Asset wurde angelegt (ID > 0)');
// ── find() ────────────────────────────────────────────────────
$asset = Asset::find($id);
$this->assertNotEmpty($asset, 'Asset::find() liefert einen Datensatz');
$this->assertEquals('Test-Laptop', $asset['bezeichnung'], 'Bezeichnung korrekt');
$this->assertEquals('2026-01-15', $asset['anschaffungsdatum'], 'Anschaffungsdatum korrekt');
$this->assertEquals(1500.00, (float)$asset['anschaffungskosten'], 'Anschaffungskosten korrekt');
$this->assertEquals(300.00, (float)$asset['abschreibung_jahr'], 'Abschreibung korrekt');
$this->assertEquals(5, (int)$asset['nutzungsdauer'], 'Nutzungsdauer korrekt');
// ── Lfd. Nr. wird automatisch vergeben ───────────────────────
$this->assertGreaterThan(0, (int)$asset['lfd_nr'], 'Lfd. Nr. > 0');
// ── all() enthält das neue Asset ─────────────────────────────
$all = Asset::all();
$found = array_filter($all, fn($a) => (int)$a['id'] === $id);
$this->assert(!empty($found), 'Asset erscheint in Asset::all()');
// ── update() ──────────────────────────────────────────────────
Asset::update($id, [
'bezeichnung' => 'Test-Laptop (aktualisiert)',
'anschaffungsdatum' => '2026-01-15',
'anschaffungskosten' => 1500.00,
'zugaenge' => 200.00,
'abgaenge' => 0,
'abschreibung_jahr' => 340.00,
'nutzungsdauer' => 5,
'notizen' => '',
]);
$updated = Asset::find($id);
$this->assertEquals('Test-Laptop (aktualisiert)', $updated['bezeichnung'], 'Bezeichnung nach Update korrekt');
$this->assertEquals(200.00, (float)$updated['zugaenge'], 'Zugänge nach Update korrekt');
$this->assertEquals(340.00, (float)$updated['abschreibung_jahr'], 'Abschreibung nach Update korrekt');
// ── delete() ──────────────────────────────────────────────────
$deleted = Asset::delete($id);
$this->assert($deleted, 'Asset::delete() gibt true zurück');
$this->assert(Asset::find($id) === false, 'Gelöschtes Asset nicht mehr auffindbar');
}
}