Finanzmanager/src/Models/Wertpapier.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

350 lines
12 KiB
PHP
Raw 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);
namespace Finanzmanager\Models;
use Finanzmanager\Database;
use Finanzmanager\Mandant;
/**
* Ein Wertpapier – Name, WKN, ISIN – samt seiner Kurse.
*
* Die Stammdaten kommen aus dem QIF-Export (Felder `Y`, `~`, `@`, `&`) oder aus
* dem Depotabruf; angelegt werden sie beim ersten Auftauchen einer Buchung.
*/
class Wertpapier
{
/** Wertpapierart, Schlüssel → Beschriftung. */
public const ARTEN = [
'aktie' => 'Aktie',
'fonds' => 'Fonds',
'anleihe' => 'Anleihe',
'zertifikat' => 'Zertifikat',
'sonstiges' => 'Sonstiges',
];
/** QIF-Feld „&“ → eigene Art. Der FinanzManager zählt schlicht durch. */
public const QIF_ARTEN = [
'1' => 'aktie',
'2' => 'fonds',
'3' => 'anleihe',
'4' => 'zertifikat',
];
/** @return list<array<string,mixed>> */
public static function alle(bool $nurAktive = false): array
{
$sql = 'SELECT * FROM wertpapiere WHERE benutzer_id = ?';
if ($nurAktive) {
$sql .= ' AND aktiv = 1';
}
return Database::fetchAll($sql . ' ORDER BY name', [Mandant::id()]);
}
public static function find(int $id): ?array
{
$zeile = Database::fetchOne(
'SELECT * FROM wertpapiere WHERE id = ? AND benutzer_id = ?',
[$id, Mandant::id()]
);
return $zeile === false ? null : $zeile;
}
/**
* Sucht über ISIN, sonst über WKN, sonst über den Namen. Die ISIN ist der
* verlässliche Schlüssel; sie fehlt aber bei manchen Altbeständen.
*
* @return array<string,mixed>|null
*/
public static function finde(string $isin, string $wkn = '', string $name = ''): ?array
{
$isin = strtoupper(trim($isin));
$wkn = strtoupper(trim($wkn));
$name = trim($name);
foreach ([
['isin = ?', $isin],
['wkn = ?', $wkn],
['name = ?', $name],
] as [$bedingung, $wert]) {
if ($wert === '') {
continue;
}
$zeile = Database::fetchOne(
'SELECT * FROM wertpapiere WHERE benutzer_id = ? AND ' . $bedingung . ' LIMIT 1',
[Mandant::id(), $wert]
);
if ($zeile !== false) {
return $zeile;
}
}
return null;
}
/**
* ISIN und WKN aus einem Verwendungszweck.
*
* Wertpapierabrechnungen führen beides im Text mit („WKN LYX01W … ISIN
* LU1834983477“). Wer ein Geschäft aus dem Kontoblatt heraus bucht, muss das
* Papier dann nicht heraussuchen – die Maske schlägt es vor.
*
* Die ISIN ist an ihrer Form erkennbar (zwei Buchstaben Länderkennung, neun
* Zeichen, eine Prüfziffer) und wird auch ohne Beschriftung gefunden. Die
* WKN ist bloß sechsstellig alphanumerisch – zu wenig, um sie ohne das Wort
* „WKN“ davor von einer Belegnummer zu unterscheiden.
*
* @return array{isin:string,wkn:string}
*/
public static function ausText(string $text): array
{
$gross = strtoupper($text);
$isin = preg_match('/\b([A-Z]{2}[A-Z0-9]{9}[0-9])\b/', $gross, $treffer) === 1
? $treffer[1] : '';
$wkn = preg_match('/\bWKN[\s:.]*([A-Z0-9]{6})\b/', $gross, $treffer) === 1
? $treffer[1] : '';
return ['isin' => $isin, 'wkn' => $wkn];
}
/** @param array<string,mixed> $daten */
public static function findenOderAnlegen(array $daten): int
{
$vorhanden = self::finde(
(string)($daten['isin'] ?? ''),
(string)($daten['wkn'] ?? ''),
(string)($daten['name'] ?? '')
);
if ($vorhanden !== null) {
// Was beim ersten Mal fehlte, wird nachgetragen – eine Buchung
// führt die ISIN, die nächste vielleicht zusätzlich die WKN.
self::ergaenze((int)$vorhanden['id'], $daten);
return (int)$vorhanden['id'];
}
return self::anlegen($daten);
}
/** @param array<string,mixed> $daten */
public static function anlegen(array $daten): int
{
$felder = self::normalisiere($daten);
Database::execute(
'INSERT INTO wertpapiere (benutzer_id, name, wkn, isin, art, waehrung, symbol, aktiv, notiz)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)',
[
Mandant::id(), $felder['name'], $felder['wkn'], $felder['isin'], $felder['art'],
$felder['waehrung'], $felder['symbol'], $felder['aktiv'], $felder['notiz'],
]
);
return Database::lastInsertId();
}
/** @param array<string,mixed> $daten */
public static function speichern(int $id, array $daten): void
{
$felder = self::normalisiere($daten);
Database::execute(
'UPDATE wertpapiere SET name = ?, wkn = ?, isin = ?, art = ?, waehrung = ?,
symbol = ?, aktiv = ?, notiz = ?
WHERE id = ? AND benutzer_id = ?',
[
$felder['name'], $felder['wkn'], $felder['isin'], $felder['art'],
$felder['waehrung'], $felder['symbol'], $felder['aktiv'], $felder['notiz'],
$id, Mandant::id(),
]
);
}
public static function loeschen(int $id): void
{
Database::execute('DELETE FROM wertpapiere WHERE id = ? AND benutzer_id = ?', [$id, Mandant::id()]);
}
public static function anzahl(): int
{
return (int)Database::fetchValue(
'SELECT COUNT(*) FROM wertpapiere WHERE benutzer_id = ?',
[Mandant::id()],
0
);
}
public static function artAusQif(string $wert): string
{
return self::QIF_ARTEN[trim($wert)] ?? 'sonstiges';
}
// ── Kurse ────────────────────────────────────────────────────────────────
public static function kursSetzen(
int $wertpapierId,
string $datum,
float $kurs,
string $quelle = 'manuell',
string $waehrung = 'EUR'
): void {
Database::execute(
'INSERT INTO kurse (wertpapier_id, datum, kurs, waehrung, quelle)
VALUES (?, ?, ?, ?, ?)
ON DUPLICATE KEY UPDATE kurs = VALUES(kurs), quelle = VALUES(quelle),
waehrung = VALUES(waehrung)',
[$wertpapierId, $datum, $kurs, $waehrung, $quelle]
);
}
/**
* Der letzte bekannte Kurs bis zu einem Stichtag. Ohne Stichtag der
* aktuellste überhaupt.
*
* @return array<string,mixed>|null
*/
public static function letzterKurs(int $wertpapierId, ?string $bis = null): ?array
{
$sql = 'SELECT * FROM kurse WHERE wertpapier_id = ?';
$params = [$wertpapierId];
if ($bis !== null) {
$sql .= ' AND datum <= ?';
$params[] = $bis;
}
$zeile = Database::fetchOne($sql . ' ORDER BY datum DESC LIMIT 1', $params);
return $zeile === false ? null : $zeile;
}
/**
* Die letzten Kurse aller Wertpapiere in einer Abfrage – die Depotübersicht
* braucht sie für jede Position und soll nicht je Zeile fragen.
*
* @return array<int, array{kurs: float, datum: string}>
*/
public static function letzteKurse(?string $bis = null): array
{
$params = [Mandant::id()];
$grenze = '';
if ($bis !== null) {
$grenze = ' AND k.datum <= ?';
$params[] = $bis;
$params[] = $bis;
}
$zeilen = Database::fetchAll(
'SELECT k.wertpapier_id, k.kurs, k.datum
FROM kurse k
JOIN wertpapiere w ON w.id = k.wertpapier_id
WHERE w.benutzer_id = ?' . $grenze . '
AND k.datum = (
SELECT MAX(k2.datum) FROM kurse k2
WHERE k2.wertpapier_id = k.wertpapier_id' . ($bis === null ? '' : ' AND k2.datum <= ?') . '
)',
$params
);
$kurse = [];
foreach ($zeilen as $zeile) {
$kurse[(int)$zeile['wertpapier_id']] = [
'kurs' => (float)$zeile['kurs'],
'datum' => (string)$zeile['datum'],
];
}
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>> */
public static function kursverlauf(int $wertpapierId, int $tage = 365): array
{
return Database::fetchAll(
'SELECT * FROM kurse
WHERE wertpapier_id = ? AND datum >= DATE_SUB(CURDATE(), INTERVAL ? DAY)
ORDER BY datum',
[$wertpapierId, $tage]
);
}
/**
* @param array<string,mixed> $daten
* @return array<string,mixed>
*/
private static function normalisiere(array $daten): array
{
$name = trim((string)($daten['name'] ?? ''));
if ($name === '') {
throw new \InvalidArgumentException('Das Wertpapier braucht einen Namen.');
}
$art = (string)($daten['art'] ?? 'aktie');
return [
'name' => mb_substr($name, 0, 255),
'wkn' => mb_substr(strtoupper(trim((string)($daten['wkn'] ?? ''))), 0, 16),
'isin' => mb_substr(strtoupper(str_replace(' ', '', (string)($daten['isin'] ?? ''))), 0, 16),
'art' => array_key_exists($art, self::ARTEN) ? $art : 'sonstiges',
'waehrung' => mb_substr(strtoupper(trim((string)($daten['waehrung'] ?? 'EUR'))) ?: 'EUR', 0, 3),
'symbol' => mb_substr(trim((string)($daten['symbol'] ?? '')), 0, 32),
'aktiv' => array_key_exists('aktiv', $daten) && empty($daten['aktiv']) ? 0 : 1,
'notiz' => mb_substr(trim((string)($daten['notiz'] ?? '')), 0, 255),
];
}
/** Trägt fehlende Felder nach, ohne vorhandene zu überschreiben. */
private static function ergaenze(int $id, array $daten): void
{
$vorhanden = self::find($id);
if ($vorhanden === null) {
return;
}
$nachtrag = [];
foreach (['wkn', 'isin'] as $feld) {
$neu = strtoupper(trim((string)($daten[$feld] ?? '')));
if ($neu !== '' && (string)$vorhanden[$feld] === '') {
$nachtrag[$feld] = $neu;
}
}
foreach ($nachtrag as $feld => $wert) {
Database::execute(
'UPDATE wertpapiere SET ' . $feld . ' = ? WHERE id = ? AND benutzer_id = ?',
[$wert, $id, Mandant::id()]
);
}
}
}