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:
parent
d3f298f26c
commit
9d771df028
10 changed files with 307 additions and 10 deletions
|
|
@ -8,6 +8,8 @@ Das Format folgt [Keep a Changelog](https://keepachangelog.com/de/1.1.0/).
|
|||
|
||||
### 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
|
||||
„Depotbuchung erfassen“) lässt sich eine Klasse wählen; sie gilt für die Buchung samt
|
||||
aller Splitzeilen der Nebenkosten (#125)
|
||||
|
|
|
|||
|
|
@ -1,5 +1,13 @@
|
|||
# Ä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)
|
||||
|
||||
- Anlass (#125): Beim Buchen von Wertpapieren fehlte die Auswahl einer Klasse.
|
||||
|
|
|
|||
|
|
@ -108,6 +108,13 @@ enthält.
|
|||
|
||||
`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
|
||||
|
||||
Bei jedem Kauf steigt der Einstand um Kaufpreis samt Gebühren, bei jedem Verkauf sinkt er
|
||||
|
|
|
|||
|
|
@ -1622,6 +1622,10 @@ table.liste th.bargeldspalte {
|
|||
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
|
||||
gleichartiger Schalter hielte eine eigene Zeile je Hinweis sie zu weit
|
||||
auseinander. */
|
||||
|
|
|
|||
111
public/static/depotbuchung.js
Normal file
111
public/static/depotbuchung.js
Normal 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);
|
||||
});
|
||||
})();
|
||||
|
|
@ -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.
|
||||
if ($betrag <= 0.0 && $stueck > 0.0 && $kurs > 0.0) {
|
||||
$betrag = round($stueck * $kurs, 2);
|
||||
}
|
||||
$vorgang = $this->post('vorgang', 'kauf');
|
||||
[
|
||||
'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([
|
||||
'konto_id' => (int)$this->post('konto_id'),
|
||||
'wertpapier_id' => $wertpapierId,
|
||||
'datum' => $this->post('datum'),
|
||||
'valuta' => $this->post('valuta'),
|
||||
'vorgang' => $this->post('vorgang', 'kauf'),
|
||||
'vorgang' => $vorgang,
|
||||
'stueck' => $stueck,
|
||||
'kurs' => $kurs > 0.0 ? $kurs : null,
|
||||
'betrag' => $betrag,
|
||||
|
|
|
|||
|
|
@ -69,6 +69,32 @@ class Depotbuchung
|
|||
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
|
||||
{
|
||||
$zeile = Database::fetchOne(
|
||||
|
|
|
|||
|
|
@ -19,7 +19,7 @@ include __DIR__ . '/../layout/kopf.php';
|
|||
<a href="/wertpapiere/buchungen" class="knopf">Zurück zu den Depotbuchungen</a>
|
||||
</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) ?>">
|
||||
|
||||
<label for="konto_id">Depot</label>
|
||||
|
|
@ -83,7 +83,8 @@ include __DIR__ . '/../layout/kopf.php';
|
|||
|
||||
<h3>Beträge</h3>
|
||||
<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
|
||||
wird bei einem Kauf die Summe belastet, bei einem Verkauf die Differenz gutgeschrieben.
|
||||
</p>
|
||||
|
|
@ -143,4 +144,7 @@ include __DIR__ . '/../layout/kopf.php';
|
|||
</div>
|
||||
</form>
|
||||
|
||||
<script src="/static/betrag.js" defer></script>
|
||||
<script src="/static/depotbuchung.js" defer></script>
|
||||
|
||||
<?php include __DIR__ . '/../layout/fuss.php'; ?>
|
||||
|
|
|
|||
52
tests/63_DepotbuchungDritterWertTest.php
Normal file
52
tests/63_DepotbuchungDritterWertTest.php
Normal 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');
|
||||
}
|
||||
}
|
||||
79
tests/e2e/specs/depotbuchung.spec.js
Normal file
79
tests/e2e/specs/depotbuchung.spec.js
Normal 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')
|
||||
})
|
||||
})
|
||||
Loading…
Add table
Reference in a new issue