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:
parent
b99402ccd8
commit
be3ee6400e
17 changed files with 979 additions and 17 deletions
30
Changes.md
30
Changes.md
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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());
|
||||||
|
|
|
||||||
|
|
@ -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); }
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
10
src/View.php
10
src/View.php
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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']) ?>">
|
||||||
|
|
|
||||||
|
|
@ -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, ',', '.')) ?>
|
||||||
|
|
|
||||||
|
|
@ -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, ',', '.')) ?>
|
||||||
|
|
|
||||||
334
src/Views/wertpapiere/papier.php
Normal file
334
src/Views/wertpapiere/papier.php
Normal 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'; ?>
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
270
tests/26_WertpapierDetailTest.php
Normal file
270
tests/26_WertpapierDetailTest.php
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
146
tests/e2e/specs/wertpapierdetail.spec.js
Normal file
146
tests/e2e/specs/wertpapierdetail.spec.js
Normal 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')
|
||||||
|
})
|
||||||
|
})
|
||||||
Loading…
Add table
Reference in a new issue