Fix #208: Totmannschalter für den Nextcloud-Import
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
parent
be4d54112d
commit
69fcc84301
11 changed files with 155 additions and 2 deletions
|
|
@ -2,6 +2,13 @@
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## 2026-09-20 – Überwachung des Nextcloud-Imports (#208)
|
||||||
|
|
||||||
|
- **`migrations/096_nextcloud_watchdog.sql`** (neu): `company_settings.nextcloud_last_run` (Zeitstempel des letzten Laufs) und `nextcloud_alarm_stunden` (Schwelle, Default 24, 0 = aus).
|
||||||
|
- **`src/Services/NextcloudWatchdog.php`** (neu): Totmannschalter. `NextcloudImportService::import()` setzt nach erfolgreichem PROPFIND den Zeitstempel – auch bei stillen Läufen ohne Ereignis. Ist er älter als die Schwelle (oder nie gesetzt, bei konfigurierter Nextcloud), erscheint eine Warnung oben auf `/ereignisse` und ein ⚠ am Nav-Eintrag „Ereignisse".
|
||||||
|
- **Einstellungen:** Feld „Import-Überwachung" im Nextcloud-Abschnitt.
|
||||||
|
- **`tests/101_NextcloudWatchdogTest.php`** (neu): Schwellenlogik gegen künstlich alte Zeitstempel.
|
||||||
|
|
||||||
## 2026-09-20 – Gezahlte Vorsteuer als Betriebsausgabe in der EÜR (EÜR-Zeile 57 / Kz 185)
|
## 2026-09-20 – Gezahlte Vorsteuer als Betriebsausgabe in der EÜR (EÜR-Zeile 57 / Kz 185)
|
||||||
|
|
||||||
- **`migrations/095_vorsteuerkonten_betriebsausgabe.sql`** (neu): Die Konten 1571/1574/1576 (abziehbare Vorsteuer) lagen als `Zahlungskonto` im Kontenrahmen und wurden vom `EuerCalculator` daher ignoriert – die auf ihnen gebuchte Vorsteuer tauchte in der EÜR nirgends auf. Da die vereinnahmte USt seit Migration 076 als Betriebseinnahme (Zeile 17) und die ans Finanzamt gezahlte USt seit Migration 077 als Betriebsausgabe (Zeile 58) geführt werden, war die Bruttomethode unvollständig und der ausgewiesene Gewinn um die gezahlte Vorsteuer zu hoch. Alle drei Konten sind jetzt `Betriebsausgabe` mit `euer_zeile = '57'`. Auch 1574 (Vorsteuer aus ig. Erwerb) muss mit: sein Gegenkonto 1774 ist seit Migration 076 Betriebseinnahme, bei Reverse-Charge zählte also nur die Erwerbsteuer als Einnahme – die Buchung war entgegen der Absicht nicht ergebnisneutral.
|
- **`migrations/095_vorsteuerkonten_betriebsausgabe.sql`** (neu): Die Konten 1571/1574/1576 (abziehbare Vorsteuer) lagen als `Zahlungskonto` im Kontenrahmen und wurden vom `EuerCalculator` daher ignoriert – die auf ihnen gebuchte Vorsteuer tauchte in der EÜR nirgends auf. Da die vereinnahmte USt seit Migration 076 als Betriebseinnahme (Zeile 17) und die ans Finanzamt gezahlte USt seit Migration 077 als Betriebsausgabe (Zeile 58) geführt werden, war die Bruttomethode unvollständig und der ausgewiesene Gewinn um die gezahlte Vorsteuer zu hoch. Alle drei Konten sind jetzt `Betriebsausgabe` mit `euer_zeile = '57'`. Auch 1574 (Vorsteuer aus ig. Erwerb) muss mit: sein Gegenkonto 1774 ist seit Migration 076 Betriebseinnahme, bei Reverse-Charge zählte also nur die Erwerbsteuer als Einnahme – die Buchung war entgegen der Absicht nicht ergebnisneutral.
|
||||||
|
|
|
||||||
|
|
@ -14,7 +14,7 @@ Vollständige Kontenliste: [kontenrahmen.md](kontenrahmen.md)
|
||||||
- SHA-256 Integritätsprüfung je Datei
|
- SHA-256 Integritätsprüfung je Datei
|
||||||
- Automatischer Weißrand-Zuschnitt bei JPEG/PNG via GD
|
- Automatischer Weißrand-Zuschnitt bei JPEG/PNG via GD
|
||||||
- KI-gestützter Kontierungsvorschlag (optional, siehe [konfiguration.md](konfiguration.md))
|
- KI-gestützter Kontierungsvorschlag (optional, siehe [konfiguration.md](konfiguration.md))
|
||||||
- Nextcloud-Import via WebDAV (optional)
|
- Nextcloud-Import via WebDAV (optional); Überwachung: Warnung in Ereignisse/Nav, wenn kein Lauf mehr stattfand (Schwelle einstellbar, Standard 24 Std.)
|
||||||
- Belege können nicht gelöscht werden, solange Buchungen darauf verweisen (GoBD)
|
- Belege können nicht gelöscht werden, solange Buchungen darauf verweisen (GoBD)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
|
||||||
6
migrations/096_nextcloud_watchdog.sql
Normal file
6
migrations/096_nextcloud_watchdog.sql
Normal file
|
|
@ -0,0 +1,6 @@
|
||||||
|
-- Totmannschalter für den Nextcloud-Import (Issue #208).
|
||||||
|
-- nextcloud_last_run: Zeitstempel des letzten Importlaufs (auch ohne Dateiaktivität)
|
||||||
|
-- nextcloud_alarm_stunden: Schwelle in Stunden, 0 = Überwachung aus
|
||||||
|
ALTER TABLE company_settings
|
||||||
|
ADD COLUMN IF NOT EXISTS nextcloud_last_run DATETIME NULL,
|
||||||
|
ADD COLUMN IF NOT EXISTS nextcloud_alarm_stunden SMALLINT UNSIGNED NOT NULL DEFAULT 24;
|
||||||
|
|
@ -5,6 +5,7 @@ declare(strict_types=1);
|
||||||
namespace Steuerkram\Controllers;
|
namespace Steuerkram\Controllers;
|
||||||
|
|
||||||
use Steuerkram\Models\EventLog;
|
use Steuerkram\Models\EventLog;
|
||||||
|
use Steuerkram\Services\NextcloudWatchdog;
|
||||||
use Steuerkram\View;
|
use Steuerkram\View;
|
||||||
|
|
||||||
class EventController extends BaseController
|
class EventController extends BaseController
|
||||||
|
|
@ -23,6 +24,7 @@ class EventController extends BaseController
|
||||||
'events' => $events,
|
'events' => $events,
|
||||||
'filter' => $filter,
|
'filter' => $filter,
|
||||||
'channels' => EventLog::channels(),
|
'channels' => EventLog::channels(),
|
||||||
|
'watchdog' => NextcloudWatchdog::warning(),
|
||||||
'eventsHtml' => $this->renderView('ereignisse/_body', [
|
'eventsHtml' => $this->renderView('ereignisse/_body', [
|
||||||
'events' => $events,
|
'events' => $events,
|
||||||
]),
|
]),
|
||||||
|
|
|
||||||
|
|
@ -42,7 +42,7 @@ class Setting
|
||||||
invoice_nr_prefix=?, beleg_nr_prefix=?, anlage_nr_prefix=?, customer_nr_prefix=?,
|
invoice_nr_prefix=?, beleg_nr_prefix=?, anlage_nr_prefix=?, customer_nr_prefix=?,
|
||||||
payment_days_default=?, test_mode=?, ki_vorschlag_aktiv=?,
|
payment_days_default=?, test_mode=?, ki_vorschlag_aktiv=?,
|
||||||
nextcloud_url=?, nextcloud_user=?, nextcloud_pass=?,
|
nextcloud_url=?, nextcloud_user=?, nextcloud_pass=?,
|
||||||
nextcloud_folder=?, nextcloud_done=?,
|
nextcloud_folder=?, nextcloud_done=?, nextcloud_alarm_stunden=?,
|
||||||
mail_smtp_host=?, mail_smtp_port=?, mail_smtp_encryption=?,
|
mail_smtp_host=?, mail_smtp_port=?, mail_smtp_encryption=?,
|
||||||
mail_smtp_user=?, mail_smtp_pass=?,
|
mail_smtp_user=?, mail_smtp_pass=?,
|
||||||
mail_from_name=?, mail_from_email=?,
|
mail_from_name=?, mail_from_email=?,
|
||||||
|
|
@ -80,6 +80,7 @@ class Setting
|
||||||
!empty($data['nextcloud_pass']) ? Crypto::encrypt($data['nextcloud_pass']) : null,
|
!empty($data['nextcloud_pass']) ? Crypto::encrypt($data['nextcloud_pass']) : null,
|
||||||
($data['nextcloud_folder'] ?? '') ?: '/Belege/Import',
|
($data['nextcloud_folder'] ?? '') ?: '/Belege/Import',
|
||||||
($data['nextcloud_done'] ?? '') ?: null,
|
($data['nextcloud_done'] ?? '') ?: null,
|
||||||
|
max(0, min(8760, (int)($data['nextcloud_alarm_stunden'] ?? 24))),
|
||||||
($data['mail_smtp_host'] ?? '') ?: '',
|
($data['mail_smtp_host'] ?? '') ?: '',
|
||||||
(int)($data['mail_smtp_port'] ?? 587),
|
(int)($data['mail_smtp_port'] ?? 587),
|
||||||
in_array($data['mail_smtp_encryption'] ?? '', ['tls', 'ssl', 'none']) ? $data['mail_smtp_encryption'] : 'tls',
|
in_array($data['mail_smtp_encryption'] ?? '', ['tls', 'ssl', 'none']) ? $data['mail_smtp_encryption'] : 'tls',
|
||||||
|
|
|
||||||
|
|
@ -115,6 +115,8 @@ class NextcloudImportService
|
||||||
return $result;
|
return $result;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
NextcloudWatchdog::markRun();
|
||||||
|
|
||||||
$result['details'][] = count($files) . ' Datei(en) im Import-Ordner gefunden.';
|
$result['details'][] = count($files) . ' Datei(en) im Import-Ordner gefunden.';
|
||||||
|
|
||||||
foreach ($files as $file) {
|
foreach ($files as $file) {
|
||||||
|
|
|
||||||
72
src/Services/NextcloudWatchdog.php
Normal file
72
src/Services/NextcloudWatchdog.php
Normal file
|
|
@ -0,0 +1,72 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Steuerkram\Services;
|
||||||
|
|
||||||
|
use Steuerkram\Database;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Totmannschalter für den Nextcloud-Import (Issue #208).
|
||||||
|
*
|
||||||
|
* Jeder Importlauf, der die Nextcloud erreicht, hinterlässt einen Zeitstempel
|
||||||
|
* (company_settings.nextcloud_last_run) – auch wenn er nichts zu tun hatte und
|
||||||
|
* deshalb kein Ereignis schreibt. Liegt dieser Zeitstempel länger zurück als
|
||||||
|
* die eingestellte Schwelle (nextcloud_alarm_stunden, 0 = aus), gilt der Import
|
||||||
|
* als ausgeblieben.
|
||||||
|
*/
|
||||||
|
class NextcloudWatchdog
|
||||||
|
{
|
||||||
|
public const DEFAULT_HOURS = 24;
|
||||||
|
|
||||||
|
public static function markRun(): void
|
||||||
|
{
|
||||||
|
Database::execute('UPDATE company_settings SET nextcloud_last_run = NOW() WHERE id = 1');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Liefert eine Warnung, wenn der Import überfällig ist, sonst null.
|
||||||
|
*/
|
||||||
|
public static function warning(?int $now = null): ?string
|
||||||
|
{
|
||||||
|
$row = Database::fetchOne(
|
||||||
|
'SELECT nextcloud_url, nextcloud_last_run, nextcloud_alarm_stunden
|
||||||
|
FROM company_settings WHERE id = 1'
|
||||||
|
) ?: [];
|
||||||
|
|
||||||
|
return self::evaluate(
|
||||||
|
(string)($row['nextcloud_url'] ?? ''),
|
||||||
|
$row['nextcloud_last_run'] ?? null,
|
||||||
|
(int)($row['nextcloud_alarm_stunden'] ?? self::DEFAULT_HOURS),
|
||||||
|
$now ?? time()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reine Schwellenlogik. Ohne konfigurierte Nextcloud oder mit Schwelle 0
|
||||||
|
* gibt es nie eine Warnung.
|
||||||
|
*/
|
||||||
|
public static function evaluate(string $url, ?string $lastRun, int $thresholdHours, int $now): ?string
|
||||||
|
{
|
||||||
|
if ($url === '' || $thresholdHours <= 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($lastRun === null || $lastRun === '') {
|
||||||
|
return 'Der Nextcloud-Import ist noch nie gelaufen. Cronjob prüfen'
|
||||||
|
. ' (oder Überwachung in den Einstellungen abschalten).';
|
||||||
|
}
|
||||||
|
|
||||||
|
$ts = strtotime($lastRun);
|
||||||
|
if ($ts === false || $now - $ts <= $thresholdHours * 3600) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$stunden = intdiv($now - $ts, 3600);
|
||||||
|
return sprintf(
|
||||||
|
'Der Nextcloud-Import ist seit %s (vor %d Std.) nicht mehr gelaufen, Schwelle: %d Std.'
|
||||||
|
. ' Cronjob prüfen.',
|
||||||
|
date('d.m.Y H:i', $ts), $stunden, $thresholdHours
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -27,6 +27,10 @@ $levelLabels = [
|
||||||
.switch input:checked + .switch-track::before { transform: translateX(18px); }
|
.switch input:checked + .switch-track::before { transform: translateX(18px); }
|
||||||
</style>
|
</style>
|
||||||
|
|
||||||
|
<?php if (!empty($watchdog)): ?>
|
||||||
|
<div class="alert alert-warning"><?= $h($watchdog) ?></div>
|
||||||
|
<?php endif; ?>
|
||||||
|
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<div style="display:flex;justify-content:space-between;align-items:center;gap:1rem;flex-wrap:wrap;padding:.75rem 1rem;border-bottom:1px solid var(--color-border)">
|
<div style="display:flex;justify-content:space-between;align-items:center;gap:1rem;flex-wrap:wrap;padding:.75rem 1rem;border-bottom:1px solid var(--color-border)">
|
||||||
<form method="get" action="/ereignisse" id="events-filter" style="display:flex;align-items:center;gap:.5rem;flex-wrap:wrap">
|
<form method="get" action="/ereignisse" id="events-filter" style="display:flex;align-items:center;gap:.5rem;flex-wrap:wrap">
|
||||||
|
|
|
||||||
|
|
@ -89,6 +89,9 @@ function navActive(string $prefix, string $current): string {
|
||||||
<a href="/ereignisse" class="<?= navActive('/ereignisse', $path) ?>">
|
<a href="/ereignisse" class="<?= navActive('/ereignisse', $path) ?>">
|
||||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 22V12M12 12L8 16M12 12l4 4"/><circle cx="12" cy="5" r="3"/><path d="M5.5 8.5A7 7 0 0 0 5 12"/><path d="M18.5 8.5A7 7 0 0 1 19 12"/></svg>
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 22V12M12 12L8 16M12 12l4 4"/><circle cx="12" cy="5" r="3"/><path d="M5.5 8.5A7 7 0 0 0 5 12"/><path d="M18.5 8.5A7 7 0 0 1 19 12"/></svg>
|
||||||
Ereignisse
|
Ereignisse
|
||||||
|
<?php if (\Steuerkram\Services\NextcloudWatchdog::warning() !== null): ?>
|
||||||
|
<span class="text-danger" title="Nextcloud-Import überfällig">⚠</span>
|
||||||
|
<?php endif; ?>
|
||||||
</a>
|
</a>
|
||||||
<?php if (str_starts_with($path, '/ereignisse')): ?>
|
<?php if (str_starts_with($path, '/ereignisse')): ?>
|
||||||
<a href="/ereignisse?level=error" class="<?= ($_GET['level'] ?? '') === 'error' ? 'active' : '' ?>" style="padding-left:2.25rem;font-size:.875rem">
|
<a href="/ereignisse?level=error" class="<?= ($_GET['level'] ?? '') === 'error' ? 'active' : '' ?>" style="padding-left:2.25rem;font-size:.875rem">
|
||||||
|
|
|
||||||
|
|
@ -343,6 +343,11 @@
|
||||||
value="<?= $h($settings['nextcloud_done'] ?? '') ?>"
|
value="<?= $h($settings['nextcloud_done'] ?? '') ?>"
|
||||||
placeholder="/Belege/Importiert">
|
placeholder="/Belege/Importiert">
|
||||||
</div>
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Import-Überwachung <span class="text-muted">(Stunden ohne Lauf bis zur Warnung, 0 = aus)</span></label>
|
||||||
|
<input type="number" name="nextcloud_alarm_stunden" min="0" max="8760"
|
||||||
|
value="<?= (int)($settings['nextcloud_alarm_stunden'] ?? 24) ?>">
|
||||||
|
</div>
|
||||||
<div class="form-hint">
|
<div class="form-hint">
|
||||||
Import manuell starten:
|
Import manuell starten:
|
||||||
<code>docker compose exec web php scripts/container/nextcloud-import.php</code>
|
<code>docker compose exec web php scripts/container/nextcloud-import.php</code>
|
||||||
|
|
|
||||||
51
tests/101_NextcloudWatchdogTest.php
Normal file
51
tests/101_NextcloudWatchdogTest.php
Normal file
|
|
@ -0,0 +1,51 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
use Steuerkram\Services\NextcloudWatchdog;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Test 101: Totmannschalter für den Nextcloud-Import (Issue #208)
|
||||||
|
*/
|
||||||
|
class NextcloudWatchdogTest extends TestCase
|
||||||
|
{
|
||||||
|
protected string $testName = 'Test 101: Nextcloud-Import-Überwachung';
|
||||||
|
|
||||||
|
public function run(): void
|
||||||
|
{
|
||||||
|
$now = strtotime('2026-09-20 12:00:00');
|
||||||
|
$url = 'https://cloud.example.com';
|
||||||
|
|
||||||
|
$this->assert(
|
||||||
|
NextcloudWatchdog::evaluate($url, '2026-09-20 11:55:00', 24, $now) === null,
|
||||||
|
'Frischer Lauf: keine Warnung'
|
||||||
|
);
|
||||||
|
$this->assert(
|
||||||
|
NextcloudWatchdog::evaluate($url, '2026-09-19 12:00:00', 24, $now) === null,
|
||||||
|
'Genau an der Schwelle: keine Warnung'
|
||||||
|
);
|
||||||
|
$alt = NextcloudWatchdog::evaluate($url, '2026-08-30 12:00:00', 24, $now);
|
||||||
|
$this->assertNotEmpty($alt, 'Drei Wochen alter Lauf: Warnung');
|
||||||
|
$this->assert(str_contains((string)$alt, '30.08.2026'), 'Warnung nennt den letzten Lauf');
|
||||||
|
$this->assert(
|
||||||
|
NextcloudWatchdog::evaluate($url, '2026-08-30 12:00:00', 0, $now) === null,
|
||||||
|
'Schwelle 0 schaltet die Überwachung ab'
|
||||||
|
);
|
||||||
|
$this->assert(
|
||||||
|
NextcloudWatchdog::evaluate('', null, 24, $now) === null,
|
||||||
|
'Ohne konfigurierte Nextcloud keine Warnung'
|
||||||
|
);
|
||||||
|
$this->assertNotEmpty(
|
||||||
|
NextcloudWatchdog::evaluate($url, null, 24, $now),
|
||||||
|
'Konfiguriert, aber nie gelaufen: Warnung'
|
||||||
|
);
|
||||||
|
|
||||||
|
// Zeitstempel in der Datenbank
|
||||||
|
\Steuerkram\Database::execute("UPDATE company_settings SET nextcloud_url = ?, nextcloud_alarm_stunden = 24, nextcloud_last_run = NULL WHERE id = 1", [$url]);
|
||||||
|
$this->assertNotEmpty(NextcloudWatchdog::warning(), 'DB: ohne Zeitstempel Warnung');
|
||||||
|
NextcloudWatchdog::markRun();
|
||||||
|
$this->assert(NextcloudWatchdog::warning() === null, 'DB: nach markRun() keine Warnung');
|
||||||
|
\Steuerkram\Database::execute("UPDATE company_settings SET nextcloud_last_run = NOW() - INTERVAL 3 DAY WHERE id = 1");
|
||||||
|
$this->assertNotEmpty(NextcloudWatchdog::warning(), 'DB: 3 Tage alter Zeitstempel löst Warnung aus');
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Add table
Reference in a new issue