-
Bearbeiten
+
Bearbeiten
@@ -360,15 +387,17 @@
- Neuen Kurs erfassen
+ Neuen Kurs erfassen
Noch keine historischen Kurse vorhanden.
@@ -421,6 +450,7 @@
|
diff --git a/modules/boersenchecker/partials/home.php b/modules/boersenchecker/partials/home.php
new file mode 100644
index 0000000..4329dc0
--- /dev/null
+++ b/modules/boersenchecker/partials/home.php
@@ -0,0 +1,147 @@
+
+
+ Boersenchecker
+ Startseite
+ Depotauswahl, Aktienauswahl und animierte Kurscharts auf Basis von Schlusskursen.
+
+
+ = e($error) ?>
+
+ = e($notice) ?>
+
+
+
+
+
+
+
+ = e((string) $position['instrument_name']) ?>
+ = $position['latest_price'] !== null ? e(number_format((float) $position['latest_price'], 2, ',', '.')) . ' ' . e((string) $position['latest_currency']) : 'n/a' ?>
+ = e((string) ($position['latest_quoted_at'] ?: 'kein Kurs')) ?>
+
+
+
+
+
+
+
+ = e((string) ($selectedInstrument['instrument_name'] ?? 'Keine Aktie ausgewaehlt')) ?>
+
+ = e((string) ($selectedInstrument['symbol'] ?? '')) ?> · = e((string) ($selectedInstrument['isin'] ?? '-')) ?>
+
+
+
+
+
+
+
+
+
+
+
+
+ Chartdaten werden geladen...
+ -
+
+
+
+
+ Aktien im Depot
+
+ Keine Aktien im ausgewaehlten Depot.
+
+
+
+
+
+ = e((string) $position['instrument_name']) ?>
+ = e((string) ($position['symbol'] ?? '')) ?> · = e((string) ($position['isin'] ?? '-')) ?>
+
+
+ Stueckzahl
+ = e(number_format((float) $position['quantity'], 6, ',', '.')) ?>
+
+
+ Kaufpreis
+ = e(number_format((float) $position['purchase_price'], 2, ',', '.')) ?> = e((string) $position['purchase_currency']) ?>
+
+
+ Letzter Kurs
+ = $position['latest_price'] !== null ? e(number_format((float) $position['latest_price'], 2, ',', '.')) . ' ' . e((string) $position['latest_currency']) : 'n/a' ?>
+
+
+
+
+
+
+
diff --git a/modules/boersenchecker/partials/instruments.php b/modules/boersenchecker/partials/instruments.php
new file mode 100644
index 0000000..a734d17
--- /dev/null
+++ b/modules/boersenchecker/partials/instruments.php
@@ -0,0 +1,161 @@
+
+
+ Boersenchecker
+ Aktienverwaltung
+ Aktien aller Depots des ausgewaehlten Benutzers bearbeiten und manuelle Kurse pflegen.
+
+
+ = e($error) ?>
+
+ = e($notice) ?>
+
+
+
+
+ Benutzer-Scope
+
+
+
+
+
+
+ Aktie waehlen
+
+
+
+
+ Symbolsuche
+
+
+
+
+
+
+
+ | = e((string) ($result['symbol'] ?? '')) ?> |
+ = e((string) ($result['name'] ?? '')) ?> |
+ = e((string) ($result['region'] ?? '')) ?> |
+ = e((string) ($result['currency'] ?? '')) ?> |
+
+
+ Uebernehmen
+
+ |
+
+
+
+
+
+
+
+
+
+
+ Aktie bearbeiten
+
+ Keine Aktie vorhanden.
+
+
+
+
+
+
+
+ Manuellen Kurs eingeben
+
+ Keine Aktie vorhanden.
+
+
+
+
+
+
+ Kursverlauf
+
+ Keine Kursdaten vorhanden.
+
+
+
+
+
+
+ | = e((string) $quote['quoted_at']) ?> |
+ = e(number_format((float) $quote['price'], 4, ',', '.')) ?> = e((string) $quote['currency']) ?> |
+ = e((string) $quote['source']) ?> |
+
+
+ |
+
+
+
+
+
+
+
+
diff --git a/modules/boersenchecker/src/Support/DashboardPage.php b/modules/boersenchecker/src/Support/DashboardPage.php
index 27500ea..b257ecb 100644
--- a/modules/boersenchecker/src/Support/DashboardPage.php
+++ b/modules/boersenchecker/src/Support/DashboardPage.php
@@ -10,6 +10,7 @@ final class DashboardPage
{
private PDO $pdo;
private array $user;
+ private bool $isAdmin;
private string $ownerSub;
private array $moduleSettings;
private string $defaultReportCurrency;
@@ -21,8 +22,10 @@ final class DashboardPage
private string $instrumentTable;
private string $positionTable;
private string $quoteTable;
+ private InstrumentRegistry $instrumentRegistry;
private string $symbolSearchKeywords = '';
private array $symbolSearchResults = [];
+ private array $availableOwners = [];
public function __construct()
{
@@ -30,7 +33,15 @@ final class DashboardPage
\module_fn('boersenchecker', 'ensure_schema');
$this->user = \auth_user() ?? [];
+ $this->isAdmin = \auth_is_admin();
$this->ownerSub = trim((string) ($this->user['sub'] ?? 'local'));
+ $this->availableOwners = $this->buildAvailableOwners();
+ if ($this->isAdmin) {
+ $requestedOwner = trim((string) ($_GET['owner_sub'] ?? $_POST['owner_sub'] ?? ''));
+ if ($requestedOwner !== '' && isset($this->availableOwners[$requestedOwner])) {
+ $this->ownerSub = $requestedOwner;
+ }
+ }
$this->moduleSettings = \modules()->settings('boersenchecker');
$this->defaultReportCurrency = $this->normalizeCurrency((string) ($this->moduleSettings['report_currency'] ?? 'EUR'));
$this->fxMaxAgeHours = (float) ($this->moduleSettings['fx_max_age_hours'] ?? 6);
@@ -51,6 +62,12 @@ final class DashboardPage
$this->instrumentTable = $table('instruments');
$this->positionTable = $table('positions');
$this->quoteTable = $table('quotes');
+ $this->instrumentRegistry = new InstrumentRegistry(
+ $this->pdo,
+ $this->instrumentTable,
+ $this->positionTable,
+ $this->quoteTable,
+ );
}
public function handle(): array
@@ -78,6 +95,9 @@ final class DashboardPage
return [
'notice' => $notice,
'error' => $error,
+ 'isAdmin' => $this->isAdmin,
+ 'ownerSub' => $this->ownerSub,
+ 'availableOwners' => array_values($this->availableOwners),
'defaultReportCurrency' => $this->defaultReportCurrency,
'fxMaxAgeHours' => $this->fxMaxAgeHours,
'alphaMinIntervalMinutes' => $this->alphaMinIntervalMinutes,
@@ -696,81 +716,7 @@ final class DashboardPage
private function upsertInstrument(array $payload): int
{
- $instrumentId = (int) ($payload['id'] ?? 0);
- $driver = strtolower((string) $this->pdo->getAttribute(PDO::ATTR_DRIVER_NAME));
- $data = [
- 'isin' => trim((string) ($payload['isin'] ?? '')) ?: null,
- 'wkn' => trim((string) ($payload['wkn'] ?? '')) ?: null,
- 'symbol' => trim((string) ($payload['symbol'] ?? '')) ?: null,
- 'name' => trim((string) ($payload['name'] ?? '')),
- 'quote_currency' => $this->normalizeCurrency((string) ($payload['quote_currency'] ?? 'EUR')),
- 'market' => trim((string) ($payload['market'] ?? '')) ?: null,
- ];
-
- if ($data['name'] === '') {
- throw new RuntimeException('Bitte mindestens einen Aktiennamen angeben.');
- }
-
- if ($instrumentId <= 0) {
- $instrumentId = $this->findInstrumentId($payload) ?? 0;
- }
-
- if ($instrumentId > 0) {
- $stmt = $this->pdo->prepare(
- 'UPDATE ' . $this->instrumentTable . '
- SET isin = :isin, wkn = :wkn, symbol = :symbol, name = :name, quote_currency = :quote_currency, market = :market, updated_at = CURRENT_TIMESTAMP
- WHERE id = :id'
- );
- $stmt->execute($data + ['id' => $instrumentId]);
- return $instrumentId;
- }
-
- if ($driver === 'pgsql') {
- $stmt = $this->pdo->prepare(
- 'INSERT INTO ' . $this->instrumentTable . ' (isin, wkn, symbol, name, quote_currency, market)
- VALUES (:isin, :wkn, :symbol, :name, :quote_currency, :market)
- RETURNING id'
- );
- $stmt->execute($data);
- return (int) $stmt->fetchColumn();
- }
-
- $stmt = $this->pdo->prepare(
- 'INSERT INTO ' . $this->instrumentTable . ' (isin, wkn, symbol, name, quote_currency, market)
- VALUES (:isin, :wkn, :symbol, :name, :quote_currency, :market)'
- );
- $stmt->execute($data);
- return (int) $this->pdo->lastInsertId();
- }
-
- private function findInstrumentId(array $payload): ?int
- {
- $isin = trim((string) ($payload['isin'] ?? ''));
- $symbol = trim((string) ($payload['symbol'] ?? ''));
- $name = trim((string) ($payload['name'] ?? ''));
-
- if ($isin !== '') {
- $stmt = $this->pdo->prepare('SELECT id FROM ' . $this->instrumentTable . ' WHERE isin = :isin LIMIT 1');
- $stmt->execute(['isin' => $isin]);
- $id = $stmt->fetchColumn();
- if ($id !== false) {
- return (int) $id;
- }
- }
-
- if ($symbol !== '' && $name !== '') {
- $stmt = $this->pdo->prepare('SELECT id FROM ' . $this->instrumentTable . ' WHERE symbol = :symbol AND name = :name LIMIT 1');
- $stmt->execute([
- 'symbol' => $symbol,
- 'name' => $name,
- ]);
- $id = $stmt->fetchColumn();
- if ($id !== false) {
- return (int) $id;
- }
- }
-
- return null;
+ return $this->instrumentRegistry->save($payload);
}
private function storeQuote(int $instrumentId, float $price, string $currency, string $quotedAt, string $source): void
@@ -838,4 +784,33 @@ final class DashboardPage
return number_format($value, $scale, ',', '.');
}
+
+ private function buildAvailableOwners(): array
+ {
+ $owners = [];
+ $currentSub = trim((string) ($this->user['sub'] ?? 'local'));
+ $owners[$currentSub] = [
+ 'sub' => $currentSub,
+ 'label' => trim((string) ($this->user['name'] ?? $this->user['email'] ?? $currentSub)) ?: $currentSub,
+ ];
+
+ if (!$this->isAdmin) {
+ return $owners;
+ }
+
+ foreach (\modules()->knownAuthUsers() as $knownUser) {
+ $sub = trim((string) ($knownUser['sub'] ?? ''));
+ if ($sub === '') {
+ continue;
+ }
+ $label = trim((string) ($knownUser['name'] ?? $knownUser['email'] ?? $knownUser['username'] ?? $sub));
+ $owners[$sub] = [
+ 'sub' => $sub,
+ 'label' => $label !== '' ? $label : $sub,
+ ];
+ }
+
+ uasort($owners, static fn (array $left, array $right): int => strcmp((string) $left['label'], (string) $right['label']));
+ return $owners;
+ }
}
diff --git a/modules/boersenchecker/src/Support/HomePage.php b/modules/boersenchecker/src/Support/HomePage.php
new file mode 100644
index 0000000..3691966
--- /dev/null
+++ b/modules/boersenchecker/src/Support/HomePage.php
@@ -0,0 +1,252 @@
+pdo = \module_fn('boersenchecker', 'pdo');
+ \module_fn('boersenchecker', 'ensure_schema');
+ $this->user = \auth_user() ?? [];
+ $this->isAdmin = \auth_is_admin();
+ $this->ownerSub = trim((string) ($this->user['sub'] ?? 'local'));
+ $this->availableOwners = $this->buildAvailableOwners();
+ if ($this->isAdmin) {
+ $requestedOwner = trim((string) ($_GET['owner_sub'] ?? $_POST['owner_sub'] ?? ''));
+ if ($requestedOwner !== '' && isset($this->availableOwners[$requestedOwner])) {
+ $this->ownerSub = $requestedOwner;
+ }
+ }
+
+ $settings = \modules()->settings('boersenchecker');
+ $this->defaultReportCurrency = strtoupper(trim((string) ($settings['report_currency'] ?? 'EUR'))) ?: 'EUR';
+ $this->alphaMinIntervalMinutes = (int) ($settings['alpha_vantage_min_interval_minutes'] ?? 60);
+ if ($this->alphaMinIntervalMinutes <= 0) {
+ $this->alphaMinIntervalMinutes = 60;
+ }
+
+ $table = static fn (string $name): string => \module_fn('boersenchecker', 'table', $name);
+ $this->portfolioTable = $table('portfolios');
+ $this->instrumentTable = $table('instruments');
+ $this->positionTable = $table('positions');
+ $this->quoteTable = $table('quotes');
+ }
+
+ public function handle(): array
+ {
+ $notice = null;
+ $error = null;
+ if ($_SERVER['REQUEST_METHOD'] === 'POST' && (string) ($_POST['action'] ?? '') === 'refresh_current_quotes_home') {
+ try {
+ $notice = $this->refreshCurrentQuotesForPortfolio((int) ($_POST['portfolio_id'] ?? 0));
+ } catch (\Throwable $e) {
+ $error = $e->getMessage();
+ }
+ }
+
+ $portfolios = $this->fetchPortfolios();
+ $selectedPortfolioId = (int) ($_GET['portfolio_id'] ?? ($_POST['portfolio_id'] ?? 0));
+ if ($selectedPortfolioId <= 0 && $portfolios !== []) {
+ $selectedPortfolioId = (int) $portfolios[0]['id'];
+ }
+
+ $positions = $selectedPortfolioId > 0 ? $this->fetchPortfolioPositions($selectedPortfolioId) : [];
+ $selectedInstrumentId = (int) ($_GET['instrument_id'] ?? 0);
+ if ($selectedInstrumentId <= 0 && $positions !== []) {
+ $selectedInstrumentId = (int) $positions[0]['instrument_id'];
+ }
+
+ $latestQuotes = $this->fetchLatestQuotes(array_values(array_unique(array_map(static fn (array $row): int => (int) $row['instrument_id'], $positions))));
+ foreach ($positions as &$position) {
+ $latestQuote = $latestQuotes[(int) $position['instrument_id']] ?? null;
+ $position['latest_price'] = is_array($latestQuote) && is_numeric($latestQuote['price'] ?? null) ? (float) $latestQuote['price'] : null;
+ $position['latest_currency'] = is_array($latestQuote) ? (string) ($latestQuote['currency'] ?? '') : '';
+ $position['latest_quoted_at'] = is_array($latestQuote) ? (string) ($latestQuote['quoted_at'] ?? '') : '';
+ }
+ unset($position);
+
+ $selectedInstrument = null;
+ foreach ($positions as $position) {
+ if ((int) $position['instrument_id'] === $selectedInstrumentId) {
+ $selectedInstrument = $position;
+ break;
+ }
+ }
+
+ return [
+ 'notice' => $notice,
+ 'error' => $error,
+ 'isAdmin' => $this->isAdmin,
+ 'ownerSub' => $this->ownerSub,
+ 'availableOwners' => array_values($this->availableOwners),
+ 'portfolios' => $portfolios,
+ 'selectedPortfolioId' => $selectedPortfolioId,
+ 'positions' => $positions,
+ 'selectedInstrumentId' => $selectedInstrumentId,
+ 'selectedInstrument' => $selectedInstrument,
+ 'chartEndpoint' => '/module/boersenchecker/chart_data?owner_sub=' . urlencode($this->ownerSub),
+ ];
+ }
+
+ private function refreshCurrentQuotesForPortfolio(int $portfolioId): string
+ {
+ if ($portfolioId <= 0) {
+ throw new RuntimeException('Bitte zuerst ein Depot auswaehlen.');
+ }
+
+ $stmt = $this->pdo->prepare(
+ 'SELECT DISTINCT i.id, i.name, i.symbol, i.quote_currency
+ FROM ' . $this->positionTable . ' p
+ INNER JOIN ' . $this->instrumentTable . ' i ON i.id = p.instrument_id
+ WHERE p.owner_sub = :owner_sub AND p.portfolio_id = :portfolio_id'
+ );
+ $stmt->execute([
+ 'owner_sub' => $this->ownerSub,
+ 'portfolio_id' => $portfolioId,
+ ]);
+ $rows = $stmt->fetchAll(PDO::FETCH_ASSOC) ?: [];
+ if ($rows === []) {
+ throw new RuntimeException('In diesem Depot sind keine Aktien vorhanden.');
+ }
+
+ $updated = 0;
+ $reused = 0;
+ foreach ($rows as $row) {
+ $instrumentId = (int) ($row['id'] ?? 0);
+ $symbol = strtoupper(trim((string) ($row['symbol'] ?? '')));
+ if ($instrumentId <= 0 || $symbol === '') {
+ continue;
+ }
+
+ $latest = $this->latestApiQuoteForInstrument($instrumentId);
+ $latestTimestamp = is_array($latest) ? strtotime((string) ($latest['quoted_at'] ?? '')) : false;
+ if ($latestTimestamp !== false && (time() - $latestTimestamp) < ($this->alphaMinIntervalMinutes * 60)) {
+ $reused++;
+ continue;
+ }
+
+ $apiResult = \module_fn('boersenchecker', 'alpha_vantage_fetch_quote', $symbol);
+ if (empty($apiResult['ok'])) {
+ continue;
+ }
+
+ $stmtInsert = $this->pdo->prepare(
+ 'INSERT INTO ' . $this->quoteTable . ' (instrument_id, price, currency, quoted_at, source)
+ VALUES (:instrument_id, :price, :currency, :quoted_at, :source)'
+ );
+ $stmtInsert->execute([
+ 'instrument_id' => $instrumentId,
+ 'price' => (float) $apiResult['price'],
+ 'currency' => strtoupper(trim((string) ($row['quote_currency'] ?? $this->defaultReportCurrency))) ?: $this->defaultReportCurrency,
+ 'quoted_at' => (string) $apiResult['fetched_at'],
+ 'source' => (string) $apiResult['source'],
+ ]);
+ $updated++;
+ }
+
+ return 'Aktuelle Kurse: ' . $updated . ' aktualisiert, ' . $reused . ' wiederverwendet.';
+ }
+
+ private function fetchPortfolios(): array
+ {
+ $stmt = $this->pdo->prepare('SELECT * FROM ' . $this->portfolioTable . ' WHERE owner_sub = :owner_sub ORDER BY name ASC');
+ $stmt->execute(['owner_sub' => $this->ownerSub]);
+ return $stmt->fetchAll(PDO::FETCH_ASSOC) ?: [];
+ }
+
+ private function fetchPortfolioPositions(int $portfolioId): array
+ {
+ $stmt = $this->pdo->prepare(
+ 'SELECT p.*, i.name AS instrument_name, i.symbol, i.isin, i.wkn, i.quote_currency, i.market
+ FROM ' . $this->positionTable . ' p
+ INNER JOIN ' . $this->instrumentTable . ' i ON i.id = p.instrument_id
+ WHERE p.owner_sub = :owner_sub AND p.portfolio_id = :portfolio_id
+ ORDER BY i.name ASC'
+ );
+ $stmt->execute([
+ 'owner_sub' => $this->ownerSub,
+ 'portfolio_id' => $portfolioId,
+ ]);
+ return $stmt->fetchAll(PDO::FETCH_ASSOC) ?: [];
+ }
+
+ private function fetchLatestQuotes(array $instrumentIds): array
+ {
+ $result = [];
+ if ($instrumentIds === []) {
+ return $result;
+ }
+ $placeholders = implode(',', array_fill(0, count($instrumentIds), '?'));
+ $stmt = $this->pdo->prepare(
+ 'SELECT *
+ FROM ' . $this->quoteTable . '
+ WHERE instrument_id IN (' . $placeholders . ')
+ ORDER BY quoted_at DESC, created_at DESC, id DESC'
+ );
+ $stmt->execute($instrumentIds);
+ foreach ($stmt->fetchAll(PDO::FETCH_ASSOC) ?: [] as $row) {
+ $instrumentId = (int) $row['instrument_id'];
+ if (!isset($result[$instrumentId])) {
+ $result[$instrumentId] = $row;
+ }
+ }
+ return $result;
+ }
+
+ private function latestApiQuoteForInstrument(int $instrumentId): ?array
+ {
+ $stmt = $this->pdo->prepare(
+ 'SELECT *
+ FROM ' . $this->quoteTable . '
+ WHERE instrument_id = :instrument_id AND source LIKE :source
+ ORDER BY quoted_at DESC, created_at DESC, id DESC
+ LIMIT 1'
+ );
+ $stmt->execute([
+ 'instrument_id' => $instrumentId,
+ 'source' => 'alpha_vantage:%',
+ ]);
+ $row = $stmt->fetch(PDO::FETCH_ASSOC);
+ return is_array($row) ? $row : null;
+ }
+
+ private function buildAvailableOwners(): array
+ {
+ $owners = [];
+ $currentSub = trim((string) ($this->user['sub'] ?? 'local'));
+ $owners[$currentSub] = [
+ 'sub' => $currentSub,
+ 'label' => trim((string) ($this->user['name'] ?? $this->user['email'] ?? $currentSub)) ?: $currentSub,
+ ];
+ if (!$this->isAdmin) {
+ return $owners;
+ }
+ foreach (\modules()->knownAuthUsers() as $knownUser) {
+ $sub = trim((string) ($knownUser['sub'] ?? ''));
+ if ($sub === '') {
+ continue;
+ }
+ $label = trim((string) ($knownUser['name'] ?? $knownUser['email'] ?? $knownUser['username'] ?? $sub));
+ $owners[$sub] = ['sub' => $sub, 'label' => $label !== '' ? $label : $sub];
+ }
+ uasort($owners, static fn (array $left, array $right): int => strcmp((string) $left['label'], (string) $right['label']));
+ return $owners;
+ }
+}
diff --git a/modules/boersenchecker/src/Support/InstrumentPage.php b/modules/boersenchecker/src/Support/InstrumentPage.php
new file mode 100644
index 0000000..cdf3e5e
--- /dev/null
+++ b/modules/boersenchecker/src/Support/InstrumentPage.php
@@ -0,0 +1,311 @@
+pdo = \module_fn('boersenchecker', 'pdo');
+ \module_fn('boersenchecker', 'ensure_schema');
+ $this->user = \auth_user() ?? [];
+ $this->isAdmin = \auth_is_admin();
+ $this->ownerSub = trim((string) ($this->user['sub'] ?? 'local'));
+ $this->availableOwners = $this->buildAvailableOwners();
+ if ($this->isAdmin) {
+ $requestedOwner = trim((string) ($_GET['owner_sub'] ?? $_POST['owner_sub'] ?? ''));
+ if ($requestedOwner !== '' && isset($this->availableOwners[$requestedOwner])) {
+ $this->ownerSub = $requestedOwner;
+ }
+ }
+
+ $settings = \modules()->settings('boersenchecker');
+ $this->defaultReportCurrency = strtoupper(trim((string) ($settings['report_currency'] ?? 'EUR'))) ?: 'EUR';
+ $table = static fn (string $name): string => \module_fn('boersenchecker', 'table', $name);
+ $this->instrumentTable = $table('instruments');
+ $this->positionTable = $table('positions');
+ $this->quoteTable = $table('quotes');
+ $this->instrumentRegistry = new InstrumentRegistry(
+ $this->pdo,
+ $this->instrumentTable,
+ $this->positionTable,
+ $this->quoteTable,
+ );
+ }
+
+ public function handle(): array
+ {
+ $notice = null;
+ $error = null;
+ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
+ try {
+ $notice = $this->handlePost();
+ } catch (\Throwable $e) {
+ $error = $e->getMessage();
+ }
+ }
+
+ $instruments = $this->fetchInstruments();
+ $selectedInstrumentId = $this->selectedInstrumentOverrideId > 0
+ ? $this->selectedInstrumentOverrideId
+ : (int) ($_GET['instrument_id'] ?? ($_POST['instrument_id'] ?? 0));
+ if ($selectedInstrumentId <= 0 && $instruments !== []) {
+ $selectedInstrumentId = (int) $instruments[0]['id'];
+ }
+
+ $selectedInstrument = null;
+ foreach ($instruments as $instrument) {
+ if ((int) $instrument['id'] === $selectedInstrumentId) {
+ $selectedInstrument = $instrument;
+ break;
+ }
+ }
+
+ $quotes = $selectedInstrumentId > 0 ? $this->fetchQuotes($selectedInstrumentId) : [];
+
+ $candidateName = trim((string) ($_GET['instrument_name_candidate'] ?? ''));
+ $candidateSymbol = trim((string) ($_GET['symbol_candidate'] ?? ''));
+ $candidateMarket = trim((string) ($_GET['market_candidate'] ?? ''));
+ $candidateCurrency = strtoupper(trim((string) ($_GET['quote_currency_candidate'] ?? $this->defaultReportCurrency))) ?: $this->defaultReportCurrency;
+ if ($selectedInstrument === null && ($candidateName !== '' || $candidateSymbol !== '' || $candidateMarket !== '')) {
+ $selectedInstrument = [
+ 'id' => 0,
+ 'name' => $candidateName,
+ 'symbol' => $candidateSymbol,
+ 'market' => $candidateMarket,
+ 'quote_currency' => $candidateCurrency,
+ 'isin' => '',
+ 'wkn' => '',
+ ];
+ }
+
+ return [
+ 'notice' => $notice,
+ 'error' => $error,
+ 'isAdmin' => $this->isAdmin,
+ 'ownerSub' => $this->ownerSub,
+ 'availableOwners' => array_values($this->availableOwners),
+ 'instruments' => $instruments,
+ 'selectedInstrument' => $selectedInstrument,
+ 'selectedInstrumentId' => $selectedInstrumentId,
+ 'quotes' => $quotes,
+ 'searchKeywords' => $this->searchKeywords,
+ 'searchResults' => $this->searchResults,
+ 'defaultReportCurrency' => $this->defaultReportCurrency,
+ ];
+ }
+
+ private function handlePost(): string
+ {
+ $action = trim((string) ($_POST['action'] ?? ''));
+ return match ($action) {
+ 'save_instrument' => $this->saveInstrument(),
+ 'save_quote' => $this->saveQuote(),
+ 'delete_quote' => $this->deleteQuote(),
+ 'refresh_alpha_vantage_instrument' => $this->refreshInstrumentQuote(),
+ 'search_symbol' => $this->searchSymbol(),
+ default => '',
+ };
+ }
+
+ private function fetchInstruments(): array
+ {
+ $stmt = $this->pdo->prepare(
+ 'SELECT DISTINCT i.*
+ FROM ' . $this->instrumentTable . ' i
+ INNER JOIN ' . $this->positionTable . ' p ON p.instrument_id = i.id
+ WHERE p.owner_sub = :owner_sub
+ ORDER BY i.name ASC'
+ );
+ $stmt->execute(['owner_sub' => $this->ownerSub]);
+ return $stmt->fetchAll(PDO::FETCH_ASSOC) ?: [];
+ }
+
+ private function fetchQuotes(int $instrumentId): array
+ {
+ $stmt = $this->pdo->prepare(
+ 'SELECT *
+ FROM ' . $this->quoteTable . '
+ WHERE instrument_id = :instrument_id
+ ORDER BY quoted_at DESC, created_at DESC, id DESC
+ LIMIT 30'
+ );
+ $stmt->execute(['instrument_id' => $instrumentId]);
+ return $stmt->fetchAll(PDO::FETCH_ASSOC) ?: [];
+ }
+
+ private function saveInstrument(): string
+ {
+ $instrumentId = (int) ($_POST['instrument_id'] ?? 0);
+ if ($instrumentId <= 0) {
+ throw new RuntimeException('Bitte eine Aktie auswaehlen.');
+ }
+ $this->assertInstrumentAccessible($instrumentId);
+
+ $resolvedId = $this->instrumentRegistry->save([
+ 'id' => $instrumentId,
+ 'name' => $_POST['instrument_name'] ?? '',
+ 'symbol' => $_POST['symbol'] ?? '',
+ 'isin' => $_POST['isin'] ?? '',
+ 'wkn' => $_POST['wkn'] ?? '',
+ 'market' => $_POST['market'] ?? '',
+ 'quote_currency' => $_POST['quote_currency'] ?? $this->defaultReportCurrency,
+ ]);
+ $this->selectedInstrumentOverrideId = $resolvedId;
+
+ return $resolvedId === $instrumentId
+ ? 'Aktie aktualisiert.'
+ : 'Aktie aktualisiert und mit bestehendem Systemeintrag zusammengefuehrt.';
+ }
+
+ private function saveQuote(): string
+ {
+ $instrumentId = (int) ($_POST['instrument_id'] ?? 0);
+ $price = (float) ($_POST['quote_price'] ?? 0);
+ if ($instrumentId <= 0 || $price <= 0) {
+ throw new RuntimeException('Bitte Aktie und Kurs angeben.');
+ }
+ $this->assertInstrumentAccessible($instrumentId);
+
+ $stmt = $this->pdo->prepare(
+ 'INSERT INTO ' . $this->quoteTable . ' (instrument_id, price, currency, quoted_at, source)
+ VALUES (:instrument_id, :price, :currency, :quoted_at, :source)'
+ );
+ $stmt->execute([
+ 'instrument_id' => $instrumentId,
+ 'price' => $price,
+ 'currency' => strtoupper(trim((string) ($_POST['quote_currency'] ?? $this->defaultReportCurrency))) ?: $this->defaultReportCurrency,
+ 'quoted_at' => date('Y-m-d H:i:s', strtotime((string) ($_POST['quoted_at'] ?? 'now')) ?: time()),
+ 'source' => trim((string) ($_POST['quote_source'] ?? 'manual')) ?: 'manual',
+ ]);
+ return 'Kurs gespeichert.';
+ }
+
+ private function deleteQuote(): string
+ {
+ $quoteId = (int) ($_POST['quote_id'] ?? 0);
+ if ($quoteId <= 0) {
+ throw new RuntimeException('Bitte einen Kurseintrag auswaehlen.');
+ }
+
+ $stmt = $this->pdo->prepare(
+ 'SELECT q.instrument_id
+ FROM ' . $this->quoteTable . ' q
+ WHERE q.id = :id
+ LIMIT 1'
+ );
+ $stmt->execute(['id' => $quoteId]);
+ $instrumentId = (int) $stmt->fetchColumn();
+ $this->assertInstrumentAccessible($instrumentId);
+
+ $stmt = $this->pdo->prepare('DELETE FROM ' . $this->quoteTable . ' WHERE id = :id');
+ $stmt->execute(['id' => $quoteId]);
+ return 'Kurs geloescht.';
+ }
+
+ private function refreshInstrumentQuote(): string
+ {
+ $instrumentId = (int) ($_POST['instrument_id'] ?? 0);
+ $instrument = $this->assertInstrumentAccessible($instrumentId);
+ $symbol = trim((string) ($instrument['symbol'] ?? ''));
+ if ($symbol === '') {
+ throw new RuntimeException('Fuer diese Aktie ist kein Symbol hinterlegt.');
+ }
+
+ $apiResult = \module_fn('boersenchecker', 'alpha_vantage_fetch_quote', $symbol);
+ if (empty($apiResult['ok'])) {
+ throw new RuntimeException((string) ($apiResult['message'] ?? 'API-Abruf fehlgeschlagen.'));
+ }
+
+ $stmtInsert = $this->pdo->prepare(
+ 'INSERT INTO ' . $this->quoteTable . ' (instrument_id, price, currency, quoted_at, source)
+ VALUES (:instrument_id, :price, :currency, :quoted_at, :source)'
+ );
+ $stmtInsert->execute([
+ 'instrument_id' => $instrumentId,
+ 'price' => (float) $apiResult['price'],
+ 'currency' => strtoupper(trim((string) ($instrument['quote_currency'] ?? $this->defaultReportCurrency))) ?: $this->defaultReportCurrency,
+ 'quoted_at' => (string) $apiResult['fetched_at'],
+ 'source' => (string) $apiResult['source'],
+ ]);
+
+ return 'API-Kurs gespeichert.';
+ }
+
+ private function searchSymbol(): string
+ {
+ $this->searchKeywords = trim((string) ($_POST['search_keywords'] ?? ''));
+ $result = \module_fn('boersenchecker', 'alpha_vantage_search_symbols', $this->searchKeywords);
+ $this->searchResults = is_array($result['results'] ?? null) ? $result['results'] : [];
+ if (empty($result['ok'])) {
+ throw new RuntimeException((string) ($result['message'] ?? 'Symbolsuche fehlgeschlagen.'));
+ }
+ return (string) ($result['message'] ?? 'Suche abgeschlossen.');
+ }
+
+ private function buildAvailableOwners(): array
+ {
+ $owners = [];
+ $currentSub = trim((string) ($this->user['sub'] ?? 'local'));
+ $owners[$currentSub] = [
+ 'sub' => $currentSub,
+ 'label' => trim((string) ($this->user['name'] ?? $this->user['email'] ?? $currentSub)) ?: $currentSub,
+ ];
+ if (!$this->isAdmin) {
+ return $owners;
+ }
+ foreach (\modules()->knownAuthUsers() as $knownUser) {
+ $sub = trim((string) ($knownUser['sub'] ?? ''));
+ if ($sub === '') {
+ continue;
+ }
+ $label = trim((string) ($knownUser['name'] ?? $knownUser['email'] ?? $knownUser['username'] ?? $sub));
+ $owners[$sub] = ['sub' => $sub, 'label' => $label !== '' ? $label : $sub];
+ }
+ uasort($owners, static fn (array $left, array $right): int => strcmp((string) $left['label'], (string) $right['label']));
+ return $owners;
+ }
+
+ private function assertInstrumentAccessible(int $instrumentId): array
+ {
+ if ($instrumentId <= 0) {
+ throw new RuntimeException('Aktie nicht gefunden.');
+ }
+
+ $stmt = $this->pdo->prepare(
+ 'SELECT DISTINCT i.*
+ FROM ' . $this->instrumentTable . ' i
+ INNER JOIN ' . $this->positionTable . ' p ON p.instrument_id = i.id
+ WHERE i.id = :id AND p.owner_sub = :owner_sub
+ LIMIT 1'
+ );
+ $stmt->execute([
+ 'id' => $instrumentId,
+ 'owner_sub' => $this->ownerSub,
+ ]);
+ $instrument = $stmt->fetch(PDO::FETCH_ASSOC);
+ if (!is_array($instrument)) {
+ throw new RuntimeException('Aktie ist in diesem Benutzer-Scope nicht verfuegbar.');
+ }
+
+ return $instrument;
+ }
+}
diff --git a/modules/boersenchecker/src/Support/InstrumentRegistry.php b/modules/boersenchecker/src/Support/InstrumentRegistry.php
new file mode 100644
index 0000000..fc8f471
--- /dev/null
+++ b/modules/boersenchecker/src/Support/InstrumentRegistry.php
@@ -0,0 +1,190 @@
+normalizePayload($payload);
+ $matchingId = $this->findMatchingInstrumentId($data, $currentId);
+
+ if ($currentId > 0 && $matchingId > 0 && $matchingId !== $currentId) {
+ return $this->mergeIntoExistingInstrument($currentId, $matchingId, $data);
+ }
+
+ if ($currentId > 0) {
+ $this->updateInstrument($currentId, $data);
+ return $currentId;
+ }
+
+ if ($matchingId > 0) {
+ $this->updateInstrument($matchingId, $data);
+ return $matchingId;
+ }
+
+ return $this->insertInstrument($data);
+ }
+
+ public function findMatchingInstrumentId(array $payload, int $excludeId = 0): ?int
+ {
+ $data = $this->normalizePayload($payload);
+ $conditions = [];
+ $excludeSql = $excludeId > 0 ? ' AND id <> :exclude_id' : '';
+
+ if ($data['isin'] !== null) {
+ $conditions[] = [
+ 'sql' => 'SELECT id FROM ' . $this->instrumentTable . ' WHERE isin = :isin' . $excludeSql . ' LIMIT 1',
+ 'params' => ['isin' => $data['isin']],
+ ];
+ }
+
+ if ($data['symbol'] !== null && $data['market'] !== null) {
+ $conditions[] = [
+ 'sql' => 'SELECT id FROM ' . $this->instrumentTable . ' WHERE symbol = :symbol AND market = :market' . $excludeSql . ' LIMIT 1',
+ 'params' => ['symbol' => $data['symbol'], 'market' => $data['market']],
+ ];
+ }
+
+ if ($data['symbol'] !== null && $data['name'] !== '') {
+ $conditions[] = [
+ 'sql' => 'SELECT id FROM ' . $this->instrumentTable . ' WHERE symbol = :symbol AND name = :name' . $excludeSql . ' LIMIT 1',
+ 'params' => ['symbol' => $data['symbol'], 'name' => $data['name']],
+ ];
+ }
+
+ foreach ($conditions as $condition) {
+ $params = $condition['params'];
+ if ($excludeId > 0) {
+ $params['exclude_id'] = $excludeId;
+ }
+
+ $stmt = $this->pdo->prepare($condition['sql']);
+ $stmt->execute($params);
+ $id = $stmt->fetchColumn();
+ if ($id !== false) {
+ return (int) $id;
+ }
+ }
+
+ return null;
+ }
+
+ private function normalizePayload(array $payload): array
+ {
+ $data = [
+ 'isin' => $this->normalizeUpper($payload['isin'] ?? null),
+ 'wkn' => $this->normalizeUpper($payload['wkn'] ?? null),
+ 'symbol' => $this->normalizeUpper($payload['symbol'] ?? null),
+ 'name' => trim((string) ($payload['name'] ?? '')),
+ 'quote_currency' => $this->normalizeUpper($payload['quote_currency'] ?? 'EUR', 'EUR'),
+ 'market' => trim((string) ($payload['market'] ?? '')) ?: null,
+ ];
+
+ if ($data['name'] === '') {
+ throw new RuntimeException('Bitte mindestens einen Aktiennamen angeben.');
+ }
+
+ return $data;
+ }
+
+ private function normalizeUpper(mixed $value, string $fallback = ''): ?string
+ {
+ $normalized = strtoupper(trim((string) $value));
+ if ($normalized !== '') {
+ return $normalized;
+ }
+
+ return $fallback !== '' ? $fallback : null;
+ }
+
+ private function updateInstrument(int $instrumentId, array $data): void
+ {
+ $stmt = $this->pdo->prepare(
+ 'UPDATE ' . $this->instrumentTable . '
+ SET isin = :isin,
+ wkn = :wkn,
+ symbol = :symbol,
+ name = :name,
+ quote_currency = :quote_currency,
+ market = :market,
+ updated_at = CURRENT_TIMESTAMP
+ WHERE id = :id'
+ );
+ $stmt->execute($data + ['id' => $instrumentId]);
+ }
+
+ private function insertInstrument(array $data): int
+ {
+ $driver = strtolower((string) $this->pdo->getAttribute(PDO::ATTR_DRIVER_NAME));
+ if ($driver === 'pgsql') {
+ $stmt = $this->pdo->prepare(
+ 'INSERT INTO ' . $this->instrumentTable . ' (isin, wkn, symbol, name, quote_currency, market)
+ VALUES (:isin, :wkn, :symbol, :name, :quote_currency, :market)
+ RETURNING id'
+ );
+ $stmt->execute($data);
+ return (int) $stmt->fetchColumn();
+ }
+
+ $stmt = $this->pdo->prepare(
+ 'INSERT INTO ' . $this->instrumentTable . ' (isin, wkn, symbol, name, quote_currency, market)
+ VALUES (:isin, :wkn, :symbol, :name, :quote_currency, :market)'
+ );
+ $stmt->execute($data);
+ return (int) $this->pdo->lastInsertId();
+ }
+
+ private function mergeIntoExistingInstrument(int $sourceId, int $targetId, array $data): int
+ {
+ $this->pdo->beginTransaction();
+ try {
+ $this->updateInstrument($targetId, $data);
+
+ $stmt = $this->pdo->prepare(
+ 'UPDATE ' . $this->positionTable . '
+ SET instrument_id = :target_id, updated_at = CURRENT_TIMESTAMP
+ WHERE instrument_id = :source_id'
+ );
+ $stmt->execute([
+ 'target_id' => $targetId,
+ 'source_id' => $sourceId,
+ ]);
+
+ $stmt = $this->pdo->prepare(
+ 'UPDATE ' . $this->quoteTable . '
+ SET instrument_id = :target_id
+ WHERE instrument_id = :source_id'
+ );
+ $stmt->execute([
+ 'target_id' => $targetId,
+ 'source_id' => $sourceId,
+ ]);
+
+ $stmt = $this->pdo->prepare('DELETE FROM ' . $this->instrumentTable . ' WHERE id = :id');
+ $stmt->execute(['id' => $sourceId]);
+
+ $this->pdo->commit();
+ } catch (\Throwable $e) {
+ if ($this->pdo->inTransaction()) {
+ $this->pdo->rollBack();
+ }
+ throw $e;
+ }
+
+ return $targetId;
+ }
+}
|