Fix n.n.: Regelmäßige Buchungen – Buchen zum gewählten Datum

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
fds2610-bot 2026-10-01 10:48:51 +02:00
parent 176282d3a3
commit 1fc78abc11
6 changed files with 78 additions and 11 deletions

View file

@ -11,6 +11,10 @@
- Neue Buchungen ohne Nummer erhalten beim Anlegen die nächste laufende Nummer ihres Kontos (höchste rein numerische Nummer + 1, `Buchung::naechsteNummer()`). Vorgegebene Nummern (QIF, CSV, Formular) bleiben, bestehende Buchungen werden nicht verändert; die interne ID bleibt verborgen. - Neue Buchungen ohne Nummer erhalten beim Anlegen die nächste laufende Nummer ihres Kontos (höchste rein numerische Nummer + 1, `Buchung::naechsteNummer()`). Vorgegebene Nummern (QIF, CSV, Formular) bleiben, bestehende Buchungen werden nicht verändert; die interne ID bleibt verborgen.
- Test: `53_BuchungsnummerTest.php`. - Test: `53_BuchungsnummerTest.php`.
## Regelmäßige Buchungen: Buchen zum gewählten Datum (01.10.2026)
- „Buchen“ in „Jetzt fällig“ schlug mit einer Fehlermeldung fehl, weil das Formular ein leeres Datum schickte. Jetzt ist „Fällig am“ per Klick editierbar; „Buchen“ bucht zu diesem Datum. Die Änderung gilt nur für diese Buchung und verändert die Reihe nicht. Ein leeres Datum fällt auf den Termin zurück, ein ungültiges wird abgewiesen.
## „+ Neu“ öffnet das Formular als Dialog (01.10.2026) ## „+ Neu“ öffnet das Formular als Dialog (01.10.2026)
- Das Formular zur Neuanlage stand in elf Ansichten unter der Liste. Jetzt öffnet der Knopf „+ Neu“ es als modalen Dialog (`app.js`, `.neudialog`); nach dem Speichern erscheint die Liste wieder, mit dem neuen Eintrag. Betroffen: Klassen, Buchungsvorlagen, Regelmäßige Buchungen, Erinnerungen, Verträge, Adressen, Sparziele, Kredite, Abgleichregeln, Budgets, Berichtsgruppen. - Das Formular zur Neuanlage stand in elf Ansichten unter der Liste. Jetzt öffnet der Knopf „+ Neu“ es als modalen Dialog (`app.js`, `.neudialog`); nach dem Speichern erscheint die Liste wieder, mit dem neuen Eintrag. Betroffen: Klassen, Buchungsvorlagen, Regelmäßige Buchungen, Erinnerungen, Verträge, Adressen, Sparziele, Kredite, Abgleichregeln, Budgets, Berichtsgruppen.

View file

@ -5,8 +5,8 @@
(function () { (function () {
'use strict'; 'use strict';
const tabelle = document.querySelector('table.reihen'); const tabellen = document.querySelectorAll('table.reihen, table.faellig');
if (!tabelle) { if (tabellen.length === 0) {
return; return;
} }
@ -59,6 +59,7 @@
anzeige.hidden = false; anzeige.hidden = false;
} }
tabellen.forEach(function (tabelle) {
tabelle.querySelectorAll('.feld').forEach(function (feld) { tabelle.querySelectorAll('.feld').forEach(function (feld) {
const anzeige = feld.querySelector('.anzeige'); const anzeige = feld.querySelector('.anzeige');
const steuer = feld.querySelector('input, select'); const steuer = feld.querySelector('input, select');
@ -99,6 +100,7 @@
} }
}); });
}); });
});
function geaendert(e) { function geaendert(e) {
const zeile = e.target.closest('tr'); const zeile = e.target.closest('tr');
@ -107,6 +109,8 @@
knopf.hidden = false; knopf.hidden = false;
} }
} }
tabellen.forEach(function (tabelle) {
tabelle.addEventListener('input', geaendert); tabelle.addEventListener('input', geaendert);
tabelle.addEventListener('change', geaendert); tabelle.addEventListener('change', geaendert);
});
})(); })();

View file

@ -169,7 +169,14 @@ class RegelmaessigeBuchung
throw new \RuntimeException('Die regelmäßige Buchung gibt es nicht.'); throw new \RuntimeException('Die regelmäßige Buchung gibt es nicht.');
} }
$datum = (string)($abweichungen['datum'] ?? $reihe['naechste_faelligkeit'] ?? date('Y-m-d')); $datum = trim((string)($abweichungen['datum'] ?? ''));
if ($datum === '') {
$datum = (string)($reihe['naechste_faelligkeit'] ?? date('Y-m-d'));
}
$pruef = \DateTimeImmutable::createFromFormat('!Y-m-d', $datum);
if ($pruef === false || $pruef->format('Y-m-d') !== $datum) {
throw new \InvalidArgumentException('Ungültiges Datum.');
}
$betrag = array_key_exists('betrag', $abweichungen) && $abweichungen['betrag'] !== '' $betrag = array_key_exists('betrag', $abweichungen) && $abweichungen['betrag'] !== ''
? round((float)$abweichungen['betrag'], 2) ? round((float)$abweichungen['betrag'], 2)
: (float)$reihe['betrag']; : (float)$reihe['betrag'];

View file

@ -42,7 +42,13 @@ include __DIR__ . '/../layout/kopf.php';
</div> </div>
<h3>Jetzt fällig</h3> <h3>Jetzt fällig</h3>
<table class="liste"> <?php foreach ($faellig as $reihe): ?>
<form id="buchen-<?= (int)$reihe['id'] ?>" method="post"
action="/termine/<?= (int)$reihe['id'] ?>/buchen">
<input type="hidden" name="csrf_token" value="<?= View::h($csrfToken) ?>">
</form>
<?php endforeach; ?>
<table class="liste faellig">
<thead> <thead>
<tr><th>Fällig am</th><th>Name</th><th>Konto</th><th class="rechts">Betrag</th> <tr><th>Fällig am</th><th>Name</th><th>Konto</th><th class="rechts">Betrag</th>
<th>Art</th><th class="rechts">Aktion</th></tr> <th>Art</th><th class="rechts">Aktion</th></tr>
@ -50,7 +56,14 @@ include __DIR__ . '/../layout/kopf.php';
<tbody> <tbody>
<?php foreach ($faellig as $reihe): ?> <?php foreach ($faellig as $reihe): ?>
<tr<?= (string)$reihe['naechste_faelligkeit'] < date('Y-m-d') ? ' class="ueberfaellig"' : '' ?>> <tr<?= (string)$reihe['naechste_faelligkeit'] < date('Y-m-d') ? ' class="ueberfaellig"' : '' ?>>
<td><?= View::h(View::datum($reihe['naechste_faelligkeit'])) ?></td> <td>
<div class="feld" data-datum>
<span class="anzeige"><?= View::h(View::datum($reihe['naechste_faelligkeit'])) ?></span>
<input type="date" form="buchen-<?= (int)$reihe['id'] ?>" name="datum" hidden
id="buchen-datum-<?= (int)$reihe['id'] ?>"
value="<?= View::h((string)$reihe['naechste_faelligkeit']) ?>">
</div>
</td>
<td><?= View::h($reihe['name']) ?></td> <td><?= View::h($reihe['name']) ?></td>
<td><?= View::h($reihe['kontoname']) ?></td> <td><?= View::h($reihe['kontoname']) ?></td>
<td class="rechts <?= View::betragKlasse($reihe['betrag']) ?>"> <td class="rechts <?= View::betragKlasse($reihe['betrag']) ?>">
@ -62,10 +75,8 @@ include __DIR__ . '/../layout/kopf.php';
: '<span class="marke">auf Bestätigung</span>' ?> : '<span class="marke">auf Bestätigung</span>' ?>
</td> </td>
<td class="rechts nowrap"> <td class="rechts nowrap">
<form method="post" action="/termine/<?= (int)$reihe['id'] ?>/buchen" class="inline"> <button type="submit" form="buchen-<?= (int)$reihe['id'] ?>"
<input type="hidden" name="csrf_token" value="<?= View::h($csrfToken) ?>"> id="buchen-knopf-<?= (int)$reihe['id'] ?>" class="knopf klein primaer">Buchen</button>
<button type="submit" class="knopf klein primaer">Buchen</button>
</form>
<form method="post" action="/termine/<?= (int)$reihe['id'] ?>/ueberspringen" class="inline"> <form method="post" action="/termine/<?= (int)$reihe['id'] ?>/ueberspringen" class="inline">
<input type="hidden" name="csrf_token" value="<?= View::h($csrfToken) ?>"> <input type="hidden" name="csrf_token" value="<?= View::h($csrfToken) ?>">
<button type="submit" class="knopf klein">Überspringen</button> <button type="submit" class="knopf klein">Überspringen</button>

