Compare commits
210 Commits
74b179a9ce
...
develop
| Author | SHA1 | Date | |
|---|---|---|---|
| 127a0e71e1 | |||
| 7323673158 | |||
| 813dd86811 | |||
| 81d1e486e8 | |||
| 3ed4fba58c | |||
| 52158ef041 | |||
| 27026533ac | |||
| 9c17fc0ca9 | |||
| f46de880f4 | |||
| 0f8f9567fe | |||
| ebbdb52f93 | |||
| 418423c5a8 | |||
| 9b2cf69d2c | |||
| e92b5dea17 | |||
| 95ae657626 | |||
| 3379e7f465 | |||
| df2f217e22 | |||
| faa4c237c8 | |||
| 7c33d60f14 | |||
| 9848c1709b | |||
| 8aa52ec639 | |||
| c6642c0ef5 | |||
| de99f21332 | |||
| 693086029d | |||
| fc95898a9d | |||
| ee5a46254f | |||
| bec6fb1e0a | |||
| 7eeed06a40 | |||
| 4d9d9f3480 | |||
| ecf0e83c4e | |||
| fbfcf50b67 | |||
| 47757675c2 | |||
| 48b7583f19 | |||
| e335a8d5bf | |||
| eb8b04a621 | |||
| fd092ef615 | |||
| 3795fc1de5 | |||
| 748d2c2e59 | |||
| 002d450deb | |||
| a9fefa7779 | |||
| 979b15d4d8 | |||
| c3ba24e939 | |||
| c81e89dc3f | |||
| 9659ce27b4 | |||
| 3cd5d90f1a | |||
| 03166c575e | |||
| 3f1731fa25 | |||
| 9acf70d7ce | |||
| 206bede930 | |||
| 468736ac4d | |||
| f161a4c622 | |||
| e121369461 | |||
| a82893d4ff | |||
| 0385864f1a | |||
| 2c1a751e82 | |||
| f5b30d6991 | |||
| 33119f66f7 | |||
| 94fc39dd50 | |||
| 3b9940d030 | |||
| 2d56289477 | |||
| fa424957b6 | |||
| 7f038f03e8 | |||
| 6d59d94273 | |||
| fe83f53749 | |||
| 1eaaf0f877 | |||
| 46e7276b14 | |||
| f5a93931be | |||
| ed0f7b6762 | |||
| 77bc307781 | |||
| b9f248aae0 | |||
| aa30feba85 | |||
| a44a0f6f0b | |||
| 44815f9c95 | |||
| 6cbf76918c | |||
| 58d0c18e25 | |||
| f920991015 | |||
| 7ad4b45d22 | |||
| c098413e23 | |||
| 2de8c95fdb | |||
| 86eeef71a8 | |||
| d5e9d588cd | |||
| aff9dd30e1 | |||
| c444ece852 | |||
| 5a154f896b | |||
| 29e2724cd8 | |||
| 6ce45f6d23 | |||
| d8f532ce0e | |||
| a9c64c5d68 | |||
| f7f99bd700 | |||
| eb864077bf | |||
| 8ec7270128 | |||
| dfbb66bf74 | |||
| f193f12c71 | |||
| e1ee3b17d4 | |||
| 10dc9bb0a7 | |||
| e1b2f7e613 | |||
| 8879a4ae5c | |||
| 0c5c89acfa | |||
| 9c9ec477d0 | |||
| 34fc8ead14 | |||
| 8bab6f9e26 | |||
| 1ba8b550f6 | |||
| 235630ee1e | |||
| ef87dc6cd3 | |||
| 79872f3337 | |||
| 4ead35047a | |||
| a0f19ff6b4 | |||
| 9e687455fa | |||
| 00c879d029 | |||
| 488964df3a | |||
| af1d94a3b6 | |||
| b76edb49b0 | |||
| 1da63807eb | |||
| cea88963da | |||
| f1b41e07cb | |||
| 74b1651eac | |||
| 1dd74d4674 | |||
| 8140f1e1b1 | |||
| 65f01b2706 | |||
| 6a97450ced | |||
| f4fa8acb97 | |||
| 0174fa9d27 | |||
| 73dae688ab | |||
| f64975b5f7 | |||
| 6d9767a388 | |||
| bd6242cd76 | |||
| f94dd83b68 | |||
| 44945a31da | |||
| bff852291e | |||
| d04a2214dd | |||
| 805f5adebf | |||
| 42dd155904 | |||
| 0858d33f38 | |||
| bd20634ad2 | |||
| d758ed0f49 | |||
| 329304f1aa | |||
| 463b2cf5e1 | |||
| e7a1878c72 | |||
| 7ce9173d57 | |||
| 39ea8da129 | |||
| 5bfb37f926 | |||
| d6f09326f4 | |||
| 739e4d4c42 | |||
| 92c9bed5bb | |||
| c73656d895 | |||
| 9103218c35 | |||
| c2c91032db | |||
| e5f32f8a6a | |||
| f10f5a4cf3 | |||
| 2c79a2192d | |||
| 342e87d25e | |||
| 9719bf6c9f | |||
| c14e673faf | |||
| 8b1924989b | |||
| 0c90aa0b88 | |||
| 39bddf39e2 | |||
| ac3ac0803b | |||
| 327f40adec | |||
| 2e39acbf03 | |||
| e83d187a16 | |||
| 91dc84d027 | |||
| a1bab34bd3 | |||
| 04a4c3f2c1 | |||
| 1d948f0508 | |||
| 82ad817ad3 | |||
| 9f2af4676d | |||
| 04ec9dc227 | |||
| 598348a4b6 | |||
| 7157c98dcb | |||
| 0b555e7dd4 | |||
| 08a8df87e2 | |||
| 5a3ebc607c | |||
| 56b3493c9e | |||
| 11fb43b9ce | |||
| 5677a8d806 | |||
| 93c867040e | |||
| 3e9fa3d4a1 | |||
| da9a6841e4 | |||
| 677f9314f5 | |||
| 4d73eec687 | |||
| aea4e9fa5f | |||
| dc7373fc08 | |||
| 94f3295b6c | |||
| 067f962cb2 | |||
| 37f5b7f5e6 | |||
| 7d0ad8eb29 | |||
| e2e87016ad | |||
| 35a0b10d0c | |||
| 7400caa687 | |||
| 394935a53b | |||
| 8786c079a8 | |||
| bbd2e39f86 | |||
| c15c90bf6d | |||
| dc4abe9563 | |||
| ea1bdd75ad | |||
| 9cf4322ffc | |||
| f883655b3d | |||
| 494f1ec82e | |||
| 566dde909f | |||
| 971b8a15fb | |||
| e83925ba64 | |||
| 9d5bb2d3cf | |||
| f55667ba83 | |||
| 291ce9f0c7 | |||
| 8de05d5552 | |||
| c144d9bcd2 | |||
| b999c2cf15 | |||
| 5687fbb21b | |||
| 4adb50dc57 | |||
| c77b4b3ea7 |
246
.gitea/workflows/deploy.yml
Normal file
246
.gitea/workflows/deploy.yml
Normal file
@@ -0,0 +1,246 @@
|
||||
name: Deploy
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- develop
|
||||
- main
|
||||
|
||||
jobs:
|
||||
deploy-staging:
|
||||
if: gitea.ref == 'refs/heads/develop'
|
||||
runs-on: private-server
|
||||
env:
|
||||
BASE_DIRS: "src public api partials tools data debug modules"
|
||||
CONFIG_BASE_DIR: "config"
|
||||
CONFIG_ENV_DIR: "config/staging"
|
||||
DEPLOY_ENV: "staging"
|
||||
LOCAL_ROOT: "/deploy/nexus"
|
||||
RSYNC_OPTS: "--delete --inplace --no-whole-file --no-owner --no-group --no-perms --omit-dir-times --chmod=Du=rwx,Dgo=rx,Fu=rw,Fgo=r"
|
||||
SITE_DOMAIN_DIR: "${{ vars.SITE_DOMAIN_DIR }}"
|
||||
TARGET_PATH: "/deploy/nexus/staging/"
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Debug workspace
|
||||
run: |
|
||||
echo "Workspace:" && pwd
|
||||
ls -la
|
||||
|
||||
- name: Deploy staging
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
if [ -z "${SITE_DOMAIN_DIR}" ]; then
|
||||
echo "❌ SITE_DOMAIN_DIR ist leer."
|
||||
echo " Bitte in Gitea als Repository Variable setzen."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "Deploy Domain: ${SITE_DOMAIN_DIR}"
|
||||
echo "Target path: ${TARGET_PATH}"
|
||||
|
||||
echo "=== DEBUG: who/where ==="
|
||||
hostname || true
|
||||
id || true
|
||||
echo "GITEA_REPOSITORY=${GITEA_REPOSITORY:-}"
|
||||
echo "GITEA_REF=${GITEA_REF:-}"
|
||||
|
||||
echo "=== DEBUG: mount + path visibility ==="
|
||||
ls -la /deploy || true
|
||||
ls -la /deploy/webserver || true
|
||||
df -h /deploy/webserver || true
|
||||
mount | grep -E "/deploy/webserver" || true
|
||||
|
||||
echo "=== DEBUG: write test (pre-sync) ==="
|
||||
mkdir -p "${TARGET_PATH}"
|
||||
date > "${TARGET_PATH}/__ci_write_test.txt" || true
|
||||
ls -la "${TARGET_PATH}" || true
|
||||
cat "${TARGET_PATH}/__ci_write_test.txt" || true
|
||||
|
||||
echo "📁 Prüfe lokale Basisverzeichnisse..."
|
||||
MISSING=0
|
||||
|
||||
for d in $BASE_DIRS; do
|
||||
if [ ! -d "$d" ]; then
|
||||
echo "❌ Verzeichnis '$d/' fehlt im Repo!"
|
||||
MISSING=1
|
||||
fi
|
||||
done
|
||||
|
||||
if [ ! -d "$CONFIG_BASE_DIR" ]; then
|
||||
echo "❌ Basis-Konfig-Verzeichnis '$CONFIG_BASE_DIR/' fehlt!"
|
||||
MISSING=1
|
||||
fi
|
||||
|
||||
if [ ! -d "$CONFIG_ENV_DIR" ]; then
|
||||
echo "❌ Env-Konfiguration '$CONFIG_ENV_DIR/' fehlt!"
|
||||
MISSING=1
|
||||
fi
|
||||
|
||||
if [ "$MISSING" -ne 0 ]; then
|
||||
echo "⛔ Abbruch wegen fehlender Verzeichnisse."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "🧱 Stelle sicher, dass Zielpfad existiert..."
|
||||
mkdir -p "${TARGET_PATH}"
|
||||
|
||||
echo "🚀 Deploy ${DEPLOY_ENV} → ${TARGET_PATH}"
|
||||
chmod -R u+rwX,go+rX "${TARGET_PATH}" || true
|
||||
echo "RSYNC_OPTS: ${RSYNC_OPTS}"
|
||||
|
||||
for d in $BASE_DIRS; do
|
||||
echo "🔁 Sync ${d}/ → ${TARGET_PATH}${d}/"
|
||||
mkdir -p "${TARGET_PATH}${d}/"
|
||||
rsync -a ${RSYNC_OPTS} --exclude=".gitkeep" "${d}/" "${TARGET_PATH}${d}/"
|
||||
done
|
||||
|
||||
echo "🧩 Baue gemischtes Config-Verzeichnis (config/*.php + ${CONFIG_ENV_DIR})..."
|
||||
rm -rf .ci_config_deploy
|
||||
mkdir -p .ci_config_deploy
|
||||
|
||||
if [ -d "${CONFIG_BASE_DIR}" ]; then
|
||||
for f in ${CONFIG_BASE_DIR}/*.php; do
|
||||
if [ -f "$f" ]; then
|
||||
echo "➕ Basis-Config-Datei: $f"
|
||||
cp "$f" .ci_config_deploy/
|
||||
fi
|
||||
done
|
||||
fi
|
||||
|
||||
if [ -d "${CONFIG_ENV_DIR}" ]; then
|
||||
echo "➕ Env-Config aus ${CONFIG_ENV_DIR}/"
|
||||
cp -R "${CONFIG_ENV_DIR}/." .ci_config_deploy/
|
||||
fi
|
||||
|
||||
echo "🔁 Sync .ci_config_deploy/ → ${TARGET_PATH}${CONFIG_BASE_DIR}/"
|
||||
mkdir -p "${TARGET_PATH}${CONFIG_BASE_DIR}/"
|
||||
rsync -a ${RSYNC_OPTS} --exclude=".gitkeep" ".ci_config_deploy/" "${TARGET_PATH}${CONFIG_BASE_DIR}/"
|
||||
|
||||
echo "=== DEBUG: post-sync spot checks ==="
|
||||
ls -la "${TARGET_PATH}" | head -n 200 || true
|
||||
find "${TARGET_PATH}" -maxdepth 2 -type f | head -n 200 || true
|
||||
|
||||
echo "✅ Deploy ${DEPLOY_ENV} abgeschlossen."
|
||||
|
||||
deploy-production:
|
||||
if: gitea.ref == 'refs/heads/main'
|
||||
runs-on: private-server
|
||||
env:
|
||||
BASE_DIRS: "src public api partials tools data debug modules"
|
||||
CONFIG_BASE_DIR: "config"
|
||||
CONFIG_ENV_DIR: "config/prod"
|
||||
DEPLOY_ENV: "production"
|
||||
LOCAL_ROOT: "/deploy/nexus"
|
||||
RSYNC_OPTS: "--delete --inplace --no-whole-file --no-owner --no-group --no-perms --omit-dir-times --chmod=Du=rwx,Dgo=rx,Fu=rw,Fgo=r"
|
||||
SITE_DOMAIN_DIR: "${{ vars.SITE_DOMAIN_DIR }}"
|
||||
TARGET_PATH: "/deploy/nexus/live/"
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Debug workspace
|
||||
run: |
|
||||
echo "Workspace:" && pwd
|
||||
ls -la
|
||||
|
||||
- name: Deploy production
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
if [ -z "${SITE_DOMAIN_DIR}" ]; then
|
||||
echo "❌ SITE_DOMAIN_DIR ist leer."
|
||||
echo " Bitte in Gitea als Repository Variable setzen."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "Deploy Domain: ${SITE_DOMAIN_DIR}"
|
||||
echo "Target path: ${TARGET_PATH}"
|
||||
|
||||
echo "=== DEBUG: who/where ==="
|
||||
hostname || true
|
||||
id || true
|
||||
echo "GITEA_REPOSITORY=${GITEA_REPOSITORY:-}"
|
||||
echo "GITEA_REF=${GITEA_REF:-}"
|
||||
|
||||
echo "=== DEBUG: mount + path visibility ==="
|
||||
ls -la /deploy || true
|
||||
ls -la /deploy/webserver || true
|
||||
df -h /deploy/webserver || true
|
||||
mount | grep -E "/deploy/webserver" || true
|
||||
|
||||
echo "=== DEBUG: write test (pre-sync) ==="
|
||||
mkdir -p "${TARGET_PATH}"
|
||||
date > "${TARGET_PATH}/__ci_write_test.txt" || true
|
||||
ls -la "${TARGET_PATH}" || true
|
||||
cat "${TARGET_PATH}/__ci_write_test.txt" || true
|
||||
|
||||
echo "📁 Prüfe lokale Basisverzeichnisse..."
|
||||
MISSING=0
|
||||
|
||||
for d in $BASE_DIRS; do
|
||||
if [ ! -d "$d" ]; then
|
||||
echo "❌ Verzeichnis '$d/' fehlt im Repo!"
|
||||
MISSING=1
|
||||
fi
|
||||
done
|
||||
|
||||
if [ ! -d "$CONFIG_BASE_DIR" ]; then
|
||||
echo "❌ Basis-Konfig-Verzeichnis '$CONFIG_BASE_DIR/' fehlt!"
|
||||
MISSING=1
|
||||
fi
|
||||
|
||||
if [ ! -d "$CONFIG_ENV_DIR" ]; then
|
||||
echo "❌ Env-Konfiguration '$CONFIG_ENV_DIR/' fehlt!"
|
||||
MISSING=1
|
||||
fi
|
||||
|
||||
if [ "$MISSING" -ne 0 ]; then
|
||||
echo "⛔ Abbruch wegen fehlender Verzeichnisse."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "🧱 Stelle sicher, dass Zielpfad existiert..."
|
||||
mkdir -p "${TARGET_PATH}"
|
||||
|
||||
echo "🚀 Deploy ${DEPLOY_ENV} → ${TARGET_PATH}"
|
||||
chmod -R u+rwX,go+rX "${TARGET_PATH}" || true
|
||||
echo "RSYNC_OPTS: ${RSYNC_OPTS}"
|
||||
|
||||
for d in $BASE_DIRS; do
|
||||
echo "🔁 Sync ${d}/ → ${TARGET_PATH}${d}/"
|
||||
mkdir -p "${TARGET_PATH}${d}/"
|
||||
rsync -a ${RSYNC_OPTS} --exclude=".gitkeep" "${d}/" "${TARGET_PATH}${d}/"
|
||||
done
|
||||
|
||||
echo "🧩 Baue gemischtes Config-Verzeichnis (config/*.php + ${CONFIG_ENV_DIR})..."
|
||||
rm -rf .ci_config_deploy
|
||||
mkdir -p .ci_config_deploy
|
||||
|
||||
if [ -d "${CONFIG_BASE_DIR}" ]; then
|
||||
for f in ${CONFIG_BASE_DIR}/*.php; do
|
||||
if [ -f "$f" ]; then
|
||||
echo "➕ Basis-Config-Datei: $f"
|
||||
cp "$f" .ci_config_deploy/
|
||||
fi
|
||||
done
|
||||
fi
|
||||
|
||||
if [ -d "${CONFIG_ENV_DIR}" ]; then
|
||||
echo "➕ Env-Config aus ${CONFIG_ENV_DIR}/"
|
||||
cp -R "${CONFIG_ENV_DIR}/." .ci_config_deploy/
|
||||
fi
|
||||
|
||||
echo "🔁 Sync .ci_config_deploy/ → ${TARGET_PATH}${CONFIG_BASE_DIR}/"
|
||||
mkdir -p "${TARGET_PATH}${CONFIG_BASE_DIR}/"
|
||||
rsync -a ${RSYNC_OPTS} --exclude=".gitkeep" ".ci_config_deploy/" "${TARGET_PATH}${CONFIG_BASE_DIR}/"
|
||||
|
||||
echo "=== DEBUG: post-sync spot checks ==="
|
||||
ls -la "${TARGET_PATH}" | head -n 200 || true
|
||||
find "${TARGET_PATH}" -maxdepth 2 -type f | head -n 200 || true
|
||||
|
||||
echo "✅ Deploy ${DEPLOY_ENV} abgeschlossen."
|
||||
23
Anweisung codex neues projekt.txt
Normal file
23
Anweisung codex neues projekt.txt
Normal file
@@ -0,0 +1,23 @@
|
||||
Neues Projekt auf Basis dieser Vorlage erstellen.
|
||||
|
||||
Dabei sind die Dateien `GENERAL.md` und `BASE_FILES.md` zwingend vollstaendig zu beachten.
|
||||
Ohne Beachtung dieser beiden Dateien darf keine Projekterstellung erfolgen.
|
||||
|
||||
Vor der Erstellung muessen die beiden Domains gesetzt werden:
|
||||
|
||||
- Live-Domain: <LIVE_DOMAIN>
|
||||
- Staging-Domain: <STAGING_DOMAIN>
|
||||
|
||||
Wichtige Pflichtregel:
|
||||
|
||||
- Wenn eine der beiden Domains fehlt, muss die Erstellung blockiert werden.
|
||||
- Wenn einer der Platzhalter `<LIVE_DOMAIN>` oder `<STAGING_DOMAIN>` noch unveraendert vorhanden ist, muss die Erstellung ebenfalls blockiert werden.
|
||||
- Eine Ausfuehrung ist nur erlaubt, wenn beide Platzhalter durch echte Domainnamen ersetzt wurden.
|
||||
|
||||
Weitere Pflichtregeln:
|
||||
|
||||
- Alle bestehenden Dateien und Ordner sind vor der Neuerstellung zu loeschen.
|
||||
- Die Datei `.gitlab-ci.yml` ist die einzige Ausnahme und muss erhalten bleiben.
|
||||
- Falls `.gitlab-ci.yml` Domainreferenzen, URLs oder projektspezifische Angaben enthaelt, muessen diese auf das neue Projekt angepasst werden.
|
||||
|
||||
Danach ist das neue Projekt exakt nach den Vorgaben aus `GENERAL.md` und `BASE_FILES.md` zu erzeugen.
|
||||
443
BASE_FILES.md
Normal file
443
BASE_FILES.md
Normal file
@@ -0,0 +1,443 @@
|
||||
# Basisdateien fuer neue Projekte
|
||||
|
||||
Dieses Dokument enthaelt die Basisdateien, die bei einem neuen Projekt direkt angelegt werden sollen. Der Schwerpunkt liegt auf den Config-Dateien, weil diese fuer den ersten lauffaehigen Stand zwingend benoetigt werden.
|
||||
|
||||
Dieses Dokument ist dafuer gedacht, zusammen mit `GENERAL.md` in ein neues GitLab-Projekt kopiert zu werden. Danach kann die Projektstruktur inklusive Basisdateien direkt erstellt werden.
|
||||
|
||||
## Wichtige Regel vor der Erstellung
|
||||
|
||||
Ein neues Projekt darf nur erstellt werden, wenn beide Domains bekannt sind:
|
||||
|
||||
- Live-Domain
|
||||
- Staging-Domain
|
||||
|
||||
Wenn eine oder beide Angaben fehlen, muss die Erstellung gestoppt werden. Vor dem Anlegen der Dateien ist dann explizit nach beiden Domains zu fragen.
|
||||
|
||||
Ohne beide Domains duerfen insbesondere diese Dateien nicht final erzeugt werden:
|
||||
|
||||
- `config/prod/domaindata.php`
|
||||
- `config/prod/settings.php`
|
||||
- `config/staging/domaindata.php`
|
||||
- `config/staging/settings.php`
|
||||
|
||||
Vor der Neuerstellung ist das Repository ausserdem auf den neuen Projektstand zurueckzusetzen:
|
||||
|
||||
- alle bestehenden Dateien und Ordner loeschen
|
||||
- `.gitlab-ci.yml` ausdruecklich behalten
|
||||
- `.gitlab-ci.yml` anschliessend auf alte Domainreferenzen, Umgebungs-URLs und projektspezifische Angaben pruefen
|
||||
- gefundene Domainreferenzen in `.gitlab-ci.yml` auf die neue Live- und Staging-Domain anpassen
|
||||
|
||||
## Platzhalter fuer neue Projekte
|
||||
|
||||
In den folgenden Vorlagen werden diese Platzhalter verwendet:
|
||||
|
||||
- `<LIVE_DOMAIN>` fuer die Produktiv-Domain
|
||||
- `<STAGING_DOMAIN>` fuer die Staging-Domain
|
||||
- `<APP_PREFIX>` fuer den Cookie- und App-Prefix
|
||||
- `<APP_NAME>` fuer einen allgemeinen Projektnamen oder OIDC-Client-Namen
|
||||
|
||||
## Datei: `config/fileload.php`
|
||||
|
||||
```php
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
spl_autoload_register(function ($class) {
|
||||
if (str_starts_with($class, 'App\\Repository\\')) {
|
||||
$prefix = 'App\\Repository\\';
|
||||
$baseDir = __DIR__ . '/../src/Repository/';
|
||||
} elseif (str_starts_with($class, 'App\\')) {
|
||||
$prefix = 'App\\';
|
||||
$baseDir = __DIR__ . '/../src/App/';
|
||||
} else {
|
||||
return;
|
||||
}
|
||||
|
||||
$len = strlen($prefix);
|
||||
$relativeClass = substr($class, $len);
|
||||
$file = $baseDir . str_replace('\\', '/', $relativeClass) . '.php';
|
||||
|
||||
if (file_exists($file)) {
|
||||
require $file;
|
||||
}
|
||||
});
|
||||
|
||||
require_once __DIR__ . '/../src/App/functions.php';
|
||||
|
||||
$domainFile = __DIR__ . '/domaindata.php';
|
||||
$settingsFile = __DIR__ . '/settings.php';
|
||||
$configFile = __DIR__ . '/db.php';
|
||||
$baseConfigFile = __DIR__ . '/base_db.php';
|
||||
$fallbackBaseConfigStaging = __DIR__ . '/staging/db_settings_basic.php';
|
||||
$fallbackBaseConfigProd = __DIR__ . '/prod/db_settings_basic.php';
|
||||
|
||||
if (file_exists($domainFile)) {
|
||||
require_once $domainFile;
|
||||
}
|
||||
|
||||
if (file_exists($settingsFile)) {
|
||||
require_once $settingsFile;
|
||||
}
|
||||
|
||||
$dbConfig = [];
|
||||
if (file_exists($configFile)) {
|
||||
$dbConfig = require $configFile;
|
||||
}
|
||||
|
||||
$baseDbConfig = [];
|
||||
if (file_exists($baseConfigFile)) {
|
||||
$baseDbConfig = require $baseConfigFile;
|
||||
}
|
||||
if (empty($baseDbConfig) && file_exists($fallbackBaseConfigStaging)) {
|
||||
$baseDbConfig = require $fallbackBaseConfigStaging;
|
||||
}
|
||||
if (empty($baseDbConfig) && file_exists($fallbackBaseConfigProd)) {
|
||||
$baseDbConfig = require $fallbackBaseConfigProd;
|
||||
}
|
||||
|
||||
global $appConfig;
|
||||
$dbEnabled = defined('APP_DB_ENABLED') ? APP_DB_ENABLED : true;
|
||||
$baseDbEnabled = defined('APP_BASE_DB_ENABLED') ? APP_BASE_DB_ENABLED : false;
|
||||
$appConfig = new \App\Config($dbConfig, $dbEnabled, $baseDbConfig, $baseDbEnabled);
|
||||
|
||||
\App\App::init($appConfig);
|
||||
```
|
||||
|
||||
## Datei: `config/config.php`
|
||||
|
||||
```php
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
ini_set('display_errors', '1');
|
||||
ini_set('display_startup_errors', '1');
|
||||
error_reporting(E_ALL);
|
||||
|
||||
foreach (['domaindata.php', 'settings.php'] as $cfgFile) {
|
||||
$rootPath = __DIR__ . '/' . $cfgFile;
|
||||
|
||||
if (file_exists($rootPath)) {
|
||||
require_once $rootPath;
|
||||
} else {
|
||||
throw new \RuntimeException("Missing required config file: $cfgFile (expected $rootPath)");
|
||||
}
|
||||
}
|
||||
|
||||
if (!defined('ASSET_VERSION')) {
|
||||
define('ASSET_VERSION', 'dev-' . date('Ymd-His'));
|
||||
}
|
||||
|
||||
if (!defined('APP_DOMAIN_PRIMARY')) {
|
||||
define('APP_DOMAIN_PRIMARY', APP_DOMAIN_NAME);
|
||||
}
|
||||
if (!defined('APP_URL_PRIMARY')) {
|
||||
define('APP_URL_PRIMARY', 'https://' . APP_DOMAIN_PRIMARY);
|
||||
}
|
||||
if (!defined('APP_API_BASE')) {
|
||||
define('APP_API_BASE', 'https://api.' . APP_DOMAIN_PRIMARY);
|
||||
}
|
||||
if (!defined('APP_DB_ENABLED')) {
|
||||
define('APP_DB_ENABLED', false);
|
||||
}
|
||||
```
|
||||
|
||||
## Datei: `config/base_db.php`
|
||||
|
||||
```php
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
$path = __DIR__ . '/db_settings_basic.php';
|
||||
if (!file_exists($path)) {
|
||||
throw new RuntimeException('Missing base DB config: expected config/db_settings_basic.php');
|
||||
}
|
||||
|
||||
return require $path;
|
||||
```
|
||||
|
||||
## Datei: `config/staging/domaindata.php`
|
||||
|
||||
```php
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
if (!defined('APP_DOMAIN_NAME')) {
|
||||
define('APP_DOMAIN_NAME', '<STAGING_DOMAIN>');
|
||||
}
|
||||
|
||||
if (!defined('APP_PREFIX')) {
|
||||
define('APP_PREFIX', '<APP_PREFIX>');
|
||||
}
|
||||
```
|
||||
|
||||
## Datei: `config/prod/domaindata.php`
|
||||
|
||||
```php
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
if (!defined('APP_DOMAIN_NAME')) {
|
||||
define('APP_DOMAIN_NAME', '<LIVE_DOMAIN>');
|
||||
}
|
||||
|
||||
if (!defined('APP_PREFIX')) {
|
||||
define('APP_PREFIX', '<APP_PREFIX>');
|
||||
}
|
||||
```
|
||||
|
||||
## Datei: `config/staging/settings.php`
|
||||
|
||||
```php
|
||||
<?php
|
||||
define('ASSET_VERSION', 'dev-' . date('Ymd-His'));
|
||||
define('APP_DOMAIN_PRIMARY', APP_DOMAIN_NAME);
|
||||
define('APP_URL_PRIMARY', 'https://' . APP_DOMAIN_PRIMARY);
|
||||
define('APP_API_BASE', 'https://api.' . APP_DOMAIN_PRIMARY);
|
||||
|
||||
define('APP_DB_ENABLED', false);
|
||||
define('APP_DB_DEBUG', true);
|
||||
define('APP_DB_AUTO_INIT', true);
|
||||
define('APP_BASE_DB_ENABLED', true);
|
||||
|
||||
define('APP_AUTH_ENABLED', false);
|
||||
define('APP_DEBUG_TOOL', true);
|
||||
define('APP_AUTH_DEBUG', true);
|
||||
|
||||
/*
|
||||
Optional fuer Projekte mit OIDC:
|
||||
|
||||
define('APP_OIDC_ISSUER', 'https://auth.example.tld/realms/<APP_NAME>');
|
||||
define('APP_OIDC_AUTH_ENDPOINT', 'https://auth.example.tld/realms/<APP_NAME>/protocol/openid-connect/auth');
|
||||
define('APP_OIDC_TOKEN_ENDPOINT', 'https://auth.example.tld/realms/<APP_NAME>/protocol/openid-connect/token');
|
||||
define('APP_OIDC_USERINFO_ENDPOINT', 'https://auth.example.tld/realms/<APP_NAME>/protocol/openid-connect/userinfo');
|
||||
define('APP_OIDC_LOGOUT_ENDPOINT', 'https://auth.example.tld/realms/<APP_NAME>/protocol/openid-connect/logout');
|
||||
define('APP_OIDC_CLIENT_ID', '<APP_NAME>');
|
||||
define('APP_OIDC_CLIENT_SECRET', 'CHANGE_ME');
|
||||
define('APP_OIDC_REDIRECT_URI', 'https://<STAGING_DOMAIN>/auth/callback');
|
||||
define('APP_OIDC_POST_LOGOUT_REDIRECT_URI', 'https://<STAGING_DOMAIN>/');
|
||||
define('APP_OIDC_GROUP_CLAIM', 'groups');
|
||||
define('APP_OIDC_ADMIN_GROUP', 'appadmin');
|
||||
define('APP_OIDC_USER_GROUP', 'user');
|
||||
*/
|
||||
```
|
||||
|
||||
## Datei: `config/prod/settings.php`
|
||||
|
||||
```php
|
||||
<?php
|
||||
define('ASSET_VERSION', 'dev-' . date('Ymd-His'));
|
||||
define('APP_DOMAIN_PRIMARY', APP_DOMAIN_NAME);
|
||||
define('APP_URL_PRIMARY', 'https://' . APP_DOMAIN_PRIMARY);
|
||||
define('APP_API_BASE', 'https://api.' . APP_DOMAIN_PRIMARY);
|
||||
|
||||
define('APP_DB_ENABLED', false);
|
||||
define('APP_DB_DEBUG', false);
|
||||
define('APP_DB_AUTO_INIT', true);
|
||||
define('APP_BASE_DB_ENABLED', true);
|
||||
|
||||
define('APP_AUTH_ENABLED', false);
|
||||
define('APP_DEBUG_TOOL', false);
|
||||
define('APP_AUTH_DEBUG', false);
|
||||
|
||||
/*
|
||||
Optional fuer Projekte mit OIDC:
|
||||
|
||||
define('APP_OIDC_ISSUER', 'https://auth.example.tld/realms/<APP_NAME>');
|
||||
define('APP_OIDC_AUTH_ENDPOINT', 'https://auth.example.tld/realms/<APP_NAME>/protocol/openid-connect/auth');
|
||||
define('APP_OIDC_TOKEN_ENDPOINT', 'https://auth.example.tld/realms/<APP_NAME>/protocol/openid-connect/token');
|
||||
define('APP_OIDC_USERINFO_ENDPOINT', 'https://auth.example.tld/realms/<APP_NAME>/protocol/openid-connect/userinfo');
|
||||
define('APP_OIDC_LOGOUT_ENDPOINT', 'https://auth.example.tld/realms/<APP_NAME>/protocol/openid-connect/logout');
|
||||
define('APP_OIDC_CLIENT_ID', '<APP_NAME>');
|
||||
define('APP_OIDC_CLIENT_SECRET', 'CHANGE_ME');
|
||||
define('APP_OIDC_REDIRECT_URI', 'https://<LIVE_DOMAIN>/auth/callback');
|
||||
define('APP_OIDC_POST_LOGOUT_REDIRECT_URI', 'https://<LIVE_DOMAIN>/');
|
||||
define('APP_OIDC_GROUP_CLAIM', 'groups');
|
||||
define('APP_OIDC_ADMIN_GROUP', 'appadmin');
|
||||
define('APP_OIDC_USER_GROUP', 'user');
|
||||
*/
|
||||
```
|
||||
|
||||
## Datei: `config/staging/db_settings_basic.php`
|
||||
|
||||
```php
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
return [
|
||||
'dsn' => 'sqlite:' . __DIR__ . '/../../data/app_staging.sqlite',
|
||||
'user' => null,
|
||||
'password' => null,
|
||||
'options' => [
|
||||
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
|
||||
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
|
||||
],
|
||||
];
|
||||
```
|
||||
|
||||
## Datei: `config/prod/db_settings_basic.php`
|
||||
|
||||
```php
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
return [
|
||||
'dsn' => 'sqlite:' . __DIR__ . '/../../data/app_prod.sqlite',
|
||||
'user' => null,
|
||||
'password' => null,
|
||||
'options' => [
|
||||
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
|
||||
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
|
||||
],
|
||||
];
|
||||
```
|
||||
|
||||
## Datei: `public/index.php`
|
||||
|
||||
Minimaler Einstiegspunkt fuer den Webzugriff:
|
||||
|
||||
```php
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
require_once __DIR__ . '/../config/fileload.php';
|
||||
|
||||
$uriPath = parse_url($_SERVER['REQUEST_URI'] ?? '/', PHP_URL_PATH) ?: '/';
|
||||
$uriPath = preg_replace('~/{2,}~', '/', $uriPath);
|
||||
$uriPath = trim($uriPath, '/');
|
||||
|
||||
if (str_contains($uriPath, '..')) {
|
||||
http_response_code(400);
|
||||
exit('Bad request');
|
||||
}
|
||||
|
||||
$pagesBase = realpath(__DIR__ . '/../partials/landingpages') ?: (__DIR__ . '/../partials/landingpages');
|
||||
$page404 = $pagesBase . '/404.php';
|
||||
|
||||
if (preg_match('~^module/([a-zA-Z0-9_-]+)(?:/(.+))?$~', $uriPath, $m)) {
|
||||
$module = $m[1];
|
||||
$page = isset($m[2]) && $m[2] !== '' ? trim($m[2], '/') : 'index';
|
||||
$modulePage = app()->modules()->resolvePage($module, $page);
|
||||
$target = $modulePage ?: $page404;
|
||||
if (!$modulePage) {
|
||||
http_response_code(404);
|
||||
}
|
||||
} elseif ($uriPath === '' || $uriPath === 'index' || $uriPath === 'index.php') {
|
||||
$target = $pagesBase . '/index.php';
|
||||
} else {
|
||||
$base = $pagesBase . '/' . $uriPath;
|
||||
if (is_dir($base) && is_file($base . '/index.php')) {
|
||||
$target = $base . '/index.php';
|
||||
} elseif (is_file($base . '.php')) {
|
||||
$target = $base . '.php';
|
||||
} else {
|
||||
http_response_code(404);
|
||||
$target = $page404;
|
||||
}
|
||||
}
|
||||
|
||||
ob_start();
|
||||
require $target;
|
||||
$content = ob_get_clean();
|
||||
|
||||
tpl('layout_start', 'structure');
|
||||
echo $content;
|
||||
tpl('layout_end', 'structure');
|
||||
```
|
||||
|
||||
## Datei: `partials/landingpages/index.php`
|
||||
|
||||
```php
|
||||
<div class="card">
|
||||
<h1>Projektstart</h1>
|
||||
<p>Die Grundstruktur wurde erfolgreich erstellt.</p>
|
||||
</div>
|
||||
```
|
||||
|
||||
## Datei: `partials/landingpages/404.php`
|
||||
|
||||
```php
|
||||
<div class="card">
|
||||
<h1>404</h1>
|
||||
<p>Die angeforderte Seite wurde nicht gefunden.</p>
|
||||
</div>
|
||||
```
|
||||
|
||||
## Datei: `partials/structure/layout_start.php`
|
||||
|
||||
```php
|
||||
<!doctype html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Projektbasis</title>
|
||||
<link rel="stylesheet" href="/assets/css/app.css?v=<?= urlencode(app()->config()->assetVersion) ?>">
|
||||
</head>
|
||||
<body>
|
||||
<main class="main-content">
|
||||
```
|
||||
|
||||
## Datei: `partials/structure/layout_end.php`
|
||||
|
||||
```php
|
||||
</main>
|
||||
<script src="/assets/js/app.js?v=<?= urlencode(app()->config()->assetVersion) ?>" defer></script>
|
||||
</body>
|
||||
</html>
|
||||
```
|
||||
|
||||
## Datei: `public/assets/css/app.css`
|
||||
|
||||
```css
|
||||
body {
|
||||
margin: 0;
|
||||
font-family: sans-serif;
|
||||
background: #f5f5f5;
|
||||
color: #111;
|
||||
}
|
||||
|
||||
.main-content {
|
||||
max-width: 960px;
|
||||
margin: 0 auto;
|
||||
padding: 32px 16px;
|
||||
}
|
||||
|
||||
.card {
|
||||
background: #fff;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 12px;
|
||||
padding: 24px;
|
||||
}
|
||||
```
|
||||
|
||||
## Datei: `public/assets/js/app.js`
|
||||
|
||||
```js
|
||||
document.documentElement.classList.add('js');
|
||||
```
|
||||
|
||||
## Datei: `public/.htaccess`
|
||||
|
||||
```apache
|
||||
RewriteEngine On
|
||||
|
||||
RewriteCond %{REQUEST_FILENAME} !-f
|
||||
RewriteCond %{REQUEST_FILENAME} !-d
|
||||
RewriteRule ^ index.php [QSA,L]
|
||||
```
|
||||
|
||||
## Empfohlene Startreihenfolge in einem neuen Projekt
|
||||
|
||||
1. Neues GitLab-Projekt anlegen.
|
||||
2. Repository lokal mit VS Code verknuepfen.
|
||||
3. Alle bestehenden Dateien und Ordner entfernen, `.gitlab-ci.yml` jedoch behalten.
|
||||
4. `GENERAL.md` und `BASE_FILES.md` in das neue Repository kopieren.
|
||||
5. `.gitlab-ci.yml` auf alte Domain- und Projektreferenzen pruefen und anpassen.
|
||||
6. Pflichtstruktur aus `GENERAL.md` anlegen.
|
||||
7. Basisdateien aus `BASE_FILES.md` anlegen.
|
||||
8. Live- und Staging-Domain einsetzen.
|
||||
9. Danach erst die eigentliche Projekterstellung oder Modulentwicklung starten.
|
||||
|
||||
## Kurzregel
|
||||
|
||||
Ohne `GENERAL.md`, ohne `BASE_FILES.md` oder ohne beide Domains ist die saubere Erstellung eines neuen Projekts auf dieser Basis nicht vollstaendig und soll nicht ausgefuehrt werden.
|
||||
375
GENERAL.md
Normal file
375
GENERAL.md
Normal file
@@ -0,0 +1,375 @@
|
||||
# Projektbasis fuer neue Projekte
|
||||
|
||||
Dieses Dokument kombiniert die Erklaerung des allgemeinen Projektaufbaus mit einer konkreten Startstruktur fuer neue Projekte. Ziel ist, dass neue Projekte dieselbe technische Basis verwenden und dieselben Regeln fuer Routing, Verzeichnisse, Module, Assets und Konfiguration einhalten.
|
||||
|
||||
## Grundprinzip
|
||||
|
||||
Das Projekt basiert auf einer schlanken PHP-Struktur mit:
|
||||
|
||||
- einem zentralen Web-Einstiegspunkt in `public/index.php`
|
||||
- einem zentralen Bootstrap in `config/fileload.php`
|
||||
- globalen Seiten unter `partials/landingpages/`
|
||||
- globalem Layout unter `partials/structure/`
|
||||
- optionalen Fachmodulen unter `modules/`
|
||||
- globalen Assets unter `public/assets/`
|
||||
- einer Deployment-gesteuerten Config-Aufteilung mit `config/staging/` und `config/prod/`
|
||||
|
||||
## Verbindliche Startstruktur fuer neue Projekte
|
||||
|
||||
Die folgende Struktur soll als Basis fuer neue Projekte verwendet werden. Sie beruecksichtigt ausdruecklich auch die Basisverzeichnisse aus `.gitlab-ci.yml`.
|
||||
|
||||
```text
|
||||
/
|
||||
|-- .gitlab-ci.yml
|
||||
|-- GENERAL.md
|
||||
|-- api/
|
||||
| `-- .gitkeep
|
||||
|-- config/
|
||||
| |-- .gitkeep
|
||||
| |-- base_db.php
|
||||
| |-- config.php
|
||||
| |-- fileload.php
|
||||
| |-- prod/
|
||||
| | |-- .gitkeep
|
||||
| | |-- db_settings_basic.php
|
||||
| | |-- domaindata.php
|
||||
| | `-- settings.php
|
||||
| `-- staging/
|
||||
| |-- .gitkeep
|
||||
| |-- db_settings_basic.php
|
||||
| |-- domaindata.php
|
||||
| `-- settings.php
|
||||
|-- data/
|
||||
| `-- .gitkeep
|
||||
|-- debug/
|
||||
| `-- .gitkeep
|
||||
|-- modules/
|
||||
| `-- .gitkeep
|
||||
|-- partials/
|
||||
| |-- landingpages/
|
||||
| | |-- .gitkeep
|
||||
| | `-- index.php
|
||||
| `-- structure/
|
||||
| |-- .gitkeep
|
||||
| |-- layout_end.php
|
||||
| `-- layout_start.php
|
||||
|-- public/
|
||||
| |-- .gitkeep
|
||||
| |-- .htaccess
|
||||
| |-- index.php
|
||||
| `-- assets/
|
||||
| |-- css/
|
||||
| | |-- .gitkeep
|
||||
| | `-- app.css
|
||||
| |-- images/
|
||||
| | `-- .gitkeep
|
||||
| `-- js/
|
||||
| |-- .gitkeep
|
||||
| `-- app.js
|
||||
|-- src/
|
||||
| |-- .gitkeep
|
||||
| |-- App/
|
||||
| | |-- .gitkeep
|
||||
| | |-- App.php
|
||||
| | |-- Assets.php
|
||||
| | |-- Config.php
|
||||
| | |-- Database.php
|
||||
| | |-- ModuleManager.php
|
||||
| | `-- functions.php
|
||||
| `-- Repository/
|
||||
| `-- .gitkeep
|
||||
`-- tools/
|
||||
`-- .gitkeep
|
||||
```
|
||||
|
||||
## Pflichtordner
|
||||
|
||||
Diese Ordner muessen als Basis immer vorhanden sein, weil sie entweder vom Projektaufbau oder vom Deployment vorausgesetzt werden:
|
||||
|
||||
- `api/`
|
||||
- `config/`
|
||||
- `data/`
|
||||
- `debug/`
|
||||
- `modules/`
|
||||
- `partials/`
|
||||
- `public/`
|
||||
- `src/`
|
||||
- `tools/`
|
||||
|
||||
Diese Liste entspricht der Deploy-Basis aus `.gitlab-ci.yml` plus den notwendigen Unterordnern fuer die Anwendung.
|
||||
|
||||
## Regel fuer leere Ordner
|
||||
|
||||
Wenn ein notwendiger Ordner anfangs noch keine echten Dateien enthaelt, muss er eine leere Datei mit dem Namen `.gitkeep` enthalten. Das gilt insbesondere fuer:
|
||||
|
||||
- `api/`
|
||||
- `data/`
|
||||
- `debug/`
|
||||
- `modules/`
|
||||
- `tools/`
|
||||
- `public/assets/images/`
|
||||
- `src/Repository/`
|
||||
- weitere leere Unterordner, die im Template bereits vorgesehen sind
|
||||
|
||||
Damit bleiben die Verzeichnisse versionierbar und werden beim Kopieren und Deployment nicht versehentlich ausgelassen.
|
||||
|
||||
## Rollen der wichtigsten Verzeichnisse
|
||||
|
||||
### `public/`
|
||||
|
||||
`public/` ist der Webroot. Hier liegen:
|
||||
|
||||
- `index.php` als zentraler Router
|
||||
- globale Assets unter `public/assets/`
|
||||
- Webserver-Dateien wie `.htaccess`
|
||||
|
||||
### `src/`
|
||||
|
||||
`src/` enthaelt den PHP-Kern der Anwendung.
|
||||
|
||||
- `src/App/` fuer App-Klassen, Bootstrap-nahe Logik, Config, Assets, Datenbank, Request, Session und Modulverwaltung
|
||||
- `src/Repository/` fuer Datenzugriffslogik
|
||||
|
||||
### `partials/`
|
||||
|
||||
`partials/` enthaelt globale Templates.
|
||||
|
||||
- `partials/landingpages/` fuer globale, direkt routbare Seiten
|
||||
- `partials/structure/` fuer das globale Layout
|
||||
|
||||
### `modules/`
|
||||
|
||||
`modules/` enthaelt fachlich getrennte Erweiterungen. Jedes Modul bleibt in seinem eigenen Ordner und kapselt Seiten, Assets, Partials und optionale Bootstrap-Logik.
|
||||
|
||||
### `api/`
|
||||
|
||||
`api/` ist fuer API-Funktionen vorgesehen, falls das Projekt eigene API-Endpunkte anbieten soll. Wenn ein Projekt keine API bereitstellt, kann der Ordner leer bleiben, soll aber als Teil der Basisstruktur dennoch vorhanden sein.
|
||||
|
||||
### `config/`
|
||||
|
||||
`config/` enthaelt den Bootstrap der Konfiguration sowie die umgebungsspezifischen Quellverzeichnisse `staging` und `prod`.
|
||||
|
||||
### `tools/`
|
||||
|
||||
`tools/` ist fuer CLI-Skripte, Worker und Hilfsprogramme vorgesehen.
|
||||
|
||||
## Routing- und Renderstruktur
|
||||
|
||||
### `public/index.php`
|
||||
|
||||
`public/index.php` ist der einzige Web-Einstiegspunkt und uebernimmt:
|
||||
|
||||
- Laden von `config/fileload.php`
|
||||
- Normalisierung des Request-Pfads
|
||||
- Pruefung optionaler Authentifizierung oder Schutzregeln
|
||||
- Routing auf globale Seiten unter `partials/landingpages/`
|
||||
- Routing auf Modulseiten unter `modules/<modul>/pages/`
|
||||
- 404-Behandlung
|
||||
- Rendern des Inhalts innerhalb des globalen Layouts
|
||||
|
||||
### Globale Seiten
|
||||
|
||||
Globale Seiten liegen unter `partials/landingpages/`. Das Routing ist dateibasiert, also ohne externen Framework-Router.
|
||||
|
||||
Beispiele:
|
||||
|
||||
- `/` -> `partials/landingpages/index.php`
|
||||
- `/users` -> `partials/landingpages/users/index.php`
|
||||
|
||||
### Layout
|
||||
|
||||
Die globale HTML-Struktur liegt unter `partials/structure/`.
|
||||
|
||||
- `layout_start.php` oeffnet das Seitenlayout
|
||||
- `layout_end.php` schliesst es
|
||||
|
||||
### Module
|
||||
|
||||
Modulrouten folgen dem Schema:
|
||||
|
||||
```text
|
||||
/module/<modulname>
|
||||
/module/<modulname>/<seite>
|
||||
```
|
||||
|
||||
Diese Routen verweisen auf Dateien unter:
|
||||
|
||||
```text
|
||||
modules/<modulname>/pages/
|
||||
```
|
||||
|
||||
## Modulstruktur fuer neue Projekte
|
||||
|
||||
Wenn ein neues Projekt Module verwendet, sollte jedes Modul nach demselben Muster aufgebaut sein:
|
||||
|
||||
```text
|
||||
modules/<modulname>/
|
||||
|-- assets/
|
||||
| `-- .gitkeep
|
||||
|-- pages/
|
||||
| |-- .gitkeep
|
||||
| `-- index.php
|
||||
|-- partials/
|
||||
| `-- .gitkeep
|
||||
|-- bootstrap.php
|
||||
`-- module.json
|
||||
```
|
||||
|
||||
Regeln:
|
||||
|
||||
- modulspezifisches CSS und JavaScript bleibt unter `modules/<modulname>/assets/`
|
||||
- globale Assets gehoeren nicht in Modulordner
|
||||
- Modulcode gehoert nicht nach `public/assets/`
|
||||
- Seiten eines Moduls liegen unter `pages/`
|
||||
- wiederverwendbare Modul-Templates liegen unter `partials/`
|
||||
|
||||
## Asset-Struktur
|
||||
|
||||
### Globale Assets
|
||||
|
||||
Projektweite Dateien liegen unter:
|
||||
|
||||
- `public/assets/css/`
|
||||
- `public/assets/js/`
|
||||
- `public/assets/images/`
|
||||
|
||||
### Modul-Assets
|
||||
|
||||
Modulbezogene Dateien liegen unter:
|
||||
|
||||
- `modules/<modulname>/assets/`
|
||||
|
||||
Neue Projekte sollen diese Trennung konsequent beibehalten.
|
||||
|
||||
## Config-Aufteilung mit `staging` und `prod`
|
||||
|
||||
Dieser Teil ist fuer neue Projekte verpflichtend.
|
||||
|
||||
### Quellverzeichnisse im Repository
|
||||
|
||||
Im Repository liegen die umgebungsspezifischen Config-Quellen unter:
|
||||
|
||||
```text
|
||||
config/staging/
|
||||
config/prod/
|
||||
```
|
||||
|
||||
Darin liegen typischerweise:
|
||||
|
||||
- `domaindata.php`
|
||||
- `settings.php`
|
||||
- `db_settings_basic.php`
|
||||
|
||||
### Laufzeitlogik
|
||||
|
||||
Die Anwendung selbst laedt zur Laufzeit nicht direkt aus `config/staging/` oder `config/prod/`, sondern aus dem Root von `config/`, also z. B.:
|
||||
|
||||
- `config/settings.php`
|
||||
- `config/domaindata.php`
|
||||
- `config/db_settings_basic.php`
|
||||
|
||||
`config/fileload.php` erwartet genau diese Root-Dateien.
|
||||
|
||||
### Deployment-Regel
|
||||
|
||||
Beim Deployment wird die passende Umgebung in das aktive `config/`-Root kopiert:
|
||||
|
||||
1. allgemeine Root-Dateien aus `config/` werden bereitgestellt
|
||||
2. je nach Branch wird die Umgebungsvariante darueberkopiert
|
||||
3. `develop` verwendet `config/staging/`
|
||||
4. `main` verwendet `config/prod/`
|
||||
5. im Zielsystem gelten die kopierten Dateien danach so, als waeren sie direkt normale Root-Configs
|
||||
|
||||
Neue Projekte muessen exakt dieses Prinzip uebernehmen.
|
||||
|
||||
## Domain-Pflicht fuer neue Projekterstellungen
|
||||
|
||||
Neue Projekte duerfen nur erstellt werden, wenn beide Domains vorliegen:
|
||||
|
||||
- Live-Domain
|
||||
- Staging-Domain
|
||||
|
||||
Diese Domains muessen in den umgebungsspezifischen Config-Dateien verwendet und automatisch eingetragen werden, insbesondere in:
|
||||
|
||||
- `config/prod/domaindata.php`
|
||||
- `config/prod/settings.php`
|
||||
- `config/staging/domaindata.php`
|
||||
- `config/staging/settings.php`
|
||||
- `.gitlab-ci.yml`, falls dort Domainreferenzen oder Umgebungs-URLs hinterlegt sind
|
||||
|
||||
## Verbindliche Erstellungsregel
|
||||
|
||||
Wenn bei der Aufgabenstellung fuer ein neues Projekt keine Domains angegeben werden, muss vor der Projekterstellung explizit nach beiden Werten gefragt werden.
|
||||
|
||||
Die Erstellung ist in diesem Fall zu blockieren.
|
||||
|
||||
Sie darf erst fortgesetzt werden, wenn beide Angaben vorhanden sind:
|
||||
|
||||
1. Live-Domain
|
||||
2. Staging-Domain
|
||||
|
||||
Fehlt mindestens eine der beiden Angaben, muss die Erstellung unterbunden werden mit einem klaren Hinweis, dass die Domains fehlen.
|
||||
|
||||
Zusaetzlich gilt:
|
||||
|
||||
- Platzhalterwerte fuer Domains duerfen nicht unveraendert uebernommen werden
|
||||
- insbesondere Werte wie `<LIVE_DOMAIN>` und `<STAGING_DOMAIN>` muessen vor der Erstellung durch echte Domainnamen ersetzt werden
|
||||
- solange einer dieser Platzhalter noch vorhanden ist, muss die Erstellung blockiert werden
|
||||
- eine Ausfuehrung darf erst erfolgen, wenn beide echten Domainwerte gesetzt wurden
|
||||
|
||||
## Reset-Regel fuer neue Projekterstellungen
|
||||
|
||||
Vor der eigentlichen Erstellung eines neuen Projekts muessen alle bestehenden Dateien und Ordner im Repository entfernt werden, mit genau einer Ausnahme:
|
||||
|
||||
- `.gitlab-ci.yml`
|
||||
|
||||
Die bestehende `.gitlab-ci.yml` bleibt erhalten, weil sie die Deploy-Basis vorgibt. Sie muss jedoch im Zuge der neuen Projekterstellung geprueft und angepasst werden, wenn darin Domainreferenzen, Umgebungs-URLs oder projektspezifische Zielpfade enthalten sind.
|
||||
|
||||
Fuer neue Projekterstellungen gilt deshalb verbindlich:
|
||||
|
||||
1. vorhandene Projektdateien und Projektordner loeschen
|
||||
2. `.gitlab-ci.yml` behalten
|
||||
3. `.gitlab-ci.yml` auf alte Domain- oder Projektreferenzen pruefen
|
||||
4. vorhandene Domainreferenzen in `.gitlab-ci.yml` auf die neue Live- und Staging-Domain anpassen
|
||||
5. danach erst die neue Basisstruktur und die neuen Basisdateien erstellen
|
||||
|
||||
## Mindestinhalt der Config-Dateien
|
||||
|
||||
Die folgenden Dateien koennen als Basis aus diesem Projekttyp uebernommen werden und muessen pro neuem Projekt angepasst werden:
|
||||
|
||||
- `config/fileload.php`
|
||||
- `config/config.php`
|
||||
- `config/base_db.php`
|
||||
- `config/prod/domaindata.php`
|
||||
- `config/prod/settings.php`
|
||||
- `config/staging/domaindata.php`
|
||||
- `config/staging/settings.php`
|
||||
|
||||
Dabei gilt:
|
||||
|
||||
- die allgemeine Config-Logik kann uebernommen werden
|
||||
- domainspezifische Werte muessen pro Projekt ersetzt werden
|
||||
- staging und prod muessen immer unterschiedliche Zielwerte bekommen, sofern nicht ausdruecklich anders gefordert
|
||||
|
||||
## Basisdateien fuer neue Projekte
|
||||
|
||||
Neben der Ordnerstruktur werden fuer neue Projekte auch Basisdateien benoetigt, insbesondere im Config-Bereich. Die konkreten Inhalte sind in einer separaten Datei beschrieben:
|
||||
|
||||
- `BASE_FILES.md`
|
||||
|
||||
Diese Datei ist dafuer gedacht, nach dem Anlegen eines neuen GitLab-Projekts zusammen mit `GENERAL.md` in das neue Repository kopiert zu werden, damit die Erstellung der Grundstruktur und der Konfigurationsdateien direkt auf einer klaren Vorlage basiert.
|
||||
|
||||
## Kurzfassung fuer neue Projekte
|
||||
|
||||
Neue Projekte auf dieser Basis verwenden:
|
||||
|
||||
- einen zentralen Router in `public/index.php`
|
||||
- einen zentralen Bootstrap in `config/fileload.php`
|
||||
- globale Seiten in `partials/landingpages/`
|
||||
- globale Layout-Dateien in `partials/structure/`
|
||||
- optionale Module in `modules/`
|
||||
- globale Assets in `public/assets/`
|
||||
- modulspezifische Assets in `modules/<modulname>/assets/`
|
||||
- eine Deploy-gesteuerte Config mit `config/staging/` und `config/prod/`
|
||||
|
||||
Leere Pflichtordner erhalten immer `.gitkeep`. Eine neue Projekterstellung ist nur zulaessig, wenn sowohl Live- als auch Staging-Domain vorab angegeben wurden.
|
||||
12
MODULCREATION_ANWEISUNG.md
Normal file
12
MODULCREATION_ANWEISUNG.md
Normal file
@@ -0,0 +1,12 @@
|
||||
Bitte `PROJECT_CONTEXT.md` lesen und strikt einhalten.
|
||||
Für Modul-Arbeiten zusätzlich immer `MODULE_DEVELOPMENT.md` lesen und beachten.
|
||||
Wichtig: Modul-spezifischer Code/Assets ausschließlich unter /modules/<modul>/,
|
||||
keine Änderungen an /public/assets/* für modul-spezifische Features.
|
||||
Staging/Live: /config/<env> im Repo wird nach /app/<env>/config kopiert.
|
||||
Modul-Assets müssen über /module/<modul>/asset geladen werden.
|
||||
Setup-Regel: `Allgemein`, `Datenbank`, `Zugriffsrechte` und `Cron Einstellungen` kommen immer aus dem globalen Setup-System.
|
||||
Nur `Custom Settings` darf modulspezifisch sein.
|
||||
Für Zeitzonen und Debug nach Möglichkeit die globalen Helfer aus `src/App/functions.php` nutzen.
|
||||
Global bereits im Setup vorhanden sind Navigation, Debug-Feld, DB-Logik, Zugriffsschutz, Cron-/Scheduler-Logik, Setup-Aktionen, Statusblöcke und Zeitzonen-Vererbung.
|
||||
Ein neues Modul soll dafür nur noch `setup.fields`, optionale `scheduler_jobs` / `interval_tasks` und bei Bedarf `setup_actions` / `setup_status` liefern.
|
||||
Zeitwerte intern immer in UTC speichern. Anzeige- und Cron-Zeitzonen sind nur für Darstellung bzw. Scheduling gedacht.
|
||||
154
MODULE_DEVELOPMENT.md
Normal file
154
MODULE_DEVELOPMENT.md
Normal file
@@ -0,0 +1,154 @@
|
||||
Projektkontext: Modul-Entwicklung
|
||||
|
||||
1) Modul-Konzept
|
||||
- Jedes klassische Modul lebt unter `/modules/<modulname>/`.
|
||||
- Pflicht-Dateien sind in der Regel:
|
||||
- `module.json`
|
||||
- `bootstrap.php`
|
||||
- `pages/*.php`
|
||||
- `assets/*`
|
||||
|
||||
2) Modulspezifische Assets
|
||||
- Modul-JS und Modul-CSS müssen im Modul-Ordner liegen.
|
||||
- Laden über Modul-Assets, zum Beispiel:
|
||||
- `$assets->addStyle('/module/pi_control/asset?file=pi_control.css');`
|
||||
- `$assets->addScript('/module/pi_control/asset?file=hosts.js', 'footer', true);`
|
||||
- Keine modulspezifischen Änderungen in `/public/assets/*`.
|
||||
|
||||
3) Scope-Regeln
|
||||
- Modul-Aufgaben: nur `/modules/<modul>/` und gegebenenfalls `/tools/<modul>` ändern
|
||||
- globale Layouts: `/partials/structure/` und `/public/assets/css/app.css`
|
||||
- Konfigurationslogik nur bei echtem Globalbedarf in `/config/` oder `/src/`
|
||||
|
||||
4) UI-Regeln für Module
|
||||
- Modulseiten sollen diesem Muster folgen:
|
||||
- Seitenheader-Box
|
||||
- Submenü-Box
|
||||
- danach Bereichs-Boxen und/oder Karten-Boxen
|
||||
- `Setup` gehört in Modulen grundsätzlich in die Submenü-Box
|
||||
- die Optik von Submenü-Aktionen kommt ausschließlich aus dem globalen CSS
|
||||
- Module dürfen dort keine eigenen Farb- oder Variantenlogiken einschleusen
|
||||
|
||||
5) Globales Setup-System
|
||||
- Modul-Setup wird zentral über `partials/landingpages/modules/setup.php` gerendert.
|
||||
- Die Bereiche
|
||||
- `Allgemein`
|
||||
- `Datenbank`
|
||||
- `Zugriffsrechte`
|
||||
- `Cron Einstellungen`
|
||||
müssen für alle Module aus dieser gemeinsamen Setup-Logik kommen.
|
||||
- Nur `Custom Settings` darf modulspezifischen Inhalt enthalten.
|
||||
- Modul-spezifische Sonderlayouts für die Bereiche `Allgemein`, `Datenbank`, `Zugriffsrechte` oder `Cron Einstellungen` sind nicht erlaubt.
|
||||
|
||||
Was global im Setup bereits verfügbar ist:
|
||||
- gemeinsame Setup-Navigation mit festen Unterseiten
|
||||
- rechte Aktionsseite mit
|
||||
- `Nexus Übersicht`
|
||||
- `Zurück zum Modul`
|
||||
- gemeinsames Speichern pro Bereich
|
||||
- gemeinsames Rendering von
|
||||
- Textfeldern
|
||||
- Zahlenfeldern
|
||||
- Checkboxen
|
||||
- Selects
|
||||
- Multiselects
|
||||
- Textareas
|
||||
- globale `Debug aktivieren`-Option pro Modul im Bereich `Allgemein`
|
||||
- gemeinsame Datenbank-Logik mit
|
||||
- `Eigene Modul-DB nutzen`
|
||||
- Anzeige oder Verbergen der Custom-DB-Felder
|
||||
- optional mehreren DB-Gruppen wie `db.*` und `metadata_db.*`
|
||||
- `Verbindung testen`
|
||||
- `Standardwerte laden`
|
||||
- gemeinsame Auth- und Zugriffslogik mit
|
||||
- `Login erforderlich`
|
||||
- erlaubten Benutzern
|
||||
- erlaubten Gruppen
|
||||
- bekannten Keycloak-Benutzern und -Gruppen
|
||||
- gemeinsame Cron- und Scheduler-Logik mit
|
||||
- Anzeige von Intervall-Tasks
|
||||
- Anzeige von Cron-Jobs
|
||||
- Bearbeiten von Cron-Einträgen im Modal
|
||||
- Cron-Test direkt aus dem Setup
|
||||
- Mehrfacheinträgen bei `mode = multi`
|
||||
- Modul-Zeitzonen-Override für Crons
|
||||
- Vererbung globaler Cron-Zeitzonen-Defaults
|
||||
- gemeinsame Darstellung von Setup-Aktionen und Statusblöcken
|
||||
- globale Zeitzonen-Datalist aus `nexus_timezones`
|
||||
|
||||
6) Was ein Modul für das Setup liefern darf
|
||||
- `module.json` mit `setup.fields`
|
||||
- optional `setup.sections.database`
|
||||
- optional `interval_tasks`
|
||||
- optional `scheduler_jobs`
|
||||
- optional `auth`
|
||||
- optional Bootstrap-Funktionen:
|
||||
- `setup_actions`
|
||||
- `run_setup_action`
|
||||
- `setup_status`
|
||||
- `runtime_settings`
|
||||
- `save_runtime_settings`
|
||||
|
||||
7) Was ein Modul nicht selbst bauen darf
|
||||
- eigene Setup-Seitenstruktur für `Allgemein`, `Datenbank`, `Zugriffsrechte`, `Cron Einstellungen`
|
||||
- eigene DB-Toggle-Logik für Standard und Custom
|
||||
- eigene Cron-Editor-Grundlogik
|
||||
- eigene Debug-UI-Grundlogik
|
||||
- eigene globale Zeitzonen-Defaults
|
||||
|
||||
8) Setup-Navigation
|
||||
- Setup-Routen laufen zentral über:
|
||||
- `/modules/setup/<modul>/general`
|
||||
- `/modules/setup/<modul>/database`
|
||||
- `/modules/setup/<modul>/access`
|
||||
- `/modules/setup/<modul>/cron`
|
||||
- `/modules/setup/<modul>/custom`
|
||||
- `Setup` gehört in der Modulansicht in die rechte Aktionsseite der Submenü-Box
|
||||
|
||||
9) Steuerung per `module.json`
|
||||
- Ein Modul kann über `setup.sections.database: true|false` steuern, ob der Menüpunkt `Datenbank` angezeigt wird.
|
||||
- Wenn `setup.sections.database` fehlt, kann die zentrale Setup-Logik den Punkt implizit aktivieren, sobald DB-Felder vorhanden sind.
|
||||
- Modulfelder für `Allgemein`, `Datenbank`, `Cron` und `Custom Settings` werden zentral nach Feldnamen aufgeteilt:
|
||||
- `debug_enabled` -> `Allgemein`
|
||||
- `use_separate_db` und `db.*` oder `metadata_db.*` -> `Datenbank`
|
||||
- `schedule_timezone` -> `Cron Einstellungen`
|
||||
- alle übrigen Setup-Felder -> `Custom Settings`
|
||||
|
||||
Weitere anerkannte Setup-Bausteine:
|
||||
- `interval_tasks`
|
||||
- `scheduler_jobs`
|
||||
- `auth`
|
||||
- `db_defaults`
|
||||
- `metadata_db_defaults`
|
||||
|
||||
10) Speicherregel
|
||||
- Beim Speichern eines Setup-Bereichs dürfen nur die in diesem Bereich sichtbaren Felder aktualisiert werden.
|
||||
- Felder aus anderen Bereichen dürfen nicht mit `null`, `0` oder leeren Strings überschrieben werden.
|
||||
|
||||
11) Datenbankbereich
|
||||
- `Eigene Modul-DB nutzen` ist der zentrale Standard-Schalter für Module mit optionaler eigener DB.
|
||||
- Wenn der Schalter deaktiviert ist, dürfen keine Custom-DB-Eingabefelder sichtbar sein.
|
||||
- Datenbankaktionen und Tabellenstatus gehören in den Menüpunkt `Datenbank`, nicht in `Custom Settings`.
|
||||
|
||||
12) Globale PHP-Helfer für Module
|
||||
- Neue Module sollen für zentrale Zeit- und Debug-Defaults nach Möglichkeit die globalen Funktionen aus `src/App/functions.php` nutzen:
|
||||
- `nexus_settings()`
|
||||
- `nexus_save_settings(array $settings)`
|
||||
- `nexus_system_timezone_name()`
|
||||
- `nexus_display_timezone_name()`
|
||||
- `nexus_cron_timezone_name()`
|
||||
- `module_debug_enabled(string $module)`
|
||||
- `module_debug_push(string $module, array $entry)`
|
||||
- `module_debug_clear(string $module)`
|
||||
|
||||
13) Regeln für neue Module
|
||||
- Keine Zeitzone wie `Europe/Berlin` hart im Modul als Standard erzwingen, wenn dafür ein globaler Nexus-Default existiert.
|
||||
- Für Anzeige- und Formatierungslogik nach Möglichkeit `nexus_display_timezone_name()` nutzen.
|
||||
- Für Cron-Fallbacks nach Möglichkeit `nexus_cron_timezone_name()` nutzen.
|
||||
- Neue Module dürfen keine lokalen Zeitzonen direkt in Datenbank-Zeitspalten persistieren.
|
||||
|
||||
14) Pi-Control-Besonderheiten
|
||||
- Worker und Jobs unter `/tools/pi_control/`
|
||||
- Check-Updates und Cron nutzen die gleichen SSH-Routinen
|
||||
- Host-Karten, Befehle und Konsole sind UI im Modul
|
||||
- Update- und Upgrade-Checks liefern Debug-Ausgaben, die als Tooltip oder Debugzeile angezeigt werden
|
||||
119
NEXUS_SYSTEM.md
Normal file
119
NEXUS_SYSTEM.md
Normal file
@@ -0,0 +1,119 @@
|
||||
Projektkontext: Nexus-System
|
||||
|
||||
1) Projekt-Zweck und Ziel
|
||||
- Nexus ist ein zentrales, webbasiertes Admin-Interface zur Steuerung einer dynamischen IT-Infrastruktur.
|
||||
- Module kapseln fachliche Funktionen, das Nexus-System stellt das globale Grundgerüst bereit.
|
||||
- Das generelle Nexus-System wird unabhängig von den bestehenden Fachmodulen weiterentwickelt.
|
||||
|
||||
2) Umgebungen und Domains
|
||||
- Live: `nexus.kusche.berlin`
|
||||
- Staging: `staging.nexus.kusche.berlin`
|
||||
|
||||
Container- und Deploy-Layout:
|
||||
- `/app/live/` -> Live-Code
|
||||
- `/app/staging/` -> Staging-Code
|
||||
- jeweils mit eigenem `config`-Unterordner:
|
||||
- `/app/live/config/`
|
||||
- `/app/staging/config/`
|
||||
|
||||
Repo-Layout zu Configs:
|
||||
- `/config/live/` und `/config/staging/` liegen im Repo
|
||||
- beim Deployment werden die Dateien daraus nach `/app/<env>/config` kopiert
|
||||
- wichtig: im laufenden Container existiert `/app/config/` nicht, sondern nur `/app/live/config` und `/app/staging/config`
|
||||
|
||||
3) Verzeichnisstruktur
|
||||
- `/public/` -> Web Root mit globalen Assets
|
||||
- `/api/` -> Backend- und API-Endpunkte
|
||||
- `/src/` -> PHP-Kernklassen und Utilities
|
||||
- `/tools/` -> CLI, Worker und Jobs
|
||||
- `/config/` -> Umgebungs-Configs
|
||||
- `/modules/<modul>/` -> klassische Nexus-Module
|
||||
- `/partials/structure/` -> Header, Footer, Menüs
|
||||
- `/partials/landingpages/` -> komplette Seitenlayouts
|
||||
- `/debug/` -> Custom Logs
|
||||
|
||||
4) Code-Änderungen nach Scope
|
||||
- globale Layouts: `/partials/structure/` und `/public/assets/css/app.css`
|
||||
- Konfigurationslogik: nur wenn nötig `/config/` und `/src/`
|
||||
- Modul-Aufgaben: nur `/modules/<modul>/` und gegebenenfalls `/tools/<modul>`
|
||||
|
||||
5) UI-Naming und Seitenaufbau
|
||||
- `Seitenheader-Box`: oberste globale Header-Box mit Seitentitel, Login und Farbschema
|
||||
- `Submenü-Box`: Box direkt unter der Seitenheader-Box für modul- oder seitenbezogene Aktionen
|
||||
- `Submenü-Aktionen`: rechtsbündige Zusatzbuttons innerhalb der Submenü-Box, z.B. `Setup` oder `Nexus Übersicht`
|
||||
- `Bereichs-Box`: größere Inhaltsbox für einen zusammenhängenden Seitenbereich
|
||||
- `Karten-Box`: kleinere Karte auf derselben Ebene wie Bereichs-Boxen, meist in Grids
|
||||
|
||||
Zentrale CSS-Klassen:
|
||||
- `main-header-box`
|
||||
- `submenu-box`
|
||||
- `module-submenu-actions`
|
||||
- `section-box`
|
||||
- `card-box`
|
||||
|
||||
Globale Struktur:
|
||||
- zuerst Seitenheader-Box
|
||||
- danach Submenü-Box
|
||||
- danach Bereichs-Boxen und/oder Karten-Boxen je nach Seite
|
||||
|
||||
Layout-Regeln:
|
||||
- vertikale Abstände zwischen `main-header-box`, `submenu-box` und den ersten Folge-Boxen müssen aus der globalen Shell kommen
|
||||
- maßgeblich sind `module-page-bg` und `module-page-stack` in `public/assets/css/app.css`
|
||||
- Top-Level-Wrapper wie Grids, Kartencontainer oder Modul-Listen dürfen keinen zusätzlichen `margin-top` oder Sonder-Gap erzeugen, der den Abstand nach dem Submenü verändert
|
||||
- bei Layout-Reviews ist explizit zu prüfen, ob `Main-Header -> Submenü -> erste Section/Card` optisch denselben Rhythmus hat wie auf Referenzseiten
|
||||
- die Optik der Submenü-Aktionsbuttons kommt ausschließlich aus dem globalen CSS
|
||||
|
||||
Beispielstruktur:
|
||||
- Börsenchecker: Seitenheader-Box, Submenü-Box, Bereichs-Box, Karten-Boxen, Bereichs-Box
|
||||
- FX-Rates: Seitenheader-Box, Submenü-Box, danach Bereichs-Boxen
|
||||
- Mining-Checker: Seitenheader-Box, Submenü-Box, Bereichs-Box, Karten-Boxen, Karten-Boxen, Bereichs-Box
|
||||
- Modulverwaltung: Seitenheader-Box, Submenü-Box, danach Karten-Boxen
|
||||
|
||||
6) Nexus-Kerngerüst
|
||||
- Der aktuelle Ausbauschritt betrifft das generelle Nexus-System, nicht die bestehenden Fachmodule.
|
||||
- Bestehende Module sind funktional und strukturell zunächst ausgenommen und dürfen durch Arbeiten am Kerngerüst nicht beeinträchtigt werden.
|
||||
- Neue Kernfunktionen müssen parallel zum bestehenden Modulsystem eingeführt werden.
|
||||
- Zielbild ist ein Nexus-Grundsystem mit flexiblen Benutzer-Dashboards, Integrationen und datengetriebenen Seitenmodulen.
|
||||
|
||||
Produktprinzip:
|
||||
- Nexus ist nicht nur Modul-Launcher, sondern ein persönliches und gruppenfähiges Dashboard-System.
|
||||
- Benutzer sollen eigene Dashboards anlegen, anordnen und konfigurieren können.
|
||||
- Inhalte auf Dashboards sollen aus drei Quellen kommen können:
|
||||
- interne Nexus-Funktionen
|
||||
- externe Integrationen
|
||||
- einfache Seitenmodule ohne eigene Modul-Implementierung
|
||||
|
||||
Abgrenzung zu bestehenden Modulen:
|
||||
- das Verzeichnis `/modules/<modul>/` bleibt das Zuhause klassischer Nexus-Module
|
||||
- das Dashboard-System ist ein zusätzliches globales Kernsystem
|
||||
- neue Kernfunktionen müssen ohne Umbau bestehender Module lauffähig sein
|
||||
|
||||
7) Globale Zeitzonen-Logik
|
||||
- globale Nexus-Einstellungen liegen unter `/settings`
|
||||
- dort werden zentral gepflegt:
|
||||
- `Anzeige-Zeitzone`
|
||||
- `Standard-Zeitzone für Crons`
|
||||
|
||||
Regeln:
|
||||
- ohne Custom bei der Anzeige-Zeitzone wird die System-Zeitzone verwendet
|
||||
- die aktive Anzeige-Zeitzone soll angezeigt werden
|
||||
- die Standard-Zeitzone für Crons ist der globale Default für Modul-Crons
|
||||
- Module dürfen diese Zeitzone im Setup übersteuern
|
||||
- einzelne Cron-Einträge dürfen sie ebenfalls übersteuern
|
||||
|
||||
UTC-Speicherregel:
|
||||
- Zeitwerte sollen projektweit intern immer in `UTC` gespeichert werden
|
||||
- Anzeige-Zeitzonen dienen nur der Darstellung für Benutzer
|
||||
- Cron-Zeitzonen dienen nur der lokalen Auswertung von Zeitplänen und Fälligkeiten
|
||||
- beim Einlesen lokaler Eingaben muss vor dem Speichern nach `UTC` normalisiert werden
|
||||
- beim Anzeigen gespeicherter Werte muss von `UTC` in die jeweils wirksame Anzeige-Zeitzone umgerechnet werden
|
||||
|
||||
8) Globales Debug-System
|
||||
- das Debug-Popup ist eine globale Infrastruktur aus dem zentralen Layout
|
||||
- die Aktivierung bleibt pro Modul über `debug_enabled` im Modul-Setup steuerbar
|
||||
- das Debug-Symbol darf nur sichtbar sein, wenn für das aktuelle Modul Debug aktiv ist
|
||||
- Module sollen keine eigene separate Debug-Oberfläche bauen, wenn der globale Debug-Stream genutzt werden kann
|
||||
|
||||
9) Sicherheits- und Netzwerk-Constraints
|
||||
- Zugriff im Heimnetz `192.168.178.0/24` per Nginx begrenzt
|
||||
- SSH-Hosts nur Heimnetz
|
||||
@@ -1,71 +1,41 @@
|
||||
Projekt-Zusammenfassung: Nexus Control Panel
|
||||
|
||||
1) Projekt-Zweck & Ziel
|
||||
Nexus ist ein zentrales, webbasiertes Admin-Interface zur Steuerung einer dynamischen IT-Infrastruktur.
|
||||
Module kapseln fachliche Funktionen (z.B. KEA DHCP, Pi Control).
|
||||
Diese Datei ist ab jetzt der zentrale Einstieg und verweist auf die drei maßgeblichen Dokumentationsbereiche.
|
||||
|
||||
2) Aktive Module (Kurzüberblick)
|
||||
- KEA DHCP: Verwaltung von Hosts/Leases, Statische Reservierungen, Metadaten.
|
||||
- Pi Control: Verwaltung von SSH-Hosts, Befehle/Preset, Konsole, Host-Status, Update/Upgrade-Checks.
|
||||
1) Nexus-System
|
||||
- Datei: [NEXUS_SYSTEM.md](NEXUS_SYSTEM.md)
|
||||
- Inhalt:
|
||||
- globales Grundgerüst von Nexus
|
||||
- Umgebungen, Verzeichnisstruktur und globale Scope-Regeln
|
||||
- UI-Naming mit Seitenheader-Box, Submenü-Box, Bereichs-Box und Karten-Box
|
||||
- globale Zeitzonen- und Debug-Regeln
|
||||
- Kerngerüst für das zukünftige Dashboard-System
|
||||
|
||||
3) Umgebungen & Domains
|
||||
- Live: nexus.int.kusche.berlin
|
||||
- Staging: staging.nexus.int.kusche.berlin
|
||||
2) Modul-Entwicklung
|
||||
- Datei: [MODULE_DEVELOPMENT.md](MODULE_DEVELOPMENT.md)
|
||||
- Inhalt:
|
||||
- Regeln für klassische Module unter `/modules/<modul>/`
|
||||
- Setup-System und zulässige Modulbausteine
|
||||
- globale Vorgaben für Modul-Layouts, Assets und Helper
|
||||
- Abgrenzung zwischen Modul-Code und globalem Nexus-System
|
||||
|
||||
Container/Deploy-Layout:
|
||||
- /app/live/ -> Live-Code
|
||||
- /app/staging/ -> Staging-Code
|
||||
- Jeweils mit eigenem /config-Unterordner:
|
||||
- /app/live/config/
|
||||
- /app/staging/config/
|
||||
3) Widget-Einbindung und Integrationen
|
||||
- Datei: [WIDGET_INTEGRATION.md](WIDGET_INTEGRATION.md)
|
||||
- Inhalt:
|
||||
- Benutzer-Dashboards
|
||||
- Dashboard-Elemente und Widget-Typen
|
||||
- Integrationen zu Fremdsystemen
|
||||
- on-the-fly Seitenmodule
|
||||
- Kollisionsschutz zu bestehenden Modulen
|
||||
- empfohlene Umsetzungsphasen
|
||||
|
||||
Repo-Layout zu Configs:
|
||||
- /config/live/ und /config/staging/ liegen im Repo.
|
||||
- Beim Deployment werden die Dateien daraus nach /app/<env>/config kopiert.
|
||||
- WICHTIG: Im laufenden Container existiert /app/config/ NICHT, sondern nur /app/live/config und /app/staging/config.
|
||||
4) Wichtige Leitplanken
|
||||
- Änderungen am generellen Nexus-System dürfen nicht in bestehende Module eingreifen, wenn das nicht ausdrücklich verlangt ist.
|
||||
- Klassische Fachmodule und das neue Dashboard- oder Widget-System sind getrennte Ebenen.
|
||||
- Modulspezifische Assets gehören weiterhin ausschließlich in den jeweiligen Modulordner.
|
||||
- Globale Layout- und Designregeln bleiben zentral in `public/assets/css/app.css` und den globalen Partials.
|
||||
|
||||
4) Verzeichnisstruktur (Repo)
|
||||
- /public/ -> Web Root (index.php, globale Assets)
|
||||
- /api/ -> Backend/API-Endpunkte
|
||||
- /src/ -> PHP-Kernklassen/Utilities
|
||||
- /tools/ -> CLI/Worker/Jobs (z.B. tools/pi_control)
|
||||
- /config/ -> Umgebungs-Configs (live/staging)
|
||||
- /modules/<modul>/ -> Module (Pages, Assets, Bootstrap)
|
||||
- /partials/structure/ -> Header/Footer/Menüs
|
||||
- /partials/landingpages/ -> Komplette Seitenlayouts
|
||||
- /debug/ -> Custom Logs
|
||||
|
||||
5) Modul-Konzept (WICHTIG)
|
||||
- Jedes Modul lebt unter /modules/<modulname>/.
|
||||
- Pflicht-Dateien i.d.R.:
|
||||
- module.json
|
||||
- bootstrap.php (Schema/Setup)
|
||||
- pages/*.php (UI/Endpoints)
|
||||
- assets/* (modulspezifisches CSS/JS)
|
||||
|
||||
Modulspezifische Assets:
|
||||
- Modul-JS/CSS MUSS im Modul-Ordner liegen.
|
||||
- Laden über Modul-Assets, z.B.:
|
||||
- $assets->addStyle('/module/pi_control/asset?file=pi_control.css');
|
||||
- $assets->addScript('/module/pi_control/asset?file=hosts.js', 'footer', true);
|
||||
- KEINE modulspezifischen Änderungen in /public/assets/*.
|
||||
|
||||
6) Code-Änderungen nach Scope
|
||||
- Modul-Aufgaben: nur /modules/<modul>/ + ggf. /tools/<modul> ändern.
|
||||
- Globale Layouts: /partials/structure und /public/assets/css/app.css.
|
||||
- Konfigurationslogik: nur wenn nötig /config/ und /src/.
|
||||
|
||||
7) Sicherheits-/Netzwerk-Constraints
|
||||
- Zugriff im Heimnetz (192.168.178.0/24) per Nginx begrenzt.
|
||||
- SSH-Hosts nur Heimnetz.
|
||||
|
||||
8) Pi Control Besonderheiten (konkret)
|
||||
- Worker/Jobs unter /tools/pi_control/
|
||||
- Check-Updates & Cron nutzen die gleichen SSH-Routinen.
|
||||
- Host-Karten, Befehle und Konsole sind UI im Modul.
|
||||
- Update/Upgrade-Checks liefern Debug-Ausgaben, die als Tooltip oder Debugzeile angezeigt werden.
|
||||
|
||||
9) Zusammenfassung (kurz)
|
||||
Nexus ist modular, mit strikter Trennung zwischen globalem Layout und modulspezifischem Code.
|
||||
Staging/Live haben eigene /app/<env>/config-Strukturen; /config/<env> im Repo wird beim Deployment kopiert.
|
||||
Modul-Assets gehören ausschließlich in den Modul-Ordner und werden dort geladen.
|
||||
5) Für neue Chats und Arbeitsaufträge
|
||||
- Für globale Nexus-Themen zuerst `NEXUS_SYSTEM.md` lesen.
|
||||
- Für Arbeiten an klassischen Modulen zuerst `MODULE_DEVELOPMENT.md` lesen.
|
||||
- Für Dashboard-, Widget- oder Integrationsarbeiten zuerst `WIDGET_INTEGRATION.md` lesen.
|
||||
|
||||
58
README.md
58
README.md
@@ -1,6 +1,64 @@
|
||||
# Nexus
|
||||
# Comment by Lars
|
||||
|
||||
## UI-Naming
|
||||
|
||||
Für die Oberfläche gilt projektweit dieses Naming:
|
||||
|
||||
- `Seitenheader-Box`: globaler Header mit Seitentitel, Login und Farbschema
|
||||
- `Submenü-Box`: zusätzliche modul- oder seitenbezogene Aktionen direkt unter dem Seitenheader; `Setup` soll in Modulen immer vorhanden sein
|
||||
- `Submenü-Aktionen`: rechtsbündige Zusatzbuttons innerhalb der Submenü-Box, z.B. `Setup` oder `Nexus Übersicht`
|
||||
- `Bereichs-Box`: größere Inhaltsbox für einen zusammenhängenden Bereich; davon können beliebig viele untereinander folgen
|
||||
- `Karten-Box`: kleinere Inhaltskarte auf derselben Ebene wie Bereichs-Boxen, typischerweise innerhalb eines Grids für Kennzahlen, Statistiken oder Modulübersichten
|
||||
|
||||
Zentrale CSS-Klassen für dieses Layout:
|
||||
|
||||
- `main-header-box`
|
||||
- `submenu-box`
|
||||
- `module-submenu-actions`
|
||||
- `section-box`
|
||||
- `card-box`
|
||||
|
||||
Beispiele:
|
||||
|
||||
- `Börsenchecker`: Seitenheader-Box, Submenü-Box, Bereichs-Box, Karten-Boxen, Bereichs-Box
|
||||
- `FX-Rates`: Seitenheader-Box, Submenü-Box, danach Bereichs-Boxen
|
||||
- `Mining-Checker`: Seitenheader-Box, Submenü-Box, Bereichs-Box, Karten-Boxen, Karten-Boxen, Bereichs-Box
|
||||
- `Modulverwaltung`: Seitenheader-Box, Submenü-Box, danach Karten-Boxen
|
||||
|
||||
Technisch:
|
||||
|
||||
- globale Shell und Header in `partials/structure/` und `public/assets/css/app.css`
|
||||
- modulbezogene Inhalte und Assets ausschließlich unter `modules/<modul>/`
|
||||
- vertikale Abstände zwischen `main-header-box`, `submenu-box` und den ersten Folge-Boxen müssen aus der globalen Shell kommen; Top-Level-Grids oder Wrapper dürfen dort keinen zusätzlichen `margin-top` oder Sonderabstand einführen
|
||||
- bei Layout-Checks ist ausdrücklich zu prüfen, ob `Main-Header -> Submenü -> erste Section/Card` denselben Rhythmus hat wie auf Referenzseiten
|
||||
- die Optik von Submenü-Aktionen kommt ausschließlich aus dem globalen CSS; Module sollen dort keine eigenen Farbvarianten definieren
|
||||
|
||||
## Nexus-Kerngerüst
|
||||
|
||||
Das generelle Nexus-System wird unabhängig von den bestehenden Fachmodulen weiterentwickelt. Aktuell gilt:
|
||||
|
||||
- bestehende Module unter `modules/<modul>/` bleiben zunächst unberührt
|
||||
- neue Kernfunktionen müssen kollisionsfrei parallel zum Modulsystem aufgebaut werden
|
||||
- Zielbild ist ein flexibles Dashboard-System im Stil moderner Startseitenlösungen mit:
|
||||
- mehreren Dashboards pro Benutzer
|
||||
- frei platzierbaren Dashboard-Elementen
|
||||
- zentralen Integrationen zu Fremdsystemen
|
||||
- datengetriebenen Seitenmodulen ohne eigenen Modulordner
|
||||
|
||||
Die maßgeblichen Begriffe und Regeln dafür stehen in:
|
||||
|
||||
- `PROJECT_CONTEXT.md` als Einstieg
|
||||
- `NEXUS_SYSTEM.md`
|
||||
- `MODULE_DEVELOPMENT.md`
|
||||
- `WIDGET_INTEGRATION.md`
|
||||
|
||||
Wichtig:
|
||||
|
||||
- Integrationen sind globale Systembausteine, keine Module
|
||||
- Seitenmodule sind on the fly konfigurierbare Inhalte ohne Code unter `modules/`
|
||||
- spätere Dashboard-Anbindungen bestehender Module sollen nur über definierte Adapter oder Provider erfolgen
|
||||
|
||||
|
||||
## Getting started
|
||||
|
||||
|
||||
126
WIDGET_INTEGRATION.md
Normal file
126
WIDGET_INTEGRATION.md
Normal file
@@ -0,0 +1,126 @@
|
||||
Projektkontext: Widget-Einbindung und Integrationen
|
||||
|
||||
1) Zielbild
|
||||
- Nexus soll ein flexibles Dashboard-System im Stil moderner Startseitenlösungen werden.
|
||||
- Benutzer sollen eigene Dashboards anlegen, anordnen und konfigurieren können.
|
||||
- Inhalte auf Dashboards sollen aus internen Nexus-Funktionen, externen Integrationen und Seitenmodulen kommen können.
|
||||
|
||||
2) Begriffe
|
||||
- `Dashboard`
|
||||
- eine benutzerspezifische oder freigegebene Übersichtsseite
|
||||
- `Dashboard-Element`
|
||||
- ein platzierbares Objekt innerhalb eines Dashboards
|
||||
- `Widget-Typ`
|
||||
- beschreibt, welche Art Element gerendert wird
|
||||
- `Integration`
|
||||
- zentrale Anbindung an ein externes System
|
||||
- `Seitenmodul`
|
||||
- datengetriebenes, on-the-fly angelegtes Modul ohne eigenen Code-Ordner unter `/modules/`
|
||||
|
||||
3) Anforderungen an Benutzer-Dashboards
|
||||
- jeder Benutzer soll mehrere eigene Dashboards anlegen können
|
||||
- jedes Dashboard braucht mindestens:
|
||||
- Name
|
||||
- Slug oder technische ID
|
||||
- Eigentümer
|
||||
- Sichtbarkeit
|
||||
- Sortierung
|
||||
- optional Standardstatus
|
||||
- Dashboards sollen perspektivisch auch teilbar sein:
|
||||
- privat
|
||||
- gruppenbasiert
|
||||
- optional global sichtbar
|
||||
- ein Benutzer soll seine Dashboards selbst umsortieren, umbenennen, duplizieren und löschen können
|
||||
- Dashboard-Konfigurationen müssen datenbankbasiert gespeichert werden, nicht in Moduldateien
|
||||
|
||||
4) Anforderungen an Dashboard-Layout und Editor
|
||||
- Dashboards müssen vom Benutzer flexibel bearbeitet werden können.
|
||||
- Jedes Dashboard-Element braucht mindestens:
|
||||
- Spaltenbreite
|
||||
- Höhe oder Zeilenspanne
|
||||
- Position im Grid
|
||||
- gerätespezifische Layoutdaten, sobald Mobile/Desktop getrennt unterstützt werden
|
||||
- der Editor soll ein frei konfigurierbares Grid unterstützen
|
||||
- Größen und Positionen müssen pro Element speicherbar sein
|
||||
- ein Wechsel zwischen Anzeige-Modus und Bearbeiten-Modus ist vorzusehen
|
||||
- das Layout-System dieses Editors ist globales Nexus-Kerngerüst und darf nicht in einzelnen Modulen dupliziert werden
|
||||
|
||||
5) Widget-Typen V1
|
||||
- `link`
|
||||
- einzelner Verweis zu einer internen oder externen Seite
|
||||
- `iframe`
|
||||
- eingebettete Webseite oder Tool-Oberfläche
|
||||
- `bookmark_group`
|
||||
- Sammlung mehrerer Links oder Schnellzugriffe
|
||||
- `external_status`
|
||||
- kompakte Zustandsanzeige aus einer Integration
|
||||
- perspektivisch zusätzlich:
|
||||
- Kennzahl
|
||||
- Integrationsstatus
|
||||
- Modulansicht im Kleinformat
|
||||
|
||||
6) Integrationen als Kernsystem
|
||||
- Integrationen sind keine Module und keine Widgets, sondern eine eigene Systemschicht.
|
||||
- Eine Integration stellt Verbindung, Zugangsdaten, Basis-URL und technische Einstellungen für ein Fremdsystem bereit.
|
||||
- Widgets oder Seitenmodule greifen nicht direkt auf Rohkonfigurationen zu, sondern auf eine definierte Integration.
|
||||
- Zugangsdaten müssen zentral und getrennt von Widget-Konfigurationen gespeichert werden.
|
||||
- Eine Integration soll mehrfach anlegbar sein, zum Beispiel mehrere Home-Assistant- oder Pi-hole-Instanzen.
|
||||
- Integrationen müssen benennbar und für Benutzer oder Gruppen freigebbar sein.
|
||||
|
||||
Beispiele für Integrationen:
|
||||
- Home Assistant
|
||||
- Pi-hole
|
||||
- Proxmox
|
||||
- Docker
|
||||
- Arr-Tools
|
||||
|
||||
7) Seitenmodule on the fly
|
||||
- Ein Seitenmodul ist ein vom Benutzer oder Administrator angelegtes Objekt ohne eigenen Programmcode.
|
||||
- Seitenmodule sind Teil des Nexus-Kerngerüsts und nicht Teil des klassischen Modulordners.
|
||||
- Ein Seitenmodul kann mindestens folgende Typen haben:
|
||||
- `link`
|
||||
- `iframe`
|
||||
- `bookmark_group`
|
||||
- `external_status`
|
||||
- Seitenmodule sollen in Dashboards eingebunden werden können.
|
||||
- Seitenmodule sollen optional auch in der allgemeinen Nexus-Übersicht auftauchen können.
|
||||
- Seitenmodule dürfen keine globale CSS- oder Layoutlogik mitbringen; sie müssen auf dem zentralen Dashboard- und Widget-System aufsetzen.
|
||||
|
||||
8) V1-Datenmodell
|
||||
- Für das generelle Nexus-System sollen neue zentrale Tabellen vorgesehen werden.
|
||||
- Empfohlene V1-Struktur:
|
||||
- `nexus_dashboards`
|
||||
- Dashboard-Metadaten
|
||||
- `nexus_dashboard_items`
|
||||
- platzierte Elemente pro Dashboard
|
||||
- `nexus_integrations`
|
||||
- technische Integrationen und Verbindungsdaten
|
||||
- `nexus_page_modules`
|
||||
- on-the-fly angelegte Seitenmodule
|
||||
- `nexus_dashboard_shares`
|
||||
- optionale Freigaben für Benutzer oder Gruppen
|
||||
- JSON-Konfigurationen sind für flexible Widget- oder Layoutoptionen erlaubt, aber Kerneigenschaften wie Eigentümer, Typ, Position und Sichtbarkeit sollen eigene Spalten behalten.
|
||||
|
||||
9) Kollisionsschutz zu Modulen
|
||||
- Änderungen am Nexus-Kerngerüst dürfen nicht voraussetzen, dass bestehende Module sofort migriert werden.
|
||||
- Neue Tabellen, Services und Seiten müssen unter globalen Namen aufgebaut werden, nicht unter einem Modulpräfix.
|
||||
- Bestehende Modulrouten, Modul-Assets und Modul-Setups dürfen durch neue Dashboard-Funktionen nicht ersetzt werden.
|
||||
- Integrationen sind global zu denken und dürfen nicht als versteckte Modul-Features in einzelne Module eingestreut werden.
|
||||
- Wenn ein bestehendes Modul später Widgets für Dashboards anbietet, muss das über definierte Adapter oder Provider geschehen, nicht über direkte Eingriffe in das Modul-Layout.
|
||||
|
||||
10) Empfohlene Umsetzungsreihenfolge
|
||||
- Phase 1:
|
||||
- zentrale Begriffe und Datenmodelle festlegen
|
||||
- globale Tabellen für Dashboards, Dashboard-Elemente, Integrationen und Seitenmodule einführen
|
||||
- globale Seiten für Dashboard-Verwaltung anlegen
|
||||
- Phase 2:
|
||||
- erstes Dashboard pro Benutzer
|
||||
- einfacher Grid-Editor
|
||||
- erste Widget-Typen `link`, `iframe`, `bookmark_group`
|
||||
- Phase 3:
|
||||
- Integrationsverwaltung
|
||||
- erste Integrationsadapter, zum Beispiel Home Assistant
|
||||
- widgetfähige Abfrage von externen Daten
|
||||
- Phase 4:
|
||||
- Freigaben, Gruppenrechte, Dashboard-Duplikate
|
||||
- spätere optionale Anbindung bestehender Module über definierte Widget-Provider
|
||||
@@ -4,7 +4,7 @@ declare(strict_types=1);
|
||||
// Example: a single "brand" domain name.
|
||||
// In real deployments you might derive this from ENV or hostnames.
|
||||
if (!defined('APP_DOMAIN_NAME')) {
|
||||
define('APP_DOMAIN_NAME', 'nexus.int.kusche.berlin');
|
||||
define('APP_DOMAIN_NAME', 'nexus.kusche.berlin');
|
||||
}
|
||||
|
||||
if (!defined('APP_PREFIX')) {
|
||||
|
||||
@@ -8,7 +8,6 @@
|
||||
define('APP_DB_AUTO_INIT', true);
|
||||
define('APP_KEA_DB_VERSION', '2.6.3');
|
||||
define('APP_BASE_DB_ENABLED', true);
|
||||
define('APP_BASIC_AUTH', false);
|
||||
define('APP_SEARCH_DEBUG', false);
|
||||
define('APP_AUTH_ENABLED', true);
|
||||
define('APP_OIDC_ISSUER', 'https://auth.kusche.berlin/realms/KuscheBerlin');
|
||||
@@ -18,8 +17,8 @@
|
||||
define('APP_OIDC_LOGOUT_ENDPOINT', 'https://auth.kusche.berlin/realms/KuscheBerlin/protocol/openid-connect/logout');
|
||||
define('APP_OIDC_CLIENT_ID', 'nexus');
|
||||
define('APP_OIDC_CLIENT_SECRET', 'c0swC5wjBV4yimJHf2p3R9OjHOr7rhHs');
|
||||
define('APP_OIDC_REDIRECT_URI', 'https://nexus.int.kusche.berlin/auth/callback');
|
||||
define('APP_OIDC_POST_LOGOUT_REDIRECT_URI', 'https://nexus.int.kusche.berlin/');
|
||||
define('APP_OIDC_REDIRECT_URI', 'https://nexus.kusche.berlin/auth/callback');
|
||||
define('APP_OIDC_POST_LOGOUT_REDIRECT_URI', 'https://nexus.kusche.berlin/');
|
||||
define('APP_OIDC_GROUP_CLAIM', 'groups');
|
||||
define('APP_OIDC_ADMIN_GROUP', 'appadmin');
|
||||
define('APP_OIDC_USER_GROUP', 'internalfamily');
|
||||
|
||||
@@ -4,7 +4,7 @@ declare(strict_types=1);
|
||||
// Example: a single "brand" domain name.
|
||||
// In real deployments you might derive this from ENV or hostnames.
|
||||
if (!defined('APP_DOMAIN_NAME')) {
|
||||
define('APP_DOMAIN_NAME', 'staging.nexus.int.kusche.berlin');
|
||||
define('APP_DOMAIN_NAME', 'staging.nexus.kusche.berlin');
|
||||
}
|
||||
|
||||
if (!defined('APP_PREFIX')) {
|
||||
|
||||
@@ -8,7 +8,6 @@
|
||||
define('APP_DB_AUTO_INIT', true);
|
||||
define('APP_KEA_DB_VERSION', '2.6.3');
|
||||
define('APP_BASE_DB_ENABLED', true);
|
||||
define('APP_BASIC_AUTH', true);
|
||||
define('APP_SEARCH_DEBUG', true);
|
||||
define('APP_AUTH_ENABLED', true);
|
||||
define('APP_OIDC_ISSUER', 'https://auth.kusche.berlin/realms/KuscheBerlin');
|
||||
@@ -18,8 +17,8 @@
|
||||
define('APP_OIDC_LOGOUT_ENDPOINT', 'https://auth.kusche.berlin/realms/KuscheBerlin/protocol/openid-connect/logout');
|
||||
define('APP_OIDC_CLIENT_ID', 'nexus');
|
||||
define('APP_OIDC_CLIENT_SECRET', 'c0swC5wjBV4yimJHf2p3R9OjHOr7rhHs');
|
||||
define('APP_OIDC_REDIRECT_URI', 'https://staging.nexus.int.kusche.berlin/auth/callback');
|
||||
define('APP_OIDC_POST_LOGOUT_REDIRECT_URI', 'https://staging.nexus.int.kusche.berlin/');
|
||||
define('APP_OIDC_REDIRECT_URI', 'https://staging.nexus.kusche.berlin/auth/callback');
|
||||
define('APP_OIDC_POST_LOGOUT_REDIRECT_URI', 'https://staging.nexus.kusche.berlin/');
|
||||
define('APP_OIDC_GROUP_CLAIM', 'groups');
|
||||
define('APP_OIDC_ADMIN_GROUP', 'appadmin');
|
||||
define('APP_OIDC_USER_GROUP', 'internalfamily');
|
||||
|
||||
341
modules/boersenchecker/assets/boersenchecker.css
Normal file
341
modules/boersenchecker/assets/boersenchecker.css
Normal file
@@ -0,0 +1,341 @@
|
||||
.bc-page {
|
||||
--bc-accent: var(--brand-accent);
|
||||
--bc-accent-strong: var(--brand-accent-2);
|
||||
--bc-ink: var(--text);
|
||||
--bc-text: var(--text);
|
||||
--bc-muted: var(--muted);
|
||||
--bc-line: var(--line);
|
||||
--bc-panel: rgba(255, 255, 255, 0.78);
|
||||
--bc-panel-soft: rgba(248, 252, 252, 0.92);
|
||||
--bc-positive: #137333;
|
||||
--bc-negative: #c62828;
|
||||
color: var(--bc-text);
|
||||
}
|
||||
|
||||
.bc-page {
|
||||
display: grid;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.bc-text {
|
||||
color: var(--bc-muted);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.bc-section-head {
|
||||
display: flex;
|
||||
align-items: start;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.bc-section-title {
|
||||
margin: 0;
|
||||
font-size: 1.45rem;
|
||||
line-height: 1.15;
|
||||
}
|
||||
|
||||
.bc-section-head p,
|
||||
.bc-section-copy {
|
||||
color: var(--bc-muted);
|
||||
margin: 8px 0 0;
|
||||
}
|
||||
|
||||
.bc-form-card,
|
||||
.bc-panel,
|
||||
.bc-stat,
|
||||
.bc-chart-card,
|
||||
.bc-position-row {
|
||||
border: 1px solid var(--bc-line);
|
||||
border-radius: 22px;
|
||||
background: var(--bc-panel);
|
||||
box-shadow: 0 10px 24px rgba(1, 22, 32, 0.06);
|
||||
}
|
||||
|
||||
.bc-form-card,
|
||||
.bc-panel,
|
||||
.bc-chart-card {
|
||||
padding: 18px;
|
||||
}
|
||||
|
||||
.bc-stat {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.bc-field-label {
|
||||
font-size: 0.85rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
color: var(--bc-muted);
|
||||
}
|
||||
|
||||
.bc-tabs {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.bc-button,
|
||||
.bc-tabs a,
|
||||
.bc-page button,
|
||||
.bc-page input,
|
||||
.bc-page select,
|
||||
.bc-page textarea {
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
.bc-button {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 16px;
|
||||
padding: 12px 16px;
|
||||
cursor: pointer;
|
||||
text-decoration: none;
|
||||
transition: 160ms ease;
|
||||
}
|
||||
|
||||
.bc-button:hover {
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.bc-button--tab {
|
||||
background: rgba(255, 255, 255, 0.94);
|
||||
color: var(--bc-ink);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.bc-button--tab-active {
|
||||
background: linear-gradient(135deg, var(--bc-accent), var(--brand-accent-3));
|
||||
color: #fff7fb;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.bc-button--primary {
|
||||
background: linear-gradient(135deg, var(--bc-accent), var(--brand-accent-3));
|
||||
color: #fff7fb;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.bc-button--secondary {
|
||||
background: rgba(255, 255, 255, 0.94);
|
||||
color: var(--bc-ink);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.bc-button--ghost {
|
||||
background: color-mix(in srgb, var(--bc-accent) 14%, transparent);
|
||||
border-color: color-mix(in srgb, var(--bc-accent) 34%, transparent);
|
||||
color: var(--bc-accent);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.bc-actions {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.bc-alert {
|
||||
padding: 16px 18px;
|
||||
border-radius: 20px;
|
||||
border: 1px solid transparent;
|
||||
}
|
||||
|
||||
.bc-alert--error {
|
||||
background: rgba(127, 29, 29, 0.28);
|
||||
border-color: rgba(252, 165, 165, 0.24);
|
||||
color: #fecaca;
|
||||
}
|
||||
|
||||
.bc-alert--success {
|
||||
background: rgba(6, 78, 59, 0.28);
|
||||
border-color: rgba(134, 239, 172, 0.24);
|
||||
color: #bbf7d0;
|
||||
}
|
||||
|
||||
.bc-toolbar,
|
||||
.bc-overview-grid,
|
||||
.bc-card-grid {
|
||||
display: grid;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.bc-toolbar {
|
||||
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
|
||||
}
|
||||
|
||||
.bc-overview-grid {
|
||||
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
|
||||
}
|
||||
|
||||
.bc-card-grid {
|
||||
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
|
||||
}
|
||||
|
||||
.bc-stat-value {
|
||||
margin-top: 8px;
|
||||
font-size: 1.42rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.bc-chart-shell {
|
||||
position: relative;
|
||||
min-height: 360px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.bc-chart-svg {
|
||||
width: 100%;
|
||||
height: 340px;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.bc-chart-path {
|
||||
fill: none;
|
||||
stroke: var(--bc-accent);
|
||||
stroke-width: 3;
|
||||
stroke-linecap: round;
|
||||
stroke-linejoin: round;
|
||||
filter: drop-shadow(0 12px 24px color-mix(in srgb, var(--bc-accent) 18%, transparent));
|
||||
}
|
||||
|
||||
.bc-chart-area {
|
||||
fill: url(#bc-chart-fill);
|
||||
}
|
||||
|
||||
.bc-chart-grid line {
|
||||
stroke: rgba(16, 33, 43, 0.08);
|
||||
stroke-dasharray: 4 6;
|
||||
}
|
||||
|
||||
.bc-range-list {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.bc-range-button {
|
||||
border: 1px solid color-mix(in srgb, var(--bc-accent) 26%, transparent);
|
||||
background: rgba(255, 255, 255, 0.76);
|
||||
color: var(--bc-text);
|
||||
padding: 8px 12px;
|
||||
border-radius: 999px;
|
||||
cursor: pointer;
|
||||
transition: transform .18s ease, background .18s ease, border-color .18s ease;
|
||||
}
|
||||
|
||||
.bc-range-button:hover,
|
||||
.bc-range-button[aria-pressed="true"] {
|
||||
transform: translateY(-1px);
|
||||
background: color-mix(in srgb, var(--bc-accent) 18%, transparent);
|
||||
border-color: color-mix(in srgb, var(--bc-accent) 42%, transparent);
|
||||
}
|
||||
|
||||
.bc-panel-fade {
|
||||
animation: bcPanelFade .35s ease;
|
||||
}
|
||||
|
||||
@keyframes bcPanelFade {
|
||||
from { opacity: 0; transform: translateY(8px) scale(.99); }
|
||||
to { opacity: 1; transform: translateY(0) scale(1); }
|
||||
}
|
||||
|
||||
.bc-position-list {
|
||||
display: grid;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.bc-position-row {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1.8fr) repeat(4, minmax(96px, .72fr));
|
||||
gap: 14px;
|
||||
align-items: center;
|
||||
padding: 16px 18px;
|
||||
}
|
||||
|
||||
.bc-performance {
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.bc-performance.is-positive {
|
||||
color: var(--bc-positive);
|
||||
}
|
||||
|
||||
.bc-performance.is-negative {
|
||||
color: var(--bc-negative);
|
||||
}
|
||||
|
||||
.bc-pill-soft {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
width: fit-content;
|
||||
padding: 6px 10px;
|
||||
border-radius: 999px;
|
||||
background: color-mix(in srgb, var(--bc-accent) 10%, transparent);
|
||||
color: var(--bc-text);
|
||||
font-size: .85rem;
|
||||
}
|
||||
|
||||
.bc-table-shell {
|
||||
overflow: auto;
|
||||
border-radius: 18px;
|
||||
border: 1px solid var(--bc-line);
|
||||
}
|
||||
|
||||
.bc-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
|
||||
.bc-table th,
|
||||
.bc-table td {
|
||||
padding: 10px 12px;
|
||||
text-align: left;
|
||||
border-bottom: 1px solid rgba(255,255,255,0.06);
|
||||
vertical-align: top;
|
||||
}
|
||||
|
||||
.bc-table thead {
|
||||
background: rgba(255,255,255,0.04);
|
||||
}
|
||||
|
||||
.bc-page .setup-field {
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.bc-page input,
|
||||
.bc-page select,
|
||||
.bc-page textarea {
|
||||
width: 100%;
|
||||
border: 1px solid var(--bc-line);
|
||||
border-radius: 14px;
|
||||
padding: 10px 12px;
|
||||
background: var(--surface-strong);
|
||||
color: var(--bc-text);
|
||||
}
|
||||
|
||||
.bc-page input::placeholder,
|
||||
.bc-page textarea::placeholder {
|
||||
color: color-mix(in srgb, var(--bc-muted) 70%, transparent);
|
||||
}
|
||||
|
||||
.bc-page a {
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.bc-page .muted {
|
||||
color: var(--bc-muted);
|
||||
}
|
||||
|
||||
@media (max-width: 980px) {
|
||||
.bc-hero-top {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.bc-position-row {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
152
modules/boersenchecker/assets/boersenchecker.js
Normal file
152
modules/boersenchecker/assets/boersenchecker.js
Normal file
@@ -0,0 +1,152 @@
|
||||
(function () {
|
||||
const app = document.querySelector('[data-bc-home]');
|
||||
if (!app) return;
|
||||
|
||||
const chartShell = app.querySelector('[data-bc-chart]');
|
||||
const instrumentSelect = app.querySelector('[data-bc-instrument]');
|
||||
const instrumentNameNode = app.querySelector('[data-bc-instrument-name]');
|
||||
const instrumentMetaNode = app.querySelector('[data-bc-instrument-meta]');
|
||||
const rangeButtons = Array.from(app.querySelectorAll('[data-range]'));
|
||||
const statusNode = app.querySelector('[data-bc-chart-status]');
|
||||
const summaryNode = app.querySelector('[data-bc-chart-summary]');
|
||||
const endpoint = app.getAttribute('data-chart-endpoint') || '';
|
||||
const instrumentsScript = app.querySelector('[data-bc-instruments-json]');
|
||||
const instrumentMap = new Map();
|
||||
if (instrumentsScript?.textContent) {
|
||||
try {
|
||||
const items = JSON.parse(instrumentsScript.textContent);
|
||||
if (Array.isArray(items)) {
|
||||
items.forEach((item) => instrumentMap.set(String(item.instrument_id), item));
|
||||
}
|
||||
} catch (_error) {}
|
||||
}
|
||||
let activeRange = '1m';
|
||||
let currentPayload = null;
|
||||
|
||||
function pointsForRange(payload, range) {
|
||||
if (!payload) return [];
|
||||
const daily = payload.daily || [];
|
||||
const weekly = payload.weekly || [];
|
||||
const monthly = payload.monthly || [];
|
||||
switch (range) {
|
||||
case '1d': return daily.slice(-2);
|
||||
case '5d': return daily.slice(-5);
|
||||
case '1m': return daily.slice(-22);
|
||||
case '3m': return daily.slice(-66);
|
||||
case '6m': return weekly.slice(-26);
|
||||
case '1y': return weekly.slice(-52);
|
||||
case '5y': return monthly.slice(-60);
|
||||
default: return daily.slice(-22);
|
||||
}
|
||||
}
|
||||
|
||||
function renderChart(points) {
|
||||
if (!chartShell) return;
|
||||
chartShell.classList.remove('bc-panel-fade');
|
||||
void chartShell.offsetWidth;
|
||||
chartShell.classList.add('bc-panel-fade');
|
||||
|
||||
if (!points || points.length === 0) {
|
||||
chartShell.innerHTML = '<div class="muted">Keine Chartdaten verfuegbar.</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
const values = points.map((point) => Number(point.close || 0));
|
||||
const min = Math.min(...values);
|
||||
const max = Math.max(...values);
|
||||
const width = 920;
|
||||
const height = 340;
|
||||
const paddingX = 24;
|
||||
const paddingY = 28;
|
||||
const usableWidth = width - paddingX * 2;
|
||||
const usableHeight = height - paddingY * 2;
|
||||
const spread = max - min || 1;
|
||||
|
||||
const coords = points.map((point, index) => {
|
||||
const x = paddingX + (usableWidth * index / Math.max(points.length - 1, 1));
|
||||
const y = paddingY + usableHeight - ((Number(point.close || 0) - min) / spread) * usableHeight;
|
||||
return { x, y };
|
||||
});
|
||||
|
||||
const path = coords.map((coord, index) => `${index === 0 ? 'M' : 'L'}${coord.x.toFixed(2)},${coord.y.toFixed(2)}`).join(' ');
|
||||
const area = `${path} L${coords[coords.length - 1].x.toFixed(2)},${height - paddingY} L${coords[0].x.toFixed(2)},${height - paddingY} Z`;
|
||||
const grid = [0, 1, 2, 3].map((step) => {
|
||||
const y = paddingY + (usableHeight * step / 3);
|
||||
return `<line x1="${paddingX}" y1="${y}" x2="${width - paddingX}" y2="${y}"></line>`;
|
||||
}).join('');
|
||||
|
||||
chartShell.innerHTML = `
|
||||
<svg class="bc-chart-svg" viewBox="0 0 ${width} ${height}" preserveAspectRatio="none">
|
||||
<defs>
|
||||
<linearGradient id="bc-chart-fill" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0%" stop-color="rgba(94,234,212,0.32)"></stop>
|
||||
<stop offset="100%" stop-color="rgba(94,234,212,0.02)"></stop>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<g class="bc-chart-grid">${grid}</g>
|
||||
<path class="bc-chart-area" d="${area}"></path>
|
||||
<path class="bc-chart-path" d="${path}"></path>
|
||||
</svg>
|
||||
`;
|
||||
|
||||
const first = values[0];
|
||||
const last = values[values.length - 1];
|
||||
const delta = last - first;
|
||||
const percent = first !== 0 ? (delta / first) * 100 : 0;
|
||||
if (summaryNode) {
|
||||
summaryNode.textContent = `${last.toFixed(2)} | ${delta >= 0 ? '+' : ''}${delta.toFixed(2)} (${percent.toFixed(2)}%)`;
|
||||
}
|
||||
}
|
||||
|
||||
async function loadChart() {
|
||||
const instrumentId = instrumentSelect ? instrumentSelect.value : '';
|
||||
if (!instrumentId || !endpoint) {
|
||||
if (statusNode) statusNode.textContent = 'Keine Aktie fuer den Chart ausgewaehlt.';
|
||||
if (summaryNode) summaryNode.textContent = '-';
|
||||
if (chartShell) chartShell.innerHTML = '<div class="muted">Keine Chartdaten verfuegbar.</div>';
|
||||
return;
|
||||
}
|
||||
if (statusNode) statusNode.textContent = 'Chartdaten werden geladen...';
|
||||
try {
|
||||
const response = await fetch(`${endpoint}${endpoint.includes('?') ? '&' : '?'}instrument_id=${encodeURIComponent(instrumentId)}`, { headers: { Accept: 'application/json' } });
|
||||
const payload = await response.json();
|
||||
if (!payload.ok) {
|
||||
throw new Error(payload.message || 'Chartdaten konnten nicht geladen werden.');
|
||||
}
|
||||
currentPayload = payload;
|
||||
renderChart(pointsForRange(payload, activeRange));
|
||||
if (statusNode) {
|
||||
const sourceLabel = payload.source_label || payload.source || 'Lokale Kurshistorie';
|
||||
const instrumentRef = payload.symbol || payload.isin || '';
|
||||
statusNode.textContent = instrumentRef
|
||||
? `Quelle: ${sourceLabel} | ${instrumentRef}`
|
||||
: `Quelle: ${sourceLabel}`;
|
||||
}
|
||||
} catch (error) {
|
||||
currentPayload = null;
|
||||
chartShell.innerHTML = `<div class="muted">${error.message}</div>`;
|
||||
if (statusNode) statusNode.textContent = 'Fehler beim Laden der Chartdaten.';
|
||||
}
|
||||
}
|
||||
|
||||
rangeButtons.forEach((button) => {
|
||||
button.addEventListener('click', () => {
|
||||
activeRange = button.getAttribute('data-range') || '1m';
|
||||
rangeButtons.forEach((item) => item.setAttribute('aria-pressed', item === button ? 'true' : 'false'));
|
||||
renderChart(pointsForRange(currentPayload, activeRange));
|
||||
});
|
||||
});
|
||||
|
||||
if (instrumentSelect) {
|
||||
instrumentSelect.addEventListener('change', () => {
|
||||
const meta = instrumentMap.get(String(instrumentSelect.value));
|
||||
if (meta) {
|
||||
if (instrumentNameNode) instrumentNameNode.textContent = meta.instrument_name || 'Keine Aktie ausgewaehlt';
|
||||
if (instrumentMetaNode) instrumentMetaNode.textContent = `${meta.symbol || ''} · ${meta.isin || '-'}`;
|
||||
}
|
||||
loadChart();
|
||||
});
|
||||
}
|
||||
|
||||
loadChart();
|
||||
})();
|
||||
995
modules/boersenchecker/bootstrap.php
Normal file
995
modules/boersenchecker/bootstrap.php
Normal file
@@ -0,0 +1,995 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\ModuleConfigException;
|
||||
|
||||
spl_autoload_register(static function (string $class): void {
|
||||
$prefix = 'Modules\\Boersenchecker\\';
|
||||
if (!str_starts_with($class, $prefix)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$file = __DIR__ . '/src/' . str_replace('\\', '/', substr($class, strlen($prefix))) . '.php';
|
||||
if (is_file($file)) {
|
||||
require_once $file;
|
||||
}
|
||||
});
|
||||
|
||||
$moduleName = 'boersenchecker';
|
||||
$mm = isset($modules) && $modules instanceof App\ModuleManager ? $modules : modules();
|
||||
|
||||
$mm->registerFunction($moduleName, 'table', static function (string $name): string {
|
||||
$prefix = 'boersencheck_';
|
||||
$sanitized = preg_replace('/[^a-zA-Z0-9_]/', '', $name);
|
||||
return $prefix . $sanitized;
|
||||
});
|
||||
|
||||
$mm->registerFunction($moduleName, 'pdo', function () use ($moduleName): \PDO {
|
||||
$settings = modules()->settings($moduleName);
|
||||
$useSeparate = !empty($settings['use_separate_db']);
|
||||
|
||||
if ($useSeparate) {
|
||||
$module = modules()->get($moduleName);
|
||||
$fallback = $module['db_defaults'] ?? [];
|
||||
return modules()->modulePdo($moduleName, $fallback);
|
||||
}
|
||||
|
||||
$base = app()->basePdo();
|
||||
if ($base instanceof \PDO) {
|
||||
return $base;
|
||||
}
|
||||
|
||||
throw new ModuleConfigException(
|
||||
$moduleName,
|
||||
'Base-DB ist deaktiviert. Bitte Base-DB aktivieren oder eine eigene Modul-DB konfigurieren.'
|
||||
);
|
||||
});
|
||||
|
||||
$mm->registerFunction($moduleName, 'ensure_schema', function () use ($moduleName): void {
|
||||
$pdo = module_fn($moduleName, 'pdo');
|
||||
$table = static fn (string $name): string => module_fn($moduleName, 'table', $name);
|
||||
$driver = strtolower((string) $pdo->getAttribute(PDO::ATTR_DRIVER_NAME));
|
||||
|
||||
$portfolioTable = $table('portfolios');
|
||||
$instrumentTable = $table('instruments');
|
||||
$positionTable = $table('positions');
|
||||
$quoteTable = $table('quotes');
|
||||
|
||||
if ($driver === 'pgsql') {
|
||||
$pdo->exec("CREATE TABLE IF NOT EXISTS {$portfolioTable} (
|
||||
id SERIAL PRIMARY KEY,
|
||||
owner_sub VARCHAR(190) NOT NULL,
|
||||
name VARCHAR(190) NOT NULL,
|
||||
base_currency VARCHAR(10) NOT NULL DEFAULT 'EUR',
|
||||
notes TEXT NULL,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
)");
|
||||
$pdo->exec("CREATE TABLE IF NOT EXISTS {$instrumentTable} (
|
||||
id SERIAL PRIMARY KEY,
|
||||
isin VARCHAR(32) NULL,
|
||||
wkn VARCHAR(32) NULL,
|
||||
symbol VARCHAR(32) NULL,
|
||||
name VARCHAR(255) NOT NULL,
|
||||
quote_currency VARCHAR(10) NOT NULL DEFAULT 'EUR',
|
||||
market VARCHAR(120) NULL,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
)");
|
||||
$pdo->exec("CREATE TABLE IF NOT EXISTS {$positionTable} (
|
||||
id SERIAL PRIMARY KEY,
|
||||
owner_sub VARCHAR(190) NOT NULL,
|
||||
portfolio_id INTEGER NOT NULL,
|
||||
instrument_id INTEGER NOT NULL,
|
||||
quantity NUMERIC(20,6) NOT NULL,
|
||||
purchase_price NUMERIC(20,8) NOT NULL,
|
||||
purchase_currency VARCHAR(10) NOT NULL,
|
||||
purchase_date DATE NOT NULL,
|
||||
fees NUMERIC(20,8) NULL,
|
||||
notes TEXT NULL,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
)");
|
||||
$pdo->exec("CREATE TABLE IF NOT EXISTS {$quoteTable} (
|
||||
id SERIAL PRIMARY KEY,
|
||||
instrument_id INTEGER NOT NULL,
|
||||
price NUMERIC(20,8) NOT NULL,
|
||||
currency VARCHAR(10) NOT NULL,
|
||||
quoted_at TIMESTAMP NOT NULL,
|
||||
source VARCHAR(64) NOT NULL DEFAULT 'manual',
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
)");
|
||||
$pdo->exec("CREATE INDEX IF NOT EXISTS {$portfolioTable}_owner_idx ON {$portfolioTable} (owner_sub)");
|
||||
$pdo->exec("CREATE UNIQUE INDEX IF NOT EXISTS {$instrumentTable}_isin_uniq ON {$instrumentTable} (isin) WHERE isin IS NOT NULL");
|
||||
$pdo->exec("CREATE INDEX IF NOT EXISTS {$instrumentTable}_symbol_idx ON {$instrumentTable} (symbol)");
|
||||
$pdo->exec("CREATE INDEX IF NOT EXISTS {$positionTable}_owner_idx ON {$positionTable} (owner_sub)");
|
||||
$pdo->exec("CREATE INDEX IF NOT EXISTS {$positionTable}_portfolio_idx ON {$positionTable} (portfolio_id)");
|
||||
$pdo->exec("CREATE INDEX IF NOT EXISTS {$positionTable}_instrument_idx ON {$positionTable} (instrument_id)");
|
||||
$pdo->exec("CREATE INDEX IF NOT EXISTS {$quoteTable}_instrument_time_idx ON {$quoteTable} (instrument_id, quoted_at DESC)");
|
||||
} elseif ($driver === 'mysql') {
|
||||
$pdo->exec("CREATE TABLE IF NOT EXISTS {$portfolioTable} (
|
||||
id INTEGER PRIMARY KEY AUTO_INCREMENT,
|
||||
owner_sub VARCHAR(190) NOT NULL,
|
||||
name VARCHAR(190) NOT NULL,
|
||||
base_currency VARCHAR(10) NOT NULL DEFAULT 'EUR',
|
||||
notes TEXT NULL,
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
KEY {$portfolioTable}_owner_idx (owner_sub)
|
||||
)");
|
||||
$pdo->exec("CREATE TABLE IF NOT EXISTS {$instrumentTable} (
|
||||
id INTEGER PRIMARY KEY AUTO_INCREMENT,
|
||||
isin VARCHAR(32) NULL,
|
||||
wkn VARCHAR(32) NULL,
|
||||
symbol VARCHAR(32) NULL,
|
||||
name VARCHAR(255) NOT NULL,
|
||||
quote_currency VARCHAR(10) NOT NULL DEFAULT 'EUR',
|
||||
market VARCHAR(120) NULL,
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
UNIQUE KEY {$instrumentTable}_isin_uniq (isin),
|
||||
KEY {$instrumentTable}_symbol_idx (symbol)
|
||||
)");
|
||||
$pdo->exec("CREATE TABLE IF NOT EXISTS {$positionTable} (
|
||||
id INTEGER PRIMARY KEY AUTO_INCREMENT,
|
||||
owner_sub VARCHAR(190) NOT NULL,
|
||||
portfolio_id INTEGER NOT NULL,
|
||||
instrument_id INTEGER NOT NULL,
|
||||
quantity DECIMAL(20,6) NOT NULL,
|
||||
purchase_price DECIMAL(20,8) NOT NULL,
|
||||
purchase_currency VARCHAR(10) NOT NULL,
|
||||
purchase_date DATE NOT NULL,
|
||||
fees DECIMAL(20,8) NULL,
|
||||
notes TEXT NULL,
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
KEY {$positionTable}_owner_idx (owner_sub),
|
||||
KEY {$positionTable}_portfolio_idx (portfolio_id),
|
||||
KEY {$positionTable}_instrument_idx (instrument_id)
|
||||
)");
|
||||
$pdo->exec("CREATE TABLE IF NOT EXISTS {$quoteTable} (
|
||||
id INTEGER PRIMARY KEY AUTO_INCREMENT,
|
||||
instrument_id INTEGER NOT NULL,
|
||||
price DECIMAL(20,8) NOT NULL,
|
||||
currency VARCHAR(10) NOT NULL,
|
||||
quoted_at DATETIME NOT NULL,
|
||||
source VARCHAR(64) NOT NULL DEFAULT 'manual',
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
KEY {$quoteTable}_instrument_time_idx (instrument_id, quoted_at)
|
||||
)");
|
||||
} else {
|
||||
$pdo->exec("CREATE TABLE IF NOT EXISTS {$portfolioTable} (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
owner_sub VARCHAR(190) NOT NULL,
|
||||
name VARCHAR(190) NOT NULL,
|
||||
base_currency VARCHAR(10) NOT NULL DEFAULT 'EUR',
|
||||
notes TEXT NULL,
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
)");
|
||||
$pdo->exec("CREATE TABLE IF NOT EXISTS {$instrumentTable} (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
isin VARCHAR(32) NULL,
|
||||
wkn VARCHAR(32) NULL,
|
||||
symbol VARCHAR(32) NULL,
|
||||
name VARCHAR(255) NOT NULL,
|
||||
quote_currency VARCHAR(10) NOT NULL DEFAULT 'EUR',
|
||||
market VARCHAR(120) NULL,
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
)");
|
||||
$pdo->exec("CREATE TABLE IF NOT EXISTS {$positionTable} (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
owner_sub VARCHAR(190) NOT NULL,
|
||||
portfolio_id INTEGER NOT NULL,
|
||||
instrument_id INTEGER NOT NULL,
|
||||
quantity DECIMAL(20,6) NOT NULL,
|
||||
purchase_price DECIMAL(20,8) NOT NULL,
|
||||
purchase_currency VARCHAR(10) NOT NULL,
|
||||
purchase_date DATE NOT NULL,
|
||||
fees DECIMAL(20,8) NULL,
|
||||
notes TEXT NULL,
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
)");
|
||||
$pdo->exec("CREATE TABLE IF NOT EXISTS {$quoteTable} (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
instrument_id INTEGER NOT NULL,
|
||||
price DECIMAL(20,8) NOT NULL,
|
||||
currency VARCHAR(10) NOT NULL,
|
||||
quoted_at DATETIME NOT NULL,
|
||||
source VARCHAR(64) NOT NULL DEFAULT 'manual',
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
)");
|
||||
$pdo->exec("CREATE INDEX IF NOT EXISTS {$portfolioTable}_owner_idx ON {$portfolioTable} (owner_sub)");
|
||||
$pdo->exec("CREATE UNIQUE INDEX IF NOT EXISTS {$instrumentTable}_isin_uniq ON {$instrumentTable} (isin)");
|
||||
$pdo->exec("CREATE INDEX IF NOT EXISTS {$instrumentTable}_symbol_idx ON {$instrumentTable} (symbol)");
|
||||
$pdo->exec("CREATE INDEX IF NOT EXISTS {$positionTable}_owner_idx ON {$positionTable} (owner_sub)");
|
||||
$pdo->exec("CREATE INDEX IF NOT EXISTS {$positionTable}_portfolio_idx ON {$positionTable} (portfolio_id)");
|
||||
$pdo->exec("CREATE INDEX IF NOT EXISTS {$positionTable}_instrument_idx ON {$positionTable} (instrument_id)");
|
||||
$pdo->exec("CREATE INDEX IF NOT EXISTS {$quoteTable}_instrument_time_idx ON {$quoteTable} (instrument_id, quoted_at DESC)");
|
||||
}
|
||||
});
|
||||
|
||||
$mm->registerFunction($moduleName, 'fx_service', static function (): ?object {
|
||||
if (modules()->isEnabled('fx-rates') && modules()->hasFunction('fx-rates', 'service')) {
|
||||
try {
|
||||
return module_fn('fx-rates', 'service');
|
||||
} catch (\Throwable) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
});
|
||||
|
||||
$mm->registerFunction($moduleName, 'fx_refresh', static function (string $baseCurrency = 'EUR', float $maxAgeHours = 6.0): array {
|
||||
$service = module_fn('boersenchecker', 'fx_service');
|
||||
if (!$service || !method_exists($service, 'ensureFreshLatestRates')) {
|
||||
return [
|
||||
'ok' => false,
|
||||
'message' => 'Das Modul fx-rates ist aktuell nicht verfuegbar oder nicht aktiviert.',
|
||||
];
|
||||
}
|
||||
|
||||
try {
|
||||
$result = $service->ensureFreshLatestRates($maxAgeHours, strtoupper(trim($baseCurrency)) ?: 'EUR');
|
||||
return [
|
||||
'ok' => true,
|
||||
'message' => !empty($result['reused'])
|
||||
? 'Vorhandene FX-Daten weiterverwendet.'
|
||||
: 'FX-Daten aktualisiert.',
|
||||
'result' => $result,
|
||||
];
|
||||
} catch (\Throwable $e) {
|
||||
return [
|
||||
'ok' => false,
|
||||
'message' => 'FX-Aktualisierung fehlgeschlagen: ' . $e->getMessage(),
|
||||
];
|
||||
}
|
||||
});
|
||||
|
||||
$mm->registerFunction($moduleName, 'fx_prepare_fetch', static function (
|
||||
string $baseCurrency = 'EUR',
|
||||
array $currencies = [],
|
||||
float $maxAgeHours = 6.0
|
||||
): array {
|
||||
$service = module_fn('boersenchecker', 'fx_service');
|
||||
if (!$service || !method_exists($service, 'ensureFreshLatestRates')) {
|
||||
return [
|
||||
'ok' => false,
|
||||
'message' => 'Das Modul fx-rates ist aktuell nicht verfuegbar oder nicht aktiviert.',
|
||||
];
|
||||
}
|
||||
|
||||
$baseCurrency = strtoupper(trim($baseCurrency)) ?: 'EUR';
|
||||
$currencies = array_values(array_unique(array_filter(array_map(
|
||||
static fn (mixed $code): string => strtoupper(trim((string) $code)),
|
||||
$currencies
|
||||
), static fn (string $code): bool => $code !== '')));
|
||||
|
||||
try {
|
||||
$result = $service->ensureFreshLatestRates($maxAgeHours, $baseCurrency, $currencies);
|
||||
return [
|
||||
'ok' => true,
|
||||
'message' => !empty($result['reused']) ? 'Vorhandene FX-Daten weiterverwendet.' : 'FX-Daten aktualisiert.',
|
||||
'result' => $result,
|
||||
'fetch_id' => is_numeric($result['fetch_id'] ?? null) ? (int) $result['fetch_id'] : null,
|
||||
'reused' => !empty($result['reused']),
|
||||
];
|
||||
} catch (\Throwable $e) {
|
||||
return [
|
||||
'ok' => false,
|
||||
'message' => 'FX-Aktualisierung fehlgeschlagen: ' . $e->getMessage(),
|
||||
];
|
||||
}
|
||||
});
|
||||
|
||||
$mm->registerFunction($moduleName, 'fx_source_with_fetch_id', static function (string $source, ?int $fetchId = null): string {
|
||||
$source = trim($source) !== '' ? trim($source) : 'manual';
|
||||
if ($fetchId === null || $fetchId <= 0) {
|
||||
return preg_replace('/\|fx_fetch:\d+$/', '', $source) ?: $source;
|
||||
}
|
||||
|
||||
$source = preg_replace('/\|fx_fetch:\d+$/', '', $source) ?: $source;
|
||||
return $source . '|fx_fetch:' . $fetchId;
|
||||
});
|
||||
|
||||
$mm->registerFunction($moduleName, 'fx_extract_fetch_id', static function (?string $source): ?int {
|
||||
$source = trim((string) $source);
|
||||
if ($source === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (preg_match('/\|fx_fetch:(\d+)$/', $source, $matches) === 1) {
|
||||
$fetchId = (int) ($matches[1] ?? 0);
|
||||
return $fetchId > 0 ? $fetchId : null;
|
||||
}
|
||||
|
||||
return null;
|
||||
});
|
||||
|
||||
$mm->registerFunction($moduleName, 'fx_convert_with_fetch', static function (
|
||||
?float $amount,
|
||||
?string $fromCurrency,
|
||||
?string $toCurrency,
|
||||
?int $fetchId = null
|
||||
): ?float {
|
||||
if ($amount === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$from = strtoupper(trim((string) $fromCurrency));
|
||||
$to = strtoupper(trim((string) $toCurrency));
|
||||
if ($from === '' || $to === '') {
|
||||
return null;
|
||||
}
|
||||
if ($from === $to) {
|
||||
return $amount;
|
||||
}
|
||||
|
||||
$service = module_fn('boersenchecker', 'fx_service');
|
||||
if (!$service) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$normalizedFetchId = $fetchId !== null && $fetchId > 0 ? $fetchId : null;
|
||||
if ($normalizedFetchId !== null && method_exists($service, 'snapshotByFetchId')) {
|
||||
try {
|
||||
$snapshot = $service->snapshotByFetchId($normalizedFetchId, null, [$from, $to]);
|
||||
if (is_array($snapshot)) {
|
||||
$base = strtoupper(trim((string) ($snapshot['base_currency'] ?? '')));
|
||||
$rates = is_array($snapshot['rates'] ?? null) ? $snapshot['rates'] : [];
|
||||
$fromRate = $from === $base ? 1.0 : (is_numeric($rates[$from] ?? null) ? (float) $rates[$from] : null);
|
||||
$toRate = $to === $base ? 1.0 : (is_numeric($rates[$to] ?? null) ? (float) $rates[$to] : null);
|
||||
if ($fromRate !== null && $fromRate > 0 && $toRate !== null && $toRate > 0) {
|
||||
return $amount * ($toRate / $fromRate);
|
||||
}
|
||||
}
|
||||
} catch (\Throwable) {
|
||||
}
|
||||
}
|
||||
|
||||
if (!method_exists($service, 'convert')) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
$value = $service->convert($amount, $from, $to);
|
||||
return is_numeric($value) ? (float) $value : null;
|
||||
} catch (\Throwable) {
|
||||
return null;
|
||||
}
|
||||
});
|
||||
|
||||
$mm->registerFunction($moduleName, 'alpha_vantage_request', static function (
|
||||
string $functionName,
|
||||
array $params = []
|
||||
): array {
|
||||
$settings = modules()->settings('boersenchecker');
|
||||
$apiKey = trim((string) ($settings['alpha_vantage_api_key'] ?? ''));
|
||||
$timeout = (int) (($settings['alpha_vantage_timeout_sec'] ?? null) ?: 12);
|
||||
$timeout = $timeout > 0 ? $timeout : 12;
|
||||
|
||||
if ($apiKey === '') {
|
||||
module_debug_push('boersenchecker', [
|
||||
'label' => 'Alpha Vantage Request',
|
||||
'type' => 'api:error',
|
||||
'request' => [
|
||||
'function' => $functionName,
|
||||
'params' => $params,
|
||||
],
|
||||
'message' => 'Alpha-Vantage-API-Key fehlt. Bitte im Modul-Setup hinterlegen.',
|
||||
]);
|
||||
return [
|
||||
'ok' => false,
|
||||
'message' => 'Alpha-Vantage-API-Key fehlt. Bitte im Modul-Setup hinterlegen.',
|
||||
];
|
||||
}
|
||||
|
||||
$url = 'https://www.alphavantage.co/query?' . http_build_query(array_merge([
|
||||
'function' => $functionName,
|
||||
'apikey' => $apiKey,
|
||||
], $params), '', '&', PHP_QUERY_RFC3986);
|
||||
|
||||
$responseBody = null;
|
||||
$httpCode = 0;
|
||||
$curlError = '';
|
||||
if (function_exists('curl_init')) {
|
||||
$ch = curl_init($url);
|
||||
if ($ch !== false) {
|
||||
curl_setopt_array($ch, [
|
||||
CURLOPT_RETURNTRANSFER => true,
|
||||
CURLOPT_FOLLOWLOCATION => true,
|
||||
CURLOPT_TIMEOUT => $timeout,
|
||||
CURLOPT_CONNECTTIMEOUT => min(5, $timeout),
|
||||
CURLOPT_HTTPHEADER => ['Accept: application/json'],
|
||||
]);
|
||||
$responseBody = curl_exec($ch);
|
||||
$curlError = curl_error($ch);
|
||||
$httpCode = (int) curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
|
||||
curl_close($ch);
|
||||
}
|
||||
}
|
||||
|
||||
if (!is_string($responseBody) || $responseBody === '') {
|
||||
$context = stream_context_create([
|
||||
'http' => [
|
||||
'method' => 'GET',
|
||||
'timeout' => $timeout,
|
||||
'header' => "Accept: application/json\r\n",
|
||||
],
|
||||
]);
|
||||
$responseBody = @file_get_contents($url, false, $context);
|
||||
}
|
||||
|
||||
if (!is_string($responseBody) || $responseBody === '') {
|
||||
module_debug_push('boersenchecker', [
|
||||
'label' => 'Alpha Vantage Request',
|
||||
'type' => 'api:error',
|
||||
'request' => [
|
||||
'function' => $functionName,
|
||||
'url' => $url,
|
||||
'params' => $params,
|
||||
],
|
||||
'response' => [
|
||||
'http_code' => $httpCode,
|
||||
'curl_error' => $curlError,
|
||||
'body' => null,
|
||||
],
|
||||
'message' => 'Alpha-Vantage-Anfrage fehlgeschlagen.',
|
||||
]);
|
||||
return [
|
||||
'ok' => false,
|
||||
'message' => 'Alpha-Vantage-Anfrage fehlgeschlagen.'
|
||||
. ($curlError !== '' ? ' ' . $curlError : '')
|
||||
. ($httpCode > 0 ? ' HTTP ' . $httpCode : ''),
|
||||
];
|
||||
}
|
||||
|
||||
$decoded = json_decode($responseBody, true);
|
||||
if (!is_array($decoded)) {
|
||||
module_debug_push('boersenchecker', [
|
||||
'label' => 'Alpha Vantage Request',
|
||||
'type' => 'api:error',
|
||||
'request' => [
|
||||
'function' => $functionName,
|
||||
'url' => $url,
|
||||
'params' => $params,
|
||||
],
|
||||
'response' => [
|
||||
'http_code' => $httpCode,
|
||||
'body_preview' => substr($responseBody, 0, 4000),
|
||||
],
|
||||
'message' => 'Alpha-Vantage-Antwort ist kein gueltiges JSON.',
|
||||
]);
|
||||
return [
|
||||
'ok' => false,
|
||||
'message' => 'Alpha-Vantage-Antwort ist kein gueltiges JSON.',
|
||||
'raw_body' => $responseBody,
|
||||
];
|
||||
}
|
||||
|
||||
foreach (['Error Message', 'Information', 'Note'] as $errorKey) {
|
||||
if (isset($decoded[$errorKey]) && is_string($decoded[$errorKey]) && trim($decoded[$errorKey]) !== '') {
|
||||
module_debug_push('boersenchecker', [
|
||||
'label' => 'Alpha Vantage Request',
|
||||
'type' => 'api:error',
|
||||
'request' => [
|
||||
'function' => $functionName,
|
||||
'url' => $url,
|
||||
'params' => $params,
|
||||
],
|
||||
'response' => [
|
||||
'http_code' => $httpCode,
|
||||
'body' => $decoded,
|
||||
],
|
||||
'message' => trim((string) $decoded[$errorKey]),
|
||||
]);
|
||||
return [
|
||||
'ok' => false,
|
||||
'message' => trim((string) $decoded[$errorKey]),
|
||||
'raw' => $decoded,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
module_debug_push('boersenchecker', [
|
||||
'label' => 'Alpha Vantage Request',
|
||||
'type' => 'api:response',
|
||||
'request' => [
|
||||
'function' => $functionName,
|
||||
'url' => $url,
|
||||
'params' => $params,
|
||||
],
|
||||
'response' => [
|
||||
'http_code' => $httpCode,
|
||||
'body' => $decoded,
|
||||
],
|
||||
]);
|
||||
|
||||
return [
|
||||
'ok' => true,
|
||||
'data' => $decoded,
|
||||
];
|
||||
});
|
||||
|
||||
$mm->registerFunction($moduleName, 'display_timezone', static function (): \DateTimeZone {
|
||||
return new \DateTimeZone(nexus_display_timezone_name());
|
||||
});
|
||||
|
||||
$mm->registerFunction($moduleName, 'normalize_market_timestamp_utc', static function (mixed $value): string {
|
||||
if (is_numeric($value)) {
|
||||
return gmdate('Y-m-d H:i:s', (int) $value);
|
||||
}
|
||||
|
||||
$raw = trim((string) $value);
|
||||
if ($raw === '') {
|
||||
return gmdate('Y-m-d H:i:s');
|
||||
}
|
||||
|
||||
try {
|
||||
$date = new \DateTimeImmutable($raw, new \DateTimeZone('UTC'));
|
||||
return $date->setTimezone(new \DateTimeZone('UTC'))->format('Y-m-d H:i:s');
|
||||
} catch (\Throwable) {
|
||||
$timestamp = strtotime($raw);
|
||||
return $timestamp !== false ? gmdate('Y-m-d H:i:s', $timestamp) : gmdate('Y-m-d H:i:s');
|
||||
}
|
||||
});
|
||||
|
||||
$mm->registerFunction($moduleName, 'format_datetime_for_display', static function (
|
||||
?string $value,
|
||||
?string $source = null,
|
||||
string $format = 'Y-m-d H:i:s'
|
||||
): string {
|
||||
$raw = trim((string) $value);
|
||||
if ($raw === '') {
|
||||
return '';
|
||||
}
|
||||
|
||||
$displayTimezone = new \DateTimeZone(nexus_display_timezone_name());
|
||||
$source = trim((string) $source);
|
||||
|
||||
if (str_starts_with($source, 'bavest:') || str_starts_with($source, 'alphavantage:')) {
|
||||
$date = \DateTimeImmutable::createFromFormat('Y-m-d H:i:s', $raw, new \DateTimeZone('UTC'));
|
||||
if (!$date instanceof \DateTimeImmutable) {
|
||||
try {
|
||||
$date = new \DateTimeImmutable($raw, new \DateTimeZone('UTC'));
|
||||
} catch (\Throwable) {
|
||||
return $raw;
|
||||
}
|
||||
}
|
||||
return $date->setTimezone($displayTimezone)->format($format);
|
||||
}
|
||||
|
||||
if (preg_match('/(Z|[+\-]\d{2}:\d{2})$/', $raw) === 1) {
|
||||
try {
|
||||
return (new \DateTimeImmutable($raw))->setTimezone($displayTimezone)->format($format);
|
||||
} catch (\Throwable) {
|
||||
return $raw;
|
||||
}
|
||||
}
|
||||
|
||||
return str_replace('T', ' ', $raw);
|
||||
});
|
||||
|
||||
$mm->registerFunction($moduleName, 'local_now_input_value', static function (): string {
|
||||
return (new \DateTimeImmutable('now', new \DateTimeZone(nexus_display_timezone_name())))->format('Y-m-d\TH:i');
|
||||
});
|
||||
|
||||
$mm->registerFunction($moduleName, 'alpha_vantage_extract_global_quote', static function (array $entry): ?array {
|
||||
$quote = is_array($entry['Global Quote'] ?? null) ? $entry['Global Quote'] : $entry;
|
||||
$price = $quote['05. price'] ?? null;
|
||||
if (!is_numeric($price)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return [
|
||||
'symbol' => trim((string) ($quote['01. symbol'] ?? '')),
|
||||
'price' => (float) $price,
|
||||
'currency' => '',
|
||||
'fetched_at' => gmdate('Y-m-d H:i:s'),
|
||||
'market_date' => trim((string) ($quote['07. latest trading day'] ?? '')),
|
||||
'source' => 'alphavantage:global_quote',
|
||||
'raw' => $quote,
|
||||
];
|
||||
});
|
||||
|
||||
$mm->registerFunction($moduleName, 'alpha_vantage_fetch_quote_by_symbol', static function (string $symbol): array {
|
||||
$symbol = strtoupper(trim($symbol));
|
||||
if ($symbol === '') {
|
||||
return [
|
||||
'ok' => false,
|
||||
'message' => 'Kein Symbol hinterlegt.',
|
||||
];
|
||||
}
|
||||
|
||||
$response = module_fn('boersenchecker', 'alpha_vantage_request', 'GLOBAL_QUOTE', [
|
||||
'symbol' => $symbol,
|
||||
]);
|
||||
if (empty($response['ok'])) {
|
||||
return $response;
|
||||
}
|
||||
|
||||
$quote = module_fn('boersenchecker', 'alpha_vantage_extract_global_quote', (array) ($response['data'] ?? []));
|
||||
if (!is_array($quote)) {
|
||||
return [
|
||||
'ok' => false,
|
||||
'message' => 'Alpha Vantage lieferte keinen Preis fuer das Symbol ' . $symbol . '.',
|
||||
];
|
||||
}
|
||||
|
||||
return ['ok' => true] + $quote;
|
||||
});
|
||||
|
||||
$mm->registerFunction($moduleName, 'alpha_vantage_fetch_quotes', static function (array $instruments): array {
|
||||
$quotes = [];
|
||||
$errors = [];
|
||||
foreach ($instruments as $instrument) {
|
||||
if (!is_array($instrument)) {
|
||||
continue;
|
||||
}
|
||||
$instrumentId = (int) ($instrument['id'] ?? 0);
|
||||
$symbol = strtoupper(trim((string) ($instrument['symbol'] ?? '')));
|
||||
if ($instrumentId <= 0 || $symbol === '') {
|
||||
continue;
|
||||
}
|
||||
|
||||
$result = module_fn('boersenchecker', 'alpha_vantage_fetch_quote_by_symbol', $symbol);
|
||||
if (empty($result['ok'])) {
|
||||
$errors[] = $symbol . ': ' . (string) ($result['message'] ?? 'API-Abruf fehlgeschlagen.');
|
||||
continue;
|
||||
}
|
||||
|
||||
$quotes[$instrumentId] = $result + ['instrument_id' => $instrumentId];
|
||||
}
|
||||
|
||||
return [
|
||||
'ok' => true,
|
||||
'quotes' => $quotes,
|
||||
'errors' => $errors,
|
||||
'message' => count($quotes) . ' Kurse ueber Alpha Vantage geladen.',
|
||||
];
|
||||
});
|
||||
|
||||
$mm->registerFunction($moduleName, 'scheduled_refresh_quotes', static function (array $context = []): array {
|
||||
$pdo = module_fn('boersenchecker', 'pdo');
|
||||
$settings = modules()->settings('boersenchecker');
|
||||
$instrumentTable = module_fn('boersenchecker', 'table', 'instruments');
|
||||
$positionTable = module_fn('boersenchecker', 'table', 'positions');
|
||||
$quoteTable = module_fn('boersenchecker', 'table', 'quotes');
|
||||
|
||||
$defaultReportCurrency = strtoupper(trim((string) ($settings['report_currency'] ?? 'EUR'))) ?: 'EUR';
|
||||
$minIntervalMinutes = (int) (($settings['alpha_vantage_min_interval_minutes'] ?? null) ?: 60);
|
||||
if ($minIntervalMinutes <= 0) {
|
||||
$minIntervalMinutes = 60;
|
||||
}
|
||||
|
||||
$stmt = $pdo->query(
|
||||
'SELECT DISTINCT
|
||||
i.id,
|
||||
i.name,
|
||||
i.symbol,
|
||||
i.quote_currency
|
||||
FROM ' . $positionTable . ' p
|
||||
INNER JOIN ' . $instrumentTable . ' i ON i.id = p.instrument_id
|
||||
WHERE i.symbol IS NOT NULL
|
||||
AND i.symbol <> \'\'
|
||||
ORDER BY i.name ASC'
|
||||
);
|
||||
$rows = $stmt->fetchAll(PDO::FETCH_ASSOC) ?: [];
|
||||
if ($rows === []) {
|
||||
return [
|
||||
'ok' => true,
|
||||
'message' => 'Kein automatischer Kursabruf: keine Aktien mit Symbol vorhanden.',
|
||||
];
|
||||
}
|
||||
|
||||
$instrumentIds = array_values(array_map(static fn (array $row): int => (int) ($row['id'] ?? 0), $rows));
|
||||
$instrumentIds = array_values(array_filter($instrumentIds, static fn (int $id): bool => $id > 0));
|
||||
$latestQuotes = [];
|
||||
if ($instrumentIds !== []) {
|
||||
$placeholders = implode(',', array_fill(0, count($instrumentIds), '?'));
|
||||
$latestStmt = $pdo->prepare(
|
||||
'SELECT *
|
||||
FROM ' . $quoteTable . '
|
||||
WHERE instrument_id IN (' . $placeholders . ')
|
||||
AND source LIKE ?
|
||||
ORDER BY quoted_at DESC, created_at DESC, id DESC'
|
||||
);
|
||||
$latestStmt->execute([...$instrumentIds, 'alphavantage:%']);
|
||||
foreach ($latestStmt->fetchAll(PDO::FETCH_ASSOC) ?: [] as $row) {
|
||||
$instrumentId = (int) ($row['instrument_id'] ?? 0);
|
||||
if ($instrumentId > 0 && !isset($latestQuotes[$instrumentId])) {
|
||||
$latestQuotes[$instrumentId] = $row;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$reused = 0;
|
||||
$candidates = [];
|
||||
foreach ($rows as $row) {
|
||||
$instrumentId = (int) ($row['id'] ?? 0);
|
||||
$latest = $latestQuotes[$instrumentId] ?? null;
|
||||
$latestTimestamp = is_array($latest) ? strtotime((string) ($latest['quoted_at'] ?? '')) : false;
|
||||
if ($latestTimestamp !== false && (time() - $latestTimestamp) < ($minIntervalMinutes * 60)) {
|
||||
$reused++;
|
||||
continue;
|
||||
}
|
||||
$candidates[] = $row;
|
||||
}
|
||||
|
||||
if ($candidates === []) {
|
||||
return [
|
||||
'ok' => true,
|
||||
'message' => 'Automatischer Kursabruf uebersprungen: alle Kurse liegen noch innerhalb des Mindestabstands.',
|
||||
];
|
||||
}
|
||||
|
||||
$quoteCurrencies = array_values(array_unique(array_filter(array_map(
|
||||
static fn (array $row): string => strtoupper(trim((string) ($row['quote_currency'] ?? ''))),
|
||||
$candidates
|
||||
), static fn (string $code): bool => $code !== '')));
|
||||
$fxResult = module_fn('boersenchecker', 'fx_prepare_fetch', $defaultReportCurrency, $quoteCurrencies, (float) (($settings['fx_max_age_hours'] ?? null) ?: 6));
|
||||
if (empty($fxResult['ok'])) {
|
||||
return $fxResult;
|
||||
}
|
||||
$fxFetchId = is_numeric($fxResult['fetch_id'] ?? null) ? (int) $fxResult['fetch_id'] : null;
|
||||
|
||||
$bulkResult = module_fn('boersenchecker', 'alpha_vantage_fetch_quotes', $candidates);
|
||||
if (empty($bulkResult['ok'])) {
|
||||
return [
|
||||
'ok' => false,
|
||||
'message' => (string) ($bulkResult['message'] ?? 'Automatischer Alpha-Vantage-Abruf fehlgeschlagen.'),
|
||||
];
|
||||
}
|
||||
|
||||
$quotes = is_array($bulkResult['quotes'] ?? null) ? $bulkResult['quotes'] : [];
|
||||
$errors = is_array($bulkResult['errors'] ?? null) ? $bulkResult['errors'] : [];
|
||||
$updated = 0;
|
||||
foreach ($candidates as $row) {
|
||||
$instrumentId = (int) ($row['id'] ?? 0);
|
||||
$quote = $quotes[$instrumentId] ?? null;
|
||||
if (!is_array($quote) || !is_numeric($quote['price'] ?? null)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$storeResult = module_fn(
|
||||
'boersenchecker',
|
||||
'store_market_quote',
|
||||
$instrumentId,
|
||||
(float) $quote['price'],
|
||||
strtoupper(trim((string) ($quote['currency'] ?? $row['quote_currency'] ?? $defaultReportCurrency))) ?: $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++;
|
||||
}
|
||||
}
|
||||
|
||||
$message = 'Automatischer Kursabruf: ' . $updated . ' neu, ' . $reused . ' wiederverwendet, ' . count($errors) . ' Fehler.';
|
||||
if ($errors !== []) {
|
||||
$message .= ' ' . implode(' | ', array_slice($errors, 0, 3));
|
||||
}
|
||||
|
||||
module_debug_push('boersenchecker', [
|
||||
'label' => 'Intervall-Aufgabe',
|
||||
'type' => 'scheduler:run',
|
||||
'task' => 'auto_refresh_quotes',
|
||||
'context' => $context,
|
||||
'message' => $message,
|
||||
]);
|
||||
|
||||
return [
|
||||
'ok' => $errors === [],
|
||||
'message' => $message,
|
||||
'updated' => $updated,
|
||||
'reused' => $reused,
|
||||
'errors' => $errors,
|
||||
];
|
||||
});
|
||||
|
||||
$mm->registerFunction($moduleName, 'alpha_vantage_search_symbols', static function (string $keywords): array {
|
||||
$keywords = trim($keywords);
|
||||
if ($keywords === '') {
|
||||
return [
|
||||
'ok' => false,
|
||||
'message' => 'Bitte Suchbegriff angeben.',
|
||||
'results' => [],
|
||||
];
|
||||
}
|
||||
|
||||
$response = module_fn('boersenchecker', 'alpha_vantage_request', 'SYMBOL_SEARCH', [
|
||||
'keywords' => $keywords,
|
||||
]);
|
||||
if (empty($response['ok'])) {
|
||||
return $response + ['results' => []];
|
||||
}
|
||||
|
||||
$data = is_array($response['data'] ?? null) ? $response['data'] : [];
|
||||
$items = is_array($data['bestMatches'] ?? null) ? $data['bestMatches'] : [];
|
||||
$results = [];
|
||||
foreach ($items as $item) {
|
||||
if (!is_array($item)) {
|
||||
continue;
|
||||
}
|
||||
$symbol = trim((string) ($item['1. symbol'] ?? ''));
|
||||
$name = trim((string) ($item['2. name'] ?? ''));
|
||||
$type = trim((string) ($item['3. type'] ?? ''));
|
||||
$region = trim((string) ($item['4. region'] ?? ''));
|
||||
$currency = strtoupper(trim((string) ($item['8. currency'] ?? '')));
|
||||
$matchScore = trim((string) ($item['9. matchScore'] ?? ''));
|
||||
if ($symbol === '' && $name === '') {
|
||||
continue;
|
||||
}
|
||||
|
||||
$results[] = [
|
||||
'symbol' => $symbol,
|
||||
'name' => $name,
|
||||
'isin' => '',
|
||||
'type' => $type,
|
||||
'region' => $region,
|
||||
'currency' => $currency,
|
||||
'match_score' => $matchScore,
|
||||
'raw' => $item,
|
||||
];
|
||||
}
|
||||
|
||||
return [
|
||||
'ok' => true,
|
||||
'message' => count($results) . ' Treffer gefunden.',
|
||||
'results' => $results,
|
||||
];
|
||||
});
|
||||
|
||||
$mm->registerFunction($moduleName, 'alpha_vantage_fetch_chart_series', static function (string $symbol): array {
|
||||
$symbol = strtoupper(trim($symbol));
|
||||
if ($symbol === '') {
|
||||
return ['ok' => false, 'message' => 'Kein Symbol angegeben.'];
|
||||
}
|
||||
|
||||
$cacheDir = sys_get_temp_dir() . '/boersenchecker-alphavantage';
|
||||
if (!is_dir($cacheDir)) {
|
||||
@mkdir($cacheDir, 0775, true);
|
||||
}
|
||||
$cachePath = $cacheDir . '/' . md5('time_series_daily_adjusted|' . $symbol) . '.json';
|
||||
|
||||
$decoded = null;
|
||||
if (is_file($cachePath) && (time() - filemtime($cachePath)) < (6 * 3600)) {
|
||||
$cached = file_get_contents($cachePath);
|
||||
$decoded = is_string($cached) ? json_decode($cached, true) : null;
|
||||
}
|
||||
|
||||
if (!is_array($decoded)) {
|
||||
$response = module_fn('boersenchecker', 'alpha_vantage_request', 'TIME_SERIES_DAILY_ADJUSTED', [
|
||||
'symbol' => $symbol,
|
||||
'outputsize' => 'full',
|
||||
]);
|
||||
if (empty($response['ok'])) {
|
||||
return $response;
|
||||
}
|
||||
$decoded = is_array($response['data'] ?? null) ? $response['data'] : [];
|
||||
@file_put_contents($cachePath, json_encode($decoded, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES));
|
||||
}
|
||||
|
||||
$rows = is_array($decoded['Time Series (Daily)'] ?? null)
|
||||
? $decoded['Time Series (Daily)']
|
||||
: (is_array($decoded['Time Series (Daily) Adjusted'] ?? null) ? $decoded['Time Series (Daily) Adjusted'] : []);
|
||||
|
||||
$daily = [];
|
||||
foreach ($rows as $date => $row) {
|
||||
if (!is_array($row) || !preg_match('/^\d{4}-\d{2}-\d{2}$/', (string) $date)) {
|
||||
continue;
|
||||
}
|
||||
$close = $row['5. adjusted close'] ?? $row['4. close'] ?? null;
|
||||
if (!is_numeric($close)) {
|
||||
continue;
|
||||
}
|
||||
$daily[] = [
|
||||
'date' => $date,
|
||||
'close' => (float) $close,
|
||||
];
|
||||
}
|
||||
usort($daily, static fn (array $left, array $right): int => strcmp((string) $left['date'], (string) $right['date']));
|
||||
if ($daily === []) {
|
||||
return [
|
||||
'ok' => false,
|
||||
'message' => 'Keine historischen Schlusskurse fuer ' . $symbol . ' verfuegbar.',
|
||||
];
|
||||
}
|
||||
|
||||
$aggregate = static function (array $points, string $format): array {
|
||||
$result = [];
|
||||
foreach ($points as $point) {
|
||||
$bucket = date($format, strtotime((string) $point['date']) ?: time());
|
||||
$result[$bucket] = $point;
|
||||
}
|
||||
return array_values($result);
|
||||
};
|
||||
|
||||
return [
|
||||
'ok' => true,
|
||||
'symbol' => $symbol,
|
||||
'daily' => $daily,
|
||||
'weekly' => $aggregate($daily, 'o-W'),
|
||||
'monthly' => $aggregate($daily, 'Y-m'),
|
||||
'source' => 'alphavantage:time_series_daily_adjusted',
|
||||
];
|
||||
});
|
||||
|
||||
$mm->registerFunction($moduleName, 'store_market_quote', static function (
|
||||
int $instrumentId,
|
||||
float $price,
|
||||
string $currency,
|
||||
string $quotedAt,
|
||||
string $source
|
||||
): array {
|
||||
$pdo = module_fn('boersenchecker', 'pdo');
|
||||
$quoteTable = module_fn('boersenchecker', 'table', 'quotes');
|
||||
|
||||
$quotedAt = trim($quotedAt);
|
||||
$currency = strtoupper(trim($currency)) ?: 'EUR';
|
||||
$source = trim($source) !== '' ? trim($source) : 'alphavantage:global_quote';
|
||||
|
||||
$checkStmt = $pdo->prepare(
|
||||
'SELECT id
|
||||
FROM ' . $quoteTable . '
|
||||
WHERE instrument_id = :instrument_id
|
||||
AND price = :price
|
||||
AND currency = :currency
|
||||
AND quoted_at = :quoted_at
|
||||
AND source = :source
|
||||
LIMIT 1'
|
||||
);
|
||||
$checkStmt->execute([
|
||||
'instrument_id' => $instrumentId,
|
||||
'price' => $price,
|
||||
'currency' => $currency,
|
||||
'quoted_at' => $quotedAt,
|
||||
'source' => $source,
|
||||
]);
|
||||
$existingId = (int) $checkStmt->fetchColumn();
|
||||
if ($existingId > 0) {
|
||||
module_debug_push('boersenchecker', [
|
||||
'label' => 'Quote Store',
|
||||
'type' => 'quote:reuse',
|
||||
'instrument_id' => $instrumentId,
|
||||
'price' => $price,
|
||||
'currency' => $currency,
|
||||
'quoted_at' => $quotedAt,
|
||||
'source' => $source,
|
||||
'message' => 'Identischer Snapshot bereits vorhanden.',
|
||||
]);
|
||||
return ['ok' => true, 'inserted' => false, 'id' => $existingId];
|
||||
}
|
||||
|
||||
$insertStmt = $pdo->prepare(
|
||||
'INSERT INTO ' . $quoteTable . ' (instrument_id, price, currency, quoted_at, source)
|
||||
VALUES (:instrument_id, :price, :currency, :quoted_at, :source)'
|
||||
);
|
||||
$insertStmt->execute([
|
||||
'instrument_id' => $instrumentId,
|
||||
'price' => $price,
|
||||
'currency' => $currency,
|
||||
'quoted_at' => $quotedAt,
|
||||
'source' => $source,
|
||||
]);
|
||||
|
||||
$insertedId = (int) $pdo->lastInsertId();
|
||||
module_debug_push('boersenchecker', [
|
||||
'label' => 'Quote Store',
|
||||
'type' => 'quote:insert',
|
||||
'instrument_id' => $instrumentId,
|
||||
'price' => $price,
|
||||
'currency' => $currency,
|
||||
'quoted_at' => $quotedAt,
|
||||
'source' => $source,
|
||||
'inserted_id' => $insertedId,
|
||||
'message' => 'Neuer Snapshot gespeichert.',
|
||||
]);
|
||||
|
||||
return ['ok' => true, 'inserted' => true, 'id' => $insertedId];
|
||||
});
|
||||
14
modules/boersenchecker/design.json
Normal file
14
modules/boersenchecker/design.json
Normal file
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"eyebrow": "Modul",
|
||||
"title": "Boersenchecker",
|
||||
"description": "Depotverwaltung fuer Aktien, Kaufdaten, Kursverlauf und Waehrungsumrechnung.",
|
||||
"actions": [
|
||||
{ "label": "Nexus Übersicht", "href": "/" },
|
||||
{ "label": "Setup", "href": "/modules/setup/boersenchecker" }
|
||||
],
|
||||
"tabs": [
|
||||
{ "label": "Ueberblick", "href": "/module/boersenchecker" },
|
||||
{ "label": "Depotverwaltung", "href": "/module/boersenchecker/depotverwaltung" },
|
||||
{ "label": "Aktienverwaltung", "href": "/module/boersenchecker/aktienverwaltung" }
|
||||
]
|
||||
}
|
||||
51
modules/boersenchecker/module.json
Normal file
51
modules/boersenchecker/module.json
Normal file
@@ -0,0 +1,51 @@
|
||||
{
|
||||
"title": "Börsenchecker",
|
||||
"version": "0.2.0",
|
||||
"description": "Depotverwaltung fuer Aktien, Kaufdaten, Kursverlauf und Waehrungsumrechnung.",
|
||||
"enabled_by_default": false,
|
||||
"setup": {
|
||||
"fields": [
|
||||
{ "name": "use_separate_db", "label": "Eigene Modul-DB nutzen", "type": "checkbox", "required": false, "help": "Wenn aktiv, werden die DB-Daten unten verwendet. Sonst wird die Nexus-Base-DB genutzt." },
|
||||
{ "name": "db.driver", "label": "DB Driver", "type": "text", "required": false, "help": "z.B. pgsql, mysql, sqlite" },
|
||||
{ "name": "db.host", "label": "DB Host", "type": "text", "required": false },
|
||||
{ "name": "db.port", "label": "DB Port", "type": "number", "required": false },
|
||||
{ "name": "db.dbname", "label": "DB Name", "type": "text", "required": false },
|
||||
{ "name": "db.schema", "label": "DB Schema", "type": "text", "required": false },
|
||||
{ "name": "db.user", "label": "DB User", "type": "text", "required": false },
|
||||
{ "name": "db.password", "label": "DB Passwort", "type": "password", "required": false },
|
||||
{ "name": "report_currency", "label": "Standard-Berichtswahrung", "type": "text", "required": false, "help": "Zielwaehrung fuer Portfolio-Summen, z.B. EUR." },
|
||||
{ "name": "fx_max_age_hours", "label": "Maximales FX-Alter (Stunden)", "type": "number", "required": false, "help": "Wird bei manueller Aktualisierung ueber das Modul fx-rates genutzt." },
|
||||
{ "name": "alpha_vantage_api_key", "label": "Alpha Vantage API Key", "type": "password", "required": false, "help": "API Key fuer Aktienkursabrufe und Suche ueber Alpha Vantage." },
|
||||
{ "name": "alpha_vantage_timeout_sec", "label": "Alpha Vantage Timeout (Sek.)", "type": "number", "required": false, "help": "HTTP-Timeout fuer API-Abrufe." },
|
||||
{ "name": "alpha_vantage_min_interval_minutes", "label": "Alpha Vantage Mindestabstand (Min.)", "type": "number", "required": false, "help": "Wenn bereits ein frischer Alpha-Vantage-Kurs existiert, wird dieser wiederverwendet statt erneut abzurufen." },
|
||||
{ "name": "auto_refresh_quotes_enabled", "label": "Automatischen Kursabruf aktivieren", "type": "checkbox", "required": false, "help": "Fuehrt Kursupdates automatisch beim ersten Modulaufruf nach Ablauf des Intervalls aus." },
|
||||
{ "name": "auto_refresh_quotes_interval_hours", "label": "Intervall fuer automatischen Kursabruf (Stunden)", "type": "number", "required": false, "help": "Nach Ablauf dieses Intervalls wird beim naechsten Modulaufruf ein automatischer Kursabruf gestartet." }
|
||||
]
|
||||
},
|
||||
"interval_tasks": [
|
||||
{
|
||||
"name": "auto_refresh_quotes",
|
||||
"label": "Automatischer Kursabruf",
|
||||
"callback": "scheduled_refresh_quotes",
|
||||
"enabled_setting": "auto_refresh_quotes_enabled",
|
||||
"interval_setting": "auto_refresh_quotes_interval_hours",
|
||||
"default_enabled": false,
|
||||
"default_interval_hours": 6,
|
||||
"lock_minutes": 20
|
||||
}
|
||||
],
|
||||
"db_defaults": {
|
||||
"driver": "pgsql",
|
||||
"host": "localhost",
|
||||
"port": 5432,
|
||||
"dbname": "",
|
||||
"schema": "public",
|
||||
"user": "",
|
||||
"password": ""
|
||||
},
|
||||
"auth": {
|
||||
"required": true,
|
||||
"users": [],
|
||||
"groups": []
|
||||
}
|
||||
}
|
||||
13
modules/boersenchecker/pages/aktienverwaltung.php
Normal file
13
modules/boersenchecker/pages/aktienverwaltung.php
Normal file
@@ -0,0 +1,13 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
require_auth();
|
||||
|
||||
$assets = app()->assets();
|
||||
if ($assets) {
|
||||
$assets->addStyle('/module/boersenchecker/asset?file=boersenchecker.css');
|
||||
$assets->addScript('/module/boersenchecker/asset?file=boersenchecker.js', 'footer', true);
|
||||
}
|
||||
|
||||
$page = new \Modules\Boersenchecker\Support\InstrumentPage();
|
||||
module_tpl('boersenchecker', 'instruments', $page->handle());
|
||||
30
modules/boersenchecker/pages/asset.php
Normal file
30
modules/boersenchecker/pages/asset.php
Normal file
@@ -0,0 +1,30 @@
|
||||
<?php
|
||||
$file = (string)($_GET['file'] ?? '');
|
||||
$base = realpath(__DIR__ . '/../assets');
|
||||
$map = [
|
||||
'boersenchecker.css' => $base . '/boersenchecker.css',
|
||||
'boersenchecker.js' => $base . '/boersenchecker.js',
|
||||
];
|
||||
|
||||
if (!isset($map[$file])) {
|
||||
http_response_code(404);
|
||||
exit('Not found');
|
||||
}
|
||||
|
||||
$path = $map[$file];
|
||||
if (!$base || !is_file($path) || !str_starts_with($path, $base)) {
|
||||
http_response_code(404);
|
||||
exit('Not found');
|
||||
}
|
||||
|
||||
$ext = pathinfo($path, PATHINFO_EXTENSION);
|
||||
if ($ext === 'css') {
|
||||
header('Content-Type: text/css; charset=utf-8');
|
||||
} elseif ($ext === 'js') {
|
||||
header('Content-Type: application/javascript; charset=utf-8');
|
||||
} else {
|
||||
header('Content-Type: application/octet-stream');
|
||||
}
|
||||
|
||||
readfile($path);
|
||||
exit;
|
||||
130
modules/boersenchecker/pages/chart_data.php
Normal file
130
modules/boersenchecker/pages/chart_data.php
Normal file
@@ -0,0 +1,130 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
require_auth();
|
||||
|
||||
$user = auth_user() ?? [];
|
||||
$ownerSub = trim((string) ($user['sub'] ?? 'local'));
|
||||
|
||||
$instrumentId = (int) ($_GET['instrument_id'] ?? 0);
|
||||
if ($instrumentId <= 0) {
|
||||
header('Content-Type: application/json; charset=utf-8');
|
||||
echo json_encode(['ok' => false, 'message' => 'instrument_id fehlt.'], JSON_UNESCAPED_UNICODE);
|
||||
exit;
|
||||
}
|
||||
|
||||
$pdo = module_fn('boersenchecker', 'pdo');
|
||||
module_fn('boersenchecker', 'ensure_schema');
|
||||
$instrumentTable = module_fn('boersenchecker', 'table', 'instruments');
|
||||
$positionTable = module_fn('boersenchecker', 'table', 'positions');
|
||||
$quoteTable = module_fn('boersenchecker', 'table', 'quotes');
|
||||
|
||||
$stmt = $pdo->prepare(
|
||||
'SELECT i.id, i.name, i.symbol, i.isin, i.quote_currency
|
||||
FROM ' . $instrumentTable . ' i
|
||||
INNER JOIN ' . $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' => $ownerSub,
|
||||
]);
|
||||
$instrument = $stmt->fetch(PDO::FETCH_ASSOC);
|
||||
|
||||
header('Content-Type: application/json; charset=utf-8');
|
||||
if (!is_array($instrument)) {
|
||||
echo json_encode(['ok' => false, 'message' => 'Aktie nicht verfuegbar.'], JSON_UNESCAPED_UNICODE);
|
||||
exit;
|
||||
}
|
||||
|
||||
$quoteStmt = $pdo->prepare(
|
||||
'SELECT id, price, currency, quoted_at, source, created_at
|
||||
FROM ' . $quoteTable . '
|
||||
WHERE instrument_id = :instrument_id
|
||||
ORDER BY quoted_at ASC, created_at ASC, id ASC'
|
||||
);
|
||||
$quoteStmt->execute([
|
||||
'instrument_id' => $instrumentId,
|
||||
]);
|
||||
$quotes = $quoteStmt->fetchAll(PDO::FETCH_ASSOC) ?: [];
|
||||
|
||||
if ($quotes === []) {
|
||||
echo json_encode([
|
||||
'ok' => false,
|
||||
'message' => 'Keine lokalen Kursdaten fuer diese Aktie vorhanden.',
|
||||
], JSON_UNESCAPED_UNICODE);
|
||||
exit;
|
||||
}
|
||||
|
||||
$dailyMap = [];
|
||||
foreach ($quotes as $quote) {
|
||||
$localDate = trim((string) module_fn(
|
||||
'boersenchecker',
|
||||
'format_datetime_for_display',
|
||||
(string) ($quote['quoted_at'] ?? ''),
|
||||
(string) ($quote['source'] ?? ''),
|
||||
'Y-m-d'
|
||||
));
|
||||
$localDateTime = trim((string) module_fn(
|
||||
'boersenchecker',
|
||||
'format_datetime_for_display',
|
||||
(string) ($quote['quoted_at'] ?? ''),
|
||||
(string) ($quote['source'] ?? ''),
|
||||
'Y-m-d H:i:s'
|
||||
));
|
||||
if ($localDate === '' || !is_numeric($quote['price'] ?? null)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$point = [
|
||||
'date' => $localDate,
|
||||
'close' => (float) $quote['price'],
|
||||
'currency' => strtoupper(trim((string) ($quote['currency'] ?? ''))),
|
||||
'quoted_at' => $localDateTime,
|
||||
'source' => (string) ($quote['source'] ?? ''),
|
||||
];
|
||||
|
||||
if (!isset($dailyMap[$localDate]) || strcmp($localDateTime, (string) ($dailyMap[$localDate]['quoted_at'] ?? '')) >= 0) {
|
||||
$dailyMap[$localDate] = $point;
|
||||
}
|
||||
}
|
||||
|
||||
$daily = array_values($dailyMap);
|
||||
usort($daily, static fn (array $left, array $right): int => strcmp((string) $left['date'], (string) $right['date']));
|
||||
|
||||
if ($daily === []) {
|
||||
echo json_encode([
|
||||
'ok' => false,
|
||||
'message' => 'Keine gueltigen lokalen Schlusskurse fuer diese Aktie vorhanden.',
|
||||
], JSON_UNESCAPED_UNICODE);
|
||||
exit;
|
||||
}
|
||||
|
||||
$aggregate = static function (array $points, string $format): array {
|
||||
$result = [];
|
||||
$timezone = new DateTimeZone(nexus_display_timezone_name());
|
||||
foreach ($points as $point) {
|
||||
$date = DateTimeImmutable::createFromFormat('Y-m-d', (string) ($point['date'] ?? ''), $timezone);
|
||||
if (!$date instanceof DateTimeImmutable) {
|
||||
continue;
|
||||
}
|
||||
$bucket = $date->format($format);
|
||||
$result[$bucket] = $point;
|
||||
}
|
||||
return array_values($result);
|
||||
};
|
||||
|
||||
echo json_encode([
|
||||
'ok' => true,
|
||||
'symbol' => strtoupper(trim((string) ($instrument['symbol'] ?? ''))),
|
||||
'isin' => strtoupper(trim((string) ($instrument['isin'] ?? ''))),
|
||||
'instrument_name' => (string) ($instrument['name'] ?? ''),
|
||||
'currency' => strtoupper(trim((string) ($instrument['quote_currency'] ?? ''))),
|
||||
'daily' => $daily,
|
||||
'weekly' => $aggregate($daily, 'o-W'),
|
||||
'monthly' => $aggregate($daily, 'Y-m'),
|
||||
'source' => 'database:quotes',
|
||||
'source_label' => 'Lokale Kurshistorie',
|
||||
], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
|
||||
exit;
|
||||
13
modules/boersenchecker/pages/depotverwaltung.php
Normal file
13
modules/boersenchecker/pages/depotverwaltung.php
Normal file
@@ -0,0 +1,13 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
require_auth();
|
||||
|
||||
$assets = app()->assets();
|
||||
if ($assets) {
|
||||
$assets->addStyle('/module/boersenchecker/asset?file=boersenchecker.css');
|
||||
$assets->addScript('/module/boersenchecker/asset?file=boersenchecker.js', 'footer', true);
|
||||
}
|
||||
|
||||
$page = new \Modules\Boersenchecker\Support\DashboardPage();
|
||||
module_tpl('boersenchecker', 'dashboard', $page->handle());
|
||||
13
modules/boersenchecker/pages/index.php
Normal file
13
modules/boersenchecker/pages/index.php
Normal file
@@ -0,0 +1,13 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
require_auth();
|
||||
|
||||
$assets = app()->assets();
|
||||
if ($assets) {
|
||||
$assets->addStyle('/module/boersenchecker/asset?file=boersenchecker.css');
|
||||
$assets->addScript('/module/boersenchecker/asset?file=boersenchecker.js', 'footer', true);
|
||||
}
|
||||
|
||||
$page = new \Modules\Boersenchecker\Support\HomePage();
|
||||
module_tpl('boersenchecker', 'home', $page->handle());
|
||||
512
modules/boersenchecker/partials/dashboard.php
Normal file
512
modules/boersenchecker/partials/dashboard.php
Normal file
@@ -0,0 +1,512 @@
|
||||
<?php $ownerQuery = $isAdmin ? '?owner_sub=' . urlencode((string) $ownerSub) : ''; ?>
|
||||
<?= module_shell_header('boersenchecker', [
|
||||
'title' => 'Depotverwaltung',
|
||||
]) ?>
|
||||
<div class="bc-page">
|
||||
<?php if ($error): ?>
|
||||
<section class="section-box"><div class="bc-alert bc-alert--error"><?= e($error) ?></div></section>
|
||||
<?php elseif ($notice): ?>
|
||||
<section class="section-box"><div class="bc-alert bc-alert--success"><?= e($notice) ?></div></section>
|
||||
<?php endif; ?>
|
||||
|
||||
<?php if ($isAdmin): ?>
|
||||
<section class="section-box">
|
||||
<div class="bc-section-head">
|
||||
<div>
|
||||
<h2 class="bc-section-title">Benutzer-Scope</h2>
|
||||
<p>Depots anderer Benutzer sind nur fuer `appadmin` sichtbar und bearbeitbar.</p>
|
||||
</div>
|
||||
</div>
|
||||
<form method="get" style="margin-top:16px; display:flex; gap:10px; flex-wrap:wrap; align-items:end;">
|
||||
<label class="setup-field muted" style="margin:0; min-width:260px;">
|
||||
<span>Depots von Benutzer</span>
|
||||
<select name="owner_sub">
|
||||
<?php foreach ($availableOwners as $owner): ?>
|
||||
<option value="<?= e((string) $owner['sub']) ?>" <?= (string) $ownerSub === (string) $owner['sub'] ? 'selected' : '' ?>>
|
||||
<?= e((string) $owner['label']) ?>
|
||||
</option>
|
||||
<?php endforeach; ?>
|
||||
</select>
|
||||
</label>
|
||||
<button class="bc-button bc-button--primary" type="submit">Anzeigen</button>
|
||||
</form>
|
||||
</section>
|
||||
<?php endif; ?>
|
||||
|
||||
<div class="bc-card-grid">
|
||||
<section class="section-box">
|
||||
<div class="bc-section-head">
|
||||
<div>
|
||||
<h2 class="bc-section-title"><?= $editPortfolio ? 'Depot bearbeiten' : 'Neues Depot' ?></h2>
|
||||
<p>Stammdaten und Berichtswahrung fuer ein Depot pflegen.</p>
|
||||
</div>
|
||||
</div>
|
||||
<form method="post" style="margin-top:16px; display:grid; gap:12px;">
|
||||
<input type="hidden" name="action" value="save_portfolio">
|
||||
<input type="hidden" name="owner_sub" value="<?= e((string) $ownerSub) ?>">
|
||||
<input type="hidden" name="portfolio_id" value="<?= e((string) ($editPortfolio['id'] ?? '0')) ?>">
|
||||
<label class="setup-field muted">
|
||||
<span>Depotname</span>
|
||||
<input type="text" name="portfolio_name" value="<?= e((string) ($editPortfolio['name'] ?? '')) ?>" required>
|
||||
</label>
|
||||
<label class="setup-field muted">
|
||||
<span>Berichtswahrung</span>
|
||||
<input type="text" name="portfolio_base_currency" value="<?= e((string) ($editPortfolio['base_currency'] ?? $defaultReportCurrency)) ?>" required>
|
||||
</label>
|
||||
<label class="setup-field muted">
|
||||
<span>Notizen</span>
|
||||
<textarea name="portfolio_notes" rows="3"><?= e((string) ($editPortfolio['notes'] ?? '')) ?></textarea>
|
||||
</label>
|
||||
<div class="bc-actions">
|
||||
<button class="bc-button bc-button--primary" type="submit">Depot speichern</button>
|
||||
<?php if ($editPortfolio): ?>
|
||||
<a class="bc-button bc-button--secondary" href="/module/boersenchecker/depotverwaltung<?= e($ownerQuery) ?>">Abbrechen</a>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<section class="section-box">
|
||||
<div class="bc-section-head">
|
||||
<div>
|
||||
<h2 class="bc-section-title">API / FX</h2>
|
||||
<p>Kurs- und Waehrungsdaten zentral aktualisieren.</p>
|
||||
</div>
|
||||
</div>
|
||||
<p class="muted" style="margin-top:16px;">
|
||||
Die Umrechnung liest gespeicherte FX-Daten zentral aus dem Modul fx-rates. Eine Aktualisierung wird nur manuell
|
||||
angestossen und respektiert die dortige Max-Age- und Reuse-Logik.
|
||||
</p>
|
||||
<p class="muted" style="margin-top:12px;">
|
||||
Aktienkurse werden ueber Alpha Vantage anhand des hinterlegten Symbols abgerufen. Die ISIN bleibt als Stammdatum erhalten.
|
||||
</p>
|
||||
<div class="bc-actions" style="margin-top:16px;">
|
||||
<form method="post">
|
||||
<input type="hidden" name="action" value="refresh_fx">
|
||||
<input type="hidden" name="owner_sub" value="<?= e((string) $ownerSub) ?>">
|
||||
<button class="bc-button bc-button--primary" type="submit">FX-Daten aktualisieren</button>
|
||||
</form>
|
||||
<form method="post">
|
||||
<input type="hidden" name="action" value="refresh_market_data_all">
|
||||
<input type="hidden" name="owner_sub" value="<?= e((string) $ownerSub) ?>">
|
||||
<button class="bc-button bc-button--secondary" type="submit">Alle API-Kurse abrufen</button>
|
||||
</form>
|
||||
</div>
|
||||
<div class="muted" style="margin-top:12px;">
|
||||
Alpha Vantage Mindestabstand: <?= e((string) $marketDataMinIntervalMinutes) ?> Min.
|
||||
</div>
|
||||
<div class="muted" style="margin-top:6px;">
|
||||
API-Key und Timeout fuer Aktienkurse werden ueber <a href="/modules/setup/boersenchecker">dieses Modul-Setup</a> gepflegt.
|
||||
</div>
|
||||
<div class="muted" style="margin-top:6px;">
|
||||
FX-Provider, API-Key und Waehrungskatalog werden im Modul <a href="/module/fx-rates">fx-rates</a> gepflegt.
|
||||
</div>
|
||||
<div class="muted" style="margin-top:12px;">
|
||||
Standard-Berichtswahrung: <?= e($defaultReportCurrency) ?> · Max. Alter: <?= e((string) $fxMaxAgeHours) ?>h
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<section class="section-box">
|
||||
<div class="bc-section-head">
|
||||
<div>
|
||||
<h2 class="bc-section-title"><?= $editPosition ? 'Position bearbeiten' : 'Neue Position' ?></h2>
|
||||
<p>Aktienpositionen fuer ein Depot mit Kaufdaten und Kurswaehrung verwalten.</p>
|
||||
</div>
|
||||
</div>
|
||||
<?php if ($portfolios === []): ?>
|
||||
<div class="muted" style="margin-top:16px;">Bitte zuerst ein Depot anlegen.</div>
|
||||
<?php else: ?>
|
||||
<form method="post" style="margin-top:16px; display:grid; gap:12px;">
|
||||
<input type="hidden" name="action" value="save_position">
|
||||
<input type="hidden" name="owner_sub" value="<?= e((string) $ownerSub) ?>">
|
||||
<input type="hidden" name="position_id" value="<?= e((string) ($editPosition['id'] ?? '0')) ?>">
|
||||
<input type="hidden" name="instrument_id" value="<?= e((string) ($editPosition['instrument_id'] ?? '0')) ?>">
|
||||
<label class="setup-field muted">
|
||||
<span>Depot</span>
|
||||
<select name="portfolio_id" required>
|
||||
<option value="">Bitte waehlen</option>
|
||||
<?php foreach ($portfolios as $portfolio): ?>
|
||||
<option value="<?= e((string) $portfolio['id']) ?>" <?= (string) ($editPosition['portfolio_id'] ?? '') === (string) $portfolio['id'] ? 'selected' : '' ?>>
|
||||
<?= e((string) $portfolio['name']) ?> (<?= e((string) $portfolio['base_currency']) ?>)
|
||||
</option>
|
||||
<?php endforeach; ?>
|
||||
</select>
|
||||
</label>
|
||||
<div class="grid" style="grid-template-columns:repeat(auto-fit, minmax(180px, 1fr)); gap:10px;">
|
||||
<label class="setup-field muted">
|
||||
<span>Aktienname</span>
|
||||
<input type="text" name="instrument_name" value="<?= e((string) ($editPosition['instrument_name'] ?? '')) ?>" required>
|
||||
</label>
|
||||
<label class="setup-field muted">
|
||||
<span>API-Symbol / Ticker</span>
|
||||
<input type="text" name="symbol" value="<?= e((string) ($editPosition['symbol'] ?? '')) ?>" placeholder="z.B. AAPL oder MBG.DE">
|
||||
</label>
|
||||
<label class="setup-field muted">
|
||||
<span>ISIN</span>
|
||||
<input type="text" name="isin" value="<?= e((string) ($editPosition['isin'] ?? '')) ?>">
|
||||
</label>
|
||||
<label class="setup-field muted">
|
||||
<span>WKN</span>
|
||||
<input type="text" name="wkn" value="<?= e((string) ($editPosition['wkn'] ?? '')) ?>">
|
||||
</label>
|
||||
<label class="setup-field muted">
|
||||
<span>Boerse / Markt</span>
|
||||
<input type="text" name="market" value="<?= e((string) ($editPosition['market'] ?? '')) ?>">
|
||||
</label>
|
||||
<label class="setup-field muted">
|
||||
<span>Kurswaehrung</span>
|
||||
<input type="text" name="quote_currency" value="<?= e((string) ($editPosition['quote_currency'] ?? $defaultReportCurrency)) ?>" required>
|
||||
</label>
|
||||
</div>
|
||||
<div class="grid" style="grid-template-columns:repeat(auto-fit, minmax(180px, 1fr)); gap:10px;">
|
||||
<label class="setup-field muted">
|
||||
<span>Stueckzahl</span>
|
||||
<input type="number" name="quantity" min="0" step="0.000001" value="<?= e((string) ($editPosition['quantity'] ?? '')) ?>" required>
|
||||
</label>
|
||||
<label class="setup-field muted">
|
||||
<span>Kaufpreis</span>
|
||||
<input type="number" name="purchase_price" min="0" step="0.00000001" value="<?= e((string) ($editPosition['purchase_price'] ?? '')) ?>" required>
|
||||
</label>
|
||||
<label class="setup-field muted">
|
||||
<span>Kaufwaehrung</span>
|
||||
<input type="text" name="purchase_currency" value="<?= e((string) ($editPosition['purchase_currency'] ?? $defaultReportCurrency)) ?>" required>
|
||||
</label>
|
||||
<label class="setup-field muted">
|
||||
<span>Kaufdatum</span>
|
||||
<input type="date" name="purchase_date" value="<?= e((string) ($editPosition['purchase_date'] ?? date('Y-m-d'))) ?>" required>
|
||||
</label>
|
||||
<label class="setup-field muted">
|
||||
<span>Gebuehren</span>
|
||||
<input type="number" name="fees" min="0" step="0.00000001" value="<?= e((string) ($editPosition['fees'] ?? '')) ?>">
|
||||
</label>
|
||||
</div>
|
||||
<label class="setup-field muted">
|
||||
<span>Notizen</span>
|
||||
<textarea name="position_notes" rows="3"><?= e((string) ($editPosition['notes'] ?? '')) ?></textarea>
|
||||
</label>
|
||||
<div class="bc-actions">
|
||||
<button class="bc-button bc-button--primary" type="submit">Position speichern</button>
|
||||
<?php if ($editPosition): ?>
|
||||
<a class="bc-button bc-button--secondary" href="/module/boersenchecker/depotverwaltung<?= e($ownerQuery) ?>">Abbrechen</a>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
</form>
|
||||
<?php endif; ?>
|
||||
</section>
|
||||
|
||||
<section class="section-box">
|
||||
<div class="bc-section-head">
|
||||
<div>
|
||||
<h2 class="bc-section-title">Wertpapiersuche</h2>
|
||||
<p>Alpha-Vantage-Suchergebnisse pruefen und Daten direkt ins Positionsformular uebernehmen.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="bc-section-copy">
|
||||
<form method="post" style="margin-top:16px; display:flex; gap:10px; flex-wrap:wrap; align-items:end;">
|
||||
<input type="hidden" name="action" value="search_symbol">
|
||||
<input type="hidden" name="owner_sub" value="<?= e((string) $ownerSub) ?>">
|
||||
<label class="setup-field muted" style="margin:0; min-width:260px; flex:1;">
|
||||
<span>Suchbegriff</span>
|
||||
<input type="text" name="search_keywords" value="<?= e($symbolSearchKeywords) ?>" placeholder="z.B. Mercedes, AAPL, Allianz" required>
|
||||
</label>
|
||||
<button class="bc-button bc-button--primary" type="submit">Suchen</button>
|
||||
</form>
|
||||
</div>
|
||||
<?php if ($symbolSearchResults !== []): ?>
|
||||
<table class="bc-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Symbol</th>
|
||||
<th>Name</th>
|
||||
<th>Typ</th>
|
||||
<th>Region</th>
|
||||
<th>Waehrung</th>
|
||||
<th>Match</th>
|
||||
<th>Aktion</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<?php foreach ($symbolSearchResults as $result): ?>
|
||||
<tr>
|
||||
<td><strong><?= e((string) ($result['symbol'] ?? '')) ?></strong></td>
|
||||
<td><?= e((string) ($result['name'] ?? '')) ?></td>
|
||||
<td><?= e((string) ($result['type'] ?? '')) ?></td>
|
||||
<td><?= e((string) ($result['region'] ?? '')) ?></td>
|
||||
<td><?= e((string) ($result['currency'] ?? '')) ?></td>
|
||||
<td><?= e((string) ($result['match_score'] ?? '')) ?></td>
|
||||
<td>
|
||||
<a class="bc-button bc-button--secondary" href="/module/boersenchecker/depotverwaltung?owner_sub=<?= urlencode((string) $ownerSub) ?>&symbol_candidate=<?= urlencode((string) ($result['symbol'] ?? '')) ?>&instrument_name_candidate=<?= urlencode((string) ($result['name'] ?? '')) ?>&isin_candidate=<?= urlencode((string) ($result['isin'] ?? '')) ?>&market_candidate=<?= urlencode((string) ($result['region'] ?? '')) ?>"e_currency_candidate=<?= urlencode((string) ($result['currency'] ?? '')) ?>">
|
||||
In Formular uebernehmen
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
<?php endforeach; ?>
|
||||
</tbody>
|
||||
</table>
|
||||
<?php else: ?>
|
||||
<div class="bc-section-copy">
|
||||
<div class="muted">Noch keine Symbolsuche ausgefuehrt.</div>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
</section>
|
||||
|
||||
<section class="section-box">
|
||||
<div class="bc-section-head">
|
||||
<div>
|
||||
<h2 class="bc-section-title">Manuellen Kurs erfassen</h2>
|
||||
<p>Kurse mit Uhrzeit und Quelle direkt in die Historie schreiben.</p>
|
||||
</div>
|
||||
</div>
|
||||
<?php if ($instrumentList === []): ?>
|
||||
<div class="muted" style="margin-top:16px;">Sobald Positionen vorhanden sind, koennen hier Kurse mit Uhrzeit gespeichert werden.</div>
|
||||
<?php else: ?>
|
||||
<form method="post" style="margin-top:16px; display:grid; gap:12px;">
|
||||
<input type="hidden" name="action" value="save_quote">
|
||||
<input type="hidden" name="owner_sub" value="<?= e((string) $ownerSub) ?>">
|
||||
<div class="grid" style="grid-template-columns:repeat(auto-fit, minmax(180px, 1fr)); gap:10px;">
|
||||
<label class="setup-field muted">
|
||||
<span>Aktie</span>
|
||||
<select name="quote_instrument_id" required>
|
||||
<option value="">Bitte waehlen</option>
|
||||
<?php foreach ($instrumentList as $instrument): ?>
|
||||
<option value="<?= e((string) $instrument['id']) ?>" <?= $selectedInstrumentForQuote === (int) $instrument['id'] ? 'selected' : '' ?>>
|
||||
<?= e((string) $instrument['name']) ?><?= $instrument['symbol'] !== '' ? ' (' . e((string) $instrument['symbol']) . ')' : '' ?>
|
||||
</option>
|
||||
<?php endforeach; ?>
|
||||
</select>
|
||||
</label>
|
||||
<label class="setup-field muted">
|
||||
<span>Kurs</span>
|
||||
<input type="number" name="quote_price" min="0" step="0.00000001" required>
|
||||
</label>
|
||||
<label class="setup-field muted">
|
||||
<span>Waehrung</span>
|
||||
<input type="text" name="quote_currency" value="<?= e($selectedInstrumentQuoteCurrency) ?>" required>
|
||||
</label>
|
||||
<label class="setup-field muted">
|
||||
<span>Zeitpunkt</span>
|
||||
<input type="datetime-local" name="quoted_at" value="<?= e($localNowInputValue) ?>" required>
|
||||
</label>
|
||||
<label class="setup-field muted">
|
||||
<span>Quelle</span>
|
||||
<input type="text" name="quote_source" value="manual">
|
||||
</label>
|
||||
</div>
|
||||
<div class="bc-actions">
|
||||
<button class="bc-button bc-button--primary" type="submit">Kurs speichern</button>
|
||||
</div>
|
||||
</form>
|
||||
<?php endif; ?>
|
||||
</section>
|
||||
|
||||
<section class="section-box">
|
||||
<div class="bc-section-head">
|
||||
<div>
|
||||
<h2 class="bc-section-title">Depots</h2>
|
||||
<p>Uebersicht aller Depots mit Kennzahlen und Schnellaktionen.</p>
|
||||
</div>
|
||||
</div>
|
||||
<?php if ($portfolios === []): ?>
|
||||
<div class="muted" style="margin-top:16px;">Noch keine Depots vorhanden.</div>
|
||||
<?php else: ?>
|
||||
<div class="bc-card-grid" style="margin-top:16px;">
|
||||
<?php foreach ($portfolios as $portfolio): ?>
|
||||
<?php
|
||||
$portfolioId = (int) $portfolio['id'];
|
||||
$stats = $portfolioStats[$portfolioId] ?? ['positions' => 0, 'invested' => 0.0, 'current' => 0.0, 'gain' => null, 'has_invested' => false, 'has_current' => false];
|
||||
?>
|
||||
<section class="card-box">
|
||||
<div style="display:flex; align-items:center; justify-content:space-between; gap:10px; flex-wrap:wrap;">
|
||||
<div>
|
||||
<strong><?= e((string) $portfolio['name']) ?></strong>
|
||||
<div class="muted"><?= e((string) $portfolio['base_currency']) ?> · <?= e((string) $stats['positions']) ?> Position(en)</div>
|
||||
</div>
|
||||
<div class="bc-actions">
|
||||
<a class="bc-button bc-button--secondary" href="/module/boersenchecker/depotverwaltung?owner_sub=<?= urlencode((string) $ownerSub) ?>&edit_portfolio=<?= e((string) $portfolioId) ?>">Bearbeiten</a>
|
||||
<form method="post" onsubmit="return confirm('Depot wirklich loeschen?')">
|
||||
<input type="hidden" name="action" value="delete_portfolio">
|
||||
<input type="hidden" name="owner_sub" value="<?= e((string) $ownerSub) ?>">
|
||||
<input type="hidden" name="portfolio_id" value="<?= e((string) $portfolioId) ?>">
|
||||
<button class="bc-button bc-button--secondary" type="submit">Loeschen</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
<?php if (!empty($portfolio['notes'])): ?>
|
||||
<div class="muted" style="margin-top:10px;"><?= e((string) $portfolio['notes']) ?></div>
|
||||
<?php endif; ?>
|
||||
<div class="grid" style="margin-top:16px; grid-template-columns:repeat(auto-fit, minmax(140px, 1fr)); gap:10px;">
|
||||
<div class="bc-stat">
|
||||
<div class="muted">Investiert</div>
|
||||
<strong><?= $stats['has_invested'] ? e($fmtNumber((float) $stats['invested'])) . ' ' . e((string) $portfolio['base_currency']) : 'n/a' ?></strong>
|
||||
</div>
|
||||
<div class="bc-stat">
|
||||
<div class="muted">Aktuell</div>
|
||||
<strong><?= $stats['has_current'] ? e($fmtNumber((float) $stats['current'])) . ' ' . e((string) $portfolio['base_currency']) : 'n/a' ?></strong>
|
||||
</div>
|
||||
<div class="bc-stat">
|
||||
<div class="muted">Gewinn / Verlust</div>
|
||||
<strong><?= $stats['gain'] !== null ? e($fmtNumber((float) $stats['gain'])) . ' ' . e((string) $portfolio['base_currency']) : 'n/a' ?></strong>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
</section>
|
||||
|
||||
<section class="section-box">
|
||||
<div class="bc-section-head">
|
||||
<div>
|
||||
<h2 class="bc-section-title">Positionen</h2>
|
||||
<p>Alle Positionen mit Kaufdaten, letztem Kurs und aktuellen Werten.</p>
|
||||
</div>
|
||||
</div>
|
||||
<?php if ($positions === []): ?>
|
||||
<div class="bc-section-copy">
|
||||
<div class="muted">Noch keine Positionen vorhanden.</div>
|
||||
</div>
|
||||
<?php else: ?>
|
||||
<table class="bc-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Depot</th>
|
||||
<th>Aktie</th>
|
||||
<th>ISIN / WKN</th>
|
||||
<th>Stueck</th>
|
||||
<th>Kauf</th>
|
||||
<th>Letzter Kurs</th>
|
||||
<th>Wert</th>
|
||||
<th>Delta</th>
|
||||
<th>Aktion</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<?php foreach ($positions as $position): ?>
|
||||
<tr>
|
||||
<td><?= e((string) ($portfolioById[(int) $position['portfolio_id']]['name'] ?? '')) ?></td>
|
||||
<td>
|
||||
<strong><?= e((string) $position['instrument_name']) ?></strong>
|
||||
<?php if (!empty($position['symbol'])): ?>
|
||||
<div class="muted"><?= e((string) $position['symbol']) ?></div>
|
||||
<?php endif; ?>
|
||||
</td>
|
||||
<td>
|
||||
<?= e((string) ($position['isin'] ?: '-')) ?>
|
||||
<div class="muted"><?= e((string) ($position['wkn'] ?: '-')) ?></div>
|
||||
</td>
|
||||
<td><?= e($fmtNumber((float) $position['quantity'], 6)) ?></td>
|
||||
<td>
|
||||
<?= e($fmtNumber((float) $position['purchase_price'], 4)) ?> <?= e((string) $position['purchase_currency']) ?>
|
||||
<div class="muted"><?= e((string) $position['purchase_date']) ?></div>
|
||||
</td>
|
||||
<td>
|
||||
<?php if ($position['latest_price'] !== null): ?>
|
||||
<?= e($fmtNumber((float) $position['latest_price'], 4)) ?> <?= e((string) $position['latest_currency']) ?>
|
||||
<div class="muted"><?= e($fmtDateTime((string) $position['latest_quoted_at'], (string) ($position['latest_source'] ?? ''))) ?></div>
|
||||
<?php else: ?>
|
||||
<span class="muted">kein Kurs</span>
|
||||
<?php endif; ?>
|
||||
</td>
|
||||
<td>
|
||||
<?php if ($position['current_total_base'] !== null): ?>
|
||||
<?= e($fmtNumber((float) $position['current_total_base'])) ?> <?= e((string) $position['base_currency']) ?>
|
||||
<?php else: ?>
|
||||
<span class="muted">n/a</span>
|
||||
<?php endif; ?>
|
||||
</td>
|
||||
<td>
|
||||
<?php if ($position['gain_base'] !== null): ?>
|
||||
<?= e($fmtNumber((float) $position['gain_base'])) ?> <?= e((string) $position['base_currency']) ?>
|
||||
<?php else: ?>
|
||||
<span class="muted">n/a</span>
|
||||
<?php endif; ?>
|
||||
</td>
|
||||
<td>
|
||||
<div class="bc-actions">
|
||||
<a class="bc-button bc-button--secondary" href="/module/boersenchecker/depotverwaltung?owner_sub=<?= urlencode((string) $ownerSub) ?>&edit_position=<?= e((string) $position['id']) ?>">Bearbeiten</a>
|
||||
<a class="bc-button bc-button--secondary" href="/module/boersenchecker/depotverwaltung?owner_sub=<?= urlencode((string) $ownerSub) ?>&instrument_id=<?= e((string) $position['instrument_id']) ?>">Kurs erfassen</a>
|
||||
<form method="post">
|
||||
<input type="hidden" name="action" value="refresh_market_data_position">
|
||||
<input type="hidden" name="owner_sub" value="<?= e((string) $ownerSub) ?>">
|
||||
<input type="hidden" name="position_id" value="<?= e((string) $position['id']) ?>">
|
||||
<button class="bc-button bc-button--secondary" type="submit">API-Kurs</button>
|
||||
</form>
|
||||
<form method="post" onsubmit="return confirm('Position wirklich loeschen?')">
|
||||
<input type="hidden" name="action" value="delete_position">
|
||||
<input type="hidden" name="owner_sub" value="<?= e((string) $ownerSub) ?>">
|
||||
<input type="hidden" name="position_id" value="<?= e((string) $position['id']) ?>">
|
||||
<button class="bc-button bc-button--secondary" type="submit">Loeschen</button>
|
||||
</form>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
<?php endforeach; ?>
|
||||
</tbody>
|
||||
</table>
|
||||
<?php endif; ?>
|
||||
</section>
|
||||
|
||||
<section class="section-box">
|
||||
<div class="bc-section-head">
|
||||
<div>
|
||||
<h2 class="bc-section-title">Kursverlauf</h2>
|
||||
<p>Historische Kurse pro Aktie mit Zeitstempel und Quelle.</p>
|
||||
</div>
|
||||
</div>
|
||||
<?php if ($instrumentList === []): ?>
|
||||
<div class="muted" style="margin-top:16px;">Noch keine Kursdaten vorhanden.</div>
|
||||
<?php else: ?>
|
||||
<div class="bc-card-grid" style="margin-top:16px;">
|
||||
<?php foreach ($instrumentList as $instrumentId => $instrument): ?>
|
||||
<?php $history = array_slice($quoteHistory[$instrumentId] ?? [], 0, 10); ?>
|
||||
<section class="card-box">
|
||||
<div style="display:flex; align-items:center; justify-content:space-between; gap:10px; flex-wrap:wrap;">
|
||||
<div>
|
||||
<strong><?= e((string) $instrument['name']) ?></strong>
|
||||
<div class="muted">
|
||||
<?= e((string) ($instrument['symbol'] ?: '-')) ?> · <?= e((string) ($instrument['isin'] ?: '-')) ?>
|
||||
</div>
|
||||
</div>
|
||||
<a class="bc-button bc-button--secondary" href="/module/boersenchecker/depotverwaltung?owner_sub=<?= urlencode((string) $ownerSub) ?>&instrument_id=<?= e((string) $instrumentId) ?>">Neuen Kurs erfassen</a>
|
||||
</div>
|
||||
<?php if ($history === []): ?>
|
||||
<div class="muted" style="margin-top:12px;">Noch keine historischen Kurse vorhanden.</div>
|
||||
<?php else: ?>
|
||||
<div class="bc-table-shell" style="margin-top:12px;">
|
||||
<table class="bc-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Zeitpunkt</th>
|
||||
<th>Kurs</th>
|
||||
<th>Quelle</th>
|
||||
<th>Aktion</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<?php foreach ($history as $quote): ?>
|
||||
<tr>
|
||||
<td><?= e($fmtDateTime((string) $quote['quoted_at'], (string) ($quote['source'] ?? ''))) ?></td>
|
||||
<td><?= e($fmtNumber((float) $quote['price'], 4)) ?> <?= e((string) $quote['currency']) ?></td>
|
||||
<td><?= e((string) $quote['source']) ?></td>
|
||||
<td>
|
||||
<form method="post" onsubmit="return confirm('Kurseintrag wirklich loeschen?')">
|
||||
<input type="hidden" name="action" value="delete_quote">
|
||||
<input type="hidden" name="owner_sub" value="<?= e((string) $ownerSub) ?>">
|
||||
<input type="hidden" name="quote_id" value="<?= e((string) $quote['id']) ?>">
|
||||
<button class="bc-button bc-button--secondary" type="submit">Loeschen</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
<?php endforeach; ?>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
</section>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
</section>
|
||||
</div>
|
||||
<?= module_shell_footer() ?>
|
||||
209
modules/boersenchecker/partials/home.php
Normal file
209
modules/boersenchecker/partials/home.php
Normal file
@@ -0,0 +1,209 @@
|
||||
<?= module_shell_header('boersenchecker', [
|
||||
'title' => 'Depot-Ueberblick',
|
||||
]) ?>
|
||||
<div class="bc-page" data-bc-home data-chart-endpoint="<?= e($chartEndpoint) ?>">
|
||||
<script type="application/json" data-bc-instruments-json><?= json_encode(array_map(static function (array $position): array {
|
||||
return [
|
||||
'instrument_id' => (int) ($position['instrument_id'] ?? 0),
|
||||
'instrument_name' => (string) ($position['instrument_name'] ?? ''),
|
||||
'symbol' => (string) ($position['symbol'] ?? ''),
|
||||
'isin' => (string) ($position['isin'] ?? ''),
|
||||
];
|
||||
}, $positions), JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) ?></script>
|
||||
|
||||
<?php if ($error): ?>
|
||||
<section class="section-box"><div class="bc-alert bc-alert--error"><?= e($error) ?></div></section>
|
||||
<?php elseif ($notice): ?>
|
||||
<section class="section-box"><div class="bc-alert bc-alert--success"><?= e($notice) ?></div></section>
|
||||
<?php endif; ?>
|
||||
|
||||
<section class="section-box">
|
||||
<div class="bc-section-head">
|
||||
<div>
|
||||
<h2 class="bc-section-title">Marktueberblick</h2>
|
||||
<p>Depotauswahl, Aktienfokus und aktueller Kursabruf in einem Bereich.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="bc-toolbar" style="margin-top:16px;">
|
||||
<form class="bc-panel" method="get">
|
||||
<div class="bc-field-label">Depotauswahl</div>
|
||||
<?php if ($portfolios === []): ?>
|
||||
<div class="bc-text" style="margin-top:12px;">Keine Depots vorhanden.</div>
|
||||
<?php else: ?>
|
||||
<label class="setup-field" style="margin-top:12px;">
|
||||
<span class="bc-text">Depot</span>
|
||||
<select name="portfolio_id" onchange="this.form.submit()">
|
||||
<?php foreach ($portfolios as $portfolio): ?>
|
||||
<option value="<?= e((string) $portfolio['id']) ?>" <?= (string) $selectedPortfolioId === (string) $portfolio['id'] ? 'selected' : '' ?>>
|
||||
<?= e((string) $portfolio['name']) ?>
|
||||
</option>
|
||||
<?php endforeach; ?>
|
||||
</select>
|
||||
</label>
|
||||
<?php endif; ?>
|
||||
</form>
|
||||
|
||||
<form class="bc-panel" method="get">
|
||||
<input type="hidden" name="portfolio_id" value="<?= e((string) $selectedPortfolioId) ?>">
|
||||
<div class="bc-field-label">Aktienauswahl</div>
|
||||
<?php if ($positions === []): ?>
|
||||
<div class="bc-text" style="margin-top:12px;">Keine Aktien im ausgewaehlten Depot.</div>
|
||||
<?php else: ?>
|
||||
<label class="setup-field" style="margin-top:12px;">
|
||||
<span class="bc-text">Aktie</span>
|
||||
<select name="instrument_id" data-bc-instrument onchange="this.form.submit()">
|
||||
<?php foreach ($positions as $position): ?>
|
||||
<option value="<?= e((string) $position['instrument_id']) ?>" <?= (string) $selectedInstrumentId === (string) $position['instrument_id'] ? 'selected' : '' ?>>
|
||||
<?= e((string) $position['instrument_name']) ?><?= !empty($position['symbol']) ? ' (' . e((string) $position['symbol']) . ')' : '' ?>
|
||||
</option>
|
||||
<?php endforeach; ?>
|
||||
</select>
|
||||
</label>
|
||||
<?php endif; ?>
|
||||
</form>
|
||||
|
||||
<form class="bc-panel" method="post">
|
||||
<input type="hidden" name="action" value="refresh_current_quotes_home">
|
||||
<input type="hidden" name="portfolio_id" value="<?= e((string) $selectedPortfolioId) ?>">
|
||||
<div class="bc-field-label">Marktdaten</div>
|
||||
<p class="bc-text" style="margin-top:12px;">Aktuelle Kurse fuer das gewaehlte Depot ueber Alpha Vantage anhand des hinterlegten Symbols abrufen.</p>
|
||||
<div class="bc-actions" style="margin-top:16px;">
|
||||
<button class="bc-button bc-button--primary" type="submit" <?= $selectedPortfolioId > 0 ? '' : 'disabled' ?>>Aktuelle Kurse abrufen</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div class="bc-overview-grid">
|
||||
<section class="card-box bc-stat">
|
||||
<div class="bc-field-label">Positionen</div>
|
||||
<div class="bc-stat-value"><?= e((string) ($summary['positions'] ?? 0)) ?></div>
|
||||
<div class="bc-text" style="margin-top:6px;">Aktien im aktuell gewaehlten Depot</div>
|
||||
</section>
|
||||
<section class="card-box bc-stat">
|
||||
<div class="bc-field-label">Investiert</div>
|
||||
<div class="bc-stat-value"><?= isset($summary['invested']) && $summary['invested'] !== null ? e(number_format((float) $summary['invested'], 2, ',', '.')) . ' ' . e($defaultReportCurrency) : 'n/a' ?></div>
|
||||
<div class="bc-text" style="margin-top:6px;">In Berichtswahrung bewertet</div>
|
||||
</section>
|
||||
<section class="card-box bc-stat">
|
||||
<div class="bc-field-label">Aktueller Wert</div>
|
||||
<div class="bc-stat-value"><?= isset($summary['current']) && $summary['current'] !== null ? e(number_format((float) $summary['current'], 2, ',', '.')) . ' ' . e($defaultReportCurrency) : 'n/a' ?></div>
|
||||
<div class="bc-text" style="margin-top:6px;">Basierend auf dem letzten gespeicherten Kurs</div>
|
||||
</section>
|
||||
<section class="card-box bc-stat">
|
||||
<div class="bc-field-label">Performance</div>
|
||||
<div class="bc-stat-value"><?= isset($summary['gain']) && $summary['gain'] !== null ? e(number_format((float) $summary['gain'], 2, ',', '.')) . ' ' . e($defaultReportCurrency) : 'n/a' ?></div>
|
||||
<div class="bc-text" style="margin-top:6px;"><?= !empty($summary['best']['instrument_name']) ? 'Top-Wert: ' . e((string) $summary['best']['instrument_name']) : 'Noch keine Vergleichsdaten' ?></div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<div class="bc-card-grid">
|
||||
<section class="card-box">
|
||||
<div class="bc-field-label">Bester Wert</div>
|
||||
<?php if (!empty($summary['best'])): ?>
|
||||
<div class="bc-stat-value"><?= e((string) $summary['best']['instrument_name']) ?></div>
|
||||
<div class="bc-pill-soft" style="margin-top:12px;"><?= e(number_format((float) ($summary['best']['gain_percent'] ?? 0), 2, ',', '.')) ?>%</div>
|
||||
<?php else: ?>
|
||||
<div class="bc-text" style="margin-top:12px;">Noch keine Performance verfuegbar.</div>
|
||||
<?php endif; ?>
|
||||
</section>
|
||||
|
||||
<section class="card-box">
|
||||
<div class="bc-field-label">Schwaechster Wert</div>
|
||||
<?php if (!empty($summary['worst'])): ?>
|
||||
<div class="bc-stat-value"><?= e((string) $summary['worst']['instrument_name']) ?></div>
|
||||
<div class="bc-pill-soft" style="margin-top:12px;"><?= e(number_format((float) ($summary['worst']['gain_percent'] ?? 0), 2, ',', '.')) ?>%</div>
|
||||
<?php else: ?>
|
||||
<div class="bc-text" style="margin-top:12px;">Noch keine Performance verfuegbar.</div>
|
||||
<?php endif; ?>
|
||||
</section>
|
||||
|
||||
<?php foreach (array_slice($positions, 0, 2) as $position): ?>
|
||||
<section class="card-box bc-stat">
|
||||
<div class="bc-field-label"><?= e((string) $position['instrument_name']) ?></div>
|
||||
<div class="bc-stat-value"><?= $position['latest_price'] !== null ? e(number_format((float) $position['latest_price'], 2, ',', '.')) . ' ' . e((string) $position['latest_currency']) : 'n/a' ?></div>
|
||||
<div class="bc-text" style="margin-top:6px;"><?= e((string) (($position['latest_quoted_at'] ?? '') !== '' ? $fmtDateTime((string) $position['latest_quoted_at'], (string) ($position['latest_source'] ?? '')) : 'kein Kurs')) ?></div>
|
||||
</section>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
|
||||
<section class="section-box">
|
||||
<div class="bc-section-head">
|
||||
<div>
|
||||
<h2 class="bc-section-title">Kursverlauf</h2>
|
||||
<p>Schlusskurse ueber mehrere Zeitfenster fuer das aktuell gewaehlte Instrument.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="bc-chart-card" style="margin-top:16px;">
|
||||
<div style="display:flex; justify-content:space-between; gap:12px; flex-wrap:wrap; align-items:center;">
|
||||
<div>
|
||||
<div class="bc-field-label">Aktie</div>
|
||||
<div class="bc-stat-value" data-bc-instrument-name><?= e((string) ($selectedInstrument['instrument_name'] ?? 'Keine Aktie ausgewaehlt')) ?></div>
|
||||
<?php if ($selectedInstrument): ?>
|
||||
<div class="bc-text" data-bc-instrument-meta><?= e((string) ($selectedInstrument['symbol'] ?? '')) ?> · <?= e((string) ($selectedInstrument['isin'] ?? '-')) ?></div>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
<div class="bc-range-list">
|
||||
<button type="button" class="bc-range-button" data-range="1d" aria-pressed="false">Tag</button>
|
||||
<button type="button" class="bc-range-button" data-range="5d" aria-pressed="false">5 Tage</button>
|
||||
<button type="button" class="bc-range-button" data-range="1m" aria-pressed="true">Monat</button>
|
||||
<button type="button" class="bc-range-button" data-range="3m" aria-pressed="false">3 Monate</button>
|
||||
<button type="button" class="bc-range-button" data-range="6m" aria-pressed="false">6 Monate</button>
|
||||
<button type="button" class="bc-range-button" data-range="1y" aria-pressed="false">Jahr</button>
|
||||
<button type="button" class="bc-range-button" data-range="5y" aria-pressed="false">5 Jahre</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="bc-text" data-bc-chart-status style="margin-top:12px;">Chartdaten werden geladen...</div>
|
||||
<div class="bc-stat-value" data-bc-chart-summary style="margin-top:6px;">-</div>
|
||||
<div class="bc-chart-shell" data-bc-chart style="margin-top:18px;"></div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="section-box">
|
||||
<div class="bc-section-head">
|
||||
<div>
|
||||
<h2 class="bc-section-title">Aktien im Depot</h2>
|
||||
<p>Stueckzahl, Kaufdaten, letzter Kurs und Performance auf einen Blick.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="bc-section-copy">
|
||||
<?php if ($positions === []): ?>
|
||||
<div class="bc-text" style="padding:0 0 18px;">Keine Aktien im ausgewaehlten Depot.</div>
|
||||
<?php else: ?>
|
||||
<div class="bc-position-list" style="padding:0 0 18px;">
|
||||
<?php foreach ($positions as $position): ?>
|
||||
<?php $gainClass = (($position['gain_report'] ?? 0) >= 0) ? 'is-positive' : 'is-negative'; ?>
|
||||
<div class="bc-position-row">
|
||||
<div>
|
||||
<strong><?= e((string) $position['instrument_name']) ?></strong>
|
||||
<div class="bc-text" style="margin-top:4px;"><?= e((string) ($position['symbol'] ?? '')) ?> · <?= e((string) ($position['isin'] ?? '-')) ?></div>
|
||||
<?php if (!empty($position['market'])): ?>
|
||||
<div class="bc-pill-soft" style="margin-top:10px;"><?= e((string) $position['market']) ?></div>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
<div>
|
||||
<div class="bc-field-label">Stueckzahl</div>
|
||||
<div><?= e(number_format((float) $position['quantity'], 6, ',', '.')) ?></div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="bc-field-label">Kaufpreis</div>
|
||||
<div><?= e(number_format((float) $position['purchase_price'], 2, ',', '.')) ?> <?= e((string) $position['purchase_currency']) ?></div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="bc-field-label">Letzter Kurs</div>
|
||||
<div><?= $position['latest_price'] !== null ? e(number_format((float) $position['latest_price'], 2, ',', '.')) . ' ' . e((string) $position['latest_currency']) : 'n/a' ?></div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="bc-field-label">Performance</div>
|
||||
<div class="bc-performance <?= e($gainClass) ?>">
|
||||
<?= isset($position['gain_report']) && $position['gain_report'] !== null ? e(number_format((float) $position['gain_report'], 2, ',', '.')) . ' ' . e($defaultReportCurrency) : 'n/a' ?>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
<?= module_shell_footer() ?>
|
||||
187
modules/boersenchecker/partials/instruments.php
Normal file
187
modules/boersenchecker/partials/instruments.php
Normal file
@@ -0,0 +1,187 @@
|
||||
<?= module_shell_header('boersenchecker', [
|
||||
'title' => 'Aktienverwaltung',
|
||||
]) ?>
|
||||
<div class="bc-page">
|
||||
<?php if ($error): ?>
|
||||
<section class="section-box"><div class="bc-alert bc-alert--error"><?= e($error) ?></div></section>
|
||||
<?php elseif ($notice): ?>
|
||||
<section class="section-box"><div class="bc-alert bc-alert--success"><?= e($notice) ?></div></section>
|
||||
<?php endif; ?>
|
||||
|
||||
<div class="bc-card-grid">
|
||||
<section class="section-box">
|
||||
<div class="bc-section-head">
|
||||
<div>
|
||||
<h2 class="bc-section-title">Aktie waehlen</h2>
|
||||
<p>Systemweit vorhandene Aktie aus allen Depots auswaehlen.</p>
|
||||
</div>
|
||||
</div>
|
||||
<form method="get" style="margin-top:16px;">
|
||||
<label class="setup-field muted">
|
||||
<span>Aktien aller Depots</span>
|
||||
<select name="instrument_id" onchange="this.form.submit()">
|
||||
<?php foreach ($instruments as $instrument): ?>
|
||||
<option value="<?= e((string) $instrument['id']) ?>" <?= (string) $selectedInstrumentId === (string) $instrument['id'] ? 'selected' : '' ?>>
|
||||
<?= e((string) $instrument['name']) ?><?= !empty($instrument['symbol']) ? ' (' . e((string) $instrument['symbol']) . ')' : '' ?>
|
||||
</option>
|
||||
<?php endforeach; ?>
|
||||
</select>
|
||||
</label>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<section class="section-box">
|
||||
<div class="bc-section-head">
|
||||
<div>
|
||||
<h2 class="bc-section-title">Wertpapiersuche</h2>
|
||||
<p>Alpha-Vantage-Suchergebnisse finden und direkt fuer die Aktie uebernehmen.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="bc-section-copy">
|
||||
<form method="post" style="margin-top:16px; display:flex; gap:10px; flex-wrap:wrap; align-items:end;">
|
||||
<input type="hidden" name="action" value="search_symbol">
|
||||
<input type="hidden" name="instrument_id" value="<?= e((string) $selectedInstrumentId) ?>">
|
||||
<label class="setup-field muted" style="margin:0; min-width:260px; flex:1;">
|
||||
<span>Suchbegriff</span>
|
||||
<input type="text" name="search_keywords" value="<?= e($searchKeywords) ?>" placeholder="z.B. Apple, AAPL, Allianz" required>
|
||||
</label>
|
||||
<button class="bc-button bc-button--primary" type="submit">Suchen</button>
|
||||
</form>
|
||||
</div>
|
||||
<?php if ($searchResults !== []): ?>
|
||||
<table class="bc-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Symbol</th>
|
||||
<th>Name</th>
|
||||
<th>Region</th>
|
||||
<th>Waehrung</th>
|
||||
<th>Aktion</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<?php foreach ($searchResults as $result): ?>
|
||||
<tr>
|
||||
<td><strong><?= e((string) ($result['symbol'] ?? '')) ?></strong></td>
|
||||
<td><?= e((string) ($result['name'] ?? '')) ?></td>
|
||||
<td><?= e((string) ($result['region'] ?? '')) ?></td>
|
||||
<td><?= e((string) ($result['currency'] ?? '')) ?></td>
|
||||
<td>
|
||||
<a class="bc-button bc-button--secondary" href="/module/boersenchecker/aktienverwaltung?instrument_id=<?= e((string) $selectedInstrumentId) ?>&symbol_candidate=<?= urlencode((string) ($result['symbol'] ?? '')) ?>&instrument_name_candidate=<?= urlencode((string) ($result['name'] ?? '')) ?>&isin_candidate=<?= urlencode((string) ($result['isin'] ?? '')) ?>&market_candidate=<?= urlencode((string) ($result['region'] ?? '')) ?>"e_currency_candidate=<?= urlencode((string) ($result['currency'] ?? '')) ?>">
|
||||
Uebernehmen
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
<?php endforeach; ?>
|
||||
</tbody>
|
||||
</table>
|
||||
<?php else: ?>
|
||||
<div class="bc-section-copy">
|
||||
<div class="muted">Noch keine Symbolsuche ausgefuehrt.</div>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<section class="section-box">
|
||||
<div class="bc-section-head">
|
||||
<div>
|
||||
<h2 class="bc-section-title">Aktie bearbeiten</h2>
|
||||
<p>Stammdaten, Markt und Kurswaehrung zentral fuer die Aktie pflegen.</p>
|
||||
</div>
|
||||
</div>
|
||||
<?php if (!$selectedInstrument || empty($selectedInstrument['id'])): ?>
|
||||
<div class="muted" style="margin-top:16px;">Keine Aktie vorhanden.</div>
|
||||
<?php else: ?>
|
||||
<form method="post" style="margin-top:16px; display:grid; gap:12px;">
|
||||
<input type="hidden" name="action" value="save_instrument">
|
||||
<input type="hidden" name="instrument_id" value="<?= e((string) ($selectedInstrument['id'] ?? 0)) ?>">
|
||||
<div class="grid" style="grid-template-columns:repeat(auto-fit, minmax(180px, 1fr)); gap:10px;">
|
||||
<label class="setup-field muted"><span>Name</span><input type="text" name="instrument_name" value="<?= e((string) (($selectedInstrument['name'] ?? '') ?: ($_GET['instrument_name_candidate'] ?? ''))) ?>" required></label>
|
||||
<label class="setup-field muted"><span>Symbol</span><input type="text" name="symbol" value="<?= e((string) (($selectedInstrument['symbol'] ?? '') ?: ($_GET['symbol_candidate'] ?? ''))) ?>"></label>
|
||||
<label class="setup-field muted"><span>ISIN</span><input type="text" name="isin" value="<?= e((string) $selectedInstrument['isin'] ?? '') ?>"></label>
|
||||
<label class="setup-field muted"><span>WKN</span><input type="text" name="wkn" value="<?= e((string) $selectedInstrument['wkn'] ?? '') ?>"></label>
|
||||
<label class="setup-field muted"><span>Markt</span><input type="text" name="market" value="<?= e((string) (($selectedInstrument['market'] ?? '') ?: ($_GET['market_candidate'] ?? ''))) ?>"></label>
|
||||
<label class="setup-field muted"><span>Kurswaehrung</span><input type="text" name="quote_currency" value="<?= e((string) (($selectedInstrument['quote_currency'] ?? $defaultReportCurrency) ?: ($_GET['quote_currency_candidate'] ?? $defaultReportCurrency))) ?>"></label>
|
||||
</div>
|
||||
<div class="bc-actions">
|
||||
<button class="bc-button bc-button--primary" type="submit">Aktie speichern</button>
|
||||
</div>
|
||||
</form>
|
||||
<form method="post" style="margin-top:12px;">
|
||||
<input type="hidden" name="action" value="refresh_market_data_instrument">
|
||||
<input type="hidden" name="instrument_id" value="<?= e((string) ($selectedInstrument['id'] ?? 0)) ?>">
|
||||
<button class="bc-button bc-button--secondary" type="submit">Aktuellen API-Kurs abrufen</button>
|
||||
</form>
|
||||
<?php endif; ?>
|
||||
</section>
|
||||
|
||||
<section class="section-box">
|
||||
<div class="bc-section-head">
|
||||
<div>
|
||||
<h2 class="bc-section-title">Manuellen Kurs eingeben</h2>
|
||||
<p>Einzelne Kurse mit Zeitstempel und Quelle fuer die ausgewaehlte Aktie speichern.</p>
|
||||
</div>
|
||||
</div>
|
||||
<?php if (!$selectedInstrument || empty($selectedInstrument['id'])): ?>
|
||||
<div class="muted" style="margin-top:16px;">Keine Aktie vorhanden.</div>
|
||||
<?php else: ?>
|
||||
<form method="post" style="margin-top:16px; display:grid; gap:12px;">
|
||||
<input type="hidden" name="action" value="save_quote">
|
||||
<input type="hidden" name="instrument_id" value="<?= e((string) ($selectedInstrument['id'] ?? 0)) ?>">
|
||||
<div class="grid" style="grid-template-columns:repeat(auto-fit, minmax(180px, 1fr)); gap:10px;">
|
||||
<label class="setup-field muted"><span>Kurs</span><input type="number" name="quote_price" min="0" step="0.00000001" required></label>
|
||||
<label class="setup-field muted"><span>Waehrung</span><input type="text" name="quote_currency" value="<?= e((string) ($selectedInstrument['quote_currency'] ?? $defaultReportCurrency)) ?>" required></label>
|
||||
<label class="setup-field muted"><span>Zeitpunkt</span><input type="datetime-local" name="quoted_at" value="<?= e($localNowInputValue) ?>" required></label>
|
||||
<label class="setup-field muted"><span>Quelle</span><input type="text" name="quote_source" value="manual"></label>
|
||||
</div>
|
||||
<div class="bc-actions">
|
||||
<button class="bc-button bc-button--primary" type="submit">Kurs speichern</button>
|
||||
</div>
|
||||
</form>
|
||||
<?php endif; ?>
|
||||
</section>
|
||||
|
||||
<section class="section-box">
|
||||
<div class="bc-section-head">
|
||||
<div>
|
||||
<h2 class="bc-section-title">Kursverlauf</h2>
|
||||
<p>Gespeicherte Kursdaten der ausgewaehlten Aktie mit Quelle und Loeschoption.</p>
|
||||
</div>
|
||||
</div>
|
||||
<?php if ($quotes === []): ?>
|
||||
<div class="bc-section-copy">
|
||||
<div class="muted">Keine Kursdaten vorhanden.</div>
|
||||
</div>
|
||||
<?php else: ?>
|
||||
<table class="bc-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Zeitpunkt</th>
|
||||
<th>Kurs</th>
|
||||
<th>Quelle</th>
|
||||
<th>Aktion</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<?php foreach ($quotes as $quote): ?>
|
||||
<tr>
|
||||
<td><?= e($fmtDateTime((string) $quote['quoted_at'], (string) ($quote['source'] ?? ''))) ?></td>
|
||||
<td><?= e(number_format((float) $quote['price'], 4, ',', '.')) ?> <?= e((string) $quote['currency']) ?></td>
|
||||
<td><?= e((string) $quote['source']) ?></td>
|
||||
<td>
|
||||
<form method="post" onsubmit="return confirm('Kurseintrag wirklich loeschen?')">
|
||||
<input type="hidden" name="action" value="delete_quote">
|
||||
<input type="hidden" name="instrument_id" value="<?= e((string) $selectedInstrumentId) ?>">
|
||||
<input type="hidden" name="quote_id" value="<?= e((string) $quote['id']) ?>">
|
||||
<button class="bc-button bc-button--secondary" type="submit">Loeschen</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
<?php endforeach; ?>
|
||||
</tbody>
|
||||
</table>
|
||||
<?php endif; ?>
|
||||
</section>
|
||||
</div>
|
||||
<?= module_shell_footer() ?>
|
||||
898
modules/boersenchecker/src/Support/DashboardPage.php
Normal file
898
modules/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
modules/boersenchecker/src/Support/HomePage.php
Normal file
364
modules/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' => '/module/boersenchecker/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
modules/boersenchecker/src/Support/InstrumentPage.php
Normal file
351
modules/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
modules/boersenchecker/src/Support/InstrumentRegistry.php
Normal file
190
modules/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;
|
||||
}
|
||||
}
|
||||
7
modules/fx-rates/api/index.php
Normal file
7
modules/fx-rates/api/index.php
Normal file
@@ -0,0 +1,7 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
require_once dirname(__DIR__) . '/bootstrap.php';
|
||||
|
||||
$service = module_fn('fx-rates', 'service');
|
||||
(new Modules\FxRates\Api\Router($service))->handle($_GET['path'] ?? '');
|
||||
168
modules/fx-rates/assets/fx-rates-currencies.js
Normal file
168
modules/fx-rates/assets/fx-rates-currencies.js
Normal file
@@ -0,0 +1,168 @@
|
||||
(() => {
|
||||
const root = document.getElementById('fx-rates-currencies');
|
||||
if (!root) {
|
||||
return;
|
||||
}
|
||||
|
||||
const page = JSON.parse(root.dataset.page || '{}');
|
||||
const currencies = Array.isArray(page.currencies) ? page.currencies : [];
|
||||
const selected = new Set(
|
||||
(Array.isArray(page.preferred_currencies) ? page.preferred_currencies : [])
|
||||
.map((code) => String(code || '').trim().toUpperCase())
|
||||
.filter(Boolean)
|
||||
);
|
||||
let displayBase = String(page.display_base_currency || '').trim().toUpperCase();
|
||||
const savedDisplayBase = String(page.saved_display_base_currency || displayBase || '').trim().toUpperCase();
|
||||
|
||||
const nodes = {
|
||||
tokenList: root.querySelector('[data-fx-token-list]'),
|
||||
searchInput: root.querySelector('[data-fx-search-input]'),
|
||||
suggestions: root.querySelector('[data-fx-suggestions]'),
|
||||
displayBaseSelect: root.querySelector('[data-fx-display-base-select]'),
|
||||
displayBaseHidden: root.querySelector('[data-fx-display-base-hidden]'),
|
||||
hiddenPreferred: root.querySelector('[data-fx-hidden-preferred]'),
|
||||
};
|
||||
|
||||
const escapeHtml = (value) => String(value || '')
|
||||
.replaceAll('&', '&')
|
||||
.replaceAll('<', '<')
|
||||
.replaceAll('>', '>')
|
||||
.replaceAll('"', '"')
|
||||
.replaceAll("'", ''');
|
||||
|
||||
const currencyByCode = new Map(
|
||||
currencies.map((currency) => [String(currency.code || '').toUpperCase(), currency])
|
||||
);
|
||||
|
||||
const sortedSelectedCodes = () => Array.from(selected).sort((left, right) => left.localeCompare(right));
|
||||
|
||||
const ensureDisplayBase = () => {
|
||||
const available = sortedSelectedCodes();
|
||||
if (available.length === 0) {
|
||||
displayBase = '';
|
||||
return;
|
||||
}
|
||||
if (!displayBase || !selected.has(displayBase)) {
|
||||
displayBase = available[0];
|
||||
}
|
||||
};
|
||||
|
||||
const renderHiddenInputs = () => {
|
||||
if (!nodes.hiddenPreferred) {
|
||||
return;
|
||||
}
|
||||
nodes.hiddenPreferred.innerHTML = sortedSelectedCodes()
|
||||
.map((code) => `<input type="hidden" name="preferred_currencies[]" value="${escapeHtml(code)}">`)
|
||||
.join('');
|
||||
if (nodes.displayBaseHidden) {
|
||||
nodes.displayBaseHidden.value = displayBase;
|
||||
}
|
||||
};
|
||||
|
||||
const renderDisplayBase = () => {
|
||||
if (!nodes.displayBaseSelect) {
|
||||
return;
|
||||
}
|
||||
ensureDisplayBase();
|
||||
const available = sortedSelectedCodes();
|
||||
nodes.displayBaseSelect.innerHTML = available.length
|
||||
? available.map((code) => `<option value="${escapeHtml(code)}" ${code === displayBase ? 'selected' : ''}>${escapeHtml(code)}</option>`).join('')
|
||||
: '<option value="">Keine Waehrungen ausgewaehlt</option>';
|
||||
nodes.displayBaseSelect.disabled = available.length === 0;
|
||||
};
|
||||
|
||||
const removeCode = (code) => {
|
||||
selected.delete(code);
|
||||
renderAll();
|
||||
};
|
||||
|
||||
const renderTokens = () => {
|
||||
if (!nodes.tokenList) {
|
||||
return;
|
||||
}
|
||||
const selectedCodes = sortedSelectedCodes();
|
||||
if (selectedCodes.length === 0) {
|
||||
nodes.tokenList.innerHTML = '<div class="fx-text">Noch keine bevorzugten Waehrungen ausgewaehlt.</div>';
|
||||
return;
|
||||
}
|
||||
nodes.tokenList.innerHTML = selectedCodes.map((code) => {
|
||||
const currency = currencyByCode.get(code) || { code, name: code };
|
||||
return `
|
||||
<button type="button" class="fx-token" data-remove-code="${escapeHtml(code)}" title="${escapeHtml(code)} entfernen">
|
||||
<span>${escapeHtml(`${code} (${currency.name || code})`)}</span>
|
||||
<span class="fx-token-close">x</span>
|
||||
</button>
|
||||
`;
|
||||
}).join('');
|
||||
|
||||
nodes.tokenList.querySelectorAll('[data-remove-code]').forEach((button) => {
|
||||
button.addEventListener('click', () => {
|
||||
removeCode(String(button.getAttribute('data-remove-code') || '').toUpperCase());
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
const renderSuggestions = () => {
|
||||
if (!nodes.suggestions || !nodes.searchInput) {
|
||||
return;
|
||||
}
|
||||
const needle = String(nodes.searchInput.value || '').trim().toLowerCase();
|
||||
if (!needle) {
|
||||
nodes.suggestions.innerHTML = '';
|
||||
return;
|
||||
}
|
||||
const matches = currencies
|
||||
.filter((currency) => !selected.has(String(currency.code || '').toUpperCase()))
|
||||
.filter((currency) => {
|
||||
const code = String(currency.code || '').toLowerCase();
|
||||
const name = String(currency.name || '').toLowerCase();
|
||||
return code.includes(needle) || name.includes(needle);
|
||||
})
|
||||
.slice(0, 12);
|
||||
|
||||
nodes.suggestions.innerHTML = matches.map((currency) => `
|
||||
<button type="button" class="fx-suggestion" data-add-code="${escapeHtml(String(currency.code || '').toUpperCase())}">
|
||||
<strong>${escapeHtml(String(currency.code || '').toUpperCase())}</strong>
|
||||
<span>${escapeHtml(String(currency.name || ''))}</span>
|
||||
</button>
|
||||
`).join('');
|
||||
|
||||
nodes.suggestions.querySelectorAll('[data-add-code]').forEach((button) => {
|
||||
button.addEventListener('click', () => {
|
||||
const code = String(button.getAttribute('data-add-code') || '').toUpperCase();
|
||||
if (code) {
|
||||
selected.add(code);
|
||||
if (nodes.searchInput) {
|
||||
nodes.searchInput.value = '';
|
||||
}
|
||||
renderAll();
|
||||
}
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
const renderAll = () => {
|
||||
ensureDisplayBase();
|
||||
renderTokens();
|
||||
renderDisplayBase();
|
||||
renderHiddenInputs();
|
||||
renderSuggestions();
|
||||
};
|
||||
|
||||
nodes.searchInput?.addEventListener('input', renderSuggestions);
|
||||
nodes.displayBaseSelect?.addEventListener('change', () => {
|
||||
displayBase = String(nodes.displayBaseSelect?.value || '').trim().toUpperCase();
|
||||
renderHiddenInputs();
|
||||
const url = new URL(window.location.href);
|
||||
if (displayBase) {
|
||||
url.searchParams.set('base', displayBase);
|
||||
} else if (savedDisplayBase) {
|
||||
url.searchParams.set('base', savedDisplayBase);
|
||||
} else {
|
||||
url.searchParams.delete('base');
|
||||
}
|
||||
window.location.href = url.toString();
|
||||
});
|
||||
|
||||
renderAll();
|
||||
})();
|
||||
287
modules/fx-rates/assets/fx-rates.css
Normal file
287
modules/fx-rates/assets/fx-rates.css
Normal file
@@ -0,0 +1,287 @@
|
||||
#fx-rates-app,
|
||||
#fx-rates-currencies {
|
||||
display: contents;
|
||||
}
|
||||
|
||||
.fx-section-head {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
align-items: flex-start;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.fx-section-head h2,
|
||||
.section-box h2,
|
||||
.card-box h2 {
|
||||
margin: 0 0 0.5rem;
|
||||
}
|
||||
|
||||
.fx-section-head p,
|
||||
.section-box p,
|
||||
.card-box p {
|
||||
margin: 0 0 0.75rem;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.fx-card-head {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
align-items: flex-start;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.fx-actions {
|
||||
display: flex;
|
||||
gap: 0.75rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.fx-form-grid {
|
||||
display: grid;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.fx-form-grid {
|
||||
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
|
||||
}
|
||||
|
||||
.fx-form-grid label,
|
||||
.fx-block {
|
||||
display: grid;
|
||||
gap: 0.35rem;
|
||||
}
|
||||
|
||||
.fx-form-grid span,
|
||||
.fx-block span {
|
||||
font-size: 0.9rem;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.fx-form-grid input,
|
||||
.fx-form-grid select,
|
||||
.fx-block input {
|
||||
width: 100%;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 12px;
|
||||
padding: 0.7rem 0.8rem;
|
||||
background: var(--surface-strong);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.fx-message {
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.fx-message.is-error {
|
||||
color: #d92d20;
|
||||
}
|
||||
|
||||
.fx-message.is-success {
|
||||
color: color-mix(in srgb, var(--accent-green) 78%, var(--text));
|
||||
}
|
||||
|
||||
.fx-table-wrap {
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.fx-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
|
||||
.fx-table th,
|
||||
.fx-table td {
|
||||
text-align: left;
|
||||
border-bottom: 1px solid var(--line);
|
||||
padding: 0.65rem 0.4rem;
|
||||
}
|
||||
|
||||
.fx-api-note {
|
||||
margin-top: 0.75rem;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.fx-convert-result {
|
||||
margin-top: 1rem;
|
||||
min-height: 1.5rem;
|
||||
font-size: 1rem;
|
||||
font-weight: 700;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.fx-card-meta {
|
||||
display: grid;
|
||||
gap: 0.35rem;
|
||||
color: var(--muted);
|
||||
font-size: 0.95rem;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.fx-action-row {
|
||||
display: flex;
|
||||
gap: 0.75rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.fx-action-row form {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.fx-save-row {
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
.fx-save-row form {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.fx-mini-grid {
|
||||
display: grid;
|
||||
gap: 1rem;
|
||||
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
|
||||
}
|
||||
|
||||
.fx-card-grid {
|
||||
display: grid;
|
||||
gap: 16px;
|
||||
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
|
||||
}
|
||||
|
||||
.fx-mini-label,
|
||||
.fx-field-label {
|
||||
font-size: 0.9rem;
|
||||
color: var(--muted);
|
||||
letter-spacing: 0.18em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.fx-card-value {
|
||||
font-size: 1.2rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.fx-currency-selection-row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 1rem;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.fx-token-list {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.fx-token-list--inline {
|
||||
flex: 1 1 auto;
|
||||
}
|
||||
|
||||
.fx-currency-search {
|
||||
flex: 0 1 360px;
|
||||
min-width: 260px;
|
||||
}
|
||||
|
||||
.fx-input,
|
||||
.fx-select {
|
||||
width: 100%;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 18px;
|
||||
padding: 0.8rem 1rem;
|
||||
background: var(--surface-strong);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.fx-field {
|
||||
display: grid;
|
||||
gap: 0.45rem;
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
.fx-token,
|
||||
.fx-suggestion {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.6rem;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 999px;
|
||||
padding: 0.7rem 1rem;
|
||||
background: var(--surface-strong);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.fx-token {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.fx-token:hover,
|
||||
.fx-suggestion:hover {
|
||||
border-color: color-mix(in srgb, var(--brand-accent-3) 45%, transparent);
|
||||
background: color-mix(in srgb, var(--brand-accent) 6%, var(--surface-strong));
|
||||
}
|
||||
|
||||
.fx-token-close {
|
||||
color: var(--brand-accent-3);
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.fx-suggestion-list {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.75rem;
|
||||
margin-top: 0.9rem;
|
||||
}
|
||||
|
||||
.fx-suggestion {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.fx-suggestion strong {
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.fx-text {
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.fx-history-date {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.45rem;
|
||||
}
|
||||
|
||||
.fx-info-button {
|
||||
width: 1.4rem;
|
||||
height: 1.4rem;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 999px;
|
||||
background: var(--surface-strong);
|
||||
color: var(--muted);
|
||||
font-size: 0.78rem;
|
||||
font-weight: 700;
|
||||
line-height: 1;
|
||||
cursor: help;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
@media (max-width: 860px) {
|
||||
.fx-currency-selection-row {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.fx-card-head {
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.fx-currency-search {
|
||||
flex: 1 1 auto;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
}
|
||||
}
|
||||
286
modules/fx-rates/assets/fx-rates.js
Normal file
286
modules/fx-rates/assets/fx-rates.js
Normal file
@@ -0,0 +1,286 @@
|
||||
(() => {
|
||||
const root = document.getElementById('fx-rates-app');
|
||||
if (!root) {
|
||||
return;
|
||||
}
|
||||
|
||||
const page = JSON.parse(root.dataset.page || '{}');
|
||||
const settings = page.settings || {};
|
||||
const nodes = {
|
||||
historyHead: root.querySelector('[data-bind="history-head"]'),
|
||||
historyBody: root.querySelector('[data-bind="history-body"]'),
|
||||
convertResult: root.querySelector('[data-bind="convert-result"]'),
|
||||
convertFrom: root.querySelector('select[name="convert_from"]'),
|
||||
convertTo: root.querySelector('select[name="convert_to"]'),
|
||||
convertAmount: root.querySelector('input[name="convert_amount"]'),
|
||||
};
|
||||
const apiBase = '/api/fx-rates/v1';
|
||||
const preferredCurrencies = Array.isArray(page.preferred_currencies)
|
||||
? page.preferred_currencies
|
||||
.map((item) => String(item || '').trim().toUpperCase())
|
||||
.filter(Boolean)
|
||||
: [];
|
||||
const refreshMaxAgeMinutes = Math.max(1, Number(settings.refresh_max_age_minutes || 60));
|
||||
|
||||
const parseDateValue = (value) => {
|
||||
const raw = String(value || '').trim();
|
||||
if (!raw) {
|
||||
return null;
|
||||
}
|
||||
|
||||
let normalized = raw.replace(' ', 'T');
|
||||
normalized = normalized.replace(/([+-]\d{2})$/, '$1:00');
|
||||
const parsed = new Date(normalized);
|
||||
return Number.isNaN(parsed.getTime()) ? null : parsed;
|
||||
};
|
||||
|
||||
const latestFetchedAt = () => {
|
||||
const latest = page.latest && typeof page.latest === 'object' ? page.latest : null;
|
||||
const direct = parseDateValue(latest?.fetched_at);
|
||||
if (direct) {
|
||||
return direct;
|
||||
}
|
||||
|
||||
const recentFetches = Array.isArray(page.recent_fetches) ? page.recent_fetches : [];
|
||||
for (const entry of recentFetches) {
|
||||
const parsed = parseDateValue(entry?.fetched_at);
|
||||
if (parsed) {
|
||||
return parsed;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
const bindManualRefreshAction = () => {
|
||||
const refreshLink = Array.from(document.querySelectorAll('a[href]')).find((link) => {
|
||||
try {
|
||||
const url = new URL(link.href, window.location.origin);
|
||||
return url.pathname === '/module/fx-rates' && url.searchParams.get('refresh') === '1';
|
||||
} catch (_error) {
|
||||
return false;
|
||||
}
|
||||
});
|
||||
|
||||
if (!refreshLink) {
|
||||
return;
|
||||
}
|
||||
|
||||
refreshLink.addEventListener('click', (event) => {
|
||||
const url = new URL(refreshLink.href, window.location.origin);
|
||||
if (url.searchParams.get('force') === '1') {
|
||||
return;
|
||||
}
|
||||
|
||||
const lastFetch = latestFetchedAt();
|
||||
if (!lastFetch) {
|
||||
return;
|
||||
}
|
||||
|
||||
const ageMinutes = (Date.now() - lastFetch.getTime()) / 60000;
|
||||
if (!Number.isFinite(ageMinutes) || ageMinutes >= refreshMaxAgeMinutes) {
|
||||
return;
|
||||
}
|
||||
|
||||
event.preventDefault();
|
||||
const confirmed = window.confirm(
|
||||
`Der letzte gespeicherte Abruf ist juenger als ${refreshMaxAgeMinutes} Minuten. ` +
|
||||
'Ein manueller Abruf wuerde die externe API trotzdem erneut aufrufen. Jetzt trotzdem abrufen?'
|
||||
);
|
||||
|
||||
if (!confirmed) {
|
||||
return;
|
||||
}
|
||||
|
||||
url.searchParams.set('force', '1');
|
||||
window.location.href = url.toString();
|
||||
});
|
||||
};
|
||||
|
||||
const escapeHtml = (value) => String(value || '')
|
||||
.replaceAll('&', '&')
|
||||
.replaceAll('<', '<')
|
||||
.replaceAll('>', '>')
|
||||
.replaceAll('"', '"')
|
||||
.replaceAll("'", ''');
|
||||
|
||||
const renderHistory = (rows, currencies) => {
|
||||
if (!nodes.historyHead || !nodes.historyBody) {
|
||||
return;
|
||||
}
|
||||
|
||||
const series = Array.isArray(currencies) ? currencies : [];
|
||||
if (!series.length) {
|
||||
nodes.historyHead.innerHTML = '<tr><th>Datum</th><th>Kurse</th></tr>';
|
||||
nodes.historyBody.innerHTML = '<tr><td colspan="2">Keine bevorzugten Waehrungen fuer den Verlauf vorhanden.</td></tr>';
|
||||
return;
|
||||
}
|
||||
|
||||
nodes.historyHead.innerHTML = `
|
||||
<tr>
|
||||
<th>Datum</th>
|
||||
${series.map((currency) => `<th>${currency}</th>`).join('')}
|
||||
</tr>
|
||||
`;
|
||||
|
||||
const entries = Array.isArray(rows) ? rows : [];
|
||||
if (!entries.length) {
|
||||
nodes.historyBody.innerHTML = `<tr><td colspan="${series.length + 1}">Noch keine Verlaufsdaten vorhanden.</td></tr>`;
|
||||
return;
|
||||
}
|
||||
|
||||
nodes.historyBody.innerHTML = entries.map((entry) => `
|
||||
<tr>
|
||||
<td>
|
||||
<div class="fx-history-date">
|
||||
<span>${entry.label}</span>
|
||||
${entry.fetch ? `
|
||||
<button
|
||||
type="button"
|
||||
class="fx-info-button"
|
||||
title="${escapeHtml(`Basis: ${entry.fetch.base_currency || '-'} | Provider: ${entry.fetch.provider || '-'} | Ausloeser: ${entry.fetch.trigger_source_label || entry.fetch.trigger_source || '-'}`)}"
|
||||
aria-label="${escapeHtml(`Abrufinfo fuer ${entry.label}`)}"
|
||||
>i</button>
|
||||
` : ''}
|
||||
</div>
|
||||
</td>
|
||||
${series.map((currency) => {
|
||||
const value = entry.rates?.[currency];
|
||||
if (typeof value !== 'number' || !Number.isFinite(value)) {
|
||||
return '<td>–</td>';
|
||||
}
|
||||
return `<td>${value.toLocaleString('de-DE', { maximumFractionDigits: 8 })}</td>`;
|
||||
}).join('')}
|
||||
</tr>
|
||||
`).join('');
|
||||
};
|
||||
|
||||
const request = async (path, options = {}) => {
|
||||
const response = await fetch(`${apiBase}${path}`, {
|
||||
credentials: 'same-origin',
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
...(options.body ? { 'Content-Type': 'application/json' } : {}),
|
||||
...(options.headers || {}),
|
||||
},
|
||||
...options,
|
||||
});
|
||||
const payload = await response.json().catch(() => ({}));
|
||||
if (!response.ok) {
|
||||
throw new Error(payload?.context?.message || payload?.error || `HTTP ${response.status}`);
|
||||
}
|
||||
return payload.data;
|
||||
};
|
||||
|
||||
const loadHistory = async () => {
|
||||
const base = String(
|
||||
settings.display_base_currency || settings.default_base_currency || 'EUR'
|
||||
).trim().toUpperCase();
|
||||
const selectedCurrencies = preferredCurrencies.length
|
||||
? preferredCurrencies
|
||||
: [base];
|
||||
const historyCurrencies = selectedCurrencies.filter((currency) => currency !== base);
|
||||
|
||||
if (!selectedCurrencies.length) {
|
||||
renderHistory([], []);
|
||||
return;
|
||||
}
|
||||
|
||||
const histories = await Promise.all(historyCurrencies.map(async (currency) => {
|
||||
const query = new URLSearchParams({ from: base, to: currency, limit: '20' });
|
||||
const rows = await request(`/history?${query.toString()}`);
|
||||
return { currency, rows: Array.isArray(rows) ? rows : [] };
|
||||
}));
|
||||
|
||||
const recentFetches = Array.isArray(page.recent_fetches) ? page.recent_fetches : [];
|
||||
const byDate = new Map();
|
||||
recentFetches.forEach((fetch) => {
|
||||
const key = String(fetch?.fetched_at || '').trim();
|
||||
if (!key || byDate.has(key)) {
|
||||
return;
|
||||
}
|
||||
byDate.set(key, {
|
||||
sortKey: key,
|
||||
label: fetch?.fetched_at_display || fetch?.fetched_at || key,
|
||||
fetch,
|
||||
rates: base !== '' ? { [base]: 1 } : {},
|
||||
});
|
||||
});
|
||||
|
||||
histories.forEach(({ currency, rows }) => {
|
||||
rows.forEach((row) => {
|
||||
const key = String(row?.fetched_at || row?.rate_date || '').trim();
|
||||
if (!key) {
|
||||
return;
|
||||
}
|
||||
if (!byDate.has(key)) {
|
||||
byDate.set(key, {
|
||||
sortKey: key,
|
||||
label: row?.fetched_at_display || row?.fetched_at || row?.rate_date || key,
|
||||
fetch: recentFetches.find((fetch) => String(fetch?.fetched_at || '').trim() === key) || null,
|
||||
rates: base !== '' ? { [base]: 1 } : {},
|
||||
});
|
||||
}
|
||||
const entry = byDate.get(key);
|
||||
if (entry && typeof row?.rate === 'number' && Number.isFinite(row.rate)) {
|
||||
entry.rates[currency] = row.rate;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
const mergedRows = Array.from(byDate.values())
|
||||
.sort((left, right) => String(right.sortKey).localeCompare(String(left.sortKey)))
|
||||
.slice(0, 15);
|
||||
|
||||
renderHistory(mergedRows, selectedCurrencies);
|
||||
};
|
||||
|
||||
const calculateConversion = async () => {
|
||||
if (!nodes.convertFrom || !nodes.convertTo || !nodes.convertAmount || !nodes.convertResult) {
|
||||
return;
|
||||
}
|
||||
|
||||
const from = String(nodes.convertFrom.value || '').trim().toUpperCase();
|
||||
const to = String(nodes.convertTo.value || '').trim().toUpperCase();
|
||||
const amount = Number(nodes.convertAmount.value || '0');
|
||||
|
||||
if (!from || !to || !Number.isFinite(amount)) {
|
||||
nodes.convertResult.textContent = 'Bitte Quellwaehrung, Zielwaehrung und Betrag angeben.';
|
||||
return;
|
||||
}
|
||||
|
||||
if (from === to) {
|
||||
nodes.convertResult.textContent = `${amount.toLocaleString('de-DE', { maximumFractionDigits: 8 })} ${from} = ${amount.toLocaleString('de-DE', { maximumFractionDigits: 8 })} ${to}`;
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const query = new URLSearchParams({ from, to });
|
||||
const data = await request(`/rate?${query.toString()}`);
|
||||
const rate = Number(data?.rate || 0);
|
||||
if (!Number.isFinite(rate) || rate <= 0) {
|
||||
throw new Error('Kein Kurs verfuegbar.');
|
||||
}
|
||||
const converted = amount * rate;
|
||||
nodes.convertResult.textContent = `${amount.toLocaleString('de-DE', { maximumFractionDigits: 8 })} ${from} = ${converted.toLocaleString('de-DE', { maximumFractionDigits: 8 })} ${to} | Kurs ${rate.toLocaleString('de-DE', { maximumFractionDigits: 8 })}`;
|
||||
} catch (error) {
|
||||
nodes.convertResult.textContent = error.message || 'Umrechnung konnte nicht berechnet werden.';
|
||||
}
|
||||
};
|
||||
|
||||
[nodes.convertFrom, nodes.convertTo, nodes.convertAmount].forEach((node) => {
|
||||
node?.addEventListener('change', () => {
|
||||
calculateConversion().catch(() => {});
|
||||
});
|
||||
node?.addEventListener('input', () => {
|
||||
calculateConversion().catch(() => {});
|
||||
});
|
||||
});
|
||||
|
||||
bindManualRefreshAction();
|
||||
|
||||
loadHistory().catch(() => {
|
||||
renderHistory([], preferredCurrencies);
|
||||
});
|
||||
calculateConversion().catch(() => {});
|
||||
})();
|
||||
269
modules/fx-rates/bootstrap.php
Normal file
269
modules/fx-rates/bootstrap.php
Normal file
@@ -0,0 +1,269 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\ModuleConfigException;
|
||||
use Modules\FxRates\Domain\FxRatesService;
|
||||
use Modules\FxRates\Infrastructure\FxRatesRepository;
|
||||
|
||||
spl_autoload_register(static function (string $class): void {
|
||||
$prefix = 'Modules\\FxRates\\';
|
||||
if (!str_starts_with($class, $prefix)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$file = __DIR__ . '/src/' . str_replace('\\', '/', substr($class, strlen($prefix))) . '.php';
|
||||
if (is_file($file)) {
|
||||
require_once $file;
|
||||
}
|
||||
});
|
||||
|
||||
$moduleName = 'fx-rates';
|
||||
$mm = isset($modules) && $modules instanceof App\ModuleManager ? $modules : modules();
|
||||
|
||||
$mm->registerFunction($moduleName, 'table', static function (string $name): string {
|
||||
$prefix = 'fxrate_';
|
||||
$sanitized = preg_replace('/[^a-zA-Z0-9_]/', '', $name);
|
||||
return $prefix . $sanitized;
|
||||
});
|
||||
|
||||
$mm->registerFunction($moduleName, 'settings', static function (): array {
|
||||
$saved = modules()->settings('fx-rates');
|
||||
$provider = trim((string) ($saved['provider'] ?? (getenv('FX_RATES_PROVIDER') ?: getenv('MINING_CHECKER_FX_PROVIDER') ?: 'currencyapi')));
|
||||
$apiVersion = strtolower(trim((string) ($saved['api_version'] ?? 'v2')));
|
||||
$apiUrl = rtrim((string) ($saved['api_url'] ?? (getenv('FX_RATES_API_URL') ?: getenv('MINING_CHECKER_FX_URL') ?: 'https://currencyapi.net')), '/');
|
||||
$apiKey = trim((string) ($saved['api_key'] ?? (getenv('FX_RATES_API_KEY') ?: getenv('MINING_CHECKER_FX_API_KEY') ?: '')));
|
||||
$timeout = max(2, (int) ($saved['timeout_sec'] ?? (getenv('FX_RATES_TIMEOUT') ?: getenv('MINING_CHECKER_FX_TIMEOUT') ?: 10)));
|
||||
|
||||
$preferredCurrencies = $saved['preferred_currencies'] ?? ['EUR', 'USD', 'DOGE'];
|
||||
if (is_string($preferredCurrencies)) {
|
||||
$preferredCurrencies = preg_split('/[\s,;]+/', $preferredCurrencies) ?: [];
|
||||
}
|
||||
$preferredCurrencies = array_values(array_unique(array_filter(array_map(
|
||||
static fn (mixed $code): string => strtoupper(trim((string) $code)),
|
||||
is_array($preferredCurrencies) ? $preferredCurrencies : []
|
||||
), static fn (string $code): bool => $code !== '')));
|
||||
|
||||
$currencyCatalog = $saved['currency_catalog'] ?? [];
|
||||
$currencyCatalog = array_values(array_filter(array_map(static function (mixed $item): ?array {
|
||||
if (!is_array($item)) {
|
||||
return null;
|
||||
}
|
||||
$code = strtoupper(trim((string) ($item['code'] ?? '')));
|
||||
$name = trim((string) ($item['name'] ?? ''));
|
||||
if ($code === '' || $name === '') {
|
||||
return null;
|
||||
}
|
||||
return ['code' => $code, 'name' => $name];
|
||||
}, is_array($currencyCatalog) ? $currencyCatalog : [])));
|
||||
|
||||
return [
|
||||
'provider' => $provider !== '' ? $provider : 'currencyapi',
|
||||
'api_version' => in_array($apiVersion, ['v2', 'v3'], true) ? $apiVersion : 'v2',
|
||||
'api_url' => $apiUrl,
|
||||
'api_key' => $apiKey,
|
||||
'timeout_sec' => $timeout,
|
||||
'refresh_max_age_minutes' => max(1, (int) ($saved['refresh_max_age_minutes'] ?? 60)),
|
||||
'default_base_currency' => strtoupper(trim((string) ($saved['default_base_currency'] ?? 'EUR'))) ?: 'EUR',
|
||||
'display_base_currency' => strtoupper(trim((string) ($saved['display_base_currency'] ?? ($saved['default_base_currency'] ?? 'EUR')))) ?: 'EUR',
|
||||
'preferred_currencies' => $preferredCurrencies,
|
||||
'currency_catalog' => $currencyCatalog,
|
||||
'currency_catalog_synced_at' => trim((string) ($saved['currency_catalog_synced_at'] ?? '')),
|
||||
'schedule_timezone' => trim((string) ($saved['schedule_timezone'] ?? nexus_cron_timezone_name())) ?: nexus_cron_timezone_name(),
|
||||
];
|
||||
});
|
||||
|
||||
$mm->registerFunction($moduleName, 'save_runtime_settings', static function (array $payload): array {
|
||||
$current = modules()->settings('fx-rates');
|
||||
$normalized = module_fn('fx-rates', 'settings');
|
||||
|
||||
if (array_key_exists('default_base_currency', $payload)) {
|
||||
$normalized['default_base_currency'] = strtoupper(trim((string) $payload['default_base_currency'])) ?: $normalized['default_base_currency'];
|
||||
}
|
||||
if (array_key_exists('display_base_currency', $payload)) {
|
||||
$normalized['display_base_currency'] = strtoupper(trim((string) $payload['display_base_currency'])) ?: $normalized['display_base_currency'];
|
||||
}
|
||||
if (array_key_exists('preferred_currencies', $payload)) {
|
||||
$preferredCurrencies = $payload['preferred_currencies'];
|
||||
if (is_string($preferredCurrencies)) {
|
||||
$preferredCurrencies = preg_split('/[\s,;]+/', $preferredCurrencies) ?: [];
|
||||
}
|
||||
$normalized['preferred_currencies'] = array_values(array_unique(array_filter(array_map(
|
||||
static fn (mixed $code): string => strtoupper(trim((string) $code)),
|
||||
is_array($preferredCurrencies) ? $preferredCurrencies : []
|
||||
), static fn (string $code): bool => $code !== '')));
|
||||
}
|
||||
|
||||
$catalogCodes = [];
|
||||
foreach (($normalized['currency_catalog'] ?? []) as $currency) {
|
||||
if (is_array($currency)) {
|
||||
$code = strtoupper(trim((string) ($currency['code'] ?? '')));
|
||||
if ($code !== '') {
|
||||
$catalogCodes[$code] = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
if ($catalogCodes !== [] && !isset($catalogCodes[$normalized['display_base_currency']])) {
|
||||
$normalized['display_base_currency'] = $normalized['default_base_currency'];
|
||||
}
|
||||
if ($catalogCodes !== []) {
|
||||
$normalized['preferred_currencies'] = array_values(array_filter(
|
||||
$normalized['preferred_currencies'],
|
||||
static fn (string $code): bool => isset($catalogCodes[$code])
|
||||
));
|
||||
}
|
||||
|
||||
$toSave = array_merge($current, [
|
||||
'default_base_currency' => $normalized['default_base_currency'],
|
||||
'display_base_currency' => $normalized['display_base_currency'],
|
||||
'preferred_currencies' => $normalized['preferred_currencies'],
|
||||
]);
|
||||
|
||||
modules()->saveSettings('fx-rates', $toSave);
|
||||
|
||||
return module_fn('fx-rates', 'settings');
|
||||
});
|
||||
|
||||
$mm->registerFunction($moduleName, 'pdo', function () use ($moduleName): PDO {
|
||||
$settings = modules()->settings($moduleName);
|
||||
$useSeparate = !empty($settings['use_separate_db']);
|
||||
|
||||
if ($useSeparate) {
|
||||
$module = modules()->get($moduleName);
|
||||
$fallback = $module['db_defaults'] ?? [];
|
||||
return modules()->modulePdo($moduleName, $fallback);
|
||||
}
|
||||
|
||||
$base = app()->basePdo();
|
||||
if ($base instanceof PDO) {
|
||||
return $base;
|
||||
}
|
||||
|
||||
throw new ModuleConfigException(
|
||||
$moduleName,
|
||||
'Base-DB ist deaktiviert. Bitte Base-DB aktivieren oder eine eigene Modul-DB konfigurieren.'
|
||||
);
|
||||
});
|
||||
|
||||
$mm->registerFunction($moduleName, 'repository', static function (): FxRatesRepository {
|
||||
return new FxRatesRepository(module_fn('fx-rates', 'pdo'), 'fxrate_');
|
||||
});
|
||||
|
||||
$mm->registerFunction($moduleName, 'ensure_schema', static function (): void {
|
||||
module_fn('fx-rates', 'repository')->ensureSchema();
|
||||
});
|
||||
|
||||
$mm->registerFunction($moduleName, 'service', static function (): FxRatesService {
|
||||
module_fn('fx-rates', 'ensure_schema');
|
||||
return new FxRatesService(
|
||||
module_fn('fx-rates', 'repository'),
|
||||
module_fn('fx-rates', 'settings')
|
||||
);
|
||||
});
|
||||
|
||||
$mm->registerFunction($moduleName, 'setup_actions', static function (): array {
|
||||
return [
|
||||
[
|
||||
'name' => 'sync_currency_catalog',
|
||||
'label' => 'Waehrungskatalog synchronisieren',
|
||||
'help' => 'Laedt die verfuegbaren Waehrungen einmalig aus dem konfigurierten FX-Provider.',
|
||||
],
|
||||
];
|
||||
});
|
||||
|
||||
$mm->registerFunction($moduleName, 'run_setup_action', static function (string $action): array {
|
||||
return match ($action) {
|
||||
'sync_currency_catalog' => (static function (): array {
|
||||
$result = module_fn('fx-rates', 'service')->refreshCurrencyCatalog();
|
||||
$current = modules()->settings('fx-rates');
|
||||
|
||||
$catalog = [];
|
||||
foreach (is_array($result['currencies'] ?? null) ? $result['currencies'] : [] as $item) {
|
||||
if (!is_array($item)) {
|
||||
continue;
|
||||
}
|
||||
$code = strtoupper(trim((string) ($item['code'] ?? '')));
|
||||
$name = trim((string) ($item['name'] ?? ''));
|
||||
if ($code === '' || $name === '') {
|
||||
continue;
|
||||
}
|
||||
$catalog[$code] = ['code' => $code, 'name' => $name];
|
||||
}
|
||||
|
||||
$latest = module_fn('fx-rates', 'service')->latestStatus();
|
||||
if (is_array($latest) && !empty($latest['id'])) {
|
||||
$snapshot = module_fn('fx-rates', 'snapshot', null, null, null, null);
|
||||
$rates = is_array($snapshot['rates'] ?? null) ? $snapshot['rates'] : [];
|
||||
foreach (array_keys($rates) as $code) {
|
||||
$code = strtoupper(trim((string) $code));
|
||||
if ($code !== '' && !isset($catalog[$code])) {
|
||||
$catalog[$code] = ['code' => $code, 'name' => $code];
|
||||
}
|
||||
}
|
||||
$baseCode = strtoupper(trim((string) ($snapshot['base_currency'] ?? '')));
|
||||
if ($baseCode !== '' && !isset($catalog[$baseCode])) {
|
||||
$catalog[$baseCode] = ['code' => $baseCode, 'name' => $baseCode];
|
||||
}
|
||||
}
|
||||
|
||||
foreach ([
|
||||
(string) ($current['default_base_currency'] ?? ''),
|
||||
(string) ($current['display_base_currency'] ?? ''),
|
||||
...((is_array($current['preferred_currencies'] ?? null) ? $current['preferred_currencies'] : [])),
|
||||
] as $code) {
|
||||
$code = strtoupper(trim((string) $code));
|
||||
if ($code !== '' && !isset($catalog[$code])) {
|
||||
$catalog[$code] = ['code' => $code, 'name' => $code];
|
||||
}
|
||||
}
|
||||
|
||||
ksort($catalog);
|
||||
$current['currency_catalog'] = array_values($catalog);
|
||||
$current['currency_catalog_synced_at'] = gmdate('Y-m-d H:i:s');
|
||||
modules()->saveSettings('fx-rates', $current);
|
||||
return $result + [
|
||||
'currencies' => array_values($catalog),
|
||||
'synced_count' => count($catalog),
|
||||
'message' => 'Waehrungskatalog synchronisiert. ' . count($catalog) . ' Waehrungen verarbeitet.',
|
||||
];
|
||||
})(),
|
||||
default => throw new \RuntimeException('Unbekannte Setup-Aktion.'),
|
||||
};
|
||||
});
|
||||
|
||||
$mm->registerFunction($moduleName, 'refresh_latest', static function (?array $currencies = null, ?string $baseCurrency = null): array {
|
||||
return module_fn('fx-rates', 'service')->refreshLatestRates($currencies, $baseCurrency);
|
||||
});
|
||||
|
||||
$mm->registerFunction($moduleName, 'ensure_fresh_latest_rates', static function (float $maxAgeHours = 24.0, ?string $baseCurrency = null, ?array $currencies = null): array {
|
||||
return module_fn('fx-rates', 'service')->ensureFreshLatestRates($maxAgeHours, $baseCurrency, $currencies);
|
||||
});
|
||||
|
||||
$mm->registerFunction($moduleName, 'rate', static function (string $fromCurrency, string $toCurrency, ?string $at = null, ?int $windowMinutes = null): ?array {
|
||||
return module_fn('fx-rates', 'service')->findRate($fromCurrency, $toCurrency, $at, $windowMinutes);
|
||||
});
|
||||
|
||||
$mm->registerFunction($moduleName, 'convert', static function (?float $amount, ?string $fromCurrency, ?string $toCurrency, ?string $at = null, ?int $windowMinutes = null): ?float {
|
||||
return module_fn('fx-rates', 'service')->convert($amount, $fromCurrency, $toCurrency, $at, $windowMinutes);
|
||||
});
|
||||
|
||||
$mm->registerFunction($moduleName, 'snapshot', static function (?string $baseCurrency = null, ?string $at = null, ?array $symbols = null, ?int $windowMinutes = null): ?array {
|
||||
return module_fn('fx-rates', 'service')->snapshot($baseCurrency, $at, $symbols, $windowMinutes);
|
||||
});
|
||||
|
||||
$mm->registerFunction($moduleName, 'recent_fetches', static function (int $limit = 20): array {
|
||||
return module_fn('fx-rates', 'service')->recentFetches($limit);
|
||||
});
|
||||
|
||||
$mm->registerFunction($moduleName, 'scheduled_refresh', static function (array $context = []): array {
|
||||
$result = module_fn('fx-rates', 'service')->runScheduledRefresh($context);
|
||||
if (function_exists('module_debug_push')) {
|
||||
module_debug_push('fx-rates', [
|
||||
'label' => 'Intervall-Aufgabe',
|
||||
'type' => 'scheduler:run',
|
||||
'task' => 'daily_refresh',
|
||||
'context' => $context,
|
||||
'message' => (string) ($result['message'] ?? ''),
|
||||
]);
|
||||
}
|
||||
return $result;
|
||||
});
|
||||
12
modules/fx-rates/design.json
Normal file
12
modules/fx-rates/design.json
Normal file
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"eyebrow": "Modul",
|
||||
"title": "Waehrungskurse",
|
||||
"description": "Zentrale Verwaltung fuer Waehrungskurse, Snapshots und FX-API-Abrufe.",
|
||||
"actions": [
|
||||
{ "label": "Setup", "href": "/modules/setup/fx-rates", "variant": "secondary" }
|
||||
],
|
||||
"tabs": [
|
||||
{ "label": "Ueberblick", "href": "/module/fx-rates" },
|
||||
{ "label": "Waehrungen", "href": "/module/fx-rates/currencies", "match_prefixes": ["/module/fx-rates/currencies"] }
|
||||
]
|
||||
}
|
||||
58
modules/fx-rates/module.json
Normal file
58
modules/fx-rates/module.json
Normal file
@@ -0,0 +1,58 @@
|
||||
{
|
||||
"title": "Waehrungskurse",
|
||||
"version": "0.1.5",
|
||||
"description": "Zentrales Modul fuer Waehrungskurse, Historie und API-Abrufe.",
|
||||
"enabled_by_default": true,
|
||||
"setup": {
|
||||
"fields": [
|
||||
{ "name": "use_separate_db", "label": "Eigene Modul-DB nutzen", "type": "checkbox", "required": false, "help": "Wenn aktiv, werden die DB-Daten unten verwendet. Sonst wird die Nexus-Base-DB genutzt." },
|
||||
{ "name": "db.driver", "label": "DB Driver", "type": "text", "required": false, "help": "z.B. pgsql, mysql, sqlite" },
|
||||
{ "name": "db.host", "label": "DB Host", "type": "text", "required": false },
|
||||
{ "name": "db.port", "label": "DB Port", "type": "number", "required": false },
|
||||
{ "name": "db.dbname", "label": "DB Name", "type": "text", "required": false },
|
||||
{ "name": "db.schema", "label": "DB Schema", "type": "text", "required": false },
|
||||
{ "name": "db.user", "label": "DB User", "type": "text", "required": false },
|
||||
{ "name": "db.password", "label": "DB Passwort", "type": "password", "required": false },
|
||||
{ "name": "provider", "label": "FX Provider", "type": "text", "required": false, "help": "Unterstuetzt legacy currencyapi.net und currencyapi.com v3." },
|
||||
{ "name": "api_version", "label": "FX API Version", "type": "select", "required": false, "help": "Steuert die Endpoint-Version unabhaengig von der Domain." },
|
||||
{ "name": "api_url", "label": "FX API URL", "type": "text", "required": false, "help": "Nur die Basis-URL eintragen, z.B. https://api.currencyapi.com oder https://currencyapi.net." },
|
||||
{ "name": "api_key", "label": "FX API Key", "type": "password", "required": false },
|
||||
{ "name": "timeout_sec", "label": "Timeout (Sek.)", "type": "number", "required": false },
|
||||
{ "name": "refresh_max_age_minutes", "label": "Max. Alter fuer API-Refresh (Min.)", "type": "number", "required": false, "help": "Blockiert neue API-Refresh-Aufrufe, solange der letzte gespeicherte Abruf juenger ist. Manuelle Abrufe koennen nach Hinweis trotzdem erzwungen werden; Cron ignoriert diesen Wert." },
|
||||
{ "name": "default_base_currency", "label": "Standard-Basiswaehrung", "type": "text", "required": false, "help": "Wird fuer taegliche Abrufe und Snapshot-Abfragen verwendet." },
|
||||
{ "name": "schedule_timezone", "label": "Scheduler-Zeitzone", "type": "text", "required": false, "help": "z.B. Europe/Berlin" }
|
||||
]
|
||||
},
|
||||
"scheduler_jobs": [
|
||||
{
|
||||
"name": "rates_refresh",
|
||||
"label": "Kursabruf",
|
||||
"callback": "scheduled_refresh",
|
||||
"mode": "multi",
|
||||
"default_enabled": true,
|
||||
"default_cron": "0 18 * * *",
|
||||
"default_timezone": "Europe/Berlin",
|
||||
"timezone_setting": "schedule_timezone",
|
||||
"lock_minutes": 120,
|
||||
"help": "Zeitgesteuerter Abruf und das Speichern neuer FX-Snapshots.",
|
||||
"builder": {
|
||||
"allow_manual": true,
|
||||
"presets": ["daily", "every_x_days", "weekly", "monthly_day", "every_x_hours"]
|
||||
}
|
||||
}
|
||||
],
|
||||
"db_defaults": {
|
||||
"driver": "pgsql",
|
||||
"host": "localhost",
|
||||
"port": 5432,
|
||||
"dbname": "",
|
||||
"schema": "public",
|
||||
"user": "",
|
||||
"password": ""
|
||||
},
|
||||
"auth": {
|
||||
"required": true,
|
||||
"users": [],
|
||||
"groups": []
|
||||
}
|
||||
}
|
||||
31
modules/fx-rates/pages/asset.php
Normal file
31
modules/fx-rates/pages/asset.php
Normal file
@@ -0,0 +1,31 @@
|
||||
<?php
|
||||
$file = (string) ($_GET['file'] ?? '');
|
||||
$base = realpath(__DIR__ . '/../assets');
|
||||
$map = [
|
||||
'fx-rates.css' => $base . '/fx-rates.css',
|
||||
'fx-rates.js' => $base . '/fx-rates.js',
|
||||
'fx-rates-currencies.js' => $base . '/fx-rates-currencies.js',
|
||||
];
|
||||
|
||||
if (!isset($map[$file])) {
|
||||
http_response_code(404);
|
||||
exit('Not found');
|
||||
}
|
||||
|
||||
$path = $map[$file];
|
||||
if (!$base || !is_file($path) || !str_starts_with($path, $base)) {
|
||||
http_response_code(404);
|
||||
exit('Not found');
|
||||
}
|
||||
|
||||
$ext = pathinfo($path, PATHINFO_EXTENSION);
|
||||
if ($ext === 'css') {
|
||||
header('Content-Type: text/css; charset=utf-8');
|
||||
} elseif ($ext === 'js') {
|
||||
header('Content-Type: application/javascript; charset=utf-8');
|
||||
} else {
|
||||
header('Content-Type: application/octet-stream');
|
||||
}
|
||||
|
||||
readfile($path);
|
||||
exit;
|
||||
280
modules/fx-rates/pages/currencies.php
Normal file
280
modules/fx-rates/pages/currencies.php
Normal file
@@ -0,0 +1,280 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
require_once dirname(__DIR__) . '/bootstrap.php';
|
||||
|
||||
$assets = app()->assets();
|
||||
if ($assets) {
|
||||
$assets->addStyle('/module/fx-rates/asset?file=fx-rates.css');
|
||||
$assets->addScript('/module/fx-rates/asset?file=fx-rates-currencies.js', 'footer', true);
|
||||
}
|
||||
|
||||
$settings = module_fn('fx-rates', 'settings');
|
||||
$service = module_fn('fx-rates', 'service');
|
||||
$notice = trim((string) ($_GET['notice'] ?? ''));
|
||||
$error = trim((string) ($_GET['error'] ?? ''));
|
||||
|
||||
if (strtoupper((string) ($_SERVER['REQUEST_METHOD'] ?? 'GET')) === 'POST') {
|
||||
try {
|
||||
$action = trim((string) ($_POST['fx_action'] ?? ''));
|
||||
if ($action === 'save_selection') {
|
||||
$payload = [
|
||||
'display_base_currency' => (string) ($_POST['display_base_currency'] ?? ''),
|
||||
'preferred_currencies' => $_POST['preferred_currencies'] ?? [],
|
||||
];
|
||||
$saved = module_fn('fx-rates', 'save_runtime_settings', $payload);
|
||||
$params = ['notice' => 'Waehrungs-Auswahl gespeichert.'];
|
||||
if (is_array($saved) && !empty($saved['display_base_currency'])) {
|
||||
$params['base'] = (string) $saved['display_base_currency'];
|
||||
}
|
||||
redirect('/module/fx-rates/currencies?' . http_build_query($params));
|
||||
}
|
||||
|
||||
if ($action === 'sync_catalog') {
|
||||
$result = module_fn('fx-rates', 'run_setup_action', 'sync_currency_catalog');
|
||||
redirect('/module/fx-rates/currencies?' . http_build_query([
|
||||
'notice' => sprintf('Waehrungskatalog synchronisiert. %d Waehrungen verarbeitet.', (int) ($result['synced_count'] ?? 0)),
|
||||
]));
|
||||
}
|
||||
|
||||
if ($action === 'refresh_rates') {
|
||||
$result = $service->refreshLatestRates(null, (string) ($settings['default_base_currency'] ?? ''), 'manual');
|
||||
redirect('/module/fx-rates/currencies?' . http_build_query([
|
||||
'notice' => sprintf('Alle Wechselkurse aktualisiert. %d Werte gespeichert.', (int) ($result['updated_count'] ?? 0)),
|
||||
]));
|
||||
}
|
||||
} catch (\Throwable $exception) {
|
||||
redirect('/module/fx-rates/currencies?' . http_build_query([
|
||||
'error' => $exception->getMessage() !== '' ? $exception->getMessage() : 'Aktion konnte nicht ausgefuehrt werden.',
|
||||
]));
|
||||
}
|
||||
}
|
||||
|
||||
$catalog = is_array($settings['currency_catalog'] ?? null) ? $settings['currency_catalog'] : [];
|
||||
$preferredCurrencies = is_array($settings['preferred_currencies'] ?? null) ? $settings['preferred_currencies'] : [];
|
||||
$savedDisplayBaseCurrency = strtoupper(trim((string) ($settings['display_base_currency'] ?? $settings['default_base_currency'] ?? 'EUR')));
|
||||
$requestedDisplayBaseCurrency = strtoupper(trim((string) ($_GET['base'] ?? '')));
|
||||
$latest = $service->latestStatus();
|
||||
$recentFetches = $service->recentFetches(15);
|
||||
|
||||
$currencies = [];
|
||||
foreach ($catalog as $item) {
|
||||
if (!is_array($item)) {
|
||||
continue;
|
||||
}
|
||||
$code = strtoupper(trim((string) ($item['code'] ?? '')));
|
||||
$name = trim((string) ($item['name'] ?? ''));
|
||||
if ($code === '' || $name === '') {
|
||||
continue;
|
||||
}
|
||||
$currencies[] = [
|
||||
'code' => $code,
|
||||
'name' => $name,
|
||||
];
|
||||
}
|
||||
|
||||
$cryptoCodes = array_fill_keys([
|
||||
'ADA', 'ARB', 'BNB', 'BTC', 'DAI', 'DOGE', 'DOT', 'ETH', 'LINK', 'LTC',
|
||||
'SOL', 'USDC', 'USDT', 'XAG', 'XAU', 'XRP',
|
||||
], true);
|
||||
$fiatCount = 0;
|
||||
$cryptoCount = 0;
|
||||
foreach ($currencies as $currency) {
|
||||
if (isset($cryptoCodes[$currency['code']])) {
|
||||
$cryptoCount++;
|
||||
} else {
|
||||
$fiatCount++;
|
||||
}
|
||||
}
|
||||
|
||||
$catalogCodes = [];
|
||||
foreach ($currencies as $currency) {
|
||||
$catalogCodes[(string) $currency['code']] = true;
|
||||
}
|
||||
|
||||
$displayBaseCurrency = $requestedDisplayBaseCurrency !== '' ? $requestedDisplayBaseCurrency : $savedDisplayBaseCurrency;
|
||||
if ($displayBaseCurrency === '' || (!isset($catalogCodes[$displayBaseCurrency]) && $preferredCurrencies !== [])) {
|
||||
$displayBaseCurrency = $savedDisplayBaseCurrency !== '' ? $savedDisplayBaseCurrency : (string) ($preferredCurrencies[0] ?? 'EUR');
|
||||
}
|
||||
|
||||
$tableCurrencies = [];
|
||||
foreach ([$displayBaseCurrency, ...$preferredCurrencies] as $currency) {
|
||||
$currency = strtoupper(trim((string) $currency));
|
||||
if ($currency !== '' && !in_array($currency, $tableCurrencies, true)) {
|
||||
$tableCurrencies[] = $currency;
|
||||
}
|
||||
}
|
||||
|
||||
$currencyPageData = json_encode([
|
||||
'currencies' => $currencies,
|
||||
'preferred_currencies' => array_values(array_unique(array_map(static fn (mixed $code): string => strtoupper(trim((string) $code)), $preferredCurrencies))),
|
||||
'display_base_currency' => $displayBaseCurrency,
|
||||
'saved_display_base_currency' => $savedDisplayBaseCurrency,
|
||||
], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
|
||||
|
||||
$tabs = [
|
||||
['label' => 'Ueberblick', 'href' => '/module/fx-rates'],
|
||||
['label' => 'Waehrungen', 'href' => '/module/fx-rates/currencies', 'active' => true],
|
||||
];
|
||||
?>
|
||||
<?= module_shell_header('fx-rates', [
|
||||
'title' => 'Waehrungen',
|
||||
'tabs' => $tabs,
|
||||
'actions' => [
|
||||
['label' => 'Nexus Übersicht', 'href' => '/', 'variant' => 'secondary', 'size' => 'sm'],
|
||||
['label' => 'Setup', 'href' => '/modules/setup/fx-rates', 'variant' => 'secondary', 'size' => 'sm'],
|
||||
],
|
||||
]) ?>
|
||||
<div id="fx-rates-currencies" data-page='<?= e(is_string($currencyPageData) ? $currencyPageData : '{}') ?>'>
|
||||
<?php if ($notice !== ''): ?>
|
||||
<section class="section-box">
|
||||
<div class="fx-message is-success"><?= e($notice) ?></div>
|
||||
</section>
|
||||
<?php elseif ($error !== ''): ?>
|
||||
<section class="section-box">
|
||||
<div class="fx-message is-error"><?= e($error) ?></div>
|
||||
</section>
|
||||
<?php endif; ?>
|
||||
|
||||
<section class="section-box">
|
||||
<div class="fx-section-head">
|
||||
<div>
|
||||
<h2>Waehrungs-Update</h2>
|
||||
<p>Auswahl wird in den Waehrungskurs-Einstellungen gespeichert und steht damit auf Handy und Desktop gleich zur Verfuegung.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="fx-action-row">
|
||||
<form method="post">
|
||||
<input type="hidden" name="fx_action" value="refresh_rates">
|
||||
<button type="submit" class="module-button module-button--primary">Alle Wechselkurse aktualisieren</button>
|
||||
</form>
|
||||
<form method="post">
|
||||
<input type="hidden" name="fx_action" value="sync_catalog">
|
||||
<button type="submit" class="module-button module-button--ghost">Waehrungskatalog sync</button>
|
||||
</form>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div class="fx-card-grid">
|
||||
<section class="card-box">
|
||||
<div class="fx-mini-label">Fiat</div>
|
||||
<div class="fx-card-value"><?= e((string) $fiatCount) ?> Waehrungen</div>
|
||||
</section>
|
||||
<section class="card-box">
|
||||
<div class="fx-mini-label">Krypto</div>
|
||||
<div class="fx-card-value"><?= e((string) $cryptoCount) ?> Waehrungen</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<section class="section-box">
|
||||
<div class="fx-field-label">Bevorzugte Waehrungen fuer Anzeige</div>
|
||||
<div class="fx-currency-selection-row">
|
||||
<div class="fx-token-list fx-token-list--inline" data-fx-token-list></div>
|
||||
<div class="fx-currency-search">
|
||||
<input type="text" class="fx-input" value="" placeholder="Waehrung hinzufuegen: EUR, USD, DOGE oder Euro" autocomplete="off" data-fx-search-input>
|
||||
</div>
|
||||
</div>
|
||||
<div class="fx-suggestion-list" data-fx-suggestions></div>
|
||||
|
||||
<div class="fx-field fx-currency-search">
|
||||
<label class="fx-field-label" for="fx-display-base-select">Darstellung auf Basis von</label>
|
||||
<select id="fx-display-base-select" class="fx-select" data-fx-display-base-select></select>
|
||||
</div>
|
||||
|
||||
<div class="fx-save-row">
|
||||
<form method="post">
|
||||
<input type="hidden" name="fx_action" value="save_selection">
|
||||
<input type="hidden" name="display_base_currency" value="<?= e($displayBaseCurrency) ?>" data-fx-display-base-hidden>
|
||||
<div data-fx-hidden-preferred></div>
|
||||
<button type="submit" class="module-button module-button--ghost">Auswahl speichern</button>
|
||||
</form>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="section-box">
|
||||
<div class="fx-card-head">
|
||||
<div>
|
||||
<h2>Letzte 15 Kurs-Uploads</h2>
|
||||
<p>Zeigt die zuletzt gespeicherten Wechselkurse aus der Datenbank.</p>
|
||||
</div>
|
||||
<div class="fx-card-meta">
|
||||
<div><strong>Anzeige-Basis:</strong> <?= e($displayBaseCurrency) ?></div>
|
||||
<div><strong>Letzter Abruf:</strong> <?= e((string) ($latest['fetched_at_display'] ?? $latest['fetched_at'] ?? 'noch keiner')) ?></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="fx-table-wrap">
|
||||
<table class="fx-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Zeit</th>
|
||||
<?php foreach ($tableCurrencies as $currency): ?>
|
||||
<th><?= e((string) $currency) ?></th>
|
||||
<?php endforeach; ?>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<?php if ($recentFetches === []): ?>
|
||||
<tr><td colspan="<?= 1 + count($tableCurrencies) ?>">Noch keine Abrufe vorhanden.</td></tr>
|
||||
<?php else: ?>
|
||||
<?php foreach ($recentFetches as $fetch): ?>
|
||||
<?php
|
||||
$fetchBaseCurrency = strtoupper(trim((string) ($fetch['base_currency'] ?? '')));
|
||||
$snapshot = $service->snapshotByFetchId((int) ($fetch['id'] ?? 0), $fetchBaseCurrency, $tableCurrencies);
|
||||
$originalRates = is_array($snapshot['rates'] ?? null) ? $snapshot['rates'] : [];
|
||||
$displayBaseRate = $displayBaseCurrency === $fetchBaseCurrency
|
||||
? 1.0
|
||||
: (is_numeric($originalRates[$displayBaseCurrency] ?? null) ? (float) $originalRates[$displayBaseCurrency] : null);
|
||||
$tableRates = [];
|
||||
foreach ($tableCurrencies as $currency) {
|
||||
$currency = strtoupper(trim((string) $currency));
|
||||
if ($currency === '') {
|
||||
continue;
|
||||
}
|
||||
if ($currency === $displayBaseCurrency) {
|
||||
$tableRates[$currency] = 1.0;
|
||||
continue;
|
||||
}
|
||||
if ($displayBaseRate === null || $displayBaseRate <= 0) {
|
||||
$tableRates[$currency] = null;
|
||||
continue;
|
||||
}
|
||||
if ($currency === $fetchBaseCurrency) {
|
||||
$tableRates[$currency] = 1 / $displayBaseRate;
|
||||
continue;
|
||||
}
|
||||
$rawRate = $originalRates[$currency] ?? null;
|
||||
$tableRates[$currency] = is_numeric($rawRate) ? ((float) $rawRate / $displayBaseRate) : null;
|
||||
}
|
||||
$infoTitle = sprintf(
|
||||
'Basis: %s | Provider: %s | Ausloeser: %s',
|
||||
(string) ($fetch['base_currency'] ?? '-'),
|
||||
(string) ($fetch['provider'] ?? '-'),
|
||||
(string) ($fetch['trigger_source_label'] ?? $fetch['trigger_source'] ?? '-')
|
||||
);
|
||||
?>
|
||||
<tr>
|
||||
<td>
|
||||
<div class="fx-history-date">
|
||||
<span><?= e((string) ($fetch['fetched_at_display'] ?? $fetch['fetched_at'] ?? '')) ?></span>
|
||||
<button
|
||||
type="button"
|
||||
class="fx-info-button"
|
||||
title="<?= e($infoTitle) ?>"
|
||||
aria-label="<?= e('Abrufinfo fuer ' . (string) ($fetch['fetched_at_display'] ?? $fetch['fetched_at'] ?? '')) ?>"
|
||||
>i</button>
|
||||
</div>
|
||||
</td>
|
||||
<?php foreach ($tableCurrencies as $currency): ?>
|
||||
<?php $value = $tableRates[(string) $currency] ?? null; ?>
|
||||
<td><?= is_numeric($value) ? e(number_format((float) $value, 8, ',', '')) : '–' ?></td>
|
||||
<?php endforeach; ?>
|
||||
</tr>
|
||||
<?php endforeach; ?>
|
||||
<?php endif; ?>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
<?= module_shell_footer() ?>
|
||||
149
modules/fx-rates/pages/index.php
Normal file
149
modules/fx-rates/pages/index.php
Normal file
@@ -0,0 +1,149 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
require_once dirname(__DIR__) . '/bootstrap.php';
|
||||
|
||||
$assets = app()->assets();
|
||||
if ($assets) {
|
||||
$assets->addStyle('/module/fx-rates/asset?file=fx-rates.css');
|
||||
$assets->addScript('/module/fx-rates/asset?file=fx-rates.js', 'footer', true);
|
||||
}
|
||||
|
||||
$settings = module_fn('fx-rates', 'settings');
|
||||
$service = module_fn('fx-rates', 'service');
|
||||
$preferredCurrencies = is_array($settings['preferred_currencies'] ?? null) ? $settings['preferred_currencies'] : [];
|
||||
$apiDescribeUrl = APP_API_BASE . '/fx-rates/v1/endpoints';
|
||||
$notice = trim((string) ($_GET['notice'] ?? ''));
|
||||
$error = trim((string) ($_GET['error'] ?? ''));
|
||||
|
||||
if ((string) ($_GET['refresh'] ?? '') === '1') {
|
||||
try {
|
||||
$force = !empty($_GET['force']);
|
||||
if ($force) {
|
||||
$result = $service->refreshLatestRates(null, (string) ($settings['default_base_currency'] ?? ''), 'manual');
|
||||
} else {
|
||||
$result = $service->autoRefreshLatestRates(
|
||||
(string) ($settings['default_base_currency'] ?? ''),
|
||||
null,
|
||||
(int) ($settings['refresh_max_age_minutes'] ?? 60),
|
||||
'manual'
|
||||
);
|
||||
}
|
||||
|
||||
$params = !empty($result['reused'])
|
||||
? [
|
||||
'notice' => sprintf(
|
||||
'Kein neuer API-Abruf. Der letzte gespeicherte Snapshot ist juenger als %d Minuten. Fuer einen erzwungenen Abruf bitte bestaetigen.',
|
||||
(int) ($settings['refresh_max_age_minutes'] ?? 60)
|
||||
),
|
||||
]
|
||||
: [
|
||||
'notice' => sprintf(
|
||||
'Aktuelle Kurse gespeichert. %d Werte aktualisiert.',
|
||||
(int) ($result['updated_count'] ?? 0)
|
||||
),
|
||||
];
|
||||
} catch (\Throwable $exception) {
|
||||
$params = ['error' => $exception->getMessage() !== '' ? $exception->getMessage() : 'Kurse konnten nicht aktualisiert werden.'];
|
||||
}
|
||||
|
||||
redirect('/module/fx-rates?' . http_build_query($params));
|
||||
}
|
||||
|
||||
$latest = $service->latestStatus();
|
||||
$recentFetches = $service->recentFetches(15);
|
||||
$pageData = json_encode([
|
||||
'settings' => $settings,
|
||||
'latest' => $latest,
|
||||
'preferred_currencies' => $preferredCurrencies,
|
||||
'recent_fetches' => $recentFetches,
|
||||
], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
|
||||
|
||||
$tabs = [
|
||||
['label' => 'Ueberblick', 'href' => '/module/fx-rates', 'active' => true],
|
||||
['label' => 'Waehrungen', 'href' => '/module/fx-rates/currencies'],
|
||||
];
|
||||
?>
|
||||
<?= module_shell_header('fx-rates', [
|
||||
'title' => 'Ueberblick',
|
||||
'tabs' => $tabs,
|
||||
'actions' => [
|
||||
['label' => 'Nexus Übersicht', 'href' => '/', 'variant' => 'secondary', 'size' => 'sm'],
|
||||
['label' => 'Setup', 'href' => '/modules/setup/fx-rates', 'variant' => 'secondary', 'size' => 'sm'],
|
||||
['label' => 'Aktuelle Kurse abrufen', 'href' => '/module/fx-rates?refresh=1', 'variant' => 'secondary', 'size' => 'sm'],
|
||||
],
|
||||
]) ?>
|
||||
<div id="fx-rates-app" data-page='<?= e(is_string($pageData) ? $pageData : '{}') ?>'>
|
||||
<?php if ($notice !== ''): ?>
|
||||
<section class="section-box">
|
||||
<div class="fx-message is-success"><?= e($notice) ?></div>
|
||||
</section>
|
||||
<?php elseif ($error !== ''): ?>
|
||||
<section class="section-box">
|
||||
<div class="fx-message is-error"><?= e($error) ?></div>
|
||||
</section>
|
||||
<?php endif; ?>
|
||||
|
||||
<section class="section-box">
|
||||
<div class="fx-section-head">
|
||||
<div>
|
||||
<h2>Umrechnung</h2>
|
||||
<p>Umrechnung auf Basis des letzten verfuegbaren Kurses zwischen den bevorzugten Waehrungen.</p>
|
||||
</div>
|
||||
</div>
|
||||
<p class="fx-api-note">
|
||||
API-Self-Describe-Endpoint:
|
||||
<a href="<?= e($apiDescribeUrl) ?>" target="_blank" rel="noopener noreferrer"><?= e($apiDescribeUrl) ?></a>
|
||||
</p>
|
||||
<div class="fx-form-grid">
|
||||
<label>
|
||||
<span>Quellwaehrung</span>
|
||||
<select name="convert_from">
|
||||
<?php foreach ($preferredCurrencies as $currency): ?>
|
||||
<option value="<?= e((string) $currency) ?>"><?= e((string) $currency) ?></option>
|
||||
<?php endforeach; ?>
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
<span>Zielwaehrung</span>
|
||||
<select name="convert_to">
|
||||
<?php foreach ($preferredCurrencies as $currency): ?>
|
||||
<option value="<?= e((string) $currency) ?>" <?= (string) $currency === (string) ($preferredCurrencies[1] ?? $preferredCurrencies[0] ?? '') ? 'selected' : '' ?>><?= e((string) $currency) ?></option>
|
||||
<?php endforeach; ?>
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
<span>Betrag</span>
|
||||
<input type="number" name="convert_amount" min="0" step="0.00000001" value="1">
|
||||
</label>
|
||||
</div>
|
||||
<div class="fx-convert-result" data-bind="convert-result">Noch keine Umrechnung berechnet.</div>
|
||||
</section>
|
||||
|
||||
<section class="section-box">
|
||||
<div class="fx-card-head">
|
||||
<div>
|
||||
<h2>Kursverlauf</h2>
|
||||
<p>Neueste Abrufe zuerst. Verlauf der bevorzugten Waehrungen relativ zur Anzeige-Basiswaehrung.</p>
|
||||
</div>
|
||||
<div class="fx-card-meta">
|
||||
<div><strong>Anzeige-Basis:</strong> <?= e((string) ($settings['display_base_currency'] ?? $settings['default_base_currency'] ?? '')) ?></div>
|
||||
<div><strong>Letzter Abruf:</strong> <?= e((string) ($latest['fetched_at_display'] ?? $latest['fetched_at'] ?? 'noch keiner')) ?></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="fx-table-wrap">
|
||||
<table class="fx-table">
|
||||
<thead data-bind="history-head">
|
||||
<tr>
|
||||
<th>Datum</th>
|
||||
<th>Kurse</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody data-bind="history-body">
|
||||
<tr><td colspan="2">Noch keine Verlaufsdaten geladen.</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
<?= module_shell_footer() ?>
|
||||
270
modules/fx-rates/src/Api/Router.php
Normal file
270
modules/fx-rates/src/Api/Router.php
Normal file
@@ -0,0 +1,270 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Modules\FxRates\Api;
|
||||
|
||||
use Modules\FxRates\Domain\FxRatesService;
|
||||
|
||||
final class Router
|
||||
{
|
||||
public function __construct(
|
||||
private FxRatesService $service
|
||||
) {
|
||||
}
|
||||
|
||||
public function handle(string $relativePath): never
|
||||
{
|
||||
$method = strtoupper((string) ($_SERVER['REQUEST_METHOD'] ?? 'GET'));
|
||||
$path = trim($relativePath, '/');
|
||||
|
||||
try {
|
||||
if ($path === 'v1/health' && $method === 'GET') {
|
||||
$this->respond(['ok' => true, 'module' => 'fx-rates']);
|
||||
}
|
||||
|
||||
if ($path === 'v1/endpoints' && $method === 'GET') {
|
||||
$this->respond(['data' => $this->endpointCatalog()]);
|
||||
}
|
||||
|
||||
if ($path === 'v1/status' && $method === 'GET') {
|
||||
$this->respond(['data' => $this->service->latestStatuses()]);
|
||||
}
|
||||
|
||||
if ($path === 'v1/recent-fetches' && $method === 'GET') {
|
||||
$limit = max(1, min(50, (int) ($_GET['limit'] ?? 12)));
|
||||
$this->respond(['data' => $this->service->recentFetches($limit)]);
|
||||
}
|
||||
|
||||
if ($path === 'v1/latest' && $method === 'GET') {
|
||||
$symbols = $this->parseCsv($_GET['symbols'] ?? null);
|
||||
$base = $this->stringOrNull($_GET['base'] ?? null);
|
||||
if ($symbols === null) {
|
||||
$settings = module_fn('fx-rates', 'settings');
|
||||
$symbols = is_array($settings['preferred_currencies'] ?? null) ? $settings['preferred_currencies'] : null;
|
||||
}
|
||||
$snapshot = $this->service->snapshot($base, null, $symbols, null);
|
||||
$this->respond(['data' => $snapshot]);
|
||||
}
|
||||
|
||||
if ($path === 'v1/fetch' && $method === 'GET') {
|
||||
$fetchId = max(0, (int) ($_GET['fetch_id'] ?? 0));
|
||||
$base = $this->stringOrNull($_GET['base'] ?? null);
|
||||
$symbols = $this->parseCsv($_GET['symbols'] ?? null);
|
||||
$snapshot = $this->service->snapshotByFetchId($fetchId, $base, $symbols);
|
||||
$this->respond(['data' => $snapshot]);
|
||||
}
|
||||
|
||||
if ($path === 'v1/nearest' && $method === 'GET') {
|
||||
$base = $this->stringOrNull($_GET['base'] ?? null);
|
||||
$symbols = $this->parseCsv($_GET['symbols'] ?? null);
|
||||
$at = $this->stringOrNull($_GET['at'] ?? null);
|
||||
$windowMinutes = $this->intOrNull($_GET['window_minutes'] ?? null);
|
||||
$snapshot = $this->service->nearestSnapshot($base, (string) $at, $symbols, $windowMinutes);
|
||||
$this->respond(['data' => $snapshot]);
|
||||
}
|
||||
|
||||
if ($path === 'v1/snapshot' && $method === 'GET') {
|
||||
$symbols = $this->parseCsv($_GET['symbols'] ?? null);
|
||||
$base = $this->stringOrNull($_GET['base'] ?? null);
|
||||
$at = $this->stringOrNull($_GET['at'] ?? null);
|
||||
$windowMinutes = $this->intOrNull($_GET['window_minutes'] ?? null);
|
||||
$snapshot = $this->service->snapshot($base, $at, $symbols, $windowMinutes);
|
||||
$this->respond(['data' => $snapshot]);
|
||||
}
|
||||
|
||||
if ($path === 'v1/rate' && $method === 'GET') {
|
||||
$from = $this->stringOrNull($_GET['from'] ?? null);
|
||||
$to = $this->stringOrNull($_GET['to'] ?? null);
|
||||
$at = $this->stringOrNull($_GET['at'] ?? null);
|
||||
$windowMinutes = $this->intOrNull($_GET['window_minutes'] ?? null);
|
||||
$rate = $this->service->findRate($from, $to, $at, $windowMinutes);
|
||||
$this->respond(['data' => $rate]);
|
||||
}
|
||||
|
||||
if ($path === 'v1/history' && $method === 'GET') {
|
||||
$from = $this->stringOrNull($_GET['from'] ?? null);
|
||||
$to = $this->stringOrNull($_GET['to'] ?? null);
|
||||
$fromAt = $this->stringOrNull($_GET['from_at'] ?? null);
|
||||
$toAt = $this->stringOrNull($_GET['to_at'] ?? null);
|
||||
$limit = max(1, min(1000, (int) ($_GET['limit'] ?? 200)));
|
||||
$history = $this->service->history((string) $from, (string) $to, $fromAt, $toAt, $limit);
|
||||
$this->respond(['data' => $history]);
|
||||
}
|
||||
|
||||
if ($path === 'v1/refresh' && $method === 'POST') {
|
||||
$input = $this->input();
|
||||
$base = $this->stringOrNull($input['base'] ?? null);
|
||||
$force = !empty($input['force']);
|
||||
$maxAgeMinutes = is_numeric($input['max_age_minutes'] ?? null) ? (int) $input['max_age_minutes'] : null;
|
||||
|
||||
$result = $force
|
||||
? $this->service->refreshLatestRates(null, $base, 'api')
|
||||
: $this->service->autoRefreshLatestRates($base, null, $maxAgeMinutes, 'api');
|
||||
|
||||
$this->respond(['data' => $result], 201);
|
||||
}
|
||||
|
||||
if ($path === 'v1/probe' && $method === 'GET') {
|
||||
$base = $this->stringOrNull($_GET['base'] ?? null);
|
||||
$this->respond(['data' => $this->service->probeLatestRates($base)]);
|
||||
}
|
||||
|
||||
if ($path === 'v1/settings' && $method === 'GET') {
|
||||
$this->respond(['data' => module_fn('fx-rates', 'settings')]);
|
||||
}
|
||||
|
||||
if ($path === 'v1/settings' && $method === 'PUT') {
|
||||
$this->respond(['data' => module_fn('fx-rates', 'save_runtime_settings', $this->input())]);
|
||||
}
|
||||
|
||||
$this->respond(['error' => 'Unbekannter API-Pfad.'], 404);
|
||||
} catch (\Throwable $exception) {
|
||||
$this->respond([
|
||||
'error' => 'FX-API Fehler.',
|
||||
'context' => ['message' => $exception->getMessage()],
|
||||
], 500);
|
||||
}
|
||||
}
|
||||
|
||||
private function respond(array $payload, int $statusCode = 200): never
|
||||
{
|
||||
http_response_code($statusCode);
|
||||
header('Content-Type: application/json; charset=utf-8');
|
||||
echo json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
|
||||
exit;
|
||||
}
|
||||
|
||||
private function input(): array
|
||||
{
|
||||
$raw = file_get_contents('php://input');
|
||||
$decoded = json_decode((string) $raw, true);
|
||||
return is_array($decoded) ? $decoded : [];
|
||||
}
|
||||
|
||||
private function parseCsv(mixed $value): ?array
|
||||
{
|
||||
if (is_array($value)) {
|
||||
$items = $value;
|
||||
} else {
|
||||
$value = trim((string) $value);
|
||||
if ($value === '') {
|
||||
return null;
|
||||
}
|
||||
$items = explode(',', $value);
|
||||
}
|
||||
|
||||
$result = [];
|
||||
foreach ($items as $item) {
|
||||
$item = strtoupper(trim((string) $item));
|
||||
if ($item !== '') {
|
||||
$result[] = $item;
|
||||
}
|
||||
}
|
||||
|
||||
$result = array_values(array_unique($result));
|
||||
return $result !== [] ? $result : null;
|
||||
}
|
||||
|
||||
private function stringOrNull(mixed $value): ?string
|
||||
{
|
||||
$value = trim((string) $value);
|
||||
return $value !== '' ? $value : null;
|
||||
}
|
||||
|
||||
private function intOrNull(mixed $value): ?int
|
||||
{
|
||||
return is_numeric($value) ? (int) $value : null;
|
||||
}
|
||||
|
||||
private function endpointCatalog(): array
|
||||
{
|
||||
return [
|
||||
'module' => 'fx-rates',
|
||||
'version' => 'v1',
|
||||
'languages' => ['de', 'en'],
|
||||
'endpoints' => [
|
||||
[
|
||||
'path' => '/api/fx-rates/v1/endpoints',
|
||||
'method' => 'GET',
|
||||
'description_de' => 'Gibt alle verfuegbaren FX-API-Endpunkte mit deutscher und englischer Erklaerung zurueck.',
|
||||
'description_en' => 'Returns all available FX API endpoints with German and English explanations.',
|
||||
],
|
||||
[
|
||||
'path' => '/api/fx-rates/v1/latest',
|
||||
'method' => 'GET',
|
||||
'params' => ['base', 'symbols'],
|
||||
'description_de' => 'Liefert den neuesten gespeicherten Snapshot, optional auf eine Zielbasis umgerechnet und auf ausgewaehlte Waehrungen gefiltert.',
|
||||
'description_en' => 'Returns the latest stored snapshot, optionally rebased to a target currency and filtered to selected symbols.',
|
||||
],
|
||||
[
|
||||
'path' => '/api/fx-rates/v1/fetch',
|
||||
'method' => 'GET',
|
||||
'params' => ['fetch_id', 'base', 'symbols'],
|
||||
'description_de' => 'Liefert einen gespeicherten Snapshot anhand der fetch_id, optional umgerechnet auf eine Zielbasis und gefiltert auf einzelne Waehrungen.',
|
||||
'description_en' => 'Returns a stored snapshot by fetch_id, optionally rebased to a target currency and filtered to selected symbols.',
|
||||
],
|
||||
[
|
||||
'path' => '/api/fx-rates/v1/nearest',
|
||||
'method' => 'GET',
|
||||
'params' => ['at', 'base', 'symbols', 'window_minutes'],
|
||||
'description_de' => 'Liefert den zeitlich naechsten gespeicherten Snapshot zu einem Datum/Uhrzeit-Wert.',
|
||||
'description_en' => 'Returns the stored snapshot nearest to a given date/time value.',
|
||||
],
|
||||
[
|
||||
'path' => '/api/fx-rates/v1/snapshot',
|
||||
'method' => 'GET',
|
||||
'params' => ['at', 'base', 'symbols', 'window_minutes'],
|
||||
'description_de' => 'Liefert einen Snapshot zur Zielbasis und sucht fuer einen Zeitpunkt den naechsten passenden gespeicherten Kurs.',
|
||||
'description_en' => 'Returns a snapshot for the requested base and finds the nearest matching stored rate for a given timestamp.',
|
||||
],
|
||||
[
|
||||
'path' => '/api/fx-rates/v1/rate',
|
||||
'method' => 'GET',
|
||||
'params' => ['from', 'to', 'at', 'window_minutes'],
|
||||
'description_de' => 'Liefert einen Einzelkurs zwischen zwei Waehrungen, direkt oder als Kreuzkurs aus gespeicherten Snapshots.',
|
||||
'description_en' => 'Returns a single rate between two currencies, directly or as a cross-rate from stored snapshots.',
|
||||
],
|
||||
[
|
||||
'path' => '/api/fx-rates/v1/history',
|
||||
'method' => 'GET',
|
||||
'params' => ['from', 'to', 'from_at', 'to_at', 'limit'],
|
||||
'description_de' => 'Liefert den gespeicherten Kursverlauf zwischen zwei Waehrungen fuer einen Zeitraum.',
|
||||
'description_en' => 'Returns the stored rate history between two currencies for a given time range.',
|
||||
],
|
||||
[
|
||||
'path' => '/api/fx-rates/v1/refresh',
|
||||
'method' => 'POST',
|
||||
'body' => ['base', 'force', 'max_age_minutes'],
|
||||
'description_de' => 'Aktualisiert Kurse nur dann neu, wenn der letzte Abruf aelter als die erlaubte Zeitspanne ist. Die Antwort enthaelt immer die fetch_id des verwendeten Snapshots.',
|
||||
'description_en' => 'Refreshes rates only if the last fetch is older than the allowed age. The response always includes the fetch_id of the snapshot used.',
|
||||
],
|
||||
[
|
||||
'path' => '/api/fx-rates/v1/status',
|
||||
'method' => 'GET',
|
||||
'description_de' => 'Liefert den neuesten gespeicherten Abruf je Basiswaehrung.',
|
||||
'description_en' => 'Returns the most recent stored fetch per base currency.',
|
||||
],
|
||||
[
|
||||
'path' => '/api/fx-rates/v1/recent-fetches',
|
||||
'method' => 'GET',
|
||||
'params' => ['limit'],
|
||||
'description_de' => 'Liefert die zuletzt gespeicherten Abrufe mit fetch_id und Zeitstempel.',
|
||||
'description_en' => 'Returns the most recently stored fetches including fetch_id and timestamp.',
|
||||
],
|
||||
[
|
||||
'path' => '/api/fx-rates/v1/probe',
|
||||
'method' => 'GET',
|
||||
'params' => ['base'],
|
||||
'description_de' => 'Prueft, ob der konfigurierte Provider aktuelle Kurse liefern kann.',
|
||||
'description_en' => 'Checks whether the configured provider can return current rates.',
|
||||
],
|
||||
[
|
||||
'path' => '/api/fx-rates/v1/settings',
|
||||
'method' => 'GET',
|
||||
'description_de' => 'Liefert die aktuellen Modul-Settings.',
|
||||
'description_en' => 'Returns the current module settings.',
|
||||
],
|
||||
],
|
||||
];
|
||||
}
|
||||
}
|
||||
1015
modules/fx-rates/src/Domain/FxRatesService.php
Normal file
1015
modules/fx-rates/src/Domain/FxRatesService.php
Normal file
File diff suppressed because it is too large
Load Diff
546
modules/fx-rates/src/Infrastructure/FxRatesRepository.php
Normal file
546
modules/fx-rates/src/Infrastructure/FxRatesRepository.php
Normal file
@@ -0,0 +1,546 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Modules\FxRates\Infrastructure;
|
||||
|
||||
use PDO;
|
||||
|
||||
final class FxRatesRepository
|
||||
{
|
||||
private string $driver;
|
||||
|
||||
public function __construct(
|
||||
private PDO $pdo,
|
||||
private string $tablePrefix = 'fxrate_'
|
||||
) {
|
||||
$this->driver = strtolower((string) $this->pdo->getAttribute(PDO::ATTR_DRIVER_NAME));
|
||||
}
|
||||
|
||||
public function ensureSchema(): void
|
||||
{
|
||||
$fetchTable = $this->table('fetches');
|
||||
$rateTable = $this->table('rates');
|
||||
|
||||
if ($this->driver === 'pgsql') {
|
||||
$this->pdo->exec("CREATE TABLE IF NOT EXISTS {$fetchTable} (
|
||||
id SERIAL PRIMARY KEY,
|
||||
provider VARCHAR(64) NOT NULL,
|
||||
trigger_source VARCHAR(32) NOT NULL DEFAULT 'manual',
|
||||
base_currency VARCHAR(10) NOT NULL,
|
||||
rate_date DATE NOT NULL,
|
||||
fetched_at TIMESTAMP NOT NULL,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
)");
|
||||
$this->pdo->exec("ALTER TABLE {$fetchTable} ADD COLUMN IF NOT EXISTS trigger_source VARCHAR(32) NOT NULL DEFAULT 'manual'");
|
||||
$this->pdo->exec("CREATE TABLE IF NOT EXISTS {$rateTable} (
|
||||
id SERIAL PRIMARY KEY,
|
||||
fetch_id INTEGER NOT NULL REFERENCES {$fetchTable}(id) ON DELETE CASCADE,
|
||||
currency_code VARCHAR(10) NOT NULL,
|
||||
current_value NUMERIC(20,10) NOT NULL
|
||||
)");
|
||||
$this->pdo->exec("CREATE INDEX IF NOT EXISTS {$fetchTable}_base_fetch_idx ON {$fetchTable} (base_currency, fetched_at DESC, id DESC)");
|
||||
$this->pdo->exec("CREATE INDEX IF NOT EXISTS {$fetchTable}_rate_date_idx ON {$fetchTable} (rate_date DESC)");
|
||||
$this->pdo->exec("CREATE INDEX IF NOT EXISTS {$rateTable}_fetch_idx ON {$rateTable} (fetch_id)");
|
||||
$this->pdo->exec("CREATE INDEX IF NOT EXISTS {$rateTable}_currency_idx ON {$rateTable} (currency_code)");
|
||||
} elseif ($this->driver === 'mysql') {
|
||||
$this->pdo->exec("CREATE TABLE IF NOT EXISTS {$fetchTable} (
|
||||
id INTEGER PRIMARY KEY AUTO_INCREMENT,
|
||||
provider VARCHAR(64) NOT NULL,
|
||||
trigger_source VARCHAR(32) NOT NULL DEFAULT 'manual',
|
||||
base_currency VARCHAR(10) NOT NULL,
|
||||
rate_date DATE NOT NULL,
|
||||
fetched_at DATETIME NOT NULL,
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
KEY {$fetchTable}_base_fetch_idx (base_currency, fetched_at, id),
|
||||
KEY {$fetchTable}_rate_date_idx (rate_date)
|
||||
)");
|
||||
$this->ensureColumn($fetchTable, 'trigger_source', "ALTER TABLE {$fetchTable} ADD COLUMN trigger_source VARCHAR(32) NOT NULL DEFAULT 'manual'");
|
||||
$this->pdo->exec("CREATE TABLE IF NOT EXISTS {$rateTable} (
|
||||
id INTEGER PRIMARY KEY AUTO_INCREMENT,
|
||||
fetch_id INTEGER NOT NULL,
|
||||
currency_code VARCHAR(10) NOT NULL,
|
||||
current_value DECIMAL(20,10) NOT NULL,
|
||||
KEY {$rateTable}_fetch_idx (fetch_id),
|
||||
KEY {$rateTable}_currency_idx (currency_code)
|
||||
)");
|
||||
} else {
|
||||
$this->pdo->exec("CREATE TABLE IF NOT EXISTS {$fetchTable} (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
provider VARCHAR(64) NOT NULL,
|
||||
trigger_source VARCHAR(32) NOT NULL DEFAULT 'manual',
|
||||
base_currency VARCHAR(10) NOT NULL,
|
||||
rate_date DATE NOT NULL,
|
||||
fetched_at DATETIME NOT NULL,
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
)");
|
||||
$this->ensureColumn($fetchTable, 'trigger_source', "ALTER TABLE {$fetchTable} ADD COLUMN trigger_source VARCHAR(32) NOT NULL DEFAULT 'manual'");
|
||||
$this->pdo->exec("CREATE TABLE IF NOT EXISTS {$rateTable} (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
fetch_id INTEGER NOT NULL,
|
||||
currency_code VARCHAR(10) NOT NULL,
|
||||
current_value DECIMAL(20,10) NOT NULL
|
||||
)");
|
||||
$this->pdo->exec("CREATE INDEX IF NOT EXISTS {$fetchTable}_base_fetch_idx ON {$fetchTable} (base_currency, fetched_at DESC, id DESC)");
|
||||
$this->pdo->exec("CREATE INDEX IF NOT EXISTS {$fetchTable}_rate_date_idx ON {$fetchTable} (rate_date DESC)");
|
||||
$this->pdo->exec("CREATE INDEX IF NOT EXISTS {$rateTable}_fetch_idx ON {$rateTable} (fetch_id)");
|
||||
$this->pdo->exec("CREATE INDEX IF NOT EXISTS {$rateTable}_currency_idx ON {$rateTable} (currency_code)");
|
||||
}
|
||||
}
|
||||
|
||||
public function getLatestFetch(?string $baseCurrency = null): ?array
|
||||
{
|
||||
$sql = 'SELECT id, provider, trigger_source, base_currency, rate_date, fetched_at, created_at FROM ' . $this->table('fetches');
|
||||
$params = [];
|
||||
if ($baseCurrency !== null && trim($baseCurrency) !== '') {
|
||||
$sql .= ' WHERE base_currency = :base_currency';
|
||||
$params['base_currency'] = strtoupper(trim($baseCurrency));
|
||||
}
|
||||
$sql .= ' ORDER BY fetched_at DESC, id DESC LIMIT 1';
|
||||
$stmt = $this->pdo->prepare($sql);
|
||||
$stmt->execute($params);
|
||||
$row = $stmt->fetch(PDO::FETCH_ASSOC);
|
||||
return is_array($row) ? $this->normalizeFetch($row) : null;
|
||||
}
|
||||
|
||||
public function listLatestFetches(): array
|
||||
{
|
||||
$stmt = $this->pdo->query(
|
||||
'SELECT id, provider, trigger_source, base_currency, rate_date, fetched_at, created_at
|
||||
FROM ' . $this->table('fetches') . '
|
||||
ORDER BY fetched_at DESC, id DESC'
|
||||
);
|
||||
|
||||
$latestByBase = [];
|
||||
foreach ($stmt->fetchAll(PDO::FETCH_ASSOC) ?: [] as $row) {
|
||||
$base = strtoupper(trim((string) ($row['base_currency'] ?? '')));
|
||||
if ($base === '' || isset($latestByBase[$base])) {
|
||||
continue;
|
||||
}
|
||||
$latestByBase[$base] = $this->normalizeFetch($row);
|
||||
}
|
||||
|
||||
ksort($latestByBase);
|
||||
return array_values($latestByBase);
|
||||
}
|
||||
|
||||
public function listRecentFetches(int $limit = 20): array
|
||||
{
|
||||
$stmt = $this->pdo->prepare(
|
||||
'SELECT id, provider, trigger_source, base_currency, rate_date, fetched_at, created_at
|
||||
FROM ' . $this->table('fetches') . '
|
||||
ORDER BY fetched_at DESC, id DESC
|
||||
LIMIT :limit'
|
||||
);
|
||||
$stmt->bindValue(':limit', max(1, $limit), PDO::PARAM_INT);
|
||||
$stmt->execute();
|
||||
|
||||
return array_map(
|
||||
fn (array $row): array => $this->normalizeFetch($row),
|
||||
$stmt->fetchAll(PDO::FETCH_ASSOC) ?: []
|
||||
);
|
||||
}
|
||||
|
||||
public function getSnapshotByFetchId(int $fetchId, ?array $symbols = null): ?array
|
||||
{
|
||||
$fetch = $this->getFetchById($fetchId);
|
||||
if ($fetch === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $fetch + [
|
||||
'rates' => $this->ratesForFetch($fetchId, $symbols),
|
||||
];
|
||||
}
|
||||
|
||||
public function findNearestFetch(?string $baseCurrency, string $timestamp, ?int $windowMinutes = null): ?array
|
||||
{
|
||||
$targetTs = strtotime($timestamp);
|
||||
if ($targetTs === false) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if ($baseCurrency !== null && trim($baseCurrency) !== '') {
|
||||
return $this->getNearestFetch(strtoupper(trim($baseCurrency)), $timestamp, $windowMinutes);
|
||||
}
|
||||
|
||||
$candidates = [];
|
||||
foreach (['<=', '>='] as $operator) {
|
||||
$order = $operator === '<=' ? 'DESC' : 'ASC';
|
||||
$stmt = $this->pdo->prepare(
|
||||
'SELECT id, provider, trigger_source, base_currency, rate_date, fetched_at, created_at
|
||||
FROM ' . $this->table('fetches') . '
|
||||
WHERE fetched_at ' . $operator . ' :target_at
|
||||
ORDER BY fetched_at ' . $order . ', id ' . $order . '
|
||||
LIMIT 1'
|
||||
);
|
||||
$stmt->execute(['target_at' => $timestamp]);
|
||||
$row = $stmt->fetch(PDO::FETCH_ASSOC);
|
||||
if (is_array($row)) {
|
||||
$candidate = $this->normalizeFetch($row);
|
||||
$candidateTs = strtotime((string) ($candidate['fetched_at'] ?? ''));
|
||||
if ($candidateTs !== false) {
|
||||
$candidate['distance_seconds'] = abs($candidateTs - $targetTs);
|
||||
$candidates[] = $candidate;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if ($candidates === []) {
|
||||
return null;
|
||||
}
|
||||
|
||||
usort($candidates, static function (array $left, array $right): int {
|
||||
return ((int) ($left['distance_seconds'] ?? PHP_INT_MAX)) <=> ((int) ($right['distance_seconds'] ?? PHP_INT_MAX));
|
||||
});
|
||||
|
||||
$selected = $candidates[0];
|
||||
if ($windowMinutes !== null && $windowMinutes > 0 && (int) ($selected['distance_seconds'] ?? 0) > ($windowMinutes * 60)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $selected;
|
||||
}
|
||||
|
||||
public function getNearestFetch(string $baseCurrency, string $timestamp, ?int $windowMinutes = null): ?array
|
||||
{
|
||||
$baseCurrency = strtoupper(trim($baseCurrency));
|
||||
if ($baseCurrency === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
$before = $this->findNeighborFetch($baseCurrency, $timestamp, '<=');
|
||||
$after = $this->findNeighborFetch($baseCurrency, $timestamp, '>=');
|
||||
$targetTs = strtotime($timestamp);
|
||||
if ($targetTs === false) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$selected = null;
|
||||
$selectedDiff = null;
|
||||
foreach ([$before, $after] as $candidate) {
|
||||
if (!is_array($candidate)) {
|
||||
continue;
|
||||
}
|
||||
$candidateTs = strtotime((string) ($candidate['fetched_at'] ?? ''));
|
||||
if ($candidateTs === false) {
|
||||
continue;
|
||||
}
|
||||
$diffSeconds = abs($candidateTs - $targetTs);
|
||||
if ($selected === null || $diffSeconds < (int) $selectedDiff) {
|
||||
$selected = $candidate;
|
||||
$selectedDiff = $diffSeconds;
|
||||
}
|
||||
}
|
||||
|
||||
if ($selected === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if ($windowMinutes !== null && $windowMinutes > 0 && $selectedDiff !== null && $selectedDiff > ($windowMinutes * 60)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $selected + ['distance_seconds' => $selectedDiff];
|
||||
}
|
||||
|
||||
public function listDirectHistory(string $baseCurrency, string $targetCurrency, ?string $from = null, ?string $to = null, int $limit = 200): array
|
||||
{
|
||||
$sql = 'SELECT
|
||||
r.id,
|
||||
f.id AS fetch_id,
|
||||
f.base_currency,
|
||||
r.currency_code AS target_currency,
|
||||
r.current_value AS rate,
|
||||
f.rate_date,
|
||||
f.provider,
|
||||
f.fetched_at
|
||||
FROM ' . $this->table('rates') . ' r
|
||||
INNER JOIN ' . $this->table('fetches') . ' f ON f.id = r.fetch_id
|
||||
WHERE f.base_currency = :base_currency
|
||||
AND r.currency_code = :target_currency';
|
||||
$params = [
|
||||
'base_currency' => strtoupper(trim($baseCurrency)),
|
||||
'target_currency' => strtoupper(trim($targetCurrency)),
|
||||
];
|
||||
|
||||
if ($from !== null && trim($from) !== '') {
|
||||
$sql .= ' AND f.fetched_at >= :from_at';
|
||||
$params['from_at'] = $from;
|
||||
}
|
||||
if ($to !== null && trim($to) !== '') {
|
||||
$sql .= ' AND f.fetched_at <= :to_at';
|
||||
$params['to_at'] = $to;
|
||||
}
|
||||
|
||||
$sql .= ' ORDER BY f.fetched_at DESC, r.id DESC LIMIT :limit';
|
||||
$stmt = $this->pdo->prepare($sql);
|
||||
foreach ($params as $key => $value) {
|
||||
$stmt->bindValue(':' . $key, $value);
|
||||
}
|
||||
$stmt->bindValue(':limit', max(1, $limit), PDO::PARAM_INT);
|
||||
$stmt->execute();
|
||||
|
||||
return array_map(fn (array $row): array => $this->normalizeRate($row), $stmt->fetchAll(PDO::FETCH_ASSOC) ?: []);
|
||||
}
|
||||
|
||||
public function saveFetch(string $baseCurrency, string $provider, string $rateDate, array $rates, ?string $fetchedAt = null, string $triggerSource = 'manual'): array
|
||||
{
|
||||
$baseCurrency = strtoupper(trim($baseCurrency));
|
||||
$provider = trim($provider) !== '' ? trim($provider) : 'currencyapi';
|
||||
$fetchedAt = trim((string) $fetchedAt) !== '' ? trim((string) $fetchedAt) : gmdate('Y-m-d H:i:s');
|
||||
$triggerSource = $this->normalizeTriggerSource($triggerSource);
|
||||
$normalizedRates = [];
|
||||
foreach ($rates as $currencyCode => $rate) {
|
||||
$currencyCode = strtoupper(trim((string) $currencyCode));
|
||||
if ($currencyCode === '' || $currencyCode === $baseCurrency || !is_numeric($rate)) {
|
||||
continue;
|
||||
}
|
||||
$normalizedRates[$currencyCode] = (float) $rate;
|
||||
}
|
||||
|
||||
$startedTransaction = false;
|
||||
if (!$this->pdo->inTransaction()) {
|
||||
$this->pdo->beginTransaction();
|
||||
$startedTransaction = true;
|
||||
}
|
||||
|
||||
try {
|
||||
if ($this->driver === 'pgsql') {
|
||||
$fetchStmt = $this->pdo->prepare(
|
||||
'INSERT INTO ' . $this->table('fetches') . ' (
|
||||
provider, trigger_source, base_currency, rate_date, fetched_at
|
||||
) VALUES (
|
||||
:provider, :trigger_source, :base_currency, :rate_date, :fetched_at
|
||||
)
|
||||
RETURNING *'
|
||||
);
|
||||
$fetchStmt->execute([
|
||||
'provider' => $provider,
|
||||
'trigger_source' => $triggerSource,
|
||||
'base_currency' => $baseCurrency,
|
||||
'rate_date' => $rateDate,
|
||||
'fetched_at' => $fetchedAt,
|
||||
]);
|
||||
$fetch = $this->normalizeFetch($fetchStmt->fetch(PDO::FETCH_ASSOC) ?: []);
|
||||
} else {
|
||||
$fetchStmt = $this->pdo->prepare(
|
||||
'INSERT INTO ' . $this->table('fetches') . ' (
|
||||
provider, trigger_source, base_currency, rate_date, fetched_at
|
||||
) VALUES (
|
||||
:provider, :trigger_source, :base_currency, :rate_date, :fetched_at
|
||||
)'
|
||||
);
|
||||
$fetchStmt->execute([
|
||||
'provider' => $provider,
|
||||
'trigger_source' => $triggerSource,
|
||||
'base_currency' => $baseCurrency,
|
||||
'rate_date' => $rateDate,
|
||||
'fetched_at' => $fetchedAt,
|
||||
]);
|
||||
$fetch = $this->getFetchById((int) $this->pdo->lastInsertId()) ?? [];
|
||||
}
|
||||
|
||||
$savedRates = [];
|
||||
if ($normalizedRates !== []) {
|
||||
$placeholders = [];
|
||||
$params = ['fetch_id' => (int) ($fetch['id'] ?? 0)];
|
||||
$index = 0;
|
||||
foreach ($normalizedRates as $currencyCode => $rate) {
|
||||
$codeKey = 'currency_code_' . $index;
|
||||
$valueKey = 'current_value_' . $index;
|
||||
$placeholders[] = "(:fetch_id, :{$codeKey}, :{$valueKey})";
|
||||
$params[$codeKey] = $currencyCode;
|
||||
$params[$valueKey] = $rate;
|
||||
$savedRates[] = [
|
||||
'fetch_id' => $fetch['id'] ?? null,
|
||||
'base_currency' => $baseCurrency,
|
||||
'target_currency' => $currencyCode,
|
||||
'rate' => $rate,
|
||||
'rate_date' => $rateDate,
|
||||
'provider' => $provider,
|
||||
'fetched_at' => $fetchedAt,
|
||||
];
|
||||
$index++;
|
||||
}
|
||||
|
||||
$insert = $this->pdo->prepare(
|
||||
'INSERT INTO ' . $this->table('rates') . ' (fetch_id, currency_code, current_value) VALUES ' . implode(', ', $placeholders)
|
||||
);
|
||||
$insert->execute($params);
|
||||
}
|
||||
|
||||
if ($startedTransaction) {
|
||||
$this->pdo->commit();
|
||||
}
|
||||
|
||||
return [
|
||||
'fetch' => $fetch,
|
||||
'rates' => $savedRates,
|
||||
];
|
||||
} catch (\Throwable $exception) {
|
||||
if ($startedTransaction && $this->pdo->inTransaction()) {
|
||||
$this->pdo->rollBack();
|
||||
}
|
||||
throw $exception;
|
||||
}
|
||||
}
|
||||
|
||||
public function findFetchByBaseAndFetchedAt(string $baseCurrency, string $fetchedAt): ?array
|
||||
{
|
||||
$baseCurrency = strtoupper(trim($baseCurrency));
|
||||
$fetchedAt = trim($fetchedAt);
|
||||
if ($baseCurrency === '' || $fetchedAt === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
$stmt = $this->pdo->prepare(
|
||||
'SELECT id, provider, trigger_source, base_currency, rate_date, fetched_at, created_at
|
||||
FROM ' . $this->table('fetches') . '
|
||||
WHERE base_currency = :base_currency
|
||||
AND fetched_at = :fetched_at
|
||||
ORDER BY id ASC
|
||||
LIMIT 1'
|
||||
);
|
||||
$stmt->execute([
|
||||
'base_currency' => $baseCurrency,
|
||||
'fetched_at' => $fetchedAt,
|
||||
]);
|
||||
$row = $stmt->fetch(PDO::FETCH_ASSOC);
|
||||
return is_array($row) ? $this->normalizeFetch($row) : null;
|
||||
}
|
||||
|
||||
private function getFetchById(int $fetchId): ?array
|
||||
{
|
||||
$stmt = $this->pdo->prepare(
|
||||
'SELECT id, provider, trigger_source, base_currency, rate_date, fetched_at, created_at
|
||||
FROM ' . $this->table('fetches') . '
|
||||
WHERE id = :id
|
||||
LIMIT 1'
|
||||
);
|
||||
$stmt->execute(['id' => $fetchId]);
|
||||
$row = $stmt->fetch(PDO::FETCH_ASSOC);
|
||||
return is_array($row) ? $this->normalizeFetch($row) : null;
|
||||
}
|
||||
|
||||
private function findNeighborFetch(string $baseCurrency, string $timestamp, string $operator): ?array
|
||||
{
|
||||
$order = $operator === '<=' ? 'DESC' : 'ASC';
|
||||
$stmt = $this->pdo->prepare(
|
||||
'SELECT id, provider, trigger_source, base_currency, rate_date, fetched_at, created_at
|
||||
FROM ' . $this->table('fetches') . '
|
||||
WHERE base_currency = :base_currency
|
||||
AND fetched_at ' . $operator . ' :target_at
|
||||
ORDER BY fetched_at ' . $order . ', id ' . $order . '
|
||||
LIMIT 1'
|
||||
);
|
||||
$stmt->execute([
|
||||
'base_currency' => $baseCurrency,
|
||||
'target_at' => $timestamp,
|
||||
]);
|
||||
$row = $stmt->fetch(PDO::FETCH_ASSOC);
|
||||
return is_array($row) ? $this->normalizeFetch($row) : null;
|
||||
}
|
||||
|
||||
private function ratesForFetch(int $fetchId, ?array $symbols = null): array
|
||||
{
|
||||
$sql = 'SELECT currency_code, current_value FROM ' . $this->table('rates') . ' WHERE fetch_id = :fetch_id';
|
||||
$params = ['fetch_id' => $fetchId];
|
||||
|
||||
$normalizedSymbols = [];
|
||||
if (is_array($symbols)) {
|
||||
foreach ($symbols as $symbol) {
|
||||
$symbol = strtoupper(trim((string) $symbol));
|
||||
if ($symbol !== '') {
|
||||
$normalizedSymbols[] = $symbol;
|
||||
}
|
||||
}
|
||||
$normalizedSymbols = array_values(array_unique($normalizedSymbols));
|
||||
}
|
||||
|
||||
if ($normalizedSymbols !== []) {
|
||||
$placeholders = [];
|
||||
foreach ($normalizedSymbols as $index => $symbol) {
|
||||
$key = 'symbol_' . $index;
|
||||
$placeholders[] = ':' . $key;
|
||||
$params[$key] = $symbol;
|
||||
}
|
||||
$sql .= ' AND currency_code IN (' . implode(', ', $placeholders) . ')';
|
||||
}
|
||||
|
||||
$sql .= ' ORDER BY currency_code ASC';
|
||||
$stmt = $this->pdo->prepare($sql);
|
||||
$stmt->execute($params);
|
||||
|
||||
$rates = [];
|
||||
foreach ($stmt->fetchAll(PDO::FETCH_ASSOC) ?: [] as $row) {
|
||||
$code = strtoupper(trim((string) ($row['currency_code'] ?? '')));
|
||||
$rate = $row['current_value'] ?? null;
|
||||
if ($code === '' || !is_numeric($rate)) {
|
||||
continue;
|
||||
}
|
||||
$rates[$code] = (float) $rate;
|
||||
}
|
||||
|
||||
return $rates;
|
||||
}
|
||||
|
||||
private function normalizeFetch(array $row): array
|
||||
{
|
||||
return [
|
||||
'id' => isset($row['id']) ? (int) $row['id'] : null,
|
||||
'provider' => (string) ($row['provider'] ?? ''),
|
||||
'trigger_source' => (string) ($row['trigger_source'] ?? 'manual'),
|
||||
'base_currency' => strtoupper((string) ($row['base_currency'] ?? '')),
|
||||
'rate_date' => (string) ($row['rate_date'] ?? ''),
|
||||
'fetched_at' => (string) ($row['fetched_at'] ?? ''),
|
||||
'created_at' => (string) ($row['created_at'] ?? ''),
|
||||
];
|
||||
}
|
||||
|
||||
private function ensureColumn(string $table, string $column, string $alterSql): void
|
||||
{
|
||||
try {
|
||||
$stmt = $this->pdo->query('SELECT * FROM ' . $table . ' LIMIT 1');
|
||||
if ($stmt instanceof \PDOStatement) {
|
||||
$row = $stmt->fetch(PDO::FETCH_ASSOC) ?: [];
|
||||
if (in_array(strtolower($column), array_map('strtolower', array_keys($row)), true)) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
} catch (\Throwable) {
|
||||
}
|
||||
|
||||
try {
|
||||
$this->pdo->exec($alterSql);
|
||||
} catch (\Throwable) {
|
||||
}
|
||||
}
|
||||
|
||||
private function normalizeTriggerSource(string $source): string
|
||||
{
|
||||
$source = strtolower(trim($source));
|
||||
return match ($source) {
|
||||
'cron', 'manual', 'api', 'migration' => $source,
|
||||
default => 'manual',
|
||||
};
|
||||
}
|
||||
|
||||
private function normalizeRate(array $row): array
|
||||
{
|
||||
return [
|
||||
'id' => isset($row['id']) ? (int) $row['id'] : null,
|
||||
'fetch_id' => isset($row['fetch_id']) ? (int) $row['fetch_id'] : null,
|
||||
'base_currency' => strtoupper((string) ($row['base_currency'] ?? '')),
|
||||
'target_currency' => strtoupper((string) ($row['target_currency'] ?? '')),
|
||||
'rate' => is_numeric($row['rate'] ?? null) ? (float) $row['rate'] : null,
|
||||
'rate_date' => (string) ($row['rate_date'] ?? ''),
|
||||
'provider' => (string) ($row['provider'] ?? ''),
|
||||
'fetched_at' => (string) ($row['fetched_at'] ?? ''),
|
||||
];
|
||||
}
|
||||
|
||||
private function table(string $logicalName): string
|
||||
{
|
||||
return $this->tablePrefix . preg_replace('/[^a-zA-Z0-9_]/', '', $logicalName);
|
||||
}
|
||||
}
|
||||
12
modules/kea/design.json
Normal file
12
modules/kea/design.json
Normal file
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"eyebrow": "Modul",
|
||||
"title": "KEA DHCP",
|
||||
"description": "Verwaltung von KEA DHCP Hosts und Reservierungen.",
|
||||
"actions": [
|
||||
{ "label": "Setup", "href": "/modules/setup/kea", "variant": "secondary" }
|
||||
],
|
||||
"tabs": [
|
||||
{ "label": "Hosts", "href": "/module/kea", "match_prefixes": ["/module/kea", "/module/kea/edit"] },
|
||||
{ "label": "Gruppen", "href": "/module/kea/groups", "match_prefixes": ["/module/kea/groups"] }
|
||||
]
|
||||
}
|
||||
26
modules/kea/migrations/001_1.0.0_baseline.php
Normal file
26
modules/kea/migrations/001_1.0.0_baseline.php
Normal file
@@ -0,0 +1,26 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Database;
|
||||
use App\ModuleMigrationContext;
|
||||
use App\Repository\KeaHostMetadataRepository;
|
||||
|
||||
return new class {
|
||||
public function up(ModuleMigrationContext $context): void
|
||||
{
|
||||
$settings = $context->settings();
|
||||
$fallback = is_array($context->module['metadata_db_defaults'] ?? null)
|
||||
? $context->module['metadata_db_defaults']
|
||||
: [];
|
||||
$config = is_array($settings['metadata_db'] ?? null)
|
||||
? array_replace($fallback, $settings['metadata_db'])
|
||||
: $fallback;
|
||||
|
||||
if (empty($config['driver']) || empty($config['dbname'])) {
|
||||
return;
|
||||
}
|
||||
|
||||
$repo = new KeaHostMetadataRepository(Database::createFromArray($config));
|
||||
$repo->ensureSchema();
|
||||
}
|
||||
};
|
||||
26
modules/kea/migrations/002_1.1.0_groups.php
Normal file
26
modules/kea/migrations/002_1.1.0_groups.php
Normal file
@@ -0,0 +1,26 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Database;
|
||||
use App\ModuleMigrationContext;
|
||||
use App\Repository\KeaHostMetadataRepository;
|
||||
|
||||
return new class {
|
||||
public function up(ModuleMigrationContext $context): void
|
||||
{
|
||||
$settings = $context->settings();
|
||||
$fallback = is_array($context->module['metadata_db_defaults'] ?? null)
|
||||
? $context->module['metadata_db_defaults']
|
||||
: [];
|
||||
$config = is_array($settings['metadata_db'] ?? null)
|
||||
? array_replace($fallback, $settings['metadata_db'])
|
||||
: $fallback;
|
||||
|
||||
if (empty($config['driver']) || empty($config['dbname'])) {
|
||||
return;
|
||||
}
|
||||
|
||||
$repo = new KeaHostMetadataRepository(Database::createFromArray($config));
|
||||
$repo->ensureSchema();
|
||||
}
|
||||
};
|
||||
26
modules/kea/migrations/003_1.2.0_device_checks.php
Normal file
26
modules/kea/migrations/003_1.2.0_device_checks.php
Normal file
@@ -0,0 +1,26 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Database;
|
||||
use App\ModuleMigrationContext;
|
||||
use App\Repository\KeaHostMetadataRepository;
|
||||
|
||||
return new class {
|
||||
public function up(ModuleMigrationContext $context): void
|
||||
{
|
||||
$settings = $context->settings();
|
||||
$fallback = is_array($context->module['metadata_db_defaults'] ?? null)
|
||||
? $context->module['metadata_db_defaults']
|
||||
: [];
|
||||
$config = is_array($settings['metadata_db'] ?? null)
|
||||
? array_replace($fallback, $settings['metadata_db'])
|
||||
: $fallback;
|
||||
|
||||
if (empty($config['driver']) || empty($config['dbname'])) {
|
||||
return;
|
||||
}
|
||||
|
||||
$repo = new KeaHostMetadataRepository(Database::createFromArray($config));
|
||||
$repo->ensureSchema();
|
||||
}
|
||||
};
|
||||
@@ -1,29 +1,24 @@
|
||||
{
|
||||
"title": "KEA DHCP",
|
||||
"version": "1.0.0",
|
||||
"version": "1.2.0",
|
||||
"schema_version": 3,
|
||||
"description": "Verwaltung von KEA DHCP Hosts und Reservierungen.",
|
||||
"menu": [
|
||||
{ "label": "Hosts", "href": "/module/kea" },
|
||||
{ "label": "Setup", "href": "/modules/setup/kea" }
|
||||
],
|
||||
"sidebar": {
|
||||
"enabled": true,
|
||||
"collapsible": true,
|
||||
"default": "collapsed",
|
||||
"items": [
|
||||
{ "label": "Hosts", "href": "/module/kea" },
|
||||
{ "label": "Setup", "href": "/modules/setup/kea" }
|
||||
]
|
||||
},
|
||||
"setup": {
|
||||
"fields": [
|
||||
{ "name": "db.driver", "label": "DB Driver", "type": "text", "required": true },
|
||||
{ "name": "db.host", "label": "DB Host", "type": "text", "required": true },
|
||||
{ "name": "db.port", "label": "DB Port", "type": "number", "required": true },
|
||||
{ "name": "db.dbname", "label": "DB Name", "type": "text", "required": true },
|
||||
{ "name": "db.schema", "label": "DB Schema", "type": "text", "required": false },
|
||||
{ "name": "db.user", "label": "DB User", "type": "text", "required": true },
|
||||
{ "name": "db.password", "label": "DB Passwort", "type": "password", "required": true },
|
||||
{ "name": "db.driver", "label": "KEA DB Driver", "type": "text", "required": true, "help": "Standard-KEA-Datenbank, die auch vom KEA-Dienst selbst genutzt wird." },
|
||||
{ "name": "db.host", "label": "KEA DB Host", "type": "text", "required": true },
|
||||
{ "name": "db.port", "label": "KEA DB Port", "type": "number", "required": true },
|
||||
{ "name": "db.dbname", "label": "KEA DB Name", "type": "text", "required": true },
|
||||
{ "name": "db.schema", "label": "KEA DB Schema", "type": "text", "required": false },
|
||||
{ "name": "db.user", "label": "KEA DB User", "type": "text", "required": true },
|
||||
{ "name": "db.password", "label": "KEA DB Passwort", "type": "password", "required": true },
|
||||
{ "name": "metadata_db.driver", "label": "Nexus DHCP DB Driver", "type": "text", "required": true, "help": "Separate Datenbank fuer Nexus-eigene DHCP-Zusatzinfos, nicht fuer KEA-Standardtabellen." },
|
||||
{ "name": "metadata_db.host", "label": "Nexus DHCP DB Host", "type": "text", "required": true },
|
||||
{ "name": "metadata_db.port", "label": "Nexus DHCP DB Port", "type": "number", "required": true },
|
||||
{ "name": "metadata_db.dbname", "label": "Nexus DHCP DB Name", "type": "text", "required": true },
|
||||
{ "name": "metadata_db.schema", "label": "Nexus DHCP DB Schema", "type": "text", "required": false },
|
||||
{ "name": "metadata_db.user", "label": "Nexus DHCP DB User", "type": "text", "required": true },
|
||||
{ "name": "metadata_db.password", "label": "Nexus DHCP DB Passwort", "type": "password", "required": true },
|
||||
{ "name": "kea_db_version", "label": "KEA DB Version", "type": "text", "required": false },
|
||||
{ "name": "kea_init_script", "label": "KEA Init Script", "type": "text", "required": false },
|
||||
{ "name": "kea_init_cmd", "label": "KEA Init Command", "type": "text", "required": false },
|
||||
@@ -38,5 +33,14 @@
|
||||
"schema": "public",
|
||||
"user": "",
|
||||
"password": ""
|
||||
},
|
||||
"metadata_db_defaults": {
|
||||
"driver": "mysql",
|
||||
"host": "192.168.178.10",
|
||||
"port": 3306,
|
||||
"dbname": "",
|
||||
"schema": "",
|
||||
"user": "",
|
||||
"password": ""
|
||||
}
|
||||
}
|
||||
|
||||
91
modules/kea/pages/data.php
Normal file
91
modules/kea/pages/data.php
Normal file
@@ -0,0 +1,91 @@
|
||||
<?php
|
||||
use App\Database;
|
||||
use App\Repository\KeaHostMetadataRepository;
|
||||
use App\Repository\KeaHostRepository;
|
||||
|
||||
header('Content-Type: application/json; charset=utf-8');
|
||||
header('Cache-Control: no-store');
|
||||
|
||||
$module = modules()->get('kea');
|
||||
$fallback = $module['db_defaults'] ?? [];
|
||||
$settings = modules()->settings('kea');
|
||||
$metadataFallback = is_array($module['metadata_db_defaults'] ?? null) ? $module['metadata_db_defaults'] : [];
|
||||
$metadataConfig = is_array($settings['metadata_db'] ?? null)
|
||||
? array_replace($metadataFallback, $settings['metadata_db'])
|
||||
: $metadataFallback;
|
||||
|
||||
try {
|
||||
$metadataRepo = null;
|
||||
$pdo = modules()->modulePdo('kea', $fallback);
|
||||
if (!empty($metadataConfig['driver']) && !empty($metadataConfig['dbname'])) {
|
||||
try {
|
||||
$metadataRepo = new KeaHostMetadataRepository(Database::createFromArray($metadataConfig));
|
||||
$metadataRepo->ensureSchema();
|
||||
} catch (\Throwable) {
|
||||
$metadataRepo = null;
|
||||
}
|
||||
}
|
||||
|
||||
$repo = new KeaHostRepository($pdo, $metadataRepo);
|
||||
$hosts = $repo->findAll(200);
|
||||
$stats = [
|
||||
'total' => $repo->countReservations() + $repo->countLeases(),
|
||||
'reservations' => $repo->countReservations(),
|
||||
'leases' => $repo->countLeases(),
|
||||
'groups' => [],
|
||||
'free_ips' => [],
|
||||
];
|
||||
|
||||
foreach ($hosts as $host) {
|
||||
$group = trim((string)($host['metadata']['group_name'] ?? ''));
|
||||
if ($group !== '') {
|
||||
$stats['groups'][$group] = ($stats['groups'][$group] ?? 0) + 1;
|
||||
}
|
||||
}
|
||||
|
||||
if ($metadataRepo !== null) {
|
||||
$stats['free_ips'] = array_map(
|
||||
static fn(array $ips): int => count($ips),
|
||||
$metadataRepo->availableIpsByGroup(
|
||||
array_merge($repo->usedIpAddresses(), $metadataRepo->desiredIps()),
|
||||
4096
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
$rows = array_map(static function (array $host): array {
|
||||
return [
|
||||
'source' => (string)($host['source'] ?? 'reservation'),
|
||||
'host_id' => (string)($host['host_id'] ?? '0'),
|
||||
'display_name' => (string)($host['metadata']['device_name'] ?? $host['metadata']['real_name'] ?? $host['display_name'] ?? $host['hostname'] ?? 'Unbekannt'),
|
||||
'ipv4_address' => (string)($host['ipv4_address'] ?? ''),
|
||||
'dhcp_identifier' => (string)($host['dhcp_identifier'] ?? ''),
|
||||
'last_seen_at' => (string)($host['last_seen_at'] ?? '-'),
|
||||
'lease_expires_at' => (string)($host['lease_expires_at'] ?? '-'),
|
||||
'real_name' => (string)($host['metadata']['real_name'] ?? '-'),
|
||||
'location' => (string)($host['metadata']['location'] ?? '-'),
|
||||
'group_name' => (string)($host['metadata']['group_name'] ?? '-'),
|
||||
];
|
||||
}, $hosts);
|
||||
|
||||
echo json_encode([
|
||||
'ok' => true,
|
||||
'stats' => [
|
||||
'total' => (int)$stats['total'],
|
||||
'reservations' => (int)$stats['reservations'],
|
||||
'leases' => (int)$stats['leases'],
|
||||
'groups' => count($stats['groups']),
|
||||
'free_ips' => array_sum($stats['free_ips']),
|
||||
],
|
||||
'rows' => $rows,
|
||||
'updated_at' => date('H:i:s'),
|
||||
], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
|
||||
} catch (\Throwable $e) {
|
||||
http_response_code(500);
|
||||
echo json_encode([
|
||||
'ok' => false,
|
||||
'error' => $e->getMessage(),
|
||||
], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
|
||||
}
|
||||
|
||||
exit;
|
||||
266
modules/kea/pages/edit.php
Normal file
266
modules/kea/pages/edit.php
Normal file
@@ -0,0 +1,266 @@
|
||||
<?php
|
||||
use App\Database;
|
||||
use App\Repository\KeaHostMetadataRepository;
|
||||
use App\Repository\KeaHostRepository;
|
||||
|
||||
$module = modules()->get('kea');
|
||||
$settings = modules()->settings('kea');
|
||||
$fallback = $module['db_defaults'] ?? [];
|
||||
$metadataFallback = is_array($module['metadata_db_defaults'] ?? null) ? $module['metadata_db_defaults'] : [];
|
||||
$metadataConfig = is_array($settings['metadata_db'] ?? null)
|
||||
? array_replace($metadataFallback, $settings['metadata_db'])
|
||||
: $metadataFallback;
|
||||
|
||||
$source = (string)($_GET['source'] ?? $_POST['source'] ?? 'reservation');
|
||||
$source = $source === 'lease' ? 'lease' : 'reservation';
|
||||
$id = (int)($_GET['id'] ?? $_POST['id'] ?? 0);
|
||||
$error = null;
|
||||
$notice = null;
|
||||
$host = null;
|
||||
$metadataRepo = null;
|
||||
$groups = [];
|
||||
$availableIpsByGroup = [];
|
||||
$checks = [];
|
||||
$hostKey = '';
|
||||
|
||||
try {
|
||||
$pdo = modules()->modulePdo('kea', $fallback);
|
||||
if (empty($metadataConfig['driver']) || empty($metadataConfig['dbname'])) {
|
||||
throw new RuntimeException('Nexus DHCP Zusatzdatenbank ist nicht konfiguriert.');
|
||||
}
|
||||
|
||||
$metadataRepo = new KeaHostMetadataRepository(Database::createFromArray($metadataConfig));
|
||||
$metadataRepo->ensureSchema();
|
||||
$groups = $metadataRepo->listGroups();
|
||||
$repo = new KeaHostRepository($pdo, $metadataRepo);
|
||||
$host = $repo->findDisplayByKey($source, $id);
|
||||
if (!$host) {
|
||||
throw new RuntimeException('KEA Eintrag wurde nicht gefunden.');
|
||||
}
|
||||
$hostKey = $source . ':' . (string)($host['host_id'] ?? $id);
|
||||
|
||||
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||
$action = (string)($_POST['action'] ?? 'save_metadata');
|
||||
if ($action === 'dns_lookup') {
|
||||
$ip = (string)($host['ipv4_address'] ?? '');
|
||||
$hostname = trim((string)($host['metadata']['device_name'] ?? $host['hostname'] ?? ''));
|
||||
$reverse = $ip !== '' ? gethostbyaddr($ip) : '';
|
||||
$forward = $hostname !== '' ? gethostbyname($hostname) : '';
|
||||
$success = ($reverse !== '' && $reverse !== $ip) || ($forward !== '' && $forward !== $hostname);
|
||||
|
||||
$metadataRepo->saveCheck($hostKey, 'dns', $success ? 'success' : 'warning', [
|
||||
'ip' => $ip,
|
||||
'hostname' => $hostname,
|
||||
'reverse' => $reverse,
|
||||
'forward' => $forward,
|
||||
]);
|
||||
$notice = $success ? 'DNS-Pruefung gespeichert.' : 'DNS-Pruefung gespeichert, aber kein eindeutiger Name gefunden.';
|
||||
} else {
|
||||
$metadata = [
|
||||
'real_name' => $_POST['real_name'] ?? '',
|
||||
'device_name' => $_POST['device_name'] ?? '',
|
||||
'owner' => $_POST['owner'] ?? '',
|
||||
'location' => $_POST['location'] ?? '',
|
||||
'device_type' => $_POST['device_type'] ?? '',
|
||||
'group_name' => $_POST['group_name'] ?? '',
|
||||
'desired_ip' => $_POST['desired_ip'] ?? '',
|
||||
'notes' => $_POST['notes'] ?? '',
|
||||
'tags' => [],
|
||||
];
|
||||
$desiredIp = trim((string)$metadata['desired_ip']);
|
||||
if ($desiredIp !== '') {
|
||||
$newHostId = $repo->reserveDisplayEntry($host, $desiredIp, $metadata);
|
||||
$source = 'reservation';
|
||||
$id = $newHostId;
|
||||
$notice = 'Zusatzdaten gespeichert und KEA-Reservierung gesetzt.';
|
||||
} else {
|
||||
$metadataRepo->saveForHost(
|
||||
$id,
|
||||
(string)($host['dhcp_identifier'] ?? ''),
|
||||
(string)($host['ipv4_address'] ?? ''),
|
||||
$metadata
|
||||
);
|
||||
$notice = 'Zusatzdaten gespeichert.';
|
||||
}
|
||||
}
|
||||
$host = $repo->findDisplayByKey($source, $id) ?: $host;
|
||||
$hostKey = $source . ':' . (string)($host['host_id'] ?? $id);
|
||||
}
|
||||
|
||||
$usedIps = array_diff(
|
||||
array_merge($repo->usedIpAddresses(), $metadataRepo->desiredIps()),
|
||||
[(string)($host['ipv4_address'] ?? ''), (string)($host['metadata']['desired_ip'] ?? '')]
|
||||
);
|
||||
$availableIpsByGroup = $metadataRepo->availableIpsByGroup($usedIps);
|
||||
$checks = $metadataRepo->latestChecks([$hostKey])[$hostKey] ?? [];
|
||||
} catch (Throwable $e) {
|
||||
$error = $e->getMessage();
|
||||
}
|
||||
|
||||
$metadata = is_array($host['metadata'] ?? null) ? $host['metadata'] : [];
|
||||
$selectedGroup = (string)($metadata['group_name'] ?? '');
|
||||
$selectedIp = (string)($metadata['desired_ip'] ?? '');
|
||||
?>
|
||||
<?= module_shell_header('kea', [
|
||||
'title' => 'KEA Eintrag bearbeiten',
|
||||
]) ?>
|
||||
<div class="module-flow kea-page">
|
||||
<section class="module-box">
|
||||
<div class="module-box-head">
|
||||
<div>
|
||||
<h2 class="module-box-title">KEA Eintrag bearbeiten</h2>
|
||||
<p>Zusatzdaten werden separat von der KEA-Datenbank gespeichert.</p>
|
||||
</div>
|
||||
<div class="setup-actions">
|
||||
<a class="module-button module-button--secondary" href="/module/kea/groups">Gruppen verwalten</a>
|
||||
<a class="module-button module-button--secondary" href="/module/kea">Zurueck</a>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<?php if ($error): ?>
|
||||
<div class="kea-message kea-message--error" role="alert">
|
||||
<strong>Fehler</strong>
|
||||
<p><?= e($error) ?></p>
|
||||
</div>
|
||||
<?php elseif ($host): ?>
|
||||
<?php if ($notice): ?>
|
||||
<div class="kea-message kea-message--success">
|
||||
<?= e($notice) ?>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
|
||||
<section class="module-box kea-panel">
|
||||
<div class="module-box-head kea-panel__head">
|
||||
<div>
|
||||
<span class="pill"><?= ($source === 'lease') ? 'Lease' : 'Reservierung' ?></span>
|
||||
<h2 class="module-box-title"><?= e((string)($host['hostname'] ?: 'Unbekannt')) ?></h2>
|
||||
<p class="muted">
|
||||
IP <?= e((string)($host['ipv4_address'] ?? '')) ?> · MAC <?= e((string)($host['dhcp_identifier'] ?? '')) ?>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form method="post" class="kea-edit-form">
|
||||
<input type="hidden" name="action" value="save_metadata">
|
||||
<input type="hidden" name="source" value="<?= e($source) ?>">
|
||||
<input type="hidden" name="id" value="<?= e((string)$id) ?>">
|
||||
|
||||
<label class="setup-field">
|
||||
<span>Echter Name</span>
|
||||
<input type="text" name="real_name" value="<?= e((string)($metadata['real_name'] ?? '')) ?>">
|
||||
</label>
|
||||
<label class="setup-field">
|
||||
<span>Gerätename</span>
|
||||
<input type="text" name="device_name" value="<?= e((string)($metadata['device_name'] ?? '')) ?>">
|
||||
</label>
|
||||
<label class="setup-field">
|
||||
<span>Besitzer</span>
|
||||
<input type="text" name="owner" value="<?= e((string)($metadata['owner'] ?? '')) ?>">
|
||||
</label>
|
||||
<label class="setup-field">
|
||||
<span>Standort</span>
|
||||
<input type="text" name="location" value="<?= e((string)($metadata['location'] ?? '')) ?>">
|
||||
</label>
|
||||
<label class="setup-field">
|
||||
<span>Gerätetyp</span>
|
||||
<input type="text" name="device_type" value="<?= e((string)($metadata['device_type'] ?? '')) ?>">
|
||||
</label>
|
||||
<label class="setup-field">
|
||||
<span>Gruppe</span>
|
||||
<select name="group_name" data-kea-group-select>
|
||||
<option value="">Bitte waehlen</option>
|
||||
<?php foreach ($groups as $group): ?>
|
||||
<option value="<?= e($group) ?>" <?= $selectedGroup === $group ? 'selected' : '' ?>><?= e($group) ?></option>
|
||||
<?php endforeach; ?>
|
||||
</select>
|
||||
<?php if ($groups === []): ?>
|
||||
<small class="muted">Noch keine Gruppen definiert. Oeffne zuerst Gruppen verwalten.</small>
|
||||
<?php endif; ?>
|
||||
</label>
|
||||
<label class="setup-field">
|
||||
<span>Feste IP</span>
|
||||
<select name="desired_ip" data-kea-ip-select data-selected-ip="<?= e($selectedIp) ?>">
|
||||
<option value="">Erst Gruppe waehlen</option>
|
||||
</select>
|
||||
<small class="muted">Es werden nur freie IPs aus dem IP-Bereich der gewaehlten Gruppe angeboten.</small>
|
||||
</label>
|
||||
<label class="setup-field kea-edit-form__wide">
|
||||
<span>Notizen</span>
|
||||
<textarea name="notes" rows="4"><?= e((string)($metadata['notes'] ?? '')) ?></textarea>
|
||||
</label>
|
||||
|
||||
<div class="setup-actions kea-edit-form__wide">
|
||||
<button class="cta-button" type="submit">Speichern</button>
|
||||
<a class="nav-link" href="/module/kea">Abbrechen</a>
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<section class="module-box kea-panel">
|
||||
<div class="module-box-head kea-panel__head">
|
||||
<div>
|
||||
<span class="pill">Pruefungen</span>
|
||||
<h2 class="module-box-title">Gerätechecks</h2>
|
||||
<p class="muted">Pruefergebnisse werden in der Nexus-DHCP-Datenbank gespeichert und koennen spaeter fuer Reports genutzt werden.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="kea-check-grid">
|
||||
<div class="kea-check-card">
|
||||
<h4>DNS / Hostname</h4>
|
||||
<?php $dnsCheck = $checks['dns'] ?? null; ?>
|
||||
<?php if ($dnsCheck): ?>
|
||||
<?php $dnsResult = json_decode((string)($dnsCheck['result_json'] ?? '{}'), true) ?: []; ?>
|
||||
<p class="muted">Status: <?= e((string)($dnsCheck['status'] ?? '')) ?> · <?= e((string)($dnsCheck['checked_at'] ?? '')) ?></p>
|
||||
<p class="mono">Reverse: <?= e((string)($dnsResult['reverse'] ?? '-')) ?></p>
|
||||
<p class="mono">Forward: <?= e((string)($dnsResult['forward'] ?? '-')) ?></p>
|
||||
<?php else: ?>
|
||||
<p class="muted">Noch keine DNS-Pruefung gespeichert.</p>
|
||||
<?php endif; ?>
|
||||
<form method="post" class="setup-actions">
|
||||
<input type="hidden" name="action" value="dns_lookup">
|
||||
<input type="hidden" name="source" value="<?= e($source) ?>">
|
||||
<input type="hidden" name="id" value="<?= e((string)$id) ?>">
|
||||
<button class="nav-link" type="submit">DNS jetzt pruefen</button>
|
||||
</form>
|
||||
</div>
|
||||
<div class="kea-check-card">
|
||||
<h4>Login-Erkennung</h4>
|
||||
<p class="muted">Vorbereitet fuer spaetere HTTP/Port-Erkennung. Noch nicht automatisch aktiv, damit keine ungewollten Scans laufen.</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<script>
|
||||
(() => {
|
||||
const ipsByGroup = <?= json_encode($availableIpsByGroup, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE) ?>;
|
||||
const groupSelect = document.querySelector('[data-kea-group-select]');
|
||||
const ipSelect = document.querySelector('[data-kea-ip-select]');
|
||||
if (!groupSelect || !ipSelect) {
|
||||
return;
|
||||
}
|
||||
const selectedIp = ipSelect.dataset.selectedIp || '';
|
||||
const renderIps = () => {
|
||||
const ips = ipsByGroup[groupSelect.value] || [];
|
||||
ipSelect.innerHTML = '';
|
||||
const empty = document.createElement('option');
|
||||
empty.value = '';
|
||||
empty.textContent = groupSelect.value ? 'Keine feste IP' : 'Erst Gruppe waehlen';
|
||||
ipSelect.appendChild(empty);
|
||||
if (selectedIp && !ips.includes(selectedIp)) {
|
||||
ips.unshift(selectedIp);
|
||||
}
|
||||
for (const ip of ips) {
|
||||
const option = document.createElement('option');
|
||||
option.value = ip;
|
||||
option.textContent = ip;
|
||||
option.selected = ip === selectedIp;
|
||||
ipSelect.appendChild(option);
|
||||
}
|
||||
};
|
||||
groupSelect.addEventListener('change', renderIps);
|
||||
renderIps();
|
||||
})();
|
||||
</script>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
<?= module_shell_footer() ?>
|
||||
227
modules/kea/pages/groups.php
Normal file
227
modules/kea/pages/groups.php
Normal file
@@ -0,0 +1,227 @@
|
||||
<?php
|
||||
use App\Database;
|
||||
use App\Repository\KeaHostMetadataRepository;
|
||||
use App\Repository\KeaHostRepository;
|
||||
|
||||
$module = modules()->get('kea');
|
||||
$settings = modules()->settings('kea');
|
||||
$metadataFallback = is_array($module['metadata_db_defaults'] ?? null) ? $module['metadata_db_defaults'] : [];
|
||||
$metadataConfig = is_array($settings['metadata_db'] ?? null)
|
||||
? array_replace($metadataFallback, $settings['metadata_db'])
|
||||
: $metadataFallback;
|
||||
$fallback = $module['db_defaults'] ?? [];
|
||||
$error = null;
|
||||
$notice = null;
|
||||
$groups = [];
|
||||
$availableIpsByGroup = [];
|
||||
$usedIps = [];
|
||||
|
||||
try {
|
||||
if (empty($metadataConfig['driver']) || empty($metadataConfig['dbname'])) {
|
||||
throw new RuntimeException('Nexus DHCP Zusatzdatenbank ist nicht konfiguriert.');
|
||||
}
|
||||
|
||||
$metadataRepo = new KeaHostMetadataRepository(Database::createFromArray($metadataConfig));
|
||||
$metadataRepo->ensureSchema();
|
||||
|
||||
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||
$action = (string)($_POST['action'] ?? '');
|
||||
if ($action === 'save_group') {
|
||||
$metadataRepo->saveGroup(
|
||||
(string)($_POST['name'] ?? ''),
|
||||
(string)($_POST['description'] ?? ''),
|
||||
(string)($_POST['parent_name'] ?? '')
|
||||
);
|
||||
$notice = 'Gruppe gespeichert.';
|
||||
} elseif ($action === 'add_range') {
|
||||
$metadataRepo->addRange(
|
||||
(string)($_POST['group_name'] ?? ''),
|
||||
(string)($_POST['start_ip'] ?? ''),
|
||||
(string)($_POST['end_ip'] ?? '')
|
||||
);
|
||||
$notice = 'IP-Bereich gespeichert.';
|
||||
}
|
||||
}
|
||||
|
||||
$groups = $metadataRepo->listGroupsWithRanges();
|
||||
$keaRepo = new KeaHostRepository(modules()->modulePdo('kea', $fallback), $metadataRepo);
|
||||
$usedIps = array_merge($keaRepo->usedIpAddresses(), $metadataRepo->desiredIps());
|
||||
$availableIpsByGroup = $metadataRepo->availableIpsByGroup($usedIps, 4096);
|
||||
} catch (Throwable $e) {
|
||||
$error = $e->getMessage();
|
||||
}
|
||||
|
||||
$usedIpLookup = array_flip(array_filter(array_map('strval', $usedIps)));
|
||||
$matrixForGroup = static function (array $group) use ($usedIpLookup): array {
|
||||
$dots = [];
|
||||
foreach (($group['ranges'] ?? []) as $range) {
|
||||
$start = ip2long((string)($range['start_ip'] ?? ''));
|
||||
$end = ip2long((string)($range['end_ip'] ?? ''));
|
||||
if ($start === false || $end === false) {
|
||||
continue;
|
||||
}
|
||||
for ($ip = $start; $ip <= $end && count($dots) < 512; $ip++) {
|
||||
$address = long2ip($ip);
|
||||
if ($address === false) {
|
||||
continue;
|
||||
}
|
||||
$dots[] = [
|
||||
'ip' => $address,
|
||||
'used' => isset($usedIpLookup[$address]),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
return $dots;
|
||||
};
|
||||
?>
|
||||
<?= module_shell_header('kea', [
|
||||
'title' => 'KEA Gruppen',
|
||||
]) ?>
|
||||
<div class="module-flow kea-page">
|
||||
<section class="module-box">
|
||||
<div class="module-box-head">
|
||||
<div>
|
||||
<h2 class="module-box-title">KEA Gruppen</h2>
|
||||
<p>Gruppen und IP-Bereiche fuer DHCP-Reservierungen.</p>
|
||||
</div>
|
||||
<a class="module-button module-button--secondary" href="/module/kea">Zurueck</a>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<?php if ($error): ?>
|
||||
<div class="kea-message kea-message--error" role="alert">
|
||||
<strong>Fehler</strong>
|
||||
<p><?= e($error) ?></p>
|
||||
</div>
|
||||
<?php elseif ($notice): ?>
|
||||
<div class="kea-message kea-message--success"><?= e($notice) ?></div>
|
||||
<?php endif; ?>
|
||||
|
||||
<section class="module-box kea-panel">
|
||||
<div class="module-box-head kea-panel__head">
|
||||
<div>
|
||||
<span class="pill">Gruppe</span>
|
||||
<h2 class="module-box-title">Gruppe anlegen</h2>
|
||||
</div>
|
||||
</div>
|
||||
<form method="post" class="kea-edit-form">
|
||||
<input type="hidden" name="action" value="save_group">
|
||||
<label class="setup-field">
|
||||
<span>Name</span>
|
||||
<input type="text" name="name" required>
|
||||
</label>
|
||||
<label class="setup-field">
|
||||
<span>Beschreibung</span>
|
||||
<input type="text" name="description">
|
||||
</label>
|
||||
<label class="setup-field">
|
||||
<span>Uebergeordnete Gruppe</span>
|
||||
<select name="parent_name">
|
||||
<option value="">Keine</option>
|
||||
<?php foreach ($groups as $group): ?>
|
||||
<option value="<?= e((string)$group['name']) ?>"><?= e((string)$group['name']) ?></option>
|
||||
<?php endforeach; ?>
|
||||
</select>
|
||||
</label>
|
||||
<div class="setup-actions kea-edit-form__wide">
|
||||
<button class="cta-button" type="submit">Gruppe speichern</button>
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<section class="module-box kea-panel">
|
||||
<div class="module-box-head kea-panel__head">
|
||||
<div>
|
||||
<span class="pill">IP-Bereich</span>
|
||||
<h2 class="module-box-title">Bereich zuweisen</h2>
|
||||
</div>
|
||||
</div>
|
||||
<form method="post" class="kea-edit-form">
|
||||
<input type="hidden" name="action" value="add_range">
|
||||
<label class="setup-field">
|
||||
<span>Gruppe</span>
|
||||
<select name="group_name" required>
|
||||
<option value="">Bitte waehlen</option>
|
||||
<?php foreach ($groups as $group): ?>
|
||||
<option value="<?= e((string)$group['name']) ?>"><?= e((string)$group['name']) ?></option>
|
||||
<?php endforeach; ?>
|
||||
</select>
|
||||
</label>
|
||||
<label class="setup-field">
|
||||
<span>Start-IP</span>
|
||||
<input type="text" name="start_ip" placeholder="192.168.178.50" required>
|
||||
</label>
|
||||
<label class="setup-field">
|
||||
<span>End-IP</span>
|
||||
<input type="text" name="end_ip" placeholder="192.168.178.99" required>
|
||||
</label>
|
||||
<div class="setup-actions kea-edit-form__wide">
|
||||
<button class="cta-button" type="submit">Bereich speichern</button>
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<section class="module-box-table kea-panel">
|
||||
<div class="module-box-head kea-panel__head">
|
||||
<div>
|
||||
<span class="pill">Uebersicht</span>
|
||||
<h2 class="module-box-title">Gruppen und freie IPs</h2>
|
||||
</div>
|
||||
</div>
|
||||
<div class="kea-table-wrap">
|
||||
<table class="kea-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Gruppe</th>
|
||||
<th>Untergruppe von</th>
|
||||
<th>Beschreibung</th>
|
||||
<th>Bereiche</th>
|
||||
<th>Freie IPs</th>
|
||||
<th>Matrix</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<?php if ($groups === []): ?>
|
||||
<tr><td colspan="6" class="kea-empty">Noch keine Gruppen definiert.</td></tr>
|
||||
<?php else: ?>
|
||||
<?php foreach ($groups as $group): ?>
|
||||
<?php $available = $availableIpsByGroup[(string)$group['name']] ?? []; ?>
|
||||
<tr>
|
||||
<td><?= e((string)$group['name']) ?></td>
|
||||
<td><?= e((string)($group['parent_name'] ?: '-')) ?></td>
|
||||
<td><?= e((string)($group['description'] ?? '-')) ?></td>
|
||||
<td>
|
||||
<?php if (($group['ranges'] ?? []) === []): ?>
|
||||
<span class="muted">Kein Bereich</span>
|
||||
<?php else: ?>
|
||||
<?php foreach ($group['ranges'] as $range): ?>
|
||||
<div class="mono"><?= e((string)$range['start_ip']) ?> - <?= e((string)$range['end_ip']) ?></div>
|
||||
<?php endforeach; ?>
|
||||
<?php endif; ?>
|
||||
</td>
|
||||
<td><?= e((string)count($available)) ?></td>
|
||||
<td>
|
||||
<?php $matrix = $matrixForGroup($group); ?>
|
||||
<?php if ($matrix === []): ?>
|
||||
<span class="muted">Kein Bereich</span>
|
||||
<?php else: ?>
|
||||
<div class="ip-matrix" aria-label="IP Matrix fuer <?= e((string)$group['name']) ?>">
|
||||
<?php foreach ($matrix as $dot): ?>
|
||||
<span
|
||||
class="ip-dot <?= $dot['used'] ? 'is-used' : 'is-free' ?>"
|
||||
title="<?= e((string)$dot['ip']) ?> · <?= $dot['used'] ? 'belegt' : 'frei' ?>"
|
||||
></span>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
</td>
|
||||
</tr>
|
||||
<?php endforeach; ?>
|
||||
<?php endif; ?>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
<?= module_shell_footer() ?>
|
||||
@@ -1,18 +1,62 @@
|
||||
<?php
|
||||
use App\Database;
|
||||
use App\Repository\KeaHostRepository;
|
||||
use App\Repository\KeaHostMetadataRepository;
|
||||
|
||||
$module = modules()->get('kea');
|
||||
$fallback = $module['db_defaults'] ?? [];
|
||||
|
||||
$pdo = modules()->modulePdo('kea', $fallback);
|
||||
$settings = modules()->settings('kea');
|
||||
$metadataFallback = is_array($module['metadata_db_defaults'] ?? null) ? $module['metadata_db_defaults'] : [];
|
||||
$metadataConfig = is_array($settings['metadata_db'] ?? null)
|
||||
? array_replace($metadataFallback, $settings['metadata_db'])
|
||||
: $metadataFallback;
|
||||
$metadataRepo = null;
|
||||
$hosts = [];
|
||||
$error = null;
|
||||
$warnings = [];
|
||||
$stats = [
|
||||
'total' => 0,
|
||||
'reservations' => 0,
|
||||
'leases' => 0,
|
||||
'groups' => [],
|
||||
'free_ips' => [],
|
||||
];
|
||||
|
||||
try {
|
||||
$repo = new KeaHostRepository($pdo);
|
||||
$hosts = $repo->findAll(50);
|
||||
$pdo = modules()->modulePdo('kea', $fallback);
|
||||
if (!empty($metadataConfig['driver']) && !empty($metadataConfig['dbname'])) {
|
||||
try {
|
||||
$metadataRepo = new KeaHostMetadataRepository(Database::createFromArray($metadataConfig));
|
||||
$metadataRepo->ensureSchema();
|
||||
} catch (\Throwable $e) {
|
||||
$warnings[] = 'Nexus DHCP Zusatzdatenbank nicht verfuegbar: ' . $e->getMessage();
|
||||
$metadataRepo = null;
|
||||
}
|
||||
}
|
||||
|
||||
$repo = new KeaHostRepository($pdo, $metadataRepo);
|
||||
$hosts = $repo->findAll(200);
|
||||
$stats['reservations'] = $repo->countReservations();
|
||||
$stats['leases'] = $repo->countLeases();
|
||||
$stats['total'] = $stats['reservations'] + $stats['leases'];
|
||||
foreach ($hosts as $host) {
|
||||
$group = trim((string)($host['metadata']['group_name'] ?? ''));
|
||||
if ($group !== '') {
|
||||
$stats['groups'][$group] = ($stats['groups'][$group] ?? 0) + 1;
|
||||
}
|
||||
}
|
||||
if ($metadataRepo !== null) {
|
||||
$stats['free_ips'] = array_map(
|
||||
static fn(array $ips): int => count($ips),
|
||||
$metadataRepo->availableIpsByGroup(
|
||||
array_merge($repo->usedIpAddresses(), $metadataRepo->desiredIps()),
|
||||
4096
|
||||
)
|
||||
);
|
||||
}
|
||||
} catch (\Exception $e) {
|
||||
$error = "Datenbankfehler: " . $e->getMessage();
|
||||
}
|
||||
|
||||
module_tpl('kea', 'dashboard', compact('hosts', 'error'));
|
||||
module_tpl('kea', 'dashboard', compact('hosts', 'error', 'warnings', 'stats'));
|
||||
|
||||
@@ -2,67 +2,128 @@
|
||||
/**
|
||||
* @var array $hosts Die Liste der KEA-Hosts.
|
||||
* @var string|null $error Eine Fehlermeldung, falls vorhanden.
|
||||
* @var array $warnings Hinweise, falls Zusatzdaten nicht geladen werden konnten.
|
||||
* @var array $stats Kennzahlen fuer die Uebersicht.
|
||||
*/
|
||||
?>
|
||||
<div class="px-4 py-6 sm:px-0">
|
||||
<div class="flex justify-between items-center mb-6">
|
||||
<h1 class="text-2xl font-semibold text-white">KEA DHCP Hosts</h1>
|
||||
<button class="bg-indigo-600 hover:bg-indigo-700 text-white px-4 py-2 rounded shadow transition-colors">
|
||||
+ Neuer Host
|
||||
</button>
|
||||
</div>
|
||||
<?= module_shell_header('kea', [
|
||||
'title' => 'KEA DHCP Hosts',
|
||||
'description' => 'Reservierungen und aktuelle Leases aus der KEA-Datenbank.',
|
||||
]) ?>
|
||||
<div class="module-flow kea-page">
|
||||
<section class="module-box">
|
||||
<div class="module-box-head">
|
||||
<div>
|
||||
<h2 class="module-box-title">KEA DHCP Hosts</h2>
|
||||
<p>Reservierungen und aktuelle Leases aus der KEA-Datenbank.</p>
|
||||
<p class="muted kea-refresh-state" data-kea-refresh-state>
|
||||
Automatische Aktualisierung alle 5 Sekunden.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<?php if ($error): ?>
|
||||
<div class="bg-red-900 border-l-4 border-red-500 text-red-100 p-4 mb-6" role="alert">
|
||||
<p class="font-bold">Fehler</p>
|
||||
<div class="kea-message kea-message--error" role="alert">
|
||||
<strong>Fehler</strong>
|
||||
<p><?= e($error) ?></p>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
|
||||
<div class="bg-gray-800 shadow overflow-hidden sm:rounded-lg border border-gray-700">
|
||||
<div class="px-4 py-5 sm:px-6 border-b border-gray-700">
|
||||
<h3 class="text-lg leading-6 font-medium text-gray-200">
|
||||
Registrierte Geräte
|
||||
</h3>
|
||||
<p class="mt-1 max-w-2xl text-sm text-gray-400">
|
||||
Übersicht der statischen Reservierungen und bekannten Clients.
|
||||
</p>
|
||||
<?php foreach (($warnings ?? []) as $warning): ?>
|
||||
<div class="kea-message kea-message--warning" role="alert">
|
||||
<p><?= e((string)$warning) ?></p>
|
||||
</div>
|
||||
<div class="overflow-x-auto">
|
||||
<table class="min-w-full divide-y divide-gray-700">
|
||||
<thead class="bg-gray-900">
|
||||
<?php endforeach; ?>
|
||||
|
||||
<div class="module-box-grid module-box-grid--stats stats">
|
||||
<section class="module-box-soft stat-card">
|
||||
<span class="stat-label">Einträge</span>
|
||||
<span class="stat-value" data-kea-stat="total"><?= e((string)($stats['total'] ?? 0)) ?></span>
|
||||
</section>
|
||||
<section class="module-box-soft stat-card">
|
||||
<span class="stat-label">Reservierungen</span>
|
||||
<span class="stat-value" data-kea-stat="reservations"><?= e((string)($stats['reservations'] ?? 0)) ?></span>
|
||||
</section>
|
||||
<section class="module-box-soft stat-card">
|
||||
<span class="stat-label">Leases</span>
|
||||
<span class="stat-value" data-kea-stat="leases"><?= e((string)($stats['leases'] ?? 0)) ?></span>
|
||||
</section>
|
||||
<section class="module-box-soft stat-card">
|
||||
<span class="stat-label">Gruppen</span>
|
||||
<span class="stat-value" data-kea-stat="groups"><?= e((string)count($stats['groups'] ?? [])) ?></span>
|
||||
</section>
|
||||
<section class="module-box-soft stat-card">
|
||||
<span class="stat-label">Freie Gruppen-IPs</span>
|
||||
<span class="stat-value" data-kea-stat="free_ips"><?= e((string)array_sum($stats['free_ips'] ?? [])) ?></span>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<section class="module-box-table kea-panel">
|
||||
<div class="module-box-head kea-panel__head">
|
||||
<div>
|
||||
<span class="pill">Inventar</span>
|
||||
<h2 class="module-box-title">Registrierte Geräte</h2>
|
||||
<p class="muted">Zusatzdaten werden in der separaten Nexus-DHCP-Datenbank gespeichert.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="kea-table-wrap">
|
||||
<table class="kea-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-400 uppercase tracking-wider">Hostname</th>
|
||||
<th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-400 uppercase tracking-wider">IP Adresse</th>
|
||||
<th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-400 uppercase tracking-wider">MAC Adresse</th>
|
||||
<th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-400 uppercase tracking-wider">Kontext</th>
|
||||
<th scope="col" class="relative px-6 py-3">
|
||||
<span class="sr-only">Edit</span>
|
||||
</th>
|
||||
<th>Quelle</th>
|
||||
<th>Hostname</th>
|
||||
<th>IP Adresse</th>
|
||||
<th>MAC Adresse</th>
|
||||
<th>Zuletzt gesehen</th>
|
||||
<th>Lease bis</th>
|
||||
<th>Echter Name</th>
|
||||
<th>Standort</th>
|
||||
<th>Gruppe</th>
|
||||
<th>Aktion</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="bg-gray-800 divide-y divide-gray-700">
|
||||
<tbody data-kea-host-rows>
|
||||
<?php if (empty($hosts)): ?>
|
||||
<tr>
|
||||
<td colspan="5" class="px-6 py-4 text-center text-sm text-gray-500">Keine Hosts gefunden.</td>
|
||||
<td colspan="10" class="kea-empty">
|
||||
Keine Reservierungen oder aktiven Leases gefunden.
|
||||
</td>
|
||||
</tr>
|
||||
<?php else: ?>
|
||||
<?php foreach ($hosts as $host): ?>
|
||||
<tr class="hover:bg-gray-750 transition-colors">
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm font-medium text-white">
|
||||
<?= e($host['hostname'] ?: 'Unbekannt') ?>
|
||||
<tr>
|
||||
<td>
|
||||
<span class="pill"><?= ($host['source'] ?? '') === 'lease' ? 'Lease' : 'Reservierung' ?></span>
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-300 font-mono">
|
||||
<td>
|
||||
<?= e((string)($host['metadata']['device_name'] ?? $host['metadata']['real_name'] ?? $host['display_name'] ?? $host['hostname'] ?? 'Unbekannt')) ?>
|
||||
</td>
|
||||
<td class="mono">
|
||||
<?= e($host['ipv4_address']) ?>
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-400 font-mono">
|
||||
<td class="mono">
|
||||
<?= e($host['dhcp_identifier']) ?>
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-400">
|
||||
<?= e($host['user_context'] ?? '-') ?>
|
||||
<td>
|
||||
<?= e((string)($host['last_seen_at'] ?? '-')) ?>
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
|
||||
<a href="#" class="text-indigo-400 hover:text-indigo-300">Bearbeiten</a>
|
||||
<td>
|
||||
<?= e((string)($host['lease_expires_at'] ?? '-')) ?>
|
||||
</td>
|
||||
<td>
|
||||
<?= e((string)($host['metadata']['real_name'] ?? '-')) ?>
|
||||
</td>
|
||||
<td>
|
||||
<?= e((string)($host['metadata']['location'] ?? '-')) ?>
|
||||
</td>
|
||||
<td>
|
||||
<?= e((string)($host['metadata']['group_name'] ?? '-')) ?>
|
||||
</td>
|
||||
<td>
|
||||
<a class="nav-link" href="/module/kea/edit?source=<?= e((string)($host['source'] ?? 'reservation')) ?>&id=<?= e((string)($host['host_id'] ?? '0')) ?>">
|
||||
Bearbeiten
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
<?php endforeach; ?>
|
||||
@@ -70,5 +131,101 @@
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
<script>
|
||||
(() => {
|
||||
const rowsTarget = document.querySelector('[data-kea-host-rows]');
|
||||
const stateTarget = document.querySelector('[data-kea-refresh-state]');
|
||||
if (!rowsTarget) {
|
||||
return;
|
||||
}
|
||||
|
||||
const setText = (selector, value) => {
|
||||
const target = document.querySelector(selector);
|
||||
if (target) {
|
||||
target.textContent = String(value ?? '0');
|
||||
}
|
||||
};
|
||||
|
||||
const cell = (value, className = '') => {
|
||||
const td = document.createElement('td');
|
||||
if (className) {
|
||||
td.className = className;
|
||||
}
|
||||
td.textContent = value || '-';
|
||||
return td;
|
||||
};
|
||||
|
||||
const renderRows = (rows) => {
|
||||
rowsTarget.textContent = '';
|
||||
if (!Array.isArray(rows) || rows.length === 0) {
|
||||
const tr = document.createElement('tr');
|
||||
const td = cell('Keine Reservierungen oder aktiven Leases gefunden.', 'kea-empty');
|
||||
td.colSpan = 10;
|
||||
tr.appendChild(td);
|
||||
rowsTarget.appendChild(tr);
|
||||
return;
|
||||
}
|
||||
|
||||
for (const row of rows) {
|
||||
const tr = document.createElement('tr');
|
||||
const source = document.createElement('td');
|
||||
const pill = document.createElement('span');
|
||||
pill.className = 'pill';
|
||||
pill.textContent = row.source === 'lease' ? 'Lease' : 'Reservierung';
|
||||
source.appendChild(pill);
|
||||
tr.appendChild(source);
|
||||
|
||||
tr.appendChild(cell(row.display_name));
|
||||
tr.appendChild(cell(row.ipv4_address, 'mono'));
|
||||
tr.appendChild(cell(row.dhcp_identifier, 'mono'));
|
||||
tr.appendChild(cell(row.last_seen_at));
|
||||
tr.appendChild(cell(row.lease_expires_at));
|
||||
tr.appendChild(cell(row.real_name));
|
||||
tr.appendChild(cell(row.location));
|
||||
tr.appendChild(cell(row.group_name));
|
||||
|
||||
const action = document.createElement('td');
|
||||
const link = document.createElement('a');
|
||||
link.className = 'nav-link';
|
||||
link.href = `/module/kea/edit?source=${encodeURIComponent(row.source || 'reservation')}&id=${encodeURIComponent(row.host_id || '0')}`;
|
||||
link.textContent = 'Bearbeiten';
|
||||
action.appendChild(link);
|
||||
tr.appendChild(action);
|
||||
|
||||
rowsTarget.appendChild(tr);
|
||||
}
|
||||
};
|
||||
|
||||
const refresh = async () => {
|
||||
try {
|
||||
const response = await fetch('/module/kea/data', {
|
||||
headers: {Accept: 'application/json'},
|
||||
cache: 'no-store',
|
||||
});
|
||||
const payload = await response.json();
|
||||
if (!response.ok || !payload.ok) {
|
||||
throw new Error(payload.error || 'Aktualisierung fehlgeschlagen.');
|
||||
}
|
||||
|
||||
setText('[data-kea-stat="total"]', payload.stats?.total);
|
||||
setText('[data-kea-stat="reservations"]', payload.stats?.reservations);
|
||||
setText('[data-kea-stat="leases"]', payload.stats?.leases);
|
||||
setText('[data-kea-stat="groups"]', payload.stats?.groups);
|
||||
setText('[data-kea-stat="free_ips"]', payload.stats?.free_ips);
|
||||
renderRows(payload.rows);
|
||||
if (stateTarget) {
|
||||
stateTarget.textContent = `Automatische Aktualisierung aktiv. Zuletzt aktualisiert: ${payload.updated_at || '-'}`;
|
||||
}
|
||||
} catch (error) {
|
||||
if (stateTarget) {
|
||||
stateTarget.textContent = `Automatische Aktualisierung fehlgeschlagen: ${error.message}`;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
window.setInterval(refresh, 5000);
|
||||
})();
|
||||
</script>
|
||||
<?= module_shell_footer() ?>
|
||||
|
||||
6
modules/mining-checker/api/index.php
Normal file
6
modules/mining-checker/api/index.php
Normal file
@@ -0,0 +1,6 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
require_once dirname(__DIR__) . '/bootstrap.php';
|
||||
|
||||
(new Modules\MiningChecker\Api\Router(dirname(__DIR__)))->handle($_GET['path'] ?? '');
|
||||
0
modules/mining-checker/assets/css/.gitkeep
Normal file
0
modules/mining-checker/assets/css/.gitkeep
Normal file
772
modules/mining-checker/assets/css/app.css
Normal file
772
modules/mining-checker/assets/css/app.css
Normal file
@@ -0,0 +1,772 @@
|
||||
#mining-checker-app {
|
||||
--mc-surface: var(--surface);
|
||||
--mc-surface-strong: linear-gradient(180deg, rgba(255,255,255,0.96), rgba(248,252,252,0.92));
|
||||
--mc-line: var(--line);
|
||||
--mc-line-strong: color-mix(in srgb, var(--brand-accent) 28%, transparent);
|
||||
--mc-text: var(--text);
|
||||
--mc-text-muted: var(--muted);
|
||||
--mc-accent: var(--brand-accent);
|
||||
--mc-accent-strong: var(--brand-accent-3);
|
||||
--mc-danger: #d92d20;
|
||||
--mc-success: #14804a;
|
||||
--mc-warning: #b54708;
|
||||
min-height: 0;
|
||||
background: none;
|
||||
color: var(--mc-text);
|
||||
font-family: inherit;
|
||||
max-width: 100%;
|
||||
overflow-x: clip;
|
||||
}
|
||||
|
||||
#mining-checker-app,
|
||||
#mining-checker-app * {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
#mining-checker-app .mc-grid-bg {
|
||||
background: none;
|
||||
max-width: 100%;
|
||||
overflow-x: clip;
|
||||
}
|
||||
|
||||
#mining-checker-app .mc-shell {
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
#mining-checker-app .mc-stack {
|
||||
display: grid;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
#mining-checker-app .mc-panel,
|
||||
#mining-checker-app .mc-stat-card,
|
||||
#mining-checker-app .mc-dashboard-card,
|
||||
#mining-checker-app .mc-target-card,
|
||||
#mining-checker-app .mc-alert,
|
||||
#mining-checker-app .mc-empty,
|
||||
#mining-checker-app .mc-table-shell,
|
||||
#mining-checker-app .mc-display-field {
|
||||
border: 1px solid var(--mc-line);
|
||||
border-radius: 22px;
|
||||
background: var(--mc-surface);
|
||||
/* box-shadow: 0 12px 30px rgba(1, 22, 32, 0.08); */
|
||||
backdrop-filter: blur(8px);
|
||||
}
|
||||
|
||||
#mining-checker-app .mc-panel,
|
||||
#mining-checker-app .mc-dashboard-card,
|
||||
#mining-checker-app .mc-alert,
|
||||
#mining-checker-app .mc-empty,
|
||||
#mining-checker-app .mc-table-shell {
|
||||
padding: 18px 20px;
|
||||
}
|
||||
|
||||
#mining-checker-app .mc-hero-top,
|
||||
#mining-checker-app .mc-inline-row,
|
||||
#mining-checker-app .mc-flex-split,
|
||||
#mining-checker-app .mc-section-head {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
#mining-checker-app .mc-hero-top,
|
||||
#mining-checker-app .mc-flex-split,
|
||||
#mining-checker-app .mc-section-head {
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
#mining-checker-app .mc-hero-copy,
|
||||
#mining-checker-app .mc-hero-controls,
|
||||
#mining-checker-app .mc-panel-body,
|
||||
#mining-checker-app .mc-form,
|
||||
#mining-checker-app .mc-field,
|
||||
#mining-checker-app .mc-filter-grid,
|
||||
#mining-checker-app .mc-chart,
|
||||
#mining-checker-app .mc-target-grid {
|
||||
display: grid;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
#mining-checker-app .mc-filter-grid {
|
||||
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
|
||||
}
|
||||
|
||||
#mining-checker-app .mc-text,
|
||||
#mining-checker-app p,
|
||||
#mining-checker-app td,
|
||||
#mining-checker-app th,
|
||||
#mining-checker-app label,
|
||||
#mining-checker-app summary,
|
||||
#mining-checker-app pre {
|
||||
color: var(--mc-text-muted);
|
||||
}
|
||||
|
||||
#mining-checker-app h1,
|
||||
#mining-checker-app h2,
|
||||
#mining-checker-app h3 {
|
||||
margin: 0;
|
||||
color: var(--mc-text);
|
||||
}
|
||||
|
||||
#mining-checker-app .mc-stats-grid,
|
||||
#mining-checker-app .mc-target-grid,
|
||||
#mining-checker-app .mc-overview-grid,
|
||||
#mining-checker-app .mc-two-col,
|
||||
#mining-checker-app .mc-main-grid {
|
||||
display: grid;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
#mining-checker-app .mc-stats-grid {
|
||||
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
|
||||
}
|
||||
|
||||
#mining-checker-app .mc-overview-grid {
|
||||
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
|
||||
}
|
||||
|
||||
#mining-checker-app .mc-target-grid {
|
||||
grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
|
||||
}
|
||||
|
||||
#mining-checker-app .mc-two-col {
|
||||
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
|
||||
}
|
||||
|
||||
#mining-checker-app .mc-asset-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(210px, 1fr));
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
#mining-checker-app .mc-asset-card {
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
#mining-checker-app .mc-asset-balance {
|
||||
font-size: 1.15rem;
|
||||
font-weight: 700;
|
||||
color: var(--mc-text);
|
||||
}
|
||||
|
||||
#mining-checker-app .mc-asset-list {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
min-width: 240px;
|
||||
}
|
||||
|
||||
#mining-checker-app .mc-asset-row {
|
||||
display: grid;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
#mining-checker-app .mc-asset-row strong {
|
||||
color: var(--mc-text);
|
||||
}
|
||||
|
||||
#mining-checker-app .mc-main-grid {
|
||||
grid-template-columns: minmax(300px, 380px) minmax(0, 1fr);
|
||||
}
|
||||
|
||||
#mining-checker-app .mc-stat-card,
|
||||
#mining-checker-app .mc-target-card {
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
#mining-checker-app .mc-stat-card {
|
||||
background: var(--mc-surface-strong);
|
||||
}
|
||||
|
||||
#mining-checker-app .mc-kicker {
|
||||
font-size: 0.76rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.18em;
|
||||
color: var(--mc-accent-strong);
|
||||
}
|
||||
|
||||
#mining-checker-app .mc-stat-value {
|
||||
font-size: 2rem;
|
||||
font-weight: 700;
|
||||
line-height: 1.1;
|
||||
color: var(--mc-text);
|
||||
}
|
||||
|
||||
#mining-checker-app .mc-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 8px 12px;
|
||||
border-radius: 999px;
|
||||
font-size: 0.75rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.18em;
|
||||
border: 1px solid var(--mc-line-strong);
|
||||
background: color-mix(in srgb, var(--mc-accent) 16%, transparent);
|
||||
color: var(--mc-accent-strong);
|
||||
}
|
||||
|
||||
#mining-checker-app .mc-badge--warn {
|
||||
background: color-mix(in srgb, var(--mc-warning) 12%, transparent);
|
||||
color: var(--mc-warning);
|
||||
}
|
||||
|
||||
#mining-checker-app .mc-badge--info {
|
||||
background: color-mix(in srgb, var(--mc-accent) 16%, transparent);
|
||||
color: var(--mc-accent-strong);
|
||||
}
|
||||
|
||||
#mining-checker-app .mc-badge--danger {
|
||||
background: color-mix(in srgb, var(--mc-danger) 12%, transparent);
|
||||
color: var(--mc-danger);
|
||||
}
|
||||
|
||||
#mining-checker-app .mc-badge--success {
|
||||
background: color-mix(in srgb, var(--mc-success) 12%, transparent);
|
||||
color: var(--mc-success);
|
||||
}
|
||||
|
||||
#mining-checker-app .mc-button,
|
||||
#mining-checker-app button,
|
||||
#mining-checker-app input,
|
||||
#mining-checker-app select,
|
||||
#mining-checker-app textarea {
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
#mining-checker-app .mc-button {
|
||||
border: 1px solid transparent;
|
||||
border-radius: 16px;
|
||||
padding: 12px 16px;
|
||||
cursor: pointer;
|
||||
text-decoration: none;
|
||||
transition: 160ms ease;
|
||||
}
|
||||
|
||||
#mining-checker-app .mc-button:hover {
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
#mining-checker-app .mc-button:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: default;
|
||||
transform: none;
|
||||
}
|
||||
|
||||
#mining-checker-app .mc-button--primary {
|
||||
background: linear-gradient(135deg, var(--mc-accent), var(--mc-accent-strong));
|
||||
color: #05121f;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
#mining-checker-app .mc-button--secondary {
|
||||
background: rgba(255, 255, 255, 0.92);
|
||||
color: var(--mc-text);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
#mining-checker-app .mc-button--danger {
|
||||
background: linear-gradient(135deg, rgba(251, 113, 133, 0.92), rgba(239, 68, 68, 0.92));
|
||||
color: #fff7f7;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
#mining-checker-app .mc-button--ghost {
|
||||
background: color-mix(in srgb, var(--mc-accent) 14%, transparent);
|
||||
border-color: color-mix(in srgb, var(--mc-accent) 34%, transparent);
|
||||
color: var(--mc-accent-strong);
|
||||
}
|
||||
|
||||
#mining-checker-app .mc-debug-tools {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 12px;
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
#mining-checker-app .mc-debug-console {
|
||||
border-color: rgba(125, 211, 252, 0.28);
|
||||
}
|
||||
|
||||
#mining-checker-app .mc-debug-view-switch {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 12px;
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
|
||||
#mining-checker-app .mc-debug-log {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
max-height: 520px;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
#mining-checker-app .mc-debug-text-console {
|
||||
max-height: 520px;
|
||||
overflow: auto;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
#mining-checker-app .mc-debug-entry {
|
||||
border: 1px solid var(--mc-line);
|
||||
border-radius: 18px;
|
||||
padding: 14px 16px;
|
||||
background: color-mix(in srgb, var(--brand-accent-2) 8%, var(--mc-surface));
|
||||
}
|
||||
|
||||
#mining-checker-app .mc-field {
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
#mining-checker-app .mc-field-label {
|
||||
font-size: 0.75rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.18em;
|
||||
color: var(--mc-text-muted);
|
||||
}
|
||||
|
||||
#mining-checker-app .mc-input,
|
||||
#mining-checker-app .mc-select,
|
||||
#mining-checker-app .mc-textarea,
|
||||
#mining-checker-app .mc-file {
|
||||
width: 100%;
|
||||
border: 1px solid var(--mc-line);
|
||||
border-radius: 16px;
|
||||
padding: 13px 15px;
|
||||
color: var(--mc-text);
|
||||
background: rgba(255, 255, 255, 0.72);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
#mining-checker-app .mc-select option {
|
||||
color: #09111f;
|
||||
background: #f8fafc;
|
||||
}
|
||||
|
||||
#mining-checker-app .mc-textarea {
|
||||
min-height: 120px;
|
||||
resize: vertical;
|
||||
}
|
||||
|
||||
#mining-checker-app .mc-input::placeholder,
|
||||
#mining-checker-app .mc-textarea::placeholder {
|
||||
color: #6d7c90;
|
||||
}
|
||||
|
||||
#mining-checker-app .mc-input:focus,
|
||||
#mining-checker-app .mc-select:focus,
|
||||
#mining-checker-app .mc-textarea:focus,
|
||||
#mining-checker-app .mc-file:focus {
|
||||
border-color: rgba(125, 211, 252, 0.5);
|
||||
box-shadow: 0 0 0 3px rgba(125, 211, 252, 0.12);
|
||||
}
|
||||
|
||||
#mining-checker-app .mc-checkbox {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
border: 1px solid var(--mc-line);
|
||||
border-radius: 16px;
|
||||
padding: 14px 16px;
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
|
||||
#mining-checker-app .mc-inline-fields {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 16px;
|
||||
align-items: end;
|
||||
}
|
||||
|
||||
#mining-checker-app .mc-inline-fields > .mc-field {
|
||||
flex: 1 1 280px;
|
||||
}
|
||||
|
||||
#mining-checker-app .mc-alert--error {
|
||||
border-color: rgba(251, 113, 133, 0.28);
|
||||
background: rgba(127, 29, 29, 0.35);
|
||||
color: #ffe4e6;
|
||||
}
|
||||
|
||||
#mining-checker-app .mc-alert--warning {
|
||||
border-color: rgba(245, 158, 11, 0.28);
|
||||
background: rgba(120, 53, 15, 0.34);
|
||||
color: #fef3c7;
|
||||
}
|
||||
|
||||
#mining-checker-app .mc-alert--success {
|
||||
border-color: rgba(52, 211, 153, 0.28);
|
||||
background: rgba(6, 78, 59, 0.34);
|
||||
color: #d1fae5;
|
||||
}
|
||||
|
||||
#mining-checker-app .mc-table-shell {
|
||||
overflow: auto;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
#mining-checker-app .mc-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
|
||||
#mining-checker-app .mc-table th,
|
||||
#mining-checker-app .mc-table td {
|
||||
padding: 14px 16px;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.06);
|
||||
text-align: left;
|
||||
vertical-align: top;
|
||||
}
|
||||
|
||||
#mining-checker-app .mc-table thead {
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
}
|
||||
|
||||
#mining-checker-app .mc-empty {
|
||||
border-style: dashed;
|
||||
}
|
||||
|
||||
#mining-checker-app details {
|
||||
border: 1px solid var(--mc-line);
|
||||
border-radius: 18px;
|
||||
padding: 16px;
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
}
|
||||
|
||||
#mining-checker-app pre {
|
||||
white-space: pre-wrap;
|
||||
margin: 12px 0 0;
|
||||
line-height: 1.65;
|
||||
}
|
||||
|
||||
#mining-checker-app .mc-mini-grid {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
|
||||
}
|
||||
|
||||
#mining-checker-app .mc-mini-card,
|
||||
#mining-checker-app .mc-display-field {
|
||||
padding: 12px 14px;
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
}
|
||||
|
||||
#mining-checker-app .mc-code-block {
|
||||
margin: 0;
|
||||
padding: 12px 14px;
|
||||
border: 1px solid var(--mc-line);
|
||||
border-radius: 16px;
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
font-family: "JetBrains Mono", "SFMono-Regular", monospace;
|
||||
font-size: 0.92rem;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
#mining-checker-app .mc-chart svg {
|
||||
width: 100%;
|
||||
height: 220px;
|
||||
}
|
||||
|
||||
#mining-checker-app .mc-modal-backdrop {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 80;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 20px;
|
||||
background: rgba(2, 6, 23, 0.72);
|
||||
}
|
||||
|
||||
#mining-checker-app .mc-modal {
|
||||
width: min(720px, 100%);
|
||||
max-height: min(80vh, 900px);
|
||||
overflow: auto;
|
||||
padding: 24px;
|
||||
border: 1px solid var(--mc-line);
|
||||
border-radius: 28px;
|
||||
background: rgba(9, 17, 31, 0.96);
|
||||
box-shadow: 0 28px 60px rgba(0, 0, 0, 0.34);
|
||||
backdrop-filter: blur(14px);
|
||||
}
|
||||
|
||||
#mining-checker-app .mc-chart path,
|
||||
#mining-checker-app .mc-chart polyline,
|
||||
#mining-checker-app .mc-chart line,
|
||||
#mining-checker-app .mc-chart rect {
|
||||
vector-effect: non-scaling-stroke;
|
||||
}
|
||||
|
||||
@media (max-width: 960px) {
|
||||
#mining-checker-app .mc-hero-top,
|
||||
#mining-checker-app .mc-inline-row,
|
||||
#mining-checker-app .mc-flex-split,
|
||||
#mining-checker-app .mc-section-head {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
#mining-checker-app .mc-main-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
#mining-checker-app .mc-shell {
|
||||
width: min(100% - 10px, 1360px);
|
||||
padding: 8px 0 20px;
|
||||
}
|
||||
|
||||
#mining-checker-app .mc-stack {
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
#mining-checker-app .mc-hero,
|
||||
#mining-checker-app .mc-panel,
|
||||
#mining-checker-app .mc-dashboard-card,
|
||||
#mining-checker-app .mc-alert,
|
||||
#mining-checker-app .mc-empty,
|
||||
#mining-checker-app .mc-table-shell {
|
||||
padding: 14px;
|
||||
border-radius: 20px;
|
||||
}
|
||||
|
||||
#mining-checker-app .mc-title {
|
||||
font-size: clamp(1.45rem, 8vw, 2.15rem);
|
||||
}
|
||||
|
||||
#mining-checker-app .mc-hero-copy {
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
#mining-checker-app .mc-hero-copy p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
#mining-checker-app .mc-hero-controls {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
#mining-checker-app .mc-home-link {
|
||||
justify-self: stretch;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
#mining-checker-app .mc-tabs {
|
||||
flex-wrap: nowrap;
|
||||
gap: 8px;
|
||||
margin: 12px -4px 0;
|
||||
padding: 0 4px 4px;
|
||||
overflow-x: auto;
|
||||
scrollbar-width: thin;
|
||||
}
|
||||
|
||||
#mining-checker-app .mc-button {
|
||||
padding: 10px 12px;
|
||||
border-radius: 14px;
|
||||
}
|
||||
|
||||
#mining-checker-app .mc-button--tab {
|
||||
flex: 0 0 auto;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
#mining-checker-app .mc-table th,
|
||||
#mining-checker-app .mc-table td {
|
||||
padding: 10px 12px;
|
||||
}
|
||||
|
||||
#mining-checker-app .mc-modal {
|
||||
max-height: min(92vh, 900px);
|
||||
padding: 16px;
|
||||
border-radius: 20px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 600px) {
|
||||
#mining-checker-app,
|
||||
#mining-checker-app * {
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
#mining-checker-app {
|
||||
min-height: 100vh;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
#mining-checker-app .mc-grid-bg {
|
||||
overflow-x: hidden;
|
||||
background-size: 18px 18px;
|
||||
}
|
||||
|
||||
#mining-checker-app .mc-shell {
|
||||
width: 100%;
|
||||
padding: 0 0 16px;
|
||||
}
|
||||
|
||||
#mining-checker-app .mc-stack {
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
#mining-checker-app .mc-hero,
|
||||
#mining-checker-app .mc-panel,
|
||||
#mining-checker-app .mc-dashboard-card,
|
||||
#mining-checker-app .mc-alert,
|
||||
#mining-checker-app .mc-empty {
|
||||
width: 100%;
|
||||
border-radius: 18px;
|
||||
padding: 14px;
|
||||
box-shadow: 0 14px 32px rgba(0, 0, 0, 0.16);
|
||||
}
|
||||
|
||||
#mining-checker-app .mc-hero {
|
||||
border-radius: 0 0 18px 18px;
|
||||
}
|
||||
|
||||
#mining-checker-app .mc-panel,
|
||||
#mining-checker-app .mc-dashboard-card,
|
||||
#mining-checker-app .mc-alert,
|
||||
#mining-checker-app .mc-empty,
|
||||
#mining-checker-app .mc-table-shell {
|
||||
border-left: 0;
|
||||
border-right: 0;
|
||||
}
|
||||
|
||||
#mining-checker-app .mc-title,
|
||||
#mining-checker-app .mc-hero-copy p {
|
||||
display: none;
|
||||
}
|
||||
|
||||
#mining-checker-app .mc-hero-top {
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
#mining-checker-app .mc-kicker {
|
||||
font-size: 0.68rem;
|
||||
letter-spacing: 0.14em;
|
||||
}
|
||||
|
||||
#mining-checker-app .mc-tabs {
|
||||
margin: 10px -6px 0;
|
||||
padding: 0 6px 6px;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
#mining-checker-app .mc-button {
|
||||
min-height: 38px;
|
||||
padding: 8px 10px;
|
||||
border-radius: 12px;
|
||||
font-size: 0.92rem;
|
||||
}
|
||||
|
||||
#mining-checker-app .mc-button:hover {
|
||||
transform: none;
|
||||
}
|
||||
|
||||
#mining-checker-app .mc-stats-grid,
|
||||
#mining-checker-app .mc-target-grid,
|
||||
#mining-checker-app .mc-overview-grid,
|
||||
#mining-checker-app .mc-two-col,
|
||||
#mining-checker-app .mc-main-grid,
|
||||
#mining-checker-app .mc-filter-grid,
|
||||
#mining-checker-app .mc-mini-grid {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
#mining-checker-app .mc-stat-card,
|
||||
#mining-checker-app .mc-target-card,
|
||||
#mining-checker-app .mc-mini-card,
|
||||
#mining-checker-app .mc-display-field {
|
||||
padding: 12px;
|
||||
border-radius: 16px;
|
||||
}
|
||||
|
||||
#mining-checker-app .mc-stat-value {
|
||||
font-size: clamp(1.45rem, 8vw, 1.85rem);
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
#mining-checker-app h2 {
|
||||
font-size: 1.25rem;
|
||||
}
|
||||
|
||||
#mining-checker-app h3 {
|
||||
font-size: 1.05rem;
|
||||
}
|
||||
|
||||
#mining-checker-app .mc-text,
|
||||
#mining-checker-app p,
|
||||
#mining-checker-app td,
|
||||
#mining-checker-app th,
|
||||
#mining-checker-app label,
|
||||
#mining-checker-app summary,
|
||||
#mining-checker-app pre {
|
||||
font-size: 0.92rem;
|
||||
}
|
||||
|
||||
#mining-checker-app .mc-input,
|
||||
#mining-checker-app .mc-select,
|
||||
#mining-checker-app .mc-textarea,
|
||||
#mining-checker-app .mc-file {
|
||||
min-width: 0;
|
||||
padding: 10px 12px;
|
||||
border-radius: 12px;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
#mining-checker-app .mc-inline-fields,
|
||||
#mining-checker-app .mc-debug-tools,
|
||||
#mining-checker-app .mc-debug-view-switch {
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
#mining-checker-app .mc-inline-fields > .mc-field {
|
||||
flex-basis: 100%;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
#mining-checker-app .mc-badge {
|
||||
padding: 7px 10px;
|
||||
font-size: 0.78rem;
|
||||
}
|
||||
|
||||
#mining-checker-app .mc-table-shell {
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
border-radius: 16px;
|
||||
overflow-x: auto;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
}
|
||||
|
||||
#mining-checker-app .mc-table {
|
||||
max-width: none;
|
||||
min-width: 640px;
|
||||
}
|
||||
|
||||
#mining-checker-app .mc-table th,
|
||||
#mining-checker-app .mc-table td {
|
||||
padding: 9px 10px;
|
||||
font-size: 0.86rem;
|
||||
}
|
||||
|
||||
#mining-checker-app .mc-chart svg {
|
||||
height: 180px;
|
||||
}
|
||||
|
||||
#mining-checker-app .mc-modal-backdrop {
|
||||
align-items: stretch;
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
#mining-checker-app .mc-modal {
|
||||
width: 100%;
|
||||
max-height: calc(100vh - 16px);
|
||||
padding: 14px;
|
||||
border-radius: 18px;
|
||||
}
|
||||
}
|
||||
0
modules/mining-checker/assets/js/.gitkeep
Normal file
0
modules/mining-checker/assets/js/.gitkeep
Normal file
3034
modules/mining-checker/assets/js/app.js
Normal file
3034
modules/mining-checker/assets/js/app.js
Normal file
File diff suppressed because it is too large
Load Diff
235
modules/mining-checker/bootstrap.php
Normal file
235
modules/mining-checker/bootstrap.php
Normal file
@@ -0,0 +1,235 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
use Modules\MiningChecker\Infrastructure\ConnectionFactory;
|
||||
use Modules\MiningChecker\Infrastructure\MiningRepository;
|
||||
use Modules\MiningChecker\Infrastructure\ModuleConfig;
|
||||
use Modules\MiningChecker\Infrastructure\SchemaManager;
|
||||
|
||||
spl_autoload_register(static function (string $class): void {
|
||||
$prefix = 'Modules\\MiningChecker\\';
|
||||
if (!str_starts_with($class, $prefix)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$relativeClass = substr($class, strlen($prefix));
|
||||
$file = __DIR__ . '/src/' . str_replace('\\', '/', $relativeClass) . '.php';
|
||||
|
||||
if (is_file($file)) {
|
||||
require_once $file;
|
||||
}
|
||||
});
|
||||
|
||||
$mm = isset($modules) && $modules instanceof App\ModuleManager ? $modules : modules();
|
||||
$moduleName = 'mining-checker';
|
||||
|
||||
$mm->registerFunction($moduleName, 'runtime_settings', static function (): array {
|
||||
$moduleBasePath = __DIR__;
|
||||
$config = ModuleConfig::load($moduleBasePath);
|
||||
$projectKey = $config->defaultProjectKey();
|
||||
$user = auth_user() ?? [];
|
||||
$ownerSub = trim((string) ($user['sub'] ?? '')) !== '' ? trim((string) ($user['sub'] ?? '')) : 'local';
|
||||
|
||||
$pdo = ConnectionFactory::make($config);
|
||||
$repository = new MiningRepository($pdo, $config->tablePrefix(), null, $ownerSub);
|
||||
|
||||
$settings = $repository->getSettings($projectKey) ?? [];
|
||||
$displayTimezone = trim((string) ($settings['display_timezone'] ?? nexus_display_timezone_name()));
|
||||
if ($displayTimezone === '') {
|
||||
$displayTimezone = nexus_display_timezone_name();
|
||||
}
|
||||
|
||||
$baselineMeasuredAt = trim((string) ($settings['baseline_measured_at'] ?? ''));
|
||||
if ($baselineMeasuredAt !== '') {
|
||||
try {
|
||||
$baselineMeasuredAt = (new DateTimeImmutable($baselineMeasuredAt, new DateTimeZone('UTC')))
|
||||
->setTimezone(new DateTimeZone($displayTimezone))
|
||||
->format('Y-m-d\TH:i');
|
||||
} catch (\Throwable) {
|
||||
$baselineMeasuredAt = '';
|
||||
}
|
||||
}
|
||||
|
||||
return [
|
||||
'baseline_measured_at' => $baselineMeasuredAt,
|
||||
'baseline_coins_total' => isset($settings['baseline_coins_total']) ? (string) $settings['baseline_coins_total'] : '',
|
||||
'report_currency' => strtoupper(trim((string) ($settings['report_currency'] ?? 'EUR'))) ?: 'EUR',
|
||||
];
|
||||
});
|
||||
|
||||
$mm->registerFunction($moduleName, 'save_runtime_settings', static function (array $payload): array {
|
||||
$moduleBasePath = __DIR__;
|
||||
$config = ModuleConfig::load($moduleBasePath);
|
||||
$projectKey = $config->defaultProjectKey();
|
||||
$user = auth_user() ?? [];
|
||||
$ownerSub = trim((string) ($user['sub'] ?? '')) !== '' ? trim((string) ($user['sub'] ?? '')) : 'local';
|
||||
|
||||
$pdo = ConnectionFactory::make($config);
|
||||
$schema = new SchemaManager($pdo, $config->tablePrefix(), $moduleBasePath);
|
||||
$schema->ensureSchema();
|
||||
|
||||
$repository = new MiningRepository($pdo, $config->tablePrefix(), null, $ownerSub);
|
||||
$repository->ensureProject($projectKey, strtoupper(str_replace('-', ' ', $projectKey)));
|
||||
|
||||
$existing = $repository->getSettings($projectKey) ?? [];
|
||||
$displayTimezone = trim((string) ($existing['display_timezone'] ?? nexus_display_timezone_name()));
|
||||
if ($displayTimezone === '') {
|
||||
$displayTimezone = nexus_display_timezone_name();
|
||||
}
|
||||
|
||||
try {
|
||||
$displayTz = new DateTimeZone($displayTimezone);
|
||||
} catch (\Throwable) {
|
||||
throw new RuntimeException('Ungueltige Anzeige-Zeitzone.');
|
||||
}
|
||||
|
||||
$baselineInput = trim((string) ($payload['baseline_measured_at'] ?? ($existing['baseline_measured_at'] ?? '')));
|
||||
if ($baselineInput === '') {
|
||||
throw new RuntimeException('Baseline Zeitpunkt fehlt.');
|
||||
}
|
||||
|
||||
try {
|
||||
$baselineUtc = (new DateTimeImmutable($baselineInput, $displayTz))
|
||||
->setTimezone(new DateTimeZone('UTC'))
|
||||
->format('Y-m-d H:i:s');
|
||||
} catch (\Throwable) {
|
||||
throw new RuntimeException('Baseline Zeitpunkt ist ungueltig.');
|
||||
}
|
||||
|
||||
$baselineCoins = trim((string) ($payload['baseline_coins_total'] ?? ($existing['baseline_coins_total'] ?? '')));
|
||||
if ($baselineCoins === '' || !is_numeric($baselineCoins)) {
|
||||
throw new RuntimeException('Baseline Coins muessen numerisch sein.');
|
||||
}
|
||||
|
||||
$preferredCurrencies = $existing['preferred_currencies'] ?? ['DOGE', 'USD', 'EUR'];
|
||||
if (is_string($preferredCurrencies)) {
|
||||
$preferredCurrencies = preg_split('/[\s,;]+/', $preferredCurrencies) ?: [];
|
||||
}
|
||||
$preferredCurrencies = array_values(array_unique(array_filter(array_map(
|
||||
static fn (mixed $value): string => strtoupper(trim((string) $value)),
|
||||
is_array($preferredCurrencies) ? $preferredCurrencies : []
|
||||
), static fn (string $value): bool => $value !== '')));
|
||||
|
||||
$settings = [
|
||||
'baseline_measured_at' => $baselineUtc,
|
||||
'baseline_coins_total' => (float) $baselineCoins,
|
||||
'daily_cost_amount' => isset($existing['daily_cost_amount']) && is_numeric((string) $existing['daily_cost_amount'])
|
||||
? (float) $existing['daily_cost_amount']
|
||||
: 0.0,
|
||||
'daily_cost_currency' => strtoupper(trim((string) ($existing['daily_cost_currency'] ?? 'EUR'))) ?: 'EUR',
|
||||
'report_currency' => strtoupper(trim((string) ($payload['report_currency'] ?? ($existing['report_currency'] ?? 'EUR')))) ?: 'EUR',
|
||||
'crypto_currency' => strtoupper(trim((string) ($existing['crypto_currency'] ?? 'DOGE'))) ?: 'DOGE',
|
||||
'display_timezone' => $displayTimezone,
|
||||
'fx_max_age_hours' => isset($existing['fx_max_age_hours']) && is_numeric((string) $existing['fx_max_age_hours'])
|
||||
? (int) $existing['fx_max_age_hours']
|
||||
: 3,
|
||||
'module_theme_mode' => in_array((string) ($existing['module_theme_mode'] ?? 'inherit'), ['inherit', 'custom'], true)
|
||||
? (string) $existing['module_theme_mode']
|
||||
: 'inherit',
|
||||
'module_theme_accent' => in_array((string) ($existing['module_theme_accent'] ?? 'teal'), ['teal', 'logo', 'pink', 'cyan', 'orange', 'green'], true)
|
||||
? (string) $existing['module_theme_accent']
|
||||
: 'teal',
|
||||
'preferred_currencies' => $preferredCurrencies,
|
||||
];
|
||||
|
||||
$repository->saveSettings($projectKey, $settings);
|
||||
|
||||
return module_fn('mining-checker', 'runtime_settings');
|
||||
});
|
||||
|
||||
$mm->registerFunction($moduleName, 'setup_actions', static function (): array {
|
||||
return [
|
||||
[
|
||||
'name' => 'initialize_schema',
|
||||
'label' => 'Tabellen importieren',
|
||||
'section' => 'database',
|
||||
'help' => 'Legt die Mining-Checker Tabellen an, wenn sie noch nicht vorhanden sind.',
|
||||
],
|
||||
[
|
||||
'name' => 'upgrade_schema',
|
||||
'label' => 'Tabellen updaten',
|
||||
'section' => 'database',
|
||||
'help' => 'Fuehrt fehlende Tabellen- und Spalten-Upgrades fuer den Mining-Checker aus.',
|
||||
],
|
||||
[
|
||||
'name' => 'seed_import',
|
||||
'label' => 'Seed-Daten importieren',
|
||||
'section' => 'database',
|
||||
'help' => 'Importiert die vordefinierten Startdaten fuer das Standardprojekt.',
|
||||
],
|
||||
];
|
||||
});
|
||||
|
||||
$mm->registerFunction($moduleName, 'setup_status', static function (): array {
|
||||
$moduleBasePath = __DIR__;
|
||||
$config = ModuleConfig::load($moduleBasePath);
|
||||
$pdo = ConnectionFactory::make($config);
|
||||
$schema = new SchemaManager($pdo, $config->tablePrefix(), $moduleBasePath);
|
||||
$status = $schema->schemaStatus();
|
||||
|
||||
return [
|
||||
'title' => 'Tabellenstatus',
|
||||
'type' => !empty($status['all_present']) && empty($status['pending_upgrades']) ? 'success' : 'hint',
|
||||
'text' => !empty($status['all_present'])
|
||||
? (empty($status['pending_upgrades'])
|
||||
? 'Alle Mining-Checker Tabellen sind vorhanden.'
|
||||
: 'Alle Grundtabellen sind vorhanden, aber es gibt noch ausstehende Upgrades.')
|
||||
: 'Das Mining-Checker Schema ist noch unvollstaendig.',
|
||||
'stats' => [
|
||||
['label' => 'Vorhandene Tabellen', 'value' => (string) ((int) ($status['present_count'] ?? 0) . '/' . count((array) ($status['required_tables'] ?? [])))],
|
||||
['label' => 'Fehlende Tabellen', 'value' => !empty($status['missing_tables']) ? implode(', ', (array) $status['missing_tables']) : 'keine'],
|
||||
['label' => 'Ausstehende Upgrades', 'value' => !empty($status['pending_upgrades']) ? implode(', ', (array) $status['pending_upgrades']) : 'keine'],
|
||||
],
|
||||
];
|
||||
});
|
||||
|
||||
$mm->registerFunction($moduleName, 'run_setup_action', static function (string $action): array {
|
||||
$moduleBasePath = __DIR__;
|
||||
$config = ModuleConfig::load($moduleBasePath);
|
||||
$projectKey = $config->defaultProjectKey();
|
||||
$user = auth_user() ?? [];
|
||||
$ownerSub = trim((string) ($user['sub'] ?? '')) !== '' ? trim((string) ($user['sub'] ?? '')) : 'local';
|
||||
|
||||
$pdo = ConnectionFactory::make($config);
|
||||
$schema = new SchemaManager($pdo, $config->tablePrefix(), $moduleBasePath);
|
||||
$repository = new MiningRepository($pdo, $config->tablePrefix(), null, $ownerSub);
|
||||
|
||||
return match ($action) {
|
||||
'initialize_schema' => (static function () use ($schema): array {
|
||||
$result = $schema->initializeSchema(false);
|
||||
$after = is_array($result['after'] ?? null) ? $result['after'] : [];
|
||||
return [
|
||||
'message' => ($result['message'] ?? 'Schema initialisiert.')
|
||||
. ' Vorhanden: ' . (int) ($after['present_count'] ?? 0)
|
||||
. '/' . count((array) ($after['required_tables'] ?? [])) . '.',
|
||||
];
|
||||
})(),
|
||||
'upgrade_schema' => (static function () use ($schema): array {
|
||||
$result = $schema->upgradeSchemaDirect();
|
||||
$applied = array_values(array_filter(array_map('strval', (array) ($result['upgraded'] ?? []))));
|
||||
return [
|
||||
'message' => ($result['message'] ?? 'Schema-Upgrade ausgefuehrt.')
|
||||
. ($applied !== [] ? ' Upgrades: ' . implode(', ', $applied) . '.' : ''),
|
||||
];
|
||||
})(),
|
||||
'seed_import' => (static function () use ($schema, $repository, $projectKey): array {
|
||||
$schema->ensureSchema();
|
||||
$repository->ensureProject($projectKey, strtoupper(str_replace('-', ' ', $projectKey)));
|
||||
$result = (new \Modules\MiningChecker\Domain\SeedImporter($repository))->import($projectKey);
|
||||
return [
|
||||
'message' => 'Seed-Daten importiert. Eingefuegt: ' . (int) ($result['inserted'] ?? 0) . '.',
|
||||
];
|
||||
})(),
|
||||
default => throw new RuntimeException('Unbekannte Setup-Aktion.'),
|
||||
};
|
||||
});
|
||||
|
||||
$mm->registerFunction('mining-checker', 'sql_import_target', static function (): array {
|
||||
$moduleBasePath = __DIR__;
|
||||
$config = ModuleConfig::load($moduleBasePath);
|
||||
|
||||
return [
|
||||
'pdo' => ConnectionFactory::make($config),
|
||||
'label' => 'Mining-Checker Projekt-Datenbank',
|
||||
];
|
||||
});
|
||||
24
modules/mining-checker/config/example.config.php
Normal file
24
modules/mining-checker/config/example.config.php
Normal file
@@ -0,0 +1,24 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
return [
|
||||
'MINING_CHECKER_DEFAULT_PROJECT_KEY' => 'doge-main',
|
||||
'MINING_CHECKER_OCR_PROVIDERS' => 'ocrspace,tesseract',
|
||||
'MINING_CHECKER_OCR_SPACE_URL' => 'https://api.ocr.space/parse/image',
|
||||
'MINING_CHECKER_OCR_SPACE_API_KEY' => 'K83150278888957',
|
||||
'MINING_CHECKER_OCR_SPACE_LANGUAGE' => 'eng',
|
||||
'MINING_CHECKER_OCR_SPACE_ENGINE' => '2',
|
||||
'MINING_CHECKER_OCR_SPACE_SCALE' => 'true',
|
||||
'MINING_CHECKER_OCR_SPACE_DETECT_ORIENTATION' => 'true',
|
||||
'MINING_CHECKER_OCR_SPACE_IS_TABLE' => 'false',
|
||||
'MINING_CHECKER_OCR_SPACE_TIMEOUT' => '25',
|
||||
'MINING_CHECKER_TESSERACT_BIN' => '/usr/bin/tesseract',
|
||||
'MINING_CHECKER_TESSERACT_LANG' => 'eng',
|
||||
'MINING_CHECKER_FX_PROVIDER' => 'currencyapi',
|
||||
'MINING_CHECKER_FX_URL' => 'https://currencyapi.net',
|
||||
'MINING_CHECKER_FX_CURRENCIES_URL' => 'https://currencyapi.net',
|
||||
'MINING_CHECKER_FX_API_KEY' => 'eb18ce459ffb0461c59229b478f2e00388d1',
|
||||
'MINING_CHECKER_FX_TIMEOUT' => '10',
|
||||
'MINING_CHECKER_FX_CACHE_TTL' => '21600',
|
||||
'MINING_CHECKER_FX_AUTO_FETCH_ON_MISS' => 'false',
|
||||
];
|
||||
46
modules/mining-checker/config/module.php
Normal file
46
modules/mining-checker/config/module.php
Normal file
@@ -0,0 +1,46 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
return [
|
||||
'default_project_key' => getenv('MINING_CHECKER_DEFAULT_PROJECT_KEY') ?: 'doge-main',
|
||||
'use_project_database' => true,
|
||||
'table_prefix' => 'miningcheck_',
|
||||
'uploads_dir' => dirname(__DIR__, 3) . '/data/mining-checker/uploads',
|
||||
'uploads_public_prefix' => '/data/mining-checker/uploads',
|
||||
'ocr' => [
|
||||
'providers' => array_values(array_filter(array_map(
|
||||
static fn (string $provider): string => trim(strtolower($provider)),
|
||||
explode(',', getenv('MINING_CHECKER_OCR_PROVIDERS') ?: 'ocrspace,tesseract')
|
||||
))),
|
||||
'ocrspace' => [
|
||||
'url' => getenv('MINING_CHECKER_OCR_SPACE_URL') ?: 'https://api.ocr.space/parse/image',
|
||||
'api_key' => getenv('MINING_CHECKER_OCR_SPACE_API_KEY') ?: 'K83150278888957',
|
||||
'language' => getenv('MINING_CHECKER_OCR_SPACE_LANGUAGE') ?: 'eng',
|
||||
'engine' => (int) (getenv('MINING_CHECKER_OCR_SPACE_ENGINE') ?: 2),
|
||||
'scale' => getenv('MINING_CHECKER_OCR_SPACE_SCALE') ?: 'true',
|
||||
'detect_orientation' => getenv('MINING_CHECKER_OCR_SPACE_DETECT_ORIENTATION') ?: 'true',
|
||||
'is_table' => getenv('MINING_CHECKER_OCR_SPACE_IS_TABLE') ?: 'false',
|
||||
'timeout' => (int) (getenv('MINING_CHECKER_OCR_SPACE_TIMEOUT') ?: 25),
|
||||
],
|
||||
'tesseract' => [
|
||||
'binary' => getenv('MINING_CHECKER_TESSERACT_BIN') ?: 'tesseract',
|
||||
'language' => getenv('MINING_CHECKER_TESSERACT_LANG') ?: 'eng',
|
||||
],
|
||||
],
|
||||
'fx' => [
|
||||
'provider' => getenv('MINING_CHECKER_FX_PROVIDER') ?: 'currencyapi',
|
||||
'url' => getenv('MINING_CHECKER_FX_URL') ?: 'https://currencyapi.net',
|
||||
'currencies_url' => getenv('MINING_CHECKER_FX_CURRENCIES_URL') ?: 'https://currencyapi.net',
|
||||
'api_key' => getenv('MINING_CHECKER_FX_API_KEY') ?: 'eb18ce459ffb0461c59229b478f2e00388d1',
|
||||
'timeout' => (int) (getenv('MINING_CHECKER_FX_TIMEOUT') ?: 10),
|
||||
'cache_ttl' => (int) (getenv('MINING_CHECKER_FX_CACHE_TTL') ?: 21600),
|
||||
'auto_fetch_on_miss' => filter_var(
|
||||
getenv('MINING_CHECKER_FX_AUTO_FETCH_ON_MISS') ?: 'false',
|
||||
FILTER_VALIDATE_BOOL
|
||||
),
|
||||
],
|
||||
'debug' => [
|
||||
'enabled' => filter_var(getenv('MINING_CHECKER_DEBUG') ?: 'false', FILTER_VALIDATE_BOOL),
|
||||
'dir' => getenv('MINING_CHECKER_DEBUG_DIR') ?: dirname(__DIR__, 3) . '/data/mining-checker/debug',
|
||||
],
|
||||
];
|
||||
17
modules/mining-checker/design.json
Normal file
17
modules/mining-checker/design.json
Normal file
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"eyebrow": "Modul",
|
||||
"title": "Mining-Checker",
|
||||
"description": "Erfassung, OCR-Auswertung und Analyse von DOGE-Mining-Messwerten als eingebettetes Modul.",
|
||||
"actions": [
|
||||
{ "label": "Nexus Übersicht", "href": "/modules" },
|
||||
{ "label": "Setup", "href": "/modules/setup/mining-checker", "variant": "secondary" }
|
||||
],
|
||||
"sections": [
|
||||
{ "key": "overview", "label": "Übersicht" },
|
||||
{ "key": "upload", "label": "Upload" },
|
||||
{ "key": "measurements", "label": "Mining-History" },
|
||||
{ "key": "wallet", "label": "Wallet" },
|
||||
{ "key": "mining", "label": "Miner-Daten" },
|
||||
{ "key": "dashboards", "label": "Dashboards" }
|
||||
]
|
||||
}
|
||||
114
modules/mining-checker/docs/README.md
Normal file
114
modules/mining-checker/docs/README.md
Normal file
@@ -0,0 +1,114 @@
|
||||
# Mining-Checker Modul
|
||||
|
||||
## Zweck
|
||||
|
||||
Das Modul erfasst DOGE-Mining-Messpunkte, analysiert OCR-Vorschlaege aus Screenshots, speichert Messreihen projektbezogen und berechnet Performance-, Kurs- und Zielmetriken.
|
||||
|
||||
## Ordnerstruktur
|
||||
|
||||
```text
|
||||
modules/mining-checker/
|
||||
|-- api/
|
||||
|-- assets/
|
||||
| |-- css/
|
||||
| `-- js/
|
||||
|-- config/
|
||||
|-- docs/
|
||||
|-- pages/
|
||||
|-- partials/
|
||||
|-- sql/
|
||||
| `-- migrations/
|
||||
|-- src/
|
||||
| |-- Api/
|
||||
| |-- Domain/
|
||||
| |-- Infrastructure/
|
||||
| `-- Support/
|
||||
|-- storage/uploads/
|
||||
|-- bootstrap.php
|
||||
`-- module.json
|
||||
```
|
||||
|
||||
## API-Endpunkte
|
||||
|
||||
- `GET /api/mining-checker/v1/health`
|
||||
- `GET /api/mining-checker/v1/projects/{projectKey}/bootstrap`
|
||||
- `GET /api/mining-checker/v1/projects/{projectKey}/measurements`
|
||||
- `POST /api/mining-checker/v1/projects/{projectKey}/measurements`
|
||||
- `POST /api/mining-checker/v1/projects/{projectKey}/ocr-preview`
|
||||
- `GET /api/mining-checker/v1/projects/{projectKey}/settings`
|
||||
- `PUT /api/mining-checker/v1/projects/{projectKey}/settings`
|
||||
- `GET /api/mining-checker/v1/projects/{projectKey}/targets`
|
||||
- `POST /api/mining-checker/v1/projects/{projectKey}/targets`
|
||||
- `PATCH /api/mining-checker/v1/projects/{projectKey}/targets/{targetId}`
|
||||
- `GET /api/mining-checker/v1/projects/{projectKey}/dashboards`
|
||||
- `POST /api/mining-checker/v1/projects/{projectKey}/dashboards`
|
||||
- `GET /api/mining-checker/v1/projects/{projectKey}/dashboard-data`
|
||||
- `POST /api/mining-checker/v1/projects/{projectKey}/seed-import`
|
||||
- `GET /api/mining-checker/v1/projects/{projectKey}/schema-status`
|
||||
- `POST /api/mining-checker/v1/projects/{projectKey}/initialize`
|
||||
- `POST /api/mining-checker/v1/projects/{projectKey}/upgrade`
|
||||
- `GET /api/mining-checker/v1/projects/{projectKey}/connection-test`
|
||||
- `GET /api/mining-checker/v1/projects/{projectKey}/fx-history`
|
||||
- `POST /api/mining-checker/v1/projects/{projectKey}/legacy-fx-migrate`
|
||||
|
||||
## Integration
|
||||
|
||||
1. SQL aus dem passenden Dialekt-Schema ausfuehren:
|
||||
- MySQL/MariaDB: `sql/schema.mysql.sql`
|
||||
- PostgreSQL: `sql/schema.pgsql.sql`
|
||||
- `sql/schema.sql` bleibt der Rueckfall fuer bestehende Setups
|
||||
2. Das Modul nutzt bewusst dieselbe Projekt-Datenbank wie die Anwendung und legt seine Tabellen mit dem Praefix `miningcheck_` an.
|
||||
3. Modulroute ueber `/module/mining-checker` aufrufen.
|
||||
4. REST-API wird ueber `/api/mining-checker/...` vom Hauptprojekt geroutet.
|
||||
|
||||
Hinweis:
|
||||
Wenn beim ersten API-Zugriff noch keine `miningcheck_*` Tabellen vorhanden sind, importiert das Modul automatisch das zum aktiven PDO-Treiber passende Schema.
|
||||
Seed-Daten werden dabei nicht automatisch eingespielt.
|
||||
Fuer eine manuelle Initialisierung, ein inkrementelles Upgrade oder einen Reset gibt es zusaetzlich `schema-status`, `upgrade` und `initialize`. Mit `{ "drop_existing": true }` werden vorhandene `miningcheck_*` Tabellen inklusive Daten geloescht und das Schema neu angelegt.
|
||||
|
||||
## OCR-Hinweis
|
||||
|
||||
Das Modul unterstuetzt einen OCR-Provider-Stack. Standardmaessig wird zuerst `ocr.space` verwendet und danach optional auf lokales `tesseract` zurueckgefallen.
|
||||
|
||||
Empfohlene Umgebungsvariablen:
|
||||
|
||||
- `MINING_CHECKER_OCR_PROVIDERS=ocrspace,tesseract`
|
||||
- `MINING_CHECKER_OCR_SPACE_URL=https://api.ocr.space/parse/image`
|
||||
- `MINING_CHECKER_OCR_SPACE_API_KEY=...`
|
||||
- `MINING_CHECKER_OCR_SPACE_LANGUAGE=eng`
|
||||
- `MINING_CHECKER_OCR_SPACE_ENGINE=2`
|
||||
- `MINING_CHECKER_OCR_SPACE_SCALE=true`
|
||||
- `MINING_CHECKER_OCR_SPACE_DETECT_ORIENTATION=true`
|
||||
- `MINING_CHECKER_OCR_SPACE_IS_TABLE=false`
|
||||
- `MINING_CHECKER_OCR_SPACE_TIMEOUT=25`
|
||||
- `MINING_CHECKER_TESSERACT_BIN=/usr/bin/tesseract`
|
||||
- `MINING_CHECKER_TESSERACT_LANG=eng`
|
||||
|
||||
Laut OCR.space-Doku wird `POST https://api.ocr.space/parse/image` mit `file`, Header-`apikey`, optional `language`, `scale`, `detectOrientation`, `isTable` und `OCREngine` verwendet. Der Modulparser wertet die OCR.space-Felder `ParsedResults`, `ParsedText`, `IsErroredOnProcessing`, `ErrorMessage` und `OCRExitCode` aus. Quellen: https://ocr.space/ocrapi
|
||||
|
||||
## Wechselkurse und Waehrungen
|
||||
|
||||
Der Mining-Checker speichert keine eigenen FX-Snapshots mehr, sondern referenziert die `fetch_id` aus `fx-rates`.
|
||||
Auch der Waehrungskatalog und die bevorzugten Waehrungen kommen ausschliesslich aus `fx-rates`. Der Mining-Checker fuehrt keine eigene Waehrungstabelle und keine eigene Alias-Verwaltung mehr im Laufzeitpfad.
|
||||
- `MINING_CHECKER_FX_AUTO_FETCH_ON_MISS=false`
|
||||
|
||||
Optionaler JSON-Body:
|
||||
|
||||
- `base`: Standard `EUR`
|
||||
- `symbols`: wird aktuell ignoriert; der Mining-Checker speichert immer den kompletten Waehrungssatz des Fetches
|
||||
|
||||
Beispiel:
|
||||
|
||||
```json
|
||||
{
|
||||
"base": "EUR"
|
||||
}
|
||||
```
|
||||
|
||||
`currencyapi.net` wird ueber das Modul `fx-rates` abgefragt. Aus dem Response werden `base`, `rates` und `updated` uebernommen; `valid` muss `true` sein. Die eigentlichen Fetches und Raten liegen im Modul `fx-rates`.
|
||||
|
||||
Pro Abruf entsteht genau ein Datensatz in `fx-rates` mit Basiswaehrung, Provider und Stichtag. Neue Mining-Messpunkte pruefen beim Speichern, ob ein neuer FX-Fetch noetig ist; falls nicht, wird die letzte passende `fetch_id` wiederverwendet.
|
||||
|
||||
Falls noch historische Mining-Checker-Fetches in `miningcheck_fx_fetches` und `miningcheck_fx_rates` liegen, kann `POST /api/mining-checker/v1/projects/{projectKey}/legacy-fx-migrate` diese nach `fx-rates` ueberfuehren. Danach werden bestehende Messpunkte soweit moeglich auf die passende `fx_fetch_id` aktualisiert.
|
||||
|
||||
Fuer Auswertungen, Berichte und Listen speichert der Mining-Checker pro Messpunkt die damals passende `fx_fetch_id`. Historische Umrechnungen laufen damit gegen genau den zugeordneten `fx-rates`-Snapshot.
|
||||
30
modules/mining-checker/module.json
Normal file
30
modules/mining-checker/module.json
Normal file
@@ -0,0 +1,30 @@
|
||||
{
|
||||
"name": "Mining-Checker",
|
||||
"title": "Mining-Checker",
|
||||
"version": "0.3.0",
|
||||
"description": "Erfassung, OCR-Auswertung und Analyse von DOGE-Mining-Messwerten als eingebettetes Modul.",
|
||||
"enabled_by_default": true,
|
||||
"setup": {
|
||||
"sections": {
|
||||
"database": true
|
||||
},
|
||||
"fields": [
|
||||
{ "name": "use_separate_db", "label": "Eigene Modul-DB nutzen", "type": "checkbox", "required": false, "help": "Wenn aktiv, werden die DB-Daten unten verwendet. Sonst wird die Nexus-Base-DB genutzt." },
|
||||
{ "name": "db.driver", "label": "DB Driver", "type": "text", "required": false, "help": "z.B. pgsql oder mysql" },
|
||||
{ "name": "db.host", "label": "DB Host", "type": "text", "required": false },
|
||||
{ "name": "db.port", "label": "DB Port", "type": "number", "required": false },
|
||||
{ "name": "db.dbname", "label": "DB Name", "type": "text", "required": false },
|
||||
{ "name": "db.schema", "label": "DB Schema", "type": "text", "required": false },
|
||||
{ "name": "db.user", "label": "DB User", "type": "text", "required": false },
|
||||
{ "name": "db.password", "label": "DB Passwort", "type": "password", "required": false },
|
||||
{ "name": "baseline_measured_at", "label": "Baseline Zeitpunkt", "type": "datetime-local", "required": false, "help": "Referenzzeitpunkt fuer die erste Baseline-Messung." },
|
||||
{ "name": "baseline_coins_total", "label": "Baseline Coins", "type": "number", "required": false, "help": "Coin-Bestand zum Baseline-Zeitpunkt." },
|
||||
{ "name": "report_currency", "label": "Standard-Berichtswaehrung", "type": "text", "required": false, "help": "Zielwaehrung fuer Kennzahlen und Berichte, z.B. EUR." }
|
||||
]
|
||||
},
|
||||
"auth": {
|
||||
"required": true,
|
||||
"users": [],
|
||||
"groups": []
|
||||
}
|
||||
}
|
||||
59
modules/mining-checker/pages/index.php
Normal file
59
modules/mining-checker/pages/index.php
Normal file
@@ -0,0 +1,59 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
require_once dirname(__DIR__) . '/bootstrap.php';
|
||||
|
||||
$moduleConfig = require dirname(__DIR__) . '/config/module.php';
|
||||
$design = module_design('mining-checker');
|
||||
$defaultProjectKey = (string) ($moduleConfig['default_project_key'] ?? 'doge-main');
|
||||
$sections = is_array($design['sections'] ?? null) ? $design['sections'] : [];
|
||||
$activeView = trim((string) ($_GET['view'] ?? 'overview'));
|
||||
$allowedViews = [];
|
||||
$tabs = [];
|
||||
foreach ($sections as $section) {
|
||||
if (!is_array($section)) {
|
||||
continue;
|
||||
}
|
||||
$key = trim((string) ($section['key'] ?? ''));
|
||||
$label = trim((string) ($section['label'] ?? ''));
|
||||
if ($key === '' || $label === '') {
|
||||
continue;
|
||||
}
|
||||
$allowedViews[] = $key;
|
||||
$tabs[] = [
|
||||
'label' => $label,
|
||||
'href' => '/module/mining-checker?view=' . rawurlencode($key),
|
||||
'active' => $activeView === $key,
|
||||
];
|
||||
}
|
||||
if ($allowedViews === []) {
|
||||
$allowedViews = ['overview'];
|
||||
}
|
||||
if (!in_array($activeView, $allowedViews, true)) {
|
||||
$activeView = $allowedViews[0];
|
||||
foreach ($tabs as &$tab) {
|
||||
$tab['active'] = str_ends_with((string) ($tab['href'] ?? ''), 'view=' . $activeView);
|
||||
}
|
||||
unset($tab);
|
||||
}
|
||||
$sectionsJson = json_encode($sections, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
|
||||
$moduleCss = file_get_contents(dirname(__DIR__) . '/assets/css/app.css') ?: '';
|
||||
$moduleJs = file_get_contents(dirname(__DIR__) . '/assets/js/app.js') ?: '';
|
||||
$moduleJs = str_replace('</script>', '<\/script>', $moduleJs);
|
||||
?>
|
||||
<?= module_shell_header('mining-checker', [
|
||||
'title' => 'DOGE Mining-Checker',
|
||||
'tabs' => $tabs,
|
||||
]) ?>
|
||||
<div class="module-flow">
|
||||
<div id="mining-checker-app"
|
||||
data-default-project-key="<?= e($defaultProjectKey) ?>"
|
||||
data-api-base="/api/mining-checker/v1"
|
||||
data-active-view="<?= e($activeView) ?>"
|
||||
data-sections-json="<?= e(is_string($sectionsJson) ? $sectionsJson : '[]') ?>"></div>
|
||||
</div>
|
||||
<style><?= $moduleCss ?></style>
|
||||
<script crossorigin src="https://unpkg.com/react@18/umd/react.production.min.js"></script>
|
||||
<script crossorigin src="https://unpkg.com/react-dom@18/umd/react-dom.production.min.js"></script>
|
||||
<script><?= $moduleJs ?></script>
|
||||
<?= module_shell_footer() ?>
|
||||
0
modules/mining-checker/partials/.gitkeep
Normal file
0
modules/mining-checker/partials/.gitkeep
Normal file
124
modules/mining-checker/sql/migrations/001_init.sql
Normal file
124
modules/mining-checker/sql/migrations/001_init.sql
Normal file
@@ -0,0 +1,124 @@
|
||||
CREATE TABLE IF NOT EXISTS miningcheck_projects (
|
||||
project_key VARCHAR(64) NOT NULL PRIMARY KEY,
|
||||
project_name VARCHAR(160) NOT NULL,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS miningcheck_settings (
|
||||
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
|
||||
project_key VARCHAR(64) NOT NULL,
|
||||
baseline_measured_at DATETIME NOT NULL,
|
||||
baseline_coins_total DECIMAL(20,6) NOT NULL,
|
||||
daily_cost_amount DECIMAL(20,10) NOT NULL,
|
||||
daily_cost_currency VARCHAR(10) NOT NULL,
|
||||
report_currency VARCHAR(10) NULL,
|
||||
crypto_currency VARCHAR(10) NULL,
|
||||
display_timezone VARCHAR(64) NULL,
|
||||
fx_max_age_hours DECIMAL(10,2) NULL,
|
||||
module_theme_mode VARCHAR(16) NULL,
|
||||
module_theme_accent VARCHAR(16) NULL,
|
||||
preferred_currencies JSON NULL,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
CONSTRAINT fk_mining_settings_project FOREIGN KEY (project_key) REFERENCES miningcheck_projects(project_key) ON DELETE CASCADE,
|
||||
CONSTRAINT uq_mining_settings_project UNIQUE (project_key)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS miningcheck_cost_plans (
|
||||
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
|
||||
project_key VARCHAR(64) NOT NULL,
|
||||
label VARCHAR(120) NOT NULL,
|
||||
starts_at DATETIME NOT NULL,
|
||||
runtime_months INT NOT NULL,
|
||||
auto_renew TINYINT(1) NOT NULL DEFAULT 0,
|
||||
base_price_amount DECIMAL(20,8) NULL,
|
||||
payment_type VARCHAR(10) NOT NULL DEFAULT 'fiat',
|
||||
total_cost_amount DECIMAL(20,8) NOT NULL,
|
||||
currency VARCHAR(10) NOT NULL,
|
||||
note TEXT NULL,
|
||||
is_active TINYINT(1) NOT NULL DEFAULT 1,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
CONSTRAINT fk_mining_cost_plans_project FOREIGN KEY (project_key) REFERENCES miningcheck_projects(project_key) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE INDEX idx_miningcheck_cost_plans_project_start
|
||||
ON miningcheck_cost_plans(project_key, starts_at);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS miningcheck_measurements (
|
||||
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
|
||||
project_key VARCHAR(64) NOT NULL,
|
||||
measured_at DATETIME NOT NULL,
|
||||
coins_total DECIMAL(20,6) NOT NULL,
|
||||
coin_currency VARCHAR(10) NOT NULL DEFAULT 'DOGE',
|
||||
price_per_coin DECIMAL(20,8) NULL,
|
||||
price_currency VARCHAR(10) NULL,
|
||||
note TEXT NULL,
|
||||
source ENUM('manual', 'image_ocr', 'seed_import') NOT NULL,
|
||||
image_path VARCHAR(255) NULL,
|
||||
ocr_raw_text MEDIUMTEXT NULL,
|
||||
ocr_confidence DECIMAL(6,4) NULL,
|
||||
ocr_flags JSON NULL,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
CONSTRAINT fk_mining_measurements_project FOREIGN KEY (project_key) REFERENCES miningcheck_projects(project_key) ON DELETE CASCADE,
|
||||
CONSTRAINT uq_mining_measurements_unique UNIQUE (project_key, measured_at, coins_total)
|
||||
);
|
||||
|
||||
CREATE INDEX idx_miningcheck_measurements_project_measured_at
|
||||
ON miningcheck_measurements(project_key, measured_at);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS miningcheck_targets (
|
||||
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
|
||||
project_key VARCHAR(64) NOT NULL,
|
||||
label VARCHAR(120) NOT NULL,
|
||||
target_amount_fiat DECIMAL(20,2) NOT NULL,
|
||||
currency VARCHAR(10) NOT NULL,
|
||||
miner_offer_id BIGINT UNSIGNED NULL,
|
||||
is_active TINYINT(1) NOT NULL DEFAULT 1,
|
||||
sort_order INT NOT NULL DEFAULT 0,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
CONSTRAINT fk_mining_targets_project FOREIGN KEY (project_key) REFERENCES miningcheck_projects(project_key) ON DELETE CASCADE,
|
||||
CONSTRAINT fk_mining_targets_offer FOREIGN KEY (miner_offer_id) REFERENCES miningcheck_miner_offers(id) ON DELETE SET NULL,
|
||||
CONSTRAINT uq_mining_targets_project_label UNIQUE (project_key, label)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS miningcheck_wallet_snapshots (
|
||||
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
|
||||
project_key VARCHAR(64) NOT NULL,
|
||||
owner_sub VARCHAR(128) NOT NULL,
|
||||
measured_at TIMESTAMP NOT NULL,
|
||||
total_value_amount DECIMAL(20,8) NULL,
|
||||
total_value_currency VARCHAR(10) NULL,
|
||||
wallet_balance DECIMAL(28,10) NULL,
|
||||
wallet_currency VARCHAR(10) NOT NULL,
|
||||
balances_json JSON NULL,
|
||||
note TEXT NULL,
|
||||
source ENUM('manual', 'image_ocr', 'seed_import') NOT NULL,
|
||||
image_path VARCHAR(255) NULL,
|
||||
ocr_raw_text MEDIUMTEXT NULL,
|
||||
ocr_confidence DECIMAL(6,4) NULL,
|
||||
ocr_flags JSON NULL,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
CONSTRAINT fk_mining_wallet_snapshots_project FOREIGN KEY (project_key) REFERENCES miningcheck_projects(project_key) ON DELETE CASCADE,
|
||||
KEY idx_miningcheck_wallet_snapshots_project_measured_at (project_key, owner_sub, measured_at)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS miningcheck_dashboard_definitions (
|
||||
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
|
||||
project_key VARCHAR(64) NOT NULL,
|
||||
name VARCHAR(160) NOT NULL,
|
||||
chart_type ENUM('line', 'bar', 'area', 'table') NOT NULL,
|
||||
x_field VARCHAR(64) NOT NULL,
|
||||
y_field VARCHAR(64) NOT NULL,
|
||||
aggregation VARCHAR(32) NOT NULL DEFAULT 'none',
|
||||
filters_json JSON NULL,
|
||||
is_active TINYINT(1) NOT NULL DEFAULT 1,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
CONSTRAINT fk_mining_dashboards_project FOREIGN KEY (project_key) REFERENCES miningcheck_projects(project_key) ON DELETE CASCADE,
|
||||
CONSTRAINT uq_mining_dashboards_project_name UNIQUE (project_key, name)
|
||||
);
|
||||
75
modules/mining-checker/sql/migrations/002_seed_doge_main.sql
Normal file
75
modules/mining-checker/sql/migrations/002_seed_doge_main.sql
Normal file
@@ -0,0 +1,75 @@
|
||||
INSERT INTO miningcheck_projects (project_key, project_name)
|
||||
VALUES ('doge-main', 'DOGE Mining Main')
|
||||
ON DUPLICATE KEY UPDATE project_name = VALUES(project_name);
|
||||
|
||||
INSERT INTO miningcheck_settings (
|
||||
project_key,
|
||||
baseline_measured_at,
|
||||
baseline_coins_total,
|
||||
daily_cost_amount,
|
||||
daily_cost_currency,
|
||||
preferred_currencies
|
||||
)
|
||||
VALUES (
|
||||
'doge-main',
|
||||
'2026-03-16 01:32:00',
|
||||
27.617864,
|
||||
0.3123287671,
|
||||
'EUR',
|
||||
'["DOGE","USD","EUR"]'
|
||||
)
|
||||
ON DUPLICATE KEY UPDATE
|
||||
baseline_measured_at = VALUES(baseline_measured_at),
|
||||
baseline_coins_total = VALUES(baseline_coins_total),
|
||||
daily_cost_amount = VALUES(daily_cost_amount),
|
||||
daily_cost_currency = VALUES(daily_cost_currency),
|
||||
preferred_currencies = VALUES(preferred_currencies);
|
||||
|
||||
INSERT INTO miningcheck_targets (project_key, label, target_amount_fiat, currency, is_active, sort_order)
|
||||
VALUES
|
||||
('doge-main', 'Ziel A', 10.82, 'EUR', 1, 10),
|
||||
('doge-main', 'Ziel B', 19.50, 'EUR', 1, 20)
|
||||
ON DUPLICATE KEY UPDATE
|
||||
target_amount_fiat = VALUES(target_amount_fiat),
|
||||
currency = VALUES(currency),
|
||||
is_active = VALUES(is_active),
|
||||
sort_order = VALUES(sort_order);
|
||||
|
||||
INSERT INTO miningcheck_dashboard_definitions (
|
||||
project_key, name, chart_type, x_field, y_field, aggregation, filters_json, is_active
|
||||
)
|
||||
VALUES
|
||||
('doge-main', 'Mining-Verlauf', 'line', 'measured_at', 'coins_total', 'none', NULL, 1),
|
||||
('doge-main', 'Performance-Verlauf', 'area', 'measured_date', 'doge_per_day_interval', 'avg', NULL, 1),
|
||||
('doge-main', 'Kurs-Verlauf', 'line', 'measured_at', 'price_per_coin', 'none', '{"currency":"EUR"}', 1)
|
||||
ON DUPLICATE KEY UPDATE
|
||||
chart_type = VALUES(chart_type),
|
||||
x_field = VALUES(x_field),
|
||||
y_field = VALUES(y_field),
|
||||
aggregation = VALUES(aggregation),
|
||||
filters_json = VALUES(filters_json),
|
||||
is_active = VALUES(is_active);
|
||||
|
||||
INSERT INTO miningcheck_measurements (
|
||||
project_key, measured_at, coins_total, price_per_coin, price_currency, note, source, image_path, ocr_raw_text, ocr_confidence, ocr_flags
|
||||
)
|
||||
VALUES
|
||||
('doge-main', '2026-03-16 01:32:00', 27.617864, NULL, NULL, 'Basiswert', 'seed_import', NULL, NULL, NULL, NULL),
|
||||
('doge-main', '2026-03-17 02:41:00', 33.751904, NULL, NULL, 'Kurs wurde spaeter separat genannt, aber nicht sicher exakt diesem Messpunkt zuordenbar', 'seed_import', NULL, NULL, NULL, NULL),
|
||||
('doge-main', '2026-03-17 07:15:00', 34.825695, 0.10037, 'EUR', NULL, 'seed_import', NULL, NULL, NULL, NULL),
|
||||
('doge-main', '2026-03-17 13:21:00', 36.328140, 0.10002, 'EUR', NULL, 'seed_import', NULL, NULL, NULL, NULL),
|
||||
('doge-main', '2026-03-17 18:53:00', 37.682757, 0.10062, 'EUR', NULL, 'seed_import', NULL, NULL, NULL, NULL),
|
||||
('doge-main', '2026-03-18 00:08:00', 38.934351, 0.10097, 'EUR', NULL, 'seed_import', NULL, NULL, NULL, NULL),
|
||||
('doge-main', '2026-03-18 07:40:00', 40.782006, 0.10040, 'EUR', NULL, 'seed_import', NULL, NULL, NULL, NULL),
|
||||
('doge-main', '2026-03-18 13:32:00', 42.223449, 0.09607, 'EUR', 'Originaleingabe im Chat: 18.6.2026', 'seed_import', NULL, NULL, NULL, NULL),
|
||||
('doge-main', '2026-03-18 21:15:00', 44.191018, 0.09446, 'EUR', NULL, 'seed_import', NULL, NULL, NULL, NULL),
|
||||
('doge-main', '2026-03-19 00:09:00', 44.908500, 0.09507, 'EUR', NULL, 'seed_import', NULL, NULL, NULL, NULL),
|
||||
('doge-main', '2026-03-19 02:33:00', 45.546924, 0.09499, 'USD', 'aus Screenshot extrahiert', 'seed_import', NULL, NULL, NULL, JSON_ARRAY('source:screenshot')),
|
||||
('doge-main', '2026-03-19 07:01:00', 46.694127, 0.09460, 'USD', 'aus Screenshot extrahiert', 'seed_import', NULL, NULL, NULL, JSON_ARRAY('source:screenshot')),
|
||||
('doge-main', '2026-03-19 12:24:00', 48.056494, 0.09419, 'USD', 'aus Screenshot extrahiert', 'seed_import', NULL, NULL, NULL, JSON_ARRAY('source:screenshot')),
|
||||
('doge-main', '2026-03-19 21:39:00', 50.427943, 0.09361, 'USD', 'aus Screenshot extrahiert', 'seed_import', NULL, NULL, NULL, JSON_ARRAY('source:screenshot'))
|
||||
ON DUPLICATE KEY UPDATE
|
||||
price_per_coin = VALUES(price_per_coin),
|
||||
price_currency = VALUES(price_currency),
|
||||
note = VALUES(note),
|
||||
source = VALUES(source);
|
||||
34
modules/mining-checker/sql/migrations/003_timezone_utc.sql
Normal file
34
modules/mining-checker/sql/migrations/003_timezone_utc.sql
Normal file
@@ -0,0 +1,34 @@
|
||||
BEGIN;
|
||||
|
||||
ALTER TABLE miningcheck_settings
|
||||
ADD COLUMN IF NOT EXISTS display_timezone VARCHAR(64);
|
||||
|
||||
UPDATE miningcheck_settings
|
||||
SET display_timezone = 'Europe/Berlin'
|
||||
WHERE display_timezone IS NULL OR BTRIM(display_timezone) = '';
|
||||
|
||||
UPDATE miningcheck_settings
|
||||
SET baseline_measured_at = ((baseline_measured_at AT TIME ZONE 'Europe/Berlin') AT TIME ZONE 'UTC')
|
||||
WHERE baseline_measured_at IS NOT NULL;
|
||||
|
||||
UPDATE miningcheck_cost_plans
|
||||
SET starts_at = ((starts_at AT TIME ZONE 'Europe/Berlin') AT TIME ZONE 'UTC')
|
||||
WHERE starts_at IS NOT NULL;
|
||||
|
||||
UPDATE miningcheck_measurements
|
||||
SET measured_at = ((measured_at AT TIME ZONE 'Europe/Berlin') AT TIME ZONE 'UTC')
|
||||
WHERE measured_at IS NOT NULL;
|
||||
|
||||
UPDATE miningcheck_payouts
|
||||
SET payout_at = ((payout_at AT TIME ZONE 'Europe/Berlin') AT TIME ZONE 'UTC')
|
||||
WHERE payout_at IS NOT NULL;
|
||||
|
||||
UPDATE miningcheck_purchased_miners
|
||||
SET purchased_at = ((purchased_at AT TIME ZONE 'Europe/Berlin') AT TIME ZONE 'UTC')
|
||||
WHERE purchased_at IS NOT NULL;
|
||||
|
||||
UPDATE miningcheck_fx_fetches
|
||||
SET fetched_at = ((fetched_at AT TIME ZONE 'Europe/Berlin') AT TIME ZONE 'UTC')
|
||||
WHERE fetched_at IS NOT NULL;
|
||||
|
||||
COMMIT;
|
||||
@@ -0,0 +1,72 @@
|
||||
BEGIN;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS miningcheck_cost_plans_legacy AS
|
||||
SELECT *
|
||||
FROM miningcheck_cost_plans
|
||||
WHERE 1 = 0;
|
||||
|
||||
INSERT INTO miningcheck_cost_plans_legacy
|
||||
SELECT cp.*
|
||||
FROM miningcheck_cost_plans cp
|
||||
LEFT JOIN miningcheck_cost_plans_legacy legacy
|
||||
ON legacy.id = cp.id
|
||||
WHERE legacy.id IS NULL;
|
||||
|
||||
INSERT INTO miningcheck_purchased_miners (
|
||||
project_key,
|
||||
miner_offer_id,
|
||||
purchased_at,
|
||||
label,
|
||||
runtime_months,
|
||||
mining_speed_value,
|
||||
mining_speed_unit,
|
||||
bonus_speed_value,
|
||||
bonus_speed_unit,
|
||||
total_cost_amount,
|
||||
currency,
|
||||
usd_reference_amount,
|
||||
reference_price_amount,
|
||||
reference_price_currency,
|
||||
auto_renew,
|
||||
note,
|
||||
is_active
|
||||
)
|
||||
SELECT
|
||||
cp.project_key,
|
||||
NULL AS miner_offer_id,
|
||||
cp.starts_at AS purchased_at,
|
||||
cp.label,
|
||||
cp.runtime_months,
|
||||
cp.mining_speed_value,
|
||||
cp.mining_speed_unit,
|
||||
cp.bonus_speed_value,
|
||||
cp.bonus_speed_unit,
|
||||
cp.total_cost_amount,
|
||||
cp.currency,
|
||||
CASE
|
||||
WHEN COALESCE(s.report_currency, 'EUR') = 'USD' THEN cp.base_price_amount
|
||||
ELSE NULL
|
||||
END AS usd_reference_amount,
|
||||
cp.base_price_amount AS reference_price_amount,
|
||||
COALESCE(s.report_currency, 'EUR') AS reference_price_currency,
|
||||
cp.auto_renew,
|
||||
CASE
|
||||
WHEN cp.note IS NULL OR BTRIM(cp.note) = '' THEN 'Migriert aus miningcheck_cost_plans'
|
||||
ELSE cp.note || ' | Migriert aus miningcheck_cost_plans'
|
||||
END AS note,
|
||||
cp.is_active
|
||||
FROM miningcheck_cost_plans cp
|
||||
LEFT JOIN miningcheck_settings s
|
||||
ON s.project_key = cp.project_key
|
||||
LEFT JOIN miningcheck_purchased_miners pm
|
||||
ON pm.project_key = cp.project_key
|
||||
AND pm.miner_offer_id IS NULL
|
||||
AND pm.purchased_at = cp.starts_at
|
||||
AND pm.label = cp.label
|
||||
AND pm.total_cost_amount = cp.total_cost_amount
|
||||
AND pm.currency = cp.currency
|
||||
WHERE pm.id IS NULL;
|
||||
|
||||
DELETE FROM miningcheck_cost_plans;
|
||||
|
||||
COMMIT;
|
||||
@@ -0,0 +1,15 @@
|
||||
BEGIN;
|
||||
|
||||
ALTER TABLE miningcheck_settings
|
||||
ADD COLUMN IF NOT EXISTS module_theme_mode VARCHAR(16),
|
||||
ADD COLUMN IF NOT EXISTS module_theme_accent VARCHAR(16);
|
||||
|
||||
UPDATE miningcheck_settings
|
||||
SET module_theme_mode = 'inherit'
|
||||
WHERE module_theme_mode IS NULL OR BTRIM(module_theme_mode) = '';
|
||||
|
||||
UPDATE miningcheck_settings
|
||||
SET module_theme_accent = 'teal'
|
||||
WHERE module_theme_accent IS NULL OR BTRIM(module_theme_accent) = '';
|
||||
|
||||
COMMIT;
|
||||
@@ -0,0 +1,182 @@
|
||||
-- Bestehende benutzerspezifische Mining-Daten werden diesem Keycloak-Sub zugeordnet:
|
||||
-- adea1766-5d1c-4c2e-98bd-5239861f745f
|
||||
-- Die Keycloak-Sub ist stabiler als preferred_username und wird fuer alle benutzerspezifischen Mining-Daten genutzt.
|
||||
|
||||
BEGIN;
|
||||
|
||||
ALTER TABLE miningcheck_settings
|
||||
ADD COLUMN IF NOT EXISTS owner_sub VARCHAR(128);
|
||||
|
||||
ALTER TABLE miningcheck_cost_plans
|
||||
ADD COLUMN IF NOT EXISTS owner_sub VARCHAR(128);
|
||||
|
||||
ALTER TABLE miningcheck_measurements
|
||||
ADD COLUMN IF NOT EXISTS owner_sub VARCHAR(128);
|
||||
|
||||
ALTER TABLE miningcheck_measurement_rates
|
||||
ADD COLUMN IF NOT EXISTS owner_sub VARCHAR(128);
|
||||
|
||||
ALTER TABLE miningcheck_payouts
|
||||
ADD COLUMN IF NOT EXISTS owner_sub VARCHAR(128);
|
||||
|
||||
ALTER TABLE miningcheck_targets
|
||||
ADD COLUMN IF NOT EXISTS owner_sub VARCHAR(128);
|
||||
|
||||
ALTER TABLE miningcheck_dashboard_definitions
|
||||
ADD COLUMN IF NOT EXISTS owner_sub VARCHAR(128);
|
||||
|
||||
ALTER TABLE miningcheck_purchased_miners
|
||||
ADD COLUMN IF NOT EXISTS owner_sub VARCHAR(128);
|
||||
|
||||
UPDATE miningcheck_settings
|
||||
SET owner_sub = 'adea1766-5d1c-4c2e-98bd-5239861f745f'
|
||||
WHERE owner_sub IS NULL OR BTRIM(owner_sub) = '';
|
||||
|
||||
UPDATE miningcheck_cost_plans
|
||||
SET owner_sub = 'adea1766-5d1c-4c2e-98bd-5239861f745f'
|
||||
WHERE owner_sub IS NULL OR BTRIM(owner_sub) = '';
|
||||
|
||||
UPDATE miningcheck_measurements
|
||||
SET owner_sub = 'adea1766-5d1c-4c2e-98bd-5239861f745f'
|
||||
WHERE owner_sub IS NULL OR BTRIM(owner_sub) = '';
|
||||
|
||||
UPDATE miningcheck_measurement_rates mr
|
||||
SET owner_sub = m.owner_sub
|
||||
FROM miningcheck_measurements m
|
||||
WHERE mr.measurement_id = m.id
|
||||
AND (mr.owner_sub IS NULL OR BTRIM(mr.owner_sub) = '');
|
||||
|
||||
UPDATE miningcheck_measurement_rates
|
||||
SET owner_sub = 'adea1766-5d1c-4c2e-98bd-5239861f745f'
|
||||
WHERE owner_sub IS NULL OR BTRIM(owner_sub) = '';
|
||||
|
||||
UPDATE miningcheck_payouts
|
||||
SET owner_sub = 'adea1766-5d1c-4c2e-98bd-5239861f745f'
|
||||
WHERE owner_sub IS NULL OR BTRIM(owner_sub) = '';
|
||||
|
||||
UPDATE miningcheck_targets
|
||||
SET owner_sub = 'adea1766-5d1c-4c2e-98bd-5239861f745f'
|
||||
WHERE owner_sub IS NULL OR BTRIM(owner_sub) = '';
|
||||
|
||||
UPDATE miningcheck_dashboard_definitions
|
||||
SET owner_sub = 'adea1766-5d1c-4c2e-98bd-5239861f745f'
|
||||
WHERE owner_sub IS NULL OR BTRIM(owner_sub) = '';
|
||||
|
||||
UPDATE miningcheck_purchased_miners
|
||||
SET owner_sub = 'adea1766-5d1c-4c2e-98bd-5239861f745f'
|
||||
WHERE owner_sub IS NULL OR BTRIM(owner_sub) = '';
|
||||
|
||||
ALTER TABLE miningcheck_settings
|
||||
ALTER COLUMN owner_sub SET NOT NULL;
|
||||
|
||||
ALTER TABLE miningcheck_cost_plans
|
||||
ALTER COLUMN owner_sub SET NOT NULL;
|
||||
|
||||
ALTER TABLE miningcheck_measurements
|
||||
ALTER COLUMN owner_sub SET NOT NULL;
|
||||
|
||||
ALTER TABLE miningcheck_measurement_rates
|
||||
ALTER COLUMN owner_sub SET NOT NULL;
|
||||
|
||||
ALTER TABLE miningcheck_payouts
|
||||
ALTER COLUMN owner_sub SET NOT NULL;
|
||||
|
||||
ALTER TABLE miningcheck_targets
|
||||
ALTER COLUMN owner_sub SET NOT NULL;
|
||||
|
||||
ALTER TABLE miningcheck_dashboard_definitions
|
||||
ALTER COLUMN owner_sub SET NOT NULL;
|
||||
|
||||
ALTER TABLE miningcheck_purchased_miners
|
||||
ALTER COLUMN owner_sub SET NOT NULL;
|
||||
|
||||
ALTER TABLE miningcheck_settings
|
||||
DROP CONSTRAINT IF EXISTS miningcheck_settings_project_key_key;
|
||||
|
||||
ALTER TABLE miningcheck_measurements
|
||||
DROP CONSTRAINT IF EXISTS uq_mining_measurements_unique;
|
||||
|
||||
ALTER TABLE miningcheck_targets
|
||||
DROP CONSTRAINT IF EXISTS uq_mining_targets_project_label;
|
||||
|
||||
ALTER TABLE miningcheck_dashboard_definitions
|
||||
DROP CONSTRAINT IF EXISTS uq_mining_dashboards_project_name;
|
||||
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM information_schema.table_constraints
|
||||
WHERE table_schema = current_schema()
|
||||
AND table_name = 'miningcheck_settings'
|
||||
AND constraint_name = 'uq_mining_settings_project_owner'
|
||||
) THEN
|
||||
ALTER TABLE miningcheck_settings
|
||||
ADD CONSTRAINT uq_mining_settings_project_owner UNIQUE (project_key, owner_sub);
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM information_schema.table_constraints
|
||||
WHERE table_schema = current_schema()
|
||||
AND table_name = 'miningcheck_measurements'
|
||||
AND constraint_name = 'uq_mining_measurements_unique'
|
||||
) THEN
|
||||
ALTER TABLE miningcheck_measurements
|
||||
ADD CONSTRAINT uq_mining_measurements_unique UNIQUE (project_key, owner_sub, measured_at, coins_total);
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM information_schema.table_constraints
|
||||
WHERE table_schema = current_schema()
|
||||
AND table_name = 'miningcheck_targets'
|
||||
AND constraint_name = 'uq_mining_targets_project_label'
|
||||
) THEN
|
||||
ALTER TABLE miningcheck_targets
|
||||
ADD CONSTRAINT uq_mining_targets_project_label UNIQUE (project_key, owner_sub, label);
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM information_schema.table_constraints
|
||||
WHERE table_schema = current_schema()
|
||||
AND table_name = 'miningcheck_dashboard_definitions'
|
||||
AND constraint_name = 'uq_mining_dashboards_project_name'
|
||||
) THEN
|
||||
ALTER TABLE miningcheck_dashboard_definitions
|
||||
ADD CONSTRAINT uq_mining_dashboards_project_name UNIQUE (project_key, owner_sub, name);
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_miningcheck_cost_plans_project_owner_start
|
||||
ON miningcheck_cost_plans(project_key, owner_sub, starts_at);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_miningcheck_measurements_project_owner_measured_at
|
||||
ON miningcheck_measurements(project_key, owner_sub, measured_at);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_miningcheck_measurement_rates_project_owner_measurement
|
||||
ON miningcheck_measurement_rates(project_key, owner_sub, measurement_id);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_miningcheck_payouts_project_owner_payout_at
|
||||
ON miningcheck_payouts(project_key, owner_sub, payout_at);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_miningcheck_targets_project_owner
|
||||
ON miningcheck_targets(project_key, owner_sub, sort_order, id);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_miningcheck_dashboards_project_owner
|
||||
ON miningcheck_dashboard_definitions(project_key, owner_sub, id);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_miningcheck_purchased_miners_project_owner_purchased_at
|
||||
ON miningcheck_purchased_miners(project_key, owner_sub, purchased_at);
|
||||
|
||||
COMMIT;
|
||||
223
modules/mining-checker/sql/schema.mysql.sql
Normal file
223
modules/mining-checker/sql/schema.mysql.sql
Normal file
@@ -0,0 +1,223 @@
|
||||
CREATE TABLE IF NOT EXISTS miningcheck_projects (
|
||||
project_key VARCHAR(64) NOT NULL PRIMARY KEY,
|
||||
project_name VARCHAR(160) NOT NULL,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS miningcheck_settings (
|
||||
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
|
||||
project_key VARCHAR(64) NOT NULL,
|
||||
baseline_measured_at DATETIME NOT NULL,
|
||||
baseline_coins_total DECIMAL(20,6) NOT NULL,
|
||||
daily_cost_amount DECIMAL(20,10) NOT NULL,
|
||||
daily_cost_currency VARCHAR(10) NOT NULL,
|
||||
report_currency VARCHAR(10) NULL,
|
||||
crypto_currency VARCHAR(10) NULL,
|
||||
display_timezone VARCHAR(64) NULL,
|
||||
fx_max_age_hours DECIMAL(10,2) NULL,
|
||||
module_theme_mode VARCHAR(16) NULL,
|
||||
module_theme_accent VARCHAR(16) NULL,
|
||||
preferred_currencies JSON NULL,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
CONSTRAINT fk_mining_settings_project FOREIGN KEY (project_key) REFERENCES miningcheck_projects(project_key) ON DELETE CASCADE,
|
||||
CONSTRAINT uq_mining_settings_project UNIQUE (project_key)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS miningcheck_cost_plans (
|
||||
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
|
||||
project_key VARCHAR(64) NOT NULL,
|
||||
label VARCHAR(120) NOT NULL,
|
||||
starts_at DATETIME NOT NULL,
|
||||
runtime_months INT NOT NULL,
|
||||
mining_speed_value DECIMAL(20,4) NULL,
|
||||
mining_speed_unit VARCHAR(8) NULL,
|
||||
bonus_speed_value DECIMAL(20,4) NULL,
|
||||
bonus_speed_unit VARCHAR(8) NULL,
|
||||
auto_renew TINYINT(1) NOT NULL DEFAULT 0,
|
||||
base_price_amount DECIMAL(20,8) NULL,
|
||||
payment_type VARCHAR(10) NOT NULL DEFAULT 'fiat',
|
||||
total_cost_amount DECIMAL(20,8) NOT NULL,
|
||||
currency VARCHAR(10) NOT NULL,
|
||||
note TEXT NULL,
|
||||
is_active TINYINT(1) NOT NULL DEFAULT 1,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
CONSTRAINT fk_mining_cost_plans_project FOREIGN KEY (project_key) REFERENCES miningcheck_projects(project_key) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE INDEX idx_miningcheck_cost_plans_project_start
|
||||
ON miningcheck_cost_plans(project_key, starts_at);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS miningcheck_measurements (
|
||||
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
|
||||
project_key VARCHAR(64) NOT NULL,
|
||||
measured_at DATETIME NOT NULL,
|
||||
coins_total DECIMAL(20,6) NOT NULL,
|
||||
coin_currency VARCHAR(10) NOT NULL DEFAULT 'DOGE',
|
||||
price_per_coin DECIMAL(20,8) NULL,
|
||||
price_currency VARCHAR(10) NULL,
|
||||
fx_fetch_id BIGINT UNSIGNED NULL,
|
||||
note TEXT NULL,
|
||||
source ENUM('manual', 'image_ocr', 'seed_import') NOT NULL,
|
||||
image_path VARCHAR(255) NULL,
|
||||
ocr_raw_text MEDIUMTEXT NULL,
|
||||
ocr_confidence DECIMAL(6,4) NULL,
|
||||
ocr_flags JSON NULL,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
CONSTRAINT fk_mining_measurements_project FOREIGN KEY (project_key) REFERENCES miningcheck_projects(project_key) ON DELETE CASCADE,
|
||||
CONSTRAINT uq_mining_measurements_unique UNIQUE (project_key, measured_at, coins_total)
|
||||
);
|
||||
|
||||
CREATE INDEX idx_miningcheck_measurements_project_measured_at
|
||||
ON miningcheck_measurements(project_key, measured_at);
|
||||
|
||||
CREATE INDEX idx_miningcheck_measurements_fx_fetch
|
||||
ON miningcheck_measurements(fx_fetch_id);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS miningcheck_measurement_rates (
|
||||
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
|
||||
measurement_id BIGINT UNSIGNED NOT NULL,
|
||||
project_key VARCHAR(64) NOT NULL,
|
||||
base_currency VARCHAR(10) NOT NULL,
|
||||
quote_currency VARCHAR(10) NOT NULL,
|
||||
rate DECIMAL(20,10) NOT NULL,
|
||||
provider VARCHAR(32) NOT NULL DEFAULT 'derived',
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
CONSTRAINT fk_mining_measurement_rates_measurement FOREIGN KEY (measurement_id) REFERENCES miningcheck_measurements(id) ON DELETE CASCADE,
|
||||
CONSTRAINT uq_mining_measurement_rate_pair UNIQUE (measurement_id, base_currency, quote_currency),
|
||||
KEY idx_miningcheck_measurement_rates_project_measurement (project_key, measurement_id)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS miningcheck_payouts (
|
||||
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
|
||||
project_key VARCHAR(64) NOT NULL,
|
||||
payout_at TIMESTAMP NOT NULL,
|
||||
coins_amount DECIMAL(20,6) NOT NULL,
|
||||
payout_currency VARCHAR(10) NOT NULL DEFAULT 'DOGE',
|
||||
note TEXT,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
CONSTRAINT fk_mining_payouts_project FOREIGN KEY (project_key) REFERENCES miningcheck_projects(project_key) ON DELETE CASCADE,
|
||||
KEY idx_miningcheck_payouts_project_payout_at (project_key, payout_at)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS miningcheck_wallet_snapshots (
|
||||
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
|
||||
project_key VARCHAR(64) NOT NULL,
|
||||
owner_sub VARCHAR(128) NOT NULL,
|
||||
measured_at TIMESTAMP NOT NULL,
|
||||
total_value_amount DECIMAL(20,8) NULL,
|
||||
total_value_currency VARCHAR(10) NULL,
|
||||
wallet_balance DECIMAL(28,10) NULL,
|
||||
wallet_currency VARCHAR(10) NOT NULL,
|
||||
balances_json JSON NULL,
|
||||
note TEXT NULL,
|
||||
source ENUM('manual', 'image_ocr', 'seed_import') NOT NULL,
|
||||
image_path VARCHAR(255) NULL,
|
||||
ocr_raw_text MEDIUMTEXT NULL,
|
||||
ocr_confidence DECIMAL(6,4) NULL,
|
||||
ocr_flags JSON NULL,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
CONSTRAINT fk_mining_wallet_snapshots_project FOREIGN KEY (project_key) REFERENCES miningcheck_projects(project_key) ON DELETE CASCADE,
|
||||
KEY idx_miningcheck_wallet_snapshots_project_measured_at (project_key, owner_sub, measured_at)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS miningcheck_miner_offers (
|
||||
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
|
||||
project_key VARCHAR(64) NOT NULL,
|
||||
label VARCHAR(120) NOT NULL,
|
||||
runtime_months INT NULL,
|
||||
mining_speed_value DECIMAL(20,4) NULL,
|
||||
mining_speed_unit VARCHAR(8) NULL,
|
||||
bonus_speed_value DECIMAL(20,4) NULL,
|
||||
bonus_speed_unit VARCHAR(8) NULL,
|
||||
base_price_amount DECIMAL(20,8) NOT NULL,
|
||||
base_price_currency VARCHAR(10) NOT NULL,
|
||||
payment_type VARCHAR(10) NOT NULL DEFAULT 'fiat',
|
||||
auto_renew TINYINT(1) NOT NULL DEFAULT 0,
|
||||
note TEXT,
|
||||
is_active TINYINT(1) NOT NULL DEFAULT 1,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
CONSTRAINT fk_mining_miner_offers_project FOREIGN KEY (project_key) REFERENCES miningcheck_projects(project_key) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS miningcheck_targets (
|
||||
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
|
||||
project_key VARCHAR(64) NOT NULL,
|
||||
label VARCHAR(120) NOT NULL,
|
||||
target_amount_fiat DECIMAL(20,2) NOT NULL,
|
||||
currency VARCHAR(10) NOT NULL,
|
||||
miner_offer_id BIGINT UNSIGNED NULL,
|
||||
is_active TINYINT(1) NOT NULL DEFAULT 1,
|
||||
sort_order INT NOT NULL DEFAULT 0,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
CONSTRAINT fk_mining_targets_project FOREIGN KEY (project_key) REFERENCES miningcheck_projects(project_key) ON DELETE CASCADE,
|
||||
CONSTRAINT fk_mining_targets_offer FOREIGN KEY (miner_offer_id) REFERENCES miningcheck_miner_offers(id) ON DELETE SET NULL,
|
||||
CONSTRAINT uq_mining_targets_project_label UNIQUE (project_key, label)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS miningcheck_dashboard_definitions (
|
||||
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
|
||||
project_key VARCHAR(64) NOT NULL,
|
||||
name VARCHAR(160) NOT NULL,
|
||||
chart_type ENUM('line', 'bar', 'area', 'table') NOT NULL,
|
||||
x_field VARCHAR(64) NOT NULL,
|
||||
y_field VARCHAR(64) NOT NULL,
|
||||
aggregation VARCHAR(32) NOT NULL DEFAULT 'none',
|
||||
filters_json JSON NULL,
|
||||
is_active TINYINT(1) NOT NULL DEFAULT 1,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
CONSTRAINT fk_mining_dashboards_project FOREIGN KEY (project_key) REFERENCES miningcheck_projects(project_key) ON DELETE CASCADE,
|
||||
CONSTRAINT uq_mining_dashboards_project_name UNIQUE (project_key, name)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS miningcheck_purchased_miners (
|
||||
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
|
||||
project_key VARCHAR(64) NOT NULL,
|
||||
miner_offer_id BIGINT UNSIGNED NULL,
|
||||
purchased_at TIMESTAMP NOT NULL,
|
||||
label VARCHAR(120) NOT NULL,
|
||||
runtime_months INT NULL,
|
||||
mining_speed_value DECIMAL(20,4) NULL,
|
||||
mining_speed_unit VARCHAR(8) NULL,
|
||||
bonus_speed_value DECIMAL(20,4) NULL,
|
||||
bonus_speed_unit VARCHAR(8) NULL,
|
||||
total_cost_amount DECIMAL(20,8) NOT NULL,
|
||||
currency VARCHAR(10) NOT NULL,
|
||||
usd_reference_amount DECIMAL(20,8) NULL,
|
||||
reference_price_amount DECIMAL(20,8) NULL,
|
||||
reference_price_currency VARCHAR(10) NULL,
|
||||
auto_renew TINYINT(1) NOT NULL DEFAULT 0,
|
||||
note TEXT,
|
||||
is_active TINYINT(1) NOT NULL DEFAULT 1,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
CONSTRAINT fk_mining_purchased_miners_project FOREIGN KEY (project_key) REFERENCES miningcheck_projects(project_key) ON DELETE CASCADE,
|
||||
CONSTRAINT fk_mining_purchased_miners_offer FOREIGN KEY (miner_offer_id) REFERENCES miningcheck_miner_offers(id) ON DELETE SET NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS miningcheck_fx_fetches (
|
||||
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
|
||||
provider VARCHAR(32) NOT NULL DEFAULT 'currencyapi',
|
||||
base_currency VARCHAR(10) NOT NULL,
|
||||
rate_date DATE NOT NULL,
|
||||
fetched_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
KEY idx_miningcheck_fx_fetches_base_fetched (base_currency, fetched_at)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS miningcheck_fx_rates (
|
||||
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
|
||||
fetch_id BIGINT UNSIGNED NOT NULL,
|
||||
currency_code VARCHAR(10) NOT NULL,
|
||||
current_value DECIMAL(20,10) NOT NULL,
|
||||
KEY idx_miningcheck_fx_rates_fetch (fetch_id),
|
||||
KEY idx_miningcheck_fx_rates_currency (currency_code),
|
||||
CONSTRAINT fk_mining_fx_rates_fetch FOREIGN KEY (fetch_id) REFERENCES miningcheck_fx_fetches(id) ON DELETE CASCADE
|
||||
);
|
||||
243
modules/mining-checker/sql/schema.pgsql.sql
Normal file
243
modules/mining-checker/sql/schema.pgsql.sql
Normal file
@@ -0,0 +1,243 @@
|
||||
CREATE TABLE IF NOT EXISTS miningcheck_projects (
|
||||
project_key VARCHAR(64) PRIMARY KEY,
|
||||
project_name VARCHAR(160) NOT NULL,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS miningcheck_settings (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
project_key VARCHAR(64) NOT NULL,
|
||||
owner_sub VARCHAR(128) NOT NULL,
|
||||
baseline_measured_at TIMESTAMP NOT NULL,
|
||||
baseline_coins_total NUMERIC(20,6) NOT NULL,
|
||||
daily_cost_amount NUMERIC(20,10) NOT NULL,
|
||||
daily_cost_currency VARCHAR(10) NOT NULL,
|
||||
report_currency VARCHAR(10),
|
||||
crypto_currency VARCHAR(10),
|
||||
display_timezone VARCHAR(64),
|
||||
fx_max_age_hours NUMERIC(10,2),
|
||||
module_theme_mode VARCHAR(16),
|
||||
module_theme_accent VARCHAR(16),
|
||||
preferred_currencies JSONB,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
CONSTRAINT fk_mining_settings_project FOREIGN KEY (project_key) REFERENCES miningcheck_projects(project_key) ON DELETE CASCADE,
|
||||
CONSTRAINT uq_mining_settings_project_owner UNIQUE (project_key, owner_sub)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS miningcheck_cost_plans (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
project_key VARCHAR(64) NOT NULL,
|
||||
owner_sub VARCHAR(128) NOT NULL,
|
||||
label VARCHAR(120) NOT NULL,
|
||||
starts_at TIMESTAMP NOT NULL,
|
||||
runtime_months INTEGER NOT NULL,
|
||||
mining_speed_value NUMERIC(20,4),
|
||||
mining_speed_unit VARCHAR(8),
|
||||
bonus_speed_value NUMERIC(20,4),
|
||||
bonus_speed_unit VARCHAR(8),
|
||||
auto_renew BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
base_price_amount NUMERIC(20,8),
|
||||
payment_type VARCHAR(10) NOT NULL DEFAULT 'fiat',
|
||||
total_cost_amount NUMERIC(20,8) NOT NULL,
|
||||
currency VARCHAR(10) NOT NULL,
|
||||
note TEXT,
|
||||
is_active BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
CONSTRAINT fk_mining_cost_plans_project FOREIGN KEY (project_key) REFERENCES miningcheck_projects(project_key) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_miningcheck_cost_plans_project_start
|
||||
ON miningcheck_cost_plans(project_key, owner_sub, starts_at);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS miningcheck_measurements (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
project_key VARCHAR(64) NOT NULL,
|
||||
owner_sub VARCHAR(128) NOT NULL,
|
||||
measured_at TIMESTAMP NOT NULL,
|
||||
coins_total NUMERIC(20,6) NOT NULL,
|
||||
coin_currency VARCHAR(10) NOT NULL DEFAULT 'DOGE',
|
||||
price_per_coin NUMERIC(20,8),
|
||||
price_currency VARCHAR(10),
|
||||
fx_fetch_id BIGINT,
|
||||
note TEXT,
|
||||
source VARCHAR(16) NOT NULL CHECK (source IN ('manual', 'image_ocr', 'seed_import')),
|
||||
image_path VARCHAR(255),
|
||||
ocr_raw_text TEXT,
|
||||
ocr_confidence NUMERIC(6,4),
|
||||
ocr_flags JSONB,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
CONSTRAINT fk_mining_measurements_project FOREIGN KEY (project_key) REFERENCES miningcheck_projects(project_key) ON DELETE CASCADE,
|
||||
CONSTRAINT uq_mining_measurements_unique UNIQUE (project_key, owner_sub, measured_at, coins_total)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_miningcheck_measurements_project_measured_at
|
||||
ON miningcheck_measurements(project_key, owner_sub, measured_at);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_miningcheck_measurements_fx_fetch
|
||||
ON miningcheck_measurements(fx_fetch_id);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS miningcheck_measurement_rates (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
measurement_id BIGINT NOT NULL,
|
||||
project_key VARCHAR(64) NOT NULL,
|
||||
owner_sub VARCHAR(128) NOT NULL,
|
||||
base_currency VARCHAR(10) NOT NULL,
|
||||
quote_currency VARCHAR(10) NOT NULL,
|
||||
rate NUMERIC(20,10) NOT NULL,
|
||||
provider VARCHAR(32) NOT NULL DEFAULT 'derived',
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
CONSTRAINT fk_mining_measurement_rates_measurement FOREIGN KEY (measurement_id) REFERENCES miningcheck_measurements(id) ON DELETE CASCADE,
|
||||
CONSTRAINT uq_mining_measurement_rate_pair UNIQUE (measurement_id, base_currency, quote_currency)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_miningcheck_measurement_rates_project_measurement
|
||||
ON miningcheck_measurement_rates(project_key, owner_sub, measurement_id);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS miningcheck_payouts (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
project_key VARCHAR(64) NOT NULL,
|
||||
owner_sub VARCHAR(128) NOT NULL,
|
||||
payout_at TIMESTAMP NOT NULL,
|
||||
coins_amount NUMERIC(20,6) NOT NULL,
|
||||
payout_currency VARCHAR(10) NOT NULL DEFAULT 'DOGE',
|
||||
note TEXT,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
CONSTRAINT fk_mining_payouts_project FOREIGN KEY (project_key) REFERENCES miningcheck_projects(project_key) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_miningcheck_payouts_project_payout_at
|
||||
ON miningcheck_payouts(project_key, owner_sub, payout_at);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS miningcheck_wallet_snapshots (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
project_key VARCHAR(64) NOT NULL,
|
||||
owner_sub VARCHAR(128) NOT NULL,
|
||||
measured_at TIMESTAMP NOT NULL,
|
||||
total_value_amount NUMERIC(20,8),
|
||||
total_value_currency VARCHAR(10),
|
||||
wallet_balance NUMERIC(28,10),
|
||||
wallet_currency VARCHAR(10) NOT NULL,
|
||||
balances_json JSONB,
|
||||
note TEXT,
|
||||
source VARCHAR(16) NOT NULL CHECK (source IN ('manual', 'image_ocr', 'seed_import')),
|
||||
image_path VARCHAR(255),
|
||||
ocr_raw_text TEXT,
|
||||
ocr_confidence NUMERIC(6,4),
|
||||
ocr_flags JSONB,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
CONSTRAINT fk_mining_wallet_snapshots_project FOREIGN KEY (project_key) REFERENCES miningcheck_projects(project_key) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_miningcheck_wallet_snapshots_project_measured_at
|
||||
ON miningcheck_wallet_snapshots(project_key, owner_sub, measured_at);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS miningcheck_miner_offers (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
project_key VARCHAR(64) NOT NULL,
|
||||
label VARCHAR(120) NOT NULL,
|
||||
runtime_months INTEGER,
|
||||
mining_speed_value NUMERIC(20,4),
|
||||
mining_speed_unit VARCHAR(8),
|
||||
bonus_speed_value NUMERIC(20,4),
|
||||
bonus_speed_unit VARCHAR(8),
|
||||
base_price_amount NUMERIC(20,8) NOT NULL,
|
||||
base_price_currency VARCHAR(10) NOT NULL,
|
||||
payment_type VARCHAR(10) NOT NULL DEFAULT 'fiat',
|
||||
auto_renew BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
note TEXT,
|
||||
is_active BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
CONSTRAINT fk_mining_miner_offers_project FOREIGN KEY (project_key) REFERENCES miningcheck_projects(project_key) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS miningcheck_targets (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
project_key VARCHAR(64) NOT NULL,
|
||||
owner_sub VARCHAR(128) NOT NULL,
|
||||
label VARCHAR(120) NOT NULL,
|
||||
target_amount_fiat NUMERIC(20,2) NOT NULL,
|
||||
currency VARCHAR(10) NOT NULL,
|
||||
miner_offer_id BIGINT,
|
||||
is_active BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
sort_order INTEGER NOT NULL DEFAULT 0,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
CONSTRAINT fk_mining_targets_project FOREIGN KEY (project_key) REFERENCES miningcheck_projects(project_key) ON DELETE CASCADE,
|
||||
CONSTRAINT fk_mining_targets_offer FOREIGN KEY (miner_offer_id) REFERENCES miningcheck_miner_offers(id) ON DELETE SET NULL,
|
||||
CONSTRAINT uq_mining_targets_project_label UNIQUE (project_key, owner_sub, label)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS miningcheck_dashboard_definitions (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
project_key VARCHAR(64) NOT NULL,
|
||||
owner_sub VARCHAR(128) NOT NULL,
|
||||
name VARCHAR(160) NOT NULL,
|
||||
chart_type VARCHAR(16) NOT NULL CHECK (chart_type IN ('line', 'bar', 'area', 'table')),
|
||||
x_field VARCHAR(64) NOT NULL,
|
||||
y_field VARCHAR(64) NOT NULL,
|
||||
aggregation VARCHAR(32) NOT NULL DEFAULT 'none',
|
||||
filters_json JSONB,
|
||||
is_active BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
CONSTRAINT fk_mining_dashboards_project FOREIGN KEY (project_key) REFERENCES miningcheck_projects(project_key) ON DELETE CASCADE,
|
||||
CONSTRAINT uq_mining_dashboards_project_name UNIQUE (project_key, owner_sub, name)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS miningcheck_purchased_miners (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
project_key VARCHAR(64) NOT NULL,
|
||||
owner_sub VARCHAR(128) NOT NULL,
|
||||
miner_offer_id BIGINT,
|
||||
purchased_at TIMESTAMP NOT NULL,
|
||||
label VARCHAR(120) NOT NULL,
|
||||
runtime_months INTEGER,
|
||||
mining_speed_value NUMERIC(20,4),
|
||||
mining_speed_unit VARCHAR(8),
|
||||
bonus_speed_value NUMERIC(20,4),
|
||||
bonus_speed_unit VARCHAR(8),
|
||||
total_cost_amount NUMERIC(20,8) NOT NULL,
|
||||
currency VARCHAR(10) NOT NULL,
|
||||
usd_reference_amount NUMERIC(20,8),
|
||||
reference_price_amount NUMERIC(20,8),
|
||||
reference_price_currency VARCHAR(10),
|
||||
auto_renew BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
note TEXT,
|
||||
is_active BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
CONSTRAINT fk_mining_purchased_miners_project FOREIGN KEY (project_key) REFERENCES miningcheck_projects(project_key) ON DELETE CASCADE,
|
||||
CONSTRAINT fk_mining_purchased_miners_offer FOREIGN KEY (miner_offer_id) REFERENCES miningcheck_miner_offers(id) ON DELETE SET NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS miningcheck_fx_fetches (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
provider VARCHAR(32) NOT NULL DEFAULT 'currencyapi',
|
||||
base_currency VARCHAR(10) NOT NULL,
|
||||
rate_date DATE NOT NULL,
|
||||
fetched_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_miningcheck_fx_fetches_base_fetched
|
||||
ON miningcheck_fx_fetches(base_currency, fetched_at);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS miningcheck_fx_rates (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
fetch_id BIGINT NOT NULL,
|
||||
currency_code VARCHAR(10) NOT NULL,
|
||||
current_value NUMERIC(20,10) NOT NULL,
|
||||
CONSTRAINT fk_mining_fx_rates_fetch FOREIGN KEY (fetch_id) REFERENCES miningcheck_fx_fetches(id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_miningcheck_fx_rates_fetch
|
||||
ON miningcheck_fx_rates(fetch_id);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_miningcheck_fx_rates_currency
|
||||
ON miningcheck_fx_rates(currency_code);
|
||||
223
modules/mining-checker/sql/schema.sql
Normal file
223
modules/mining-checker/sql/schema.sql
Normal file
@@ -0,0 +1,223 @@
|
||||
CREATE TABLE IF NOT EXISTS miningcheck_projects (
|
||||
project_key VARCHAR(64) NOT NULL PRIMARY KEY,
|
||||
project_name VARCHAR(160) NOT NULL,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS miningcheck_settings (
|
||||
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
|
||||
project_key VARCHAR(64) NOT NULL,
|
||||
baseline_measured_at DATETIME NOT NULL,
|
||||
baseline_coins_total DECIMAL(20,6) NOT NULL,
|
||||
daily_cost_amount DECIMAL(20,10) NOT NULL,
|
||||
daily_cost_currency VARCHAR(10) NOT NULL,
|
||||
report_currency VARCHAR(10) NULL,
|
||||
crypto_currency VARCHAR(10) NULL,
|
||||
display_timezone VARCHAR(64) NULL,
|
||||
fx_max_age_hours DECIMAL(10,2) NULL,
|
||||
module_theme_mode VARCHAR(16) NULL,
|
||||
module_theme_accent VARCHAR(16) NULL,
|
||||
preferred_currencies JSON NULL,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
CONSTRAINT fk_mining_settings_project FOREIGN KEY (project_key) REFERENCES miningcheck_projects(project_key) ON DELETE CASCADE,
|
||||
CONSTRAINT uq_mining_settings_project UNIQUE (project_key)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS miningcheck_cost_plans (
|
||||
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
|
||||
project_key VARCHAR(64) NOT NULL,
|
||||
label VARCHAR(120) NOT NULL,
|
||||
starts_at DATETIME NOT NULL,
|
||||
runtime_months INT NOT NULL,
|
||||
mining_speed_value DECIMAL(20,4) NULL,
|
||||
mining_speed_unit VARCHAR(8) NULL,
|
||||
bonus_speed_value DECIMAL(20,4) NULL,
|
||||
bonus_speed_unit VARCHAR(8) NULL,
|
||||
auto_renew TINYINT(1) NOT NULL DEFAULT 0,
|
||||
base_price_amount DECIMAL(20,8) NULL,
|
||||
payment_type VARCHAR(10) NOT NULL DEFAULT 'fiat',
|
||||
total_cost_amount DECIMAL(20,8) NOT NULL,
|
||||
currency VARCHAR(10) NOT NULL,
|
||||
note TEXT NULL,
|
||||
is_active TINYINT(1) NOT NULL DEFAULT 1,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
CONSTRAINT fk_mining_cost_plans_project FOREIGN KEY (project_key) REFERENCES miningcheck_projects(project_key) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE INDEX idx_miningcheck_cost_plans_project_start
|
||||
ON miningcheck_cost_plans(project_key, starts_at);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS miningcheck_measurements (
|
||||
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
|
||||
project_key VARCHAR(64) NOT NULL,
|
||||
measured_at DATETIME NOT NULL,
|
||||
coins_total DECIMAL(20,6) NOT NULL,
|
||||
coin_currency VARCHAR(10) NOT NULL DEFAULT 'DOGE',
|
||||
price_per_coin DECIMAL(20,8) NULL,
|
||||
price_currency VARCHAR(10) NULL,
|
||||
fx_fetch_id BIGINT UNSIGNED NULL,
|
||||
note TEXT NULL,
|
||||
source ENUM('manual', 'image_ocr', 'seed_import') NOT NULL,
|
||||
image_path VARCHAR(255) NULL,
|
||||
ocr_raw_text MEDIUMTEXT NULL,
|
||||
ocr_confidence DECIMAL(6,4) NULL,
|
||||
ocr_flags JSON NULL,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
CONSTRAINT fk_mining_measurements_project FOREIGN KEY (project_key) REFERENCES miningcheck_projects(project_key) ON DELETE CASCADE,
|
||||
CONSTRAINT uq_mining_measurements_unique UNIQUE (project_key, measured_at, coins_total)
|
||||
);
|
||||
|
||||
CREATE INDEX idx_miningcheck_measurements_project_measured_at
|
||||
ON miningcheck_measurements(project_key, measured_at);
|
||||
|
||||
CREATE INDEX idx_miningcheck_measurements_fx_fetch
|
||||
ON miningcheck_measurements(fx_fetch_id);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS miningcheck_measurement_rates (
|
||||
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
|
||||
measurement_id BIGINT UNSIGNED NOT NULL,
|
||||
project_key VARCHAR(64) NOT NULL,
|
||||
base_currency VARCHAR(10) NOT NULL,
|
||||
quote_currency VARCHAR(10) NOT NULL,
|
||||
rate DECIMAL(20,10) NOT NULL,
|
||||
provider VARCHAR(32) NOT NULL DEFAULT 'derived',
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
CONSTRAINT fk_mining_measurement_rates_measurement FOREIGN KEY (measurement_id) REFERENCES miningcheck_measurements(id) ON DELETE CASCADE,
|
||||
CONSTRAINT uq_mining_measurement_rate_pair UNIQUE (measurement_id, base_currency, quote_currency),
|
||||
KEY idx_miningcheck_measurement_rates_project_measurement (project_key, measurement_id)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS miningcheck_payouts (
|
||||
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
|
||||
project_key VARCHAR(64) NOT NULL,
|
||||
payout_at TIMESTAMP NOT NULL,
|
||||
coins_amount DECIMAL(20,6) NOT NULL,
|
||||
payout_currency VARCHAR(10) NOT NULL DEFAULT 'DOGE',
|
||||
note TEXT,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
CONSTRAINT fk_mining_payouts_project FOREIGN KEY (project_key) REFERENCES miningcheck_projects(project_key) ON DELETE CASCADE,
|
||||
KEY idx_miningcheck_payouts_project_payout_at (project_key, payout_at)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS miningcheck_wallet_snapshots (
|
||||
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
|
||||
project_key VARCHAR(64) NOT NULL,
|
||||
owner_sub VARCHAR(128) NOT NULL,
|
||||
measured_at TIMESTAMP NOT NULL,
|
||||
total_value_amount DECIMAL(20,8) NULL,
|
||||
total_value_currency VARCHAR(10) NULL,
|
||||
wallet_balance DECIMAL(28,10) NULL,
|
||||
wallet_currency VARCHAR(10) NOT NULL,
|
||||
balances_json JSON NULL,
|
||||
note TEXT NULL,
|
||||
source ENUM('manual', 'image_ocr', 'seed_import') NOT NULL,
|
||||
image_path VARCHAR(255) NULL,
|
||||
ocr_raw_text MEDIUMTEXT NULL,
|
||||
ocr_confidence DECIMAL(6,4) NULL,
|
||||
ocr_flags JSON NULL,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
CONSTRAINT fk_mining_wallet_snapshots_project FOREIGN KEY (project_key) REFERENCES miningcheck_projects(project_key) ON DELETE CASCADE,
|
||||
KEY idx_miningcheck_wallet_snapshots_project_measured_at (project_key, owner_sub, measured_at)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS miningcheck_miner_offers (
|
||||
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
|
||||
project_key VARCHAR(64) NOT NULL,
|
||||
label VARCHAR(120) NOT NULL,
|
||||
runtime_months INT NULL,
|
||||
mining_speed_value DECIMAL(20,4) NULL,
|
||||
mining_speed_unit VARCHAR(8) NULL,
|
||||
bonus_speed_value DECIMAL(20,4) NULL,
|
||||
bonus_speed_unit VARCHAR(8) NULL,
|
||||
base_price_amount DECIMAL(20,8) NOT NULL,
|
||||
base_price_currency VARCHAR(10) NOT NULL,
|
||||
payment_type VARCHAR(10) NOT NULL DEFAULT 'fiat',
|
||||
auto_renew TINYINT(1) NOT NULL DEFAULT 0,
|
||||
note TEXT,
|
||||
is_active TINYINT(1) NOT NULL DEFAULT 1,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
CONSTRAINT fk_mining_miner_offers_project FOREIGN KEY (project_key) REFERENCES miningcheck_projects(project_key) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS miningcheck_targets (
|
||||
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
|
||||
project_key VARCHAR(64) NOT NULL,
|
||||
label VARCHAR(120) NOT NULL,
|
||||
target_amount_fiat DECIMAL(20,2) NOT NULL,
|
||||
currency VARCHAR(10) NOT NULL,
|
||||
miner_offer_id BIGINT UNSIGNED NULL,
|
||||
is_active TINYINT(1) NOT NULL DEFAULT 1,
|
||||
sort_order INT NOT NULL DEFAULT 0,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
CONSTRAINT fk_mining_targets_project FOREIGN KEY (project_key) REFERENCES miningcheck_projects(project_key) ON DELETE CASCADE,
|
||||
CONSTRAINT fk_mining_targets_offer FOREIGN KEY (miner_offer_id) REFERENCES miningcheck_miner_offers(id) ON DELETE SET NULL,
|
||||
CONSTRAINT uq_mining_targets_project_label UNIQUE (project_key, label)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS miningcheck_dashboard_definitions (
|
||||
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
|
||||
project_key VARCHAR(64) NOT NULL,
|
||||
name VARCHAR(160) NOT NULL,
|
||||
chart_type ENUM('line', 'bar', 'area', 'table') NOT NULL,
|
||||
x_field VARCHAR(64) NOT NULL,
|
||||
y_field VARCHAR(64) NOT NULL,
|
||||
aggregation VARCHAR(32) NOT NULL DEFAULT 'none',
|
||||
filters_json JSON NULL,
|
||||
is_active TINYINT(1) NOT NULL DEFAULT 1,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
CONSTRAINT fk_mining_dashboards_project FOREIGN KEY (project_key) REFERENCES miningcheck_projects(project_key) ON DELETE CASCADE,
|
||||
CONSTRAINT uq_mining_dashboards_project_name UNIQUE (project_key, name)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS miningcheck_purchased_miners (
|
||||
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
|
||||
project_key VARCHAR(64) NOT NULL,
|
||||
miner_offer_id BIGINT UNSIGNED NULL,
|
||||
purchased_at TIMESTAMP NOT NULL,
|
||||
label VARCHAR(120) NOT NULL,
|
||||
runtime_months INT NULL,
|
||||
mining_speed_value DECIMAL(20,4) NULL,
|
||||
mining_speed_unit VARCHAR(8) NULL,
|
||||
bonus_speed_value DECIMAL(20,4) NULL,
|
||||
bonus_speed_unit VARCHAR(8) NULL,
|
||||
total_cost_amount DECIMAL(20,8) NOT NULL,
|
||||
currency VARCHAR(10) NOT NULL,
|
||||
usd_reference_amount DECIMAL(20,8) NULL,
|
||||
reference_price_amount DECIMAL(20,8) NULL,
|
||||
reference_price_currency VARCHAR(10) NULL,
|
||||
auto_renew TINYINT(1) NOT NULL DEFAULT 0,
|
||||
note TEXT,
|
||||
is_active TINYINT(1) NOT NULL DEFAULT 1,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
CONSTRAINT fk_mining_purchased_miners_project FOREIGN KEY (project_key) REFERENCES miningcheck_projects(project_key) ON DELETE CASCADE,
|
||||
CONSTRAINT fk_mining_purchased_miners_offer FOREIGN KEY (miner_offer_id) REFERENCES miningcheck_miner_offers(id) ON DELETE SET NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS miningcheck_fx_fetches (
|
||||
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
|
||||
provider VARCHAR(32) NOT NULL DEFAULT 'currencyapi',
|
||||
base_currency VARCHAR(10) NOT NULL,
|
||||
rate_date DATE NOT NULL,
|
||||
fetched_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
KEY idx_miningcheck_fx_fetches_base_fetched (base_currency, fetched_at)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS miningcheck_fx_rates (
|
||||
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
|
||||
fetch_id BIGINT UNSIGNED NOT NULL,
|
||||
currency_code VARCHAR(10) NOT NULL,
|
||||
current_value DECIMAL(20,10) NOT NULL,
|
||||
KEY idx_miningcheck_fx_rates_fetch (fetch_id),
|
||||
KEY idx_miningcheck_fx_rates_currency (currency_code),
|
||||
CONSTRAINT fk_mining_fx_rates_fetch FOREIGN KEY (fetch_id) REFERENCES miningcheck_fx_fetches(id) ON DELETE CASCADE
|
||||
);
|
||||
75
modules/mining-checker/sql/seed.sql
Normal file
75
modules/mining-checker/sql/seed.sql
Normal file
@@ -0,0 +1,75 @@
|
||||
INSERT INTO miningcheck_projects (project_key, project_name)
|
||||
VALUES ('doge-main', 'DOGE Mining Main')
|
||||
ON DUPLICATE KEY UPDATE project_name = VALUES(project_name);
|
||||
|
||||
INSERT INTO miningcheck_settings (
|
||||
project_key,
|
||||
baseline_measured_at,
|
||||
baseline_coins_total,
|
||||
daily_cost_amount,
|
||||
daily_cost_currency,
|
||||
preferred_currencies
|
||||
)
|
||||
VALUES (
|
||||
'doge-main',
|
||||
'2026-03-16 01:32:00',
|
||||
27.617864,
|
||||
0.3123287671,
|
||||
'EUR',
|
||||
'["DOGE","USD","EUR"]'
|
||||
)
|
||||
ON DUPLICATE KEY UPDATE
|
||||
baseline_measured_at = VALUES(baseline_measured_at),
|
||||
baseline_coins_total = VALUES(baseline_coins_total),
|
||||
daily_cost_amount = VALUES(daily_cost_amount),
|
||||
daily_cost_currency = VALUES(daily_cost_currency),
|
||||
preferred_currencies = VALUES(preferred_currencies);
|
||||
|
||||
INSERT INTO miningcheck_targets (project_key, label, target_amount_fiat, currency, is_active, sort_order)
|
||||
VALUES
|
||||
('doge-main', 'Ziel A', 10.82, 'EUR', 1, 10),
|
||||
('doge-main', 'Ziel B', 19.50, 'EUR', 1, 20)
|
||||
ON DUPLICATE KEY UPDATE
|
||||
target_amount_fiat = VALUES(target_amount_fiat),
|
||||
currency = VALUES(currency),
|
||||
is_active = VALUES(is_active),
|
||||
sort_order = VALUES(sort_order);
|
||||
|
||||
INSERT INTO miningcheck_dashboard_definitions (
|
||||
project_key, name, chart_type, x_field, y_field, aggregation, filters_json, is_active
|
||||
)
|
||||
VALUES
|
||||
('doge-main', 'Mining-Verlauf', 'line', 'measured_at', 'coins_total', 'none', NULL, 1),
|
||||
('doge-main', 'DOGE pro Tag', 'area', 'measured_date', 'doge_per_day_interval', 'avg', NULL, 1),
|
||||
('doge-main', 'Kurs-Verlauf', 'line', 'measured_at', 'price_per_coin', 'none', '{"currencies":["EUR","USD"]}', 1)
|
||||
ON DUPLICATE KEY UPDATE
|
||||
chart_type = VALUES(chart_type),
|
||||
x_field = VALUES(x_field),
|
||||
y_field = VALUES(y_field),
|
||||
aggregation = VALUES(aggregation),
|
||||
filters_json = VALUES(filters_json),
|
||||
is_active = VALUES(is_active);
|
||||
|
||||
INSERT INTO miningcheck_measurements (
|
||||
project_key, measured_at, coins_total, price_per_coin, price_currency, note, source, image_path, ocr_raw_text, ocr_confidence, ocr_flags
|
||||
)
|
||||
VALUES
|
||||
('doge-main', '2026-03-16 01:32:00', 27.617864, NULL, NULL, 'Basiswert', 'seed_import', NULL, NULL, NULL, NULL),
|
||||
('doge-main', '2026-03-17 02:41:00', 33.751904, NULL, NULL, 'Kurs wurde spaeter separat genannt, aber nicht sicher exakt diesem Messpunkt zuordenbar', 'seed_import', NULL, NULL, NULL, NULL),
|
||||
('doge-main', '2026-03-17 07:15:00', 34.825695, 0.10037, 'EUR', NULL, 'seed_import', NULL, NULL, NULL, NULL),
|
||||
('doge-main', '2026-03-17 13:21:00', 36.328140, 0.10002, 'EUR', NULL, 'seed_import', NULL, NULL, NULL, NULL),
|
||||
('doge-main', '2026-03-17 18:53:00', 37.682757, 0.10062, 'EUR', NULL, 'seed_import', NULL, NULL, NULL, NULL),
|
||||
('doge-main', '2026-03-18 00:08:00', 38.934351, 0.10097, 'EUR', NULL, 'seed_import', NULL, NULL, NULL, NULL),
|
||||
('doge-main', '2026-03-18 07:40:00', 40.782006, 0.10040, 'EUR', NULL, 'seed_import', NULL, NULL, NULL, NULL),
|
||||
('doge-main', '2026-03-18 13:32:00', 42.223449, 0.09607, 'EUR', 'Originaleingabe im Chat: 18.6.2026', 'seed_import', NULL, NULL, NULL, NULL),
|
||||
('doge-main', '2026-03-18 21:15:00', 44.191018, 0.09446, 'EUR', NULL, 'seed_import', NULL, NULL, NULL, NULL),
|
||||
('doge-main', '2026-03-19 00:09:00', 44.908500, 0.09507, 'EUR', NULL, 'seed_import', NULL, NULL, NULL, NULL),
|
||||
('doge-main', '2026-03-19 02:33:00', 45.546924, 0.09499, 'USD', 'aus Screenshot extrahiert', 'seed_import', NULL, NULL, NULL, JSON_ARRAY('source:screenshot')),
|
||||
('doge-main', '2026-03-19 07:01:00', 46.694127, 0.09460, 'USD', 'aus Screenshot extrahiert', 'seed_import', NULL, NULL, NULL, JSON_ARRAY('source:screenshot')),
|
||||
('doge-main', '2026-03-19 12:24:00', 48.056494, 0.09419, 'USD', 'aus Screenshot extrahiert', 'seed_import', NULL, NULL, NULL, JSON_ARRAY('source:screenshot')),
|
||||
('doge-main', '2026-03-19 21:39:00', 50.427943, 0.09361, 'USD', 'aus Screenshot extrahiert', 'seed_import', NULL, NULL, NULL, JSON_ARRAY('source:screenshot'))
|
||||
ON DUPLICATE KEY UPDATE
|
||||
price_per_coin = VALUES(price_per_coin),
|
||||
price_currency = VALUES(price_currency),
|
||||
note = VALUES(note),
|
||||
source = VALUES(source);
|
||||
2639
modules/mining-checker/src/Api/Router.php
Normal file
2639
modules/mining-checker/src/Api/Router.php
Normal file
File diff suppressed because it is too large
Load Diff
1451
modules/mining-checker/src/Domain/AnalyticsService.php
Normal file
1451
modules/mining-checker/src/Domain/AnalyticsService.php
Normal file
File diff suppressed because it is too large
Load Diff
950
modules/mining-checker/src/Domain/FxService.php
Normal file
950
modules/mining-checker/src/Domain/FxService.php
Normal file
@@ -0,0 +1,950 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Modules\MiningChecker\Domain;
|
||||
|
||||
use Modules\MiningChecker\Infrastructure\MiningRepository;
|
||||
use Modules\MiningChecker\Support\DebugTrace;
|
||||
|
||||
final class FxService
|
||||
{
|
||||
private ?MiningRepository $repository;
|
||||
private string $provider;
|
||||
private string $apiBaseUrl;
|
||||
private string $currenciesApiBaseUrl;
|
||||
private string $apiKey;
|
||||
private int $timeout;
|
||||
private int $cacheTtl;
|
||||
private bool $autoFetchOnMiss;
|
||||
private array $memoryCache = [];
|
||||
private array $snapshotCache = [];
|
||||
private ?DebugTrace $debug;
|
||||
|
||||
public function __construct(
|
||||
?MiningRepository $repository = null,
|
||||
string $apiBaseUrl = 'https://currencyapi.net',
|
||||
string $currenciesApiBaseUrl = 'https://currencyapi.net',
|
||||
int $timeout = 10,
|
||||
int $cacheTtl = 21600,
|
||||
bool $autoFetchOnMiss = false,
|
||||
string $provider = 'currencyapi',
|
||||
string $apiKey = '',
|
||||
?DebugTrace $debug = null
|
||||
)
|
||||
{
|
||||
$this->repository = $repository;
|
||||
$this->provider = trim(strtolower($provider)) !== '' ? trim(strtolower($provider)) : 'currencyapi';
|
||||
$this->apiBaseUrl = rtrim($apiBaseUrl, '/');
|
||||
$this->currenciesApiBaseUrl = rtrim($currenciesApiBaseUrl, '/');
|
||||
$this->apiKey = trim($apiKey);
|
||||
$this->timeout = max(2, $timeout);
|
||||
$this->cacheTtl = max(60, $cacheTtl);
|
||||
$this->autoFetchOnMiss = $autoFetchOnMiss;
|
||||
$this->debug = $debug;
|
||||
}
|
||||
|
||||
public function convert(?float $amount, ?string $from, ?string $to): ?float
|
||||
{
|
||||
return $this->convertAt($amount, $from, $to, null, null, null);
|
||||
}
|
||||
|
||||
public function convertAt(?float $amount, ?string $from, ?string $to, ?string $at = null, ?int $windowMinutes = null, ?int $fetchId = null): ?float
|
||||
{
|
||||
if ($amount === null || $from === null || $to === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$normalizedFetchId = $fetchId !== null && $fetchId > 0 ? $fetchId : null;
|
||||
$shared = $this->sharedFxService();
|
||||
if ($shared !== null && $normalizedFetchId !== null) {
|
||||
$snapshot = $this->snapshotByFetchId($normalizedFetchId, strtoupper(trim((string) $from)), [strtoupper(trim((string) $to))]);
|
||||
if (is_array($snapshot)) {
|
||||
$resolved = $this->resolveRateFromSnapshot($snapshot, strtoupper(trim((string) $from)), strtoupper(trim((string) $to)));
|
||||
if ($resolved !== null) {
|
||||
return $amount * $resolved;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if ($shared !== null && method_exists($shared, 'convert')) {
|
||||
$converted = $shared->convert($amount, $from, $to, $at, $windowMinutes);
|
||||
return is_numeric($converted) ? (float) $converted : null;
|
||||
}
|
||||
|
||||
$rate = $this->rateAt($from, $to, $at, $windowMinutes, $normalizedFetchId);
|
||||
return $rate === null ? null : $amount * $rate;
|
||||
}
|
||||
|
||||
public function rate(?string $from, ?string $to): ?float
|
||||
{
|
||||
return $this->rateAt($from, $to, null, null, null);
|
||||
}
|
||||
|
||||
public function rateAt(?string $from, ?string $to, ?string $at = null, ?int $windowMinutes = null, ?int $fetchId = null): ?float
|
||||
{
|
||||
$base = strtoupper(trim((string) $from));
|
||||
$target = strtoupper(trim((string) $to));
|
||||
$normalizedFetchId = $fetchId !== null && $fetchId > 0 ? $fetchId : null;
|
||||
|
||||
if ($base === '' || $target === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
if ($base === $target) {
|
||||
return 1.0;
|
||||
}
|
||||
|
||||
$shared = $this->sharedFxService();
|
||||
if ($shared !== null && $normalizedFetchId !== null) {
|
||||
$snapshot = $this->snapshotByFetchId($normalizedFetchId, $base, [$target]);
|
||||
if (is_array($snapshot)) {
|
||||
$resolved = $this->resolveRateFromSnapshot($snapshot, $base, $target);
|
||||
if ($resolved !== null) {
|
||||
return $resolved;
|
||||
}
|
||||
}
|
||||
}
|
||||
if ($shared !== null && method_exists($shared, 'findRate')) {
|
||||
$resolved = $shared->findRate($from, $to, $at, $windowMinutes);
|
||||
return is_array($resolved) && is_numeric($resolved['rate'] ?? null) ? (float) $resolved['rate'] : null;
|
||||
}
|
||||
|
||||
$cacheKey = implode(':', [$base, $target, $at ?? '', (string) ($windowMinutes ?? 0), (string) ($normalizedFetchId ?? 0)]);
|
||||
if (array_key_exists($cacheKey, $this->memoryCache)) {
|
||||
return $this->memoryCache[$cacheKey];
|
||||
}
|
||||
|
||||
$stored = $this->storedRate($base, $target);
|
||||
if ($stored !== null) {
|
||||
$this->memoryCache[$cacheKey] = $stored;
|
||||
return $stored;
|
||||
}
|
||||
|
||||
$cached = $this->readFileCache($cacheKey);
|
||||
if ($cached !== null) {
|
||||
$this->memoryCache[$cacheKey] = $cached;
|
||||
return $cached;
|
||||
}
|
||||
|
||||
if (!$this->autoFetchOnMiss) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$rate = $this->fetchAndPersistRate($base, $target);
|
||||
$this->memoryCache[$cacheKey] = $rate;
|
||||
if ($rate !== null) {
|
||||
$this->writeFileCache($cacheKey, $rate);
|
||||
}
|
||||
|
||||
return $rate;
|
||||
}
|
||||
|
||||
public function snapshotByFetchId(int $fetchId, ?string $baseCurrency = null, ?array $symbols = null): ?array
|
||||
{
|
||||
if ($fetchId <= 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$cacheKey = $this->snapshotCacheKey('fetch', [
|
||||
$fetchId,
|
||||
strtoupper(trim((string) ($baseCurrency ?? ''))),
|
||||
$this->normalizeSymbolsForCache($symbols),
|
||||
]);
|
||||
if (array_key_exists($cacheKey, $this->snapshotCache)) {
|
||||
return $this->snapshotCache[$cacheKey];
|
||||
}
|
||||
|
||||
$shared = $this->sharedFxService();
|
||||
if ($shared !== null && method_exists($shared, 'snapshotByFetchId')) {
|
||||
$snapshot = $shared->snapshotByFetchId($fetchId, $baseCurrency, $symbols);
|
||||
return $this->snapshotCache[$cacheKey] = (is_array($snapshot) ? $snapshot : null);
|
||||
}
|
||||
|
||||
return $this->snapshotCache[$cacheKey] = null;
|
||||
}
|
||||
|
||||
public function latestSnapshot(?string $baseCurrency = null, ?array $symbols = null): ?array
|
||||
{
|
||||
$cacheKey = $this->snapshotCacheKey('latest', [
|
||||
strtoupper(trim((string) ($baseCurrency ?? ''))),
|
||||
$this->normalizeSymbolsForCache($symbols),
|
||||
]);
|
||||
if (array_key_exists($cacheKey, $this->snapshotCache)) {
|
||||
return $this->snapshotCache[$cacheKey];
|
||||
}
|
||||
|
||||
$shared = $this->sharedFxService();
|
||||
if ($shared !== null && method_exists($shared, 'snapshot')) {
|
||||
$snapshot = $shared->snapshot($baseCurrency, null, $symbols, null);
|
||||
return $this->snapshotCache[$cacheKey] = (is_array($snapshot) ? $snapshot : null);
|
||||
}
|
||||
|
||||
return $this->snapshotCache[$cacheKey] = null;
|
||||
}
|
||||
|
||||
public function nearestSnapshot(?string $baseCurrency, string $at, ?array $symbols = null, ?int $windowMinutes = null): ?array
|
||||
{
|
||||
$cacheKey = $this->snapshotCacheKey('nearest', [
|
||||
strtoupper(trim((string) ($baseCurrency ?? ''))),
|
||||
trim($at),
|
||||
$windowMinutes ?? 0,
|
||||
$this->normalizeSymbolsForCache($symbols),
|
||||
]);
|
||||
if (array_key_exists($cacheKey, $this->snapshotCache)) {
|
||||
return $this->snapshotCache[$cacheKey];
|
||||
}
|
||||
|
||||
$shared = $this->sharedFxService();
|
||||
if ($shared !== null && method_exists($shared, 'nearestSnapshot')) {
|
||||
$snapshot = $shared->nearestSnapshot($baseCurrency, $at, $symbols, $windowMinutes);
|
||||
return $this->snapshotCache[$cacheKey] = (is_array($snapshot) ? $snapshot : null);
|
||||
}
|
||||
|
||||
return $this->snapshotCache[$cacheKey] = null;
|
||||
}
|
||||
|
||||
public function refreshLatestRates(?array $currencies = null, string $base = 'EUR'): array
|
||||
{
|
||||
$shared = $this->sharedFxService();
|
||||
if ($shared !== null && method_exists($shared, 'refreshLatestRates')) {
|
||||
return $shared->refreshLatestRates($currencies, $base);
|
||||
}
|
||||
|
||||
$normalizedBase = strtoupper(trim($base));
|
||||
$targets = $currencies === null
|
||||
? null
|
||||
: array_values(array_unique(array_filter(array_map(
|
||||
static fn ($code): string => strtoupper(trim((string) $code)),
|
||||
$currencies
|
||||
), static fn (string $code): bool => $code !== '' && $code !== $normalizedBase)));
|
||||
|
||||
$payload = $this->fetchLatestPayload($normalizedBase, $targets);
|
||||
$rates = is_array($payload['rates'] ?? null) ? $payload['rates'] : [];
|
||||
$rateDate = $this->normalizeRateDate($payload['date'] ?? null);
|
||||
$forwardRates = [];
|
||||
foreach ($rates as $target => $rate) {
|
||||
if (!is_numeric($rate)) {
|
||||
continue;
|
||||
}
|
||||
$targetCode = strtoupper((string) $target);
|
||||
if ($targetCode === '' || $targetCode === $normalizedBase) {
|
||||
continue;
|
||||
}
|
||||
$forwardRates[$targetCode] = (float) $rate;
|
||||
}
|
||||
|
||||
$updated = $this->persistRateSet($normalizedBase, $forwardRates, $rateDate);
|
||||
|
||||
return [
|
||||
'base' => $normalizedBase,
|
||||
'rate_date' => $rateDate,
|
||||
'updated_count' => count($updated),
|
||||
'rates' => $updated,
|
||||
];
|
||||
}
|
||||
|
||||
public function ensureFreshLatestRates(float $maxAgeHours = 3.0, string $base = 'USD'): array
|
||||
{
|
||||
$shared = $this->sharedFxService();
|
||||
if ($shared !== null && method_exists($shared, 'ensureFreshLatestRates')) {
|
||||
return $shared->ensureFreshLatestRates($maxAgeHours, $base, null);
|
||||
}
|
||||
|
||||
$normalizedBase = strtoupper(trim($base));
|
||||
$maxAgeHours = $maxAgeHours > 0 ? $maxAgeHours : 3.0;
|
||||
|
||||
if ($this->repository === null) {
|
||||
return $this->refreshLatestRates(null, $normalizedBase);
|
||||
}
|
||||
|
||||
$latestFetch = $this->repository->getLatestFxFetch($normalizedBase);
|
||||
$latestFetchedAt = is_array($latestFetch) ? $this->parseStoredUtcTimestamp((string) ($latestFetch['fetched_at'] ?? '')) : null;
|
||||
$ageSeconds = $latestFetchedAt !== null ? (time() - $latestFetchedAt) : null;
|
||||
$maxAgeSeconds = (int) round($maxAgeHours * 3600);
|
||||
|
||||
if ($ageSeconds !== null && $ageSeconds >= 0 && $ageSeconds <= $maxAgeSeconds) {
|
||||
$this->debug?->add('fx.latest.reuse', [
|
||||
'base' => $normalizedBase,
|
||||
'fetched_at' => $latestFetch['fetched_at'] ?? null,
|
||||
'age_seconds' => $ageSeconds,
|
||||
'max_age_seconds' => $maxAgeSeconds,
|
||||
]);
|
||||
|
||||
return [
|
||||
'base' => $normalizedBase,
|
||||
'rate_date' => $latestFetch['rate_date'] ?? null,
|
||||
'updated_count' => 0,
|
||||
'rates' => [],
|
||||
'reused' => true,
|
||||
'fetched_at' => $latestFetch['fetched_at'] ?? null,
|
||||
];
|
||||
}
|
||||
|
||||
$this->debug?->add('fx.latest.refresh_required', [
|
||||
'base' => $normalizedBase,
|
||||
'previous_fetched_at' => $latestFetch['fetched_at'] ?? null,
|
||||
'age_seconds' => $ageSeconds,
|
||||
'max_age_seconds' => $maxAgeSeconds,
|
||||
]);
|
||||
|
||||
$result = $this->refreshLatestRates(null, $normalizedBase);
|
||||
$result['reused'] = false;
|
||||
return $result;
|
||||
}
|
||||
|
||||
public function probeLatestRates(string $base = 'EUR'): array
|
||||
{
|
||||
$shared = $this->sharedFxService();
|
||||
if ($shared !== null && method_exists($shared, 'probeLatestRates')) {
|
||||
return $shared->probeLatestRates($base);
|
||||
}
|
||||
|
||||
$normalizedBase = strtoupper(trim($base));
|
||||
return $this->fetchLatestProbe($normalizedBase);
|
||||
}
|
||||
|
||||
public function refreshCurrencyCatalog(): array
|
||||
{
|
||||
$shared = $this->sharedFxService();
|
||||
if ($shared !== null && method_exists($shared, 'refreshCurrencyCatalog')) {
|
||||
return $shared->refreshCurrencyCatalog();
|
||||
}
|
||||
|
||||
$payload = $this->fetchCurrenciesPayload();
|
||||
$items = is_array($payload['currencies'] ?? null) ? $payload['currencies'] : [];
|
||||
if ($items === []) {
|
||||
return [
|
||||
'synced_count' => 0,
|
||||
'currencies' => [],
|
||||
];
|
||||
}
|
||||
|
||||
$synced = [];
|
||||
$sortOrder = 1000;
|
||||
|
||||
foreach ($items as $code => $name) {
|
||||
$normalizedCode = strtoupper(trim((string) $code));
|
||||
$normalizedName = trim((string) $name);
|
||||
if ($normalizedCode === '' || $normalizedName === '') {
|
||||
continue;
|
||||
}
|
||||
|
||||
$currency = [
|
||||
'code' => substr($normalizedCode, 0, 10),
|
||||
'name' => function_exists('mb_substr') ? mb_substr($normalizedName, 0, 64) : substr($normalizedName, 0, 64),
|
||||
'symbol' => substr($normalizedCode, 0, 8),
|
||||
'is_active' => 1,
|
||||
'is_crypto' => $this->isCryptoCode($normalizedCode) ? 1 : 0,
|
||||
'sort_order' => $this->catalogSortOrder($normalizedCode, $sortOrder),
|
||||
];
|
||||
|
||||
$synced[] = $currency;
|
||||
$sortOrder++;
|
||||
}
|
||||
|
||||
usort($synced, static function (array $left, array $right): int {
|
||||
return [$left['sort_order'], $left['code']] <=> [$right['sort_order'], $right['code']];
|
||||
});
|
||||
|
||||
return [
|
||||
'synced_count' => count($synced),
|
||||
'currencies' => $synced,
|
||||
];
|
||||
}
|
||||
|
||||
public function probeCurrencyCatalog(): array
|
||||
{
|
||||
$shared = $this->sharedFxService();
|
||||
if ($shared !== null && method_exists($shared, 'probeCurrencyCatalog')) {
|
||||
return $shared->probeCurrencyCatalog();
|
||||
}
|
||||
|
||||
return $this->fetchCurrenciesProbe();
|
||||
}
|
||||
|
||||
private function fetchAndPersistRate(string $base, string $target): ?float
|
||||
{
|
||||
$payload = $this->fetchLatestPayload($base, [$target]);
|
||||
$rates = is_array($payload['rates'] ?? null) ? $payload['rates'] : [];
|
||||
$rate = $rates[$target] ?? null;
|
||||
if (!is_numeric($rate)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$numericRate = (float) $rate;
|
||||
$rateDate = $this->normalizeRateDate($payload['date'] ?? null);
|
||||
$this->persistRateSet($base, [$target => $numericRate], $rateDate);
|
||||
return $numericRate;
|
||||
}
|
||||
|
||||
private function fetchLatestPayload(string $base, ?array $targets = null): array
|
||||
{
|
||||
if (!function_exists('curl_init')) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$url = $this->buildLatestUrl($base, $targets);
|
||||
if ($url === null) {
|
||||
$this->debug?->add('fx.latest.skip', ['reason' => 'missing_url_or_key', 'base' => $base]);
|
||||
return [];
|
||||
}
|
||||
|
||||
$this->debug?->add('fx.latest.request', [
|
||||
'base' => $base,
|
||||
'url' => $this->maskUrl($url),
|
||||
'targets' => $targets,
|
||||
]);
|
||||
|
||||
$ch = curl_init();
|
||||
curl_setopt_array($ch, [
|
||||
CURLOPT_URL => $url,
|
||||
CURLOPT_RETURNTRANSFER => true,
|
||||
CURLOPT_FOLLOWLOCATION => true,
|
||||
CURLOPT_TIMEOUT => $this->timeout,
|
||||
CURLOPT_HTTPHEADER => ['Accept: application/json'],
|
||||
]);
|
||||
|
||||
$response = curl_exec($ch);
|
||||
$httpStatus = (int) curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
|
||||
$curlError = curl_error($ch);
|
||||
curl_close($ch);
|
||||
|
||||
$this->debug?->add('fx.latest.response', [
|
||||
'http_status' => $httpStatus,
|
||||
'curl_error' => $curlError,
|
||||
'response_bytes' => is_string($response) ? strlen($response) : 0,
|
||||
'response_preview' => is_string($response) ? substr($response, 0, 1200) : null,
|
||||
]);
|
||||
|
||||
if ($response === false || $curlError !== '' || $httpStatus >= 400) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$payload = json_decode((string) $response, true);
|
||||
return $this->normalizePayload($payload, $base, $targets);
|
||||
}
|
||||
|
||||
private function fetchLatestProbe(string $base): array
|
||||
{
|
||||
if (!function_exists('curl_init')) {
|
||||
return ['ok' => false, 'message' => 'curl_init ist nicht verfuegbar.'];
|
||||
}
|
||||
|
||||
$url = $this->buildLatestUrl($base, null);
|
||||
if ($url === null) {
|
||||
return ['ok' => false, 'message' => 'FX-URL oder API-Key fehlt.'];
|
||||
}
|
||||
|
||||
$this->debug?->add('fx.latest.probe.request', [
|
||||
'base' => $base,
|
||||
'url' => $this->maskUrl($url),
|
||||
]);
|
||||
|
||||
$ch = curl_init();
|
||||
curl_setopt_array($ch, [
|
||||
CURLOPT_URL => $url,
|
||||
CURLOPT_RETURNTRANSFER => true,
|
||||
CURLOPT_FOLLOWLOCATION => true,
|
||||
CURLOPT_TIMEOUT => $this->timeout,
|
||||
CURLOPT_HEADER => true,
|
||||
CURLOPT_HTTPHEADER => ['Accept: application/json'],
|
||||
]);
|
||||
|
||||
$response = curl_exec($ch);
|
||||
$httpStatus = (int) curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
|
||||
$headerSize = (int) curl_getinfo($ch, CURLINFO_HEADER_SIZE);
|
||||
$curlError = curl_error($ch);
|
||||
curl_close($ch);
|
||||
|
||||
$rawHeaders = is_string($response) ? substr($response, 0, $headerSize) : '';
|
||||
$body = is_string($response) ? substr($response, $headerSize) : '';
|
||||
|
||||
$result = [
|
||||
'ok' => $response !== false && $curlError === '' && $httpStatus < 400,
|
||||
'url' => $this->maskUrl($url),
|
||||
'http_status' => $httpStatus,
|
||||
'curl_error' => $curlError,
|
||||
'response_headers' => $rawHeaders,
|
||||
'response_body' => substr($body, 0, 4000),
|
||||
];
|
||||
|
||||
$this->debug?->add('fx.latest.probe.response', $result);
|
||||
return $result;
|
||||
}
|
||||
|
||||
private function fetchCurrenciesPayload(): array
|
||||
{
|
||||
if (!function_exists('curl_init') || $this->apiKey === '') {
|
||||
return [];
|
||||
}
|
||||
|
||||
$url = sprintf(
|
||||
'%s/api/v2/currencies?output=json&key=%s',
|
||||
$this->currenciesApiBaseUrl,
|
||||
rawurlencode($this->apiKey)
|
||||
);
|
||||
|
||||
$this->debug?->add('fx.currencies.request', [
|
||||
'url' => $this->maskUrl($url),
|
||||
]);
|
||||
|
||||
$ch = curl_init();
|
||||
curl_setopt_array($ch, [
|
||||
CURLOPT_URL => $url,
|
||||
CURLOPT_RETURNTRANSFER => true,
|
||||
CURLOPT_FOLLOWLOCATION => true,
|
||||
CURLOPT_TIMEOUT => $this->timeout,
|
||||
CURLOPT_HTTPHEADER => ['Accept: application/json'],
|
||||
]);
|
||||
|
||||
$response = curl_exec($ch);
|
||||
$httpStatus = (int) curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
|
||||
$curlError = curl_error($ch);
|
||||
curl_close($ch);
|
||||
|
||||
$this->debug?->add('fx.currencies.response', [
|
||||
'http_status' => $httpStatus,
|
||||
'curl_error' => $curlError,
|
||||
'response_bytes' => is_string($response) ? strlen($response) : 0,
|
||||
'response_preview' => is_string($response) ? substr($response, 0, 1200) : null,
|
||||
]);
|
||||
|
||||
if ($response === false || $curlError !== '' || $httpStatus >= 400) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$payload = json_decode((string) $response, true);
|
||||
if (!is_array($payload)) {
|
||||
throw new \RuntimeException('Waehrungskatalog konnte nicht gelesen werden.');
|
||||
}
|
||||
|
||||
if (($payload['valid'] ?? false) !== true || !is_array($payload['currencies'] ?? null)) {
|
||||
throw new \RuntimeException($this->extractProviderError($payload, 'Waehrungskatalog konnte nicht geladen werden.'));
|
||||
}
|
||||
|
||||
return $payload;
|
||||
}
|
||||
|
||||
private function fetchCurrenciesProbe(): array
|
||||
{
|
||||
if (!function_exists('curl_init') || $this->apiKey === '') {
|
||||
return ['ok' => false, 'message' => 'curl_init oder API-Key fehlt.'];
|
||||
}
|
||||
|
||||
$url = sprintf(
|
||||
'%s/api/v2/currencies?output=json&key=%s',
|
||||
$this->currenciesApiBaseUrl,
|
||||
rawurlencode($this->apiKey)
|
||||
);
|
||||
|
||||
$this->debug?->add('fx.currencies.probe.request', [
|
||||
'url' => $this->maskUrl($url),
|
||||
]);
|
||||
|
||||
$ch = curl_init();
|
||||
curl_setopt_array($ch, [
|
||||
CURLOPT_URL => $url,
|
||||
CURLOPT_RETURNTRANSFER => true,
|
||||
CURLOPT_FOLLOWLOCATION => true,
|
||||
CURLOPT_TIMEOUT => $this->timeout,
|
||||
CURLOPT_HEADER => true,
|
||||
CURLOPT_HTTPHEADER => ['Accept: application/json'],
|
||||
]);
|
||||
|
||||
$response = curl_exec($ch);
|
||||
$httpStatus = (int) curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
|
||||
$headerSize = (int) curl_getinfo($ch, CURLINFO_HEADER_SIZE);
|
||||
$curlError = curl_error($ch);
|
||||
curl_close($ch);
|
||||
|
||||
$rawHeaders = is_string($response) ? substr($response, 0, $headerSize) : '';
|
||||
$body = is_string($response) ? substr($response, $headerSize) : '';
|
||||
|
||||
$result = [
|
||||
'ok' => $response !== false && $curlError === '' && $httpStatus < 400,
|
||||
'url' => $this->maskUrl($url),
|
||||
'http_status' => $httpStatus,
|
||||
'curl_error' => $curlError,
|
||||
'response_headers' => $rawHeaders,
|
||||
'response_body' => substr($body, 0, 4000),
|
||||
];
|
||||
|
||||
$this->debug?->add('fx.currencies.probe.response', $result);
|
||||
return $result;
|
||||
}
|
||||
|
||||
private function storedRate(string $base, string $target): ?float
|
||||
{
|
||||
if ($this->repository === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
$direct = $this->repository->getLatestFxRate($base, $target);
|
||||
if (is_array($direct) && is_numeric($direct['rate'] ?? null)) {
|
||||
return (float) $direct['rate'];
|
||||
}
|
||||
|
||||
$inverse = $this->repository->getLatestFxRate($target, $base);
|
||||
if (is_array($inverse) && is_numeric($inverse['rate'] ?? null) && (float) $inverse['rate'] > 0) {
|
||||
return 1 / (float) $inverse['rate'];
|
||||
}
|
||||
|
||||
$measurementRate = $this->repository->getLatestMeasurementRate($base, $target);
|
||||
if (is_array($measurementRate) && is_numeric($measurementRate['rate'] ?? null)) {
|
||||
return (float) $measurementRate['rate'];
|
||||
}
|
||||
|
||||
$inverseMeasurementRate = $this->repository->getLatestMeasurementRate($target, $base);
|
||||
if (
|
||||
is_array($inverseMeasurementRate) &&
|
||||
is_numeric($inverseMeasurementRate['rate'] ?? null) &&
|
||||
(float) $inverseMeasurementRate['rate'] > 0
|
||||
) {
|
||||
return 1 / (float) $inverseMeasurementRate['rate'];
|
||||
}
|
||||
|
||||
foreach (['USD', 'EUR'] as $viaBase) {
|
||||
if ($base === $viaBase || $target === $viaBase) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$fromVia = $this->repository->getLatestFxRate($viaBase, $base);
|
||||
$toVia = $this->repository->getLatestFxRate($viaBase, $target);
|
||||
if (
|
||||
is_array($fromVia) && is_numeric($fromVia['rate'] ?? null) &&
|
||||
is_array($toVia) && is_numeric($toVia['rate'] ?? null) &&
|
||||
(float) $fromVia['rate'] > 0
|
||||
) {
|
||||
return (float) $toVia['rate'] / (float) $fromVia['rate'];
|
||||
}
|
||||
|
||||
$fromViaInverse = $this->repository->getLatestFxRate($base, $viaBase);
|
||||
$toViaInverse = $this->repository->getLatestFxRate($target, $viaBase);
|
||||
if (
|
||||
is_array($fromViaInverse) && is_numeric($fromViaInverse['rate'] ?? null) &&
|
||||
is_array($toViaInverse) && is_numeric($toViaInverse['rate'] ?? null) &&
|
||||
(float) $toViaInverse['rate'] > 0
|
||||
) {
|
||||
return (1 / (float) $fromViaInverse['rate']) / (1 / (float) $toViaInverse['rate']);
|
||||
}
|
||||
}
|
||||
} catch (\Throwable) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private function persistRateSet(string $base, array $rates, string $rateDate): array
|
||||
{
|
||||
$normalizedBase = strtoupper($base);
|
||||
$normalizedRates = [];
|
||||
foreach ($rates as $target => $rate) {
|
||||
if (!is_numeric($rate)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$normalizedTarget = strtoupper((string) $target);
|
||||
$normalizedRates[$normalizedTarget] = (float) $rate;
|
||||
$this->memoryCache[$normalizedBase . ':' . $normalizedTarget] = (float) $rate;
|
||||
$this->writeFileCache($normalizedBase . ':' . $normalizedTarget, (float) $rate);
|
||||
}
|
||||
|
||||
if ($this->repository === null) {
|
||||
$result = [];
|
||||
foreach ($normalizedRates as $target => $rate) {
|
||||
$result[] = [
|
||||
'base_currency' => $normalizedBase,
|
||||
'target_currency' => $target,
|
||||
'rate' => $rate,
|
||||
'rate_date' => $rateDate,
|
||||
'provider' => $this->provider,
|
||||
];
|
||||
}
|
||||
return $result;
|
||||
}
|
||||
|
||||
try {
|
||||
$saved = $this->repository->saveFxFetch($normalizedBase, $this->provider, $rateDate, $normalizedRates);
|
||||
return is_array($saved['rates'] ?? null) ? $saved['rates'] : [];
|
||||
} catch (\Throwable) {
|
||||
$result = [];
|
||||
foreach ($normalizedRates as $target => $rate) {
|
||||
$result[] = [
|
||||
'base_currency' => $normalizedBase,
|
||||
'target_currency' => $target,
|
||||
'rate' => $rate,
|
||||
'rate_date' => $rateDate,
|
||||
'provider' => $this->provider,
|
||||
];
|
||||
}
|
||||
return $result;
|
||||
}
|
||||
}
|
||||
|
||||
private function buildLatestUrl(string $base, ?array $targets = null): ?string
|
||||
{
|
||||
if ($this->provider === 'currencyapi') {
|
||||
if ($this->apiKey === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
return sprintf(
|
||||
'%s/api/v2/rates?base=%s&output=json&key=%s',
|
||||
$this->apiBaseUrl,
|
||||
rawurlencode($base),
|
||||
rawurlencode($this->apiKey)
|
||||
);
|
||||
}
|
||||
|
||||
$targets = $targets ?? $this->defaultCurrencies();
|
||||
return sprintf(
|
||||
'%s/latest?base=%s&symbols=%s',
|
||||
$this->apiBaseUrl,
|
||||
rawurlencode($base),
|
||||
rawurlencode(implode(',', $targets))
|
||||
);
|
||||
}
|
||||
|
||||
private function normalizePayload(mixed $payload, string $base, ?array $targets = null): array
|
||||
{
|
||||
if (!is_array($payload)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
if ($this->provider === 'currencyapi') {
|
||||
if (($payload['valid'] ?? false) !== true || !is_array($payload['rates'] ?? null)) {
|
||||
throw new \RuntimeException($this->extractProviderError($payload, 'FX-Kurse konnten nicht geladen werden.'));
|
||||
}
|
||||
|
||||
$allRates = $payload['rates'];
|
||||
$filteredRates = [];
|
||||
if ($targets === null) {
|
||||
foreach ($allRates as $target => $rate) {
|
||||
$targetCode = strtoupper((string) $target);
|
||||
if ($targetCode === $base || !is_numeric($rate)) {
|
||||
continue;
|
||||
}
|
||||
$filteredRates[$targetCode] = (float) $rate;
|
||||
}
|
||||
} else {
|
||||
foreach ($targets as $target) {
|
||||
$targetCode = strtoupper((string) $target);
|
||||
if ($targetCode === $base) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$rate = $allRates[$targetCode] ?? null;
|
||||
if (is_numeric($rate)) {
|
||||
$filteredRates[$targetCode] = (float) $rate;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return [
|
||||
'base' => strtoupper((string) ($payload['base'] ?? $base)),
|
||||
'date' => $payload['updated'] ?? null,
|
||||
'rates' => $filteredRates,
|
||||
];
|
||||
}
|
||||
|
||||
if (!is_array($payload['rates'] ?? null)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
if (array_key_exists('success', $payload) && $payload['success'] !== true) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return $payload;
|
||||
}
|
||||
|
||||
private function extractProviderError(array $payload, string $fallback): string
|
||||
{
|
||||
foreach (['error', 'message', 'msg'] as $field) {
|
||||
$value = $payload[$field] ?? null;
|
||||
if (is_string($value) && trim($value) !== '') {
|
||||
return trim($value);
|
||||
}
|
||||
}
|
||||
|
||||
$errors = $payload['errors'] ?? null;
|
||||
if (is_array($errors)) {
|
||||
$flat = [];
|
||||
array_walk_recursive($errors, static function ($value) use (&$flat): void {
|
||||
if (is_string($value) && trim($value) !== '') {
|
||||
$flat[] = trim($value);
|
||||
}
|
||||
});
|
||||
if ($flat !== []) {
|
||||
return implode(' | ', array_values(array_unique($flat)));
|
||||
}
|
||||
}
|
||||
|
||||
return $fallback;
|
||||
}
|
||||
|
||||
private function defaultCurrencies(): array
|
||||
{
|
||||
return ['EUR', 'USD'];
|
||||
}
|
||||
|
||||
private function normalizeRateDate(mixed $value): string
|
||||
{
|
||||
if (is_int($value) || is_float($value) || (is_string($value) && ctype_digit(trim($value)))) {
|
||||
$timestamp = (int) $value;
|
||||
if ($timestamp > 0) {
|
||||
return date('Y-m-d', $timestamp);
|
||||
}
|
||||
}
|
||||
|
||||
if (is_string($value) && trim($value) !== '') {
|
||||
$timestamp = strtotime($value);
|
||||
if ($timestamp !== false) {
|
||||
return date('Y-m-d', $timestamp);
|
||||
}
|
||||
|
||||
if (preg_match('/^\d{4}-\d{2}-\d{2}/', $value, $matches) === 1) {
|
||||
return $matches[0];
|
||||
}
|
||||
}
|
||||
|
||||
return date('Y-m-d');
|
||||
}
|
||||
|
||||
private function parseStoredUtcTimestamp(string $value): ?int
|
||||
{
|
||||
$normalized = trim($value);
|
||||
if ($normalized === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
if (preg_match('/^\d{4}-\d{2}-\d{2}(?:[ T]\d{2}:\d{2}(?::\d{2})?)?$/', $normalized) === 1) {
|
||||
$date = new \DateTimeImmutable(str_replace(' ', 'T', $normalized), new \DateTimeZone('UTC'));
|
||||
} else {
|
||||
$date = new \DateTimeImmutable($normalized);
|
||||
}
|
||||
return $date->setTimezone(new \DateTimeZone('UTC'))->getTimestamp();
|
||||
} catch (\Throwable) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private function catalogSortOrder(string $code, int $fallback): int
|
||||
{
|
||||
return match (strtoupper($code)) {
|
||||
'EUR' => 10,
|
||||
'USD' => 20,
|
||||
'DOGE' => 30,
|
||||
'BTC' => 40,
|
||||
'ETH' => 50,
|
||||
'USDT' => 60,
|
||||
'USDC' => 70,
|
||||
default => $fallback,
|
||||
};
|
||||
}
|
||||
|
||||
private function isCryptoCode(string $code): bool
|
||||
{
|
||||
return in_array(strtoupper($code), [
|
||||
'ADA', 'ARB', 'BNB', 'BTC', 'DAI', 'DOGE', 'DOT', 'ETH', 'LINK', 'LTC',
|
||||
'SOL', 'USDC', 'USDT', 'XRP',
|
||||
], true);
|
||||
}
|
||||
|
||||
private function cacheFile(string $cacheKey): string
|
||||
{
|
||||
return rtrim(sys_get_temp_dir(), '/') . '/mining-checker-fx-' . md5($cacheKey) . '.json';
|
||||
}
|
||||
|
||||
private function readFileCache(string $cacheKey): ?float
|
||||
{
|
||||
$file = $this->cacheFile($cacheKey);
|
||||
if (!is_file($file) || (time() - filemtime($file)) > $this->cacheTtl) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$payload = json_decode((string) file_get_contents($file), true);
|
||||
$rate = $payload['rate'] ?? null;
|
||||
return is_numeric($rate) ? (float) $rate : null;
|
||||
}
|
||||
|
||||
private function writeFileCache(string $cacheKey, float $rate): void
|
||||
{
|
||||
@file_put_contents($this->cacheFile($cacheKey), json_encode([
|
||||
'rate' => $rate,
|
||||
'cached_at' => time(),
|
||||
], JSON_UNESCAPED_UNICODE));
|
||||
}
|
||||
|
||||
private function maskUrl(string $url): string
|
||||
{
|
||||
return preg_replace_callback('/([?&]key=)([^&]+)/i', static function (array $matches): string {
|
||||
$key = $matches[2] ?? '';
|
||||
if (strlen($key) <= 8) {
|
||||
return $matches[1] . $key;
|
||||
}
|
||||
|
||||
return $matches[1] . substr($key, 0, 6) . '...' . substr($key, -4);
|
||||
}, $url) ?: $url;
|
||||
}
|
||||
|
||||
private function sharedFxService(): ?object
|
||||
{
|
||||
if (!function_exists('modules') || !modules()->isEnabled('fx-rates') || !modules()->hasFunction('fx-rates', 'service')) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
$service = module_fn('fx-rates', 'service');
|
||||
return is_object($service) ? $service : null;
|
||||
} catch (\Throwable) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private function resolveRateFromSnapshot(array $snapshot, string $from, string $to): ?float
|
||||
{
|
||||
$base = strtoupper(trim((string) ($snapshot['base_currency'] ?? '')));
|
||||
$rates = is_array($snapshot['rates'] ?? null) ? $snapshot['rates'] : [];
|
||||
|
||||
if ($base === '' || $from === '' || $to === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
if ($base === $from && is_numeric($rates[$to] ?? null)) {
|
||||
return (float) $rates[$to];
|
||||
}
|
||||
|
||||
if ($base === $to && is_numeric($rates[$from] ?? null) && (float) $rates[$from] > 0) {
|
||||
return 1 / (float) $rates[$from];
|
||||
}
|
||||
|
||||
if (is_numeric($rates[$from] ?? null) && is_numeric($rates[$to] ?? null) && (float) $rates[$from] > 0) {
|
||||
return (float) $rates[$to] / (float) $rates[$from];
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private function snapshotCacheKey(string $prefix, array $parts): string
|
||||
{
|
||||
return $prefix . ':' . implode(':', array_map(static fn (mixed $part): string => (string) $part, $parts));
|
||||
}
|
||||
|
||||
private function normalizeSymbolsForCache(?array $symbols): string
|
||||
{
|
||||
if (!is_array($symbols) || $symbols === []) {
|
||||
return '*';
|
||||
}
|
||||
|
||||
$normalized = array_values(array_unique(array_filter(array_map(
|
||||
static fn (mixed $symbol): string => strtoupper(trim((string) $symbol)),
|
||||
$symbols
|
||||
))));
|
||||
sort($normalized);
|
||||
return implode(',', $normalized);
|
||||
}
|
||||
}
|
||||
648
modules/mining-checker/src/Domain/OcrService.php
Normal file
648
modules/mining-checker/src/Domain/OcrService.php
Normal file
@@ -0,0 +1,648 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Modules\MiningChecker\Domain;
|
||||
|
||||
use Modules\MiningChecker\Infrastructure\ModuleConfig;
|
||||
use Modules\MiningChecker\Support\ApiException;
|
||||
|
||||
final class OcrService
|
||||
{
|
||||
private ModuleConfig $config;
|
||||
|
||||
public function __construct(ModuleConfig $config)
|
||||
{
|
||||
$this->config = $config;
|
||||
}
|
||||
|
||||
public function preview(array $file, array $input): array
|
||||
{
|
||||
if (($file['error'] ?? UPLOAD_ERR_NO_FILE) !== UPLOAD_ERR_OK) {
|
||||
throw new ApiException('Screenshot-Upload fehlt oder ist fehlerhaft.', 422);
|
||||
}
|
||||
|
||||
$mime = mime_content_type($file['tmp_name']) ?: '';
|
||||
if (!in_array($mime, ['image/png', 'image/jpeg', 'image/webp'], true)) {
|
||||
throw new ApiException('Nur PNG, JPEG und WEBP werden akzeptiert.', 422, ['mime' => $mime]);
|
||||
}
|
||||
|
||||
$projectKey = (string) ($input['project_key'] ?? $this->config->defaultProjectKey());
|
||||
$uploadDir = $this->resolveUploadDir($projectKey);
|
||||
|
||||
$extension = pathinfo((string) ($file['name'] ?? 'upload.png'), PATHINFO_EXTENSION) ?: 'png';
|
||||
$filename = date('Ymd-His') . '-' . bin2hex(random_bytes(4)) . '.' . strtolower($extension);
|
||||
$targetFile = $uploadDir . '/' . $filename;
|
||||
if (!move_uploaded_file($file['tmp_name'], $targetFile)) {
|
||||
throw new ApiException('Bild konnte nicht gespeichert werden.', 500);
|
||||
}
|
||||
|
||||
$rawText = trim((string) ($input['ocr_hint_text'] ?? ''));
|
||||
$flags = [];
|
||||
|
||||
if ($rawText === '') {
|
||||
['text' => $rawText, 'flags' => $providerFlags] = $this->extractRawText($targetFile);
|
||||
$flags = array_merge($flags, $providerFlags);
|
||||
} else {
|
||||
$flags[] = 'ocr_hint_text_used';
|
||||
}
|
||||
|
||||
$parsed = $this->parseText(
|
||||
$rawText,
|
||||
(string) ($input['date_context'] ?? date('Y-m-d')),
|
||||
strtoupper(trim((string) ($input['wallet_currency_hint'] ?? '')))
|
||||
);
|
||||
$parsed['image_path'] = $targetFile;
|
||||
$parsed['raw_text'] = $rawText;
|
||||
$parsed['flags'] = array_values(array_unique(array_merge($flags, $parsed['flags'])));
|
||||
|
||||
return $parsed;
|
||||
}
|
||||
|
||||
private function resolveUploadDir(string $projectKey): string
|
||||
{
|
||||
$safeProjectKey = preg_replace('~[^a-zA-Z0-9_-]~', '-', $projectKey) ?: 'default';
|
||||
$candidates = [
|
||||
rtrim($this->config->uploadsDir(), '/') . '/' . $safeProjectKey,
|
||||
rtrim(sys_get_temp_dir(), '/') . '/mining-checker/uploads/' . $safeProjectKey,
|
||||
];
|
||||
|
||||
foreach ($candidates as $candidate) {
|
||||
if ($this->ensureWritableDirectory($candidate)) {
|
||||
return $candidate;
|
||||
}
|
||||
}
|
||||
|
||||
throw new ApiException('Upload-Verzeichnis konnte nicht erstellt werden.', 500, [
|
||||
'candidates' => $candidates,
|
||||
]);
|
||||
}
|
||||
|
||||
private function ensureWritableDirectory(string $directory): bool
|
||||
{
|
||||
if (is_dir($directory)) {
|
||||
return is_writable($directory);
|
||||
}
|
||||
|
||||
return @mkdir($directory, 0775, true) || is_dir($directory);
|
||||
}
|
||||
|
||||
private function extractRawText(string $imagePath): array
|
||||
{
|
||||
$ocrConfig = $this->config->ocr();
|
||||
$providers = $ocrConfig['providers'] ?? ['tesseract'];
|
||||
$flags = [];
|
||||
|
||||
if (!is_array($providers) || $providers === []) {
|
||||
$providers = ['tesseract'];
|
||||
}
|
||||
|
||||
foreach ($providers as $provider) {
|
||||
$providerName = strtolower(trim((string) $provider));
|
||||
if ($providerName === '') {
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($providerName === 'ocrspace') {
|
||||
$result = $this->runOcrSpace((array) ($ocrConfig['ocrspace'] ?? []), $imagePath);
|
||||
} elseif ($providerName === 'tesseract') {
|
||||
$result = $this->runTesseract((array) ($ocrConfig['tesseract'] ?? []), $imagePath);
|
||||
} else {
|
||||
$flags[] = 'ocr_provider_unsupported:' . $providerName;
|
||||
continue;
|
||||
}
|
||||
|
||||
$flags = array_merge($flags, $result['flags']);
|
||||
if (($result['text'] ?? '') !== '') {
|
||||
return [
|
||||
'text' => (string) $result['text'],
|
||||
'flags' => array_values(array_unique(array_merge(
|
||||
$flags,
|
||||
['ocr_provider:' . $providerName]
|
||||
))),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
return [
|
||||
'text' => '',
|
||||
'flags' => array_values(array_unique(array_merge($flags, ['ocr_engine_missing']))),
|
||||
];
|
||||
}
|
||||
|
||||
private function runOcrSpace(array $providerConfig, string $imagePath): array
|
||||
{
|
||||
if (!function_exists('curl_init') || !class_exists(\CURLFile::class)) {
|
||||
return [
|
||||
'text' => '',
|
||||
'flags' => ['ocr_provider_missing:ocrspace', 'ocr_transport_missing:curl'],
|
||||
];
|
||||
}
|
||||
|
||||
$url = trim((string) ($providerConfig['url'] ?? ''));
|
||||
if ($url === '') {
|
||||
return [
|
||||
'text' => '',
|
||||
'flags' => ['ocr_provider_missing:ocrspace', 'ocrspace_url_missing'],
|
||||
];
|
||||
}
|
||||
|
||||
$apiKey = trim((string) ($providerConfig['api_key'] ?? ''));
|
||||
if ($apiKey === '') {
|
||||
return [
|
||||
'text' => '',
|
||||
'flags' => ['ocr_provider_missing:ocrspace', 'ocrspace_api_key_missing'],
|
||||
];
|
||||
}
|
||||
|
||||
$postFields = [
|
||||
'file' => new \CURLFile($imagePath),
|
||||
'language' => (string) ($providerConfig['language'] ?? 'eng'),
|
||||
'OCREngine' => (string) ((int) ($providerConfig['engine'] ?? 2)),
|
||||
'scale' => (string) ($providerConfig['scale'] ?? 'true'),
|
||||
'detectOrientation' => (string) ($providerConfig['detect_orientation'] ?? 'true'),
|
||||
'isTable' => (string) ($providerConfig['is_table'] ?? 'false'),
|
||||
'isOverlayRequired' => 'false',
|
||||
];
|
||||
|
||||
$ch = curl_init();
|
||||
curl_setopt_array($ch, [
|
||||
CURLOPT_URL => $url,
|
||||
CURLOPT_POST => true,
|
||||
CURLOPT_POSTFIELDS => $postFields,
|
||||
CURLOPT_RETURNTRANSFER => true,
|
||||
CURLOPT_FOLLOWLOCATION => true,
|
||||
CURLOPT_TIMEOUT => max(5, (int) ($providerConfig['timeout'] ?? 25)),
|
||||
CURLOPT_HTTPHEADER => [
|
||||
'Accept: application/json',
|
||||
'apikey: ' . $apiKey,
|
||||
],
|
||||
]);
|
||||
|
||||
$response = curl_exec($ch);
|
||||
$curlError = curl_error($ch);
|
||||
$httpStatus = (int) curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
|
||||
curl_close($ch);
|
||||
|
||||
if ($response === false || $curlError !== '') {
|
||||
return [
|
||||
'text' => '',
|
||||
'flags' => ['ocr_provider_empty:ocrspace', 'ocrspace_request_failed'],
|
||||
];
|
||||
}
|
||||
|
||||
$payload = json_decode((string) $response, true);
|
||||
if (!is_array($payload)) {
|
||||
return [
|
||||
'text' => '',
|
||||
'flags' => ['ocr_provider_empty:ocrspace', 'ocrspace_invalid_response'],
|
||||
];
|
||||
}
|
||||
|
||||
$flags = [];
|
||||
$rawText = '';
|
||||
$parsedResults = $payload['ParsedResults'] ?? null;
|
||||
if (is_array($parsedResults)) {
|
||||
$texts = [];
|
||||
foreach ($parsedResults as $result) {
|
||||
if (!is_array($result)) {
|
||||
continue;
|
||||
}
|
||||
$fileExitCode = (string) ($result['FileParseExitCode'] ?? '');
|
||||
if ($fileExitCode !== '') {
|
||||
$flags[] = 'ocrspace_file_exit_code:' . $fileExitCode;
|
||||
}
|
||||
$parsedText = trim((string) ($result['ParsedText'] ?? ''));
|
||||
if ($parsedText !== '') {
|
||||
$texts[] = $parsedText;
|
||||
}
|
||||
$resultError = trim((string) ($result['ErrorMessage'] ?? ''));
|
||||
if ($resultError !== '') {
|
||||
$flags[] = 'ocrspace_result_error';
|
||||
}
|
||||
}
|
||||
$rawText = trim(implode("\n", $texts));
|
||||
}
|
||||
|
||||
$ocrExitCode = (string) ($payload['OCRExitCode'] ?? '');
|
||||
$isErroredOnProcessing = !empty($payload['IsErroredOnProcessing']);
|
||||
$errorMessage = trim((string) ($payload['ErrorMessage'] ?? ''));
|
||||
$errorDetails = trim((string) ($payload['ErrorDetails'] ?? ''));
|
||||
|
||||
if ($httpStatus >= 400) {
|
||||
$flags[] = 'ocrspace_http_error';
|
||||
}
|
||||
|
||||
if ($ocrExitCode !== '') {
|
||||
$flags[] = 'ocrspace_exit_code:' . $ocrExitCode;
|
||||
}
|
||||
|
||||
$flags[] = 'ocrspace_engine:' . (string) ((int) ($providerConfig['engine'] ?? 2));
|
||||
|
||||
if ($isErroredOnProcessing) {
|
||||
$flags[] = 'ocrspace_processing_error';
|
||||
}
|
||||
|
||||
if ($errorMessage !== '' || $errorDetails !== '') {
|
||||
$flags[] = 'ocrspace_error';
|
||||
}
|
||||
|
||||
return [
|
||||
'text' => $rawText,
|
||||
'flags' => $rawText === '' ? array_values(array_unique(array_merge($flags, ['ocr_provider_empty:ocrspace']))) : array_values(array_unique($flags)),
|
||||
];
|
||||
}
|
||||
|
||||
private function runTesseract(array $providerConfig, string $imagePath): array
|
||||
{
|
||||
$binary = (string) ($providerConfig['binary'] ?? 'tesseract');
|
||||
if (!$this->binaryExists($binary)) {
|
||||
return [
|
||||
'text' => '',
|
||||
'flags' => ['ocr_provider_missing:tesseract'],
|
||||
];
|
||||
}
|
||||
|
||||
$language = (string) ($providerConfig['language'] ?? 'eng');
|
||||
$tmpBase = tempnam(sys_get_temp_dir(), 'mc-ocr-');
|
||||
if ($tmpBase === false) {
|
||||
return [
|
||||
'text' => '',
|
||||
'flags' => ['ocr_tempfile_failed:tesseract'],
|
||||
];
|
||||
}
|
||||
|
||||
@unlink($tmpBase);
|
||||
$command = sprintf(
|
||||
'%s %s %s -l %s 2>/dev/null',
|
||||
escapeshellcmd($binary),
|
||||
escapeshellarg($imagePath),
|
||||
escapeshellarg($tmpBase),
|
||||
escapeshellarg($language)
|
||||
);
|
||||
shell_exec($command);
|
||||
|
||||
$txtFile = $tmpBase . '.txt';
|
||||
$text = is_file($txtFile) ? (string) file_get_contents($txtFile) : '';
|
||||
@unlink($txtFile);
|
||||
return [
|
||||
'text' => trim($text),
|
||||
'flags' => trim($text) === '' ? ['ocr_provider_empty:tesseract'] : [],
|
||||
];
|
||||
}
|
||||
|
||||
private function binaryExists(string $binary): bool
|
||||
{
|
||||
return $binary !== '' && trim((string) shell_exec('command -v ' . escapeshellarg($binary) . ' 2>/dev/null')) !== '';
|
||||
}
|
||||
|
||||
private function parseText(string $rawText, string $dateContext, string $walletCurrencyHint = ''): array
|
||||
{
|
||||
$measurement = $this->parseMeasurementText($rawText, $dateContext);
|
||||
$wallet = $this->parseWalletText($rawText, $dateContext, $walletCurrencyHint);
|
||||
|
||||
$isWallet = ($wallet['score'] ?? 0) > ($measurement['score'] ?? 0)
|
||||
&& (
|
||||
($wallet['suggested_wallet']['wallet_balance'] ?? null) !== null
|
||||
|| ($wallet['suggested_wallet']['total_value_amount'] ?? null) !== null
|
||||
);
|
||||
|
||||
return [
|
||||
'kind' => $isWallet ? 'wallet' : 'measurement',
|
||||
'suggested' => $measurement['suggested'],
|
||||
'suggested_wallet' => $wallet['suggested_wallet'],
|
||||
'confidence' => round((float) ($isWallet ? ($wallet['confidence'] ?? 0.0) : ($measurement['confidence'] ?? 0.0)), 4),
|
||||
'flags' => $isWallet ? $wallet['flags'] : $measurement['flags'],
|
||||
];
|
||||
}
|
||||
|
||||
private function parseMeasurementText(string $rawText, string $dateContext): array
|
||||
{
|
||||
$flags = [];
|
||||
$suggestedTime = null;
|
||||
$coinsTotal = null;
|
||||
$price = null;
|
||||
$currency = null;
|
||||
$normalizedText = preg_replace('/[[:space:]]+/u', ' ', trim($rawText)) ?: '';
|
||||
$lines = array_values(array_filter(array_map(
|
||||
static fn (string $line): string => trim($line),
|
||||
preg_split('/\R/u', $rawText) ?: []
|
||||
), static fn (string $line): bool => $line !== ''));
|
||||
|
||||
if ($normalizedText === '') {
|
||||
$flags[] = 'ocr_raw_text_empty';
|
||||
}
|
||||
|
||||
if (preg_match('/\b([01]?\d|2[0-3]):([0-5]\d)\b/', $normalizedText, $timeMatch)) {
|
||||
$suggestedTime = sprintf('%s %02d:%02d:00', $dateContext, (int) $timeMatch[1], (int) $timeMatch[2]);
|
||||
}
|
||||
|
||||
preg_match_all('/\b\d+(?:[.,]\d+)?\b/', $normalizedText, $numberMatches);
|
||||
$decimalCandidates = [];
|
||||
foreach ($numberMatches[0] ?? [] as $candidate) {
|
||||
$normalized = (float) str_replace(',', '.', $candidate);
|
||||
if ($normalized <= 0) {
|
||||
continue;
|
||||
}
|
||||
$decimalCandidates[] = [
|
||||
'raw' => $candidate,
|
||||
'value' => $normalized,
|
||||
'precision' => str_contains($candidate, ',') || str_contains($candidate, '.')
|
||||
? strlen((string) preg_replace('/^\d+[.,]/', '', $candidate))
|
||||
: 0,
|
||||
];
|
||||
}
|
||||
|
||||
if (preg_match('/DOGE\s*\/\s*(USD|EUR|USDT|USDC|BTC|ETH|LTC)/i', $normalizedText, $pairMatch)) {
|
||||
$currency = strtoupper((string) $pairMatch[1]);
|
||||
} elseif (preg_match('/\b(EUR|USD|USDT|USDC|BTC|ETH|LTC)\b/i', $normalizedText, $currencyMatch)) {
|
||||
$currency = strtoupper((string) $currencyMatch[1]);
|
||||
} elseif (str_contains($normalizedText, '$')) {
|
||||
$currency = 'USD';
|
||||
} else {
|
||||
$flags[] = 'currency_missing';
|
||||
}
|
||||
|
||||
if (preg_match('/DOGE\s*\/\s*(?:USD|EUR|USDT|USDC|BTC|ETH|LTC)[^\d]{0,20}(\d+[.,]\d{3,8})/i', $normalizedText, $priceMatch)) {
|
||||
$price = round((float) str_replace(',', '.', $priceMatch[1]), 8);
|
||||
}
|
||||
|
||||
if ($coinsTotal === null) {
|
||||
foreach ($lines as $line) {
|
||||
if (!preg_match('/MINING[- ]?GUTHABEN|MINING[- ]?BALANCE|GUTHABEN|BALANCE/i', $line)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (preg_match('/(\d+[.,]\d{4,8})/', $line, $lineCoinsMatch)) {
|
||||
$coinsTotal = round((float) str_replace(',', '.', $lineCoinsMatch[1]), 6);
|
||||
$flags[] = 'coins_from_balance_line';
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if ($coinsTotal === null && preg_match('/(\d+[.,]\d{4,8})\s*(?:DOGE)?\s*(?:MINING[- ]?GUTHABEN|MINING[- ]?BALANCE|GUTHABEN|BALANCE)/i', $normalizedText, $coinsMatch)) {
|
||||
$coinsTotal = round((float) str_replace(',', '.', $coinsMatch[1]), 6);
|
||||
$flags[] = 'coins_from_balance_context';
|
||||
}
|
||||
|
||||
if ($coinsTotal === null) {
|
||||
$coinsCandidates = array_values(array_filter($decimalCandidates, static function (array $item) use ($price): bool {
|
||||
if ($item['precision'] < 4) {
|
||||
return false;
|
||||
}
|
||||
if ($item['value'] <= 0 || $item['value'] >= 1000000) {
|
||||
return false;
|
||||
}
|
||||
if ($price !== null && abs($item['value'] - $price) < 0.0000005) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}));
|
||||
|
||||
if ($coinsCandidates !== []) {
|
||||
usort($coinsCandidates, static function (array $a, array $b): int {
|
||||
return [$b['precision'], $b['value']] <=> [$a['precision'], $a['value']];
|
||||
});
|
||||
$coinsTotal = round((float) $coinsCandidates[0]['value'], 6);
|
||||
if (count($coinsCandidates) > 1) {
|
||||
$flags[] = 'coins_ambiguous';
|
||||
}
|
||||
} else {
|
||||
$flags[] = 'coins_missing';
|
||||
}
|
||||
}
|
||||
|
||||
$priceCandidates = array_values(array_filter(
|
||||
$decimalCandidates,
|
||||
static fn (array $item): bool => $item['value'] > 0 && $item['value'] < 1
|
||||
));
|
||||
|
||||
if ($price === null && $priceCandidates !== []) {
|
||||
usort($priceCandidates, static function (array $a, array $b): int {
|
||||
return [$b['precision'], $a['value']] <=> [$a['precision'], $b['value']];
|
||||
});
|
||||
$price = round((float) $priceCandidates[0]['value'], 8);
|
||||
if (count($priceCandidates) > 1 && count(array_filter($priceCandidates, static fn (array $item): bool => $item['precision'] >= 4)) > 1) {
|
||||
$flags[] = 'price_ambiguous';
|
||||
}
|
||||
}
|
||||
|
||||
if ($price === null && $coinsTotal !== null && preg_match('/~\s*(\d+[.,]\d+)\s*\$/', $normalizedText, $fiatMatch)) {
|
||||
$fiatValue = (float) str_replace(',', '.', $fiatMatch[1]);
|
||||
if ($fiatValue > 0) {
|
||||
$price = round($fiatValue / $coinsTotal, 8);
|
||||
$flags[] = 'price_derived_from_balance_value';
|
||||
$currency = $currency ?? 'USD';
|
||||
}
|
||||
}
|
||||
|
||||
$matchedFields = 0;
|
||||
foreach ([$coinsTotal, $price, $currency] as $field) {
|
||||
if ($field !== null) {
|
||||
$matchedFields++;
|
||||
}
|
||||
}
|
||||
|
||||
$confidence = max(0.05, min(0.99, ($matchedFields / 3) - (count($flags) * 0.04)));
|
||||
|
||||
return [
|
||||
'suggested' => [
|
||||
'measured_at' => $suggestedTime,
|
||||
'coins_total' => $coinsTotal,
|
||||
'price_per_coin' => $price,
|
||||
'price_currency' => $currency,
|
||||
'note' => null,
|
||||
'source' => 'image_ocr',
|
||||
],
|
||||
'confidence' => round($confidence, 4),
|
||||
'flags' => $flags,
|
||||
'score' => $matchedFields,
|
||||
];
|
||||
}
|
||||
|
||||
private function parseWalletText(string $rawText, string $dateContext, string $walletCurrencyHint = ''): array
|
||||
{
|
||||
$flags = [];
|
||||
$suggestedTime = null;
|
||||
$totalValueAmount = null;
|
||||
$totalValueCurrency = null;
|
||||
$walletBalance = null;
|
||||
$walletCurrency = $walletCurrencyHint !== '' ? $walletCurrencyHint : 'DOGE';
|
||||
$balances = [];
|
||||
|
||||
$normalizedText = preg_replace('/[[:space:]]+/u', ' ', trim($rawText)) ?: '';
|
||||
$lines = array_values(array_filter(array_map(
|
||||
static fn (string $line): string => trim($line),
|
||||
preg_split('/\R/u', $rawText) ?: []
|
||||
), static fn (string $line): bool => $line !== ''));
|
||||
|
||||
if (preg_match('/\b([01]?\d|2[0-3]):([0-5]\d)\b/', $normalizedText, $timeMatch)) {
|
||||
$suggestedTime = sprintf('%s %02d:%02d:00', $dateContext, (int) $timeMatch[1], (int) $timeMatch[2]);
|
||||
}
|
||||
|
||||
if (
|
||||
preg_match('/GESAMTSALDO[^\d]{0,24}(\d+(?:[.,]\d+)?)\s*(USD|EUR|USDT|USDC|BTC|ETH|DOGE|CTC|HSH)/i', $normalizedText, $totalMatch)
|
||||
|| preg_match('/(\d+(?:[.,]\d+)?)\s*(USD|EUR)\b.*GESAMTSALDO/i', $normalizedText, $totalMatch)
|
||||
) {
|
||||
$totalValueAmount = round((float) str_replace(',', '.', $totalMatch[1]), 8);
|
||||
$totalValueCurrency = strtoupper((string) $totalMatch[2]);
|
||||
} else {
|
||||
$flags[] = 'wallet_total_missing';
|
||||
}
|
||||
|
||||
$assetRows = [];
|
||||
foreach ($lines as $lineIndex => $line) {
|
||||
if (!preg_match('/(\d+(?:[.,]\d+)?)\s*([A-Z]{2,10})\b/u', $line, $match)) {
|
||||
continue;
|
||||
}
|
||||
$amount = round((float) str_replace(',', '.', $match[1]), 10);
|
||||
$currency = strtoupper((string) $match[2]);
|
||||
if ($amount <= 0 || $currency === '' || in_array($currency, ['USD', 'EUR'], true)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$assetRows[] = [
|
||||
'index' => $lineIndex,
|
||||
'currency' => $currency,
|
||||
'balance' => $amount,
|
||||
];
|
||||
}
|
||||
|
||||
foreach ($assetRows as $assetIndex => $assetRow) {
|
||||
$currency = (string) $assetRow['currency'];
|
||||
$balanceAmount = (float) $assetRow['balance'];
|
||||
$startIndex = (int) $assetRow['index'];
|
||||
$endIndex = isset($assetRows[$assetIndex + 1]['index'])
|
||||
? (int) $assetRows[$assetIndex + 1]['index']
|
||||
: count($lines);
|
||||
|
||||
$usdCandidates = [];
|
||||
for ($i = $startIndex; $i < $endIndex; $i++) {
|
||||
if (!isset($lines[$i])) {
|
||||
continue;
|
||||
}
|
||||
if (preg_match_all('/(\d+(?:[.,]\d+)?)\s*USD\b/u', $lines[$i], $usdMatches, PREG_SET_ORDER)) {
|
||||
foreach ($usdMatches as $usdMatch) {
|
||||
$usdCandidates[] = round((float) str_replace(',', '.', $usdMatch[1]), 8);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$priceAmount = $this->pickWalletUnitPrice($balanceAmount, $usdCandidates);
|
||||
$balances[$currency] = [
|
||||
'balance' => $balanceAmount,
|
||||
'price_amount' => $priceAmount,
|
||||
'price_currency' => $priceAmount !== null ? 'USD' : null,
|
||||
];
|
||||
}
|
||||
|
||||
if ($walletCurrencyHint !== '' && array_key_exists($walletCurrencyHint, $balances)) {
|
||||
$walletCurrency = $walletCurrencyHint;
|
||||
$walletBalance = is_array($balances[$walletCurrencyHint])
|
||||
? (float) ($balances[$walletCurrencyHint]['balance'] ?? 0.0)
|
||||
: (float) $balances[$walletCurrencyHint];
|
||||
} elseif ($balances !== []) {
|
||||
foreach (['DOGE', 'BTC', 'ETH', 'CTC', 'HSH', 'LTC', 'USDT', 'USDC'] as $preferredCurrency) {
|
||||
if (array_key_exists($preferredCurrency, $balances)) {
|
||||
$walletCurrency = $preferredCurrency;
|
||||
$walletBalance = is_array($balances[$preferredCurrency])
|
||||
? (float) ($balances[$preferredCurrency]['balance'] ?? 0.0)
|
||||
: (float) $balances[$preferredCurrency];
|
||||
break;
|
||||
}
|
||||
}
|
||||
if ($walletBalance === null) {
|
||||
$firstCurrency = array_key_first($balances);
|
||||
if (is_string($firstCurrency)) {
|
||||
$walletCurrency = $firstCurrency;
|
||||
$walletBalance = is_array($balances[$firstCurrency])
|
||||
? (float) ($balances[$firstCurrency]['balance'] ?? 0.0)
|
||||
: (float) $balances[$firstCurrency];
|
||||
}
|
||||
}
|
||||
} else {
|
||||
$flags[] = 'wallet_balance_missing';
|
||||
}
|
||||
|
||||
$walletIndicators = 0;
|
||||
$normalizedLower = strtolower($normalizedText);
|
||||
foreach (['wallets', 'gesamtsaldo', 'alle münzen', 'alle munzen', 'letzte transaktion'] as $indicator) {
|
||||
if (str_contains($normalizedLower, $indicator)) {
|
||||
$walletIndicators++;
|
||||
}
|
||||
}
|
||||
|
||||
$matchedFields = 0;
|
||||
foreach ([$totalValueAmount, $walletBalance, $walletCurrency] as $field) {
|
||||
if ($field !== null && $field !== '') {
|
||||
$matchedFields++;
|
||||
}
|
||||
}
|
||||
$score = $matchedFields + ($walletIndicators * 2);
|
||||
$confidence = max(0.05, min(0.99, ($matchedFields / 3) + (min(3, $walletIndicators) * 0.12) - (count($flags) * 0.03)));
|
||||
|
||||
ksort($balances);
|
||||
|
||||
return [
|
||||
'suggested_wallet' => [
|
||||
'measured_at' => $suggestedTime,
|
||||
'total_value_amount' => $totalValueAmount,
|
||||
'total_value_currency' => $totalValueCurrency,
|
||||
'wallet_balance' => $walletBalance,
|
||||
'wallet_currency' => $walletCurrency,
|
||||
'balances_json' => $balances,
|
||||
'note' => null,
|
||||
'source' => 'image_ocr',
|
||||
],
|
||||
'confidence' => round($confidence, 4),
|
||||
'flags' => array_values(array_unique($flags)),
|
||||
'score' => $score,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<float|int> $candidates
|
||||
*/
|
||||
private function pickWalletUnitPrice(float $balance, array $candidates): ?float
|
||||
{
|
||||
$candidates = array_values(array_filter(array_map(
|
||||
static fn (mixed $value): float => round((float) $value, 8),
|
||||
$candidates
|
||||
), static fn (float $value): bool => $value > 0));
|
||||
|
||||
if ($balance <= 0 || $candidates === []) {
|
||||
return null;
|
||||
}
|
||||
if (count($candidates) === 1) {
|
||||
return $candidates[0];
|
||||
}
|
||||
|
||||
$bestPrice = null;
|
||||
$bestError = null;
|
||||
$candidateCount = count($candidates);
|
||||
|
||||
for ($i = 0; $i < $candidateCount; $i++) {
|
||||
$priceCandidate = $candidates[$i];
|
||||
for ($j = 0; $j < $candidateCount; $j++) {
|
||||
if ($i === $j) {
|
||||
continue;
|
||||
}
|
||||
$totalCandidate = $candidates[$j];
|
||||
$estimatedTotal = $balance * $priceCandidate;
|
||||
$denominator = max(abs($totalCandidate), 0.00000001);
|
||||
$error = abs($estimatedTotal - $totalCandidate) / $denominator;
|
||||
if ($bestError === null || $error < $bestError) {
|
||||
$bestError = $error;
|
||||
$bestPrice = $priceCandidate;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if ($bestPrice !== null && $bestError !== null && $bestError <= 0.2) {
|
||||
return round($bestPrice, 8);
|
||||
}
|
||||
|
||||
return round($candidates[count($candidates) - 1], 8);
|
||||
}
|
||||
}
|
||||
79
modules/mining-checker/src/Domain/SeedData.php
Normal file
79
modules/mining-checker/src/Domain/SeedData.php
Normal file
@@ -0,0 +1,79 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Modules\MiningChecker\Domain;
|
||||
|
||||
final class SeedData
|
||||
{
|
||||
public static function projectKey(): string
|
||||
{
|
||||
return 'doge-main';
|
||||
}
|
||||
|
||||
public static function projectName(): string
|
||||
{
|
||||
return 'DOGE Mining Main';
|
||||
}
|
||||
|
||||
public static function settings(): array
|
||||
{
|
||||
return [
|
||||
'baseline_measured_at' => '2026-03-16 01:32:00',
|
||||
'baseline_coins_total' => 27.617864,
|
||||
'daily_cost_amount' => 0.3123287671,
|
||||
'daily_cost_currency' => 'EUR',
|
||||
'preferred_currencies' => ['DOGE', 'USD', 'EUR'],
|
||||
];
|
||||
}
|
||||
|
||||
public static function currencies(): array
|
||||
{
|
||||
return [
|
||||
['code' => 'EUR', 'name' => 'Euro', 'symbol' => 'EUR', 'is_active' => 1, 'sort_order' => 10],
|
||||
['code' => 'USD', 'name' => 'US-Dollar', 'symbol' => 'USD', 'is_active' => 1, 'sort_order' => 20],
|
||||
['code' => 'DOGE', 'name' => 'Dogecoin', 'symbol' => 'DOGE', 'is_active' => 1, 'sort_order' => 100],
|
||||
['code' => 'BTC', 'name' => 'Bitcoin', 'symbol' => 'BTC', 'is_active' => 1, 'sort_order' => 110],
|
||||
['code' => 'ETH', 'name' => 'Ethereum', 'symbol' => 'ETH', 'is_active' => 1, 'sort_order' => 120],
|
||||
['code' => 'LTC', 'name' => 'Litecoin', 'symbol' => 'LTC', 'is_active' => 1, 'sort_order' => 130],
|
||||
['code' => 'USDT', 'name' => 'Tether', 'symbol' => 'USDT', 'is_active' => 1, 'sort_order' => 140],
|
||||
['code' => 'USDC', 'name' => 'USD Coin', 'symbol' => 'USDC', 'is_active' => 1, 'sort_order' => 150],
|
||||
];
|
||||
}
|
||||
|
||||
public static function measurements(): array
|
||||
{
|
||||
return [
|
||||
['measured_at' => '2026-03-16 01:32:00', 'coins_total' => 27.617864, 'price_per_coin' => null, 'price_currency' => null, 'note' => 'Basiswert', 'source' => 'seed_import'],
|
||||
['measured_at' => '2026-03-17 02:41:00', 'coins_total' => 33.751904, 'price_per_coin' => null, 'price_currency' => null, 'note' => 'Kurs wurde spaeter separat genannt, aber nicht sicher exakt diesem Messpunkt zuordenbar', 'source' => 'seed_import'],
|
||||
['measured_at' => '2026-03-17 07:15:00', 'coins_total' => 34.825695, 'price_per_coin' => 0.10037, 'price_currency' => 'EUR', 'note' => null, 'source' => 'seed_import'],
|
||||
['measured_at' => '2026-03-17 13:21:00', 'coins_total' => 36.328140, 'price_per_coin' => 0.10002, 'price_currency' => 'EUR', 'note' => null, 'source' => 'seed_import'],
|
||||
['measured_at' => '2026-03-17 18:53:00', 'coins_total' => 37.682757, 'price_per_coin' => 0.10062, 'price_currency' => 'EUR', 'note' => null, 'source' => 'seed_import'],
|
||||
['measured_at' => '2026-03-18 00:08:00', 'coins_total' => 38.934351, 'price_per_coin' => 0.10097, 'price_currency' => 'EUR', 'note' => null, 'source' => 'seed_import'],
|
||||
['measured_at' => '2026-03-18 07:40:00', 'coins_total' => 40.782006, 'price_per_coin' => 0.10040, 'price_currency' => 'EUR', 'note' => null, 'source' => 'seed_import'],
|
||||
['measured_at' => '2026-03-18 13:32:00', 'coins_total' => 42.223449, 'price_per_coin' => 0.09607, 'price_currency' => 'EUR', 'note' => 'Originaleingabe im Chat: 18.6.2026', 'source' => 'seed_import'],
|
||||
['measured_at' => '2026-03-18 21:15:00', 'coins_total' => 44.191018, 'price_per_coin' => 0.09446, 'price_currency' => 'EUR', 'note' => null, 'source' => 'seed_import'],
|
||||
['measured_at' => '2026-03-19 00:09:00', 'coins_total' => 44.908500, 'price_per_coin' => 0.09507, 'price_currency' => 'EUR', 'note' => null, 'source' => 'seed_import'],
|
||||
['measured_at' => '2026-03-19 02:33:00', 'coins_total' => 45.546924, 'price_per_coin' => 0.09499, 'price_currency' => 'USD', 'note' => 'aus Screenshot extrahiert', 'source' => 'seed_import', 'ocr_flags' => ['source:screenshot']],
|
||||
['measured_at' => '2026-03-19 07:01:00', 'coins_total' => 46.694127, 'price_per_coin' => 0.09460, 'price_currency' => 'USD', 'note' => 'aus Screenshot extrahiert', 'source' => 'seed_import', 'ocr_flags' => ['source:screenshot']],
|
||||
['measured_at' => '2026-03-19 12:24:00', 'coins_total' => 48.056494, 'price_per_coin' => 0.09419, 'price_currency' => 'USD', 'note' => 'aus Screenshot extrahiert', 'source' => 'seed_import', 'ocr_flags' => ['source:screenshot']],
|
||||
['measured_at' => '2026-03-19 21:39:00', 'coins_total' => 50.427943, 'price_per_coin' => 0.09361, 'price_currency' => 'USD', 'note' => 'aus Screenshot extrahiert', 'source' => 'seed_import', 'ocr_flags' => ['source:screenshot']],
|
||||
];
|
||||
}
|
||||
|
||||
public static function targets(): array
|
||||
{
|
||||
return [
|
||||
['label' => 'Ziel A', 'target_amount_fiat' => 10.82, 'currency' => 'EUR', 'is_active' => 1, 'sort_order' => 10],
|
||||
['label' => 'Ziel B', 'target_amount_fiat' => 19.50, 'currency' => 'EUR', 'is_active' => 1, 'sort_order' => 20],
|
||||
];
|
||||
}
|
||||
|
||||
public static function dashboards(): array
|
||||
{
|
||||
return [
|
||||
['name' => 'Mining-Verlauf', 'chart_type' => 'line', 'x_field' => 'measured_at', 'y_field' => 'coins_total', 'aggregation' => 'none', 'filters' => [], 'is_active' => 1],
|
||||
['name' => 'Performance-Verlauf', 'chart_type' => 'area', 'x_field' => 'measured_date', 'y_field' => 'doge_per_day_interval', 'aggregation' => 'avg', 'filters' => [], 'is_active' => 1],
|
||||
['name' => 'Kurs-Verlauf', 'chart_type' => 'line', 'x_field' => 'measured_at', 'y_field' => 'price_per_coin', 'aggregation' => 'none', 'filters' => [], 'is_active' => 1],
|
||||
];
|
||||
}
|
||||
}
|
||||
62
modules/mining-checker/src/Domain/SeedImporter.php
Normal file
62
modules/mining-checker/src/Domain/SeedImporter.php
Normal file
@@ -0,0 +1,62 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Modules\MiningChecker\Domain;
|
||||
|
||||
use Modules\MiningChecker\Infrastructure\MiningRepository;
|
||||
|
||||
final class SeedImporter
|
||||
{
|
||||
private MiningRepository $repository;
|
||||
|
||||
public function __construct(MiningRepository $repository)
|
||||
{
|
||||
$this->repository = $repository;
|
||||
}
|
||||
|
||||
public function import(string $projectKey): array
|
||||
{
|
||||
$seedProjectKey = SeedData::projectKey();
|
||||
if ($projectKey !== $seedProjectKey) {
|
||||
return ['inserted' => 0, 'project_key' => $projectKey, 'warning' => 'Seed-Daten sind nur fuer doge-main definiert.'];
|
||||
}
|
||||
|
||||
$this->repository->ensureProject($projectKey, SeedData::projectName());
|
||||
$this->repository->saveSettings($projectKey, SeedData::settings());
|
||||
|
||||
$insertedMeasurements = 0;
|
||||
foreach (SeedData::measurements() as $measurement) {
|
||||
try {
|
||||
$this->repository->createMeasurement($projectKey, array_merge([
|
||||
'image_path' => null,
|
||||
'ocr_raw_text' => null,
|
||||
'ocr_confidence' => null,
|
||||
'ocr_flags' => null,
|
||||
], $measurement));
|
||||
$insertedMeasurements++;
|
||||
} catch (\Throwable $exception) {
|
||||
// Duplicate seeds are expected on repeated imports.
|
||||
}
|
||||
}
|
||||
|
||||
$targetCount = 0;
|
||||
foreach (SeedData::targets() as $target) {
|
||||
$this->repository->saveTarget($projectKey, $target);
|
||||
$targetCount++;
|
||||
}
|
||||
|
||||
$dashboardCount = 0;
|
||||
foreach (SeedData::dashboards() as $dashboard) {
|
||||
$this->repository->saveDashboard($projectKey, $dashboard);
|
||||
$dashboardCount++;
|
||||
}
|
||||
|
||||
return [
|
||||
'project_key' => $projectKey,
|
||||
'imported_measurements' => $insertedMeasurements,
|
||||
'historical_rows_total' => count(SeedData::measurements()),
|
||||
'targets_synced' => $targetCount,
|
||||
'dashboards_synced' => $dashboardCount,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Modules\MiningChecker\Infrastructure;
|
||||
|
||||
use App\Database as AppDatabase;
|
||||
use Modules\MiningChecker\Support\ApiException;
|
||||
use PDO;
|
||||
|
||||
final class ConnectionFactory
|
||||
{
|
||||
public static function make(ModuleConfig $config): PDO
|
||||
{
|
||||
$moduleSettings = modules()->settings('mining-checker');
|
||||
$useSeparateDb = self::usesSeparateDatabase($moduleSettings);
|
||||
|
||||
if ($useSeparateDb) {
|
||||
$dbConfig = is_array($moduleSettings['db'] ?? null) ? $moduleSettings['db'] : [];
|
||||
if ($dbConfig === []) {
|
||||
throw new ApiException('Custom-Datenbank ist aktiviert, aber nicht vollstaendig konfiguriert.', 500);
|
||||
}
|
||||
self::assertSupportedDriver($dbConfig);
|
||||
if (method_exists(AppDatabase::class, 'connectFromConfig')) {
|
||||
return AppDatabase::connectFromConfig($dbConfig);
|
||||
}
|
||||
return AppDatabase::createFromArray($dbConfig);
|
||||
}
|
||||
|
||||
$dbConfig = app()->config()->dbConfig;
|
||||
if ($dbConfig === []) {
|
||||
throw new ApiException('Projekt-Datenbankkonfiguration fehlt in config/db_settings_basic.php.', 500);
|
||||
}
|
||||
|
||||
self::assertSupportedDriver($dbConfig);
|
||||
|
||||
if (method_exists(AppDatabase::class, 'connectFromConfig')) {
|
||||
return AppDatabase::connectFromConfig($dbConfig);
|
||||
}
|
||||
|
||||
return AppDatabase::createFromArray($dbConfig);
|
||||
}
|
||||
|
||||
private static function usesSeparateDatabase(array $moduleSettings): bool
|
||||
{
|
||||
$raw = $moduleSettings['use_separate_db'] ?? false;
|
||||
if (is_bool($raw)) {
|
||||
return $raw;
|
||||
}
|
||||
|
||||
$normalized = strtolower(trim((string) $raw));
|
||||
return in_array($normalized, ['1', 'true', 'yes', 'on', 'custom'], true);
|
||||
}
|
||||
|
||||
private static function assertSupportedDriver(array $dbConfig): void
|
||||
{
|
||||
$driver = strtolower((string) ($dbConfig['driver'] ?? ($dbConfig['dsn'] ?? '')));
|
||||
if ($driver !== '' && !in_array($driver, ['mysql', 'pgsql'], true) && !str_starts_with($driver, 'mysql:') && !str_starts_with($driver, 'pgsql:')) {
|
||||
throw new ApiException(
|
||||
'Mining-Checker unterstuetzt aktuell MySQL/MariaDB und PostgreSQL. Stelle den Driver auf mysql oder pgsql.',
|
||||
500,
|
||||
['driver' => $dbConfig['driver'] ?? 'unknown']
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
1401
modules/mining-checker/src/Infrastructure/MiningRepository.php
Normal file
1401
modules/mining-checker/src/Infrastructure/MiningRepository.php
Normal file
File diff suppressed because it is too large
Load Diff
66
modules/mining-checker/src/Infrastructure/ModuleConfig.php
Normal file
66
modules/mining-checker/src/Infrastructure/ModuleConfig.php
Normal file
@@ -0,0 +1,66 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Modules\MiningChecker\Infrastructure;
|
||||
|
||||
final class ModuleConfig
|
||||
{
|
||||
private array $config;
|
||||
|
||||
public function __construct(array $config)
|
||||
{
|
||||
$this->config = $config;
|
||||
}
|
||||
|
||||
public static function load(string $moduleBasePath): self
|
||||
{
|
||||
$config = require $moduleBasePath . '/config/module.php';
|
||||
return new self(is_array($config) ? $config : []);
|
||||
}
|
||||
|
||||
public function defaultProjectKey(): string
|
||||
{
|
||||
return (string) ($this->config['default_project_key'] ?? 'doge-main');
|
||||
}
|
||||
|
||||
public function useProjectDatabase(): bool
|
||||
{
|
||||
return (bool) ($this->config['use_project_database'] ?? true);
|
||||
}
|
||||
|
||||
public function tablePrefix(): string
|
||||
{
|
||||
return (string) ($this->config['table_prefix'] ?? 'miningcheck_');
|
||||
}
|
||||
|
||||
public function uploadsDir(): string
|
||||
{
|
||||
return (string) ($this->config['uploads_dir'] ?? sys_get_temp_dir());
|
||||
}
|
||||
|
||||
public function uploadsPublicPrefix(): string
|
||||
{
|
||||
return rtrim((string) ($this->config['uploads_public_prefix'] ?? '/uploads'), '/');
|
||||
}
|
||||
|
||||
public function ocr(): array
|
||||
{
|
||||
return (array) ($this->config['ocr'] ?? []);
|
||||
}
|
||||
|
||||
public function fx(): array
|
||||
{
|
||||
return (array) ($this->config['fx'] ?? []);
|
||||
}
|
||||
|
||||
public function debug(): array
|
||||
{
|
||||
return (array) ($this->config['debug'] ?? []);
|
||||
}
|
||||
|
||||
public function debugDir(): string
|
||||
{
|
||||
$debug = $this->debug();
|
||||
return (string) ($debug['dir'] ?? (dirname($this->uploadsDir()) . '/debug'));
|
||||
}
|
||||
}
|
||||
1482
modules/mining-checker/src/Infrastructure/SchemaManager.php
Normal file
1482
modules/mining-checker/src/Infrastructure/SchemaManager.php
Normal file
File diff suppressed because it is too large
Load Diff
29
modules/mining-checker/src/Support/ApiException.php
Normal file
29
modules/mining-checker/src/Support/ApiException.php
Normal file
@@ -0,0 +1,29 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Modules\MiningChecker\Support;
|
||||
|
||||
use RuntimeException;
|
||||
|
||||
final class ApiException extends RuntimeException
|
||||
{
|
||||
private int $statusCode;
|
||||
private array $context;
|
||||
|
||||
public function __construct(string $message, int $statusCode = 400, array $context = [])
|
||||
{
|
||||
parent::__construct($message);
|
||||
$this->statusCode = $statusCode;
|
||||
$this->context = $context;
|
||||
}
|
||||
|
||||
public function statusCode(): int
|
||||
{
|
||||
return $this->statusCode;
|
||||
}
|
||||
|
||||
public function context(): array
|
||||
{
|
||||
return $this->context;
|
||||
}
|
||||
}
|
||||
35
modules/mining-checker/src/Support/DebugState.php
Normal file
35
modules/mining-checker/src/Support/DebugState.php
Normal file
@@ -0,0 +1,35 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Modules\MiningChecker\Support;
|
||||
|
||||
final class DebugState
|
||||
{
|
||||
private static array $trace = [];
|
||||
private static ?string $latestFilePath = null;
|
||||
|
||||
public static function replace(array $trace): void
|
||||
{
|
||||
self::$trace = $trace;
|
||||
}
|
||||
|
||||
public static function export(): array
|
||||
{
|
||||
return self::$trace;
|
||||
}
|
||||
|
||||
public static function clear(): void
|
||||
{
|
||||
self::$trace = [];
|
||||
}
|
||||
|
||||
public static function setLatestFilePath(?string $filePath): void
|
||||
{
|
||||
self::$latestFilePath = $filePath;
|
||||
}
|
||||
|
||||
public static function latestFilePath(): ?string
|
||||
{
|
||||
return self::$latestFilePath;
|
||||
}
|
||||
}
|
||||
60
modules/mining-checker/src/Support/DebugTrace.php
Normal file
60
modules/mining-checker/src/Support/DebugTrace.php
Normal file
@@ -0,0 +1,60 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Modules\MiningChecker\Support;
|
||||
|
||||
final class DebugTrace
|
||||
{
|
||||
private bool $enabled;
|
||||
private array $entries = [];
|
||||
private ?string $filePath;
|
||||
|
||||
public function __construct(bool $enabled = false, ?string $filePath = null)
|
||||
{
|
||||
$this->enabled = $enabled;
|
||||
$this->filePath = $enabled ? $filePath : null;
|
||||
DebugState::replace([]);
|
||||
if ($this->enabled && $this->filePath !== null) {
|
||||
$this->persist();
|
||||
}
|
||||
}
|
||||
|
||||
public function enabled(): bool
|
||||
{
|
||||
return $this->enabled;
|
||||
}
|
||||
|
||||
public function add(string $event, array $context = []): void
|
||||
{
|
||||
if (!$this->enabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
$this->entries[] = [
|
||||
'time' => date('c'),
|
||||
'event' => $event,
|
||||
'context' => $context,
|
||||
];
|
||||
DebugState::replace($this->entries);
|
||||
$this->persist();
|
||||
}
|
||||
|
||||
public function export(): array
|
||||
{
|
||||
return $this->enabled ? $this->entries : [];
|
||||
}
|
||||
|
||||
private function persist(): void
|
||||
{
|
||||
if (!$this->enabled || $this->filePath === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
$directory = dirname($this->filePath);
|
||||
if (!is_dir($directory)) {
|
||||
@mkdir($directory, 0775, true);
|
||||
}
|
||||
|
||||
@file_put_contents($this->filePath, json_encode($this->entries, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_PRETTY_PRINT));
|
||||
}
|
||||
}
|
||||
27
modules/mining-checker/src/Support/Http.php
Normal file
27
modules/mining-checker/src/Support/Http.php
Normal file
@@ -0,0 +1,27 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Modules\MiningChecker\Support;
|
||||
|
||||
final class Http
|
||||
{
|
||||
public static function json(array $payload, int $statusCode = 200): never
|
||||
{
|
||||
http_response_code($statusCode);
|
||||
header('Content-Type: application/json; charset=utf-8');
|
||||
echo json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
|
||||
exit;
|
||||
}
|
||||
|
||||
public static function input(): array
|
||||
{
|
||||
$contentType = $_SERVER['CONTENT_TYPE'] ?? '';
|
||||
if (str_contains($contentType, 'application/json')) {
|
||||
$raw = file_get_contents('php://input');
|
||||
$data = json_decode($raw ?: '[]', true);
|
||||
return is_array($data) ? $data : [];
|
||||
}
|
||||
|
||||
return $_POST;
|
||||
}
|
||||
}
|
||||
0
modules/mining-checker/storage/uploads/.gitkeep
Normal file
0
modules/mining-checker/storage/uploads/.gitkeep
Normal file
@@ -269,32 +269,8 @@
|
||||
padding: 0 6px;
|
||||
}
|
||||
|
||||
.modal {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(10, 14, 24, 0.55);
|
||||
display: none;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 24px;
|
||||
z-index: 40;
|
||||
}
|
||||
.modal.is-open { display: flex; }
|
||||
.modal-card {
|
||||
width: min(1100px, 96vw);
|
||||
max-height: 90vh;
|
||||
overflow: auto;
|
||||
background: var(--panel);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 16px;
|
||||
box-shadow: var(--shadow);
|
||||
padding: 16px;
|
||||
}
|
||||
.modal-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
}
|
||||
.modal-actions {
|
||||
display: inline-flex;
|
||||
|
||||
14
modules/pi_control/design.json
Normal file
14
modules/pi_control/design.json
Normal file
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"eyebrow": "Modul",
|
||||
"title": "Pi Control",
|
||||
"description": "Verwaltung und Steuerung von Raspberry Pis per SSH, Presets und Konsole.",
|
||||
"actions": [
|
||||
{ "label": "Setup", "href": "/modules/setup/pi_control", "variant": "secondary" }
|
||||
],
|
||||
"tabs": [
|
||||
{ "label": "Ueberblick", "href": "/module/pi_control", "match_prefixes": ["/module/pi_control"] },
|
||||
{ "label": "Hosts", "href": "/module/pi_control/hosts", "match_prefixes": ["/module/pi_control/hosts"] },
|
||||
{ "label": "Befehle", "href": "/module/pi_control/commands", "match_prefixes": ["/module/pi_control/commands"] },
|
||||
{ "label": "Konsole", "href": "/module/pi_control/console", "match_prefixes": ["/module/pi_control/console"] }
|
||||
]
|
||||
}
|
||||
@@ -2,28 +2,6 @@
|
||||
"title": "Pi Control",
|
||||
"version": "0.1.0",
|
||||
"description": "Verwaltung und Steuerung von Raspberry Pis (SSH/Commands/Presets).",
|
||||
"menu": [
|
||||
{ "label": "Übersicht", "href": "/module/pi_control" },
|
||||
{ "label": "Konsole", "href": "/module/pi_control/console" },
|
||||
{
|
||||
"label": "Settings",
|
||||
"children": [
|
||||
{ "label": "Hosts", "href": "/module/pi_control/hosts" },
|
||||
{ "label": "Befehle", "href": "/module/pi_control/commands" }
|
||||
]
|
||||
}
|
||||
],
|
||||
"sidebar": {
|
||||
"enabled": true,
|
||||
"collapsible": true,
|
||||
"default": "collapsed",
|
||||
"items": [
|
||||
{ "label": "Übersicht", "href": "/module/pi_control" },
|
||||
{ "label": "Konsole", "href": "/module/pi_control/console" },
|
||||
{ "label": "Hosts", "href": "/module/pi_control/hosts" },
|
||||
{ "label": "Befehle", "href": "/module/pi_control/commands" }
|
||||
]
|
||||
},
|
||||
"setup": {
|
||||
"fields": [
|
||||
{ "name": "use_separate_db", "label": "Eigene Modul-DB nutzen", "type": "checkbox", "required": false, "help": "Wenn aktiv, werden die DB-Daten unten verwendet. Sonst wird die Base-DB genutzt." },
|
||||
@@ -34,7 +12,7 @@
|
||||
{ "name": "db.schema", "label": "DB Schema", "type": "text", "required": false },
|
||||
{ "name": "db.user", "label": "DB User", "type": "text", "required": false },
|
||||
{ "name": "db.password", "label": "DB Passwort", "type": "password", "required": false },
|
||||
{ "name": "ttyd_url", "label": "ttyd URL", "type": "text", "required": false, "help": "z.B. https://staging.nexus.int.kusche.berlin/ttyd" },
|
||||
{ "name": "ttyd_url", "label": "ttyd URL", "type": "text", "required": false, "help": "z.B. https://staging.nexus.kusche.berlin/ttyd" },
|
||||
{ "name": "terminal_token_ttl", "label": "Token TTL (Minuten)", "type": "number", "required": false, "help": "Gültigkeit der Konsole-Token, z.B. 10" },
|
||||
{ "name": "terminal_shared_secret", "label": "Terminal Shared Secret", "type": "password", "required": false, "help": "Zusätzliche Absicherung für terminal_info (Header X-Terminal-Secret)" },
|
||||
{ "name": "terminal_tmux_session", "label": "tmux Session-Name", "type": "text", "required": false, "help": "Session-Name für bestehende Konsole (Standard: nexus)" },
|
||||
|
||||
@@ -79,26 +79,30 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||
|
||||
$commands = $pdo->query('SELECT * FROM ' . $table('commands') . ' ORDER BY COALESCE(sort_order, id) ASC, id ASC')->fetchAll(PDO::FETCH_ASSOC);
|
||||
?>
|
||||
<div class="card">
|
||||
<div class="pill">Pi Control</div>
|
||||
<div style="display:flex; align-items:center; justify-content:space-between; gap:12px; flex-wrap:wrap; margin-top:.75rem;">
|
||||
<h1 style="margin:0;">Befehle</h1>
|
||||
<button class="cta-button" type="button" data-command-new>+ Neuer Befehl</button>
|
||||
</div>
|
||||
<p class="muted">Verwalte vordefinierte SSH-Befehle.</p>
|
||||
|
||||
<?php if ($error): ?>
|
||||
<div class="card notice-card" style="margin-top:1rem; border-color:#ffb4a8; background:#fff5f3; color:#7a2114;">
|
||||
<?= e($error) ?>
|
||||
<?= module_shell_header('pi_control', [
|
||||
'title' => 'Befehle',
|
||||
]) ?>
|
||||
<div class="module-flow">
|
||||
<section class="module-box">
|
||||
<div class="module-box-head">
|
||||
<div>
|
||||
<h2 class="module-box-title">Befehle</h2>
|
||||
<p>Verwalte vordefinierte SSH-Befehle.</p>
|
||||
</div>
|
||||
<button class="module-button module-button--primary" type="button" data-command-new>+ Neuer Befehl</button>
|
||||
</div>
|
||||
<?php elseif ($notice): ?>
|
||||
<div class="card notice-card" style="margin-top:1rem; border-color:var(--accent-2);">
|
||||
<?= e($notice) ?>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
|
||||
<div class="grid" style="margin-top:1rem;">
|
||||
<div class="card" style="background:var(--panel-2);">
|
||||
<?php if ($error): ?>
|
||||
<div class="setup-db-message setup-db-message--error" style="margin-top:16px;">
|
||||
<?= e($error) ?>
|
||||
</div>
|
||||
<?php elseif ($notice): ?>
|
||||
<div class="setup-db-message setup-db-message--success" style="margin-top:16px;">
|
||||
<?= e($notice) ?>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
|
||||
<div class="module-box" style="margin-top:16px;">
|
||||
<strong>Vorhandene Befehle</strong>
|
||||
<?php if (!$commands): ?>
|
||||
<div class="muted" style="margin-top:.75rem;">Keine Befehle vorhanden.</div>
|
||||
@@ -142,7 +146,7 @@ $commands = $pdo->query('SELECT * FROM ' . $table('commands') . ' ORDER BY COALE
|
||||
<p class="muted" style="margin-top:.5rem;">Reihenfolge per Drag & Drop ändern.</p>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<div class="modal" data-command-modal aria-hidden="true">
|
||||
@@ -174,3 +178,4 @@ $commands = $pdo->query('SELECT * FROM ' . $table('commands') . ' ORDER BY COALE
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
<?= module_shell_footer() ?>
|
||||
|
||||
@@ -418,26 +418,32 @@ function sendToActiveConsole(array $host, string $command, bool $strictHostKey):
|
||||
return [false, $msg !== '' ? $msg : 'Befehl konnte nicht gesendet werden.'];
|
||||
}
|
||||
?>
|
||||
<div class="card">
|
||||
<div class="pill">Pi Control</div>
|
||||
<h1 style="margin-top:.75rem;">Konsole</h1>
|
||||
<p class="muted">Wähle einen Host und führe einen Befehl aus.</p>
|
||||
|
||||
<?php if ($error): ?>
|
||||
<div class="card notice-card" style="margin-top:1rem; border-color:#ffb4a8; background:#fff5f3; color:#7a2114;">
|
||||
<?= e($error) ?>
|
||||
<?= module_shell_header('pi_control', [
|
||||
'title' => 'Konsole',
|
||||
]) ?>
|
||||
<div class="module-flow">
|
||||
<section class="module-box">
|
||||
<div class="module-box-head">
|
||||
<div>
|
||||
<h2 class="module-box-title">Konsole</h2>
|
||||
<p>Wähle einen Host und führe einen Befehl aus.</p>
|
||||
</div>
|
||||
</div>
|
||||
<?php elseif ($notice): ?>
|
||||
<div class="card notice-card" style="margin-top:1rem; border-color:var(--accent-2);">
|
||||
<?= e($notice) ?>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
|
||||
<div class="grid" style="margin-top:1rem;">
|
||||
<div class="card form-card" style="background:var(--panel-2);">
|
||||
<?php if ($error): ?>
|
||||
<div class="setup-db-message setup-db-message--error" style="margin-top:16px;">
|
||||
<?= e($error) ?>
|
||||
</div>
|
||||
<?php elseif ($notice): ?>
|
||||
<div class="setup-db-message setup-db-message--success" style="margin-top:16px;">
|
||||
<?= e($notice) ?>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
|
||||
<div class="module-box" style="margin-top:16px;">
|
||||
<strong>Live-Konsole</strong>
|
||||
<div class="card" style="margin-top:1rem; border-color:#ffb4a8; background:#fff5f3; color:#7a2114; display:none;" data-console-error></div>
|
||||
<div class="card" style="margin-top:1rem; border-color:var(--accent-2); display:none;" data-console-notice></div>
|
||||
<div class="setup-db-message setup-db-message--error" style="margin-top:1rem; display:none;" data-console-error></div>
|
||||
<div class="setup-db-message setup-db-message--success" style="margin-top:1rem; display:none;" data-console-notice></div>
|
||||
<form method="post" class="form-grid" style="margin-top:.75rem;" data-console-form>
|
||||
<label class="form-field">
|
||||
<span class="muted">Host</span>
|
||||
@@ -516,5 +522,6 @@ function sendToActiveConsole(array $host, string $command, bool $strictHostKey):
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
<?= module_shell_footer() ?>
|
||||
|
||||
@@ -333,29 +333,34 @@ function hostAuthOk(array $host, bool $strictHostKey): bool
|
||||
return $exitCode === 0;
|
||||
}
|
||||
?>
|
||||
<div class="card">
|
||||
<div class="pill">Pi Control</div>
|
||||
<div style="display:flex; align-items:center; justify-content:space-between; gap:12px; flex-wrap:wrap; margin-top:.75rem;">
|
||||
<h1 style="margin:0;">Hosts</h1>
|
||||
<div style="display:flex; gap:10px; flex-wrap:wrap;">
|
||||
<button class="nav-link" type="button" data-host-check-all>Alle Hosts prüfen</button>
|
||||
<button class="cta-button" type="button" data-host-new>+ Neuer Host</button>
|
||||
<?= module_shell_header('pi_control', [
|
||||
'title' => 'Hosts',
|
||||
]) ?>
|
||||
<div class="module-flow">
|
||||
<section class="module-box">
|
||||
<div class="module-box-head">
|
||||
<div>
|
||||
<h2 class="module-box-title">Hosts</h2>
|
||||
<p>Verwalte die Raspberry Pis, die du steuern möchtest.</p>
|
||||
</div>
|
||||
<div style="display:flex; gap:10px; flex-wrap:wrap;">
|
||||
<button class="module-button module-button--secondary module-button--small" type="button" data-host-check-all>Alle Hosts prüfen</button>
|
||||
<button class="module-button module-button--primary" type="button" data-host-new>+ Neuer Host</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<p class="muted">Verwalte die Raspberry Pis, die du steuern möchtest.</p>
|
||||
|
||||
<?php if ($error): ?>
|
||||
<div class="card notice-card" style="margin-top:1rem; border-color:#ffb4a8; background:#fff5f3; color:#7a2114;">
|
||||
<?= e($error) ?>
|
||||
</div>
|
||||
<?php elseif ($notice): ?>
|
||||
<div class="card notice-card" style="margin-top:1rem; border-color:var(--accent-2);">
|
||||
<?= e($notice) ?>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
<?php if ($error): ?>
|
||||
<div class="setup-db-message setup-db-message--error" style="margin-top:16px;">
|
||||
<?= e($error) ?>
|
||||
</div>
|
||||
<?php elseif ($notice): ?>
|
||||
<div class="setup-db-message setup-db-message--success" style="margin-top:16px;">
|
||||
<?= e($notice) ?>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
|
||||
<div class="grid" style="margin-top:1rem;">
|
||||
<div class="card form-card" style="background:var(--panel-2);">
|
||||
<div class="module-box-grid module-box-grid--panels" style="margin-top:16px;">
|
||||
<div class="module-box form-card">
|
||||
<strong>Registrierte Hosts</strong>
|
||||
<?php if (!$hosts): ?>
|
||||
<div class="muted" style="margin-top:.75rem;">Keine Hosts vorhanden.</div>
|
||||
@@ -416,7 +421,9 @@ function hostAuthOk(array $host, bool $strictHostKey): bool
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<div class="modal" data-host-modal aria-hidden="true">
|
||||
@@ -469,3 +476,4 @@ function hostAuthOk(array $host, bool $strictHostKey): bool
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
<?= module_shell_footer() ?>
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user