asdasd
This commit is contained in:
898
custom/apps/boersenchecker/src/Support/DashboardPage.php
Normal file
898
custom/apps/boersenchecker/src/Support/DashboardPage.php
Normal file
@@ -0,0 +1,898 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Modules\Boersenchecker\Support;
|
||||
|
||||
use PDO;
|
||||
use RuntimeException;
|
||||
|
||||
final class DashboardPage
|
||||
{
|
||||
private PDO $pdo;
|
||||
private array $user;
|
||||
private bool $isAdmin;
|
||||
private string $ownerSub;
|
||||
private array $moduleSettings;
|
||||
private string $defaultReportCurrency;
|
||||
private float $fxMaxAgeHours;
|
||||
private int $marketDataMinIntervalMinutes;
|
||||
private int $editPortfolioId;
|
||||
private int $editPositionId;
|
||||
private string $portfolioTable;
|
||||
private string $instrumentTable;
|
||||
private string $positionTable;
|
||||
private string $quoteTable;
|
||||
private InstrumentRegistry $instrumentRegistry;
|
||||
private string $symbolSearchKeywords = '';
|
||||
private array $symbolSearchResults = [];
|
||||
private array $availableOwners = [];
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->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;
|
||||
}
|
||||
}
|
||||
$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);
|
||||
if ($this->fxMaxAgeHours <= 0) {
|
||||
$this->fxMaxAgeHours = 6.0;
|
||||
}
|
||||
|
||||
$this->marketDataMinIntervalMinutes = (int) (($this->moduleSettings['alpha_vantage_min_interval_minutes'] ?? null) ?: 60);
|
||||
if ($this->marketDataMinIntervalMinutes <= 0) {
|
||||
$this->marketDataMinIntervalMinutes = 60;
|
||||
}
|
||||
|
||||
$this->editPortfolioId = (int) ($_GET['edit_portfolio'] ?? 0);
|
||||
$this->editPositionId = (int) ($_GET['edit_position'] ?? 0);
|
||||
|
||||
$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');
|
||||
$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();
|
||||
}
|
||||
}
|
||||
|
||||
$state = $this->loadState();
|
||||
|
||||
if ($notice === null && isset($state['notice_override']) && is_string($state['notice_override'])) {
|
||||
$notice = $state['notice_override'];
|
||||
}
|
||||
if ($error === null && isset($state['error_override']) && is_string($state['error_override'])) {
|
||||
$error = $state['error_override'];
|
||||
}
|
||||
|
||||
return [
|
||||
'notice' => $notice,
|
||||
'error' => $error,
|
||||
'isAdmin' => $this->isAdmin,
|
||||
'ownerSub' => $this->ownerSub,
|
||||
'availableOwners' => array_values($this->availableOwners),
|
||||
'defaultReportCurrency' => $this->defaultReportCurrency,
|
||||
'fxMaxAgeHours' => $this->fxMaxAgeHours,
|
||||
'marketDataMinIntervalMinutes' => $this->marketDataMinIntervalMinutes,
|
||||
'symbolSearchKeywords' => $this->symbolSearchKeywords,
|
||||
'symbolSearchResults' => $this->symbolSearchResults,
|
||||
'editPortfolio' => $state['editPortfolio'],
|
||||
'editPosition' => $state['editPosition'],
|
||||
'portfolios' => $state['portfolios'],
|
||||
'portfolioById' => $state['portfolioById'],
|
||||
'portfolioStats' => $state['portfolioStats'],
|
||||
'positions' => $state['positions'],
|
||||
'instrumentList' => $state['instrumentList'],
|
||||
'quoteHistory' => $state['quoteHistory'],
|
||||
'selectedInstrumentForQuote' => $state['selectedInstrumentForQuote'],
|
||||
'selectedInstrumentQuoteCurrency' => $state['selectedInstrumentQuoteCurrency'],
|
||||
'fmtNumber' => fn (?float $value, int $scale = 2): string => $this->formatNumber($value, $scale),
|
||||
'fmtDateTime' => fn (?string $value, ?string $source = null): string => (string) \module_fn('boersenchecker', 'format_datetime_for_display', $value, $source),
|
||||
'localNowInputValue' => (string) \module_fn('boersenchecker', 'local_now_input_value'),
|
||||
];
|
||||
}
|
||||
|
||||
private function handlePost(): string
|
||||
{
|
||||
$action = trim((string) ($_POST['action'] ?? ''));
|
||||
|
||||
return match ($action) {
|
||||
'save_portfolio' => $this->savePortfolio(),
|
||||
'delete_portfolio' => $this->deletePortfolio(),
|
||||
'save_position' => $this->savePosition(),
|
||||
'delete_position' => $this->deletePosition(),
|
||||
'save_quote' => $this->saveQuote(),
|
||||
'refresh_market_data_position' => $this->refreshMarketDataPosition(),
|
||||
'refresh_market_data_all' => $this->refreshMarketDataAll(),
|
||||
'search_symbol' => $this->searchSymbol(),
|
||||
'delete_quote' => $this->deleteQuote(),
|
||||
'refresh_fx' => $this->refreshFx(),
|
||||
default => '',
|
||||
};
|
||||
}
|
||||
|
||||
private function loadState(): array
|
||||
{
|
||||
$portfolios = $this->fetchPortfolios();
|
||||
$positions = $this->fetchPositions();
|
||||
$instrumentList = $this->buildInstrumentList($positions);
|
||||
[$quoteHistory, $latestQuotes] = $this->fetchQuotes(array_keys($instrumentList));
|
||||
$portfolioById = $this->buildPortfolioById($portfolios);
|
||||
[$positions, $portfolioStats] = $this->enrichPositions($positions, $portfolioById, $latestQuotes);
|
||||
|
||||
$editPortfolio = null;
|
||||
if ($this->editPortfolioId > 0 && isset($portfolioById[$this->editPortfolioId])) {
|
||||
$editPortfolio = $portfolioById[$this->editPortfolioId];
|
||||
}
|
||||
|
||||
$editPosition = null;
|
||||
if ($this->editPositionId > 0) {
|
||||
foreach ($positions as $position) {
|
||||
if ((int) $position['id'] === $this->editPositionId) {
|
||||
$editPosition = $position;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$selectedInstrumentForQuote = $editPosition
|
||||
? (int) $editPosition['instrument_id']
|
||||
: (int) ($_GET['instrument_id'] ?? 0);
|
||||
$selectedInstrumentQuoteCurrency = $this->defaultReportCurrency;
|
||||
if ($selectedInstrumentForQuote > 0 && isset($instrumentList[$selectedInstrumentForQuote])) {
|
||||
$selectedInstrumentQuoteCurrency = $this->normalizeCurrency(
|
||||
(string) ($instrumentList[$selectedInstrumentForQuote]['quote_currency'] ?? $this->defaultReportCurrency)
|
||||
);
|
||||
}
|
||||
|
||||
$candidateName = trim((string) ($_GET['instrument_name_candidate'] ?? ''));
|
||||
$candidateSymbol = trim((string) ($_GET['symbol_candidate'] ?? ''));
|
||||
$candidateIsin = trim((string) ($_GET['isin_candidate'] ?? ''));
|
||||
$candidateMarket = trim((string) ($_GET['market_candidate'] ?? ''));
|
||||
$candidateCurrency = $this->normalizeCurrency((string) ($_GET['quote_currency_candidate'] ?? $this->defaultReportCurrency));
|
||||
|
||||
if ($editPosition === null) {
|
||||
$editPosition = [
|
||||
'instrument_name' => $candidateName,
|
||||
'symbol' => $candidateSymbol,
|
||||
'isin' => $candidateIsin,
|
||||
'market' => $candidateMarket,
|
||||
'quote_currency' => $candidateCurrency,
|
||||
'purchase_currency' => $this->defaultReportCurrency,
|
||||
'purchase_date' => date('Y-m-d'),
|
||||
];
|
||||
} else {
|
||||
if ($candidateName !== '') {
|
||||
$editPosition['instrument_name'] = $candidateName;
|
||||
}
|
||||
if ($candidateSymbol !== '') {
|
||||
$editPosition['symbol'] = $candidateSymbol;
|
||||
}
|
||||
if ($candidateIsin !== '') {
|
||||
$editPosition['isin'] = $candidateIsin;
|
||||
}
|
||||
if ($candidateMarket !== '') {
|
||||
$editPosition['market'] = $candidateMarket;
|
||||
}
|
||||
if ($candidateCurrency !== '') {
|
||||
$editPosition['quote_currency'] = $candidateCurrency;
|
||||
}
|
||||
}
|
||||
|
||||
return [
|
||||
'portfolios' => $portfolios,
|
||||
'portfolioById' => $portfolioById,
|
||||
'portfolioStats' => $portfolioStats,
|
||||
'positions' => $positions,
|
||||
'instrumentList' => $instrumentList,
|
||||
'quoteHistory' => $quoteHistory,
|
||||
'editPortfolio' => $editPortfolio,
|
||||
'editPosition' => $editPosition,
|
||||
'selectedInstrumentForQuote' => $selectedInstrumentForQuote,
|
||||
'selectedInstrumentQuoteCurrency' => $selectedInstrumentQuoteCurrency,
|
||||
];
|
||||
}
|
||||
|
||||
private function savePortfolio(): string
|
||||
{
|
||||
$portfolioId = (int) ($_POST['portfolio_id'] ?? 0);
|
||||
$name = trim((string) ($_POST['portfolio_name'] ?? ''));
|
||||
$baseCurrency = $this->normalizeCurrency((string) ($_POST['portfolio_base_currency'] ?? $this->defaultReportCurrency));
|
||||
$notes = trim((string) ($_POST['portfolio_notes'] ?? ''));
|
||||
|
||||
if ($name === '') {
|
||||
throw new RuntimeException('Bitte einen Depotnamen angeben.');
|
||||
}
|
||||
|
||||
if ($portfolioId > 0) {
|
||||
$stmt = $this->pdo->prepare(
|
||||
'UPDATE ' . $this->portfolioTable . '
|
||||
SET name = :name, base_currency = :base_currency, notes = :notes, updated_at = CURRENT_TIMESTAMP
|
||||
WHERE id = :id AND owner_sub = :owner_sub'
|
||||
);
|
||||
$stmt->execute([
|
||||
'id' => $portfolioId,
|
||||
'owner_sub' => $this->ownerSub,
|
||||
'name' => $name,
|
||||
'base_currency' => $baseCurrency,
|
||||
'notes' => $notes !== '' ? $notes : null,
|
||||
]);
|
||||
return 'Depot aktualisiert.';
|
||||
}
|
||||
|
||||
$stmt = $this->pdo->prepare(
|
||||
'INSERT INTO ' . $this->portfolioTable . ' (owner_sub, name, base_currency, notes)
|
||||
VALUES (:owner_sub, :name, :base_currency, :notes)'
|
||||
);
|
||||
$stmt->execute([
|
||||
'owner_sub' => $this->ownerSub,
|
||||
'name' => $name,
|
||||
'base_currency' => $baseCurrency,
|
||||
'notes' => $notes !== '' ? $notes : null,
|
||||
]);
|
||||
return 'Depot angelegt.';
|
||||
}
|
||||
|
||||
private function deletePortfolio(): string
|
||||
{
|
||||
$portfolioId = (int) ($_POST['portfolio_id'] ?? 0);
|
||||
$countStmt = $this->pdo->prepare('SELECT COUNT(*) FROM ' . $this->positionTable . ' WHERE portfolio_id = :portfolio_id AND owner_sub = :owner_sub');
|
||||
$countStmt->execute([
|
||||
'portfolio_id' => $portfolioId,
|
||||
'owner_sub' => $this->ownerSub,
|
||||
]);
|
||||
if ((int) $countStmt->fetchColumn() > 0) {
|
||||
throw new RuntimeException('Depot kann erst geloescht werden, wenn alle Positionen entfernt wurden.');
|
||||
}
|
||||
|
||||
$stmt = $this->pdo->prepare('DELETE FROM ' . $this->portfolioTable . ' WHERE id = :id AND owner_sub = :owner_sub');
|
||||
$stmt->execute([
|
||||
'id' => $portfolioId,
|
||||
'owner_sub' => $this->ownerSub,
|
||||
]);
|
||||
return 'Depot geloescht.';
|
||||
}
|
||||
|
||||
private function savePosition(): string
|
||||
{
|
||||
$positionId = (int) ($_POST['position_id'] ?? 0);
|
||||
$portfolioId = (int) ($_POST['portfolio_id'] ?? 0);
|
||||
$quantity = (float) ($_POST['quantity'] ?? 0);
|
||||
$purchasePrice = (float) ($_POST['purchase_price'] ?? 0);
|
||||
$purchaseCurrency = $this->normalizeCurrency((string) ($_POST['purchase_currency'] ?? $this->defaultReportCurrency));
|
||||
$purchaseDate = trim((string) ($_POST['purchase_date'] ?? ''));
|
||||
$fees = trim((string) ($_POST['fees'] ?? ''));
|
||||
$notes = trim((string) ($_POST['position_notes'] ?? ''));
|
||||
|
||||
if ($portfolioId <= 0) {
|
||||
throw new RuntimeException('Bitte zuerst ein Depot auswaehlen.');
|
||||
}
|
||||
if ($quantity <= 0 || $purchasePrice <= 0 || $purchaseDate === '') {
|
||||
throw new RuntimeException('Bitte Stueckzahl, Kaufpreis und Kaufdatum angeben.');
|
||||
}
|
||||
|
||||
$portfolioOwnerStmt = $this->pdo->prepare('SELECT id FROM ' . $this->portfolioTable . ' WHERE id = :id AND owner_sub = :owner_sub LIMIT 1');
|
||||
$portfolioOwnerStmt->execute([
|
||||
'id' => $portfolioId,
|
||||
'owner_sub' => $this->ownerSub,
|
||||
]);
|
||||
if ($portfolioOwnerStmt->fetchColumn() === false) {
|
||||
throw new RuntimeException('Das ausgewaehlte Depot ist nicht verfuegbar.');
|
||||
}
|
||||
|
||||
$instrumentId = $this->upsertInstrument([
|
||||
'id' => (int) ($_POST['instrument_id'] ?? 0),
|
||||
'isin' => $_POST['isin'] ?? '',
|
||||
'wkn' => $_POST['wkn'] ?? '',
|
||||
'symbol' => $_POST['symbol'] ?? '',
|
||||
'name' => $_POST['instrument_name'] ?? '',
|
||||
'quote_currency' => $_POST['quote_currency'] ?? $purchaseCurrency,
|
||||
'market' => $_POST['market'] ?? '',
|
||||
]);
|
||||
|
||||
$payload = [
|
||||
'owner_sub' => $this->ownerSub,
|
||||
'portfolio_id' => $portfolioId,
|
||||
'instrument_id' => $instrumentId,
|
||||
'quantity' => $quantity,
|
||||
'purchase_price' => $purchasePrice,
|
||||
'purchase_currency' => $purchaseCurrency,
|
||||
'purchase_date' => $purchaseDate,
|
||||
'fees' => $fees !== '' ? (float) $fees : null,
|
||||
'notes' => $notes !== '' ? $notes : null,
|
||||
];
|
||||
|
||||
if ($positionId > 0) {
|
||||
$stmt = $this->pdo->prepare(
|
||||
'UPDATE ' . $this->positionTable . '
|
||||
SET portfolio_id = :portfolio_id,
|
||||
instrument_id = :instrument_id,
|
||||
quantity = :quantity,
|
||||
purchase_price = :purchase_price,
|
||||
purchase_currency = :purchase_currency,
|
||||
purchase_date = :purchase_date,
|
||||
fees = :fees,
|
||||
notes = :notes,
|
||||
updated_at = CURRENT_TIMESTAMP
|
||||
WHERE id = :id AND owner_sub = :owner_sub'
|
||||
);
|
||||
$stmt->execute($payload + ['id' => $positionId]);
|
||||
return 'Position aktualisiert.';
|
||||
}
|
||||
|
||||
$stmt = $this->pdo->prepare(
|
||||
'INSERT INTO ' . $this->positionTable . ' (
|
||||
owner_sub, portfolio_id, instrument_id, quantity, purchase_price, purchase_currency, purchase_date, fees, notes
|
||||
) VALUES (
|
||||
:owner_sub, :portfolio_id, :instrument_id, :quantity, :purchase_price, :purchase_currency, :purchase_date, :fees, :notes
|
||||
)'
|
||||
);
|
||||
$stmt->execute($payload);
|
||||
return 'Position gespeichert.';
|
||||
}
|
||||
|
||||
private function deletePosition(): string
|
||||
{
|
||||
$positionId = (int) ($_POST['position_id'] ?? 0);
|
||||
$stmt = $this->pdo->prepare('DELETE FROM ' . $this->positionTable . ' WHERE id = :id AND owner_sub = :owner_sub');
|
||||
$stmt->execute([
|
||||
'id' => $positionId,
|
||||
'owner_sub' => $this->ownerSub,
|
||||
]);
|
||||
return 'Position geloescht.';
|
||||
}
|
||||
|
||||
private function saveQuote(): string
|
||||
{
|
||||
$instrumentId = (int) ($_POST['quote_instrument_id'] ?? 0);
|
||||
$price = (float) ($_POST['quote_price'] ?? 0);
|
||||
$currency = $this->normalizeCurrency((string) ($_POST['quote_currency'] ?? $this->defaultReportCurrency));
|
||||
$quotedAt = $this->normalizeDateTimeLocal((string) ($_POST['quoted_at'] ?? ''));
|
||||
$source = trim((string) ($_POST['quote_source'] ?? 'manual')) ?: 'manual';
|
||||
|
||||
if ($instrumentId <= 0 || $price <= 0) {
|
||||
throw new RuntimeException('Bitte Aktie und Kurs angeben.');
|
||||
}
|
||||
|
||||
$this->storeQuote($instrumentId, $price, $currency, $quotedAt, $source);
|
||||
return 'Kurs gespeichert.';
|
||||
}
|
||||
|
||||
private function refreshMarketDataPosition(): string
|
||||
{
|
||||
$positionId = (int) ($_POST['position_id'] ?? 0);
|
||||
$stmt = $this->pdo->prepare(
|
||||
'SELECT
|
||||
p.instrument_id,
|
||||
i.name AS instrument_name,
|
||||
i.symbol,
|
||||
i.isin,
|
||||
i.quote_currency
|
||||
FROM ' . $this->positionTable . ' p
|
||||
INNER JOIN ' . $this->instrumentTable . ' i ON i.id = p.instrument_id
|
||||
WHERE p.id = :id AND p.owner_sub = :owner_sub
|
||||
LIMIT 1'
|
||||
);
|
||||
$stmt->execute([
|
||||
'id' => $positionId,
|
||||
'owner_sub' => $this->ownerSub,
|
||||
]);
|
||||
$row = $stmt->fetch(PDO::FETCH_ASSOC);
|
||||
if (!is_array($row)) {
|
||||
throw new RuntimeException('Position nicht gefunden.');
|
||||
}
|
||||
|
||||
$instrumentId = (int) $row['instrument_id'];
|
||||
$symbol = strtoupper(trim((string) ($row['symbol'] ?? '')));
|
||||
$quoteCurrency = $this->normalizeCurrency((string) ($row['quote_currency'] ?? $this->defaultReportCurrency));
|
||||
if ($symbol === '') {
|
||||
throw new RuntimeException('Fuer diese Aktie ist noch kein Symbol hinterlegt.');
|
||||
}
|
||||
|
||||
$latestApiQuote = $this->latestApiQuoteForInstrument($instrumentId);
|
||||
$latestTimestamp = is_array($latestApiQuote) ? strtotime((string) ($latestApiQuote['quoted_at'] ?? '')) : false;
|
||||
if ($latestTimestamp !== false && (time() - $latestTimestamp) < ($this->marketDataMinIntervalMinutes * 60)) {
|
||||
return 'Vorhandener Alpha-Vantage-Kurs fuer ' . (string) $row['instrument_name'] . ' wiederverwendet.';
|
||||
}
|
||||
|
||||
$apiResult = \module_fn('boersenchecker', 'alpha_vantage_fetch_quote_by_symbol', $symbol);
|
||||
if (empty($apiResult['ok'])) {
|
||||
throw new RuntimeException((string) ($apiResult['message'] ?? 'API-Abruf fehlgeschlagen.'));
|
||||
}
|
||||
$fxResult = \module_fn('boersenchecker', 'fx_prepare_fetch', $this->defaultReportCurrency, [$quoteCurrency], $this->fxMaxAgeHours);
|
||||
if (empty($fxResult['ok'])) {
|
||||
throw new RuntimeException((string) ($fxResult['message'] ?? 'FX-Aktualisierung fehlgeschlagen.'));
|
||||
}
|
||||
$fxFetchId = is_numeric($fxResult['fetch_id'] ?? null) ? (int) $fxResult['fetch_id'] : null;
|
||||
if ($this->isApiSnapshotStale($latestApiQuote, (string) ($apiResult['fetched_at'] ?? ''))) {
|
||||
$displayTime = (string) \module_fn(
|
||||
'boersenchecker',
|
||||
'format_datetime_for_display',
|
||||
(string) ($apiResult['fetched_at'] ?? ''),
|
||||
(string) ($apiResult['source'] ?? 'alphavantage:global_quote')
|
||||
);
|
||||
return 'Alpha Vantage lieferte fuer ' . (string) $row['instrument_name'] . ' keinen neueren Snapshot als ' . $displayTime . '.';
|
||||
}
|
||||
|
||||
$storeResult = \module_fn(
|
||||
'boersenchecker',
|
||||
'store_market_quote',
|
||||
$instrumentId,
|
||||
(float) $apiResult['price'],
|
||||
$quoteCurrency,
|
||||
(string) $apiResult['fetched_at'],
|
||||
(string) \module_fn('boersenchecker', 'fx_source_with_fetch_id', (string) $apiResult['source'], $fxFetchId)
|
||||
);
|
||||
if (!empty($storeResult['inserted'])) {
|
||||
return 'Alpha-Vantage-Kurs fuer ' . (string) $row['instrument_name'] . ' gespeichert.';
|
||||
}
|
||||
return 'Vorhandener Alpha-Vantage-Snapshot fuer ' . (string) $row['instrument_name'] . ' wiederverwendet.';
|
||||
}
|
||||
|
||||
private function refreshMarketDataAll(): string
|
||||
{
|
||||
$stmt = $this->pdo->prepare(
|
||||
'SELECT DISTINCT
|
||||
i.id,
|
||||
i.name,
|
||||
i.symbol,
|
||||
i.isin,
|
||||
i.quote_currency
|
||||
FROM ' . $this->positionTable . ' p
|
||||
INNER JOIN ' . $this->instrumentTable . ' i ON i.id = p.instrument_id
|
||||
WHERE p.owner_sub = :owner_sub
|
||||
ORDER BY i.name ASC'
|
||||
);
|
||||
$stmt->execute(['owner_sub' => $this->ownerSub]);
|
||||
$rows = $stmt->fetchAll(PDO::FETCH_ASSOC) ?: [];
|
||||
if ($rows === []) {
|
||||
throw new RuntimeException('Keine Positionen fuer den API-Abruf vorhanden.');
|
||||
}
|
||||
|
||||
$fetched = 0;
|
||||
$reused = 0;
|
||||
$stale = 0;
|
||||
$skipped = 0;
|
||||
$failed = 0;
|
||||
$errors = [];
|
||||
|
||||
$bulkRows = [];
|
||||
foreach ($rows as $row) {
|
||||
$instrumentId = (int) ($row['id'] ?? 0);
|
||||
$symbol = strtoupper(trim((string) ($row['symbol'] ?? '')));
|
||||
if ($instrumentId <= 0 || $symbol === '') {
|
||||
$skipped++;
|
||||
continue;
|
||||
}
|
||||
|
||||
$latestApiQuote = $this->latestApiQuoteForInstrument($instrumentId);
|
||||
$latestTimestamp = is_array($latestApiQuote) ? strtotime((string) ($latestApiQuote['quoted_at'] ?? '')) : false;
|
||||
if ($latestTimestamp !== false && (time() - $latestTimestamp) < ($this->marketDataMinIntervalMinutes * 60)) {
|
||||
$reused++;
|
||||
continue;
|
||||
}
|
||||
|
||||
$bulkRows[] = $row;
|
||||
}
|
||||
|
||||
if ($bulkRows !== []) {
|
||||
$quoteCurrencies = array_values(array_unique(array_filter(array_map(
|
||||
fn (array $row): string => $this->normalizeCurrency((string) ($row['quote_currency'] ?? '')),
|
||||
$bulkRows
|
||||
))));
|
||||
$fxResult = \module_fn('boersenchecker', 'fx_prepare_fetch', $this->defaultReportCurrency, $quoteCurrencies, $this->fxMaxAgeHours);
|
||||
if (empty($fxResult['ok'])) {
|
||||
throw new RuntimeException((string) ($fxResult['message'] ?? 'FX-Aktualisierung fehlgeschlagen.'));
|
||||
}
|
||||
$fxFetchId = is_numeric($fxResult['fetch_id'] ?? null) ? (int) $fxResult['fetch_id'] : null;
|
||||
|
||||
$bulkResult = \module_fn('boersenchecker', 'alpha_vantage_fetch_quotes', $bulkRows);
|
||||
if (empty($bulkResult['ok'])) {
|
||||
throw new RuntimeException((string) ($bulkResult['message'] ?? 'Alpha-Vantage-Abruf fehlgeschlagen.'));
|
||||
}
|
||||
|
||||
$bulkQuotes = is_array($bulkResult['quotes'] ?? null) ? $bulkResult['quotes'] : [];
|
||||
foreach ($bulkRows as $row) {
|
||||
$instrumentId = (int) ($row['id'] ?? 0);
|
||||
$quoteCurrency = $this->normalizeCurrency((string) ($row['quote_currency'] ?? $this->defaultReportCurrency));
|
||||
$apiResult = $bulkQuotes[$instrumentId] ?? null;
|
||||
if (!is_array($apiResult) || !is_numeric($apiResult['price'] ?? null)) {
|
||||
$failed++;
|
||||
$errors[] = (string) ($row['name'] ?? $instrumentId) . ': kein Preis in der Alpha-Vantage-Antwort.';
|
||||
continue;
|
||||
}
|
||||
$latestApiQuote = $this->latestApiQuoteForInstrument($instrumentId);
|
||||
if ($this->isApiSnapshotStale($latestApiQuote, (string) ($apiResult['fetched_at'] ?? ''))) {
|
||||
$stale++;
|
||||
continue;
|
||||
}
|
||||
|
||||
$storeResult = \module_fn(
|
||||
'boersenchecker',
|
||||
'store_market_quote',
|
||||
$instrumentId,
|
||||
(float) $apiResult['price'],
|
||||
$quoteCurrency,
|
||||
(string) $apiResult['fetched_at'],
|
||||
(string) \module_fn('boersenchecker', 'fx_source_with_fetch_id', (string) $apiResult['source'], $fxFetchId)
|
||||
);
|
||||
if (!empty($storeResult['inserted'])) {
|
||||
$fetched++;
|
||||
} else {
|
||||
$reused++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if ($errors !== []) {
|
||||
throw new RuntimeException(
|
||||
'Alpha Vantage: ' . $fetched . ' neu, ' . $reused . ' wiederverwendet, ' . $stale . ' nicht neuer, ' . $skipped . ' ohne Symbol, ' . $failed . ' Fehler. '
|
||||
. implode(' | ', array_slice($errors, 0, 3))
|
||||
);
|
||||
}
|
||||
|
||||
return 'Alpha Vantage: ' . $fetched . ' neu, ' . $reused . ' wiederverwendet, ' . $stale . ' nicht neuer, ' . $skipped . ' ohne Symbol, ' . $failed . ' Fehler.';
|
||||
}
|
||||
|
||||
private function searchSymbol(): string
|
||||
{
|
||||
$keywords = trim((string) ($_POST['search_keywords'] ?? ''));
|
||||
$this->symbolSearchKeywords = $keywords;
|
||||
$result = \module_fn('boersenchecker', 'alpha_vantage_search_symbols', $keywords);
|
||||
$this->symbolSearchResults = 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 deleteQuote(): string
|
||||
{
|
||||
$quoteId = (int) ($_POST['quote_id'] ?? 0);
|
||||
$stmt = $this->pdo->prepare(
|
||||
'DELETE FROM ' . $this->quoteTable . '
|
||||
WHERE id = :id
|
||||
AND instrument_id IN (
|
||||
SELECT DISTINCT instrument_id
|
||||
FROM ' . $this->positionTable . '
|
||||
WHERE owner_sub = :owner_sub
|
||||
)'
|
||||
);
|
||||
$stmt->execute([
|
||||
'id' => $quoteId,
|
||||
'owner_sub' => $this->ownerSub,
|
||||
]);
|
||||
return 'Kurseintrag geloescht.';
|
||||
}
|
||||
|
||||
private function refreshFx(): string
|
||||
{
|
||||
$result = \module_fn('boersenchecker', 'fx_refresh', $this->defaultReportCurrency, $this->fxMaxAgeHours);
|
||||
if (empty($result['ok'])) {
|
||||
throw new RuntimeException((string) ($result['message'] ?? 'FX-Aktualisierung fehlgeschlagen.'));
|
||||
}
|
||||
return (string) ($result['message'] ?? 'FX-Daten aktualisiert.');
|
||||
}
|
||||
|
||||
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 fetchPositions(): array
|
||||
{
|
||||
$stmt = $this->pdo->prepare(
|
||||
'SELECT
|
||||
p.*,
|
||||
i.isin,
|
||||
i.wkn,
|
||||
i.symbol,
|
||||
i.name AS instrument_name,
|
||||
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
|
||||
ORDER BY p.purchase_date DESC, p.id DESC'
|
||||
);
|
||||
$stmt->execute(['owner_sub' => $this->ownerSub]);
|
||||
return $stmt->fetchAll(PDO::FETCH_ASSOC) ?: [];
|
||||
}
|
||||
|
||||
private function buildInstrumentList(array $positions): array
|
||||
{
|
||||
$instrumentList = [];
|
||||
foreach ($positions as $position) {
|
||||
$instrumentId = (int) $position['instrument_id'];
|
||||
if (!isset($instrumentList[$instrumentId])) {
|
||||
$instrumentList[$instrumentId] = [
|
||||
'id' => $instrumentId,
|
||||
'name' => (string) $position['instrument_name'],
|
||||
'symbol' => (string) ($position['symbol'] ?? ''),
|
||||
'isin' => (string) ($position['isin'] ?? ''),
|
||||
'quote_currency' => (string) ($position['quote_currency'] ?? ''),
|
||||
];
|
||||
}
|
||||
}
|
||||
return $instrumentList;
|
||||
}
|
||||
|
||||
private function fetchQuotes(array $instrumentIds): array
|
||||
{
|
||||
$quoteHistory = [];
|
||||
$latestQuotes = [];
|
||||
if ($instrumentIds === []) {
|
||||
return [$quoteHistory, $latestQuotes];
|
||||
}
|
||||
|
||||
$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);
|
||||
$quotes = $stmt->fetchAll(PDO::FETCH_ASSOC) ?: [];
|
||||
foreach ($quotes as $quote) {
|
||||
$instrumentId = (int) $quote['instrument_id'];
|
||||
$quoteHistory[$instrumentId][] = $quote;
|
||||
if (!isset($latestQuotes[$instrumentId])) {
|
||||
$latestQuotes[$instrumentId] = $quote;
|
||||
}
|
||||
}
|
||||
|
||||
return [$quoteHistory, $latestQuotes];
|
||||
}
|
||||
|
||||
private function buildPortfolioById(array $portfolios): array
|
||||
{
|
||||
$portfolioById = [];
|
||||
foreach ($portfolios as $portfolio) {
|
||||
$portfolio['base_currency'] = $this->normalizeCurrency((string) ($portfolio['base_currency'] ?? $this->defaultReportCurrency));
|
||||
$portfolioById[(int) $portfolio['id']] = $portfolio;
|
||||
}
|
||||
return $portfolioById;
|
||||
}
|
||||
|
||||
private function enrichPositions(array $positions, array $portfolioById, array $latestQuotes): array
|
||||
{
|
||||
$portfolioStats = [];
|
||||
foreach ($portfolioById as $portfolioId => $portfolio) {
|
||||
$portfolioStats[$portfolioId] = [
|
||||
'invested' => 0.0,
|
||||
'current' => 0.0,
|
||||
'gain' => 0.0,
|
||||
'positions' => 0,
|
||||
'has_invested' => false,
|
||||
'has_current' => false,
|
||||
];
|
||||
}
|
||||
|
||||
foreach ($positions as &$position) {
|
||||
$portfolioId = (int) $position['portfolio_id'];
|
||||
$baseCurrency = (string) ($portfolioById[$portfolioId]['base_currency'] ?? $this->defaultReportCurrency);
|
||||
$quantity = (float) $position['quantity'];
|
||||
$purchasePrice = (float) $position['purchase_price'];
|
||||
$fees = is_numeric($position['fees'] ?? null) ? (float) $position['fees'] : 0.0;
|
||||
$purchaseTotal = ($quantity * $purchasePrice) + $fees;
|
||||
$purchaseTotalBase = $this->convertAmount($purchaseTotal, (string) $position['purchase_currency'], $baseCurrency);
|
||||
$latestQuote = $latestQuotes[(int) $position['instrument_id']] ?? null;
|
||||
$currentTotalBase = null;
|
||||
|
||||
if (is_array($latestQuote) && is_numeric($latestQuote['price'] ?? null)) {
|
||||
$currentOriginal = $quantity * (float) $latestQuote['price'];
|
||||
$currentTotalBase = $this->convertAmount($currentOriginal, (string) $latestQuote['currency'], $baseCurrency, (string) ($latestQuote['source'] ?? ''));
|
||||
$position['latest_price'] = (float) $latestQuote['price'];
|
||||
$position['latest_currency'] = (string) $latestQuote['currency'];
|
||||
$position['latest_quoted_at'] = (string) $latestQuote['quoted_at'];
|
||||
$position['latest_source'] = (string) ($latestQuote['source'] ?? '');
|
||||
$position['current_total_base'] = $currentTotalBase;
|
||||
} else {
|
||||
$position['latest_price'] = null;
|
||||
$position['latest_currency'] = null;
|
||||
$position['latest_quoted_at'] = null;
|
||||
$position['latest_source'] = null;
|
||||
$position['current_total_base'] = null;
|
||||
}
|
||||
|
||||
$position['purchase_total'] = $purchaseTotal;
|
||||
$position['purchase_total_base'] = $purchaseTotalBase;
|
||||
$position['base_currency'] = $baseCurrency;
|
||||
$position['gain_base'] = $currentTotalBase !== null && $purchaseTotalBase !== null
|
||||
? $currentTotalBase - $purchaseTotalBase
|
||||
: null;
|
||||
|
||||
if (isset($portfolioStats[$portfolioId])) {
|
||||
$portfolioStats[$portfolioId]['positions']++;
|
||||
if ($purchaseTotalBase !== null) {
|
||||
$portfolioStats[$portfolioId]['invested'] += $purchaseTotalBase;
|
||||
$portfolioStats[$portfolioId]['has_invested'] = true;
|
||||
}
|
||||
if ($currentTotalBase !== null) {
|
||||
$portfolioStats[$portfolioId]['current'] += $currentTotalBase;
|
||||
$portfolioStats[$portfolioId]['has_current'] = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
unset($position);
|
||||
|
||||
foreach ($portfolioStats as &$stats) {
|
||||
$stats['gain'] = ($stats['has_invested'] && $stats['has_current'])
|
||||
? $stats['current'] - $stats['invested']
|
||||
: null;
|
||||
}
|
||||
unset($stats);
|
||||
|
||||
return [$positions, $portfolioStats];
|
||||
}
|
||||
|
||||
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' => 'alphavantage:%',
|
||||
]);
|
||||
$row = $stmt->fetch(PDO::FETCH_ASSOC);
|
||||
return is_array($row) ? $row : null;
|
||||
}
|
||||
|
||||
private function isApiSnapshotStale(?array $latestQuote, string $incomingQuotedAt): bool
|
||||
{
|
||||
if (!is_array($latestQuote)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$latestTimestamp = strtotime((string) ($latestQuote['quoted_at'] ?? ''));
|
||||
$incomingTimestamp = strtotime(trim($incomingQuotedAt));
|
||||
if ($latestTimestamp === false || $incomingTimestamp === false) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return $incomingTimestamp <= $latestTimestamp;
|
||||
}
|
||||
|
||||
private function upsertInstrument(array $payload): int
|
||||
{
|
||||
return $this->instrumentRegistry->save($payload);
|
||||
}
|
||||
|
||||
private function storeQuote(int $instrumentId, float $price, string $currency, string $quotedAt, string $source): void
|
||||
{
|
||||
$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' => $currency,
|
||||
'quoted_at' => $quotedAt,
|
||||
'source' => $source,
|
||||
]);
|
||||
}
|
||||
|
||||
private function convertAmount(?float $amount, string $from, string $to, ?string $quoteSource = null): ?float
|
||||
{
|
||||
if ($amount === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$from = $this->normalizeCurrency($from);
|
||||
$to = $this->normalizeCurrency($to);
|
||||
if ($from === $to) {
|
||||
return $amount;
|
||||
}
|
||||
|
||||
try {
|
||||
$fetchId = (int) (\module_fn('boersenchecker', 'fx_extract_fetch_id', (string) $quoteSource) ?? 0);
|
||||
$value = \module_fn('boersenchecker', 'fx_convert_with_fetch', $amount, $from, $to, $fetchId > 0 ? $fetchId : null);
|
||||
return is_numeric($value) ? (float) $value : null;
|
||||
} catch (\Throwable) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private function normalizeCurrency(?string $value, string $fallback = 'EUR'): string
|
||||
{
|
||||
$normalized = strtoupper(trim((string) $value));
|
||||
return $normalized !== '' ? $normalized : $fallback;
|
||||
}
|
||||
|
||||
private function normalizeDateTimeLocal(?string $value): string
|
||||
{
|
||||
$timezone = new \DateTimeZone(nexus_display_timezone_name());
|
||||
$value = trim((string) $value);
|
||||
if ($value === '') {
|
||||
return (new \DateTimeImmutable('now', $timezone))->format('Y-m-d H:i:s');
|
||||
}
|
||||
|
||||
$date = \DateTimeImmutable::createFromFormat('Y-m-d\TH:i', $value, $timezone);
|
||||
if ($date instanceof \DateTimeImmutable) {
|
||||
return $date->format('Y-m-d H:i:s');
|
||||
}
|
||||
|
||||
try {
|
||||
return (new \DateTimeImmutable($value, $timezone))->format('Y-m-d H:i:s');
|
||||
} catch (\Throwable) {
|
||||
return (new \DateTimeImmutable('now', $timezone))->format('Y-m-d H:i:s');
|
||||
}
|
||||
}
|
||||
|
||||
private function formatNumber(?float $value, int $scale = 2): string
|
||||
{
|
||||
if ($value === null) {
|
||||
return 'n/a';
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
364
custom/apps/boersenchecker/src/Support/HomePage.php
Normal file
364
custom/apps/boersenchecker/src/Support/HomePage.php
Normal file
@@ -0,0 +1,364 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Modules\Boersenchecker\Support;
|
||||
|
||||
use PDO;
|
||||
use RuntimeException;
|
||||
|
||||
final class HomePage
|
||||
{
|
||||
private PDO $pdo;
|
||||
private string $ownerSub;
|
||||
private string $portfolioTable;
|
||||
private string $instrumentTable;
|
||||
private string $positionTable;
|
||||
private string $quoteTable;
|
||||
private string $defaultReportCurrency;
|
||||
private float $fxMaxAgeHours;
|
||||
private int $marketDataMinIntervalMinutes;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->pdo = \module_fn('boersenchecker', 'pdo');
|
||||
\module_fn('boersenchecker', 'ensure_schema');
|
||||
$user = \auth_user() ?? [];
|
||||
$this->ownerSub = trim((string) ($user['sub'] ?? 'local'));
|
||||
|
||||
$settings = \modules()->settings('boersenchecker');
|
||||
$this->defaultReportCurrency = strtoupper(trim((string) ($settings['report_currency'] ?? 'EUR'))) ?: 'EUR';
|
||||
$this->fxMaxAgeHours = (float) ($settings['fx_max_age_hours'] ?? 6);
|
||||
if ($this->fxMaxAgeHours <= 0) {
|
||||
$this->fxMaxAgeHours = 6.0;
|
||||
}
|
||||
$this->marketDataMinIntervalMinutes = (int) (($settings['alpha_vantage_min_interval_minutes'] ?? null) ?: 60);
|
||||
if ($this->marketDataMinIntervalMinutes <= 0) {
|
||||
$this->marketDataMinIntervalMinutes = 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'] ?? '') : '';
|
||||
$position['latest_source'] = is_array($latestQuote) ? (string) ($latestQuote['source'] ?? '') : '';
|
||||
$position['current_total_report'] = null;
|
||||
$position['gain_report'] = null;
|
||||
$position['gain_percent'] = null;
|
||||
if ($position['latest_price'] !== null) {
|
||||
$currentNative = (float) $position['latest_price'] * (float) ($position['quantity'] ?? 0);
|
||||
$currentReport = $this->convertAmount(
|
||||
$currentNative,
|
||||
(string) ($position['latest_currency'] ?: ($position['quote_currency'] ?? $this->defaultReportCurrency)),
|
||||
$this->defaultReportCurrency,
|
||||
(string) ($position['latest_source'] ?? '')
|
||||
);
|
||||
$position['current_total_report'] = $currentReport;
|
||||
if ($position['purchase_total_report'] !== null && $currentReport !== null) {
|
||||
$gain = $currentReport - (float) $position['purchase_total_report'];
|
||||
$position['gain_report'] = $gain;
|
||||
$base = (float) $position['purchase_total_report'];
|
||||
$position['gain_percent'] = $base > 0 ? ($gain / $base) * 100 : null;
|
||||
}
|
||||
}
|
||||
}
|
||||
unset($position);
|
||||
|
||||
$selectedInstrument = null;
|
||||
foreach ($positions as $position) {
|
||||
if ((int) $position['instrument_id'] === $selectedInstrumentId) {
|
||||
$selectedInstrument = $position;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return [
|
||||
'notice' => $notice,
|
||||
'error' => $error,
|
||||
'portfolios' => $portfolios,
|
||||
'selectedPortfolioId' => $selectedPortfolioId,
|
||||
'positions' => $positions,
|
||||
'selectedInstrumentId' => $selectedInstrumentId,
|
||||
'selectedInstrument' => $selectedInstrument,
|
||||
'summary' => $this->buildSummary($positions),
|
||||
'defaultReportCurrency' => $this->defaultReportCurrency,
|
||||
'chartEndpoint' => '/api/boersenchecker/index.php?path=v1/chart-data',
|
||||
'fmtDateTime' => fn (?string $value, ?string $source = null): string => (string) \module_fn('boersenchecker', 'format_datetime_for_display', $value, $source),
|
||||
];
|
||||
}
|
||||
|
||||
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.isin, 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;
|
||||
$stale = 0;
|
||||
$bulkCandidates = [];
|
||||
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->marketDataMinIntervalMinutes * 60)) {
|
||||
$reused++;
|
||||
continue;
|
||||
}
|
||||
|
||||
$bulkCandidates[] = $row;
|
||||
}
|
||||
|
||||
if ($bulkCandidates !== []) {
|
||||
$quoteCurrencies = array_values(array_unique(array_filter(array_map(
|
||||
fn (array $row): string => $this->normalizeCurrency((string) ($row['quote_currency'] ?? '')),
|
||||
$bulkCandidates
|
||||
))));
|
||||
$fxResult = \module_fn('boersenchecker', 'fx_prepare_fetch', $this->defaultReportCurrency, $quoteCurrencies, $this->fxMaxAgeHours);
|
||||
if (empty($fxResult['ok'])) {
|
||||
throw new RuntimeException((string) ($fxResult['message'] ?? 'FX-Aktualisierung fehlgeschlagen.'));
|
||||
}
|
||||
$fxFetchId = is_numeric($fxResult['fetch_id'] ?? null) ? (int) $fxResult['fetch_id'] : null;
|
||||
|
||||
$bulkResult = \module_fn('boersenchecker', 'alpha_vantage_fetch_quotes', $bulkCandidates);
|
||||
$quotes = is_array($bulkResult['quotes'] ?? null) ? $bulkResult['quotes'] : [];
|
||||
foreach ($bulkCandidates as $row) {
|
||||
$instrumentId = (int) ($row['id'] ?? 0);
|
||||
$quote = $quotes[$instrumentId] ?? null;
|
||||
if (!is_array($quote) || !is_numeric($quote['price'] ?? null)) {
|
||||
continue;
|
||||
}
|
||||
$latest = $this->latestApiQuoteForInstrument($instrumentId);
|
||||
if ($this->isApiSnapshotStale($latest, (string) ($quote['fetched_at'] ?? ''))) {
|
||||
$stale++;
|
||||
continue;
|
||||
}
|
||||
$storeResult = \module_fn(
|
||||
'boersenchecker',
|
||||
'store_market_quote',
|
||||
$instrumentId,
|
||||
(float) $quote['price'],
|
||||
strtoupper(trim((string) ($quote['currency'] ?? $row['quote_currency'] ?? $this->defaultReportCurrency))) ?: $this->defaultReportCurrency,
|
||||
(string) ($quote['fetched_at'] ?? gmdate('Y-m-d H:i:s')),
|
||||
(string) \module_fn('boersenchecker', 'fx_source_with_fetch_id', (string) ($quote['source'] ?? 'alphavantage:global_quote'), $fxFetchId)
|
||||
);
|
||||
if (!empty($storeResult['inserted'])) {
|
||||
$updated++;
|
||||
} else {
|
||||
$reused++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return 'Aktuelle Kurse: ' . $updated . ' aktualisiert, ' . $reused . ' wiederverwendet, ' . $stale . ' API-Snapshots waren nicht neuer.';
|
||||
}
|
||||
|
||||
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,
|
||||
]);
|
||||
$rows = $stmt->fetchAll(PDO::FETCH_ASSOC) ?: [];
|
||||
foreach ($rows as &$row) {
|
||||
$quantity = (float) ($row['quantity'] ?? 0);
|
||||
$purchasePrice = (float) ($row['purchase_price'] ?? 0);
|
||||
$fees = is_numeric($row['fees'] ?? null) ? (float) $row['fees'] : 0.0;
|
||||
$purchaseTotal = ($quantity * $purchasePrice) + $fees;
|
||||
$row['purchase_total'] = $purchaseTotal;
|
||||
$row['purchase_total_report'] = $this->convertAmount(
|
||||
$purchaseTotal,
|
||||
(string) ($row['purchase_currency'] ?? $this->defaultReportCurrency),
|
||||
$this->defaultReportCurrency
|
||||
);
|
||||
}
|
||||
unset($row);
|
||||
return $rows;
|
||||
}
|
||||
|
||||
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' => 'alphavantage:%',
|
||||
]);
|
||||
$row = $stmt->fetch(PDO::FETCH_ASSOC);
|
||||
return is_array($row) ? $row : null;
|
||||
}
|
||||
|
||||
private function isApiSnapshotStale(?array $latestQuote, string $incomingQuotedAt): bool
|
||||
{
|
||||
if (!is_array($latestQuote)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$latestTimestamp = strtotime((string) ($latestQuote['quoted_at'] ?? ''));
|
||||
$incomingTimestamp = strtotime(trim($incomingQuotedAt));
|
||||
if ($latestTimestamp === false || $incomingTimestamp === false) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return $incomingTimestamp <= $latestTimestamp;
|
||||
}
|
||||
|
||||
private function buildSummary(array $positions): array
|
||||
{
|
||||
$invested = 0.0;
|
||||
$current = 0.0;
|
||||
$hasInvested = false;
|
||||
$hasCurrent = false;
|
||||
$best = null;
|
||||
$worst = null;
|
||||
|
||||
foreach ($positions as $position) {
|
||||
if (is_numeric($position['purchase_total_report'] ?? null)) {
|
||||
$invested += (float) $position['purchase_total_report'];
|
||||
$hasInvested = true;
|
||||
}
|
||||
if (is_numeric($position['current_total_report'] ?? null)) {
|
||||
$current += (float) $position['current_total_report'];
|
||||
$hasCurrent = true;
|
||||
}
|
||||
if (is_numeric($position['gain_percent'] ?? null)) {
|
||||
if ($best === null || (float) $position['gain_percent'] > (float) ($best['gain_percent'] ?? 0)) {
|
||||
$best = $position;
|
||||
}
|
||||
if ($worst === null || (float) $position['gain_percent'] < (float) ($worst['gain_percent'] ?? 0)) {
|
||||
$worst = $position;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return [
|
||||
'positions' => count($positions),
|
||||
'invested' => $hasInvested ? $invested : null,
|
||||
'current' => $hasCurrent ? $current : null,
|
||||
'gain' => ($hasInvested && $hasCurrent) ? $current - $invested : null,
|
||||
'best' => $best,
|
||||
'worst' => $worst,
|
||||
];
|
||||
}
|
||||
|
||||
private function convertAmount(?float $amount, string $from, string $to, ?string $quoteSource = null): ?float
|
||||
{
|
||||
if ($amount === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$from = $this->normalizeCurrency($from, $this->defaultReportCurrency);
|
||||
$to = $this->normalizeCurrency($to, $this->defaultReportCurrency);
|
||||
if ($from === $to) {
|
||||
return $amount;
|
||||
}
|
||||
|
||||
try {
|
||||
$fetchId = (int) (\module_fn('boersenchecker', 'fx_extract_fetch_id', (string) $quoteSource) ?? 0);
|
||||
$value = \module_fn('boersenchecker', 'fx_convert_with_fetch', $amount, $from, $to, $fetchId > 0 ? $fetchId : null);
|
||||
return is_numeric($value) ? (float) $value : null;
|
||||
} catch (\Throwable) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private function normalizeCurrency(?string $value, string $fallback = 'EUR'): string
|
||||
{
|
||||
$normalized = strtoupper(trim((string) $value));
|
||||
return $normalized !== '' ? $normalized : $fallback;
|
||||
}
|
||||
}
|
||||
351
custom/apps/boersenchecker/src/Support/InstrumentPage.php
Normal file
351
custom/apps/boersenchecker/src/Support/InstrumentPage.php
Normal file
@@ -0,0 +1,351 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Modules\Boersenchecker\Support;
|
||||
|
||||
use PDO;
|
||||
use RuntimeException;
|
||||
|
||||
final class InstrumentPage
|
||||
{
|
||||
private PDO $pdo;
|
||||
private string $ownerSub;
|
||||
private string $instrumentTable;
|
||||
private string $positionTable;
|
||||
private string $quoteTable;
|
||||
private string $defaultReportCurrency;
|
||||
private float $fxMaxAgeHours;
|
||||
private string $searchKeywords = '';
|
||||
private array $searchResults = [];
|
||||
private int $selectedInstrumentOverrideId = 0;
|
||||
private InstrumentRegistry $instrumentRegistry;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->pdo = \module_fn('boersenchecker', 'pdo');
|
||||
\module_fn('boersenchecker', 'ensure_schema');
|
||||
$user = \auth_user() ?? [];
|
||||
$this->ownerSub = trim((string) ($user['sub'] ?? 'local'));
|
||||
|
||||
$settings = \modules()->settings('boersenchecker');
|
||||
$this->defaultReportCurrency = strtoupper(trim((string) ($settings['report_currency'] ?? 'EUR'))) ?: 'EUR';
|
||||
$this->fxMaxAgeHours = (float) ($settings['fx_max_age_hours'] ?? 6);
|
||||
if ($this->fxMaxAgeHours <= 0) {
|
||||
$this->fxMaxAgeHours = 6.0;
|
||||
}
|
||||
$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'] ?? ''));
|
||||
$candidateIsin = trim((string) ($_GET['isin_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,
|
||||
'isin' => $candidateIsin,
|
||||
'market' => $candidateMarket,
|
||||
'quote_currency' => $candidateCurrency,
|
||||
'wkn' => '',
|
||||
];
|
||||
}
|
||||
|
||||
return [
|
||||
'notice' => $notice,
|
||||
'error' => $error,
|
||||
'instruments' => $instruments,
|
||||
'selectedInstrument' => $selectedInstrument,
|
||||
'selectedInstrumentId' => $selectedInstrumentId,
|
||||
'quotes' => $quotes,
|
||||
'searchKeywords' => $this->searchKeywords,
|
||||
'searchResults' => $this->searchResults,
|
||||
'defaultReportCurrency' => $this->defaultReportCurrency,
|
||||
'fmtDateTime' => fn (?string $value, ?string $source = null): string => (string) \module_fn('boersenchecker', 'format_datetime_for_display', $value, $source),
|
||||
'localNowInputValue' => (string) \module_fn('boersenchecker', 'local_now_input_value'),
|
||||
];
|
||||
}
|
||||
|
||||
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_market_data_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' => $this->normalizeDateTimeLocal((string) ($_POST['quoted_at'] ?? '')),
|
||||
'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 = strtoupper(trim((string) ($instrument['symbol'] ?? '')));
|
||||
if ($symbol === '') {
|
||||
throw new RuntimeException('Fuer diese Aktie ist kein Symbol hinterlegt.');
|
||||
}
|
||||
|
||||
$apiResult = \module_fn('boersenchecker', 'alpha_vantage_fetch_quote_by_symbol', $symbol);
|
||||
if (empty($apiResult['ok'])) {
|
||||
throw new RuntimeException((string) ($apiResult['message'] ?? 'API-Abruf fehlgeschlagen.'));
|
||||
}
|
||||
$quoteCurrency = strtoupper(trim((string) ($instrument['quote_currency'] ?? $this->defaultReportCurrency))) ?: $this->defaultReportCurrency;
|
||||
$fxResult = \module_fn('boersenchecker', 'fx_prepare_fetch', $this->defaultReportCurrency, [$quoteCurrency], $this->fxMaxAgeHours);
|
||||
if (empty($fxResult['ok'])) {
|
||||
throw new RuntimeException((string) ($fxResult['message'] ?? 'FX-Aktualisierung fehlgeschlagen.'));
|
||||
}
|
||||
$fxFetchId = is_numeric($fxResult['fetch_id'] ?? null) ? (int) $fxResult['fetch_id'] : null;
|
||||
$latestApiQuote = $this->latestApiQuoteForInstrument($instrumentId);
|
||||
if ($this->isApiSnapshotStale($latestApiQuote, (string) ($apiResult['fetched_at'] ?? ''))) {
|
||||
$displayTime = (string) \module_fn(
|
||||
'boersenchecker',
|
||||
'format_datetime_for_display',
|
||||
(string) ($apiResult['fetched_at'] ?? ''),
|
||||
(string) ($apiResult['source'] ?? 'alphavantage:global_quote')
|
||||
);
|
||||
return 'Alpha Vantage lieferte keinen neueren Snapshot als ' . $displayTime . '.';
|
||||
}
|
||||
|
||||
$storeResult = \module_fn(
|
||||
'boersenchecker',
|
||||
'store_market_quote',
|
||||
$instrumentId,
|
||||
(float) $apiResult['price'],
|
||||
$quoteCurrency,
|
||||
(string) $apiResult['fetched_at'],
|
||||
(string) \module_fn('boersenchecker', 'fx_source_with_fetch_id', (string) $apiResult['source'], $fxFetchId)
|
||||
);
|
||||
|
||||
return !empty($storeResult['inserted'])
|
||||
? 'Alpha-Vantage-Kurs gespeichert.'
|
||||
: 'Vorhandener Alpha-Vantage-Snapshot wiederverwendet.';
|
||||
}
|
||||
|
||||
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 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 nicht verfuegbar.');
|
||||
}
|
||||
|
||||
return $instrument;
|
||||
}
|
||||
|
||||
private function normalizeDateTimeLocal(?string $value): string
|
||||
{
|
||||
$timezone = new \DateTimeZone(nexus_display_timezone_name());
|
||||
$value = trim((string) $value);
|
||||
if ($value === '') {
|
||||
return (new \DateTimeImmutable('now', $timezone))->format('Y-m-d H:i:s');
|
||||
}
|
||||
|
||||
$date = \DateTimeImmutable::createFromFormat('Y-m-d\TH:i', $value, $timezone);
|
||||
if ($date instanceof \DateTimeImmutable) {
|
||||
return $date->format('Y-m-d H:i:s');
|
||||
}
|
||||
|
||||
try {
|
||||
return (new \DateTimeImmutable($value, $timezone))->format('Y-m-d H:i:s');
|
||||
} catch (\Throwable) {
|
||||
return (new \DateTimeImmutable('now', $timezone))->format('Y-m-d H:i:s');
|
||||
}
|
||||
}
|
||||
|
||||
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' => 'alphavantage:%',
|
||||
]);
|
||||
$row = $stmt->fetch(PDO::FETCH_ASSOC);
|
||||
return is_array($row) ? $row : null;
|
||||
}
|
||||
|
||||
private function isApiSnapshotStale(?array $latestQuote, string $incomingQuotedAt): bool
|
||||
{
|
||||
if (!is_array($latestQuote)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$latestTimestamp = strtotime((string) ($latestQuote['quoted_at'] ?? ''));
|
||||
$incomingTimestamp = strtotime(trim($incomingQuotedAt));
|
||||
if ($latestTimestamp === false || $incomingTimestamp === false) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return $incomingTimestamp <= $latestTimestamp;
|
||||
}
|
||||
}
|
||||
190
custom/apps/boersenchecker/src/Support/InstrumentRegistry.php
Normal file
190
custom/apps/boersenchecker/src/Support/InstrumentRegistry.php
Normal file
@@ -0,0 +1,190 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Modules\Boersenchecker\Support;
|
||||
|
||||
use PDO;
|
||||
use RuntimeException;
|
||||
|
||||
final class InstrumentRegistry
|
||||
{
|
||||
public function __construct(
|
||||
private PDO $pdo,
|
||||
private string $instrumentTable,
|
||||
private string $positionTable,
|
||||
private string $quoteTable,
|
||||
) {
|
||||
}
|
||||
|
||||
public function save(array $payload): int
|
||||
{
|
||||
$currentId = (int) ($payload['id'] ?? 0);
|
||||
$data = $this->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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user