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>
270 lines
12 KiB
PHP
270 lines
12 KiB
PHP
<?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();
|
||
}
|
||
}
|