Anlagevermögen überarbeitet, AFA
This commit is contained in:
parent
1e2c8cf498
commit
b637b7b5ab
23 changed files with 1138 additions and 63 deletions
|
|
@ -10,6 +10,9 @@ DB_PASS=steuerkram
|
|||
# Erzeugen mit: php -r "echo bin2hex(random_bytes(32));"
|
||||
APP_KEY=
|
||||
|
||||
# Hintergrundfarbe der linken Sidebar (hex, optional)
|
||||
# SIDEBAR_BG_COLOR=#1e293b
|
||||
|
||||
# KI-Kontierungsvorschlag (optional)
|
||||
# Wenn nichts konfiguriert wird, sucht die App automatisch nach einem lokalen Ollama auf localhost:11434.
|
||||
#
|
||||
|
|
|
|||
39
Changes.md
39
Changes.md
|
|
@ -2,6 +2,45 @@
|
|||
|
||||
---
|
||||
|
||||
## 2026-05-24 – Feature: EÜR Anlageliste CSV/PDF-Export
|
||||
|
||||
- EÜR-Ansicht: Kopfzeile der Anlageliste-Karte enthält jetzt rechtsbündig „Herunterladen: [CSV] [PDF]"
|
||||
- CSV-Export: `GET /tax/euer/{year}/anlageliste/csv` – alle 9 Spalten mit UTF-8 BOM (Excel-kompatibel), Semikolon-getrennt, inkl. Summenzeile
|
||||
- PDF-Export: `GET /tax/euer/{year}/anlageliste/pdf` – A4 Querformat, enge Spaltenbreiten sodass alle 9 Spalten auf eine Seite passen; neuer `PdfAnlagelisteService`
|
||||
- EÜR-Karte: heller „PDF herunterladen"-Button entfernt; Topbar-Button umbenannt zu „PDF"
|
||||
|
||||
---
|
||||
|
||||
## 2026-05-24 – Feature: AFA-Buchung stornieren/löschen in AfA-Tabelle
|
||||
|
||||
- AfA-Tabelle im Asset-Edit-Modal: Gebuchte Zeilen zeigen neben dem grünen Haken einen Storno-Button (Undo-Pfeil, alle Modi) und im Testmodus zusätzlich einen Papierkorb-Button
|
||||
- Storno legt eine Gegenbuchung (`buchungstyp=AfA`, `is_reversal=1`, negativer Betrag) an und setzt `afa_booking_id` auf NULL → der Pfeil-aus-Box-Button erscheint wieder
|
||||
- Löschen (Testmodus) löscht die AFA-Buchung direkt und setzt `afa_booking_id` auf NULL
|
||||
- Neuer Controller: `POST /assets/{id}/depreciation/{rowId}/reverse` und `…/delete`
|
||||
- `AssetDepreciation::setBookingId()` akzeptiert jetzt `?int` (null erlaubt)
|
||||
|
||||
---
|
||||
|
||||
## 2026-05-24 – Feature: Buchungsjournal-Filter Buchungstyp + EÜR-Anlageliste
|
||||
|
||||
- Buchungsjournal: neues Filterfeld „Buchungstyp" (Einnahme / Ausgabe / AfA); vorbelegt mit „Alle Typen"
|
||||
- Buchungsjournal: AfA-Buchungen (ohne Beleg) werden jetzt korrekt angezeigt (`LEFT JOIN belege`); Beleg-Zelle zeigt „–" wenn kein Beleg
|
||||
- Buchungsjournal: Badge für Buchungstyp `AfA` in grau (badge-gray)
|
||||
- EÜR-Ansicht: neue Tabelle „Anlagegüter {Jahr}" unterhalb der EÜR mit Spalten Anlage-ID, Bezeichnung, Anschaffungsdatum, Anschaffungskosten, Nutzungsdauer, AfA Vorjahre (kumulierte gebuchte AfA-Buchungen), Restbuchwert Vorjahr, AfA {Jahr} (gebuchte AfA des Jahres), Restbuchwert 31.12.{Jahr}; Summenzeile am Ende
|
||||
- `Asset::anlageliste(int $year)` als neue Modell-Methode (single query mit Subqueries)
|
||||
|
||||
---
|
||||
|
||||
## 2026-05-24 – Feature: AfA-Tabelle anteilig nach Anschaffungsmonat
|
||||
|
||||
- Das erste Abschreibungsjahr wird anteilig nach Restmonaten berechnet: der Anschaffungsmonat zählt voll (Januar = 12 Monate, April = 9 Monate usw.)
|
||||
- Das letzte Jahr erhält automatisch die verbleibenden Monate (12 − Monate des ersten Jahres); bei Januar-Kauf entfällt das extra letzte Jahr
|
||||
- Die Cent-Option (`cent_im_ersten_jahr`) bleibt davon unberührt: der Cent-Rest geht weiterhin vollständig ins erste Jahr, ohne Aufteilung auf das letzte Jahr
|
||||
- Sofortabschreibung (`nutzungsdauer = 0`) ist unverändert
|
||||
- Neuer Test `41_AfaAnteiligTest` prüft April-, Oktober-, Juli-, Dezember- und Januar-Käufe sowie die Cent-Option mit anteiligem Jahr
|
||||
|
||||
---
|
||||
|
||||
## 2026-05-22 – Feature: „Abrufen ab"-Auswahl für Direktverbindungen
|
||||
|
||||
- Im Bearbeiten-Dialog jeder FinTS- bzw. API-Direktverbindung gibt es ein neues Dropdown **Abrufen ab** mit den Optionen *Ab letztem Abruf (00:00 Uhr)* (Default), *Letzte 7 Tage*, *Aktueller Monat (ab 01.)*, *Letzte 30 Tage*, *Ohne Datum (letzte 365 Tage)*
|
||||
|
|
|
|||
|
|
@ -19,6 +19,7 @@ services:
|
|||
DB_PASS: steuerkram
|
||||
APP_SECRET: change-this-in-production
|
||||
APP_KEY: ${APP_KEY:-}
|
||||
SIDEBAR_BG_COLOR: ${SIDEBAR_BG_COLOR:-#1e293b}
|
||||
OLLAMA_URL: ${OLLAMA_URL:-http://host-gateway:11434}
|
||||
OLLAMA_MODEL: ${OLLAMA_MODEL:-qwen3-vl}
|
||||
extra_hosts:
|
||||
|
|
|
|||
15
migrations/074_afa_booking_support.sql
Normal file
15
migrations/074_afa_booking_support.sql
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
-- Migration 074: AFA-Buchungen direkt aus dem Abschreibungsplan anlegen
|
||||
-- bookings.beleg_id wird nullable (keine Pflichtdokument für interne AFA-Buchungen)
|
||||
-- bookings.afa_jahr zur Jahres-Zuordnung
|
||||
-- buchungstyp um 'AfA' erweitert
|
||||
-- asset_depreciation_schedule bekommt afa_booking_id
|
||||
|
||||
ALTER TABLE bookings
|
||||
MODIFY COLUMN beleg_id INT UNSIGNED NULL,
|
||||
MODIFY COLUMN buchungstyp ENUM('Einnahme','Ausgabe','AfA') NOT NULL,
|
||||
ADD COLUMN afa_jahr SMALLINT UNSIGNED NULL AFTER asset_id;
|
||||
|
||||
ALTER TABLE asset_depreciation_schedule
|
||||
ADD COLUMN IF NOT EXISTS afa_booking_id INT UNSIGNED NULL AFTER position,
|
||||
ADD CONSTRAINT fk_afa_schedule_booking
|
||||
FOREIGN KEY IF NOT EXISTS (afa_booking_id) REFERENCES bookings(id) ON DELETE SET NULL;
|
||||
|
|
@ -138,6 +138,9 @@ $router->get('/assets/{id}/edit', fn($p) => (new AssetController())-
|
|||
$router->post('/assets/{id}', fn($p) => (new AssetController())->update($p));
|
||||
$router->post('/assets/{id}/delete', fn($p) => (new AssetController())->destroy($p));
|
||||
$router->post('/assets/{id}/depreciation', fn($p) => (new AssetController())->saveDepreciation($p));
|
||||
$router->post('/assets/{id}/depreciation/{rowId}/book', fn($p) => (new AssetController())->bookAfa($p));
|
||||
$router->post('/assets/{id}/depreciation/{rowId}/reverse', fn($p) => (new AssetController())->reverseAfaBooking($p));
|
||||
$router->post('/assets/{id}/depreciation/{rowId}/delete', fn($p) => (new AssetController())->deleteAfaBooking($p));
|
||||
|
||||
// ── UStVA ────────────────────────────────────────────────────
|
||||
$router->get('/tax/ustva', fn($p) => (new UstvaController())->index($p));
|
||||
|
|
@ -151,6 +154,8 @@ $router->get('/tax/euer', fn($p) => (new EuerController())->index($p));
|
|||
$router->get('/tax/euer/{year}', fn($p) => (new EuerController())->show($p));
|
||||
$router->get('/tax/euer/{year}/pdf', fn($p) => (new EuerController())->pdf($p));
|
||||
$router->get('/tax/euer/{year}/konto/{konto_id}', fn($p) => (new EuerController())->kontoBookings($p));
|
||||
$router->get('/tax/euer/{year}/anlageliste/csv', fn($p) => (new EuerController())->anlageCsv($p));
|
||||
$router->get('/tax/euer/{year}/anlageliste/pdf', fn($p) => (new EuerController())->anlagePdf($p));
|
||||
|
||||
// ── Einstellungen ────────────────────────────────────────────
|
||||
$router->get('/settings', fn($p) => (new SettingsController())->show($p));
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@ class Config
|
|||
'db_user' => $_ENV['DB_USER'] ?? getenv('DB_USER') ?: 'steuerkram',
|
||||
'db_pass' => $_ENV['DB_PASS'] ?? getenv('DB_PASS') ?: 'steuerkram',
|
||||
'app_key' => $_ENV['APP_KEY'] ?? getenv('APP_KEY') ?: '',
|
||||
'sidebar_bg_color' => $_ENV['SIDEBAR_BG_COLOR'] ?? getenv('SIDEBAR_BG_COLOR') ?: '#1e293b',
|
||||
'upload_dir' => dirname(__DIR__) . '/public/static/uploads',
|
||||
'base_url' => '',
|
||||
];
|
||||
|
|
|
|||
|
|
@ -9,6 +9,10 @@ class Constants
|
|||
// Buchungstypen
|
||||
const BUCHUNGSTYP_EINNAHME = 'Einnahme';
|
||||
const BUCHUNGSTYP_AUSGABE = 'Ausgabe';
|
||||
const BUCHUNGSTYP_AFA = 'AfA';
|
||||
|
||||
// AFA-Abschreibungskonten
|
||||
const KONTO_AFA_GWG = '4855'; // Sofortabschreibung GWG (vorgewählt für Konto 0480)
|
||||
|
||||
// Vorsteuer-Konten (SKR03)
|
||||
const KONTO_VST_19 = '1576';
|
||||
|
|
|
|||
|
|
@ -4,8 +4,13 @@ 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
|
||||
|
|
@ -78,6 +83,15 @@ class AssetController extends BaseController
|
|||
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;
|
||||
|
||||
$view = new View();
|
||||
$view->render('layout/base.php', [
|
||||
|
|
@ -88,6 +102,9 @@ class AssetController extends BaseController
|
|||
'action' => '/assets/' . $asset['id'],
|
||||
'anlagearten' => Asset::anlagearten(),
|
||||
'scheduleRows' => $scheduleRows,
|
||||
'abschreibungskonten' => $abschreibungskonten,
|
||||
'previousAfaKontoId' => $previousAfaKontoId,
|
||||
'testMode' => Setting::isTestMode(),
|
||||
]),
|
||||
]);
|
||||
}
|
||||
|
|
@ -208,6 +225,217 @@ class AssetController extends BaseController
|
|||
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
|
||||
|
|
|
|||
|
|
@ -37,6 +37,7 @@ class BookingController extends BaseController
|
|||
'filter' => $filter,
|
||||
'years' => $years,
|
||||
'accounts' => $accounts,
|
||||
'buchungstypen' => [\Steuerkram\Constants::BUCHUNGSTYP_EINNAHME, \Steuerkram\Constants::BUCHUNGSTYP_AUSGABE, \Steuerkram\Constants::BUCHUNGSTYP_AFA],
|
||||
'testMode' => Setting::isTestMode(),
|
||||
]),
|
||||
'topbarActions' => '<a href="/journal/export?'.http_build_query($filter).'" class="btn btn-secondary">CSV Export</a>',
|
||||
|
|
|
|||
|
|
@ -4,10 +4,12 @@ declare(strict_types=1);
|
|||
|
||||
namespace Steuerkram\Controllers;
|
||||
|
||||
use Steuerkram\Models\Asset;
|
||||
use Steuerkram\Models\Setting;
|
||||
use Steuerkram\Models\FiscalYear;
|
||||
use Steuerkram\Services\EuerCalculator;
|
||||
use Steuerkram\Services\PdfEuerService;
|
||||
use Steuerkram\Services\PdfAnlagelisteService;
|
||||
use Steuerkram\View;
|
||||
|
||||
class EuerController extends BaseController
|
||||
|
|
@ -30,14 +32,17 @@ class EuerController extends BaseController
|
|||
$calc = new EuerCalculator();
|
||||
$euer = $calc->calculate($year);
|
||||
|
||||
$anlageliste = Asset::anlageliste($year);
|
||||
|
||||
$view = new View();
|
||||
$view->render('layout/base.php', [
|
||||
'pageTitle' => "EÜR {$year}",
|
||||
'content' => $this->renderView('tax/euer_show', [
|
||||
'euer' => $euer,
|
||||
'year' => $year,
|
||||
'anlageliste' => $anlageliste,
|
||||
]),
|
||||
'topbarActions' => '<a href="/tax/euer/' . $year . '/pdf" class="btn btn-secondary">PDF herunterladen</a>',
|
||||
'topbarActions' => '',
|
||||
]);
|
||||
}
|
||||
|
||||
|
|
@ -70,5 +75,78 @@ class EuerController extends BaseController
|
|||
$svc = new PdfEuerService();
|
||||
$svc->render($euer, $settings);
|
||||
}
|
||||
|
||||
public function anlageCsv(array $params): void
|
||||
{
|
||||
$year = (int)$params['year'];
|
||||
$anlageliste = Asset::anlageliste($year);
|
||||
$settings = Setting::get();
|
||||
$firma = $settings['firma'] ?? '';
|
||||
|
||||
$fmtDate = function ($v) {
|
||||
if (empty($v)) return '';
|
||||
$ts = strtotime($v);
|
||||
return $ts ? date('d.m.Y', $ts) : $v;
|
||||
};
|
||||
$fmtNum = fn($v) => number_format((float)$v, 2, ',', '.');
|
||||
|
||||
header('Content-Type: text/csv; charset=UTF-8');
|
||||
header('Content-Disposition: attachment; filename="Anlageliste-' . $year . '.csv"');
|
||||
header('Cache-Control: no-cache');
|
||||
|
||||
$out = fopen('php://output', 'w');
|
||||
fprintf($out, chr(0xEF) . chr(0xBB) . chr(0xBF)); // UTF-8 BOM für Excel
|
||||
fputcsv($out, [
|
||||
'Anlage-ID', 'Bezeichnung', 'Anschaffungsdatum', 'Anschaffungskosten',
|
||||
'Nutzungsdauer', 'AfA Vorjahre', 'Restbuchwert Vorjahr',
|
||||
'AfA ' . $year, 'Restbuchwert 31.12.' . $year,
|
||||
], ';');
|
||||
|
||||
$sumBasis = $sumAfaVj = $sumRbwVj = $sumAfaJ = $sumRbw = 0.0;
|
||||
foreach ($anlageliste as $al) {
|
||||
$sumBasis += $al['basis'];
|
||||
$sumAfaVj += $al['afa_vorjahre'];
|
||||
$sumRbwVj += $al['restbuchwert_vorjahr'];
|
||||
$sumAfaJ += $al['afa_jahr'];
|
||||
$sumRbw += $al['restbuchwert'];
|
||||
|
||||
$nd = $al['nutzungsdauer'] !== null
|
||||
? ($al['nutzungsdauer'] == 0 ? 'sofort' : $al['nutzungsdauer'])
|
||||
: '';
|
||||
|
||||
fputcsv($out, [
|
||||
$al['anlage_nr'] ?: $al['lfd_nr'],
|
||||
$al['bezeichnung'],
|
||||
$fmtDate($al['anschaffungsdatum']),
|
||||
$fmtNum($al['basis']),
|
||||
$nd,
|
||||
$fmtNum($al['afa_vorjahre']),
|
||||
$fmtNum($al['restbuchwert_vorjahr']),
|
||||
$fmtNum($al['afa_jahr']),
|
||||
$fmtNum($al['restbuchwert']),
|
||||
], ';');
|
||||
}
|
||||
|
||||
fputcsv($out, [
|
||||
'Summe', '', '',
|
||||
$fmtNum($sumBasis), '',
|
||||
$fmtNum($sumAfaVj),
|
||||
$fmtNum($sumRbwVj),
|
||||
$fmtNum($sumAfaJ),
|
||||
$fmtNum($sumRbw),
|
||||
], ';');
|
||||
|
||||
fclose($out);
|
||||
}
|
||||
|
||||
public function anlagePdf(array $params): void
|
||||
{
|
||||
$year = (int)$params['year'];
|
||||
$anlageliste = Asset::anlageliste($year);
|
||||
$settings = Setting::get();
|
||||
|
||||
$svc = new PdfAnlagelisteService();
|
||||
$svc->render($year, $anlageliste, $settings);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -41,6 +41,15 @@ class Account
|
|||
return Database::fetchOne('SELECT * FROM accounts WHERE kontonummer = ?', [$nr]);
|
||||
}
|
||||
|
||||
public static function abschreibungskonten(): array
|
||||
{
|
||||
return Database::fetchAll(
|
||||
"SELECT id, kontonummer, bezeichnung FROM accounts
|
||||
WHERE kontonummer BETWEEN '4815' AND '4899' AND aktiv = 1
|
||||
ORDER BY kontonummer ASC"
|
||||
);
|
||||
}
|
||||
|
||||
public static function search(string $q, int $limit = 20): array
|
||||
{
|
||||
return Database::fetchAll(
|
||||
|
|
|
|||
|
|
@ -8,6 +8,58 @@ use Steuerkram\Database;
|
|||
|
||||
class Asset
|
||||
{
|
||||
/**
|
||||
* Anlageliste für die EÜR: alle Assets, die im gegebenen Jahr aktiv sind,
|
||||
* mit kumulierten AfA-Buchungen aus Vorjahren und dem laufenden Jahr.
|
||||
*/
|
||||
public static function anlageliste(int $year): array
|
||||
{
|
||||
$rows = Database::fetchAll(
|
||||
"SELECT
|
||||
a.*,
|
||||
ac.kontonummer AS anlageart_kontonummer,
|
||||
ac.bezeichnung AS anlageart_bezeichnung,
|
||||
COALESCE(bk_prev.afa_vorjahre, 0) AS afa_vorjahre,
|
||||
COALESCE(bk_curr.afa_jahr, 0) AS afa_jahr
|
||||
FROM assets a
|
||||
LEFT JOIN accounts ac ON a.account_id = ac.id
|
||||
LEFT JOIN (
|
||||
SELECT asset_id, SUM(betrag_netto) AS afa_vorjahre
|
||||
FROM bookings
|
||||
WHERE buchungstyp = 'AfA' AND afa_jahr < ?
|
||||
GROUP BY asset_id
|
||||
) bk_prev ON bk_prev.asset_id = a.id
|
||||
LEFT JOIN (
|
||||
SELECT asset_id, SUM(betrag_netto) AS afa_jahr
|
||||
FROM bookings
|
||||
WHERE buchungstyp = 'AfA' AND afa_jahr = ?
|
||||
GROUP BY asset_id
|
||||
) bk_curr ON bk_curr.asset_id = a.id
|
||||
WHERE YEAR(a.anschaffungsdatum) <= ?
|
||||
ORDER BY a.lfd_nr ASC, a.id ASC",
|
||||
[$year, $year, $year]
|
||||
);
|
||||
|
||||
$result = [];
|
||||
foreach ($rows as $row) {
|
||||
$basis = (float)$row['anschaffungskosten'] + (float)$row['zugaenge'] - (float)$row['abgaenge'];
|
||||
$afaVorjahre = (float)$row['afa_vorjahre'];
|
||||
$afaJahr = (float)$row['afa_jahr'];
|
||||
$restbuchwertVorjahr = round($basis - $afaVorjahre, 2);
|
||||
$restbuchwert = round($restbuchwertVorjahr - $afaJahr, 2);
|
||||
|
||||
$result[] = array_merge($row, [
|
||||
'basis' => $basis,
|
||||
'afa_vorjahre' => $afaVorjahre,
|
||||
'afa_jahr' => $afaJahr,
|
||||
'restbuchwert_vorjahr' => $restbuchwertVorjahr,
|
||||
'restbuchwert' => $restbuchwert,
|
||||
]);
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
public static function all(?int $fiscalYearId = null): array
|
||||
{
|
||||
$where = '1=1';
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@ class AssetDepreciation
|
|||
public static function findByAsset(int $assetId): array
|
||||
{
|
||||
return Database::fetchAll(
|
||||
'SELECT id, asset_id, jahr, abschreibungsbetrag, restwert, position
|
||||
'SELECT id, asset_id, jahr, abschreibungsbetrag, restwert, position, afa_booking_id
|
||||
FROM asset_depreciation_schedule
|
||||
WHERE asset_id = ?
|
||||
ORDER BY position ASC',
|
||||
|
|
@ -19,6 +19,23 @@ class AssetDepreciation
|
|||
);
|
||||
}
|
||||
|
||||
public static function findRow(int $id): array|false
|
||||
{
|
||||
return Database::fetchOne(
|
||||
'SELECT id, asset_id, jahr, abschreibungsbetrag, restwert, position, afa_booking_id
|
||||
FROM asset_depreciation_schedule WHERE id = ?',
|
||||
[$id]
|
||||
);
|
||||
}
|
||||
|
||||
public static function setBookingId(int $rowId, ?int $bookingId): void
|
||||
{
|
||||
Database::execute(
|
||||
'UPDATE asset_depreciation_schedule SET afa_booking_id = ? WHERE id = ?',
|
||||
[$bookingId ?: null, $rowId]
|
||||
);
|
||||
}
|
||||
|
||||
public static function calculate(array $asset): array
|
||||
{
|
||||
$anschaffungskosten = (float)($asset['anschaffungskosten'] ?? 0);
|
||||
|
|
@ -48,24 +65,43 @@ class AssetDepreciation
|
|||
return $rows;
|
||||
}
|
||||
|
||||
// Anteilige Monate: Anschaffungsmonat zählt voll
|
||||
$anschaffungsmonat = !empty($asset['anschaffungsdatum'])
|
||||
? (int)date('n', strtotime($asset['anschaffungsdatum']))
|
||||
: 1;
|
||||
$monateErstesJahr = 13 - $anschaffungsmonat; // Jan=12, Apr=9, Dez=1
|
||||
$monateLetztesJahr = 12 - $monateErstesJahr; // Jan=0, Apr=3, Dez=11
|
||||
$hatLetztesJahr = $monateLetztesJahr > 0;
|
||||
$totalJahre = $nutzungsdauer + ($hatLetztesJahr ? 1 : 0);
|
||||
|
||||
if ($centImErstenJahr) {
|
||||
// Folgejahre: auf volle Euro gerundet (abgerundet)
|
||||
// Mitteljahre: volle Euro (abgerundet); Cent-Rest geht komplett ins erste Jahr
|
||||
$regelJahr = floor($basis / $nutzungsdauer);
|
||||
$erstesJahr = round($basis - $regelJahr * ($nutzungsdauer - 1), 2);
|
||||
$centRest = round($basis - $regelJahr * $nutzungsdauer, 2);
|
||||
$erstesJahrAfa = round($regelJahr * $monateErstesJahr / 12, 2) + $centRest;
|
||||
} else {
|
||||
$regelJahr = round($basis / $nutzungsdauer, 2);
|
||||
$erstesJahr = round($basis - $regelJahr * ($nutzungsdauer - 1), 2);
|
||||
$erstesJahrAfa = round((float)$regelJahr * $monateErstesJahr / 12, 2);
|
||||
}
|
||||
|
||||
$restwert = $basis;
|
||||
for ($i = 0; $i < $nutzungsdauer; $i++) {
|
||||
$afa = ($i === 0) ? $erstesJahr : (float)$regelJahr;
|
||||
for ($i = 0; $i < $totalJahre; $i++) {
|
||||
$istLetztesJahr = ($i === $totalJahre - 1);
|
||||
|
||||
if ($i === 0) {
|
||||
$afa = $erstesJahrAfa;
|
||||
} elseif ($istLetztesJahr) {
|
||||
// Letztes Jahr absorbiert Rundungsdifferenz, Restwert wird exakt 0
|
||||
$afa = round($restwert, 2);
|
||||
} else {
|
||||
$afa = (float)$regelJahr;
|
||||
}
|
||||
|
||||
$restwert = round($restwert - $afa, 2);
|
||||
// Letztes Jahr: Restwert auf 0 normalisieren (Rundungsfehler)
|
||||
if ($i === $nutzungsdauer - 1) {
|
||||
$afa = round($afa + $restwert, 2);
|
||||
if ($istLetztesJahr) {
|
||||
$restwert = 0.00;
|
||||
}
|
||||
|
||||
$rows[] = [
|
||||
'jahr' => $startjahr + $i,
|
||||
'abschreibungsbetrag' => $afa,
|
||||
|
|
@ -79,18 +115,32 @@ class AssetDepreciation
|
|||
|
||||
public static function saveForAsset(int $assetId, array $rows): void
|
||||
{
|
||||
// Bestehende afa_booking_id-Zuordnungen per Jahr sichern
|
||||
$existing = Database::fetchAll(
|
||||
'SELECT jahr, afa_booking_id FROM asset_depreciation_schedule
|
||||
WHERE asset_id = ? AND afa_booking_id IS NOT NULL',
|
||||
[$assetId]
|
||||
);
|
||||
$bookingByJahr = [];
|
||||
foreach ($existing as $e) {
|
||||
$bookingByJahr[(int)$e['jahr']] = (int)$e['afa_booking_id'];
|
||||
}
|
||||
|
||||
self::deleteByAsset($assetId);
|
||||
foreach ($rows as $row) {
|
||||
$jahr = (int)$row['jahr'];
|
||||
$bookingId = $bookingByJahr[$jahr] ?? null;
|
||||
Database::execute(
|
||||
'INSERT INTO asset_depreciation_schedule
|
||||
(asset_id, jahr, abschreibungsbetrag, restwert, position)
|
||||
VALUES (?, ?, ?, ?, ?)',
|
||||
(asset_id, jahr, abschreibungsbetrag, restwert, position, afa_booking_id)
|
||||
VALUES (?, ?, ?, ?, ?, ?)',
|
||||
[
|
||||
$assetId,
|
||||
(int)$row['jahr'],
|
||||
$jahr,
|
||||
round((float)$row['abschreibungsbetrag'], 2),
|
||||
round((float)$row['restwert'], 2),
|
||||
(int)$row['position'],
|
||||
$bookingId,
|
||||
]
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -49,6 +49,10 @@ class Booking
|
|||
$params[] = '%' . $filter['search'] . '%';
|
||||
$params[] = '%' . $filter['search'] . '%';
|
||||
}
|
||||
if (!empty($filter['buchungstyp'])) {
|
||||
$where[] = 'bk.buchungstyp = ?';
|
||||
$params[] = $filter['buchungstyp'];
|
||||
}
|
||||
|
||||
$offset = ($page - 1) * $perPage;
|
||||
$sql = 'SELECT bk.*,
|
||||
|
|
@ -60,7 +64,7 @@ class Booking
|
|||
FROM bookings bk
|
||||
JOIN accounts k ON bk.kategorie_konto_id = k.id
|
||||
JOIN accounts z ON bk.zahlungskonto_id = z.id
|
||||
JOIN belege bl ON bk.beleg_id = bl.id
|
||||
LEFT JOIN belege bl ON bk.beleg_id = bl.id
|
||||
JOIN fiscal_years fy ON bk.fiscal_year_id = fy.id
|
||||
LEFT JOIN assets a ON bk.asset_id = a.id
|
||||
LEFT JOIN suppliers s ON bl.supplier_id = s.id
|
||||
|
|
@ -75,7 +79,7 @@ class Booking
|
|||
|
||||
$countSql = 'SELECT COUNT(*) as cnt FROM bookings bk
|
||||
JOIN fiscal_years fy ON bk.fiscal_year_id = fy.id
|
||||
JOIN belege bl ON bk.beleg_id = bl.id
|
||||
LEFT JOIN belege bl ON bk.beleg_id = bl.id
|
||||
LEFT JOIN suppliers s ON bl.supplier_id = s.id
|
||||
WHERE ' . implode(' AND ', $where);
|
||||
$countParams = array_slice($params, 0, -2);
|
||||
|
|
|
|||
135
src/Services/PdfAnlagelisteService.php
Normal file
135
src/Services/PdfAnlagelisteService.php
Normal file
|
|
@ -0,0 +1,135 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Steuerkram\Services;
|
||||
|
||||
use Mpdf\Mpdf;
|
||||
|
||||
class PdfAnlagelisteService
|
||||
{
|
||||
public function render(int $year, array $anlageliste, array $settings): void
|
||||
{
|
||||
$mpdf = new Mpdf([
|
||||
'mode' => 'utf-8',
|
||||
'format' => 'A4-L',
|
||||
'margin_top' => 15,
|
||||
'margin_bottom' => 15,
|
||||
'margin_left' => 10,
|
||||
'margin_right' => 10,
|
||||
]);
|
||||
|
||||
$mpdf->SetTitle('Anlageliste ' . $year);
|
||||
$mpdf->SetAuthor($settings['firma'] ?? '');
|
||||
$mpdf->WriteHTML($this->buildCss(), \Mpdf\HTMLParserMode::HEADER_CSS);
|
||||
$mpdf->WriteHTML($this->buildHtml($year, $anlageliste, $settings), \Mpdf\HTMLParserMode::HTML_BODY);
|
||||
$mpdf->Output('Anlageliste-' . $year . '.pdf', 'D');
|
||||
}
|
||||
|
||||
private function buildHtml(int $year, array $anlageliste, array $settings): string
|
||||
{
|
||||
$h = fn($v) => htmlspecialchars((string)$v, ENT_QUOTES, 'UTF-8');
|
||||
$fmt = fn($v) => number_format((float)$v, 2, ',', '.') . ' €';
|
||||
$fmtDate = function ($v) {
|
||||
if (empty($v)) return '–';
|
||||
$ts = strtotime($v);
|
||||
return $ts ? date('d.m.Y', $ts) : $v;
|
||||
};
|
||||
|
||||
$firma = $h($settings['firma'] ?? '');
|
||||
$rows = '';
|
||||
$sumBasis = $sumAfaVj = $sumRbwVj = $sumAfaJ = $sumRbw = 0.0;
|
||||
|
||||
foreach ($anlageliste as $al) {
|
||||
$sumBasis += $al['basis'];
|
||||
$sumAfaVj += $al['afa_vorjahre'];
|
||||
$sumRbwVj += $al['restbuchwert_vorjahr'];
|
||||
$sumAfaJ += $al['afa_jahr'];
|
||||
$sumRbw += $al['restbuchwert'];
|
||||
|
||||
$nd = $al['nutzungsdauer'] !== null
|
||||
? ($al['nutzungsdauer'] == 0 ? 'sofort' : $al['nutzungsdauer'] . ' J.')
|
||||
: '–';
|
||||
|
||||
$rows .= '<tr>'
|
||||
. '<td class="nr">' . $h($al['anlage_nr'] ?: $al['lfd_nr']) . '</td>'
|
||||
. '<td class="bez">' . $h($al['bezeichnung']) . '</td>'
|
||||
. '<td class="dt">' . $h($fmtDate($al['anschaffungsdatum'])) . '</td>'
|
||||
. '<td class="val">' . $fmt($al['basis']) . '</td>'
|
||||
. '<td class="nd">' . $h($nd) . '</td>'
|
||||
. '<td class="val">' . $fmt($al['afa_vorjahre']) . '</td>'
|
||||
. '<td class="val">' . $fmt($al['restbuchwert_vorjahr']) . '</td>'
|
||||
. '<td class="val">' . $fmt($al['afa_jahr']) . '</td>'
|
||||
. '<td class="val">' . $fmt($al['restbuchwert']) . '</td>'
|
||||
. '</tr>';
|
||||
}
|
||||
|
||||
$sumRow = '<tr class="total-row">'
|
||||
. '<td colspan="3"><strong>Summe</strong></td>'
|
||||
. '<td class="val"><strong>' . $fmt($sumBasis) . '</strong></td>'
|
||||
. '<td></td>'
|
||||
. '<td class="val"><strong>' . $fmt($sumAfaVj) . '</strong></td>'
|
||||
. '<td class="val"><strong>' . $fmt($sumRbwVj) . '</strong></td>'
|
||||
. '<td class="val"><strong>' . $fmt($sumAfaJ) . '</strong></td>'
|
||||
. '<td class="val"><strong>' . $fmt($sumRbw) . '</strong></td>'
|
||||
. '</tr>';
|
||||
|
||||
return <<<HTML
|
||||
<h1>Anlageliste {$year}</h1>
|
||||
<p class="subtitle">{$firma} · Geschäftsjahr {$year}</p>
|
||||
|
||||
<table class="anlage-table">
|
||||
<colgroup>
|
||||
<col style="width:18mm">
|
||||
<col style="width:60mm">
|
||||
<col style="width:22mm">
|
||||
<col style="width:30mm">
|
||||
<col style="width:16mm">
|
||||
<col style="width:30mm">
|
||||
<col style="width:33mm">
|
||||
<col style="width:26mm">
|
||||
<col style="width:33mm">
|
||||
</colgroup>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Anlage-ID</th>
|
||||
<th>Bezeichnung</th>
|
||||
<th>Anschaffungs-<br>datum</th>
|
||||
<th>Anschaffungs-<br>kosten</th>
|
||||
<th>ND</th>
|
||||
<th>AfA<br>Vorjahre</th>
|
||||
<th>Restbuchwert<br>Vorjahr</th>
|
||||
<th>AfA<br>{$year}</th>
|
||||
<th>Restbuchwert<br>31.12.{$year}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{$rows}
|
||||
</tbody>
|
||||
<tfoot>
|
||||
{$sumRow}
|
||||
</tfoot>
|
||||
</table>
|
||||
<p class="generated-note">Erstellt am {$h(date('d.m.Y H:i'))} · Steuerkram</p>
|
||||
HTML;
|
||||
}
|
||||
|
||||
private function buildCss(): string
|
||||
{
|
||||
return <<<CSS
|
||||
body { font-family: DejaVu Sans, sans-serif; font-size: 8pt; }
|
||||
h1 { font-size: 13pt; margin-bottom: 1mm; }
|
||||
.subtitle { font-size: 8pt; color: #555; margin-bottom: 5mm; }
|
||||
.anlage-table { width: 100%; border-collapse: collapse; font-size: 7.5pt; table-layout: fixed; }
|
||||
.anlage-table thead th { background: #1e293b; color: #fff; padding: 2mm; text-align: left; vertical-align: bottom; }
|
||||
.anlage-table tbody tr:nth-child(even) { background: #f8f8f8; }
|
||||
.anlage-table tbody td { padding: 1.2mm 2mm; border-bottom: 0.2pt solid #e0e0e0; vertical-align: middle; }
|
||||
.nr { color: #555; font-size: 7pt; }
|
||||
.dt { color: #555; font-size: 7pt; }
|
||||
.nd { text-align: right; color: #555; }
|
||||
.val { text-align: right; font-family: DejaVu Sans Mono, monospace; }
|
||||
.total-row td { padding: 2mm; background: #e8ecf0; border-top: 1pt solid #555; font-size: 8pt; }
|
||||
.generated-note { font-size: 6.5pt; color: #888; margin-top: 5mm; }
|
||||
CSS;
|
||||
}
|
||||
}
|
||||
|
|
@ -146,9 +146,23 @@ $autoFlag = isset($asset['automatisch_berechnet']) ? (bool)(int)$asset['automa
|
|||
</div>
|
||||
|
||||
<?php if ($isEdit): ?>
|
||||
<?php
|
||||
$gwgKontonummer = '0480';
|
||||
$isGwg = ($asset['anlageart_kontonummer'] ?? '') === $gwgKontonummer;
|
||||
$abschreibungskonten = $abschreibungskonten ?? [];
|
||||
$defaultKontoNr = \Steuerkram\Constants::KONTO_AFA_GWG;
|
||||
$defaultKontoId = 0;
|
||||
foreach ($abschreibungskonten as $k) {
|
||||
if ($k['kontonummer'] === $defaultKontoNr) { $defaultKontoId = $k['id']; break; }
|
||||
}
|
||||
if (!$isGwg) { $defaultKontoId = 0; } // nur bei GWG vorauswählen
|
||||
// Früher genutztes Konto hat Vorrang vor dem GWG-Standard
|
||||
if (!empty($previousAfaKontoId)) { $defaultKontoId = (int)$previousAfaKontoId; }
|
||||
$testMode = $testMode ?? false;
|
||||
?>
|
||||
<!-- ── AfA-Tabellen-Modal ─────────────────────────────────── -->
|
||||
<div class="modal-overlay" id="modal-afa">
|
||||
<div class="modal" style="width:min(680px,95vw);max-height:85vh;overflow-y:auto">
|
||||
<div class="modal" style="width:min(760px,95vw);max-height:85vh;overflow-y:auto">
|
||||
<div class="modal-title">Abschreibungstabelle</div>
|
||||
|
||||
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:.75rem;flex-wrap:wrap;gap:.5rem">
|
||||
|
|
@ -174,19 +188,21 @@ $autoFlag = isset($asset['automatisch_berechnet']) ? (bool)(int)$asset['automa
|
|||
<thead>
|
||||
<tr>
|
||||
<th style="text-align:left;padding:.5rem .75rem;border-bottom:2px solid var(--color-border)">Jahr</th>
|
||||
<th style="text-align:right;padding:.5rem .75rem;border-bottom:2px solid var(--color-border)">Abschreibung (€)</th>
|
||||
<th style="text-align:right;padding:.5rem .75rem;border-bottom:2px solid var(--color-border)">Restwert (€)</th>
|
||||
<th style="text-align:right;padding:.5rem .75rem;border-bottom:2px solid var(--color-border)">Abschreibung (€)</th>
|
||||
<th style="text-align:right;padding:.5rem .75rem;border-bottom:2px solid var(--color-border)">Restwert (€)</th>
|
||||
<th style="text-align:center;padding:.5rem .75rem;border-bottom:2px solid var(--color-border);width:2.5rem" title="Buchungsstatus">Buchung</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="afa-table-body">
|
||||
<?php if (empty($scheduleRows)): ?>
|
||||
<tr id="afa-empty-row">
|
||||
<td colspan="3" class="text-center text-muted" style="padding:1.5rem">
|
||||
<td colspan="4" class="text-center text-muted" style="padding:1.5rem">
|
||||
Noch keine Abschreibungstabelle vorhanden. Klicke „Neu berechnen".
|
||||
</td>
|
||||
</tr>
|
||||
<?php else: ?>
|
||||
<?php foreach ($scheduleRows as $row): ?>
|
||||
<?php $gebucht = !empty($row['afa_booking_id']); ?>
|
||||
<tr>
|
||||
<td style="padding:.4rem .75rem;border-bottom:1px solid var(--color-border)">
|
||||
<?= $h($row['jahr']) ?>
|
||||
|
|
@ -197,6 +213,60 @@ $autoFlag = isset($asset['automatisch_berechnet']) ? (bool)(int)$asset['automa
|
|||
<td class="td-num afa-restwert" style="padding:.4rem .75rem;border-bottom:1px solid var(--color-border)">
|
||||
<?= number_format((float)$row['restwert'], 2, ',', '.') ?>
|
||||
</td>
|
||||
<td style="padding:.4rem .75rem;border-bottom:1px solid var(--color-border);text-align:center">
|
||||
<?php if ($gebucht): ?>
|
||||
<div style="display:inline-flex;gap:.2rem;align-items:center;justify-content:center">
|
||||
<span title="Buchung vorhanden" style="color:#22c55e;display:inline-flex;align-items:center">
|
||||
<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">
|
||||
<polyline points="20 6 9 17 4 12"/>
|
||||
</svg>
|
||||
</span>
|
||||
<button type="button"
|
||||
class="afa-reverse-btn"
|
||||
data-row-id="<?= (int)$row['id'] ?>"
|
||||
title="AFA-Buchung stornieren"
|
||||
style="background:none;border:none;cursor:pointer;color:var(--color-muted);padding:.2rem;line-height:1;display:inline-flex;align-items:center">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24"
|
||||
fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M3 7v6h6"/>
|
||||
<path d="M21 17a9 9 0 0 0-9-9 9 9 0 0 0-6 2.3L3 13"/>
|
||||
</svg>
|
||||
</button>
|
||||
<?php if ($testMode): ?>
|
||||
<button type="button"
|
||||
class="afa-delete-btn"
|
||||
data-row-id="<?= (int)$row['id'] ?>"
|
||||
title="AFA-Buchung löschen (Testmodus)"
|
||||
style="background:none;border:none;cursor:pointer;color:var(--color-danger);padding:.2rem;line-height:1;display:inline-flex;align-items:center">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24"
|
||||
fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<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>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
<?php else: ?>
|
||||
<button type="button"
|
||||
class="afa-book-btn"
|
||||
data-row-id="<?= (int)$row['id'] ?>"
|
||||
data-jahr="<?= (int)$row['jahr'] ?>"
|
||||
data-betrag="<?= number_format((float)$row['abschreibungsbetrag'], 2, ',', '.') ?>"
|
||||
data-default-konto="<?= $defaultKontoId ?>"
|
||||
title="AFA-Buchung für <?= (int)$row['jahr'] ?> anlegen"
|
||||
style="background:none;border:none;cursor:pointer;color:var(--color-muted);padding:.2rem;line-height:1;display:inline-flex;align-items:center;justify-content:center">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24"
|
||||
fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<rect x="3" y="3" width="14" height="14" rx="2"/>
|
||||
<polyline points="17 8 21 12 17 16"/>
|
||||
<line x1="10" y1="12" x2="21" y2="12"/>
|
||||
</svg>
|
||||
</button>
|
||||
<?php endif; ?>
|
||||
</td>
|
||||
</tr>
|
||||
<?php endforeach; ?>
|
||||
<?php endif; ?>
|
||||
|
|
@ -210,6 +280,28 @@ $autoFlag = isset($asset['automatisch_berechnet']) ? (bool)(int)$asset['automa
|
|||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ── AFA-Buchungs-Dialog ──────────────────────────────────── -->
|
||||
<div class="modal-overlay" id="modal-afa-book">
|
||||
<div class="modal" style="width:min(460px,95vw)">
|
||||
<div class="modal-title">AFA-Buchung anlegen</div>
|
||||
<p class="text-sm text-muted" style="margin-bottom:1rem">
|
||||
Jahr <strong id="afabook-jahr"></strong> · Betrag <strong id="afabook-betrag"></strong> €
|
||||
</p>
|
||||
<div class="form-group">
|
||||
<label for="afabook-konto">Abschreibungskonto (Aufwand)</label>
|
||||
<select id="afabook-konto">
|
||||
<?php foreach ($abschreibungskonten as $k): ?>
|
||||
<option value="<?= (int)$k['id'] ?>"><?= $h($k['kontonummer']) ?> – <?= $h($k['bezeichnung']) ?></option>
|
||||
<?php endforeach; ?>
|
||||
</select>
|
||||
</div>
|
||||
<div class="modal-actions">
|
||||
<button type="button" class="btn btn-secondary" data-modal-close>Abbrechen</button>
|
||||
<button type="button" class="btn btn-primary" id="afabook-confirm-btn">Buchung anlegen</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
|
||||
<script>
|
||||
|
|
@ -368,7 +460,7 @@ $autoFlag = isset($asset['automatisch_berechnet']) ? (bool)(int)$asset['automa
|
|||
function renderTable(rows) {
|
||||
if (!tbody) return;
|
||||
if (!rows.length) {
|
||||
tbody.innerHTML = '<tr><td colspan="3" class="text-center text-muted" style="padding:1.5rem">Keine Daten.</td></tr>';
|
||||
tbody.innerHTML = '<tr><td colspan="4" class="text-center text-muted" style="padding:1.5rem">Keine Daten.</td></tr>';
|
||||
return;
|
||||
}
|
||||
const isManual = manualToggle?.checked ?? false;
|
||||
|
|
@ -383,6 +475,9 @@ $autoFlag = isset($asset['automatisch_berechnet']) ? (bool)(int)$asset['automa
|
|||
style="padding:.4rem .75rem;border-bottom:1px solid var(--color-border)${isManual ? ';background:var(--color-input-bg,#fff);outline:1px solid var(--color-border);border-radius:3px' : ''}">
|
||||
${fmtNum(r.restwert)}
|
||||
</td>
|
||||
<td style="padding:.4rem .75rem;border-bottom:1px solid var(--color-border);text-align:center;width:2.5rem">
|
||||
<span title="Tabelle neu speichern, um Buchungen anzulegen" style="color:var(--color-muted);font-size:.7rem">–</span>
|
||||
</td>
|
||||
</tr>`).join('');
|
||||
}
|
||||
|
||||
|
|
@ -425,5 +520,161 @@ $autoFlag = isset($asset['automatisch_berechnet']) ? (bool)(int)$asset['automa
|
|||
|
||||
// Init: Neu-Berechnen-Button nur bei manueller Tabelle zeigen
|
||||
syncToggle();
|
||||
|
||||
// ── AFA-Buchungs-Dialog ──────────────────────────────────────
|
||||
let afabookRowId = 0;
|
||||
const afabookJahr = document.getElementById('afabook-jahr');
|
||||
const afabookBetrag = document.getElementById('afabook-betrag');
|
||||
const afabookKonto = document.getElementById('afabook-konto');
|
||||
const afabookModal = document.getElementById('modal-afa-book');
|
||||
const afabookBtn = document.getElementById('afabook-confirm-btn');
|
||||
|
||||
document.addEventListener('click', (e) => {
|
||||
const btn = e.target.closest('.afa-book-btn');
|
||||
if (!btn) return;
|
||||
afabookRowId = parseInt(btn.dataset.rowId, 10);
|
||||
if (afabookJahr) afabookJahr.textContent = btn.dataset.jahr;
|
||||
if (afabookBetrag) afabookBetrag.textContent = btn.dataset.betrag;
|
||||
const defaultId = parseInt(btn.dataset.defaultKonto, 10);
|
||||
if (afabookKonto && defaultId) {
|
||||
afabookKonto.value = defaultId;
|
||||
}
|
||||
if (afabookModal) afabookModal.classList.add('open');
|
||||
});
|
||||
|
||||
const isTestMode = <?= $testMode ? 'true' : 'false' ?>;
|
||||
|
||||
function bookedCellHtml(rowId, buchungsNr) {
|
||||
const title = buchungsNr ? `Buchung vorhanden (${buchungsNr})` : 'Buchung vorhanden';
|
||||
const deleteBtn = isTestMode
|
||||
? `<button type="button" class="afa-delete-btn" data-row-id="${rowId}"
|
||||
title="AFA-Buchung löschen (Testmodus)"
|
||||
style="background:none;border:none;cursor:pointer;color:var(--color-danger);padding:.2rem;line-height:1;display:inline-flex;align-items:center">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24"
|
||||
fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<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>`
|
||||
: '';
|
||||
return `<div style="display:inline-flex;gap:.2rem;align-items:center;justify-content:center">
|
||||
<span title="${title}" style="color:#22c55e;display:inline-flex;align-items:center">
|
||||
<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">
|
||||
<polyline points="20 6 9 17 4 12"/>
|
||||
</svg>
|
||||
</span>
|
||||
<button type="button" class="afa-reverse-btn" data-row-id="${rowId}"
|
||||
title="AFA-Buchung stornieren"
|
||||
style="background:none;border:none;cursor:pointer;color:var(--color-muted);padding:.2rem;line-height:1;display:inline-flex;align-items:center">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24"
|
||||
fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M3 7v6h6"/>
|
||||
<path d="M21 17a9 9 0 0 0-9-9 9 9 0 0 0-6 2.3L3 13"/>
|
||||
</svg>
|
||||
</button>
|
||||
${deleteBtn}
|
||||
</div>`;
|
||||
}
|
||||
|
||||
// SVG für "noch nicht gebucht" (Pfeil aus Box)
|
||||
function unbookedCellHtml(rowId) {
|
||||
return `<button type="button"
|
||||
class="afa-book-btn"
|
||||
data-row-id="${rowId}"
|
||||
data-jahr=""
|
||||
data-betrag=""
|
||||
data-default-konto="${<?= $defaultKontoId ?>}"
|
||||
title="AFA-Buchung anlegen"
|
||||
style="background:none;border:none;cursor:pointer;color:var(--color-muted);padding:.2rem;line-height:1;display:inline-flex;align-items:center;justify-content:center">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24"
|
||||
fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<rect x="3" y="3" width="14" height="14" rx="2"/>
|
||||
<polyline points="17 8 21 12 17 16"/>
|
||||
<line x1="10" y1="12" x2="21" y2="12"/>
|
||||
</svg>
|
||||
</button>`;
|
||||
}
|
||||
|
||||
// Storno: Buchung stornieren, danach Haken → Pfeil-Button
|
||||
document.addEventListener('click', async (e) => {
|
||||
const btn = e.target.closest('.afa-reverse-btn');
|
||||
if (!btn) return;
|
||||
if (!confirm('AFA-Buchung wirklich stornieren? Es wird eine Gegenbuchung angelegt.')) return;
|
||||
const rowId = parseInt(btn.dataset.rowId, 10);
|
||||
try {
|
||||
const res = await fetch(`/assets/${assetId}/depreciation/${rowId}/reverse`, { method: 'POST' });
|
||||
const data = await res.json();
|
||||
if (data.ok) {
|
||||
const cell = btn.closest('td');
|
||||
cell.innerHTML = unbookedCellHtml(rowId);
|
||||
// Jahr/Betrag aus Tabellenzeile ermitteln und in data-* setzen
|
||||
const tr = cell.closest('tr');
|
||||
const tds = tr.querySelectorAll('td');
|
||||
const newBtn = cell.querySelector('.afa-book-btn');
|
||||
if (newBtn && tds.length >= 2) {
|
||||
newBtn.dataset.jahr = tds[0].textContent.trim();
|
||||
newBtn.dataset.betrag = tds[1].textContent.trim();
|
||||
}
|
||||
} else {
|
||||
alert('Fehler: ' + (data.error ?? 'Unbekannt'));
|
||||
}
|
||||
} catch (_) { alert('Netzwerkfehler beim Stornieren.'); }
|
||||
});
|
||||
|
||||
// Löschen (Testmodus): Buchung löschen, danach Haken → Pfeil-Button
|
||||
document.addEventListener('click', async (e) => {
|
||||
const btn = e.target.closest('.afa-delete-btn');
|
||||
if (!btn) return;
|
||||
if (!confirm('AFA-Buchung wirklich löschen? (Nur im Testmodus – nicht GoBD-konform!)')) return;
|
||||
const rowId = parseInt(btn.dataset.rowId, 10);
|
||||
try {
|
||||
const res = await fetch(`/assets/${assetId}/depreciation/${rowId}/delete`, { method: 'POST' });
|
||||
const data = await res.json();
|
||||
if (data.ok) {
|
||||
const cell = btn.closest('td');
|
||||
cell.innerHTML = unbookedCellHtml(rowId);
|
||||
const tr = cell.closest('tr');
|
||||
const tds = tr.querySelectorAll('td');
|
||||
const newBtn = cell.querySelector('.afa-book-btn');
|
||||
if (newBtn && tds.length >= 2) {
|
||||
newBtn.dataset.jahr = tds[0].textContent.trim();
|
||||
newBtn.dataset.betrag = tds[1].textContent.trim();
|
||||
}
|
||||
} else {
|
||||
alert('Fehler: ' + (data.error ?? 'Unbekannt'));
|
||||
}
|
||||
} catch (_) { alert('Netzwerkfehler beim Löschen.'); }
|
||||
});
|
||||
|
||||
if (afabookBtn) {
|
||||
afabookBtn.addEventListener('click', async () => {
|
||||
const kontoId = afabookKonto ? parseInt(afabookKonto.value, 10) : 0;
|
||||
if (!kontoId) { alert('Bitte ein Abschreibungskonto wählen.'); return; }
|
||||
|
||||
try {
|
||||
const res = await fetch(`/assets/${assetId}/depreciation/${afabookRowId}/book`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ ausgabe_konto_id: kontoId }),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (data.ok) {
|
||||
if (afabookModal) afabookModal.classList.remove('open');
|
||||
const bookBtn = tbody?.querySelector(`.afa-book-btn[data-row-id="${afabookRowId}"]`);
|
||||
if (bookBtn) {
|
||||
const cell = bookBtn.closest('td');
|
||||
cell.innerHTML = bookedCellHtml(afabookRowId, data.buchungs_nr);
|
||||
}
|
||||
} else {
|
||||
alert('Fehler: ' + (data.error ?? 'Unbekannt'));
|
||||
}
|
||||
} catch (_) {
|
||||
alert('Netzwerkfehler beim Buchen.');
|
||||
}
|
||||
});
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
|
|
|
|||
|
|
@ -10,9 +10,6 @@
|
|||
<th>Anlageart</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>
|
||||
|
|
@ -30,9 +27,6 @@
|
|||
</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"
|
||||
|
|
@ -64,12 +58,12 @@
|
|||
<?php endforeach; ?>
|
||||
<?php if (empty($assets)): ?>
|
||||
<tr>
|
||||
<td colspan="9" class="text-center text-muted" style="padding:2rem">
|
||||
<td colspan="6" class="text-center text-muted" style="padding:2rem">
|
||||
Noch keine Wirtschaftsgüter angelegt.
|
||||
</td>
|
||||
</tr>
|
||||
<?php endif; ?>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
|
|||
|
|
@ -25,6 +25,12 @@
|
|||
</option>
|
||||
<?php endforeach; ?>
|
||||
</select>
|
||||
<select name="buchungstyp" style="width:100px">
|
||||
<option value="">Alle Typen</option>
|
||||
<?php foreach ($buchungstypen as $bt): ?>
|
||||
<option value="<?= $h($bt) ?>"<?= ($filter['buchungstyp'] ?? '') === $bt ? ' selected' : '' ?>><?= $h($bt) ?></option>
|
||||
<?php endforeach; ?>
|
||||
</select>
|
||||
<button type="submit" class="btn btn-secondary">Filtern</button>
|
||||
<a href="/journal" class="btn btn-secondary">✕</a>
|
||||
</form>
|
||||
|
|
@ -65,12 +71,23 @@
|
|||
</td>
|
||||
<td class="td-muted"><?= View::formatDate($bk['buchungs_datum']) ?></td>
|
||||
<td>
|
||||
<?php if (!empty($bk['beleg_id'])): ?>
|
||||
<a href="/belege/<?= $h($bk['beleg_id']) ?>" class="td-mono text-sm"><?= $h($bk['beleg_nr']) ?></a>
|
||||
<?php if (!empty($bk['supplier_name'])): ?>
|
||||
<br><span class="td-muted"><?= $h(mb_strimwidth($bk['supplier_name'], 0, 25, '…')) ?></span>
|
||||
<?php endif; ?>
|
||||
<?php else: ?>
|
||||
<span class="text-muted">–</span>
|
||||
<?php endif; ?>
|
||||
</td>
|
||||
<td><span class="badge <?= $bk['buchungstyp'] === 'Einnahme' ? 'badge-green' : 'badge-red' ?>"><?= $h($bk['buchungstyp']) ?></span></td>
|
||||
<?php
|
||||
$badgeClass = match($bk['buchungstyp']) {
|
||||
'Einnahme' => 'badge-green',
|
||||
'Ausgabe' => 'badge-red',
|
||||
default => 'badge-gray',
|
||||
};
|
||||
?>
|
||||
<td><span class="badge <?= $badgeClass ?>"><?= $h($bk['buchungstyp']) ?></span></td>
|
||||
<td class="td-mono text-sm">
|
||||
<?= $h($bk['kategorie_nr']) ?><br>
|
||||
<span class="td-muted"><?= $h(mb_strimwidth($bk['kategorie_name'], 0, 25, '…')) ?></span>
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@
|
|||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title><?= \Steuerkram\View::h($pageTitle ?? 'Steuerkram') ?> – Steuerkram</title>
|
||||
<link rel="stylesheet" href="/static/css/app.css?v=<?= defined('ASSET_VERSION') ? ASSET_VERSION : 'dev' ?>">
|
||||
<style>:root { --color-nav-bg: <?= \Steuerkram\View::h(\Steuerkram\Config::get('sidebar_bg_color', '#1e293b')) ?>; }</style>
|
||||
<link rel="icon" type="image/png" href="/static/favicon/favicon-96x96.png" sizes="96x96" />
|
||||
<link rel="icon" type="image/svg+xml" href="/static/favicon/favicon.svg" />
|
||||
<link rel="shortcut icon" href="/static/favicon/favicon.ico" />
|
||||
|
|
|
|||
|
|
@ -7,9 +7,7 @@ $fmt = fn($v) => View::formatMoney($v);
|
|||
<div class="card">
|
||||
<div class="card-header">
|
||||
<div class="card-title">EÜR Geschäftsjahr <?= $h($year) ?></div>
|
||||
<div style="display:flex;gap:.5rem">
|
||||
<a href="/tax/euer/<?= $h($year) ?>/pdf" class="btn btn-primary">PDF herunterladen</a>
|
||||
</div>
|
||||
<a href="/tax/euer/<?= $h($year) ?>/pdf" class="btn btn-primary btn-sm">PDF</a>
|
||||
</div>
|
||||
|
||||
<div class="table-wrap">
|
||||
|
|
@ -129,3 +127,75 @@ $fmt = fn($v) => View::formatMoney($v);
|
|||
<?php if (empty($euer['einnahmen']) && empty($euer['ausgaben']) && empty($euer['nicht_abzugsfaehig'])): ?>
|
||||
<div class="flash flash-info mt-2">Für <?= $h($year) ?> wurden noch keine Buchungen erfasst.</div>
|
||||
<?php endif; ?>
|
||||
|
||||
<?php if (!empty($anlageliste)): ?>
|
||||
<div class="card mt-2">
|
||||
<div class="card-header">
|
||||
<div class="card-title">Anlagegüter <?= $h($year) ?></div>
|
||||
<div style="display:flex;align-items:center;gap:.5rem;margin-left:auto">
|
||||
<span style="color:var(--color-muted);font-size:.85rem">Herunterladen:</span>
|
||||
<a href="/tax/euer/<?= $h($year) ?>/anlageliste/csv" class="btn btn-sm btn-primary">CSV</a>
|
||||
<a href="/tax/euer/<?= $h($year) ?>/anlageliste/pdf" class="btn btn-sm btn-primary">PDF</a>
|
||||
</div>
|
||||
</div>
|
||||
<div class="table-wrap">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Anlage-ID</th>
|
||||
<th>Bezeichnung</th>
|
||||
<th>Anschaffungsdatum</th>
|
||||
<th class="td-right">Anschaffungskosten</th>
|
||||
<th class="td-right">Nutzungsdauer</th>
|
||||
<th class="td-right">AfA Vorjahre</th>
|
||||
<th class="td-right">Restbuchwert Vorjahr</th>
|
||||
<th class="td-right">AfA <?= $h($year) ?></th>
|
||||
<th class="td-right">Restbuchwert 31.12.<?= $h($year) ?></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<?php
|
||||
$sumBasis = 0.0;
|
||||
$sumAfaVorjahre = 0.0;
|
||||
$sumRbwVorjahr = 0.0;
|
||||
$sumAfaJahr = 0.0;
|
||||
$sumRbw = 0.0;
|
||||
foreach ($anlageliste as $al):
|
||||
$sumBasis += $al['basis'];
|
||||
$sumAfaVorjahre += $al['afa_vorjahre'];
|
||||
$sumRbwVorjahr += $al['restbuchwert_vorjahr'];
|
||||
$sumAfaJahr += $al['afa_jahr'];
|
||||
$sumRbw += $al['restbuchwert'];
|
||||
$nd = $al['nutzungsdauer'] !== null ? $al['nutzungsdauer'] . ($al['nutzungsdauer'] == 1 ? ' J.' : ' J.') : '–';
|
||||
if ($al['nutzungsdauer'] === '0' || $al['nutzungsdauer'] === 0) $nd = 'sofort';
|
||||
?>
|
||||
<tr>
|
||||
<td class="td-mono text-sm">
|
||||
<a href="/assets/<?= (int)$al['id'] ?>/edit"><?= $h($al['anlage_nr'] ?: $al['lfd_nr']) ?></a>
|
||||
</td>
|
||||
<td><?= $h($al['bezeichnung']) ?></td>
|
||||
<td class="td-muted text-sm"><?= View::formatDate($al['anschaffungsdatum']) ?></td>
|
||||
<td class="td-right td-mono"><?= $fmt($al['basis']) ?></td>
|
||||
<td class="td-right td-muted"><?= $h($nd) ?></td>
|
||||
<td class="td-right td-mono"><?= $fmt($al['afa_vorjahre']) ?></td>
|
||||
<td class="td-right td-mono"><?= $fmt($al['restbuchwert_vorjahr']) ?></td>
|
||||
<td class="td-right td-mono"><?= $fmt($al['afa_jahr']) ?></td>
|
||||
<td class="td-right td-mono"><?= $fmt($al['restbuchwert']) ?></td>
|
||||
</tr>
|
||||
<?php endforeach; ?>
|
||||
</tbody>
|
||||
<tfoot>
|
||||
<tr style="background:#e8ecf0;font-weight:700">
|
||||
<td colspan="3">Summe</td>
|
||||
<td class="td-right td-mono"><?= $fmt($sumBasis) ?></td>
|
||||
<td></td>
|
||||
<td class="td-right td-mono"><?= $fmt($sumAfaVorjahre) ?></td>
|
||||
<td class="td-right td-mono"><?= $fmt($sumRbwVorjahr) ?></td>
|
||||
<td class="td-right td-mono"><?= $fmt($sumAfaJahr) ?></td>
|
||||
<td class="td-right td-mono"><?= $fmt($sumRbw) ?></td>
|
||||
</tr>
|
||||
</tfoot>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
|
|
|
|||
|
|
@ -1 +1 @@
|
|||
1-260523
|
||||
1-260524
|
||||
|
|
|
|||
|
|
@ -77,7 +77,8 @@ class AbschreibungstabelleTest extends TestCase
|
|||
'anschaffungsdatum' => '2022-06-01',
|
||||
]);
|
||||
|
||||
$this->assertEquals(5, count($rows), 'AfA mit Zu-/Abgängen: 5 Zeilen');
|
||||
// Juni = 7 Monate erstes Jahr, 5 Monate letztes Jahr → 5+1=6 Zeilen
|
||||
$this->assertEquals(6, count($rows), 'AfA mit Zu-/Abgängen: 6 Zeilen (anteiliges Jahr)');
|
||||
$basis = 5000.00 + 500.00 - 200.00;
|
||||
$sumAfa = array_sum(array_column($rows, 'abschreibungsbetrag'));
|
||||
$this->assertEquals($basis, round($sumAfa, 2), 'Summe AfA = Basis mit Zu-/Abgängen');
|
||||
|
|
|
|||
116
tests/41_AfaAnteiligTest.php
Normal file
116
tests/41_AfaAnteiligTest.php
Normal file
|
|
@ -0,0 +1,116 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use Steuerkram\Models\AssetDepreciation;
|
||||
|
||||
class AfaAnteiligTest extends TestCase
|
||||
{
|
||||
protected string $testName = 'Test 41: AfA anteiliges erstes/letztes Jahr';
|
||||
|
||||
public function run(): void
|
||||
{
|
||||
// ── April-Kauf (Monat 4): 9 Monate erstes Jahr, 3 Monate letztes Jahr ──
|
||||
$rows = AssetDepreciation::calculate([
|
||||
'anschaffungskosten' => 1200.00,
|
||||
'zugaenge' => 0,
|
||||
'abgaenge' => 0,
|
||||
'nutzungsdauer' => 3,
|
||||
'cent_im_ersten_jahr'=> false,
|
||||
'anschaffungsdatum' => '2024-04-01',
|
||||
]);
|
||||
|
||||
$this->assertEquals(4, count($rows), 'April-Kauf 3 Jahre: 4 Zeilen (inkl. anteiliges letztes Jahr)');
|
||||
$this->assertEquals(300.00, $rows[0]['abschreibungsbetrag'], 'April: erstes Jahr 9/12 = 300,00');
|
||||
$this->assertEquals(400.00, $rows[1]['abschreibungsbetrag'], 'April: Jahr 2 voll = 400,00');
|
||||
$this->assertEquals(400.00, $rows[2]['abschreibungsbetrag'], 'April: Jahr 3 voll = 400,00');
|
||||
$this->assertEquals(100.00, $rows[3]['abschreibungsbetrag'], 'April: letztes Jahr 3/12 = 100,00');
|
||||
$this->assertEquals(0.00, $rows[3]['restwert'], 'April: Restwert am Ende = 0');
|
||||
$sumAfa = array_sum(array_column($rows, 'abschreibungsbetrag'));
|
||||
$this->assertEquals(1200.00, round($sumAfa, 2), 'April: Summe = Basis');
|
||||
|
||||
// ── Oktober-Kauf (Monat 10): 3 Monate erstes Jahr, 9 Monate letztes Jahr ──
|
||||
$rows = AssetDepreciation::calculate([
|
||||
'anschaffungskosten' => 1200.00,
|
||||
'zugaenge' => 0,
|
||||
'abgaenge' => 0,
|
||||
'nutzungsdauer' => 2,
|
||||
'cent_im_ersten_jahr'=> false,
|
||||
'anschaffungsdatum' => '2023-10-15',
|
||||
]);
|
||||
|
||||
$this->assertEquals(3, count($rows), 'Oktober-Kauf 2 Jahre: 3 Zeilen');
|
||||
$this->assertEquals(150.00, $rows[0]['abschreibungsbetrag'], 'Oktober: erstes Jahr 3/12 = 150,00');
|
||||
$this->assertEquals(600.00, $rows[1]['abschreibungsbetrag'], 'Oktober: Jahr 2 voll = 600,00');
|
||||
$this->assertEquals(450.00, $rows[2]['abschreibungsbetrag'], 'Oktober: letztes Jahr 9/12 = 450,00');
|
||||
$this->assertEquals(0.00, $rows[2]['restwert'], 'Oktober: Restwert = 0');
|
||||
|
||||
// ── Januar-Kauf (Monat 1): 12 Monate = kein extra letztes Jahr ──
|
||||
$rows = AssetDepreciation::calculate([
|
||||
'anschaffungskosten' => 1200.00,
|
||||
'zugaenge' => 0,
|
||||
'abgaenge' => 0,
|
||||
'nutzungsdauer' => 3,
|
||||
'cent_im_ersten_jahr'=> false,
|
||||
'anschaffungsdatum' => '2024-01-10',
|
||||
]);
|
||||
|
||||
$this->assertEquals(3, count($rows), 'Januar-Kauf: weiterhin exakt nutzungsdauer Zeilen');
|
||||
$sumAfa = array_sum(array_column($rows, 'abschreibungsbetrag'));
|
||||
$this->assertEquals(1200.00, round($sumAfa, 2), 'Januar: Summe = Basis');
|
||||
|
||||
// ── April-Kauf mit Cent-Option ───────────────────────────────────────────
|
||||
// basis=1001.40, nutzungsdauer=3, April
|
||||
// regelJahr=floor(333.8)=333, centRest=1001.40-999=2.40
|
||||
// erstesJahr = round(333*9/12,2) + 2.40 = 249.75 + 2.40 = 252.15
|
||||
// Jahr 2+3: 333 (volle Euro)
|
||||
// letztes Jahr: 1001.40 - 252.15 - 333 - 333 = 83.25
|
||||
$rows = AssetDepreciation::calculate([
|
||||
'anschaffungskosten' => 1001.40,
|
||||
'zugaenge' => 0,
|
||||
'abgaenge' => 0,
|
||||
'nutzungsdauer' => 3,
|
||||
'cent_im_ersten_jahr'=> true,
|
||||
'anschaffungsdatum' => '2024-04-01',
|
||||
]);
|
||||
|
||||
$this->assertEquals(4, count($rows), 'April Cent-Option 3 Jahre: 4 Zeilen');
|
||||
$this->assertEquals(252.15, $rows[0]['abschreibungsbetrag'], 'April Cent: erstes Jahr 249,75 + 2,40 Cent = 252,15');
|
||||
$this->assertEquals(333.00, $rows[1]['abschreibungsbetrag'], 'April Cent: Jahr 2 voll = 333,00 (volle Euro)');
|
||||
$this->assertEquals(333.00, $rows[2]['abschreibungsbetrag'], 'April Cent: Jahr 3 voll = 333,00 (volle Euro)');
|
||||
$this->assertEquals(83.25, $rows[3]['abschreibungsbetrag'], 'April Cent: letztes Jahr 3/12 von 333 = 83,25');
|
||||
$sumAfa = array_sum(array_column($rows, 'abschreibungsbetrag'));
|
||||
$this->assertEquals(1001.40, round($sumAfa, 2), 'April Cent: Summe = Basis');
|
||||
|
||||
// ── Dezember-Kauf (Monat 12): 1 Monat erstes Jahr, 11 Monate letztes Jahr ──
|
||||
$rows = AssetDepreciation::calculate([
|
||||
'anschaffungskosten' => 1200.00,
|
||||
'zugaenge' => 0,
|
||||
'abgaenge' => 0,
|
||||
'nutzungsdauer' => 3,
|
||||
'cent_im_ersten_jahr'=> false,
|
||||
'anschaffungsdatum' => '2024-12-01',
|
||||
]);
|
||||
|
||||
$this->assertEquals(4, count($rows), 'Dezember-Kauf 3 Jahre: 4 Zeilen');
|
||||
$this->assertEquals(round(400 * 1 / 12, 2), $rows[0]['abschreibungsbetrag'], 'Dezember: erstes Jahr 1/12');
|
||||
$this->assertEquals(0.00, $rows[3]['restwert'], 'Dezember: Restwert am Ende = 0');
|
||||
$sumAfa = array_sum(array_column($rows, 'abschreibungsbetrag'));
|
||||
$this->assertEquals(1200.00, round($sumAfa, 2), 'Dezember: Summe = Basis');
|
||||
|
||||
// ── Startjahr des letzten Jahres korrekt ────────────────────────────────
|
||||
$rows = AssetDepreciation::calculate([
|
||||
'anschaffungskosten' => 600.00,
|
||||
'zugaenge' => 0,
|
||||
'abgaenge' => 0,
|
||||
'nutzungsdauer' => 2,
|
||||
'cent_im_ersten_jahr'=> false,
|
||||
'anschaffungsdatum' => '2023-07-01',
|
||||
]);
|
||||
// Juli = 6 Monate erstes Jahr, 6 Monate letztes Jahr → 3 Zeilen
|
||||
$this->assertEquals(3, count($rows), 'Juli-Kauf 2 Jahre: 3 Zeilen');
|
||||
$this->assertEquals(2023, $rows[0]['jahr'], 'Startjahr erste Zeile = 2023');
|
||||
$this->assertEquals(2024, $rows[1]['jahr'], 'Startjahr zweite Zeile = 2024');
|
||||
$this->assertEquals(2025, $rows[2]['jahr'], 'Startjahr letzte Zeile = 2025');
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue