Steuerkram/src/Controllers/AssetController.php
fds2610-bot 7cea573ffc Fix #e2e: E2E-Tests ergänzt, /accounts/new und /assets/new lieferten HTTP 500
Steuerkram an den e2e-runner angebunden (tests/e2e/, 44 Playwright-Tests).
Der erste Smoke-Lauf fand zwei Seiten mit HTTP 500: AccountController::create
las $_GET['from'] ungeprüft, assets/form.php nutzte $testMode und
$defaultKontoId außerhalb des Bearbeiten-Zweigs. Beides war bis zur
Fehler-Härtung (#142) nur eine stille Warning.

Test 67 räumt sein Belegverzeichnis jetzt mit ab - es blieb als root-eigenes
Verzeichnis liegen und blockierte spätere echte Uploads mit HTTP 500.

Test 77 (neu) sichert die Löschsperre von Beleg::delete() ab.
2026-08-02 11:58:33 +02:00

534 lines
20 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<?php
declare(strict_types=1);
namespace Steuerkram\Controllers;
use Steuerkram\Constants;
use Steuerkram\Models\Account;
use Steuerkram\Models\Asset;
use Steuerkram\Models\AssetDepreciation;
use Steuerkram\Models\Booking;
use Steuerkram\Models\FiscalYear;
use Steuerkram\Models\Setting;
use Steuerkram\View;
class AssetController extends BaseController
{
public function index(array $params): void
{
$assets = Asset::all();
$nurUnzugeordnet = ($_GET['filter'] ?? 'unzugeordnet') !== 'alle';
$anlageBookings = Asset::anlagekontoBookings($nurUnzugeordnet);
$view = new View();
$view->render('layout/base.php', [
'pageTitle' => 'Anlagevermögen',
'content' => $this->renderView('assets/list', [
'assets' => $assets,
'anlageBookings' => $anlageBookings,
'nurUnzugeordnet' => $nurUnzugeordnet,
]),
'topbarActions' => '<a href="/assets/new" class="btn btn-primary" title="Wirtschaftsgut anlegen" style="display:inline-flex;align-items:center;justify-content:center;padding:.36rem;border-radius:50%;width:1.8rem;height:1.8rem">'
. '<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">'
. '<line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/>'
. '</svg>'
. '</a>',
]);
}
public function create(array $params): void
{
$prefill = [];
if (!empty($_GET['bk_booking_id'])) {
$prefill['bk_booking_id'] = (int)$_GET['bk_booking_id'];
}
if (!empty($_GET['bk_account_id'])) {
$prefill['account_id'] = (int)$_GET['bk_account_id'];
}
if (!empty($_GET['bk_datum'])) {
$prefill['anschaffungsdatum'] = $_GET['bk_datum'];
}
if (isset($_GET['bk_betrag']) && $_GET['bk_betrag'] !== '') {
$prefill['anschaffungskosten'] = number_format((float)$_GET['bk_betrag'], 2, '.', '');
}
$view = new View();
$view->render('layout/base.php', [
'pageTitle' => 'Wirtschaftsgut anlegen',
'content' => $this->renderView('assets/form', [
'asset' => $prefill,
'errors' => [],
'action' => '/assets',
'anlagearten' => Asset::anlagearten(),
'scheduleRows' => [],
'testMode' => Setting::isTestMode(),
]),
]);
}
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',
'anlagearten' => Asset::anlagearten(),
'scheduleRows' => [],
]),
]);
return;
}
$id = Asset::create($data);
$asset = Asset::find($id);
if ($asset && (int)($asset['automatisch_berechnet'] ?? 1) === 1) {
AssetDepreciation::generateAndSave($asset);
}
$bkBookingId = (int)($_POST['bk_booking_id'] ?? 0);
if ($bkBookingId > 0) {
\Steuerkram\Database::execute(
'UPDATE bookings SET asset_id = ? WHERE id = ? AND asset_id IS NULL',
[$id, $bkBookingId]
);
}
View::redirect('/assets/' . $id . '/edit', 'Wirtschaftsgut erfolgreich angelegt.');
}
public function edit(array $params): void
{
$asset = Asset::find((int)$params['id']);
if (!$asset) { http_response_code(404); return; }
$scheduleRows = AssetDepreciation::findByAsset((int)$asset['id']);
$abschreibungskonten = Account::abschreibungskonten();
$prevAfaKonto = \Steuerkram\Database::fetchOne(
'SELECT kategorie_konto_id FROM bookings
WHERE asset_id = ? AND buchungstyp = ? AND is_reversal = 0 AND reversed_by IS NULL
ORDER BY id DESC LIMIT 1',
[(int)$asset['id'], Constants::BUCHUNGSTYP_AFA]
);
$previousAfaKontoId = $prevAfaKonto ? (int)$prevAfaKonto['kategorie_konto_id'] : null;
$bookingHints = $this->buildBookingHints($asset);
$view = new View();
$view->render('layout/base.php', [
'pageTitle' => 'Wirtschaftsgut bearbeiten',
'content' => $this->renderView('assets/form', [
'asset' => $asset,
'errors' => [],
'action' => '/assets/' . $asset['id'],
'anlagearten' => Asset::anlagearten(),
'scheduleRows' => $scheduleRows,
'abschreibungskonten' => $abschreibungskonten,
'previousAfaKontoId' => $previousAfaKontoId,
'testMode' => Setting::isTestMode(),
'bookingHints' => $bookingHints,
]),
]);
}
private function buildBookingHints(array $asset): array
{
$hints = [];
$bkAccountId = isset($_GET['bk_account_id']) && $_GET['bk_account_id'] !== ''
? (int)$_GET['bk_account_id'] : null;
$bkDatum = $_GET['bk_datum'] ?? '';
if ($bkAccountId !== null && (int)($asset['account_id'] ?? 0) !== $bkAccountId) {
$bkAccount = Account::find($bkAccountId);
$hints['account_id'] = $bkAccount
? $bkAccount['kontonummer'] . ' – ' . $bkAccount['bezeichnung']
: 'Konto-ID ' . $bkAccountId;
}
if ($bkDatum !== '' && ($asset['anschaffungsdatum'] ?? '') !== $bkDatum) {
$hints['anschaffungsdatum'] = View::formatDate($bkDatum);
}
return $hints;
}
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) {
$scheduleRows = AssetDepreciation::findByAsset($id);
$view = new View();
$view->render('layout/base.php', [
'pageTitle' => 'Wirtschaftsgut bearbeiten',
'content' => $this->renderView('assets/form', [
'asset' => array_merge($asset, $data),
'errors' => $errors,
'action' => '/assets/' . $id,
'anlagearten' => Asset::anlagearten(),
'scheduleRows' => $scheduleRows,
]),
]);
return;
}
// Wenn Parameter geändert und Tabelle war manuell: Flag beibehalten
$wasAutomatisch = (bool)(int)($asset['automatisch_berechnet'] ?? 1);
Asset::update($id, $data);
$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
{
$id = (int)$params['id'];
$result = Asset::delete($id);
if ($result === 'hat_buchungen') {
View::redirect('/assets', 'Das Wirtschaftsgut kann nicht gelöscht werden, da noch Buchungen damit verknüpft sind.', 'error');
return;
}
if (!$result) {
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]);
}
/**
* AJAX: AFA-Buchung für eine Zeile der Abschreibungstabelle anlegen
* POST /assets/{id}/depreciation/{rowId}/book
*/
public function bookAfa(array $params): void
{
header('Content-Type: application/json; charset=utf-8');
$assetId = (int)$params['id'];
$rowId = (int)$params['rowId'];
$asset = Asset::find($assetId);
if (!$asset) {
http_response_code(404);
echo json_encode(['error' => 'Asset nicht gefunden']);
return;
}
$scheduleRow = AssetDepreciation::findRow($rowId);
if (!$scheduleRow || (int)$scheduleRow['asset_id'] !== $assetId) {
http_response_code(404);
echo json_encode(['error' => 'Abschreibungszeile nicht gefunden']);
return;
}
if (!empty($scheduleRow['afa_booking_id'])) {
http_response_code(409);
echo json_encode(['error' => 'Buchung bereits vorhanden']);
return;
}
$body = json_decode(file_get_contents('php://input'), true);
$ausgabekontoId = isset($body['ausgabe_konto_id']) ? (int)$body['ausgabe_konto_id'] : 0;
if ($ausgabekontoId <= 0) {
http_response_code(400);
echo json_encode(['error' => 'Ausgabenkonto fehlt']);
return;
}
if (!$asset['account_id']) {
http_response_code(400);
echo json_encode(['error' => 'Kein Anlagekonto am Wirtschaftsgut hinterlegt']);
return;
}
$jahr = (int)$scheduleRow['jahr'];
$betrag = round((float)$scheduleRow['abschreibungsbetrag'], 2);
$datum = $jahr . '-12-31';
$text = 'AfA ' . $asset['bezeichnung'] . ' ' . $jahr;
$fy = FiscalYear::findOrCreateByYear($jahr);
$buchungsNr = \Steuerkram\Database::fetchOne(
"SELECT CONCAT('AFA-', LPAD(COALESCE(MAX(CAST(SUBSTRING(buchungs_nr,5) AS UNSIGNED)),0)+1,5,'0')) AS nr
FROM bookings WHERE buchungs_nr LIKE 'AFA-%'"
)['nr'] ?? ('AFA-' . str_pad('1', 5, '0', STR_PAD_LEFT));
\Steuerkram\Database::execute(
'INSERT INTO bookings
(buchungs_nr, beleg_id, asset_id, afa_jahr, buchungs_datum, buchungstyp,
kategorie_konto_id, zahlungskonto_id,
betrag_netto, ust_satz, steuerart, betrag_ust, betrag_brutto,
buchungstext, fiscal_year_id)
VALUES (?, NULL, ?, ?, ?, ?, ?, ?, ?, 0, ?, 0, ?, ?, ?)',
[
$buchungsNr,
$assetId,
$jahr,
$datum,
Constants::BUCHUNGSTYP_AFA,
$ausgabekontoId,
(int)$asset['account_id'],
$betrag,
'normal',
$betrag,
$text,
(int)$fy['id'],
]
);
$bookingId = (int)\Steuerkram\Database::lastInsertId();
AssetDepreciation::setBookingId($rowId, $bookingId);
echo json_encode(['ok' => true, 'buchungs_nr' => $buchungsNr]);
}
/**
* AJAX: AFA-Buchung stornieren (alle Modi)
* POST /assets/{id}/depreciation/{rowId}/reverse
*/
public function reverseAfaBooking(array $params): void
{
header('Content-Type: application/json; charset=utf-8');
$rowId = (int)$params['rowId'];
$row = AssetDepreciation::findRow($rowId);
if (!$row || (int)$row['asset_id'] !== (int)$params['id']) {
http_response_code(404);
echo json_encode(['error' => 'Zeile nicht gefunden']);
return;
}
if (empty($row['afa_booking_id'])) {
http_response_code(409);
echo json_encode(['error' => 'Keine Buchung vorhanden']);
return;
}
$bookingId = (int)$row['afa_booking_id'];
$orig = Booking::find($bookingId);
if (!$orig) {
// Buchung bereits weg – Schedule-Zeile trotzdem bereinigen
AssetDepreciation::setBookingId($rowId, 0);
echo json_encode(['ok' => true]);
return;
}
if ($orig['is_reversal']) {
http_response_code(409);
echo json_encode(['error' => 'Stornobuchungen können nicht storniert werden']);
return;
}
if (!empty($orig['reversed_by'])) {
// Bereits storniert – nur Schedule bereinigen
AssetDepreciation::setBookingId($rowId, 0);
echo json_encode(['ok' => true]);
return;
}
$fy = FiscalYear::findOrCreateByYear((int)date('Y'));
$nr = \Steuerkram\Database::fetchOne(
"SELECT CONCAT('AFA-', LPAD(COALESCE(MAX(CAST(SUBSTRING(buchungs_nr,5) AS UNSIGNED)),0)+1,5,'0')) AS nr
FROM bookings WHERE buchungs_nr LIKE 'AFA-%'"
)['nr'] ?? 'AFA-00001';
\Steuerkram\Database::beginTransaction();
try {
\Steuerkram\Database::execute(
'INSERT INTO bookings
(buchungs_nr, beleg_id, asset_id, afa_jahr, buchungs_datum, buchungstyp,
kategorie_konto_id, zahlungskonto_id,
betrag_netto, ust_satz, steuerart, betrag_ust, betrag_brutto,
buchungstext, is_reversal, fiscal_year_id)
VALUES (?, NULL, ?, ?, ?, ?, ?, ?, ?, 0, ?, 0, ?, ?, 1, ?)',
[
$nr,
$orig['asset_id'],
$orig['afa_jahr'],
date('Y-m-d'),
Constants::BUCHUNGSTYP_AFA,
$orig['kategorie_konto_id'],
$orig['zahlungskonto_id'],
-1 * (float)$orig['betrag_netto'],
'normal',
-1 * (float)$orig['betrag_brutto'],
'STORNO zu ' . $orig['buchungs_nr'],
$fy['id'],
]
);
$reversalId = (int)\Steuerkram\Database::lastInsertId();
\Steuerkram\Database::execute(
'UPDATE bookings SET reversed_by = ? WHERE id = ?',
[$reversalId, $bookingId]
);
AssetDepreciation::setBookingId($rowId, 0);
\Steuerkram\Database::commit();
} catch (\Throwable $e) {
\Steuerkram\Database::rollBack();
http_response_code(500);
echo json_encode(['error' => $e->getMessage()]);
return;
}
echo json_encode(['ok' => true, 'storno_nr' => $nr]);
}
/**
* AJAX: AFA-Buchung löschen (nur Testmodus)
* POST /assets/{id}/depreciation/{rowId}/delete
*/
public function deleteAfaBooking(array $params): void
{
header('Content-Type: application/json; charset=utf-8');
if (!Setting::isTestMode()) {
http_response_code(403);
echo json_encode(['error' => 'Nur im Testmodus erlaubt']);
return;
}
$rowId = (int)$params['rowId'];
$row = AssetDepreciation::findRow($rowId);
if (!$row || (int)$row['asset_id'] !== (int)$params['id']) {
http_response_code(404);
echo json_encode(['error' => 'Zeile nicht gefunden']);
return;
}
if (empty($row['afa_booking_id'])) {
http_response_code(409);
echo json_encode(['error' => 'Keine Buchung vorhanden']);
return;
}
$bookingId = (int)$row['afa_booking_id'];
\Steuerkram\Database::execute('DELETE FROM bookings WHERE id = ?', [$bookingId]);
AssetDepreciation::setBookingId($rowId, 0);
echo json_encode(['ok' => true]);
}
// ── Private helpers ──────────────────────────────────────────
private function extractPost(): array
{
return [
'bezeichnung' => trim($_POST['bezeichnung'] ?? ''),
'account_id' => trim($_POST['account_id'] ?? ''),
'anschaffungsdatum' => trim($_POST['anschaffungsdatum'] ?? ''),
'anschaffungskosten' => $_POST['anschaffungskosten'] ?? '0',
'zugaenge' => $_POST['zugaenge'] ?? '0',
'abgaenge' => $_POST['abgaenge'] ?? '0',
'abschreibung_jahr' => $_POST['abschreibung_jahr'] ?? '0',
'nutzungsdauer' => trim($_POST['nutzungsdauer'] ?? ''),
'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'] ?? ''),
];
}
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;
}
}