New desktop
This commit is contained in:
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user