533 lines
20 KiB
PHP
533 lines
20 KiB
PHP
<?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;
|
||
}
|
||
}
|
||
|