Steuerkram/src/Controllers/AssetController.php
2026-05-24 15:22:18 +02:00

533 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' => [],
]),
]);
}
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;
}
}