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:
parent
057075368c
commit
619e427a30
8 changed files with 72 additions and 11 deletions
|
|
@ -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
|
- Regeln: Die Splittabellen zeigen die Spalte „Anteil“ ohne Prozentzeichen und darunter die
|
||||||
laufend berechnete Summe der Anteile
|
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
|
### Fixed
|
||||||
|
|
||||||
|
|
|
||||||
14
Changes.md
14
Changes.md
|
|
@ -1,5 +1,19 @@
|
||||||
# Änderungen
|
# Ä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)
|
## Regel direkt aus einer Buchung anlegen (29.09.2026)
|
||||||
|
|
||||||
- Neuer Toggle „Regel anlegen“ in der Buchungsmaske, standardmäßig aus; das bisherige
|
- Neuer Toggle „Regel anlegen“ in der Buchungsmaske, standardmäßig aus; das bisherige
|
||||||
|
|
|
||||||
|
|
@ -31,7 +31,9 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Die Summe der Splitzeilen unter `felder` gegen `ziel` – das Ergebnis geht
|
/* 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) {
|
function splitsumme(felder, ziel) {
|
||||||
let summe = 0;
|
let summe = 0;
|
||||||
let belegt = false;
|
let belegt = false;
|
||||||
|
|
@ -50,7 +52,7 @@
|
||||||
belegt: belegt,
|
belegt: belegt,
|
||||||
summe: summe,
|
summe: summe,
|
||||||
rest: rest,
|
rest: rest,
|
||||||
text: belegt ? formatiere(summe) + (rest === 0 ? '' : ' (Rest ' + formatiere(rest) + ')') : '0,00'
|
text: formatiere(rest)
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,10 @@
|
||||||
/* Buchungsmaske: Restbetrag der Splitzeilen mitrechnen und direkte Zuordnung
|
/* Buchungsmaske: Restbetrag der Splitzeilen mitrechnen und direkte Zuordnung
|
||||||
* und Splitbuchung gegeneinander sperren.
|
* 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
|
* Nur Komfort – die Maske kommt fertig vom Server. Ohne JavaScript bleibt alles
|
||||||
* bedienbar: Die Splitsumme prüft ohnehin das Model.
|
* bedienbar: Die Splitsumme prüft ohnehin das Model.
|
||||||
*
|
*
|
||||||
|
|
@ -26,6 +30,7 @@
|
||||||
|
|
||||||
const summenfeld = document.getElementById('splitsumme');
|
const summenfeld = document.getElementById('splitsumme');
|
||||||
const betragsfeld = document.getElementById('betrag');
|
const betragsfeld = document.getElementById('betrag');
|
||||||
|
const speichernknopf = maske.querySelector('button[type="submit"]');
|
||||||
|
|
||||||
function rechneSumme() {
|
function rechneSumme() {
|
||||||
if (!summenfeld) {
|
if (!summenfeld) {
|
||||||
|
|
@ -46,6 +51,13 @@
|
||||||
|
|
||||||
summenfeld.textContent = stand.text;
|
summenfeld.textContent = stand.text;
|
||||||
summenfeld.classList.toggle('negativ', stand.belegt && stand.rest !== 0);
|
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']
|
const direktfelder = ['zuordnung', 'klasse_id']
|
||||||
|
|
|
||||||
|
|
@ -335,6 +335,7 @@
|
||||||
const zeilenkasten = document.getElementById('splitdialogzeilen');
|
const zeilenkasten = document.getElementById('splitdialogzeilen');
|
||||||
const summenfeld = document.getElementById('splitdialogsumme');
|
const summenfeld = document.getElementById('splitdialogsumme');
|
||||||
const loeschknopf = document.getElementById('splitloeschen');
|
const loeschknopf = document.getElementById('splitloeschen');
|
||||||
|
const speichernknopf = dialog.querySelector('button[type="submit"].primaer');
|
||||||
const vorlage = zeilenkasten.innerHTML;
|
const vorlage = zeilenkasten.innerHTML;
|
||||||
let buchungsbetrag = 0;
|
let buchungsbetrag = 0;
|
||||||
|
|
||||||
|
|
@ -342,10 +343,16 @@
|
||||||
return Array.from(zeilenkasten.querySelectorAll('.splitbetrag'));
|
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() {
|
function rechneSumme() {
|
||||||
const stand = window.FmBetrag.splitsumme(felder(), buchungsbetrag);
|
const stand = window.FmBetrag.splitsumme(felder(), buchungsbetrag);
|
||||||
summenfeld.textContent = stand.text;
|
summenfeld.textContent = stand.text;
|
||||||
summenfeld.classList.toggle('negativ', stand.belegt && stand.rest !== 0);
|
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.
|
/* Eine weitere Zeile: die letzte kopieren, leeren und neu durchnummerieren.
|
||||||
|
|
|
||||||
|
|
@ -205,7 +205,7 @@ include __DIR__ . '/../layout/kopf.php';
|
||||||
</tbody>
|
</tbody>
|
||||||
<tfoot>
|
<tfoot>
|
||||||
<tr>
|
<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>
|
<td class="rechts"><output id="splitsumme" class="betrag">0,00</output></td>
|
||||||
</tr>
|
</tr>
|
||||||
</tfoot>
|
</tfoot>
|
||||||
|
|
|
||||||
|
|
@ -507,7 +507,7 @@ include __DIR__ . '/../layout/kopf.php';
|
||||||
</tbody>
|
</tbody>
|
||||||
<tfoot>
|
<tfoot>
|
||||||
<tr>
|
<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>
|
<td class="rechts"><output id="splitdialogsumme" class="betrag">0,00</output></td>
|
||||||
</tr>
|
</tr>
|
||||||
</tfoot>
|
</tfoot>
|
||||||
|
|
|
||||||
|
|
@ -286,16 +286,25 @@ test.describe('Buchungen', () => {
|
||||||
await expect(dialog).toBeVisible()
|
await expect(dialog).toBeVisible()
|
||||||
await expect(dialog).toContainText('-90,00')
|
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('select[name="split[0][zuordnung]"]').selectOption({ label: KATEGORIE })
|
||||||
await dialog.locator('input[name="split[0][betrag]"]').fill('-50')
|
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('select[name="split[1][zuordnung]"]').selectOption({ label: `[${SPAR}]` })
|
||||||
await dialog.locator('input[name="split[1][betrag]"]').fill('-40')
|
await dialog.locator('input[name="split[1][betrag]"]').fill('-40')
|
||||||
|
|
||||||
// Der Dialog rechnet den Restbetrag mit; er muss aufgehen.
|
// 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.
|
// 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('.meldung-erfolg').last()).toContainText('Splitbuchung')
|
||||||
await expect(page.locator('tr', { hasText: `Aufzuteilen ${MARKE}` }).first())
|
await expect(page.locator('tr', { hasText: `Aufzuteilen ${MARKE}` }).first())
|
||||||
|
|
@ -494,21 +503,35 @@ test.describe('Buchungen', () => {
|
||||||
.not.toHaveAttribute('title')
|
.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`)
|
await page.goto(`/konten/${giroId}/buchung`)
|
||||||
|
const speichern = page.locator('.buchungsmaske button[type="submit"]')
|
||||||
|
|
||||||
await page.fill('#datum', '2026-05-03')
|
await page.fill('#datum', '2026-05-03')
|
||||||
await page.fill('#betrag', '100')
|
await page.fill('#betrag', '100')
|
||||||
await page.fill('#empfaenger', `Krumm ${MARKE}`)
|
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.selectOption('select[name="split[0][zuordnung]"]', { label: KATEGORIE })
|
||||||
await page.fill('input[name="split[0][betrag]"]', '-60')
|
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('#splitsumme')).toHaveText('0,00')
|
||||||
await expect(page.locator('.meldung-fehler')).toContainText('Splitzeilen')
|
await expect(speichern).toBeEnabled()
|
||||||
|
|
||||||
|
await speichern.click()
|
||||||
|
await expect(page.locator('.meldung-erfolg')).toBeVisible()
|
||||||
})
|
})
|
||||||
|
|
||||||
test('Kontoblatt lässt sich durchsuchen', async ({ page }) => {
|
test('Kontoblatt lässt sich durchsuchen', async ({ page }) => {
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue