Fix #129: Stück, Kurs und Betrag in der Depotbuchung automatisch berechnen

In „Depotbuchung erfassen“ ergibt sich bei Kauf und Verkauf aus zwei der drei
Felder Betrag, Stück und Kurs das dritte (Betrag = Stück × Kurs). Der
ausgerechnete Wert ist hervorgehoben und zieht nach, wenn sich die anderen
Felder ändern. Serverseitig rechnet Depotbuchung::dritterWert() dieselbe
Regel für den Weg ohne JavaScript.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
fds2610-bot 2026-10-12 21:41:29 +02:00
parent d3f298f26c
commit 9d771df028
10 changed files with 307 additions and 10 deletions

View file

@ -8,6 +8,8 @@ Das Format folgt [Keep a Changelog](https://keepachangelog.com/de/1.1.0/).
### Added ### Added
- Depotbuchung erfassen: Bei Kauf und Verkauf rechnet sich aus zwei der drei Felder
Betrag, Stück und Kurs automatisch das dritte (Betrag = Stück × Kurs) (#129)
- Wertpapiere: Beim Buchen eines Wertpapiergeschäfts (Dialog im Kontoblatt und - Wertpapiere: Beim Buchen eines Wertpapiergeschäfts (Dialog im Kontoblatt und
„Depotbuchung erfassen“) lässt sich eine Klasse wählen; sie gilt für die Buchung samt „Depotbuchung erfassen“) lässt sich eine Klasse wählen; sie gilt für die Buchung samt
aller Splitzeilen der Nebenkosten (#125) aller Splitzeilen der Nebenkosten (#125)

View file

@ -1,5 +1,13 @@
# Änderungen # Änderungen
## Depotbuchung erfassen: Stück, Kurs und Betrag rechnen sich (12.10.2026)
- Anlass (#129): Bei Kauf und Verkauf sollten sich Betrag, Stück und Kurs gegenseitig ergeben – Betrag = Stück × Kurs.
- Die Maske *Depotbuchung erfassen* rechnet bei Kauf und Verkauf das dritte Feld aus, sobald zwei eingetragen sind (`public/static/depotbuchung.js`): den Betrag auf zwei, Kurs und Stück auf sechs Nachkommastellen. Der ausgerechnete Wert ist farblich hinterlegt und zieht nach, wenn sich eines der anderen Felder ändert; selbst umgetippt gilt er als eingetragen, beim Leeren eines der anderen Felder verschwindet er. Bei Dividende, Ein- und Auslieferung wird nicht gerechnet; wechselt der Vorgang auf Kauf oder Verkauf, rechnet die Maske nach.
- Serverseitig dieselbe Regel für den Weg ohne JavaScript (`Depotbuchung::dritterWert()`): Neben dem Betrag ergeben sich jetzt auch Kurs bzw. Stück bei Kauf und Verkauf.
- Der Dialog des Wertpapiergeschäfts im Kontoblatt bleibt unverändert: Dort ist der Buchungsbetrag die feste Größe.
- Tests: `tests/63_DepotbuchungDritterWertTest.php`, `tests/e2e/specs/depotbuchung.spec.js`.
## Wertpapiere: Klasse beim Buchen wählen (12.10.2026) ## Wertpapiere: Klasse beim Buchen wählen (12.10.2026)
- Anlass (#125): Beim Buchen von Wertpapieren fehlte die Auswahl einer Klasse. - Anlass (#125): Beim Buchen von Wertpapieren fehlte die Auswahl einer Klasse.

View file

@ -108,6 +108,13 @@ enthält.
`stueck` trägt die Richtung: Zugänge positiv, Abgänge negativ, Dividenden null. `stueck` trägt die Richtung: Zugänge positiv, Abgänge negativ, Dividenden null.
In der Maske *Depotbuchung erfassen* genügen bei Kauf und Verkauf zwei der drei Angaben
Stück, Kurs und Betrag: Sobald zwei eingetragen sind, rechnet `public/static/depotbuchung.js`
das dritte aus (Betrag auf zwei, Stück und Kurs auf sechs Nachkommastellen) und hebt es
farblich hervor. Ein ausgerechneter Wert wird nachgezogen, wenn sich eines der anderen
Felder ändert; tippt man ihn selbst um, gilt er als eingetragen. Ohne JavaScript rechnet
`Depotbuchung::dritterWert()` dieselbe Regel beim Speichern.
## Einstand nach gleitendem Durchschnitt ## Einstand nach gleitendem Durchschnitt
Bei jedem Kauf steigt der Einstand um Kaufpreis samt Gebühren, bei jedem Verkauf sinkt er Bei jedem Kauf steigt der Einstand um Kaufpreis samt Gebühren, bei jedem Verkauf sinkt er

View file

@ -1622,6 +1622,10 @@ table.liste th.bargeldspalte {
margin: .15rem 0 0; margin: .15rem 0 0;
} }
/* Ein Feld, dessen Wert die Maske aus den anderen ausgerechnet hat
* (Stück, Kurs, Betrag in „Depotbuchung erfassen“). */
input.berechnet { background: var(--akzent-schwach); }
/* Der Hinweis hinter einem Ankreuzfeld statt darunter – bei einer Liste /* Der Hinweis hinter einem Ankreuzfeld statt darunter – bei einer Liste
gleichartiger Schalter hielte eine eigene Zeile je Hinweis sie zu weit gleichartiger Schalter hielte eine eigene Zeile je Hinweis sie zu weit
auseinander. */ auseinander. */

View file

@ -0,0 +1,111 @@
/* Stück, Kurs und Betrag in der Maske „Depotbuchung erfassen“.
*
* Bei Kauf und Verkauf gilt Betrag = Stück × Kurs: Sobald zwei der drei Felder
* eingetragen sind, ergibt sich das dritte. Der ausgerechnete Wert bleibt
* ausgerechnet – ändert man danach eines der beiden anderen Felder, wird er
* nachgezogen. Tippt man ihn selbst um, gilt er als eingetragen; leert man eines
* der beiden anderen Felder, verschwindet er wieder, weil nichts mehr dahinter
* steht.
*
* Der Server rechnet dieselbe Regel noch einmal (Depotbuchung::dritterWert) –
* für den Weg ohne JavaScript.
*/
(function () {
'use strict';
const formular = document.getElementById('depotbuchungsformular');
if (!formular || !window.FmBetrag) {
return;
}
const vorgang = document.getElementById('vorgang');
const felder = {
stueck: document.getElementById('stueck'),
kurs: document.getElementById('kurs'),
betrag: document.getElementById('betrag')
};
if (!vorgang || !felder.stueck || !felder.kurs || !felder.betrag) {
return;
}
// Welches Feld zuletzt ausgerechnet wurde – null, solange alle eingetragen sind.
let berechnet = null;
function wert(name) {
return window.FmBetrag.zahl(felder[name].value);
}
function leer(name) {
return felder[name].value.trim() === '';
}
function formatiere(name, zahl) {
if (name === 'betrag') {
return window.FmBetrag.formatiere(zahl);
}
// Stück und Kurs führt die Datenbank mit sechs Nachkommastellen.
return zahl.toLocaleString('de-DE', { minimumFractionDigits: 0, maximumFractionDigits: 6 });
}
function setze(name, zahl) {
felder[name].value = zahl === null ? '' : formatiere(name, zahl);
felder[name].classList.toggle('berechnet', zahl !== null);
berechnet = zahl === null ? null : name;
}
function ausrechnen(name) {
const stueck = wert('stueck');
const kurs = wert('kurs');
const betrag = wert('betrag');
switch (name) {
case 'betrag':
return stueck > 0 && kurs > 0 ? Math.round(stueck * kurs * 100) / 100 : null;
case 'kurs':
return stueck > 0 && betrag > 0 ? Math.round(betrag / stueck * 1e6) / 1e6 : null;
default:
return kurs > 0 && betrag > 0 ? Math.round(betrag / kurs * 1e6) / 1e6 : null;
}
}
function rechne(geaendert) {
if (geaendert !== null && geaendert === berechnet) {
felder[geaendert].classList.remove('berechnet');
berechnet = null;
}
if (vorgang.value !== 'kauf' && vorgang.value !== 'verkauf') {
return;
}
if (geaendert !== null && leer(geaendert) && berechnet !== null) {
setze(berechnet, null);
return;
}
let ziel = berechnet;
if (ziel === null) {
const offen = Object.keys(felder).filter(leer);
if (offen.length !== 1) {
return;
}
ziel = offen[0];
}
const ergebnis = ausrechnen(ziel);
if (ergebnis !== null) {
setze(ziel, ergebnis);
} else if (ziel === berechnet) {
setze(ziel, null);
}
}
Object.keys(felder).forEach(function (name) {
felder[name].addEventListener('input', function () {
rechne(name);
});
});
vorgang.addEventListener('change', function () {
rechne(null);
});
})();

View file

@ -141,21 +141,25 @@ class WertpapiereController extends BaseController
]); ]);
} }
$stueck = $this->zahl($this->post('stueck'));
$kurs = $this->zahl($this->post('kurs'));
$betrag = $this->zahl($this->post('betrag'));
// Zwei von drei Angaben genügen: Der dritte Wert ergibt sich. // Zwei von drei Angaben genügen: Der dritte Wert ergibt sich.
if ($betrag <= 0.0 && $stueck > 0.0 && $kurs > 0.0) { $vorgang = $this->post('vorgang', 'kauf');
$betrag = round($stueck * $kurs, 2); [
} 'stueck' => $stueck,
'kurs' => $kurs,
'betrag' => $betrag,
] = Depotbuchung::dritterWert(
$vorgang,
$this->zahl($this->post('stueck')),
$this->zahl($this->post('kurs')),
$this->zahl($this->post('betrag'))
);
Depotbuchung::anlegen([ Depotbuchung::anlegen([
'konto_id' => (int)$this->post('konto_id'), 'konto_id' => (int)$this->post('konto_id'),
'wertpapier_id' => $wertpapierId, 'wertpapier_id' => $wertpapierId,
'datum' => $this->post('datum'), 'datum' => $this->post('datum'),
'valuta' => $this->post('valuta'), 'valuta' => $this->post('valuta'),
'vorgang' => $this->post('vorgang', 'kauf'), 'vorgang' => $vorgang,
'stueck' => $stueck, 'stueck' => $stueck,
'kurs' => $kurs > 0.0 ? $kurs : null, 'kurs' => $kurs > 0.0 ? $kurs : null,
'betrag' => $betrag, 'betrag' => $betrag,

View file

@ -69,6 +69,32 @@ class Depotbuchung
return self::QIF_VORGAENGE[trim($vorgang)] ?? 'kauf'; return self::QIF_VORGAENGE[trim($vorgang)] ?? 'kauf';
} }
/**
* Zwei von drei Angaben genügen: Betrag = Stück × Kurs.
*
* Die Maske „Depotbuchung erfassen“ rechnet den dritten Wert schon im
* Browser aus (`depotbuchung.js`); hier steht dieselbe Regel für den Weg
* ohne JavaScript. Der Betrag ergibt sich bei jedem Vorgang, Kurs und Stück
* nur bei Kauf und Verkauf – nur dort hängen die drei sicher zusammen.
* Eine fehlende Angabe ist 0.
*
* @return array{stueck:float,kurs:float,betrag:float}
*/
public static function dritterWert(string $vorgang, float $stueck, float $kurs, float $betrag): array
{
if ($betrag <= 0.0 && $stueck > 0.0 && $kurs > 0.0) {
$betrag = round($stueck * $kurs, 2);
} elseif (in_array($vorgang, ['kauf', 'verkauf'], true) && $betrag > 0.0) {
if ($kurs <= 0.0 && $stueck > 0.0) {
$kurs = round($betrag / $stueck, 6);
} elseif ($stueck <= 0.0 && $kurs > 0.0) {
$stueck = round($betrag / $kurs, 6);
}
}
return ['stueck' => $stueck, 'kurs' => $kurs, 'betrag' => $betrag];
}
public static function find(int $id): ?array public static function find(int $id): ?array
{ {
$zeile = Database::fetchOne( $zeile = Database::fetchOne(

View file

@ -19,7 +19,7 @@ include __DIR__ . '/../layout/kopf.php';
<a href="/wertpapiere/buchungen" class="knopf">Zurück zu den Depotbuchungen</a> <a href="/wertpapiere/buchungen" class="knopf">Zurück zu den Depotbuchungen</a>
</div> </div>
<form method="post" action="/wertpapiere/buchungen/neu" class="formular breit"> <form method="post" action="/wertpapiere/buchungen/neu" class="formular breit" id="depotbuchungsformular">
<input type="hidden" name="csrf_token" value="<?= View::h($csrfToken) ?>"> <input type="hidden" name="csrf_token" value="<?= View::h($csrfToken) ?>">
<label for="konto_id">Depot</label> <label for="konto_id">Depot</label>
@ -83,7 +83,8 @@ include __DIR__ . '/../layout/kopf.php';
<h3>Beträge</h3> <h3>Beträge</h3>
<p class="feldhinweis"> <p class="feldhinweis">
Zwei von drei Angaben genügen: Fehlt der Betrag, ergibt er sich aus Stück mal Kurs. Zwei von drei Angaben genügen: Betrag = Stück × Kurs. Bei Kauf und Verkauf rechnet
die Maske den dritten Wert aus, sobald zwei eingetragen sind.
Gebühren und Steuern stehen <em>neben</em> dem Betrag – auf dem Verrechnungskonto Gebühren und Steuern stehen <em>neben</em> dem Betrag – auf dem Verrechnungskonto
wird bei einem Kauf die Summe belastet, bei einem Verkauf die Differenz gutgeschrieben. wird bei einem Kauf die Summe belastet, bei einem Verkauf die Differenz gutgeschrieben.
</p> </p>
@ -143,4 +144,7 @@ include __DIR__ . '/../layout/kopf.php';
</div> </div>
</form> </form>
<script src="/static/betrag.js" defer></script>
<script src="/static/depotbuchung.js" defer></script>
<?php include __DIR__ . '/../layout/fuss.php'; ?> <?php include __DIR__ . '/../layout/fuss.php'; ?>

View file

@ -0,0 +1,52 @@
<?php
declare(strict_types=1);
use Finanzmanager\Models\Depotbuchung;
/**
* Fix #129: Bei Kauf und Verkauf ergibt sich aus zwei der drei Angaben Stück,
* Kurs und Betrag die dritte – Betrag = Stück × Kurs. Die Maske rechnet im
* Browser, der Server dieselbe Regel für den Weg ohne JavaScript.
*/
class DepotbuchungDritterWertTest extends TestCase
{
protected string $testName = 'Test 63: Depotbuchung – dritten Wert aus Stück, Kurs und Betrag';
public function run(): void
{
$werte = Depotbuchung::dritterWert('kauf', 12.0, 45.67, 0.0);
$this->assertEquals(548.04, $werte['betrag'], 'Kauf: Betrag = Stück × Kurs');
$this->assertEquals(12.0, $werte['stueck'], 'Kauf: Stück bleibt');
$this->assertEquals(45.67, $werte['kurs'], 'Kauf: Kurs bleibt');
$werte = Depotbuchung::dritterWert('verkauf', 8.0, 0.0, 1000.0);
$this->assertEquals(125.0, $werte['kurs'], 'Verkauf: Kurs = Betrag / Stück');
$werte = Depotbuchung::dritterWert('kauf', 0.0, 3.0, 100.0);
$this->assertEquals(33.333333, $werte['stueck'], 'Kauf: Stück = Betrag / Kurs, sechs Nachkommastellen');
$werte = Depotbuchung::dritterWert('kauf', 10.0, 20.0, 150.0);
$this->assertEquals(
['stueck' => 10.0, 'kurs' => 20.0, 'betrag' => 150.0],
$werte,
'Alle drei angegeben: nichts wird überschrieben'
);
$werte = Depotbuchung::dritterWert('kauf', 0.0, 0.0, 150.0);
$this->assertEquals(
['stueck' => 0.0, 'kurs' => 0.0, 'betrag' => 150.0],
$werte,
'Nur eine Angabe: nichts zu rechnen'
);
$werte = Depotbuchung::dritterWert('dividende', 0.0, 2.0, 50.0);
$this->assertEquals(0.0, $werte['stueck'], 'Dividende: keine Stückzahl aus Betrag und Kurs');
$werte = Depotbuchung::dritterWert('einlage', 5.0, 0.0, 500.0);
$this->assertEquals(0.0, $werte['kurs'], 'Einlieferung: kein Kurs aus Betrag und Stück');
$werte = Depotbuchung::dritterWert('einlage', 5.0, 10.0, 0.0);
$this->assertEquals(50.0, $werte['betrag'], 'Einlieferung: Betrag aus Stück × Kurs wie bisher');
}
}

View file

@ -0,0 +1,79 @@
// @ts-check
const { test, expect } = require('@playwright/test')
/*
* Fix #129: In „Depotbuchung erfassen“ hängen bei Kauf und Verkauf Stück, Kurs
* und Betrag zusammen – Betrag = Stück × Kurs. Sobald zwei Felder eingetragen
* sind, rechnet die Maske das dritte aus.
*/
const BENUTZER = process.env.E2E_USER || 'e2e'
const PASSWORT = process.env.E2E_PASSWORD || 'e2e-testpasswort'
async function anmelden(page) {
await page.goto('/login')
if (page.url().includes('/einrichtung')) {
throw new Error(
'Instanz ist noch nicht eingerichtet: ' +
'docker compose exec -u www-data php php scripts/e2e-benutzer.php'
)
}
await page.fill('#benutzername', BENUTZER)
await page.fill('#passwort', PASSWORT)
await page.click('button[type="submit"]')
await expect(page).toHaveURL(/\/$/)
}
test.describe('Depotbuchung erfassen: Stück, Kurs und Betrag', () => {
test.beforeEach(async ({ page }) => {
await anmelden(page)
await page.goto('/wertpapiere/buchungen/neu')
})
test('Aus Stück und Kurs ergibt sich der Betrag', async ({ page }) => {
await page.selectOption('#vorgang', 'kauf')
await page.fill('#stueck', '12')
await page.fill('#kurs', '45,67')
await expect(page.locator('#betrag')).toHaveValue('548,04')
await expect(page.locator('#betrag')).toHaveClass(/berechnet/)
// Der ausgerechnete Betrag zieht nach, wenn sich ein Feld ändert.
await page.fill('#stueck', '10')
await expect(page.locator('#betrag')).toHaveValue('456,70')
// Wird ein Feld geleert, steht hinter dem Betrag nichts mehr.
await page.fill('#kurs', '')
await expect(page.locator('#betrag')).toHaveValue('')
})
test('Aus Betrag und Stück ergibt sich der Kurs', async ({ page }) => {
await page.selectOption('#vorgang', 'verkauf')
await page.fill('#betrag', '1.000,00')
await page.fill('#stueck', '8')
await expect(page.locator('#kurs')).toHaveValue('125')
})
test('Aus Betrag und Kurs ergibt sich die Stückzahl', async ({ page }) => {
await page.selectOption('#vorgang', 'kauf')
await page.fill('#kurs', '3')
await page.fill('#betrag', '100')
await expect(page.locator('#stueck')).toHaveValue('33,333333')
// Selbst eingetragen gilt der Wert als fest und wird nicht mehr überschrieben.
await page.fill('#stueck', '30')
await page.fill('#kurs', '4')
await expect(page.locator('#stueck')).toHaveValue('30')
await expect(page.locator('#betrag')).toHaveValue('100')
})
test('Bei einer Dividende wird nichts gerechnet', async ({ page }) => {
await page.selectOption('#vorgang', 'dividende')
await page.fill('#stueck', '12')
await page.fill('#kurs', '2')
await expect(page.locator('#betrag')).toHaveValue('')
// Wechselt der Vorgang auf Kauf, rechnet die Maske nach.
await page.selectOption('#vorgang', 'kauf')
await expect(page.locator('#betrag')).toHaveValue('24,00')
})
})