render('layout/base.php', [ 'pageTitle' => 'Anlagevermögen', 'content' => $this->renderView('assets/list', [ 'assets' => $assets, 'anlageBookings' => $anlageBookings, 'nurUnzugeordnet' => $nurUnzugeordnet, ]), 'topbarActions' => '' . '' . '' . '' . '', ]); } 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; } }