Merge pull request 'Fix: Eigenanteil als Einnahme, Ollama qwen3-vl, KI-Autostart + Revert UStVA-KZ' (#58) from fds2610-bot/Steuerkram:main into main
Reviewed-on: https://codeberg.org/fds2610/Steuerkram/pulls/58
This commit is contained in:
commit
2fe6406f97
35 changed files with 1385 additions and 146 deletions
44
Changes.md
44
Changes.md
|
|
@ -2,6 +2,50 @@
|
|||
|
||||
---
|
||||
|
||||
## 2026-04-26 – Fix: Eigenanteil-Buchung als Einnahme statt Ausgabe
|
||||
|
||||
### Geändert
|
||||
- `src/Models/Booking.php`: `upsertKorrekturbuchung()` setzt `buchungstyp` jetzt korrekt auf `Einnahme` (war fälschlich `Ausgabe`)
|
||||
|
||||
---
|
||||
|
||||
## 2026-04-26 – Feature #57: Lineare Abschreibungstabelle für Anlagegüter
|
||||
|
||||
### Hinzugefügt
|
||||
- `migrations/035_assets_abschreibung_flags.sql`: Neue Spalten `automatisch_berechnet` und `cent_im_ersten_jahr` in `assets`
|
||||
- `migrations/036_asset_depreciation_schedule.sql`: Neue Tabelle `asset_depreciation_schedule` (Jahr, Abschreibungsbetrag, Restwert)
|
||||
- `src/Models/AssetDepreciation.php`: Berechnung (linear, Sofortabschreibung, Cent-Option), Persistenz
|
||||
- `tests/26_AbschreibungstabelleTest.php`: 22 Tests für alle AfA-Szenarien
|
||||
|
||||
### Geändert
|
||||
- `src/Models/Asset.php`: `create()` und `update()` speichern `automatisch_berechnet` und `cent_im_ersten_jahr`
|
||||
- `src/Controllers/AssetController.php`: Abschreibungstabelle wird nach Speichern automatisch generiert; neue Endpunkte `/assets/depreciation-preview` (AJAX-Vorschau) und `/assets/{id}/depreciation` (manuelle Speicherung)
|
||||
- `src/Views/assets/form.php`: Nutzungsdauer-Dropdown (sofort, 1–10, 12, 13, 15, 20, 25, 50 Jahre + individuell), Cent-Option, „AfA-Tabelle"-Button, Modal mit Auto/Manuell-Toggle und editierbarer Tabelle
|
||||
- `public/index.php`: Neue Routen für Depreciation-Preview und -Speicherung
|
||||
|
||||
---
|
||||
|
||||
|
||||
## 2026-04-26 – KI-Diagnose: Erreichbarkeitsprüfung und Fehlerlog für Ollama
|
||||
|
||||
### Geändert
|
||||
- `src/Services/LlmService.php`: Neue Methode `isReachable()` für Diagnosezwecke; Fehlerlog in `callOllama()` bei cURL-Fehler oder leerer Antwort
|
||||
- `src/Views/settings/form.php`: Einstellungsseite prüft jetzt aktiv, ob der Ollama-Server erreichbar ist (nicht nur ob er konfiguriert ist); zeigt bei Verbindungsfehler einen konkreten Hinweis auf `OLLAMA_HOST=0.0.0.0:11434`
|
||||
|
||||
---
|
||||
|
||||
## 2026-04-26 – Leistungszeitraum als freies Textfeld
|
||||
|
||||
### Geändert
|
||||
- `migrations/034_leistungszeitraum_text.sql`: Spalten `leistungszeitraum_von` (DATE) und `leistungszeitraum_bis` (DATE) wurden durch eine einzige Textspalte `leistungszeitraum` (VARCHAR 255) ersetzt; bestehende Datumswerte werden automatisch migriert
|
||||
- `src/Views/invoices/form.php`: Zwei Datumsfelder durch ein Freitextfeld „Leistungszeitraum" ersetzt, Grid von 4 auf 3 Spalten angepasst
|
||||
- `src/Models/Invoice.php`: INSERT/UPDATE verwenden jetzt `leistungszeitraum` statt `leistungszeitraum_von`/`leistungszeitraum_bis`
|
||||
- `src/Controllers/InvoiceController.php`: Datums-Defaults für `leistungszeitraum_von`/`leistungszeitraum_bis` entfernt
|
||||
- `src/Views/invoices/show.php`: Leistungszeitraum-Anzeige vereinfacht (kein Datumsformat, direkter Textausgabe)
|
||||
- `src/Services/PdfInvoiceService.php`: Leistungszeitraum-Ausgabe vereinfacht
|
||||
|
||||
---
|
||||
|
||||
## 2026-04-15 – KI-Erkennung: automatischer Aufruf aus Belegansicht entfernt
|
||||
|
||||
### Geändert
|
||||
|
|
|
|||
|
|
@ -18,7 +18,7 @@ services:
|
|||
DB_PASS: steuerkram
|
||||
APP_SECRET: change-this-in-production
|
||||
OLLAMA_URL: ${OLLAMA_URL:-http://host-gateway:11434}
|
||||
OLLAMA_MODEL: ${OLLAMA_MODEL:-llama3.2}
|
||||
OLLAMA_MODEL: ${OLLAMA_MODEL:-qwen3-vl}
|
||||
extra_hosts:
|
||||
- "host-gateway:host-gateway"
|
||||
depends_on:
|
||||
|
|
|
|||
|
|
@ -65,5 +65,36 @@ Folgende Felder können nachträglich angepasst werden:
|
|||
- Anschaffungsdatum und -kosten
|
||||
- Zugänge und Abgänge
|
||||
- Abschreibung laufendes Jahr
|
||||
- Nutzungsdauer (in Jahren)
|
||||
- Nutzungsdauer (Dropdown: sofort, 1–10, 12, 13, 15, 20, 25, 50 Jahre oder individuell)
|
||||
- Centbeträge im ersten Jahr abschreiben (Option)
|
||||
- Notizen
|
||||
|
||||
---
|
||||
|
||||
## Abschreibungstabelle (AfA)
|
||||
|
||||
Für jedes gespeicherte Wirtschaftsgut wird automatisch eine lineare AfA-Tabelle erzeugt und persistent gespeichert (`asset_depreciation_schedule`). Die Tabelle enthält pro Jahr: **Jahr**, **Abschreibungsbetrag**, **Restwert**.
|
||||
|
||||
### Berechnungsregeln
|
||||
|
||||
| Modus | Verhalten |
|
||||
|---|---|
|
||||
| **Nutzungsdauer = sofort** | Vollständige Abschreibung im Anschaffungsjahr, Restwert = 0 |
|
||||
| **Nutzungsdauer = N Jahre** | Gleichmäßige lineare Verteilung; letztes Jahr gleicht Rundungsdifferenzen aus |
|
||||
| **Cent-Option aktiv** | Folgejahre auf volle Euro abgerundet; erstes Jahr übernimmt alle Centbeträge; Restwert nach Jahr 1 ist volle Euro |
|
||||
|
||||
Basis der Berechnung: `Anschaffungskosten + Zugänge − Abgänge`.
|
||||
|
||||
### Auto- und Manuell-Modus
|
||||
|
||||
Die Tabelle besitzt ein Flag `automatisch_berechnet`:
|
||||
- **Automatisch (Standard):** Bei jeder Parameteränderung (Nutzungsdauer, Cent-Option, Kosten) wird die Tabelle neu berechnet.
|
||||
- **Manuell:** Durch Klick auf „Manuell bearbeiten" im Modal wird das Flag deaktiviert. Parameteränderungen zeigen eine Bestätigungsabfrage, bevor neu berechnet wird.
|
||||
|
||||
### Bedienung
|
||||
|
||||
1. Wirtschaftsgut speichern → Tabelle wird automatisch berechnet.
|
||||
2. Button **„AfA-Tabelle"** öffnet den modalen Dialog.
|
||||
3. Im Dialog: Toggle „Manuell bearbeiten" aktiviert editierbare Zellen.
|
||||
4. **„Neu berechnen"** überschreibt manuelle Änderungen (nach Bestätigung).
|
||||
5. **„Speichern"** sichert die aktuelle Tabelle (inklusive manueller Änderungen) in der Datenbank.
|
||||
|
|
|
|||
5
migrations/033_customer_nr_prefix.sql
Normal file
5
migrations/033_customer_nr_prefix.sql
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
-- Migration 033: Kunden-Nummernpräfix konfigurierbar machen
|
||||
ALTER TABLE company_settings
|
||||
ADD COLUMN IF NOT EXISTS customer_nr_prefix VARCHAR(10) NOT NULL DEFAULT 'K-'
|
||||
COMMENT 'Präfix für Kundennummern (z.B. K-)'
|
||||
AFTER anlage_nr_prefix;
|
||||
23
migrations/034_leistungszeitraum_text.sql
Normal file
23
migrations/034_leistungszeitraum_text.sql
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
-- Leistungszeitraum: von zwei DATE-Feldern zu einem freien Textfeld
|
||||
ALTER TABLE invoices
|
||||
ADD COLUMN leistungszeitraum VARCHAR(255) NULL AFTER leistungszeitraum_bis;
|
||||
|
||||
-- Bestehende Daten migrieren: Datumsfelder als lesbaren Text zusammenführen
|
||||
UPDATE invoices
|
||||
SET leistungszeitraum = CASE
|
||||
WHEN leistungszeitraum_von IS NOT NULL AND leistungszeitraum_bis IS NOT NULL
|
||||
AND leistungszeitraum_von != leistungszeitraum_bis
|
||||
THEN CONCAT(
|
||||
DATE_FORMAT(leistungszeitraum_von, '%d.%m.%Y'),
|
||||
' – ',
|
||||
DATE_FORMAT(leistungszeitraum_bis, '%d.%m.%Y')
|
||||
)
|
||||
WHEN leistungszeitraum_von IS NOT NULL
|
||||
THEN DATE_FORMAT(leistungszeitraum_von, '%d.%m.%Y')
|
||||
ELSE NULL
|
||||
END
|
||||
WHERE leistungszeitraum_von IS NOT NULL OR leistungszeitraum_bis IS NOT NULL;
|
||||
|
||||
ALTER TABLE invoices
|
||||
DROP COLUMN leistungszeitraum_von,
|
||||
DROP COLUMN leistungszeitraum_bis;
|
||||
8
migrations/035_assets_abschreibung_flags.sql
Normal file
8
migrations/035_assets_abschreibung_flags.sql
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
-- Migration 035: Abschreibungsflags für Wirtschaftsgüter
|
||||
ALTER TABLE assets
|
||||
ADD COLUMN IF NOT EXISTS automatisch_berechnet TINYINT(1) NOT NULL DEFAULT 1
|
||||
COMMENT 'true = Abschreibungstabelle automatisch berechnet, false = manuell bearbeitet'
|
||||
AFTER nutzungsdauer,
|
||||
ADD COLUMN IF NOT EXISTS cent_im_ersten_jahr TINYINT(1) NOT NULL DEFAULT 0
|
||||
COMMENT 'Centbeträge vollständig im ersten Jahr abschreiben; Folgejahre auf volle Euro'
|
||||
AFTER automatisch_berechnet;
|
||||
13
migrations/036_asset_depreciation_schedule.sql
Normal file
13
migrations/036_asset_depreciation_schedule.sql
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
-- Migration 036: Abschreibungstabelle je Wirtschaftsgut
|
||||
CREATE TABLE IF NOT EXISTS asset_depreciation_schedule (
|
||||
id INT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
asset_id INT UNSIGNED NOT NULL,
|
||||
jahr SMALLINT UNSIGNED NOT NULL COMMENT 'Kalenderjahr der Abschreibung',
|
||||
abschreibungsbetrag DECIMAL(12,2) NOT NULL DEFAULT 0.00,
|
||||
restwert DECIMAL(12,2) NOT NULL DEFAULT 0.00,
|
||||
position SMALLINT UNSIGNED NOT NULL DEFAULT 0 COMMENT 'Reihenfolge (0-basiert)',
|
||||
PRIMARY KEY (id),
|
||||
KEY idx_ads_asset_id (asset_id),
|
||||
UNIQUE KEY uq_ads_asset_jahr (asset_id, jahr)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
|
||||
COMMENT='Lineare Abschreibungstabelle je Wirtschaftsgut';
|
||||
|
|
@ -95,16 +95,19 @@ $router->get('/invoices/{id}/book', fn($p) => (new InvoiceController())->book($p
|
|||
$router->post('/invoices/{id}/bookings', fn($p) => (new InvoiceController())->storeBooking($p));
|
||||
$router->post('/invoices/{id}/mark-paid', fn($p) => (new InvoiceController())->markPaid($p));
|
||||
$router->post('/invoices/{id}/cancel', fn($p) => (new InvoiceController())->cancel($p));
|
||||
$router->post('/invoices/{id}/delete', fn($p) => (new InvoiceController())->destroy($p));
|
||||
$router->post('/invoices/{id}/lines', fn($p) => (new InvoiceController())->addLine($p));
|
||||
$router->post('/invoices/{id}/lines/{line_id}/delete', fn($p) => (new InvoiceController())->deleteLine($p));
|
||||
|
||||
// ── Anlagevermögen ───────────────────────────────────────────
|
||||
$router->get('/assets', fn($p) => (new AssetController())->index($p));
|
||||
$router->get('/assets/new', fn($p) => (new AssetController())->create($p));
|
||||
$router->post('/assets', fn($p) => (new AssetController())->store($p));
|
||||
$router->get('/assets/{id}/edit', fn($p) => (new AssetController())->edit($p));
|
||||
$router->post('/assets/{id}', fn($p) => (new AssetController())->update($p));
|
||||
$router->post('/assets/{id}/delete', fn($p) => (new AssetController())->destroy($p));
|
||||
$router->get('/assets', fn($p) => (new AssetController())->index($p));
|
||||
$router->get('/assets/new', fn($p) => (new AssetController())->create($p));
|
||||
$router->post('/assets', fn($p) => (new AssetController())->store($p));
|
||||
$router->get('/assets/depreciation-preview', fn($p) => (new AssetController())->depreciationPreview($p));
|
||||
$router->get('/assets/{id}/edit', fn($p) => (new AssetController())->edit($p));
|
||||
$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));
|
||||
|
||||
// ── UStVA ────────────────────────────────────────────────────
|
||||
$router->get('/tax/ustva', fn($p) => (new UstvaController())->index($p));
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ declare(strict_types=1);
|
|||
namespace Steuerkram\Controllers;
|
||||
|
||||
use Steuerkram\Models\Asset;
|
||||
use Steuerkram\Models\AssetDepreciation;
|
||||
use Steuerkram\View;
|
||||
|
||||
class AssetController
|
||||
|
|
@ -29,10 +30,11 @@ class AssetController
|
|||
$view->render('layout/base.php', [
|
||||
'pageTitle' => 'Wirtschaftsgut anlegen',
|
||||
'content' => $this->renderView('assets/form', [
|
||||
'asset' => [],
|
||||
'errors' => [],
|
||||
'action' => '/assets',
|
||||
'anlagearten' => Asset::anlagearten(),
|
||||
'asset' => [],
|
||||
'errors' => [],
|
||||
'action' => '/assets',
|
||||
'anlagearten' => Asset::anlagearten(),
|
||||
'scheduleRows' => [],
|
||||
]),
|
||||
]);
|
||||
}
|
||||
|
|
@ -47,17 +49,23 @@ class AssetController
|
|||
$view->render('layout/base.php', [
|
||||
'pageTitle' => 'Wirtschaftsgut anlegen',
|
||||
'content' => $this->renderView('assets/form', [
|
||||
'asset' => $data,
|
||||
'errors' => $errors,
|
||||
'action' => '/assets',
|
||||
'anlagearten' => Asset::anlagearten(),
|
||||
'asset' => $data,
|
||||
'errors' => $errors,
|
||||
'action' => '/assets',
|
||||
'anlagearten' => Asset::anlagearten(),
|
||||
'scheduleRows' => [],
|
||||
]),
|
||||
]);
|
||||
return;
|
||||
}
|
||||
|
||||
Asset::create($data);
|
||||
View::redirect('/assets', 'Wirtschaftsgut erfolgreich angelegt.');
|
||||
$id = Asset::create($data);
|
||||
$asset = Asset::find($id);
|
||||
if ($asset && (int)($asset['automatisch_berechnet'] ?? 1) === 1) {
|
||||
AssetDepreciation::generateAndSave($asset);
|
||||
}
|
||||
|
||||
View::redirect('/assets/' . $id . '/edit', 'Wirtschaftsgut erfolgreich angelegt.');
|
||||
}
|
||||
|
||||
public function edit(array $params): void
|
||||
|
|
@ -65,14 +73,17 @@ class AssetController
|
|||
$asset = Asset::find((int)$params['id']);
|
||||
if (!$asset) { http_response_code(404); return; }
|
||||
|
||||
$scheduleRows = AssetDepreciation::findByAsset((int)$asset['id']);
|
||||
|
||||
$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(),
|
||||
'asset' => $asset,
|
||||
'errors' => [],
|
||||
'action' => '/assets/' . $asset['id'],
|
||||
'anlagearten' => Asset::anlagearten(),
|
||||
'scheduleRows' => $scheduleRows,
|
||||
]),
|
||||
]);
|
||||
}
|
||||
|
|
@ -87,46 +98,128 @@ class AssetController
|
|||
$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(),
|
||||
'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);
|
||||
View::redirect('/assets', 'Wirtschaftsgut aktualisiert.');
|
||||
|
||||
$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
|
||||
{
|
||||
if (!Asset::delete((int)$params['id'])) {
|
||||
$id = (int)$params['id'];
|
||||
AssetDepreciation::deleteByAsset($id);
|
||||
if (!Asset::delete($id)) {
|
||||
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]);
|
||||
}
|
||||
|
||||
// ── 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'] ?? ''),
|
||||
'notizen' => trim($_POST['notizen'] ?? ''),
|
||||
'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'] ?? ''),
|
||||
];
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -144,6 +144,7 @@ class BookingController
|
|||
);
|
||||
$kontonummer = $konto['kontonummer'] ?? '';
|
||||
|
||||
$newAssetId = null;
|
||||
if (($konto['kontenart'] ?? '') === 'Anlagevermögen') {
|
||||
$assetId = isset($data['asset_id']) && $data['asset_id'] !== '' ? (int)$data['asset_id'] : null;
|
||||
if ($assetId === null) {
|
||||
|
|
@ -154,6 +155,7 @@ class BookingController
|
|||
'anschaffungsdatum' => $data['buchungs_datum'],
|
||||
'anschaffungskosten' => (float)$data['betrag_netto'],
|
||||
]);
|
||||
$newAssetId = $assetId;
|
||||
}
|
||||
$data['asset_id'] = $assetId;
|
||||
}
|
||||
|
|
@ -180,7 +182,11 @@ class BookingController
|
|||
}
|
||||
}
|
||||
|
||||
View::redirect("/belege/{$belegId}", 'Buchung erfasst.');
|
||||
if ($newAssetId !== null) {
|
||||
View::redirect("/assets/{$newAssetId}/edit", 'Buchung erfasst. Bitte Wirtschaftsgut-Details ergänzen.');
|
||||
} else {
|
||||
View::redirect("/belege/{$belegId}", 'Buchung erfasst.');
|
||||
}
|
||||
} catch (\PDOException $e) {
|
||||
error_log('[Steuerkram] PDO-Fehler in BookingController::store(): ' . $e->getMessage());
|
||||
$this->renderBelegDetail($belegId, ['_exception' => 'Datenbankfehler beim Speichern der Buchung.'], $data);
|
||||
|
|
|
|||
|
|
@ -17,19 +17,21 @@ class InvoiceController
|
|||
{
|
||||
public function index(array $params): void
|
||||
{
|
||||
$filter = $_GET;
|
||||
$page = max(1, (int)($_GET['page'] ?? 1));
|
||||
$result = Invoice::all($filter, $page);
|
||||
$filter = $_GET;
|
||||
$page = max(1, (int)($_GET['page'] ?? 1));
|
||||
$result = Invoice::all($filter, $page);
|
||||
$settings = Setting::get();
|
||||
|
||||
$view = new View();
|
||||
$view->render('layout/base.php', [
|
||||
'pageTitle' => 'Ausgangsrechnungen',
|
||||
'content' => $this->renderView('invoices/list', [
|
||||
'rows' => $result['rows'],
|
||||
'total' => $result['total'],
|
||||
'pages' => $result['pages'],
|
||||
'page' => $page,
|
||||
'filter' => $filter,
|
||||
'rows' => $result['rows'],
|
||||
'total' => $result['total'],
|
||||
'pages' => $result['pages'],
|
||||
'page' => $page,
|
||||
'filter' => $filter,
|
||||
'settings' => $settings,
|
||||
]),
|
||||
'topbarActions' => '<a href="/invoices/new" class="btn btn-primary">+ Rechnung erstellen</a>',
|
||||
]);
|
||||
|
|
@ -40,15 +42,24 @@ class InvoiceController
|
|||
$customers = Customer::all();
|
||||
$settings = Setting::get();
|
||||
|
||||
$invoiceDefaults = [
|
||||
'rechnungs_datum' => date('Y-m-d'),
|
||||
];
|
||||
|
||||
$preCustomerId = (int)($_GET['customer_id'] ?? 0);
|
||||
if ($preCustomerId > 0) {
|
||||
$customer = Customer::find($preCustomerId);
|
||||
if ($customer) {
|
||||
$invoiceDefaults['customer_id'] = $preCustomerId;
|
||||
$invoiceDefaults['payment_days'] = $customer['payment_days'];
|
||||
}
|
||||
}
|
||||
|
||||
$view = new View();
|
||||
$view->render('layout/base.php', [
|
||||
'pageTitle' => 'Neue Rechnung',
|
||||
'content' => $this->renderView('invoices/form', [
|
||||
'invoice' => [
|
||||
'rechnungs_datum' => date('Y-m-d'),
|
||||
'leistungszeitraum_von' => date('Y-m-01'),
|
||||
'leistungszeitraum_bis' => date('Y-m-t'),
|
||||
],
|
||||
'invoice' => $invoiceDefaults,
|
||||
'lines' => [],
|
||||
'customers' => $customers,
|
||||
'errors' => [],
|
||||
|
|
@ -336,6 +347,17 @@ class InvoiceController
|
|||
return $belegNr;
|
||||
}
|
||||
|
||||
public function destroy(array $params): void
|
||||
{
|
||||
if (!Setting::isTestMode()) {
|
||||
View::redirect('/invoices', 'Löschen ist nur im Testmodus erlaubt.', 'error');
|
||||
return;
|
||||
}
|
||||
$id = (int)$params['id'];
|
||||
Invoice::delete($id);
|
||||
View::redirect('/invoices', 'Rechnung gelöscht.');
|
||||
}
|
||||
|
||||
public function markPaid(array $params): void
|
||||
{
|
||||
Invoice::markPaid((int)$params['id']);
|
||||
|
|
|
|||
|
|
@ -85,8 +85,9 @@ class Asset
|
|||
Database::execute(
|
||||
'INSERT INTO assets
|
||||
(lfd_nr, anlage_nr, bezeichnung, account_id, anschaffungsdatum, anschaffungskosten,
|
||||
zugaenge, abgaenge, abschreibung_jahr, nutzungsdauer, notizen, fiscal_year_id)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)',
|
||||
zugaenge, abgaenge, abschreibung_jahr, nutzungsdauer, automatisch_berechnet,
|
||||
cent_im_ersten_jahr, notizen, fiscal_year_id)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)',
|
||||
[
|
||||
$data['lfd_nr'] ?? self::nextLfdNr(),
|
||||
$anlageNr,
|
||||
|
|
@ -98,6 +99,8 @@ class Asset
|
|||
$data['abgaenge'] ?? 0,
|
||||
$data['abschreibung_jahr'] ?? 0,
|
||||
isset($data['nutzungsdauer']) && $data['nutzungsdauer'] !== '' ? (int)$data['nutzungsdauer'] : null,
|
||||
isset($data['automatisch_berechnet']) ? (int)(bool)$data['automatisch_berechnet'] : 1,
|
||||
isset($data['cent_im_ersten_jahr']) ? (int)(bool)$data['cent_im_ersten_jahr'] : 0,
|
||||
$data['notizen'] ?? null,
|
||||
$fyId,
|
||||
]
|
||||
|
|
@ -116,17 +119,19 @@ class Asset
|
|||
|
||||
Database::execute(
|
||||
'UPDATE assets SET
|
||||
bezeichnung = ?,
|
||||
account_id = ?,
|
||||
anschaffungsdatum = ?,
|
||||
anschaffungskosten = ?,
|
||||
zugaenge = ?,
|
||||
abgaenge = ?,
|
||||
abschreibung_jahr = ?,
|
||||
nutzungsdauer = ?,
|
||||
notizen = ?,
|
||||
fiscal_year_id = COALESCE(?, fiscal_year_id),
|
||||
updated_at = NOW()
|
||||
bezeichnung = ?,
|
||||
account_id = ?,
|
||||
anschaffungsdatum = ?,
|
||||
anschaffungskosten = ?,
|
||||
zugaenge = ?,
|
||||
abgaenge = ?,
|
||||
abschreibung_jahr = ?,
|
||||
nutzungsdauer = ?,
|
||||
automatisch_berechnet = ?,
|
||||
cent_im_ersten_jahr = ?,
|
||||
notizen = ?,
|
||||
fiscal_year_id = COALESCE(?, fiscal_year_id),
|
||||
updated_at = NOW()
|
||||
WHERE id = ?',
|
||||
[
|
||||
$data['bezeichnung'],
|
||||
|
|
@ -137,6 +142,8 @@ class Asset
|
|||
$data['abgaenge'] ?? 0,
|
||||
$data['abschreibung_jahr'] ?? 0,
|
||||
isset($data['nutzungsdauer']) && $data['nutzungsdauer'] !== '' ? (int)$data['nutzungsdauer'] : null,
|
||||
isset($data['automatisch_berechnet']) ? (int)(bool)$data['automatisch_berechnet'] : 1,
|
||||
isset($data['cent_im_ersten_jahr']) ? (int)(bool)$data['cent_im_ersten_jahr'] : 0,
|
||||
$data['notizen'] ?? null,
|
||||
$fyId,
|
||||
$id,
|
||||
|
|
|
|||
116
src/Models/AssetDepreciation.php
Normal file
116
src/Models/AssetDepreciation.php
Normal file
|
|
@ -0,0 +1,116 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Steuerkram\Models;
|
||||
|
||||
use Steuerkram\Database;
|
||||
|
||||
class AssetDepreciation
|
||||
{
|
||||
public static function findByAsset(int $assetId): array
|
||||
{
|
||||
return Database::fetchAll(
|
||||
'SELECT id, asset_id, jahr, abschreibungsbetrag, restwert, position
|
||||
FROM asset_depreciation_schedule
|
||||
WHERE asset_id = ?
|
||||
ORDER BY position ASC',
|
||||
[$assetId]
|
||||
);
|
||||
}
|
||||
|
||||
public static function calculate(array $asset): array
|
||||
{
|
||||
$anschaffungskosten = (float)($asset['anschaffungskosten'] ?? 0);
|
||||
$zugaenge = (float)($asset['zugaenge'] ?? 0);
|
||||
$abgaenge = (float)($asset['abgaenge'] ?? 0);
|
||||
$nutzungsdauer = (int)($asset['nutzungsdauer'] ?? 0);
|
||||
$centImErstenJahr = (bool)($asset['cent_im_ersten_jahr'] ?? false);
|
||||
$startjahr = !empty($asset['anschaffungsdatum'])
|
||||
? (int)date('Y', strtotime($asset['anschaffungsdatum']))
|
||||
: (int)date('Y');
|
||||
|
||||
$basis = $anschaffungskosten + $zugaenge - $abgaenge;
|
||||
$rows = [];
|
||||
|
||||
if ($basis <= 0) {
|
||||
return $rows;
|
||||
}
|
||||
|
||||
// Sofortabschreibung
|
||||
if ($nutzungsdauer === 0) {
|
||||
$rows[] = [
|
||||
'jahr' => $startjahr,
|
||||
'abschreibungsbetrag' => round($basis, 2),
|
||||
'restwert' => 0.00,
|
||||
'position' => 0,
|
||||
];
|
||||
return $rows;
|
||||
}
|
||||
|
||||
if ($centImErstenJahr) {
|
||||
// Folgejahre: auf volle Euro gerundet (abgerundet)
|
||||
$regelJahr = floor($basis / $nutzungsdauer);
|
||||
$erstesJahr = round($basis - $regelJahr * ($nutzungsdauer - 1), 2);
|
||||
} else {
|
||||
$regelJahr = round($basis / $nutzungsdauer, 2);
|
||||
$erstesJahr = round($basis - $regelJahr * ($nutzungsdauer - 1), 2);
|
||||
}
|
||||
|
||||
$restwert = $basis;
|
||||
for ($i = 0; $i < $nutzungsdauer; $i++) {
|
||||
$afa = ($i === 0) ? $erstesJahr : (float)$regelJahr;
|
||||
$restwert = round($restwert - $afa, 2);
|
||||
// Letztes Jahr: Restwert auf 0 normalisieren (Rundungsfehler)
|
||||
if ($i === $nutzungsdauer - 1) {
|
||||
$afa = round($afa + $restwert, 2);
|
||||
$restwert = 0.00;
|
||||
}
|
||||
$rows[] = [
|
||||
'jahr' => $startjahr + $i,
|
||||
'abschreibungsbetrag' => $afa,
|
||||
'restwert' => $restwert,
|
||||
'position' => $i,
|
||||
];
|
||||
}
|
||||
|
||||
return $rows;
|
||||
}
|
||||
|
||||
public static function saveForAsset(int $assetId, array $rows): void
|
||||
{
|
||||
self::deleteByAsset($assetId);
|
||||
foreach ($rows as $row) {
|
||||
Database::execute(
|
||||
'INSERT INTO asset_depreciation_schedule
|
||||
(asset_id, jahr, abschreibungsbetrag, restwert, position)
|
||||
VALUES (?, ?, ?, ?, ?)',
|
||||
[
|
||||
$assetId,
|
||||
(int)$row['jahr'],
|
||||
round((float)$row['abschreibungsbetrag'], 2),
|
||||
round((float)$row['restwert'], 2),
|
||||
(int)$row['position'],
|
||||
]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
public static function deleteByAsset(int $assetId): void
|
||||
{
|
||||
Database::execute(
|
||||
'DELETE FROM asset_depreciation_schedule WHERE asset_id = ?',
|
||||
[$assetId]
|
||||
);
|
||||
}
|
||||
|
||||
public static function generateAndSave(array $asset): void
|
||||
{
|
||||
$id = (int)($asset['id'] ?? 0);
|
||||
if ($id <= 0) {
|
||||
return;
|
||||
}
|
||||
$rows = self::calculate($asset);
|
||||
self::saveForAsset($id, $rows);
|
||||
}
|
||||
}
|
||||
|
|
@ -14,9 +14,15 @@ class Beleg
|
|||
$params = [];
|
||||
|
||||
if (!empty($filter['search'])) {
|
||||
$where[] = '(b.beleg_nr LIKE ? OR b.filename LIKE ? OR b.rechnungs_nr LIKE ? OR b.notes LIKE ?)';
|
||||
$where[] = '(b.beleg_nr LIKE ? OR b.filename LIKE ? OR b.rechnungs_nr LIKE ? OR b.notes LIKE ?
|
||||
OR CAST(b.beleg_datum AS CHAR) LIKE ?
|
||||
OR s.name LIKE ?
|
||||
OR b.bewirtungsgrund LIKE ? OR b.teilnehmerliste LIKE ?
|
||||
OR (SELECT CAST(SUM(bk2s.betrag_brutto) AS CHAR) FROM bookings bk2s
|
||||
WHERE bk2s.beleg_id = b.id AND bk2s.is_reversal = 0) LIKE ?)';
|
||||
$s = '%' . $filter['search'] . '%';
|
||||
$params = array_merge($params, [$s, $s, $s, $s]);
|
||||
$sBrutto = '%' . str_replace(',', '.', $filter['search']) . '%';
|
||||
$params = array_merge($params, [$s, $s, $s, $s, $s, $s, $s, $s, $sBrutto]);
|
||||
}
|
||||
if (!empty($filter['year'])) {
|
||||
$where[] = '(YEAR(b.beleg_datum) = ? OR b.beleg_datum IS NULL)';
|
||||
|
|
@ -59,7 +65,7 @@ class Beleg
|
|||
$rows = Database::fetchAll($sql, $params);
|
||||
|
||||
// Count total
|
||||
$countSql = 'SELECT COUNT(*) as cnt FROM belege b WHERE ' . implode(' AND ', $where);
|
||||
$countSql = 'SELECT COUNT(*) as cnt FROM belege b LEFT JOIN suppliers s ON b.supplier_id = s.id WHERE ' . implode(' AND ', $where);
|
||||
$countParams = array_slice($params, 0, -2);
|
||||
$total = (int)(Database::fetchOne($countSql, $countParams)['cnt'] ?? 0);
|
||||
|
||||
|
|
|
|||
|
|
@ -251,7 +251,7 @@ class Booking
|
|||
self::create([
|
||||
'beleg_id' => $belegId,
|
||||
'buchungs_datum' => $buchungsDatum,
|
||||
'buchungstyp' => 'Ausgabe',
|
||||
'buchungstyp' => 'Einnahme',
|
||||
'kategorie_konto_id' => $kategorieId,
|
||||
'zahlungskonto_id' => $zahlungsId,
|
||||
'betrag_netto' => $betragNetto,
|
||||
|
|
@ -264,7 +264,7 @@ class Booking
|
|||
self::create([
|
||||
'beleg_id' => $belegId,
|
||||
'buchungs_datum' => $buchungsDatum,
|
||||
'buchungstyp' => 'Ausgabe',
|
||||
'buchungstyp' => 'Einnahme',
|
||||
'kategorie_konto_id' => $kategorieId,
|
||||
'zahlungskonto_id' => $zahlungsId,
|
||||
'betrag_netto' => $betragNetto,
|
||||
|
|
|
|||
|
|
@ -108,8 +108,16 @@ class Customer
|
|||
|
||||
public static function generateKundennummer(): string
|
||||
{
|
||||
$result = Database::fetchOne('SELECT MAX(CAST(SUBSTRING(kundennummer, 3) AS UNSIGNED)) as max_nr FROM customers WHERE kundennummer REGEXP \'^KD[0-9]+$\'');
|
||||
$settings = \Steuerkram\Models\Setting::get();
|
||||
$prefix = $settings['customer_nr_prefix'] ?? 'K-';
|
||||
$prefixLen = strlen($prefix) + 1;
|
||||
$escaped = preg_quote($prefix, '/');
|
||||
$result = Database::fetchOne(
|
||||
"SELECT MAX(CAST(SUBSTRING(kundennummer, {$prefixLen}) AS UNSIGNED)) as max_nr
|
||||
FROM customers WHERE kundennummer REGEXP ?",
|
||||
['^' . $escaped . '[0-9]+$']
|
||||
);
|
||||
$next = (int)($result['max_nr'] ?? 0) + 1;
|
||||
return sprintf('KD%04d', $next);
|
||||
return $prefix . sprintf('%04d', $next);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -78,14 +78,13 @@ class Invoice
|
|||
$faellig = date('Y-m-d', strtotime($datum . " +{$payDays} days"));
|
||||
|
||||
Database::execute(
|
||||
'INSERT INTO invoices (customer_id, betreff, leistungszeitraum_von, leistungszeitraum_bis,
|
||||
'INSERT INTO invoices (customer_id, betreff, leistungszeitraum,
|
||||
rechnungs_datum, faellig_datum, status, notes)
|
||||
VALUES (?, ?, ?, ?, ?, ?, \'Entwurf\', ?)',
|
||||
VALUES (?, ?, ?, ?, ?, \'Entwurf\', ?)',
|
||||
[
|
||||
(int)$data['customer_id'],
|
||||
$data['betreff'],
|
||||
$data['leistungszeitraum_von'] ?: null,
|
||||
$data['leistungszeitraum_bis'] ?: null,
|
||||
($data['leistungszeitraum'] ?? '') !== '' ? $data['leistungszeitraum'] : null,
|
||||
$datum,
|
||||
$faellig,
|
||||
$data['notes'] ?? null,
|
||||
|
|
@ -116,14 +115,13 @@ class Invoice
|
|||
$faellig = date('Y-m-d', strtotime($datum . " +{$payDays} days"));
|
||||
|
||||
Database::execute(
|
||||
'UPDATE invoices SET customer_id=?, betreff=?, leistungszeitraum_von=?, leistungszeitraum_bis=?,
|
||||
'UPDATE invoices SET customer_id=?, betreff=?, leistungszeitraum=?,
|
||||
rechnungs_datum=?, faellig_datum=?, notes=?, updated_at=NOW()
|
||||
WHERE id=? AND status=\'Entwurf\'',
|
||||
[
|
||||
(int)$data['customer_id'],
|
||||
$data['betreff'],
|
||||
$data['leistungszeitraum_von'] ?: null,
|
||||
$data['leistungszeitraum_bis'] ?: null,
|
||||
($data['leistungszeitraum'] ?? '') !== '' ? $data['leistungszeitraum'] : null,
|
||||
$datum,
|
||||
$faellig,
|
||||
$data['notes'] ?? null,
|
||||
|
|
@ -232,6 +230,22 @@ class Invoice
|
|||
);
|
||||
}
|
||||
|
||||
public static function delete(int $id): void
|
||||
{
|
||||
$invoice = self::find($id);
|
||||
if (!$invoice) return;
|
||||
|
||||
$belegId = !empty($invoice['beleg_id']) ? (int)$invoice['beleg_id'] : null;
|
||||
|
||||
Database::execute('DELETE FROM invoice_lines WHERE invoice_id = ?', [$id]);
|
||||
Database::execute('DELETE FROM invoices WHERE id = ?', [$id]);
|
||||
|
||||
if ($belegId !== null) {
|
||||
Database::execute('DELETE FROM bookings WHERE beleg_id = ?', [$belegId]);
|
||||
Database::execute('DELETE FROM belege WHERE id = ?', [$belegId]);
|
||||
}
|
||||
}
|
||||
|
||||
public static function generateRechnungsNr(): string
|
||||
{
|
||||
$settings = Database::fetchOne(
|
||||
|
|
|
|||
|
|
@ -32,7 +32,7 @@ class Setting
|
|||
ust_id=?, steuernummer=?, finanzamt=?,
|
||||
iban=?, bic=?, bank_name=?,
|
||||
ustva_steuernummer=?, ustva_bundesland=?,
|
||||
invoice_nr_prefix=?, beleg_nr_prefix=?, anlage_nr_prefix=?,
|
||||
invoice_nr_prefix=?, beleg_nr_prefix=?, anlage_nr_prefix=?, customer_nr_prefix=?,
|
||||
payment_days_default=?, test_mode=?, ki_vorschlag_aktiv=?,
|
||||
nextcloud_url=?, nextcloud_user=?, nextcloud_pass=?,
|
||||
nextcloud_folder=?, nextcloud_done=?,
|
||||
|
|
@ -59,6 +59,7 @@ class Setting
|
|||
$data['invoice_nr_prefix'] ?? 'AR',
|
||||
$data['beleg_nr_prefix'] ?? 'BLG',
|
||||
$data['anlage_nr_prefix'] ?? 'ANL',
|
||||
$data['customer_nr_prefix'] ?? 'K-',
|
||||
(int)($data['payment_days_default'] ?? 14),
|
||||
isset($data['test_mode']) ? 1 : 0,
|
||||
isset($data['ki_vorschlag_aktiv']) ? 1 : 0,
|
||||
|
|
|
|||
|
|
@ -93,6 +93,17 @@ class LlmService
|
|||
return $data['message']['content'] ?? '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Prüft ohne Timeout-Kosten, ob der Provider überhaupt erreichbar ist.
|
||||
* Nur für Diagnosezwecke (Einstellungsseite) – nicht im normalen Request-Pfad nutzen.
|
||||
*/
|
||||
public function isReachable(): bool
|
||||
{
|
||||
if ($this->mode === null) return false;
|
||||
if ($this->mode === 'ollama') return $this->probeOllama($this->url);
|
||||
return true; // OpenAI-Modus: kein schneller Probe verfügbar
|
||||
}
|
||||
|
||||
private function detect(): void
|
||||
{
|
||||
$ollamaUrl = getenv('OLLAMA_URL') ?: ($_ENV['OLLAMA_URL'] ?? '');
|
||||
|
|
@ -147,7 +158,8 @@ class LlmService
|
|||
'stream' => false,
|
||||
];
|
||||
|
||||
$ch = curl_init($this->url . '/api/generate');
|
||||
$endpoint = $this->url . '/api/generate';
|
||||
$ch = curl_init($endpoint);
|
||||
curl_setopt_array($ch, [
|
||||
CURLOPT_POST => true,
|
||||
CURLOPT_RETURNTRANSFER => true,
|
||||
|
|
@ -157,9 +169,17 @@ class LlmService
|
|||
]);
|
||||
$response = curl_exec($ch);
|
||||
$err = curl_error($ch);
|
||||
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||
curl_close($ch);
|
||||
|
||||
if ($err || !$response) return '';
|
||||
if ($err) {
|
||||
error_log("[Steuerkram] LlmService::callOllama() cURL-Fehler auf {$endpoint}: {$err}");
|
||||
return '';
|
||||
}
|
||||
if (!$response) {
|
||||
error_log("[Steuerkram] LlmService::callOllama() leere Antwort von {$endpoint} (HTTP {$httpCode})");
|
||||
return '';
|
||||
}
|
||||
return json_decode($response, true)['response'] ?? '';
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -68,14 +68,7 @@ class PdfInvoiceService
|
|||
$logoWrapStyle = 'text-align:center';
|
||||
}
|
||||
|
||||
// Leistungszeitraum
|
||||
$lz = '';
|
||||
if (!empty($inv['leistungszeitraum_von'])) {
|
||||
$lz = $fmtDate($inv['leistungszeitraum_von']);
|
||||
if (!empty($inv['leistungszeitraum_bis']) && $inv['leistungszeitraum_bis'] !== $inv['leistungszeitraum_von']) {
|
||||
$lz .= ' – ' . $fmtDate($inv['leistungszeitraum_bis']);
|
||||
}
|
||||
}
|
||||
$lz = $inv['leistungszeitraum'] ?? '';
|
||||
|
||||
// Fälligkeitsdatum
|
||||
$fälligText = !empty($inv['faellig_datum'])
|
||||
|
|
|
|||
|
|
@ -1,6 +1,17 @@
|
|||
<?php use Steuerkram\View; $h = fn($v) => View::h($v); ?>
|
||||
<?php
|
||||
use Steuerkram\View;
|
||||
$h = fn($v) => View::h($v);
|
||||
|
||||
<div class="card" style="max-width:600px">
|
||||
$assetId = (int)($asset['id'] ?? 0);
|
||||
$isEdit = $assetId > 0;
|
||||
$nutzung = isset($asset['nutzungsdauer']) && $asset['nutzungsdauer'] !== '' ? (int)$asset['nutzungsdauer'] : null;
|
||||
$predefined = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 12, 13, 15, 20, 25, 50];
|
||||
$isCustom = $nutzung !== null && !in_array($nutzung, $predefined, true);
|
||||
$centFlag = !empty($asset['cent_im_ersten_jahr']);
|
||||
$autoFlag = isset($asset['automatisch_berechnet']) ? (bool)(int)$asset['automatisch_berechnet'] : true;
|
||||
?>
|
||||
|
||||
<div class="card" style="max-width:620px">
|
||||
<div class="card-body">
|
||||
<form method="post" action="<?= $h($action) ?>">
|
||||
|
||||
|
|
@ -65,18 +76,55 @@
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<div style="display:grid;grid-template-columns:1fr 1fr;gap:1rem">
|
||||
<div class="form-group">
|
||||
<label for="abschreibung_jahr">Abschreibung lfd. Jahr (€)</label>
|
||||
<input type="number" id="abschreibung_jahr" name="abschreibung_jahr"
|
||||
value="<?= $h($asset['abschreibung_jahr'] ?? '0') ?>"
|
||||
min="0" step="0.01">
|
||||
<div class="form-group">
|
||||
<label for="abschreibung_jahr">Abschreibung lfd. Jahr (€)</label>
|
||||
<input type="number" id="abschreibung_jahr" name="abschreibung_jahr"
|
||||
value="<?= $h($asset['abschreibung_jahr'] ?? '0') ?>"
|
||||
min="0" step="0.01">
|
||||
</div>
|
||||
|
||||
<!-- Nutzungsdauer + Abschreibungstabelle -->
|
||||
<div style="display:grid;grid-template-columns:1fr auto;gap:.75rem;align-items:flex-end">
|
||||
<div class="form-group" style="margin-bottom:0">
|
||||
<label for="nutzungsdauer_select">Nutzungsdauer (AfA)</label>
|
||||
<div style="display:flex;gap:.5rem">
|
||||
<select id="nutzungsdauer_select" style="flex:1">
|
||||
<option value="" <?= $nutzung === null ? 'selected' : '' ?>>– keine –</option>
|
||||
<option value="0" <?= $nutzung === 0 ? 'selected' : '' ?>>sofort</option>
|
||||
<?php foreach ([1,2,3,4,5,6,7,8,9,10,12,13,15,20,25,50] as $j): ?>
|
||||
<option value="<?= $j ?>" <?= $nutzung === $j ? 'selected' : '' ?>>
|
||||
<?= $j ?> <?= $j === 1 ? 'Jahr' : 'Jahre' ?>
|
||||
</option>
|
||||
<?php endforeach; ?>
|
||||
<option value="custom" <?= $isCustom ? 'selected' : '' ?>>Individuell …</option>
|
||||
</select>
|
||||
<input type="number" id="nutzungsdauer_custom" name="nutzungsdauer"
|
||||
value="<?= $h($nutzung !== null ? $nutzung : '') ?>"
|
||||
min="1" step="1" placeholder="Jahre"
|
||||
style="width:90px;<?= !$isCustom ? 'display:none' : '' ?>">
|
||||
<input type="hidden" id="nutzungsdauer_hidden" name="nutzungsdauer"
|
||||
value="<?= $h($nutzung !== null ? $nutzung : '') ?>"
|
||||
<?= $isCustom ? 'disabled' : '' ?>>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="nutzungsdauer">Nutzungsdauer (Jahre)</label>
|
||||
<input type="number" id="nutzungsdauer" name="nutzungsdauer"
|
||||
value="<?= $h($asset['nutzungsdauer'] ?? '') ?>"
|
||||
min="1" step="1" placeholder="optional">
|
||||
<div>
|
||||
<?php if ($isEdit): ?>
|
||||
<button type="button" class="btn btn-secondary" data-modal-open="modal-afa"
|
||||
style="white-space:nowrap" id="btn-show-afa">
|
||||
AfA-Tabelle
|
||||
</button>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group" style="margin-top:.75rem">
|
||||
<label style="display:flex;align-items:center;gap:.5rem;cursor:pointer;font-weight:normal">
|
||||
<input type="checkbox" name="cent_im_ersten_jahr" id="cent_im_ersten_jahr" value="1"
|
||||
<?= $centFlag ? 'checked' : '' ?>>
|
||||
Centbeträge im ersten Jahr abschreiben
|
||||
</label>
|
||||
<div class="text-muted text-sm" style="margin-top:.25rem;margin-left:1.5rem">
|
||||
Wenn aktiv: Restwert nach Jahr 1 auf volle Euro gerundet; Folgejahre gleichmäßig ohne Centabweichungen.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
@ -85,6 +133,10 @@
|
|||
<textarea id="notizen" name="notizen" rows="3"><?= $h($asset['notizen'] ?? '') ?></textarea>
|
||||
</div>
|
||||
|
||||
<!-- Automatisch-Flag (versteckt, wird per JS aktualisiert) -->
|
||||
<input type="hidden" name="automatisch_berechnet" id="automatisch_berechnet_input"
|
||||
value="<?= $autoFlag ? '1' : '0' ?>">
|
||||
|
||||
<div style="display:flex;gap:.75rem;margin-top:1rem">
|
||||
<button type="submit" class="btn btn-primary">Speichern</button>
|
||||
<a href="/assets" class="btn btn-secondary">Abbrechen</a>
|
||||
|
|
@ -92,3 +144,286 @@
|
|||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<?php if ($isEdit): ?>
|
||||
<!-- ── 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-title">Abschreibungstabelle</div>
|
||||
|
||||
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:.75rem;flex-wrap:wrap;gap:.5rem">
|
||||
<div style="display:flex;align-items:center;gap:.75rem">
|
||||
<span class="text-sm text-muted" id="afa-mode-label">Modus:</span>
|
||||
<label style="display:flex;align-items:center;gap:.4rem;cursor:pointer;font-size:.875rem;font-weight:normal">
|
||||
<input type="checkbox" id="afa-manual-toggle">
|
||||
Manuell bearbeiten
|
||||
</label>
|
||||
</div>
|
||||
<button type="button" class="btn btn-secondary btn-sm" id="afa-recalc-btn"
|
||||
title="Tabelle neu berechnen (überschreibt manuelle Änderungen)">
|
||||
Neu berechnen
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div id="afa-warning" class="flash flash-warning text-sm" style="display:none;margin-bottom:.75rem">
|
||||
Achtung: Die Abschreibungstabelle wurde manuell bearbeitet. Neu berechnen überschreibt alle manuellen Änderungen.
|
||||
</div>
|
||||
|
||||
<div id="afa-table-wrap">
|
||||
<table style="width:100%;border-collapse:collapse">
|
||||
<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>
|
||||
</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">
|
||||
Noch keine Abschreibungstabelle vorhanden. Klicke „Neu berechnen".
|
||||
</td>
|
||||
</tr>
|
||||
<?php else: ?>
|
||||
<?php foreach ($scheduleRows as $row): ?>
|
||||
<tr>
|
||||
<td style="padding:.4rem .75rem;border-bottom:1px solid var(--color-border)">
|
||||
<?= $h($row['jahr']) ?>
|
||||
</td>
|
||||
<td class="td-num afa-betrag" style="padding:.4rem .75rem;border-bottom:1px solid var(--color-border)">
|
||||
<?= number_format((float)$row['abschreibungsbetrag'], 2, ',', '.') ?>
|
||||
</td>
|
||||
<td class="td-num afa-restwert" style="padding:.4rem .75rem;border-bottom:1px solid var(--color-border)">
|
||||
<?= number_format((float)$row['restwert'], 2, ',', '.') ?>
|
||||
</td>
|
||||
</tr>
|
||||
<?php endforeach; ?>
|
||||
<?php endif; ?>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div class="modal-actions">
|
||||
<button type="button" class="btn btn-secondary" data-modal-close>Schließen</button>
|
||||
<button type="button" class="btn btn-primary" id="afa-save-btn">Speichern</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
|
||||
<script>
|
||||
(function() {
|
||||
// ── Nutzungsdauer Dropdown ──────────────────────────────
|
||||
const sel = document.getElementById('nutzungsdauer_select');
|
||||
const custom = document.getElementById('nutzungsdauer_custom');
|
||||
const hidden = document.getElementById('nutzungsdauer_hidden');
|
||||
|
||||
if (sel) {
|
||||
sel.addEventListener('change', () => {
|
||||
const val = sel.value;
|
||||
if (val === 'custom') {
|
||||
custom.style.display = '';
|
||||
custom.removeAttribute('disabled');
|
||||
hidden.setAttribute('disabled', '');
|
||||
custom.focus();
|
||||
} else {
|
||||
custom.style.display = 'none';
|
||||
custom.setAttribute('disabled', '');
|
||||
hidden.removeAttribute('disabled');
|
||||
hidden.value = val;
|
||||
}
|
||||
// Automatisch-Flag zurücksetzen bei Parameteränderung
|
||||
scheduleMarkAuto();
|
||||
});
|
||||
|
||||
custom.addEventListener('input', () => {
|
||||
scheduleMarkAuto();
|
||||
});
|
||||
}
|
||||
|
||||
function getNutzungsdauer() {
|
||||
if (!sel) return null;
|
||||
const val = sel.value;
|
||||
if (val === 'custom') return parseInt(custom.value, 10) || null;
|
||||
if (val === '') return null;
|
||||
return parseInt(val, 10);
|
||||
}
|
||||
|
||||
function scheduleMarkAuto() {
|
||||
// Wenn Tabelle war manuell und Parameter ändert sich: Rückfrage
|
||||
const autoInput = document.getElementById('automatisch_berechnet_input');
|
||||
if (autoInput && autoInput.value === '0') {
|
||||
const confirm = window.confirm(
|
||||
'Manuell geänderte Werte vorhanden. Sollen diese überschrieben werden?\n\n' +
|
||||
'OK = Neu berechnen, Abbrechen = Manuelle Werte behalten'
|
||||
);
|
||||
if (confirm) {
|
||||
autoInput.value = '1';
|
||||
updateAfaWarning(true);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── AfA-Tabellen-Modal ──────────────────────────────────
|
||||
const manualToggle = document.getElementById('afa-manual-toggle');
|
||||
const recalcBtn = document.getElementById('afa-recalc-btn');
|
||||
const saveBtn = document.getElementById('afa-save-btn');
|
||||
const tbody = document.getElementById('afa-table-body');
|
||||
const autoInput = document.getElementById('automatisch_berechnet_input');
|
||||
const assetId = <?= $assetId ?>;
|
||||
|
||||
// Spiegele aktuellen Auto-Status in Modal-Toggle
|
||||
function syncToggle() {
|
||||
if (!manualToggle || !autoInput) return;
|
||||
manualToggle.checked = autoInput.value === '0';
|
||||
updateAfaWarning(autoInput.value === '0');
|
||||
}
|
||||
|
||||
function updateAfaWarning(isManual) {
|
||||
const warn = document.getElementById('afa-warning');
|
||||
if (warn) warn.style.display = isManual ? '' : 'none';
|
||||
if (recalcBtn) recalcBtn.style.display = isManual ? '' : 'none';
|
||||
}
|
||||
|
||||
// Modal öffnen → Toggle syncen
|
||||
document.addEventListener('click', (e) => {
|
||||
if (e.target.closest('[data-modal-open="modal-afa"]')) {
|
||||
syncToggle();
|
||||
setTableEditable(manualToggle?.checked ?? false);
|
||||
}
|
||||
});
|
||||
|
||||
if (manualToggle) {
|
||||
manualToggle.addEventListener('change', () => {
|
||||
const isManual = manualToggle.checked;
|
||||
if (autoInput) autoInput.value = isManual ? '0' : '1';
|
||||
updateAfaWarning(isManual);
|
||||
setTableEditable(isManual);
|
||||
});
|
||||
}
|
||||
|
||||
function setTableEditable(editable) {
|
||||
if (!tbody) return;
|
||||
tbody.querySelectorAll('tr').forEach(tr => {
|
||||
tr.querySelectorAll('.afa-betrag, .afa-restwert').forEach(cell => {
|
||||
cell.contentEditable = editable ? 'true' : 'false';
|
||||
cell.style.background = editable ? 'var(--color-input-bg, #fff)' : '';
|
||||
cell.style.outline = editable ? '1px solid var(--color-border)' : '';
|
||||
cell.style.borderRadius = editable ? '3px' : '';
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function fmtNum(v) {
|
||||
return parseFloat(String(v).replace(',', '.')).toLocaleString('de-DE', {
|
||||
minimumFractionDigits: 2, maximumFractionDigits: 2
|
||||
});
|
||||
}
|
||||
|
||||
function parseNum(s) {
|
||||
return parseFloat(String(s).replace(/\./g, '').replace(',', '.')) || 0;
|
||||
}
|
||||
|
||||
// Neu berechnen
|
||||
if (recalcBtn) {
|
||||
recalcBtn.addEventListener('click', async () => {
|
||||
if (!window.confirm('Abschreibungstabelle neu berechnen? Manuelle Änderungen gehen verloren.')) return;
|
||||
await loadPreview();
|
||||
if (autoInput) autoInput.value = '1';
|
||||
if (manualToggle) manualToggle.checked = false;
|
||||
updateAfaWarning(false);
|
||||
setTableEditable(false);
|
||||
});
|
||||
}
|
||||
|
||||
async function loadPreview() {
|
||||
const aks = document.getElementById('anschaffungskosten')?.value ?? '0';
|
||||
const zug = document.getElementById('zugaenge')?.value ?? '0';
|
||||
const abg = document.getElementById('abgaenge')?.value ?? '0';
|
||||
const dat = document.getElementById('anschaffungsdatum')?.value ?? '';
|
||||
const nd = getNutzungsdauer();
|
||||
const cent = document.getElementById('cent_im_ersten_jahr')?.checked ? '1' : '0';
|
||||
|
||||
if (nd === null) {
|
||||
if (tbody) tbody.innerHTML = '<tr><td colspan="3" class="text-center text-muted" style="padding:1.5rem">Keine Nutzungsdauer angegeben.</td></tr>';
|
||||
return;
|
||||
}
|
||||
|
||||
const qs = new URLSearchParams({
|
||||
anschaffungskosten: aks, zugaenge: zug, abgaenge: abg,
|
||||
nutzungsdauer: nd, cent_im_ersten_jahr: cent,
|
||||
anschaffungsdatum: dat
|
||||
});
|
||||
|
||||
try {
|
||||
const res = await fetch('/assets/depreciation-preview?' + qs);
|
||||
const data = await res.json();
|
||||
renderTable(data.rows ?? []);
|
||||
} catch (_) {
|
||||
if (tbody) tbody.innerHTML = '<tr><td colspan="3" class="text-center text-muted" style="padding:1rem">Fehler beim Laden.</td></tr>';
|
||||
}
|
||||
}
|
||||
|
||||
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>';
|
||||
return;
|
||||
}
|
||||
const isManual = manualToggle?.checked ?? false;
|
||||
tbody.innerHTML = rows.map(r => `
|
||||
<tr>
|
||||
<td style="padding:.4rem .75rem;border-bottom:1px solid var(--color-border)">${r.jahr}</td>
|
||||
<td class="td-num afa-betrag" contenteditable="${isManual}"
|
||||
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.abschreibungsbetrag)}
|
||||
</td>
|
||||
<td class="td-num afa-restwert" contenteditable="${isManual}"
|
||||
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>
|
||||
</tr>`).join('');
|
||||
}
|
||||
|
||||
// Speichern
|
||||
if (saveBtn) {
|
||||
saveBtn.addEventListener('click', async () => {
|
||||
const rows = [];
|
||||
if (tbody) {
|
||||
tbody.querySelectorAll('tr').forEach((tr, i) => {
|
||||
const cells = tr.querySelectorAll('td');
|
||||
if (cells.length < 3) return;
|
||||
rows.push({
|
||||
jahr: parseInt(cells[0].textContent.trim(), 10),
|
||||
abschreibungsbetrag: cells[1].textContent.trim(),
|
||||
restwert: cells[2].textContent.trim(),
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
const automatisch = !(manualToggle?.checked ?? false) ? 1 : 0;
|
||||
|
||||
try {
|
||||
const res = await fetch(`/assets/${assetId}/depreciation`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ rows, automatisch }),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (data.ok) {
|
||||
if (autoInput) autoInput.value = automatisch ? '1' : '0';
|
||||
document.getElementById('modal-afa')?.classList.remove('open');
|
||||
} else {
|
||||
alert('Fehler beim Speichern: ' + (data.error ?? 'Unbekannt'));
|
||||
}
|
||||
} catch (_) {
|
||||
alert('Netzwerkfehler beim Speichern.');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Init: Neu-Berechnen-Button nur bei manueller Tabelle zeigen
|
||||
syncToggle();
|
||||
})();
|
||||
</script>
|
||||
|
|
|
|||
|
|
@ -973,7 +973,7 @@ async function _startKiAnalyse() {
|
|||
document.addEventListener('DOMContentLoaded', function () {
|
||||
const arr = _normalizeKiArr(_kiVorschlag);
|
||||
if (arr === null) {
|
||||
// Noch nicht verarbeitet – nichts anzeigen
|
||||
if (_kiAktiv) { _startKiAnalyse(); }
|
||||
} else if (arr.length === 0) {
|
||||
// Verarbeitet, aber kein Ergebnis
|
||||
_showKiBadgeNoResult();
|
||||
|
|
|
|||
|
|
@ -92,7 +92,7 @@
|
|||
<div class="toolbar">
|
||||
<div class="toolbar-left">
|
||||
<form method="get" style="display:flex;gap:.5rem;flex-wrap:wrap;align-items:center">
|
||||
<input type="search" name="search" placeholder="Belegnr., Dateiname, Rechnungsnr.…"
|
||||
<input type="search" name="search" placeholder="Belegnr., Lieferant, Datum, Rechnungsnr., Betrag…"
|
||||
value="<?= $h($filter['search'] ?? '') ?>" style="width:240px">
|
||||
<select name="year" style="width:80px">
|
||||
<option value="">Alle Jahre</option>
|
||||
|
|
|
|||
|
|
@ -37,21 +37,16 @@ $preCustomer = (int)($_GET['customer_id'] ?? 0);
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-grid form-grid-4 mb-2">
|
||||
<div class="form-grid form-grid-3 mb-2">
|
||||
<div class="form-group">
|
||||
<label for="rechnungs_datum">Rechnungsdatum *</label>
|
||||
<input type="date" id="rechnungs_datum" name="rechnungs_datum"
|
||||
value="<?= $h($invoice['rechnungs_datum'] ?? date('Y-m-d')) ?>" required>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="leistungszeitraum_von">Leistungszeitraum von</label>
|
||||
<input type="date" id="leistungszeitraum_von" name="leistungszeitraum_von"
|
||||
value="<?= $h($invoice['leistungszeitraum_von'] ?? '') ?>">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="leistungszeitraum_bis">Leistungszeitraum bis</label>
|
||||
<input type="date" id="leistungszeitraum_bis" name="leistungszeitraum_bis"
|
||||
value="<?= $h($invoice['leistungszeitraum_bis'] ?? '') ?>">
|
||||
<label for="leistungszeitraum">Leistungszeitraum</label>
|
||||
<input type="text" id="leistungszeitraum" name="leistungszeitraum"
|
||||
value="<?= $h($invoice['leistungszeitraum'] ?? '') ?>">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="payment_days">Zahlungsziel (Tage)</label>
|
||||
|
|
@ -149,4 +144,10 @@ function updatePaymentDays(sel) {
|
|||
const days = opt.dataset.payment;
|
||||
if (days) document.getElementById('payment_days').value = days;
|
||||
}
|
||||
<?php if (!$isEdit): ?>
|
||||
document.addEventListener('DOMContentLoaded', function () {
|
||||
const sel = document.getElementById('customer_id');
|
||||
if (sel && sel.value) updatePaymentDays(sel);
|
||||
});
|
||||
<?php endif; ?>
|
||||
</script>
|
||||
|
|
|
|||
|
|
@ -64,6 +64,12 @@
|
|||
<?php if ($inv['status'] !== 'Entwurf'): ?>
|
||||
<a href="/invoices/<?= $h($inv['id']) ?>/pdf" class="btn btn-secondary btn-sm">PDF</a>
|
||||
<?php endif; ?>
|
||||
<?php if (!empty($settings['test_mode'])): ?>
|
||||
<form method="post" action="/invoices/<?= $h($inv['id']) ?>/delete" style="display:inline"
|
||||
onsubmit="return confirm('Rechnung wirklich löschen?')">
|
||||
<button type="submit" class="btn btn-danger btn-sm">Löschen</button>
|
||||
</form>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
|
|
|
|||
|
|
@ -7,22 +7,7 @@ $customerName = !empty($invoice['firma'])
|
|||
? $invoice['firma']
|
||||
: trim(($invoice['anrede'] ?? '') . ' ' . ($invoice['vorname'] ?? '') . ' ' . $invoice['nachname']);
|
||||
|
||||
$lz = '';
|
||||
if (!empty($invoice['leistungszeitraum_von'])) {
|
||||
$lz = View::formatDate($invoice['leistungszeitraum_von']);
|
||||
if (!empty($invoice['leistungszeitraum_bis'])) {
|
||||
$lz .= ' – ' . View::formatDate($invoice['leistungszeitraum_bis']);
|
||||
}
|
||||
}
|
||||
|
||||
// Leistungszeitraum mit <br> für die Vorschau-Tabelle (Datumsangaben untereinander)
|
||||
$lzHtml = '';
|
||||
if (!empty($invoice['leistungszeitraum_von'])) {
|
||||
$lzHtml = $h(View::formatDate($invoice['leistungszeitraum_von']));
|
||||
if (!empty($invoice['leistungszeitraum_bis']) && $invoice['leistungszeitraum_bis'] !== $invoice['leistungszeitraum_von']) {
|
||||
$lzHtml .= '<br>' . $h(View::formatDate($invoice['leistungszeitraum_bis']));
|
||||
}
|
||||
}
|
||||
$lzHtml = $h($invoice['leistungszeitraum'] ?? '');
|
||||
|
||||
$badgeClass = match($invoice['status']) {
|
||||
'Bezahlt' => 'badge-green',
|
||||
|
|
|
|||
|
|
@ -140,14 +140,22 @@
|
|||
Konfiguration via <code>.env</code> (lokales Ollama oder Remote-API).
|
||||
</p>
|
||||
<?php
|
||||
$llm = new \Steuerkram\Services\LlmService();
|
||||
$llm = new \Steuerkram\Services\LlmService();
|
||||
$providerLabel = $llm->getProviderLabel();
|
||||
$reachable = $providerLabel !== null ? $llm->isReachable() : false;
|
||||
$ollamaUrlEnv = getenv('OLLAMA_URL') ?: ($_ENV['OLLAMA_URL'] ?? '');
|
||||
?>
|
||||
<p class="text-sm" style="margin:0 0 0 1.75rem;color:<?= $providerLabel ? 'var(--color-success,#16a34a)' : 'var(--color-muted,#6b7280)' ?>">
|
||||
<?php if ($providerLabel): ?>
|
||||
✓ Provider erkannt: <?= $h($providerLabel) ?>
|
||||
<p class="text-sm" style="margin:0 0 0 1.75rem;color:<?= $reachable ? 'var(--color-success,#16a34a)' : '#dc2626' ?>">
|
||||
<?php if ($reachable): ?>
|
||||
✓ Provider erreichbar: <?= $h($providerLabel) ?>
|
||||
<?php elseif ($providerLabel): ?>
|
||||
✗ Provider konfiguriert (<?= $h($providerLabel) ?>), aber Server nicht erreichbar.
|
||||
Prüfe ob Ollama auf dem Host läuft und auf allen Interfaces hört (<code>OLLAMA_HOST=0.0.0.0:11434</code>).
|
||||
Details im PHP-Fehlerlog des Webservers (<code>docker compose logs web</code>).
|
||||
<?php elseif ($ollamaUrlEnv !== ''): ?>
|
||||
✗ OLLAMA_URL gesetzt (<?= $h(rtrim($ollamaUrlEnv, '/')) ?>), aber kein Provider erkannt.
|
||||
<?php else: ?>
|
||||
— Kein LLM-Provider konfiguriert oder erreichbar (Auto-Discovery localhost:11434 fehlgeschlagen)
|
||||
— Kein LLM-Provider konfiguriert (Auto-Discovery localhost:11434 fehlgeschlagen)
|
||||
<?php endif; ?>
|
||||
</p>
|
||||
</div>
|
||||
|
|
@ -236,6 +244,10 @@
|
|||
<label>Anlagen-Präfix</label>
|
||||
<input type="text" name="anlage_nr_prefix" value="<?= $h($settings['anlage_nr_prefix'] ?? 'ANL') ?>" maxlength="10">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Kunden-Präfix</label>
|
||||
<input type="text" name="customer_nr_prefix" value="<?= $h($settings['customer_nr_prefix'] ?? 'K-') ?>" maxlength="10">
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Standard-Zahlungsziel (Tage)</label>
|
||||
|
|
|
|||
|
|
@ -1 +1 @@
|
|||
1-260425
|
||||
1-260426
|
||||
|
|
|
|||
|
|
@ -26,6 +26,17 @@ class StornoBuchungTest extends TestCase
|
|||
return;
|
||||
}
|
||||
|
||||
// ── EÜR: Konto-Stand VOR den Test-Buchungen ermitteln ─────────
|
||||
$euerCalcPre = new \Steuerkram\Services\EuerCalculator();
|
||||
$euerPre = $euerCalcPre->calculate($year);
|
||||
$kontoNettoPre = 0.0;
|
||||
foreach ($euerPre['ausgaben'] as $row) {
|
||||
if ((int)$row['id'] === (int)$ausgabeId) {
|
||||
$kontoNettoPre = (float)$row['netto'];
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Beleg anlegen ──────────────────────────────────────────────
|
||||
$belegId = \Steuerkram\Models\Beleg::create([
|
||||
'beleg_nr' => 'STORNO-TEST-' . uniqid(),
|
||||
|
|
@ -63,23 +74,23 @@ class StornoBuchungTest extends TestCase
|
|||
$this->assertEquals(-19.00, round((float)$reversal['betrag_ust'], 2), 'Stornobuchung: betrag_ust = -19');
|
||||
$this->assertEquals(-119.00, round((float)$reversal['betrag_brutto'], 2), 'Stornobuchung: betrag_brutto = -119');
|
||||
|
||||
// ── EÜR: storniertes Konto hat Netto-Summe = 0 ───────────────
|
||||
$euerCalc = new \Steuerkram\Services\EuerCalculator();
|
||||
$euer = $euerCalc->calculate($year);
|
||||
|
||||
$kontoNetto = null;
|
||||
// ── EÜR: Storno + Original heben sich auf (Konto-Saldo unverändert) ─
|
||||
// Vergleich mit dem Stand vor den Test-Buchungen, da auf dem Produktivsystem
|
||||
// bereits echte Buchungen auf dem Konto existieren können.
|
||||
$euerCalc = new \Steuerkram\Services\EuerCalculator();
|
||||
$euer = $euerCalc->calculate($year);
|
||||
$kontoNetto = 0.0;
|
||||
foreach ($euer['ausgaben'] as $row) {
|
||||
if ((int)$row['id'] === (int)$ausgabeId) {
|
||||
$kontoNetto = (float)$row['netto'];
|
||||
break;
|
||||
}
|
||||
}
|
||||
// Wenn das Konto nicht mehr auftaucht (alle Buchungen = 0), ist das ebenfalls korrekt.
|
||||
if ($kontoNetto !== null) {
|
||||
$this->assertEquals(0.0, round($kontoNetto, 2), 'EÜR: Storno + Original = 0 auf Ausgabe-Konto');
|
||||
} else {
|
||||
$this->assert(true, 'EÜR: Konto taucht bei Summe=0 nicht auf (HAVING buchungsanzahl > 0 greift)');
|
||||
}
|
||||
$this->assertEquals(
|
||||
round($kontoNettoPre, 2),
|
||||
round($kontoNetto, 2),
|
||||
'EÜR: Storno + Original = 0 auf Ausgabe-Konto'
|
||||
);
|
||||
|
||||
// ── UStVA: Vorsteuer-KZ hebt sich auf ─────────────────────────
|
||||
// Kz 61 enthält Vorsteuer aus dem Ausgabe-Konto (ustva_kz='61').
|
||||
|
|
|
|||
110
tests/21_BelegSucheErweitertTest.php
Normal file
110
tests/21_BelegSucheErweitertTest.php
Normal file
|
|
@ -0,0 +1,110 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* Test 21: Belegsuche – erweiterte Felder (Lieferant, Datum, Bewirtungsgrund, Teilnehmer, Brutto).
|
||||
*/
|
||||
class BelegSucheErweitertTest extends TestCase
|
||||
{
|
||||
protected string $testName = 'Test 21: Belegsuche – erweiterte Felder';
|
||||
|
||||
public function run(): void
|
||||
{
|
||||
$bankId = $this->findAccountId('1200');
|
||||
$ausgabeId = $this->findAccountId('4920');
|
||||
|
||||
if (!$bankId || !$ausgabeId) {
|
||||
$this->assert(false, 'Benötigte Konten fehlen – SKR03-Seed vorhanden?');
|
||||
return;
|
||||
}
|
||||
|
||||
$uid = uniqid('suchtest_', true);
|
||||
|
||||
// Lieferant anlegen
|
||||
$supplierName = 'TestLieferant_' . $uid;
|
||||
\Steuerkram\Database::execute(
|
||||
'INSERT INTO suppliers (name) VALUES (?)',
|
||||
[$supplierName]
|
||||
);
|
||||
$supplierId = (int)\Steuerkram\Database::fetchOne('SELECT LAST_INSERT_ID() AS id')['id'];
|
||||
|
||||
// Beleg mit allen Suchfeldern anlegen
|
||||
$belegNr = 'SUCH-' . substr(md5($uid), 0, 8);
|
||||
$belegDatum = '2025-07-15';
|
||||
$bewirtung = 'Kundentermin_' . $uid;
|
||||
$teilnehmer = 'Alice_' . $uid . ', Bob';
|
||||
|
||||
$belegId = \Steuerkram\Models\Beleg::create([
|
||||
'beleg_nr' => $belegNr,
|
||||
'filename' => 'suchtest.pdf',
|
||||
'storage_path' => '',
|
||||
'mime_type' => 'application/pdf',
|
||||
'file_hash' => hash('sha256', $uid),
|
||||
'beleg_datum' => $belegDatum,
|
||||
'belegtyp' => 'Eingangsrechnung',
|
||||
]);
|
||||
|
||||
// Lieferant, Bewirtungsgrund und Teilnehmerliste setzen
|
||||
\Steuerkram\Database::execute(
|
||||
'UPDATE belege SET supplier_id = ?, bewirtungsgrund = ?, teilnehmerliste = ? WHERE id = ?',
|
||||
[$supplierId, $bewirtung, $teilnehmer, $belegId]
|
||||
);
|
||||
|
||||
// Buchung anlegen → betrag_brutto für Brutto-Suche
|
||||
\Steuerkram\Models\Booking::create([
|
||||
'beleg_id' => $belegId,
|
||||
'buchungs_datum' => $belegDatum,
|
||||
'buchungstyp' => 'Ausgabe',
|
||||
'kategorie_konto_id' => $ausgabeId,
|
||||
'zahlungskonto_id' => $bankId,
|
||||
'betrag_netto' => 299.00,
|
||||
'ust_satz' => 19.0,
|
||||
'buchungstext' => 'Suchtest',
|
||||
'is_reversal' => false,
|
||||
]);
|
||||
|
||||
// ── Suche nach Lieferantenname ────────────────────────────────────
|
||||
$res = \Steuerkram\Models\Beleg::all(['search' => $supplierName], 1, 50);
|
||||
$found = $this->containsBeleg($res['rows'], $belegId);
|
||||
$this->assert($found, 'Suche nach Lieferantenname findet den Beleg');
|
||||
|
||||
// ── Suche nach Datum (YYYY-MM-DD) ────────────────────────────────
|
||||
$res = \Steuerkram\Models\Beleg::all(['search' => '2025-07-15'], 1, 50);
|
||||
$found = $this->containsBeleg($res['rows'], $belegId);
|
||||
$this->assert($found, 'Suche nach Belegdatum findet den Beleg');
|
||||
|
||||
// ── Suche nach Bewirtungsgrund ────────────────────────────────────
|
||||
$res = \Steuerkram\Models\Beleg::all(['search' => $bewirtung], 1, 50);
|
||||
$found = $this->containsBeleg($res['rows'], $belegId);
|
||||
$this->assert($found, 'Suche nach Bewirtungsgrund findet den Beleg');
|
||||
|
||||
// ── Suche nach Teilnehmerliste ────────────────────────────────────
|
||||
$res = \Steuerkram\Models\Beleg::all(['search' => 'Alice_' . $uid], 1, 50);
|
||||
$found = $this->containsBeleg($res['rows'], $belegId);
|
||||
$this->assert($found, 'Suche nach Teilnehmer findet den Beleg');
|
||||
|
||||
// ── Suche nach Bruttobetrag ────────────────────────────────────────
|
||||
// 299 * 1.19 = 355.81
|
||||
$res = \Steuerkram\Models\Beleg::all(['search' => '355.81'], 1, 50);
|
||||
$found = $this->containsBeleg($res['rows'], $belegId);
|
||||
$this->assert($found, 'Suche nach Bruttobetrag findet den Beleg (Dezimalpunkt)');
|
||||
|
||||
$res = \Steuerkram\Models\Beleg::all(['search' => '355,81'], 1, 50);
|
||||
$found = $this->containsBeleg($res['rows'], $belegId);
|
||||
$this->assert($found, 'Suche nach Bruttobetrag findet den Beleg (Dezimalkomma)');
|
||||
|
||||
// ── Bestehende Felder funktionieren noch ──────────────────────────
|
||||
$res = \Steuerkram\Models\Beleg::all(['search' => $belegNr], 1, 50);
|
||||
$found = $this->containsBeleg($res['rows'], $belegId);
|
||||
$this->assert($found, 'Suche nach Belegnr. funktioniert weiterhin');
|
||||
}
|
||||
|
||||
private function containsBeleg(array $rows, int $id): bool
|
||||
{
|
||||
foreach ($rows as $row) {
|
||||
if ((int)$row['id'] === $id) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
63
tests/22_RechnungZahlungszielTest.php
Normal file
63
tests/22_RechnungZahlungszielTest.php
Normal file
|
|
@ -0,0 +1,63 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use Steuerkram\Models\Customer;
|
||||
use Steuerkram\Models\Setting;
|
||||
|
||||
class RechnungZahlungszielTest extends TestCase
|
||||
{
|
||||
protected string $testName = 'Test 22: Rechnung – Zahlungsziel aus Kundendatensatz';
|
||||
|
||||
public function run(): void
|
||||
{
|
||||
$customerId = Customer::create([
|
||||
'nachname' => 'ZahlungszielTestKunde',
|
||||
'strasse' => 'Musterstr. 1',
|
||||
'plz' => '12345',
|
||||
'ort' => 'Musterstadt',
|
||||
'land' => 'DE',
|
||||
'payment_days' => 30,
|
||||
]);
|
||||
|
||||
$customer = Customer::find($customerId);
|
||||
$this->assert($customer !== false, 'Kunde wurde angelegt');
|
||||
$this->assertEquals(30, (int)$customer['payment_days'], 'Kundendatensatz enthält payment_days=30');
|
||||
|
||||
// Simuliere Controller-Logik: customer_id per GET → payment_days vorbelegen
|
||||
$preCustomerId = $customerId;
|
||||
$invoiceDefaults = [
|
||||
'rechnungs_datum' => date('Y-m-d'),
|
||||
'leistungszeitraum_von' => date('Y-m-01'),
|
||||
'leistungszeitraum_bis' => date('Y-m-t'),
|
||||
];
|
||||
if ($preCustomerId > 0) {
|
||||
$c = Customer::find($preCustomerId);
|
||||
if ($c) {
|
||||
$invoiceDefaults['customer_id'] = $preCustomerId;
|
||||
$invoiceDefaults['payment_days'] = $c['payment_days'];
|
||||
}
|
||||
}
|
||||
|
||||
$this->assertEquals($customerId, $invoiceDefaults['customer_id'], 'customer_id in Rechnungsdefaults gesetzt');
|
||||
$this->assertEquals(30, (int)$invoiceDefaults['payment_days'], 'payment_days aus Kundendaten vorbelegt');
|
||||
|
||||
// Ohne vorausgewählten Kunden bleibt payment_days ungesetzt
|
||||
$defaultsOhneKunde = [
|
||||
'rechnungs_datum' => date('Y-m-d'),
|
||||
];
|
||||
$this->assert(!isset($defaultsOhneKunde['payment_days']), 'payment_days ohne Kunden nicht gesetzt');
|
||||
|
||||
// Anderer Kunde mit abweichendem Zahlungsziel
|
||||
$customer2Id = Customer::create([
|
||||
'nachname' => 'ZahlungszielTestKunde2',
|
||||
'strasse' => 'Testgasse 2',
|
||||
'plz' => '54321',
|
||||
'ort' => 'Testdorf',
|
||||
'land' => 'DE',
|
||||
'payment_days' => 7,
|
||||
]);
|
||||
$c2 = Customer::find($customer2Id);
|
||||
$this->assertEquals(7, (int)$c2['payment_days'], 'Zweiter Kunde hat payment_days=7');
|
||||
}
|
||||
}
|
||||
44
tests/23_KundenNummernkreisTest.php
Normal file
44
tests/23_KundenNummernkreisTest.php
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use Steuerkram\Models\Customer;
|
||||
use Steuerkram\Models\Setting;
|
||||
use Steuerkram\Database;
|
||||
|
||||
class KundenNummernkreisTest extends TestCase
|
||||
{
|
||||
protected string $testName = 'Test 23: Kunden-Nummernkreis konfigurierbar';
|
||||
|
||||
public function run(): void
|
||||
{
|
||||
$settings = Setting::get();
|
||||
$this->assert(array_key_exists('customer_nr_prefix', $settings), 'customer_nr_prefix-Spalte existiert in company_settings');
|
||||
|
||||
// Präfix in den Einstellungen speichern und lesen
|
||||
$originalPrefix = $settings['customer_nr_prefix'] ?? 'K-';
|
||||
$testPrefix = 'TEST-';
|
||||
Setting::update(array_merge($settings, ['customer_nr_prefix' => $testPrefix]));
|
||||
$updated = Setting::get();
|
||||
$this->assertEquals($testPrefix, $updated['customer_nr_prefix'], 'customer_nr_prefix wird korrekt gespeichert');
|
||||
|
||||
// Kundennummer mit neuem Präfix generieren
|
||||
$nr = Customer::generateKundennummer();
|
||||
$this->assert(str_starts_with($nr, $testPrefix), 'Generierte Kundennummer beginnt mit neuem Präfix');
|
||||
$numericPart = substr($nr, strlen($testPrefix));
|
||||
$this->assert(ctype_digit($numericPart), 'Numerischer Teil der Kundennummer enthält nur Ziffern');
|
||||
$this->assert((int)$numericPart > 0, 'Numerischer Teil > 0');
|
||||
|
||||
// Zurücksetzen auf Standard-Präfix
|
||||
Setting::update(array_merge($updated, ['customer_nr_prefix' => 'K-']));
|
||||
$reset = Setting::get();
|
||||
$this->assertEquals('K-', $reset['customer_nr_prefix'], 'Präfix auf K- zurückgesetzt');
|
||||
|
||||
// Kundennummer mit Default-Präfix K-
|
||||
$nrDefault = Customer::generateKundennummer();
|
||||
$this->assert(str_starts_with($nrDefault, 'K-'), 'Generierte Kundennummer beginnt mit K-');
|
||||
|
||||
// Originalwert wiederherstellen
|
||||
Setting::update(array_merge($reset, ['customer_nr_prefix' => $originalPrefix]));
|
||||
}
|
||||
}
|
||||
56
tests/24_RechnungLoeschenTestmodusTest.php
Normal file
56
tests/24_RechnungLoeschenTestmodusTest.php
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use Steuerkram\Models\Customer;
|
||||
use Steuerkram\Models\Invoice;
|
||||
use Steuerkram\Models\Setting;
|
||||
|
||||
class RechnungLoeschenTestmodusTest extends TestCase
|
||||
{
|
||||
protected string $testName = 'Test 24: Rechnung löschen (nur im Testmodus)';
|
||||
|
||||
public function run(): void
|
||||
{
|
||||
$customerId = Customer::create([
|
||||
'nachname' => 'LoeschTestKunde',
|
||||
'strasse' => 'Teststr. 1',
|
||||
'plz' => '12345',
|
||||
'ort' => 'Teststadt',
|
||||
'land' => 'DE',
|
||||
]);
|
||||
|
||||
$invoiceId = Invoice::create([
|
||||
'customer_id' => $customerId,
|
||||
'betreff' => 'Lösch-Test-Rechnung',
|
||||
'rechnungs_datum' => date('Y-m-d'),
|
||||
'lines' => [
|
||||
[
|
||||
'bezeichnung' => 'Testposition',
|
||||
'menge' => 1,
|
||||
'einheit' => 'Stk.',
|
||||
'einzelpreis' => 100,
|
||||
'ust_satz' => 19,
|
||||
],
|
||||
],
|
||||
]);
|
||||
|
||||
$this->assertGreaterThan(0, $invoiceId, 'Rechnung wurde angelegt');
|
||||
$invoice = Invoice::find($invoiceId);
|
||||
$this->assert($invoice !== false, 'Rechnung per find() auffindbar');
|
||||
|
||||
// Rechnung löschen
|
||||
Invoice::delete($invoiceId);
|
||||
|
||||
$afterDelete = Invoice::find($invoiceId);
|
||||
$this->assert($afterDelete === false, 'Rechnung nach delete() nicht mehr auffindbar');
|
||||
|
||||
// Positionen ebenfalls gelöscht
|
||||
$lines = Invoice::lines($invoiceId);
|
||||
$this->assertEquals(0, count($lines), 'Invoice-Lines nach delete() gelöscht');
|
||||
|
||||
// test_mode-Flag ist bei Neuinstallation gesetzt
|
||||
$settings = Setting::get();
|
||||
$this->assert(isset($settings['test_mode']), 'test_mode-Feld in company_settings vorhanden');
|
||||
}
|
||||
}
|
||||
82
tests/25_AnlageAutoRedirectTest.php
Normal file
82
tests/25_AnlageAutoRedirectTest.php
Normal file
|
|
@ -0,0 +1,82 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use Steuerkram\Models\Asset;
|
||||
use Steuerkram\Models\Beleg;
|
||||
use Steuerkram\Models\Booking;
|
||||
|
||||
class AnlageAutoRedirectTest extends TestCase
|
||||
{
|
||||
protected string $testName = 'Test 25: Neues Anlagegut aus Buchung → asset_id gesetzt';
|
||||
|
||||
public function run(): void
|
||||
{
|
||||
// Anlagevermögens-Konto suchen
|
||||
$anlageKonto = \Steuerkram\Database::fetchOne(
|
||||
"SELECT id FROM accounts WHERE kontenart = 'Anlagevermögen' AND aktiv = 1 LIMIT 1"
|
||||
);
|
||||
$this->assertNotEmpty($anlageKonto, 'Anlagevermögens-Konto vorhanden');
|
||||
|
||||
$bankKonto = \Steuerkram\Database::fetchOne(
|
||||
"SELECT id FROM accounts WHERE kontenart = 'Zahlungskonto' AND aktiv = 1 LIMIT 1"
|
||||
);
|
||||
$this->assertNotEmpty($bankKonto, 'Zahlungskonto vorhanden');
|
||||
|
||||
if (!$anlageKonto || !$bankKonto) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Beleg anlegen
|
||||
$belegNr = 'ANLAGE-TEST-' . uniqid();
|
||||
$datum = date('Y-m-d');
|
||||
$belegId = Beleg::create([
|
||||
'beleg_nr' => $belegNr,
|
||||
'filename' => 'anlage-test.pdf',
|
||||
'storage_path' => 'static/uploads/test/anlage-test.pdf',
|
||||
'mime_type' => 'application/pdf',
|
||||
'file_hash' => hash('sha256', $belegNr),
|
||||
'beleg_datum' => $datum,
|
||||
'rechnungs_nr' => 'RE-ANLAGE-001',
|
||||
'betrag_brutto' => 1190.00,
|
||||
'belegtyp' => 'Eingangsrechnung',
|
||||
]);
|
||||
$this->assertGreaterThan(0, $belegId, 'Beleg angelegt');
|
||||
|
||||
// Buchung auf Anlagevermögens-Konto, kein bestehendes Asset
|
||||
$buchungId = Booking::create([
|
||||
'beleg_id' => $belegId,
|
||||
'buchungs_datum' => $datum,
|
||||
'buchungstyp' => 'Ausgabe',
|
||||
'kategorie_konto_id' => (int)$anlageKonto['id'],
|
||||
'zahlungskonto_id' => (int)$bankKonto['id'],
|
||||
'betrag_netto' => 1000.00,
|
||||
'ust_satz' => 19.0,
|
||||
'buchungstext' => 'Test-Notebook',
|
||||
'asset_id' => null,
|
||||
]);
|
||||
$this->assertGreaterThan(0, $buchungId, 'Buchung auf Anlagekonto angelegt');
|
||||
|
||||
// Neues Asset muss automatisch existieren und mit Buchungstext befüllt sein
|
||||
$buchung = Booking::find($buchungId);
|
||||
$this->assertNotEmpty($buchung, 'Buchung abrufbar');
|
||||
|
||||
// In der Praxis legt BookingController das Asset an und setzt asset_id.
|
||||
// Hier testen wir, dass Asset::create() korrekt einen Datensatz erzeugt,
|
||||
// der die Buchungsdaten spiegelt – so wie BookingController es tut.
|
||||
$assetId = Asset::create([
|
||||
'bezeichnung' => 'Test-Notebook',
|
||||
'account_id' => (int)$anlageKonto['id'],
|
||||
'anschaffungsdatum' => $datum,
|
||||
'anschaffungskosten' => 1000.00,
|
||||
]);
|
||||
$this->assertGreaterThan(0, $assetId, 'Asset wird aus Buchungsdaten angelegt (ID > 0)');
|
||||
|
||||
$asset = Asset::find($assetId);
|
||||
$this->assertNotEmpty($asset, 'Asset abrufbar');
|
||||
$this->assertEquals('Test-Notebook', $asset['bezeichnung'], 'Bezeichnung = Buchungstext');
|
||||
$this->assertEquals($datum, $asset['anschaffungsdatum'], 'Anschaffungsdatum = Buchungsdatum');
|
||||
$this->assertEquals(1000.00, (float)$asset['anschaffungskosten'], 'Anschaffungskosten = Nettobetrag');
|
||||
$this->assertEquals((int)$anlageKonto['id'], (int)$asset['account_id'], 'Account-ID verknüpft');
|
||||
}
|
||||
}
|
||||
121
tests/26_AbschreibungstabelleTest.php
Normal file
121
tests/26_AbschreibungstabelleTest.php
Normal file
|
|
@ -0,0 +1,121 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use Steuerkram\Models\AssetDepreciation;
|
||||
|
||||
class AbschreibungstabelleTest extends TestCase
|
||||
{
|
||||
protected string $testName = 'Test 26: Abschreibungstabelle (lineare AfA)';
|
||||
|
||||
public function run(): void
|
||||
{
|
||||
// ── Sofortabschreibung ───────────────────────────────────
|
||||
$rows = AssetDepreciation::calculate([
|
||||
'anschaffungskosten' => 1000.00,
|
||||
'zugaenge' => 0,
|
||||
'abgaenge' => 0,
|
||||
'nutzungsdauer' => 0,
|
||||
'cent_im_ersten_jahr'=> false,
|
||||
'anschaffungsdatum' => '2025-03-15',
|
||||
]);
|
||||
|
||||
$this->assertEquals(1, count($rows), 'Sofortabschreibung: genau 1 Zeile');
|
||||
$this->assertEquals(2025, $rows[0]['jahr'], 'Sofortabschreibung: Startjahr korrekt');
|
||||
$this->assertEquals(1000.00, $rows[0]['abschreibungsbetrag'], 'Sofortabschreibung: voller Betrag');
|
||||
$this->assertEquals(0.00, $rows[0]['restwert'], 'Sofortabschreibung: Restwert 0');
|
||||
|
||||
// ── Gleichmäßige lineare AfA ohne Cent-Option ────────────
|
||||
$rows = AssetDepreciation::calculate([
|
||||
'anschaffungskosten' => 3000.00,
|
||||
'zugaenge' => 0,
|
||||
'abgaenge' => 0,
|
||||
'nutzungsdauer' => 3,
|
||||
'cent_im_ersten_jahr'=> false,
|
||||
'anschaffungsdatum' => '2023-01-01',
|
||||
]);
|
||||
|
||||
$this->assertEquals(3, count($rows), 'Lineare AfA 3 Jahre: 3 Zeilen');
|
||||
$this->assertEquals(1000.00, $rows[0]['abschreibungsbetrag'], 'Jahr 1: 1000,00');
|
||||
$this->assertEquals(2000.00, $rows[0]['restwert'], 'Jahr 1 Restwert: 2000,00');
|
||||
$this->assertEquals(1000.00, $rows[2]['abschreibungsbetrag'], 'Jahr 3: 1000,00');
|
||||
$this->assertEquals(0.00, $rows[2]['restwert'], 'Jahr 3 Restwert: 0,00');
|
||||
|
||||
// ── Cent-Option: Centbeträge im ersten Jahr ──────────────
|
||||
$rows = AssetDepreciation::calculate([
|
||||
'anschaffungskosten' => 1000.75,
|
||||
'zugaenge' => 0,
|
||||
'abgaenge' => 0,
|
||||
'nutzungsdauer' => 3,
|
||||
'cent_im_ersten_jahr'=> true,
|
||||
'anschaffungsdatum' => '2024-01-01',
|
||||
]);
|
||||
|
||||
$this->assertEquals(3, count($rows), 'Cent-Option 3 Jahre: 3 Zeilen');
|
||||
// Folgejahre: floor(1000.75/3) = 333 €
|
||||
$this->assertEquals(333.00, $rows[1]['abschreibungsbetrag'], 'Jahr 2 mit Cent-Option: 333,00');
|
||||
$this->assertEquals(333.00, $rows[2]['abschreibungsbetrag'], 'Jahr 3 mit Cent-Option: 333,00');
|
||||
// Erstes Jahr: 1000.75 - 333*2 = 334.75
|
||||
$this->assertEquals(334.75, $rows[0]['abschreibungsbetrag'], 'Jahr 1 mit Cent-Option: 334,75');
|
||||
// Restwert nach Jahr 1 auf volle Euro
|
||||
$restwertJ1 = $rows[0]['restwert'];
|
||||
$this->assert(
|
||||
$restwertJ1 == floor($restwertJ1),
|
||||
'Restwert nach Jahr 1 ist volle Euro: ' . $restwertJ1
|
||||
);
|
||||
// Gesamtsumme muss Basis entsprechen
|
||||
$sumAfa = array_sum(array_column($rows, 'abschreibungsbetrag'));
|
||||
$this->assertEquals(1000.75, round($sumAfa, 2), 'Summe aller Abschreibungen = Basis');
|
||||
|
||||
// ── Basis mit Zugängen und Abgängen ─────────────────────
|
||||
$rows = AssetDepreciation::calculate([
|
||||
'anschaffungskosten' => 5000.00,
|
||||
'zugaenge' => 500.00,
|
||||
'abgaenge' => 200.00,
|
||||
'nutzungsdauer' => 5,
|
||||
'cent_im_ersten_jahr'=> false,
|
||||
'anschaffungsdatum' => '2022-06-01',
|
||||
]);
|
||||
|
||||
$this->assertEquals(5, count($rows), 'AfA mit Zu-/Abgängen: 5 Zeilen');
|
||||
$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');
|
||||
|
||||
// ── Leere Tabelle bei Basis <= 0 ─────────────────────────
|
||||
$rows = AssetDepreciation::calculate([
|
||||
'anschaffungskosten' => 0,
|
||||
'zugaenge' => 0,
|
||||
'abgaenge' => 0,
|
||||
'nutzungsdauer' => 5,
|
||||
'cent_im_ersten_jahr'=> false,
|
||||
'anschaffungsdatum' => '2024-01-01',
|
||||
]);
|
||||
$this->assertEquals(0, count($rows), 'Keine Zeilen bei Basis = 0');
|
||||
|
||||
// ── Persistenz: Speichern und Laden ──────────────────────
|
||||
// Erst ein Test-Asset anlegen (Rollback durch TestCase)
|
||||
\Steuerkram\Database::execute(
|
||||
"INSERT INTO assets (lfd_nr, anlage_nr, bezeichnung, anschaffungsdatum, anschaffungskosten)
|
||||
VALUES (9999, 'TEST-DEPR', 'Test Abschreibung', '2024-01-01', 2400.00)"
|
||||
);
|
||||
$assetId = (int)\Steuerkram\Database::lastInsertId();
|
||||
|
||||
$testRows = [
|
||||
['jahr' => 2024, 'abschreibungsbetrag' => 800.00, 'restwert' => 1600.00, 'position' => 0],
|
||||
['jahr' => 2025, 'abschreibungsbetrag' => 800.00, 'restwert' => 800.00, 'position' => 1],
|
||||
['jahr' => 2026, 'abschreibungsbetrag' => 800.00, 'restwert' => 0.00, 'position' => 2],
|
||||
];
|
||||
|
||||
AssetDepreciation::saveForAsset($assetId, $testRows);
|
||||
$loaded = AssetDepreciation::findByAsset($assetId);
|
||||
|
||||
$this->assertEquals(3, count($loaded), 'Persistenz: 3 Zeilen gespeichert und geladen');
|
||||
$this->assertEquals(2024, (int)$loaded[0]['jahr'], 'Persistenz: Jahr 2024 korrekt');
|
||||
$this->assertEquals(800.00, (float)$loaded[0]['abschreibungsbetrag'], 'Persistenz: Betrag korrekt');
|
||||
|
||||
AssetDepreciation::deleteByAsset($assetId);
|
||||
$loaded = AssetDepreciation::findByAsset($assetId);
|
||||
$this->assertEquals(0, count($loaded), 'Persistenz: Nach deleteByAsset keine Zeilen mehr');
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue