Fix #96: Splitbuchungen zeigen Restbetrag, Speichern nur bei Null

Die Summenzeile der Splitbuchungen (Buchungsmaske und Splitdialog im
Kontoblatt) zeigt jetzt den noch offenen Restbetrag zum Buchungswert statt
der Summe der Zeilen. „Speichern" bleibt gesperrt, solange Splitzeilen
befüllt sind und der Restbetrag nicht Null ist; ohne Splitzeilen (auch mit
direkter Kategorie/Gegenkonto) bleibt der Knopf wie bisher an.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
fds2610-bot 2026-09-29 23:34:27 +02:00
parent 057075368c
commit 619e427a30
8 changed files with 72 additions and 11 deletions

View file

@ -10,6 +10,9 @@ Das Format folgt [Keep a Changelog](https://keepachangelog.com/de/1.1.0/).
- Regeln: Die Splittabellen zeigen die Spalte „Anteil“ ohne Prozentzeichen und darunter die
laufend berechnete Summe der Anteile
- Buchungsmaske und Splitdialog im Kontoblatt: Die Summenzeile der Splitbuchungen zeigt den
noch offenen Restbetrag zum Buchungswert statt der Summe der Zeilen; „Speichern“ bleibt
gesperrt, solange Splitzeilen befüllt sind und der Restbetrag nicht Null ist (#96)
### Fixed

View file

@ -1,5 +1,19 @@
# Änderungen
## Splitbuchungen: Summenzeile zeigt den Restbetrag, Speichern nur bei Null (29.09.2026)
- Die Summenzeile der Splitbuchungen (Buchungsmaske und Splitdialog im Kontoblatt) zeigte
bisher die Summe der erfassten Zeilen, mit „(Rest X)“ als Zusatz. Jetzt steht dort direkt
der noch offene Restbetrag zum Buchungswert (`Buchungswert - Summe der Zeilen`) – die
gemeinsame Rechenfunktion `FmBetrag.splitsumme()` in `betrag.js` liefert ihn beiden Masken.
- „Speichern“ (bzw. „Splitbuchung speichern“) bleibt gesperrt, solange mindestens eine
Splitzeile einen Betrag trägt und der Restbetrag nicht Null ist. Ohne Splitzeilen – egal ob
unbelegt oder mit direkter Kategorie/Gegenkonto zugeordnet – bleibt der Knopf wie bisher an;
das Model prüft die Summe der Splitzeilen ohnehin schon serverseitig.
- Der bisherige E2E-Test, der eine unstimmige Splitsumme über eine serverseitige Fehlermeldung
prüfte, prüft jetzt die neue Sperre im Browser; die serverseitige Prüfung selbst deckt
weiterhin Test 09 (PHP) ab.
## Regel direkt aus einer Buchung anlegen (29.09.2026)
- Neuer Toggle „Regel anlegen“ in der Buchungsmaske, standardmäßig aus; das bisherige

View file

@ -31,7 +31,9 @@
}
/* Die Summe der Splitzeilen unter `felder` gegen `ziel` – das Ergebnis geht
* so in die Anzeige, wie beide Masken es brauchen. */
* so in die Anzeige, wie beide Masken es brauchen. Angezeigt wird der noch
* offene Restbetrag (ziel - summe), nicht die Summe der Zeilen selbst: Nur
* so sieht man auf einen Blick, was noch fehlt. */
function splitsumme(felder, ziel) {
let summe = 0;
let belegt = false;
@ -50,7 +52,7 @@
belegt: belegt,
summe: summe,
rest: rest,
text: belegt ? formatiere(summe) + (rest === 0 ? '' : ' (Rest ' + formatiere(rest) + ')') : '0,00'
text: formatiere(rest)
};
}

View file

@ -1,6 +1,10 @@
/* Buchungsmaske: Restbetrag der Splitzeilen mitrechnen und direkte Zuordnung
* und Splitbuchung gegeneinander sperren.
*
* Solange Splitzeilen befüllt sind, aber noch ein Restbetrag zum
* Buchungswert offen ist, bleibt „Speichern“ gesperrt – das Model wiese die
* Buchung sonst ohnehin ab, hier spart es die Rundreise zum Server.
*
* Nur Komfort – die Maske kommt fertig vom Server. Ohne JavaScript bleibt alles
* bedienbar: Die Splitsumme prüft ohnehin das Model.
*
@ -26,6 +30,7 @@
const summenfeld = document.getElementById('splitsumme');
const betragsfeld = document.getElementById('betrag');
const speichernknopf = maske.querySelector('button[type="submit"]');
function rechneSumme() {
if (!summenfeld) {
@ -46,6 +51,13 @@
summenfeld.textContent = stand.text;
summenfeld.classList.toggle('negativ', stand.belegt && stand.rest !== 0);
// Gesperrt nur, solange tatsächlich Splitzeilen mit Beträgen stehen und
// deren Summe den Buchungswert verfehlt. Eine direkte Zuordnung (oder
// gar keine Eingabe) deckt den ganzen Betrag stets ab.
if (speichernknopf) {
speichernknopf.disabled = stand.belegt && stand.rest !== 0;
}
}
const direktfelder = ['zuordnung', 'klasse_id']

View file

@ -335,6 +335,7 @@
const zeilenkasten = document.getElementById('splitdialogzeilen');
const summenfeld = document.getElementById('splitdialogsumme');
const loeschknopf = document.getElementById('splitloeschen');
const speichernknopf = dialog.querySelector('button[type="submit"].primaer');
const vorlage = zeilenkasten.innerHTML;
let buchungsbetrag = 0;
@ -342,10 +343,16 @@
return Array.from(zeilenkasten.querySelectorAll('.splitbetrag'));
}
// Gesperrt nur, solange Beträge stehen, deren Summe den Buchungswert
// verfehlt. Bleiben alle Zeilen leer, hebt „Splitbuchung speichern“ die
// Aufteilung auf – das bleibt erlaubt.
function rechneSumme() {
const stand = window.FmBetrag.splitsumme(felder(), buchungsbetrag);
summenfeld.textContent = stand.text;
summenfeld.classList.toggle('negativ', stand.belegt && stand.rest !== 0);
if (speichernknopf) {
speichernknopf.disabled = stand.belegt && stand.rest !== 0;
}
}
/* Eine weitere Zeile: die letzte kopieren, leeren und neu durchnummerieren.

View file

@ -205,7 +205,7 @@ include __DIR__ . '/../layout/kopf.php';
</tbody>
<tfoot>
<tr>
<td colspan="3" class="rechts">Summe der Zeilen / Restbetrag</td>
<td colspan="3" class="rechts">Restbetrag</td>
<td class="rechts"><output id="splitsumme" class="betrag">0,00</output></td>
</tr>
</tfoot>

View file

@ -507,7 +507,7 @@ include __DIR__ . '/../layout/kopf.php';
</tbody>
<tfoot>
<tr>
<td colspan="3" class="rechts">Summe der Zeilen / Restbetrag</td>
<td colspan="3" class="rechts">Restbetrag</td>
<td class="rechts"><output id="splitdialogsumme" class="betrag">0,00</output></td>
</tr>
</tfoot>

View file

@ -286,16 +286,25 @@ test.describe('Buchungen', () => {
await expect(dialog).toBeVisible()
await expect(dialog).toContainText('-90,00')
const dialogSpeichern = dialog.locator('button[type="submit"].primaer')
await dialog.locator('select[name="split[0][zuordnung]"]').selectOption({ label: KATEGORIE })
await dialog.locator('input[name="split[0][betrag]"]').fill('-50')
// Solange der Restbetrag offen ist, zeigt die Summenzeile ihn an und
// „Splitbuchung speichern“ bleibt gesperrt.
await expect(page.locator('#splitdialogsumme')).toHaveText('-40,00')
await expect(dialogSpeichern).toBeDisabled()
await dialog.locator('select[name="split[1][zuordnung]"]').selectOption({ label: `[${SPAR}]` })
await dialog.locator('input[name="split[1][betrag]"]').fill('-40')
// Der Dialog rechnet den Restbetrag mit; er muss aufgehen.
await expect(page.locator('#splitdialogsumme')).not.toContainText('Rest')
await expect(page.locator('#splitdialogsumme')).toHaveText('0,00')
await expect(dialogSpeichern).toBeEnabled()
// Der Dialog hat zwei Submit-Knöpfe: Speichern und Löschen.
await dialog.locator('button[type="submit"].primaer').click()
await dialogSpeichern.click()
await expect(page.locator('.meldung-erfolg').last()).toContainText('Splitbuchung')
await expect(page.locator('tr', { hasText: `Aufzuteilen ${MARKE}` }).first())
@ -494,21 +503,35 @@ test.describe('Buchungen', () => {
.not.toHaveAttribute('title')
})
test('Splitzeilen, die nicht aufgehen, werden abgewiesen', async ({ page }) => {
test('Splitzeilen, die nicht aufgehen, sperren das Speichern', async ({ page }) => {
await page.goto(`/konten/${giroId}/buchung`)
const speichern = page.locator('.buchungsmaske button[type="submit"]')
await page.fill('#datum', '2026-05-03')
await page.fill('#betrag', '100')
await page.fill('#empfaenger', `Krumm ${MARKE}`)
// Ohne jede Splitzeile bleibt der Button an – die Buchung ist noch keine
// Splitbuchung, auch wenn die Summenzeile schon den vollen Betrag zeigt.
await expect(page.locator('#splitsumme')).toHaveText('-100,00')
await expect(speichern).toBeEnabled()
await page.selectOption('select[name="split[0][zuordnung]"]', { label: KATEGORIE })
await page.fill('input[name="split[0][betrag]"]', '-60')
await expect(page.locator('#splitsumme')).toContainText('Rest')
// Die Summenzeile zeigt den noch offenen Restbetrag, nicht die Summe der
// Zeilen – und „Speichern“ ist gesperrt, solange er nicht Null ist.
await expect(page.locator('#splitsumme')).toHaveText('-40,00')
await expect(speichern).toBeDisabled()
await page.click('.buchungsmaske button[type="submit"]')
await page.selectOption('select[name="split[1][zuordnung]"]', { label: KATEGORIE })
await page.fill('input[name="split[1][betrag]"]', '-40')
await expect(page.locator('.meldung-fehler')).toBeVisible()
await expect(page.locator('.meldung-fehler')).toContainText('Splitzeilen')
await expect(page.locator('#splitsumme')).toHaveText('0,00')
await expect(speichern).toBeEnabled()
await speichern.click()
await expect(page.locator('.meldung-erfolg')).toBeVisible()
})
test('Kontoblatt lässt sich durchsuchen', async ({ page }) => {