Detailseite eines Wertpapiers

Der Name in Bestandsübersicht, Depotbuchungen und Stammdaten führt jetzt auf
/wertpapiere/papier/{id}: Kennzahlen über die ganze Historie, Stammdaten und
Kurs, Bestand je Depot, Wertentwicklung und die Buchungen eines Zeitraums.

Depot::bestand() und Depot::entwicklung() nehmen dafür eine Wertpapiernummer
entgegen – die Detailseite rechnet nicht selbst, sondern bekommt dieselben
Zahlen wie die Übersicht. Neu sind Wertpapier::kurseckwerte() und
Depotbuchung::datumsgrenzen().

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
fds2610-bot 2026-08-13 15:08:01 +02:00
parent b99402ccd8
commit be3ee6400e
17 changed files with 979 additions and 17 deletions

View file

@ -1,5 +1,35 @@
# Änderungen # Änderungen
## Detailseite eines Wertpapiers (13.08.2026)
Bisher endete der Weg zu einem Wertpapier in einer Tabellenzeile: Stückzahl, Kurs, Wert –
und wer mehr wissen wollte, musste sich die Angaben aus Stammdaten, Depotbuchungen und
Auswertungen zusammensuchen. Der Name in der Übersicht war kein Link.
**Die neue Seite** (`/wertpapiere/papier/{id}`, `Views/wertpapiere/papier.php`,
`WertpapiereController::papier()`)
- Erreichbar über den Wertpapiernamen in der Bestandsübersicht und in der Liste der
Depotbuchungen sowie über *Details* in den Stammdaten.
- Oben die Kennzahlen über die ganze Historie: Bestand, Wert, Einstand, nicht realisierter
und realisierter Gewinn, Erträge.
- Darunter zwei Kacheln: **Stammdaten** (ISIN, WKN, Art, Währung, Kürzel, Status, erste
und letzte Buchung) und **Kurs** (letzter Kurs mit Datum und Quelle, Hoch und Tief der
letzten zwölf Monate, Einstand je Stück).
- **Bestand je Depot** – ein Papier kann in mehreren Depots liegen – und die
**Wertentwicklung** als dasselbe Diagramm wie in der Depotübersicht, auf dieses Papier
gerechnet.
- Unten die **Buchungen** eines Zeitraums, voreingestellt das laufende Jahr, mit denselben
Vorgaben wie die Auswertungen. Dazu eine Zeile mit Zu- und Abgängen und den Erträgen des
Zeitraums. Die Kennzahlen oben bleiben davon unberührt.
**Unter der Haube**
- `Depot::bestand()` und `Depot::entwicklung()` nehmen eine Wertpapiernummer entgegen. Die
Detailseite rechnet damit nicht selbst, sondern bekommt dieselben Zahlen wie die
Übersicht – beide können nicht auseinanderlaufen.
- Neu: `Wertpapier::kurseckwerte()` (Hoch, Tief, Zahl der Kurse eines Zeitraums) und
`Depotbuchung::datumsgrenzen()` (erste und letzte Buchung, wahlweise je Papier).
- `View::stueck()` formatiert Stückzahlen an einer Stelle statt in vier Ansichten.
## Wertpapiergeschäft aus der Kontobuchung (13.08.2026) ## Wertpapiergeschäft aus der Kontobuchung (13.08.2026)
Bisher konnte eine Depotbuchung ihre Geldbuchung nur selbst anlegen. Der Weg, den der Bisher konnte eine Depotbuchung ihre Geldbuchung nur selbst anlegen. Der Weg, den der

View file

@ -32,7 +32,9 @@ Haushalt wird beides parallel genutzt.
übernehmen. Eine abgerufene Wertpapierabrechnung wird im Kontoblatt zum Geschäft: Das übernehmen. Eine abgerufene Wertpapierabrechnung wird im Kontoblatt zum Geschäft: Das
Depot in der Zuordnungsliste öffnet die Maske für Stück, Kurs und Nebenkosten, und Depot in der Zuordnungsliste öffnet die Maske für Stück, Kurs und Nebenkosten, und
daraus entstehen die Depotbuchung mit dem Einstandspreis und die Splitbuchung, die die daraus entstehen die Depotbuchung mit dem Einstandspreis und die Splitbuchung, die die
Gebühren und Steuern auf ihre Kategorien verteilt. Gebühren und Steuern auf ihre Kategorien verteilt. Jeder Wertpapiername führt auf eine
Detailseite mit Stammdaten, Kurs, Bestand je Depot, Wertentwicklung und den Buchungen
eines wählbaren Zeitraums.
* **Nebenmodule** – Buchungsvorlagen, regelmäßige Buchungen mit Fälligkeitslauf und * **Nebenmodule** – Buchungsvorlagen, regelmäßige Buchungen mit Fälligkeitslauf und
Finanzkalender, Erinnerungen, Adressen, Verträge, Sparziele, Kredite mit Tilgungsplan. Finanzkalender, Erinnerungen, Adressen, Verträge, Sparziele, Kredite mit Tilgungsplan.
* **Zahlungsverkehr** – Überweisungen und Terminüberweisungen über FinTS. * **Zahlungsverkehr** – Überweisungen und Terminüberweisungen über FinTS.

View file

@ -116,6 +116,30 @@ Einstand.
Vollständig verkaufte Positionen verschwinden aus der Bestandsliste; ihr realisierter Vollständig verkaufte Positionen verschwinden aus der Bestandsliste; ihr realisierter
Gewinn zählt weiter mit. Gewinn zählt weiter mit.
## Detailseite eines Wertpapiers
Der Name in der Bestandsübersicht, in der Liste der Depotbuchungen und in den Stammdaten
führt auf `/wertpapiere/papier/{id}` – alles zu einem Papier auf einer Seite:
* **Kennzahlen** über die ganze Historie: Bestand, Wert, Einstand, nicht realisierter und
realisierter Gewinn, Erträge. Wer wissen will, was ein Papier gebracht hat, meint alles
seit dem ersten Kauf – nicht den gewählten Berichtszeitraum.
* **Stammdaten und Kurs** in je einer Kachel: ISIN, WKN, Art, Währung, Kürzel, Status,
erste und letzte Buchung; daneben der letzte Kurs mit Datum und Quelle, Hoch und Tief
der letzten zwölf Monate (`Wertpapier::kurseckwerte()`) und der Einstand je Stück.
* **Bestand je Depot**: Ein Papier kann in mehreren Depots liegen; gerechnet wird je Depot,
weil auch der Einstand je Depot geführt wird. Ist alles verkauft, bleibt die Seite
bestehen – realisierte Gewinne und Erträge zählen weiter.
* **Wertentwicklung** – dasselbe Diagramm wie in der Depotübersicht, nur auf dieses Papier
gerechnet, und nur, wenn es Kurshistorie gibt.
* **Buchungen** eines Zeitraums, voreingestellt das laufende Jahr, mit denselben Vorgaben
wie die Auswertungen (`Services\Zeitraum`). Darunter eine Zeile mit Zu- und Abgängen und
den Erträgen des Zeitraums.
Gerechnet wird nicht neu: `Depot::bestand()` nimmt eine Wertpapiernummer entgegen und
liefert dieselben Zahlen wie die Übersicht, nur auf ein Papier eingegrenzt. Damit können
Detailseite und Übersicht nicht auseinanderlaufen.
## Kurse ## Kurse
Ein Kurs je Wertpapier und Tag. Mehr Auflösung braucht eine Vermögensübersicht nicht, und Ein Kurs je Wertpapier und Tag. Mehr Auflösung braucht eine Vermögensübersicht nicht, und
@ -265,6 +289,13 @@ Steuern, das Löschen, das die Bankbuchung stehen lässt, sowie die Abweisungen:
Geschäft, Kosten in Höhe der Buchung, Konto statt Depot, Kauf ohne Stückzahl. Die beiden Geschäft, Kosten in Höhe der Buchung, Konto statt Depot, Kauf ohne Stückzahl. Die beiden
Dialoge prüft `tests/e2e/specs/wertpapiergeschaeft.spec.js`. Dialoge prüft `tests/e2e/specs/wertpapiergeschaeft.spec.js`.
`tests/26_WertpapierDetailTest.php` – die Detailseite: der auf ein Papier eingegrenzte
Bestand (fremde Papiere bleiben draußen, mehrere Depots werden je Position geführt, Depot
und Papier lassen sich zugleich eingrenzen), Hoch und Tief der Kurse, die Datumsgrenzen
der Depotbuchungen, das Format der Stückzahlen sowie die gerenderte Seite: Stammdaten,
Kennzahlen und eine Buchungsliste, die dem Zeitraum folgt, während die Kennzahlen es nicht
tun. Den Klick vom Namen in der Übersicht prüft `tests/e2e/specs/wertpapierdetail.spec.js`.
`tests/21_DepotbestandImportTest.php` – der CSV-Bestandsimport an einem Ausschnitt des `tests/21_DepotbestandImportTest.php` – der CSV-Bestandsimport an einem Ausschnitt des
echten DKB-Exports: Vorschau ohne Nebenwirkung, Einstand und Wert aus beiden Kursen, echten DKB-Exports: Vorschau ohne Nebenwirkung, Einstand und Wert aus beiden Kursen,
Bruchstückzahlen, Assetklasse → Art, hinterlegte Bewertungskurse, Idempotenz beim zweiten Bruchstückzahlen, Assetklasse → Art, hinterlegte Bewertungskurse, Idempotenz beim zweiten

