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