Finanzmanager/tests/26_WertpapierDetailTest.php
fds2610-bot be3ee6400e 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>
2026-08-13 15:08:01 +02:00

270 lines
12 KiB
PHP
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<?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();
}
}