View file

@ -30,6 +30,7 @@ class RegelmaessigeLoeschenTest extends TestCase
$this->manuelleBuchungBleibtUnberuehrt(); $this->manuelleBuchungBleibtUnberuehrt();
$this->buchungstextWirdUebernommen(); $this->buchungstextWirdUebernommen();
$this->naechsterTerminLaesstSichSetzen(); $this->naechsterTerminLaesstSichSetzen();
$this->buchenMitAbweichendemDatum();
} }
private function reihe(string $name, string $beginn, ?string $ende = null): int private function reihe(string $name, string $beginn, ?string $ende = null): int
@ -125,4 +126,25 @@ class RegelmaessigeLoeschenTest extends TestCase
RegelmaessigeBuchung::speichern($id, ['naechste_faelligkeit' => 'Unsinn'] + $daten); RegelmaessigeBuchung::speichern($id, ['naechste_faelligkeit' => 'Unsinn'] + $daten);
$this->assertEquals('2026-12-15', $this->faelligkeit($id), 'Ungültige Angaben werden ignoriert'); $this->assertEquals('2026-12-15', $this->faelligkeit($id), 'Ungültige Angaben werden ignoriert');
} }
private function buchenMitAbweichendemDatum(): void
{
$id = $this->reihe('Datumsbuchung', '2026-10-01');
$b1 = RegelmaessigeBuchung::buchen($id, ['datum' => '', 'betrag' => '']);
$this->assertEquals('2026-10-01', (string)Buchung::find($b1)['datum'], 'Leeres Datum bucht zum Termin');
$id = $this->reihe('Datumsbuchung 2', '2026-10-01');
$termin = $this->faelligkeit($id);
$b2 = RegelmaessigeBuchung::buchen($id, ['datum' => '2026-10-05']);
$this->assertEquals('2026-10-05', (string)Buchung::find($b2)['datum'], 'Das gewählte Datum ist das Buchungsdatum');
$this->assertEquals($termin, (string)Buchung::find($b2)['faelligkeit'], 'Der Termin der Reihe bleibt der gespeicherte');
$fehler = false;
try {
RegelmaessigeBuchung::buchen($id, ['datum' => '31.02.2026']);
} catch (\InvalidArgumentException) {
$fehler = true;
}
$this->assert($fehler, 'Ein ungültiges Datum wird abgewiesen');
}
} }

View file

@ -339,6 +339,25 @@ test.describe('Module der Phasen 4 bis 7', () => {
await expect(page.locator('table.reihen tr', { hasText: 'E2E-Reihe' })).toHaveCount(0) await expect(page.locator('table.reihen tr', { hasText: 'E2E-Reihe' })).toHaveCount(0)
}) })
test('Fälliges Datum lässt sich vor dem Buchen per Klick ändern', async ({ page }) => {
await page.goto('/termine')
await page.click('#neu-knopf')
await page.fill('#name', 'E2E-Fällig')
await page.fill('#neu_betrag', '-1,00')
await page.click('form[action="/termine/neu"] button.primaer')
const zeile = page.locator('table.faellig tr', { hasText: 'E2E-Fällig' })
const datum = zeile.locator('input[name="datum"]')
await expect(datum).toBeHidden()
await zeile.locator('.feld[data-datum] .anzeige').click()
await expect(datum).toBeVisible()
await expect(zeile.locator('button:has-text("Buchen")')).toBeVisible()
page.once('dialog', (dialog) => dialog.accept())
await page.locator('table.reihen tr', { hasText: 'E2E-Fällig' }).locator('button:has-text("Löschen")').click()
await expect(page.locator('table.reihen tr', { hasText: 'E2E-Fällig' })).toHaveCount(0)
})
test('Der Finanzkalender ist erreichbar und lässt sich einstellen', async ({ page }) => { test('Der Finanzkalender ist erreichbar und lässt sich einstellen', async ({ page }) => {
await page.goto('/termine/kalender') await page.goto('/termine/kalender')
await expect(page.locator('h1')).toContainText('Finanzkalender') await expect(page.locator('h1')).toContainText('Finanzkalender')