View file

@ -247,6 +247,7 @@ $router->post('/zahlungsverkehr/{id}/loeschen', fn(array $p) => (new Zahlungsver
// ── Wertpapiere: Bestände, Depotbuchungen, Kurse, Auswertungen ────────────── // ── Wertpapiere: Bestände, Depotbuchungen, Kurse, Auswertungen ──────────────
$router->get('/wertpapiere', fn() => (new WertpapiereController())->index()); $router->get('/wertpapiere', fn() => (new WertpapiereController())->index());
$router->get('/wertpapiere/auswertung', fn() => (new WertpapiereController())->auswertung()); $router->get('/wertpapiere/auswertung', fn() => (new WertpapiereController())->auswertung());
$router->get('/wertpapiere/papier/{id}', fn(array $p) => (new WertpapiereController())->papier($p));
$router->get('/wertpapiere/buchungen', fn() => (new WertpapiereController())->buchungen()); $router->get('/wertpapiere/buchungen', fn() => (new WertpapiereController())->buchungen());
$router->get('/wertpapiere/buchungen/neu', fn() => (new WertpapiereController())->neu()); $router->get('/wertpapiere/buchungen/neu', fn() => (new WertpapiereController())->neu());

View file

@ -1721,6 +1721,24 @@ table.bericht .wertlink:hover {
margin: 0; margin: 0;
} }
/* Stammdaten einer Detailseite: Merkmal links, Wert rechts. Kein Formular,
also auch keine Feldpaare – nur zwei Spalten, die untereinander bündig
bleiben, und ein Umbruch auf eine Spalte, wenn es eng wird. */
.merkmale {
display: grid;
grid-template-columns: minmax(6rem, auto) 1fr;
gap: .3rem .9rem;
margin: 0;
}
.merkmale dt {
color: var(--text-gedaempft);
font-size: .85rem;
align-self: center;
}
.merkmale dd { margin: 0; }
/* Zahlenlastige Tabellen bekommen Ziffernschrift in den rechtsbündigen /* Zahlenlastige Tabellen bekommen Ziffernschrift in den rechtsbündigen
Spalten – so stehen Kommastellen untereinander. */ Spalten – so stehen Kommastellen untereinander. */
table.liste.zahlen td.rechts { font-family: var(--schrift-zahl); } table.liste.zahlen td.rechts { font-family: var(--schrift-zahl); }

View file

@ -38,6 +38,54 @@ class WertpapiereController extends BaseController
]); ]);
} }
/**
* Die Detailseite eines Wertpapiers: Stammdaten, Kurs, Bestand je Depot,
* die Wertentwicklung – und unten die Buchungen eines Zeitraums.
*
* Die Kennzahlen oben gelten für die ganze Historie: Wer wissen will, was
* ein Papier gebracht hat, meint alles seit dem ersten Kauf. Nur die Liste
* unten folgt dem gewählten Zeitraum, voreingestellt das laufende Jahr.
*
* @param array<string,string> $params
*/
public function papier(array $params): void
{
$this->verlangeAnmeldung();
$id = (int)$params['id'];
$wertpapier = Wertpapier::find($id);
if ($wertpapier === null) {
View::redirect('/wertpapiere/stammdaten', 'Dieses Wertpapier gibt es nicht.', 'fehler');
return;
}
$grenzen = Depotbuchung::datumsgrenzen($id);
$zeitraum = Zeitraum::berechne(
$this->query('zeitraum', 'aktuelles_jahr'),
$this->query('von'),
$this->query('bis'),
null,
$grenzen
);
$this->render('wertpapiere/papier.php', [
'titel' => (string)$wertpapier['name'],
'wertpapier' => $wertpapier,
'bestand' => Depot::bestand(null, null, $id),
'kurs' => Wertpapier::letzterKurs($id),
'eckwerte' => Wertpapier::kurseckwerte($id, date('Y-m-d', strtotime('-1 year'))),
'entwicklung' => Depot::entwicklung(null, 12, null, $id),
'grenzen' => $grenzen,
'zeitraum' => $zeitraum,
'buchungen' => Depotbuchung::liste([
'wertpapier_id' => $id,
'von' => $zeitraum['von'],
'bis' => $zeitraum['bis'],
]),
]);
}
public function buchungen(): void public function buchungen(): void
{ {
$this->verlangeAnmeldung(); $this->verlangeAnmeldung();

View file

@ -116,6 +116,34 @@ class Depotbuchung
return Database::fetchAll($sql . ' ORDER BY d.datum DESC, d.id DESC', $params); return Database::fetchAll($sql . ' ORDER BY d.datum DESC, d.id DESC', $params);
} }
/**
* Erste und letzte Depotbuchung – für ein Wertpapier oder über alle.
*
* Die Detailseite zeigt daraus, seit wann das Papier geführt wird, und
* begrenzt damit den Zeitraum „Gesamter Zeitraum“ auf das, was es
* tatsächlich gibt.
*
* @return array{von: ?string, bis: ?string}
*/
public static function datumsgrenzen(?int $wertpapierId = null): array
{
$params = [Mandant::id()];
$sql = 'SELECT MIN(datum) AS von, MAX(datum) AS bis
FROM depot_buchungen WHERE benutzer_id = ?';
if ($wertpapierId !== null) {
$sql .= ' AND wertpapier_id = ?';
$params[] = $wertpapierId;
}
$zeile = Database::fetchOne($sql, $params);
return [
'von' => ($zeile['von'] ?? null) === null ? null : (string)$zeile['von'],
'bis' => ($zeile['bis'] ?? null) === null ? null : (string)$zeile['bis'],
];
}
/** /**
* Die Depotbuchung zu einer Geldbuchung – oder null, wenn die Buchung kein * Die Depotbuchung zu einer Geldbuchung – oder null, wenn die Buchung kein
* Wertpapiergeschäft ist. * Wertpapiergeschäft ist.

View file

@ -262,6 +262,32 @@ class Wertpapier
return $kurse; return $kurse;
} }
/**
* Hoch und Tief eines Zeitraums, dazu die Zahl der bekannten Kurse – die
* Detailseite stellt sie neben den letzten Kurs. Ohne einen einzigen Kurs
* im Zeitraum gibt es nichts zu zeigen: null.
*
* @return array{tief: float, hoch: float, anzahl: int}|null
*/
public static function kurseckwerte(int $wertpapierId, string $von): ?array
{
$zeile = Database::fetchOne(
'SELECT MIN(kurs) AS tief, MAX(kurs) AS hoch, COUNT(*) AS anzahl
FROM kurse WHERE wertpapier_id = ? AND datum >= ?',
[$wertpapierId, $von]
);
if ($zeile === false || (int)$zeile['anzahl'] === 0) {
return null;
}
return [
'tief' => (float)$zeile['tief'],
'hoch' => (float)$zeile['hoch'],
'anzahl' => (int)$zeile['anzahl'],
];
}
/** @return list<array<string,mixed>> */ /** @return list<array<string,mixed>> */
public static function kursverlauf(int $wertpapierId, int $tage = 365): array public static function kursverlauf(int $wertpapierId, int $tage = 365): array
{ {

View file

@ -29,6 +29,10 @@ class Depot
/** /**
* Die Bestände eines Depots (oder aller Depots) zum Stichtag. * Die Bestände eines Depots (oder aller Depots) zum Stichtag.
* *
* Mit `$wertpapierId` bleibt ein einziges Papier übrig – so rechnet die
* Detailseite dieselben Zahlen wie die Übersicht, nur je Depot statt für
* das ganze Depot.
*
* @return array{ * @return array{
* positionen: list<array<string,mixed>>, * positionen: list<array<string,mixed>>,
* einstand: float, * einstand: float,
@ -39,8 +43,11 @@ class Depot
* bewertbar: bool * bewertbar: bool
* } * }
*/ */
public static function bestand(?int $kontoId = null, ?string $stichtag = null): array public static function bestand(
{ ?int $kontoId = null,
?string $stichtag = null,
?int $wertpapierId = null
): array {
$stichtag ??= date('Y-m-d'); $stichtag ??= date('Y-m-d');
$kurse = Wertpapier::letzteKurse($stichtag); $kurse = Wertpapier::letzteKurse($stichtag);
@ -48,7 +55,7 @@ class Depot
$realisiert = 0.0; $realisiert = 0.0;
$ertraege = 0.0; $ertraege = 0.0;
foreach (self::buchungen($kontoId, $stichtag) as $buchung) { foreach (self::buchungen($kontoId, $stichtag, $wertpapierId) as $buchung) {
$schluessel = (int)$buchung['wertpapier_id'] . ':' . (int)$buchung['konto_id']; $schluessel = (int)$buchung['wertpapier_id'] . ':' . (int)$buchung['konto_id'];
$position = $positionen[$schluessel] ?? [ $position = $positionen[$schluessel] ?? [
@ -283,12 +290,16 @@ class Depot
* *
* @return list<array{monat: string, kuerzel: string, wert: float, einstand: float}> * @return list<array{monat: string, kuerzel: string, wert: float, einstand: float}>
*/ */
public static function entwicklung(?int $kontoId = null, int $monate = 12, ?string $heute = null): array public static function entwicklung(
{ ?int $kontoId = null,
int $monate = 12,
?string $heute = null,
?int $wertpapierId = null
): array {
$reihe = []; $reihe = [];
foreach (Vermoegensentwicklung::monate($monate, $heute) as $monat) { foreach (Vermoegensentwicklung::monate($monate, $heute) as $monat) {
$bestand = self::bestand($kontoId, $monat['bis']); $bestand = self::bestand($kontoId, $monat['bis'], $wertpapierId);
$reihe[] = [ $reihe[] = [
'monat' => sprintf('%04d-%02d', $monat['jahr'], $monat['monat']), 'monat' => sprintf('%04d-%02d', $monat['jahr'], $monat['monat']),
@ -307,7 +318,7 @@ class Depot
* *
* @return list<array<string,mixed>> * @return list<array<string,mixed>>
*/ */
private static function buchungen(?int $kontoId, string $bis): array private static function buchungen(?int $kontoId, string $bis, ?int $wertpapierId = null): array
{ {
$params = [Mandant::id(), $bis]; $params = [Mandant::id(), $bis];
$sql = 'SELECT d.*, w.name AS wertpapier, w.isin, w.wkn, k.name AS depot $sql = 'SELECT d.*, w.name AS wertpapier, w.isin, w.wkn, k.name AS depot
@ -321,6 +332,11 @@ class Depot
$params[] = $kontoId; $params[] = $kontoId;
} }
if ($wertpapierId !== null) {
$sql .= ' AND d.wertpapier_id = ?';
$params[] = $wertpapierId;
}
return Database::fetchAll($sql . ' ORDER BY d.datum, d.id', $params); return Database::fetchAll($sql . ' ORDER BY d.datum, d.id', $params);
} }
} }

View file

@ -55,6 +55,16 @@ class View
return $mitWaehrung ? $text . "\u{00A0}€" : $text; return $mitWaehrung ? $text . "\u{00A0}€" : $text;
} }
/**
* Stückzahlen. Fonds werden in Bruchteilen geführt, Aktien in ganzen
* Stücken – dieselbe Spalte trägt beides, deshalb sechs Nachkommastellen
* ohne die Nullen, die niemand liest: „12“ statt „12,000000“.
*/
public static function stueck(mixed $wert): string
{
return rtrim(rtrim(number_format((float)$wert, 6, ',', '.'), '0'), ',');
}
/** /**
* Monatsnamen auf Deutsch. Nicht über date('F') oder IntlDateFormatter – * Monatsnamen auf Deutsch. Nicht über date('F') oder IntlDateFormatter –
* date() ist immer englisch und intl ist keine Standarderweiterung. Der * date() ist immer englisch und intl ist keine Standarderweiterung. Der

View file

@ -112,9 +112,7 @@ include __DIR__ . '/../layout/kopf.php';
<?php endif; ?> <?php endif; ?>
</td> </td>
<td><?= View::h($geschaeft['depot']) ?></td> <td><?= View::h($geschaeft['depot']) ?></td>
<td class="rechts"> <td class="rechts"><?= View::h(View::stueck($geschaeft['stueck'])) ?></td>
<?= View::h(rtrim(rtrim(number_format($geschaeft['stueck'], 6, ',', '.'), '0'), ',')) ?>
</td>
<td class="rechts"><?= View::betrag($geschaeft['erloes']) ?></td> <td class="rechts"><?= View::betrag($geschaeft['erloes']) ?></td>
<td class="rechts"><?= View::betrag($geschaeft['einstand']) ?></td> <td class="rechts"><?= View::betrag($geschaeft['einstand']) ?></td>
<td class="rechts <?= View::betragKlasse($geschaeft['gewinn']) ?>"> <td class="rechts <?= View::betragKlasse($geschaeft['gewinn']) ?>">

View file

@ -114,7 +114,9 @@ include __DIR__ . '/../layout/kopf.php';
<?php endif; ?> <?php endif; ?>
</td> </td>
<td> <td>
<?= View::h($buchung['wertpapier']) ?> <a href="/wertpapiere/papier/<?= (int)$buchung['wertpapier_id'] ?>">
<?= View::h($buchung['wertpapier']) ?>
</a>
<?php if ($buchung['isin'] !== ''): ?> <?php if ($buchung['isin'] !== ''): ?>
<span class="nebentext"><?= View::h($buchung['isin']) ?></span> <span class="nebentext"><?= View::h($buchung['isin']) ?></span>
<?php endif; ?> <?php endif; ?>
@ -122,8 +124,7 @@ include __DIR__ . '/../layout/kopf.php';
<td><?= View::h($buchung['depot']) ?></td> <td><?= View::h($buchung['depot']) ?></td>
<td class="rechts"> <td class="rechts">
<?= (float)$buchung['stueck'] === 0.0 <?= (float)$buchung['stueck'] === 0.0
? '–' ? '–' : View::h(View::stueck($buchung['stueck'])) ?>
: View::h(rtrim(rtrim(number_format((float)$buchung['stueck'], 6, ',', '.'), '0'), ',')) ?>
</td> </td>
<td class="rechts"> <td class="rechts">
<?= $buchung['kurs'] === null ? '–' : View::h(number_format((float)$buchung['kurs'], 2, ',', '.')) ?> <?= $buchung['kurs'] === null ? '–' : View::h(number_format((float)$buchung['kurs'], 2, ',', '.')) ?>

View file

@ -115,13 +115,15 @@ include __DIR__ . '/../layout/kopf.php';
<?php foreach ($bestand['positionen'] as $position): ?> <?php foreach ($bestand['positionen'] as $position): ?>
<tr> <tr>
<td> <td>
<?= View::h($position['name']) ?> <a href="/wertpapiere/papier/<?= (int)$position['wertpapier_id'] ?>">
<?= View::h($position['name']) ?>
</a>
<?php if ($position['isin'] !== ''): ?> <?php if ($position['isin'] !== ''): ?>
<span class="nebentext"><?= View::h($position['isin']) ?></span> <span class="nebentext"><?= View::h($position['isin']) ?></span>
<?php endif; ?> <?php endif; ?>
</td> </td>
<td><?= View::h($position['depot']) ?></td> <td><?= View::h($position['depot']) ?></td>
<td class="rechts"><?= View::h(rtrim(rtrim(number_format($position['stueck'], 6, ',', '.'), '0'), ',')) ?></td> <td class="rechts"><?= View::h(View::stueck($position['stueck'])) ?></td>
<td class="rechts"> <td class="rechts">
<?= $position['einstand_je_stueck'] === null <?= $position['einstand_je_stueck'] === null
? '–' : View::h(number_format($position['einstand_je_stueck'], 2, ',', '.')) ?> ? '–' : View::h(number_format($position['einstand_je_stueck'], 2, ',', '.')) ?>

View file

@ -0,0 +1,334 @@
<?php
use Finanzmanager\Models\Depotbuchung;
use Finanzmanager\Models\Wertpapier;
use Finanzmanager\Services\Zeitraum;
use Finanzmanager\View;
/**
* Alles zu einem Wertpapier auf einer Seite: oben die Stammdaten, der Kurs und
* die Kennzahlen über die ganze Historie, unten die Buchungen eines Zeitraums.
*
* @var array<string,mixed> $wertpapier
* @var array<string,mixed> $bestand
* @var array<string,mixed>|null $kurs
* @var array{tief:float, hoch:float, anzahl:int}|null $eckwerte
* @var list<array{monat:string, kuerzel:string, wert:float, einstand:float}> $entwicklung
* @var array{von: ?string, bis: ?string} $grenzen
* @var array{vorgabe:string, von:string, bis:string} $zeitraum
* @var list<array<string,mixed>> $buchungen
* @var string $csrfToken
*/
$id = (int)$wertpapier['id'];
// Ein Papier kann in mehreren Depots liegen; die Zahlen der Seite sind ihre
// Summe. Der Einstand je Stück wird deshalb neu gemittelt und nicht aus einer
// einzelnen Position übernommen.
$stueck = 0.0;
foreach ($bestand['positionen'] as $position) {
$stueck += (float)$position['stueck'];
}
$einstandJeStueck = $stueck > 0.0 ? round($bestand['einstand'] / $stueck, 4) : null;
// Was im gewählten Zeitraum geschehen ist – die Liste darunter zeigt es Zeile
// für Zeile, hier steht es in drei Zahlen.
$zugang = ['stueck' => 0.0, 'betrag' => 0.0];
$abgang = ['stueck' => 0.0, 'betrag' => 0.0];
$ertrag = 0.0;
foreach ($buchungen as $buchung) {
$betrag = (float)$buchung['betrag'];
$kosten = (float)$buchung['gebuehren'] + (float)$buchung['steuern'];
if (in_array((string)$buchung['vorgang'], Depotbuchung::ZUGAENGE, true)) {
$zugang['stueck'] += abs((float)$buchung['stueck']);
$zugang['betrag'] += $betrag + $kosten;
} elseif (in_array((string)$buchung['vorgang'], Depotbuchung::ABGAENGE, true)) {
$abgang['stueck'] += abs((float)$buchung['stueck']);
$abgang['betrag'] += $betrag - $kosten;
} elseif ((string)$buchung['vorgang'] === 'dividende') {
$ertrag += $betrag - $kosten;
}
}
include __DIR__ . '/../layout/kopf.php';
?>
<div class="tableiste">
<a href="/wertpapiere" class="tab">Bestände</a>
<a href="/wertpapiere/buchungen" class="tab">Depotbuchungen</a>
<a href="/wertpapiere/stammdaten" class="tab aktiv">Wertpapiere</a>
<a href="/wertpapiere/auswertung?art=veraeusserungen" class="tab">Veräußerungen</a>
<a href="/wertpapiere/auswertung?art=ertraege" class="tab">Erträge</a>
</div>
<div class="seitenaktionen">
<span class="nebentext">
<?= View::h(Wertpapier::ARTEN[$wertpapier['art']] ?? $wertpapier['art']) ?>
<?php if ((string)$wertpapier['isin'] !== ''): ?>
· <?= View::h($wertpapier['isin']) ?>
<?php endif; ?>
<?php if ((int)$wertpapier['aktiv'] !== 1): ?>
· <span class="marke">inaktiv</span>
<?php endif; ?>
</span>
<a href="/wertpapiere/buchungen?wertpapier=<?= $id ?>" class="knopf">Alle Depotbuchungen</a>
<a href="/wertpapiere/stammdaten" class="knopf">Stammdaten bearbeiten</a>
<a href="/wertpapiere/buchungen/neu" class="knopf primaer">Depotbuchung erfassen</a>
</div>
<div class="kennzahlenleiste">
<div class="kennzahlkachel">
<span class="kennzahltitel">Bestand</span>
<span class="kennzahl"><?= View::h(View::stueck($stueck)) ?></span>
</div>
<div class="kennzahlkachel">
<span class="kennzahltitel">Aktueller Wert</span>
<span class="kennzahl"><?= $bestand['bewertbar'] ? View::betrag($bestand['wert']) : '–' ?></span>
</div>
<div class="kennzahlkachel">
<span class="kennzahltitel">Einstandswert</span>
<span class="kennzahl"><?= View::betrag($bestand['einstand']) ?></span>
</div>
<div class="kennzahlkachel">
<span class="kennzahltitel">Nicht realisiert</span>
<span class="kennzahl <?= View::betragKlasse($bestand['gewinn']) ?>">
<?= $bestand['bewertbar'] ? View::betrag($bestand['gewinn']) : '–' ?>
</span>
</div>
<div class="kennzahlkachel">
<span class="kennzahltitel">Realisiert</span>
<span class="kennzahl <?= View::betragKlasse($bestand['realisiert']) ?>">
<?= View::betrag($bestand['realisiert']) ?>
</span>
</div>
<div class="kennzahlkachel">
<span class="kennzahltitel">Erträge</span>
<span class="kennzahl"><?= View::betrag($bestand['ertraege']) ?></span>
</div>
</div>
<div class="kachelraster">
<section class="kachel">
<header><h2>Stammdaten</h2></header>
<dl class="merkmale">
<dt>ISIN</dt>
<dd><?= View::h($wertpapier['isin'] ?: '–') ?></dd>
<dt>WKN</dt>
<dd><?= View::h($wertpapier['wkn'] ?: '–') ?></dd>
<dt>Art</dt>
<dd><?= View::h(Wertpapier::ARTEN[$wertpapier['art']] ?? $wertpapier['art']) ?></dd>
<dt>Währung</dt>
<dd><?= View::h($wertpapier['waehrung']) ?></dd>
<dt>Kürzel</dt>
<dd><?= View::h($wertpapier['symbol'] ?: '–') ?></dd>
<dt>Status</dt>
<dd><?= (int)$wertpapier['aktiv'] === 1 ? 'aktiv' : 'inaktiv' ?></dd>
<?php if ((string)$wertpapier['notiz'] !== ''): ?>
<dt>Notiz</dt>
<dd><?= View::h($wertpapier['notiz']) ?></dd>
<?php endif; ?>
<dt>Erste Buchung</dt>
<dd><?= View::h(View::datum($grenzen['von'])) ?></dd>
<dt>Letzte Buchung</dt>
<dd><?= View::h(View::datum($grenzen['bis'])) ?></dd>
</dl>
</section>
<section class="kachel">
<header>
<h2>Kurs</h2>
<p class="kachelhinweis">Hoch und Tief der letzten zwölf Monate, soweit Kurse vorliegen.</p>
</header>
<dl class="merkmale">
<dt>Letzter Kurs</dt>
<dd>
<?php if ($kurs === null): ?>
<span class="nebentext">kein Kurs</span>
<?php else: ?>
<?= View::h(number_format((float)$kurs['kurs'], 2, ',', '.')) ?>
<span class="nebentext">
<?= View::h(View::datum($kurs['datum'])) ?> · <?= View::h($kurs['quelle']) ?>
</span>
<?php endif; ?>
</dd>
<dt>Höchstkurs</dt>
<dd><?= $eckwerte === null ? '–' : View::h(number_format($eckwerte['hoch'], 2, ',', '.')) ?></dd>
<dt>Tiefstkurs</dt>
<dd><?= $eckwerte === null ? '–' : View::h(number_format($eckwerte['tief'], 2, ',', '.')) ?></dd>
<dt>Bekannte Kurse</dt>
<dd><?= $eckwerte === null ? '0' : (int)$eckwerte['anzahl'] ?></dd>
<dt>Einstand je Stück</dt>
<dd>
<?= $einstandJeStueck === null
? '–' : View::h(number_format($einstandJeStueck, 2, ',', '.')) ?>
</dd>
</dl>
</section>
</div>
<section class="kachel">
<header>
<h2>Bestand je Depot</h2>
</header>
<?php if ($bestand['positionen'] === []): ?>
<p class="platzhalter">
Kein Bestand – das Papier ist verkauft oder war nie im Depot. Realisierte
Gewinne und Erträge zählen oben weiter mit.
</p>
<?php else: ?>
<table class="liste zahlen">
<thead>
<tr>
<th>Depot</th>
<th class="rechts">Stück</th>
<th class="rechts">Einstand je Stück</th>
<th class="rechts">Einstand</th>
<th class="rechts">Wert</th>
<th class="rechts">Gewinn</th>
</tr>
</thead>
<tbody>
<?php foreach ($bestand['positionen'] as $position): ?>
<tr>
<td><?= View::h($position['depot']) ?></td>
<td class="rechts"><?= View::h(View::stueck($position['stueck'])) ?></td>
<td class="rechts">
<?= $position['einstand_je_stueck'] === null
? '–' : View::h(number_format($position['einstand_je_stueck'], 2, ',', '.')) ?>
</td>
<td class="rechts"><?= View::betrag($position['einstand']) ?></td>
<td class="rechts">
<?= $position['wert'] === null ? '–' : View::betrag($position['wert']) ?>
</td>
<td class="rechts <?= $position['gewinn'] === null ? '' : View::betragKlasse($position['gewinn']) ?>">
<?= $position['gewinn'] === null ? '–' : View::betrag($position['gewinn']) ?>
</td>
</tr>
<?php endforeach; ?>
</tbody>
<?php if (count($bestand['positionen']) > 1): ?>
<tfoot>
<tr class="summenzeile">
<td>Summe</td>
<td class="rechts"><?= View::h(View::stueck($stueck)) ?></td>
<td></td>
<td class="rechts"><?= View::betrag($bestand['einstand']) ?></td>
<td class="rechts"><?= View::betrag($bestand['wert']) ?></td>
<td class="rechts <?= View::betragKlasse($bestand['gewinn']) ?>">
<?= View::betrag($bestand['gewinn']) ?>
</td>
</tr>
</tfoot>
<?php endif; ?>
</table>
<?php if (!$bestand['bewertbar']): ?>
<p class="hinweis klein">
Für dieses Papier fehlt ein Kurs – Wert und nicht realisierter Gewinn bleiben
offen. Einzutragen unter <a href="/wertpapiere/stammdaten">Wertpapiere</a>.
</p>
<?php endif; ?>
<?php endif; ?>
</section>
<?php
// Ohne Kurshistorie stünde die Linie auf null – dann lieber gar kein Diagramm.
$hatEntwicklung = false;
foreach ($entwicklung as $punkt) {
$hatEntwicklung = $hatEntwicklung || $punkt['wert'] > 0.0;
}
?>
<?php if ($hatEntwicklung): ?>
<section class="kachel">
<header>
<h2>Wertentwicklung</h2>
<p class="kachelhinweis">Bewerteter Bestand je Monatsende, dazu der Einstand.</p>
</header>
<?php $reihe = $entwicklung; include __DIR__ . '/entwicklungsdiagramm.php'; ?>
</section>
<?php endif; ?>
<h2>Buchungen</h2>
<form method="get" action="/wertpapiere/papier/<?= $id ?>" class="filterleiste">
<label for="zeitraum">Zeitraum</label>
<select id="zeitraum" name="zeitraum">
<?php foreach (Zeitraum::VORGABEN as $schluessel => $name): ?>
<option value="<?= View::h($schluessel) ?>" <?= $zeitraum['vorgabe'] === $schluessel ? 'selected' : '' ?>>
<?= View::h($name) ?>
</option>
<?php endforeach; ?>
</select>
<label for="von">von</label>
<input type="date" id="von" name="von" value="<?= View::h($zeitraum['von']) ?>">
<label for="bis">bis</label>
<input type="date" id="bis" name="bis" value="<?= View::h($zeitraum['bis']) ?>">
<button type="submit" class="knopf primaer">Anzeigen</button>
</form>
<p class="berichtskopf"><?= View::h(Zeitraum::beschriftung($zeitraum['von'], $zeitraum['bis'])) ?></p>
<?php if ($buchungen === []): ?>
<p class="platzhalter">Keine Buchungen in diesem Zeitraum.</p>
<?php else: ?>
<table class="liste zahlen">
<thead>
<tr>
<th>Datum</th><th>Vorgang</th><th>Depot</th>
<th class="rechts">Stück</th><th class="rechts">Kurs</th>
<th class="rechts">Betrag</th><th class="rechts">Kosten</th>
<th>Verrechnung</th>
</tr>
</thead>
<tbody>
<?php foreach ($buchungen as $buchung): ?>
<tr>
<td><?= View::h(View::datum($buchung['datum'])) ?></td>
<td>
<?= View::h(Depotbuchung::VORGAENGE[$buchung['vorgang']] ?? $buchung['vorgang']) ?>
<?php if ($buchung['vorgang_qif'] !== ''): ?>
<span class="nebentext"><?= View::h($buchung['vorgang_qif']) ?></span>
<?php endif; ?>
</td>
<td><?= View::h($buchung['depot']) ?></td>
<td class="rechts">
<?= (float)$buchung['stueck'] === 0.0
? '–' : View::h(View::stueck($buchung['stueck'])) ?>
</td>
<td class="rechts">
<?= $buchung['kurs'] === null ? '–' : View::h(number_format((float)$buchung['kurs'], 2, ',', '.')) ?>
</td>
<td class="rechts"><?= View::betrag($buchung['betrag']) ?></td>
<td class="rechts">
<?php $kosten = (float)$buchung['gebuehren'] + (float)$buchung['steuern']; ?>
<?= $kosten > 0.0 ? View::betrag($kosten) : '–' ?>
</td>
<td>
<?php if ($buchung['buchung_id'] !== null): ?>
<a href="/buchungen/<?= (int)$buchung['buchung_id'] ?>">
<?= View::h($buchung['verrechnungskonto'] ?? 'Buchung') ?>
</a>
<?php else: ?>
<span class="nebentext">–</span>
<?php endif; ?>
</td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
<p class="hinweis klein">
Im Zeitraum:
Zugänge <?= View::h(View::stueck($zugang['stueck'])) ?> Stück
für <?= View::betrag($zugang['betrag']) ?> ·
Abgänge <?= View::h(View::stueck($abgang['stueck'])) ?> Stück
für <?= View::betrag($abgang['betrag']) ?> ·
Erträge <?= View::betrag($ertrag) ?>.
Zugänge samt Nebenkosten, Abgänge und Erträge abzüglich – so, wie das Geld
geflossen ist.
</p>
<?php endif; ?>
<?php include __DIR__ . '/../layout/fuss.php'; ?>

View file

@ -105,7 +105,8 @@ include __DIR__ . '/../layout/kopf.php';
<?= (int)$papier['aktiv'] === 1 ? 'checked' : '' ?>> <?= (int)$papier['aktiv'] === 1 ? 'checked' : '' ?>>
</label> </label>
</td> </td>
<td class="rechts"> <td class="rechts nowrap">
<a href="/wertpapiere/papier/<?= (int)$papier['id'] ?>" class="knopf klein">Details</a>
<button type="submit" form="<?= $f ?>" class="knopf klein">Speichern</button> <button type="submit" form="<?= $f ?>" class="knopf klein">Speichern</button>
</td> </td>
</tr> </tr>

View file

@ -0,0 +1,270 @@
<?php
declare(strict_types=1);
use Finanzmanager\Models\Depotbuchung;
use Finanzmanager\Models\Konto;
use Finanzmanager\Models\Wertpapier;
use Finanzmanager\Services\Depot;
use Finanzmanager\Services\Zeitraum;
use Finanzmanager\View;
/**
* Die Detailseite eines Wertpapiers: Sie rechnet dieselben Zahlen wie die
* Depotübersicht, aber für ein einziges Papier über alle Depots hinweg – und
* zeigt darunter die Buchungen eines wählbaren Zeitraums.
*/
class WertpapierDetailTest extends TestCase
{
protected string $testName = 'Test 26: Wertpapier-Detailseite';
private int $depot = 0;
private int $zweitdepot = 0;
private int $verrechnung = 0;
private int $papier = 0;
private int $anderes = 0;
public function run(): void
{
$this->mandantAnlegen();
$this->stammdaten();
$this->bestandFiltertAufEinPapier();
$this->bestandLiegtInBeidenDepots();
$this->kurseckwerteZeigenHochUndTief();
$this->datumsgrenzenUmfassenNurDiesesPapier();
$this->stueckOhneUeberfluessigeNullen();
$this->seiteZeigtStammdatenUndBuchungen();
$this->seiteFolgtDemZeitraum();
$this->uebersichtVerlinktDieDetailseite();
}
private function stammdaten(): void
{
$this->depot = Konto::anlegen([
'name' => 'Detaildepot', 'kontoart' => 'depot', 'anfangssaldo' => 0, 'aktiv' => 1,
]);
$this->zweitdepot = Konto::anlegen([
'name' => 'Zweitdepot', 'kontoart' => 'depot', 'anfangssaldo' => 0, 'aktiv' => 1,
]);
$this->verrechnung = Konto::anlegen([
'name' => 'Detailverrechnung', 'kontoart' => 'bank', 'anfangssaldo' => 50000, 'aktiv' => 1,
]);
$this->papier = Wertpapier::findenOderAnlegen([
'name' => 'Detailaktie AG', 'isin' => 'DE0007654321', 'wkn' => '765432', 'art' => 'aktie',
]);
$this->anderes = Wertpapier::findenOderAnlegen([
'name' => 'Nebenaktie SE', 'isin' => 'FR0001234567', 'art' => 'aktie',
]);
// Zehn Stück zu 100 € plus 10 € Gebühren, dann fünf davon zu 130 €
// verkauft, dazu eine Dividende – die Zahlen der Kennzahlenleiste.
Depotbuchung::anlegen([
'konto_id' => $this->depot, 'wertpapier_id' => $this->papier,
'datum' => '2026-01-10', 'vorgang' => 'kauf',
'stueck' => 10, 'kurs' => 100, 'betrag' => 1000, 'gebuehren' => 10,
'verrechnungskonto_id' => $this->verrechnung,
]);
Depotbuchung::anlegen([
'konto_id' => $this->depot, 'wertpapier_id' => $this->papier,
'datum' => '2026-03-20', 'vorgang' => 'verkauf',
'stueck' => 5, 'kurs' => 130, 'betrag' => 650, 'gebuehren' => 5,
'verrechnungskonto_id' => $this->verrechnung,
]);
Depotbuchung::anlegen([
'konto_id' => $this->depot, 'wertpapier_id' => $this->papier,
'datum' => '2026-05-02', 'vorgang' => 'dividende',
'betrag' => 40, 'steuern' => 10,
'verrechnungskonto_id' => $this->verrechnung,
]);
// Dasselbe Papier ein zweites Mal, in einem anderen Depot.
Depotbuchung::anlegen([
'konto_id' => $this->zweitdepot, 'wertpapier_id' => $this->papier,
'datum' => '2026-06-15', 'vorgang' => 'kauf',
'stueck' => 4, 'kurs' => 125, 'betrag' => 500, 'gebuehren' => 0,
'verrechnungskonto_id' => $this->verrechnung,
]);
// Ein fremdes Papier, das auf der Detailseite nichts zu suchen hat.
Depotbuchung::anlegen([
'konto_id' => $this->depot, 'wertpapier_id' => $this->anderes,
'datum' => '2025-11-11', 'vorgang' => 'kauf',
'stueck' => 3, 'kurs' => 50, 'betrag' => 150,
'verrechnungskonto_id' => $this->verrechnung,
]);
Wertpapier::kursSetzen($this->papier, '2026-07-01', 120.0, 'manuell');
Wertpapier::kursSetzen($this->papier, '2026-08-01', 140.0, 'leeway');
Wertpapier::kursSetzen($this->anderes, '2026-08-01', 60.0, 'manuell');
}
private function bestandFiltertAufEinPapier(): void
{
$bestand = Depot::bestand(null, '2026-12-31', $this->papier);
foreach ($bestand['positionen'] as $position) {
$this->assertEquals(
$this->papier,
$position['wertpapier_id'],
'Nur das gewählte Papier steht in den Positionen'
);
}
// 10 Stück für 1010 €, davon 5 verkauft: die Hälfte des Einstands geht
// ab (505 €), der Erlös von 645 € ergibt 140 € realisiert.
$this->assertEquals(140.0, $bestand['realisiert'], 'Der realisierte Gewinn gehört zu diesem Papier');
$this->assertEquals(30.0, $bestand['ertraege'], 'Die Dividende zählt netto');
$this->assertEquals(1005.0, $bestand['einstand'], '505 € aus dem ersten Depot, 500 € aus dem zweiten');
$this->assertEquals(1260.0, $bestand['wert'], 'Neun Stück zum letzten Kurs von 140 €');
}
private function bestandLiegtInBeidenDepots(): void
{
$bestand = Depot::bestand(null, '2026-12-31', $this->papier);
$this->assertEquals(2, count($bestand['positionen']), 'Je Depot eine Position');
$stueck = 0.0;
foreach ($bestand['positionen'] as $position) {
$stueck += (float)$position['stueck'];
}
$this->assertEquals(9.0, $stueck, 'Fünf im ersten, vier im zweiten Depot');
// Mit Depot eingegrenzt bleibt nur eine Position übrig.
$eines = Depot::bestand($this->zweitdepot, '2026-12-31', $this->papier);
$this->assertEquals(1, count($eines['positionen']), 'Depot und Papier lassen sich zugleich eingrenzen');
$this->assertEquals(4.0, $eines['positionen'][0]['stueck'], 'Vier Stück im Zweitdepot');
}
private function kurseckwerteZeigenHochUndTief(): void
{
$eckwerte = Wertpapier::kurseckwerte($this->papier, '2026-01-01');
$this->assertNotEmpty($eckwerte, 'Es gibt Eckwerte');
$this->assertEquals(140.0, $eckwerte['hoch'], 'Der höchste Kurs');
$this->assertEquals(120.0, $eckwerte['tief'], 'Der niedrigste Kurs');
$this->assertEquals(2, $eckwerte['anzahl'], 'Zwei bekannte Kurse');
// Kurse anderer Papiere bleiben außen vor, ein leerer Zeitraum ergibt null.
$this->assertEquals(60.0, Wertpapier::kurseckwerte($this->anderes, '2026-01-01')['hoch'], 'Je Papier gerechnet');
$this->assertNull(Wertpapier::kurseckwerte($this->papier, '2030-01-01'), 'Ohne Kurse im Zeitraum: null');
}
private function datumsgrenzenUmfassenNurDiesesPapier(): void
{
$grenzen = Depotbuchung::datumsgrenzen($this->papier);
$this->assertEquals('2026-01-10', $grenzen['von'], 'Erste Buchung des Papiers');
$this->assertEquals('2026-06-15', $grenzen['bis'], 'Letzte Buchung des Papiers');
// Ohne Papier zählen alle – das fremde von 2025 verschiebt den Anfang.
$alle = Depotbuchung::datumsgrenzen();
$this->assertEquals('2025-11-11', $alle['von'], 'Über alle Papiere reicht es weiter zurück');
}
private function stueckOhneUeberfluessigeNullen(): void
{
$this->assertEquals('12', View::stueck(12.0), 'Ganze Stücke ohne Nachkommastellen');
$this->assertEquals('0', View::stueck(0.0), 'Null bleibt eine Null');
$this->assertEquals('1,5', View::stueck(1.5), 'Bruchteile bleiben erhalten');
$this->assertEquals('1.234,25', View::stueck(1234.25), 'Tausenderpunkt wie bei Beträgen');
}
private function seiteZeigtStammdatenUndBuchungen(): void
{
$seite = $this->detailseite('aktuelles_jahr');
$this->assert(str_contains($seite, 'Detailaktie AG'), 'Der Name steht auf der Seite');
$this->assert(str_contains($seite, 'DE0007654321'), 'Die ISIN steht auf der Seite');
$this->assert(str_contains($seite, '765432'), 'Die WKN steht auf der Seite');
$this->assert(str_contains($seite, 'Detaildepot'), 'Das Depot mit Bestand wird genannt');
$this->assert(str_contains($seite, 'Zweitdepot'), 'Auch das zweite Depot');
$this->assert(!str_contains($seite, 'Nebenaktie SE'), 'Fremde Papiere bleiben draußen');
// Kennzahlen und Kursangaben.
$this->assert(str_contains($seite, '1.260,00'), 'Der bewertete Bestand steht in den Kennzahlen');
$this->assert(str_contains($seite, '140,00'), 'Der letzte Kurs steht in der Kurskachel');
$this->assert(str_contains($seite, 'leeway'), 'Die Quelle des Kurses wird genannt');
$this->assert(str_contains($seite, '10.01.2026'), 'Die erste Buchung wird genannt');
// Die Buchungen des laufenden Jahres, mit Vorgängen im Klartext.
$this->assert(str_contains($seite, '20.03.2026'), 'Der Verkauf steht in der Liste');
$this->assert(str_contains($seite, 'Dividende'), 'Die Dividende steht in der Liste');
$this->assert(
substr_count($seite, '/wertpapiere/papier/' . $this->papier) > 0,
'Der Zeitraumfilter zeigt auf dieselbe Seite'
);
}
private function seiteFolgtDemZeitraum(): void
{
// Das laufende Jahr ist 2026 – die Testdaten liegen darin. Ein Zeitraum
// ohne Buchungen muss die Liste leeren, ohne die Kennzahlen anzutasten:
// Die gelten für die ganze Historie.
$leer = $this->detailseite('benutzerdefiniert', '2026-09-01', '2026-09-30');
$this->assert(str_contains($leer, 'Keine Buchungen in diesem Zeitraum'), 'Leerer Zeitraum, leere Liste');
$this->assert(!str_contains($leer, '20.03.2026'), 'Der Verkauf liegt außerhalb');
$this->assert(str_contains($leer, '1.260,00'), 'Die Kennzahlen bleiben die der ganzen Historie');
// Der Kauf vom Januar steht dann nur noch bei den Stammdaten als erste
// Buchung, nicht mehr in der Liste darunter.
$eng = $this->detailseite('benutzerdefiniert', '2026-03-01', '2026-03-31');
$this->assert(str_contains($eng, '20.03.2026'), 'Der Verkauf liegt im engen Zeitraum');
$this->assertEquals(1, substr_count($eng, '10.01.2026'), 'Der Kauf vom Januar nicht');
}
private function uebersichtVerlinktDieDetailseite(): void
{
$bestand = Depot::bestand($this->depot, '2026-12-31');
$seite = $this->rendere('wertpapiere/index.php', [
'titel' => 'Wertpapiere',
'depots' => Depot::depots(),
'depot' => $this->depot,
'bestand' => $bestand,
'entwicklung' => [],
'kursabruf' => false,
]);
$this->assert(
str_contains($seite, 'href="/wertpapiere/papier/' . $this->papier . '"'),
'Der Wertpapiername in der Übersicht führt zur Detailseite'
);
}
/** Rendert die Detailseite so, wie der Controller sie zusammenstellt. */
private function detailseite(string $vorgabe, string $von = '', string $bis = ''): string
{
$grenzen = Depotbuchung::datumsgrenzen($this->papier);
$zeitraum = Zeitraum::berechne($vorgabe, $von, $bis, '2026-08-13', $grenzen);
return $this->rendere('wertpapiere/papier.php', [
'titel' => 'Detailaktie AG',
'wertpapier' => Wertpapier::find($this->papier),
'bestand' => Depot::bestand(null, '2026-12-31', $this->papier),
'kurs' => Wertpapier::letzterKurs($this->papier),
'eckwerte' => Wertpapier::kurseckwerte($this->papier, '2025-08-13'),
'entwicklung' => [],
'grenzen' => $grenzen,
'zeitraum' => $zeitraum,
'buchungen' => Depotbuchung::liste([
'wertpapier_id' => $this->papier,
'von' => $zeitraum['von'],
'bis' => $zeitraum['bis'],
]),
]);
}
/** @param array<string,mixed> $daten */
private function rendere(string $view, array $daten): string
{
$daten += ['aktuellerPfad' => '/wertpapiere', 'flash' => [], 'benutzer' => null, 'csrfToken' => 'x'];
extract($daten, EXTR_SKIP);
ob_start();
include dirname(__DIR__) . '/src/Views/' . $view;
return (string)ob_get_clean();
}
}

View file

@ -0,0 +1,146 @@
// @ts-check
const { test, expect } = require('@playwright/test')
/*
* Vom Namen in der Depotübersicht zur Detailseite des Wertpapiers: Stammdaten,
* Kennzahlen und darunter die Buchungen eines Zeitraums – voreingestellt das
* laufende Jahr.
*/
const BENUTZER = process.env.E2E_USER || 'e2e'
const PASSWORT = process.env.E2E_PASSWORD || 'e2e-testpasswort'
const MARKE = Date.now().toString().slice(-6)
const DEPOT = `E2E Detaildepot ${MARKE}`
const GIRO = `E2E Detailgiro ${MARKE}`
const PAPIER = `E2E Detailaktie ${MARKE}`
// Wie im Test des Wertpapiergeschäfts: Ein Papier wird über seine Kennung
// wiedergefunden, feste Kennungen hingen den zweiten Lauf an den ersten.
const ISIN = `DE0007${MARKE}`
// Die Zeiträume rechnen mit dem Tagesdatum, nicht mit festen Jahreszahlen –
// sonst liefe der Test am 1. Januar ins Leere.
const HEUTE = new Date()
const JAHR = HEUTE.getFullYear()
const DIESES_JAHR = `${JAHR}-03-04`
const VORJAHR = `${JAHR - 1}-03-04`
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(/\/$/)
}
async function kontoAnlegen(page, name, kontoart, anfangssaldo) {
await page.goto('/konten/neu')
await page.fill('#name', name)
await page.selectOption('#kontoart', kontoart)
await page.fill('#anfangssaldo', anfangssaldo)
await page.click('.formular button[type="submit"]')
await expect(page).toHaveURL(/\/konten\/\d+\/bearbeiten/)
}
/** Ein Kauf über die Maske unter *Wertpapiere*. */
async function kaufErfassen(page, datum, stueck, kurs, neuesPapier) {
await page.goto('/wertpapiere/buchungen/neu')
await page.selectOption('#konto_id', { label: DEPOT })
await page.selectOption('#vorgang', 'kauf')
await page.fill('#datum', datum)
if (neuesPapier) {
await page.fill('#wertpapier_name', PAPIER)
await page.fill('#isin', ISIN)
} else {
await page.selectOption('#wertpapier_id', { label: `${PAPIER} (${ISIN})` })
}
await page.fill('#stueck', stueck)
await page.fill('#kurs', kurs)
await page.selectOption('#verrechnungskonto_id', { label: GIRO })
await page.click('.formular button[type="submit"]')
await expect(page.locator('.meldung-erfolg')).toBeVisible()
}
test.describe('Detailseite eines Wertpapiers', () => {
test.beforeAll(async ({ browser }) => {
const page = await browser.newPage()
await anmelden(page)
await kontoAnlegen(page, GIRO, 'bank', '10.000,00')
await kontoAnlegen(page, DEPOT, 'depot', '0,00')
// Zwei Käufe in zwei Jahren – der Zeitraumfilter muss sie trennen.
await kaufErfassen(page, VORJAHR, '4', '50', true)
await kaufErfassen(page, DIESES_JAHR, '6', '100', false)
// Ohne Kurs bliebe die Bewertung offen; der Kurs kommt von Hand. Der
// Name steht in der Stammdatenliste in einem Eingabefeld und nicht als
// Text – die Zeile wird deshalb über den Feldwert gesucht.
await page.goto('/wertpapiere/stammdaten')
const zeile = page.locator('tr').filter({ has: page.locator(`input[value="${PAPIER}"]`) })
await zeile.locator('input[name="kurs"]').fill('120')
await zeile.getByRole('button', { name: 'Setzen' }).click()
await expect(page.locator('.meldung-erfolg')).toBeVisible()
await page.close()
})
test.beforeEach(async ({ page }) => {
await anmelden(page)
})
test('Der Name in der Übersicht führt zu den Details', async ({ page }) => {
await page.goto('/wertpapiere')
await page.locator('tr', { hasText: PAPIER }).getByRole('link', { name: PAPIER }).click()
await expect(page).toHaveURL(/\/wertpapiere\/papier\/\d+/)
await expect(page.locator('h1')).toHaveText(PAPIER)
// Stammdaten und Kurs stehen in je einer Kachel.
await expect(page.locator('.kachel', { hasText: 'Stammdaten' }).locator('.merkmale'))
.toContainText(ISIN)
await expect(page.locator('.kachel', { hasText: 'Letzter Kurs' }).locator('.merkmale'))
.toContainText('120,00')
// Zehn Stück für 800 €, bewertet mit 1.200,00 €.
const kennzahlen = page.locator('.kennzahlenleiste')
await expect(kennzahlen).toContainText('10')
await expect(kennzahlen).toContainText('1.200,00')
await expect(kennzahlen).toContainText('800,00')
// Der Bestand steht je Depot – hier liegt alles in einem.
const bestand = page.locator('.kachel', { hasText: 'Bestand je Depot' })
await expect(bestand).toContainText(DEPOT)
await expect(bestand).toContainText('800,00')
})
test('Die Buchungen folgen dem gewählten Zeitraum', async ({ page }) => {
await page.goto('/wertpapiere')
await page.locator('tr', { hasText: PAPIER }).getByRole('link', { name: PAPIER }).click()
const liste = page.locator('table.liste').last()
// Voreingestellt ist das laufende Jahr: nur der Kauf von diesem Jahr.
await expect(page.locator('#zeitraum')).toHaveValue('aktuelles_jahr')
await expect(liste).toContainText(`04.03.${JAHR}`)
await expect(liste).not.toContainText(`04.03.${JAHR - 1}`)
await page.selectOption('#zeitraum', 'letztes_jahr')
await page.click('.filterleiste button[type="submit"]')
await expect(page.locator('table.liste').last()).toContainText(`04.03.${JAHR - 1}`)
await expect(page.locator('table.liste').last()).not.toContainText(`04.03.${JAHR}`)
// Die Kennzahlen oben gelten weiter für die ganze Historie.
await expect(page.locator('.kennzahlenleiste')).toContainText('800,00')
})
})