Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
173d6d2e61 | ||
|
|
409a02561d | ||
|
|
8119aaad66 | ||
|
|
ee36f78cc3 | ||
|
|
38e674de34 | ||
|
|
b745910afa | ||
|
|
840e902b26 | ||
|
|
5e5028c53d | ||
|
|
83320f0443 | ||
|
|
f076f49631 | ||
|
|
8e5bf74a30 |
@@ -17,6 +17,44 @@ und dieses Projekt folgt [Semantic Versioning](https://semver.org/lang/de/).
|
|||||||
|
|
||||||
### Sicherheit
|
### Sicherheit
|
||||||
|
|
||||||
|
## [0.27.0] – 2026-10-10
|
||||||
|
|
||||||
|
### Hinzugefügt
|
||||||
|
- `deploy/restore.sh`: spielt Datenbank **und** hochgeladene PDFs aus einer Sicherung zurück, startet die Dienste und prüft die Erreichbarkeit (#045)
|
||||||
|
|
||||||
|
### Behoben
|
||||||
|
- **Sicherungen ließen sich nur in eine leere Datenbank zurückspielen** – im Ernstfall also gerade nicht. `pg_dump` läuft jetzt mit `--clean --if-exists` (#045)
|
||||||
|
- Für die hochgeladenen PDFs gab es keinen beschriebenen Weg zurück (#045)
|
||||||
|
|
||||||
|
### Dokumentation
|
||||||
|
- `docs/betrieb/sicherung.md` beschreibt den **geprobten** Ablauf: Daten zerstört, zurückgespielt, geprüft (#045)
|
||||||
|
|
||||||
|
## [0.26.1] – 2026-10-10
|
||||||
|
|
||||||
|
### Sicherheit
|
||||||
|
- `sharp` auf 0.35.5 angehoben: Für ältere Fassungen wurde eine als hoch eingestufte Lücke in der mitgelieferten librsvg gemeldet (CVE-2026-96889). ProxLedger nutzt `sharp` nur beim Erzeugen der Symbole, nicht zur Laufzeit – die Fassung gehört trotzdem aktualisiert
|
||||||
|
- `source-map-js` über `overrides` auf 1.2.2 angehoben (Denial of Service, kam über postcss und vite mit)
|
||||||
|
- Next.js auf 16.4.0
|
||||||
|
|
||||||
|
## [0.26.0] – 2026-10-04
|
||||||
|
|
||||||
|
### Hinzugefügt
|
||||||
|
- Kennzeichen **Eigenbedarf** am Kunden: für eigene Hosts entfallen Kundenrechnung, die zugehörigen Warnungen, die Aufgabe „Kundenrechnung erstellen“ und die Marge – die Proxmox-Rechnung wird weiterhin geführt (#044)
|
||||||
|
|
||||||
|
## [0.25.0] – 2026-10-03
|
||||||
|
|
||||||
|
### Hinzugefügt
|
||||||
|
- **Laufzeit von Hand anlegen** – für Hosts, die (noch) kein Ablaufdatum melden, und für Nachträge (#043)
|
||||||
|
- **Laufzeit bearbeiten**: Key, Beginn, Ende und Notiz lassen sich nachträglich ändern (#043)
|
||||||
|
- **Laufzeiten zusammenführen**, wenn neben einer erfassten Laufzeit ein Vorschlag mit abweichendem Datum steht; die Rechnungen wandern mit (#043)
|
||||||
|
- Kundenansicht: Laufzeit und Host verlinkt, dazu Marge und Rabatt je Zeile (#043)
|
||||||
|
|
||||||
|
## [0.24.1] – 2026-10-03
|
||||||
|
|
||||||
|
### Behoben
|
||||||
|
- **Cluster mit QDevice meldeten falsche Knotennamen** (`A,V,NMW`, `NR`) und das QDevice als zusätzlichen Knoten: `pvecm nodes` hat dann eine Spalte mehr. Die Namensspalte wird jetzt aus der Kopfzeile bestimmt (#042)
|
||||||
|
- Agent-Version 0.2.2
|
||||||
|
|
||||||
## [0.24.0] – 2026-10-03
|
## [0.24.0] – 2026-10-03
|
||||||
|
|
||||||
### Hinzugefügt
|
### Hinzugefügt
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
## Zeitübersicht
|
## Zeitübersicht
|
||||||
|
|
||||||
**Gesamt:** 22 h 33 min
|
**Gesamt:** 25 h 28 min
|
||||||
|
|
||||||
| Issue | Zeit |
|
| Issue | Zeit |
|
||||||
|---|---|
|
|---|---|
|
||||||
@@ -47,6 +47,10 @@
|
|||||||
| #039 Zugang zur Laufzeit | 0 h 30 min |
|
| #039 Zugang zur Laufzeit | 0 h 30 min |
|
||||||
| #040 Schlagworte sichtbar | 0 h 40 min |
|
| #040 Schlagworte sichtbar | 0 h 40 min |
|
||||||
| #041 Doppelte Hosts | 0 h 50 min |
|
| #041 Doppelte Hosts | 0 h 50 min |
|
||||||
|
| #042 QDevice im Cluster | 0 h 20 min |
|
||||||
|
| #043 Laufzeiten bearbeiten | 1 h 05 min |
|
||||||
|
| #044 Eigenbedarf | 0 h 40 min |
|
||||||
|
| #045 Wiederherstellung geprobt | 0 h 50 min |
|
||||||
|
|
||||||
| Meilenstein | Zeit |
|
| Meilenstein | Zeit |
|
||||||
|---|---|
|
|---|---|
|
||||||
@@ -69,7 +73,7 @@
|
|||||||
|
|
||||||
## 2026-09-17 · Sitzung 1 · 19:36–laufend
|
## 2026-09-17 · Sitzung 1 · 19:36–laufend
|
||||||
**Issue:** #001–#007 (Meilensteine v0.1.0 bis v0.4.0)
|
**Issue:** #001–#007 (Meilensteine v0.1.0 bis v0.4.0)
|
||||||
**Version:** 0.24.0
|
**Version:** 0.27.0
|
||||||
|
|
||||||
### Erledigt
|
### Erledigt
|
||||||
- Auftrag gelesen, Rückfragen gestellt
|
- Auftrag gelesen, Rückfragen gestellt
|
||||||
@@ -212,6 +216,22 @@
|
|||||||
- Doppelte Hosts (#041): Zwei Einträge desselben Namens beim selben Kunden entstehen, wenn sich die Maschinenkennung ändert. Neue Warnregel plus die bis dahin fehlende Möglichkeit, einen Host überhaupt loszuwerden
|
- Doppelte Hosts (#041): Zwei Einträge desselben Namens beim selben Kunden entstehen, wenn sich die Maschinenkennung ändert. Neue Warnregel plus die bis dahin fehlende Möglichkeit, einen Host überhaupt loszuwerden
|
||||||
- Bewusst kein automatisches Zusammenführen: Es müsste raten, welcher Eintrag welche Rechnung erbt
|
- Bewusst kein automatisches Zusammenführen: Es müsste raten, welcher Eintrag welche Rechnung erbt
|
||||||
|
|
||||||
|
- QDevice im Cluster (#042): Der Agent nahm blind die dritte Spalte von `pvecm nodes` als Namen. Mit QDevice steht dort der Zustand des QDevice
|
||||||
|
- Dritter Fall dieser Art: Was beim Feldtest auf einem Cluster stimmte, stimmt nicht überall. Spalten gehören über ihre Überschrift angesprochen
|
||||||
|
|
||||||
|
- Laufzeiten (#043): Anlegen gab es nur als Server-Aktion ohne Oberfläche, Ändern und Zusammenführen gar nicht. Dabei tritt der Doppelfall zwangsläufig auf, sobald das gemeldete Ablaufdatum von der Bestellung abweicht
|
||||||
|
- Beim Zusammenführen muss die Quelle ihre Rechnungsposition zuerst loslassen – die Datenbank lässt eine Position nur an einer Laufzeit zu. Der Test hat die falsche Reihenfolge sofort gezeigt
|
||||||
|
|
||||||
|
- Eigenbedarf (#044): Eigene Hosts brauchen einen Kunden, sonst fehlen sie in jeder Auswertung – als gewöhnlicher Kunde erzeugen sie aber Aufgaben, die es nicht gibt, und eine negative Marge
|
||||||
|
- Die Einkaufsseite bleibt vollständig: Eigene Hosts kosten echtes Geld
|
||||||
|
|
||||||
|
### 10.10.2026 – Wartungsblick
|
||||||
|
- `npm audit` turnusmäßig geprüft: zwei neue hohe Meldungen (`sharp`/librsvg, `source-map-js`). Beide angehoben, Tests und Build grün
|
||||||
|
- Produktivinstanz antwortet: `https://proxledger.wilde-ste.de/api/gesundheit` meldet Datenbank erreichbar
|
||||||
|
|
||||||
|
- Wiederherstellung geprobt (#045): Daten zerstört, zurückgespielt, geprüft. Zwei Lücken kamen erst dabei heraus – ohne `--clean` ließ sich nur in eine leere Datenbank zurückspielen, und für die PDFs gab es gar keinen Weg
|
||||||
|
- Damit ist einer der vier Punkte bis 1.0.0 erledigt. Offen bleiben PMG, PBS mit Lizenz und ein vollständiger Lizenzjahr-Durchlauf (17.10.)
|
||||||
|
|
||||||
### Nächster Schritt
|
### Nächster Schritt
|
||||||
- #016 Benutzerhandbuch mit Screenshots, danach #017 Favicons und #019 CI-Pipeline
|
- #016 Benutzerhandbuch mit Screenshots, danach #017 Favicons und #019 CI-Pipeline
|
||||||
- Für 1.0.0 fehlt noch: PMG (kein Host verfügbar) und der Fall „aktive Subscription auf PBS“ (#023)
|
- Für 1.0.0 fehlt noch: PMG (kein Host verfügbar) und der Fall „aktive Subscription auf PBS“ (#023)
|
||||||
|
|||||||
@@ -12,7 +12,7 @@
|
|||||||
set -o nounset
|
set -o nounset
|
||||||
set -o pipefail
|
set -o pipefail
|
||||||
|
|
||||||
AGENT_VERSION="0.2.1"
|
AGENT_VERSION="0.2.2"
|
||||||
CONFIG_DATEI="${PROXLEDGER_CONFIG:-/etc/proxledger/agent.conf}"
|
CONFIG_DATEI="${PROXLEDGER_CONFIG:-/etc/proxledger/agent.conf}"
|
||||||
SPOOL_VERZEICHNIS="${PROXLEDGER_SPOOL:-/var/lib/proxledger/spool}"
|
SPOOL_VERZEICHNIS="${PROXLEDGER_SPOOL:-/var/lib/proxledger/spool}"
|
||||||
SPOOL_MAXIMUM=30
|
SPOOL_MAXIMUM=30
|
||||||
@@ -175,9 +175,24 @@ cluster_name() {
|
|||||||
|
|
||||||
# Namen aller Cluster-Mitglieder laut corosync. Auch Knoten ohne Agent stehen hier –
|
# Namen aller Cluster-Mitglieder laut corosync. Auch Knoten ohne Agent stehen hier –
|
||||||
# daraus erkennt das Dashboard vergessene Knoten (#022).
|
# daraus erkennt das Dashboard vergessene Knoten (#022).
|
||||||
|
# Namen aller Cluster-Knoten aus `pvecm nodes`.
|
||||||
|
#
|
||||||
|
# Die Spaltenzahl ist nicht fest: Mit einem QDevice steht zwischen Votes und Name noch dessen
|
||||||
|
# Zustand (A,V,NMW oder NR). Deshalb wird die Spalte "Name" aus der Kopfzeile bestimmt, statt
|
||||||
|
# blind die dritte zu nehmen. Die Zeile des QDevice selbst traegt die Nodeid 0 und ist kein Knoten.
|
||||||
cluster_mitglieder() {
|
cluster_mitglieder() {
|
||||||
if command -v pvecm >/dev/null 2>&1; then
|
if command -v pvecm >/dev/null 2>&1; then
|
||||||
pvecm nodes 2>/dev/null | awk '/^[[:space:]]*[0-9]+[[:space:]]+[0-9]+/ {print $3}' | sed 's/ *(local)//'
|
pvecm nodes 2>/dev/null | awk '
|
||||||
|
/Nodeid/ {
|
||||||
|
for (i = 1; i <= NF; i++) if ($i == "Name") spalte = i
|
||||||
|
next
|
||||||
|
}
|
||||||
|
/^[[:space:]]*[0-9]+[[:space:]]+[0-9]+/ {
|
||||||
|
if ($1 == 0) next
|
||||||
|
name = (spalte ? $spalte : $3)
|
||||||
|
sub(/\(.*/, "", name)
|
||||||
|
if (name != "") print name
|
||||||
|
}'
|
||||||
fi
|
fi
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -30,7 +30,8 @@ echo "Sichere ProxLedger (${ART}) nach ${ZIEL} …"
|
|||||||
|
|
||||||
if [ "$ART" = "docker" ]; then
|
if [ "$ART" = "docker" ]; then
|
||||||
echo "Sichere Datenbank …"
|
echo "Sichere Datenbank …"
|
||||||
docker compose exec -T postgres pg_dump -U "${POSTGRES_USER:-proxledger}" "${POSTGRES_DB:-proxledger}" \
|
docker compose exec -T postgres pg_dump --clean --if-exists \
|
||||||
|
-U "${POSTGRES_USER:-proxledger}" "${POSTGRES_DB:-proxledger}" \
|
||||||
| gzip > "${ZIEL}/datenbank-${STAND}.sql.gz"
|
| gzip > "${ZIEL}/datenbank-${STAND}.sql.gz"
|
||||||
|
|
||||||
echo "Sichere hochgeladene Dateien …"
|
echo "Sichere hochgeladene Dateien …"
|
||||||
@@ -48,7 +49,7 @@ else
|
|||||||
}
|
}
|
||||||
|
|
||||||
echo "Sichere Datenbank …"
|
echo "Sichere Datenbank …"
|
||||||
pg_dump "$DATABASE_URL" | gzip > "${ZIEL}/datenbank-${STAND}.sql.gz"
|
pg_dump --clean --if-exists "$DATABASE_URL" | gzip > "${ZIEL}/datenbank-${STAND}.sql.gz"
|
||||||
|
|
||||||
echo "Sichere hochgeladene Dateien …"
|
echo "Sichere hochgeladene Dateien …"
|
||||||
UPLOADS="${UPLOAD_DIR:-${VERZEICHNIS}/data/uploads}"
|
UPLOADS="${UPLOAD_DIR:-${VERZEICHNIS}/data/uploads}"
|
||||||
|
|||||||
@@ -0,0 +1,127 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# ProxLedger aus einer Sicherung wiederherstellen (#046).
|
||||||
|
#
|
||||||
|
# bash deploy/restore.sh /var/backups/proxledger # jüngste Sicherung
|
||||||
|
# bash deploy/restore.sh /var/backups/proxledger 20261010-192437
|
||||||
|
# bash deploy/restore.sh … --ja # ohne Rückfrage
|
||||||
|
#
|
||||||
|
# Spielt Datenbank und hochgeladene PDFs zurück. Die `.env` wird **nicht** angefasst:
|
||||||
|
# Sie trägt APP_SECRET, und ein falscher Wert dort macht gespeicherte Zugangsdaten unlesbar.
|
||||||
|
# Die passende Fassung liegt als env-<Stand>.txt in der Sicherung – im Zweifel von Hand vergleichen.
|
||||||
|
|
||||||
|
set -o errexit
|
||||||
|
set -o nounset
|
||||||
|
set -o pipefail
|
||||||
|
|
||||||
|
VERZEICHNIS=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)
|
||||||
|
cd "$VERZEICHNIS"
|
||||||
|
|
||||||
|
blau() { printf '\033[1;36m› %s\033[0m\n' "$*"; }
|
||||||
|
gruen() { printf '\033[1;32m✓ %s\033[0m\n' "$*"; }
|
||||||
|
fehler() {
|
||||||
|
printf '\033[1;31m✗ %s\033[0m\n' "$*" >&2
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
QUELLE="${1:-/var/backups/proxledger}"
|
||||||
|
STAND="${2:-}"
|
||||||
|
[ "${2:-}" = "--ja" ] && STAND=""
|
||||||
|
OHNE_RUECKFRAGE="nein"
|
||||||
|
for argument in "$@"; do [ "$argument" = "--ja" ] && OHNE_RUECKFRAGE="ja"; done
|
||||||
|
|
||||||
|
[ -d "$QUELLE" ] || fehler "Kein Sicherungsordner unter ${QUELLE}."
|
||||||
|
|
||||||
|
# Jüngste Sicherung nehmen, wenn kein Stand angegeben ist.
|
||||||
|
if [ -z "$STAND" ]; then
|
||||||
|
DATENBANK=$(ls -1t "${QUELLE}"/datenbank-*.sql.gz 2>/dev/null | head -n1 || true)
|
||||||
|
else
|
||||||
|
DATENBANK="${QUELLE}/datenbank-${STAND}.sql.gz"
|
||||||
|
fi
|
||||||
|
|
||||||
|
[ -n "${DATENBANK:-}" ] && [ -f "$DATENBANK" ] || fehler "Keine Datenbanksicherung in ${QUELLE} gefunden."
|
||||||
|
UPLOADS="${DATENBANK/datenbank-/uploads-}"
|
||||||
|
UPLOADS="${UPLOADS/.sql.gz/.tar.gz}"
|
||||||
|
|
||||||
|
# Siehe deploy/update.sh – der Dienst ist das eindeutigere Merkmal.
|
||||||
|
if systemctl cat proxledger.service >/dev/null 2>&1; then
|
||||||
|
ART="dienst"
|
||||||
|
else
|
||||||
|
ART="docker"
|
||||||
|
fi
|
||||||
|
|
||||||
|
printf '\033[1;33m! Achtung: Der aktuelle Datenbestand wird durch die Sicherung ersetzt.\033[0m\n'
|
||||||
|
echo " Installationsart: ${ART}"
|
||||||
|
echo " Datenbank: $(basename "$DATENBANK")"
|
||||||
|
echo " Uploads: $([ -f "$UPLOADS" ] && basename "$UPLOADS" || echo 'keine in dieser Sicherung')"
|
||||||
|
|
||||||
|
if [ "$OHNE_RUECKFRAGE" != "ja" ]; then
|
||||||
|
read -r -p "Wirklich zurückspielen? [tippen Sie ja] " antwort
|
||||||
|
[ "$antwort" = "ja" ] || fehler "Abgebrochen."
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------- Datenbank
|
||||||
|
blau "Datenbank zurückspielen …"
|
||||||
|
if [ "$ART" = "docker" ]; then
|
||||||
|
# Web und Worker anhalten: Sie würden während des Einspielens weiterschreiben.
|
||||||
|
docker compose stop web worker >/dev/null 2>&1 || true
|
||||||
|
docker compose up -d postgres >/dev/null
|
||||||
|
# Kurz warten, bis Postgres Verbindungen annimmt.
|
||||||
|
for _ in $(seq 1 30); do
|
||||||
|
docker compose exec -T postgres pg_isready -U "${POSTGRES_USER:-proxledger}" >/dev/null 2>&1 && break
|
||||||
|
sleep 1
|
||||||
|
done
|
||||||
|
gunzip -c "$DATENBANK" | docker compose exec -T postgres psql -U "${POSTGRES_USER:-proxledger}" \
|
||||||
|
-d "${POSTGRES_DB:-proxledger}" >/dev/null
|
||||||
|
else
|
||||||
|
systemctl stop proxledger proxledger-worker >/dev/null 2>&1 || true
|
||||||
|
# shellcheck disable=SC1091
|
||||||
|
set -o allexport
|
||||||
|
. ./.env
|
||||||
|
set +o allexport
|
||||||
|
[ -n "${DATABASE_URL:-}" ] || fehler "DATABASE_URL fehlt in der .env."
|
||||||
|
gunzip -c "$DATENBANK" | psql "$DATABASE_URL" >/dev/null
|
||||||
|
fi
|
||||||
|
gruen "Datenbank zurückgespielt."
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------- Uploads
|
||||||
|
if [ -f "$UPLOADS" ]; then
|
||||||
|
blau "Hochgeladene PDFs zurückspielen …"
|
||||||
|
if [ "$ART" = "docker" ]; then
|
||||||
|
# Das Archiv enthält den Pfad /data/uploads – deshalb nach / entpacken.
|
||||||
|
docker compose run --rm -T --entrypoint tar web -xzf - -C / < "$UPLOADS"
|
||||||
|
else
|
||||||
|
ZIEL_UPLOADS="${UPLOAD_DIR:-${VERZEICHNIS}/data/uploads}"
|
||||||
|
mkdir -p "$(dirname "$ZIEL_UPLOADS")"
|
||||||
|
tar -xzf "$UPLOADS" -C "$(dirname "$ZIEL_UPLOADS")"
|
||||||
|
fi
|
||||||
|
gruen "PDFs zurückgespielt."
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------- Starten und prüfen
|
||||||
|
blau "Dienste starten …"
|
||||||
|
if [ "$ART" = "docker" ]; then
|
||||||
|
docker compose up -d >/dev/null
|
||||||
|
else
|
||||||
|
systemctl start proxledger proxledger-worker
|
||||||
|
fi
|
||||||
|
|
||||||
|
PORT="${PORT:-3000}"
|
||||||
|
for versuch in $(seq 1 45); do
|
||||||
|
if [ "$ART" = "docker" ]; then
|
||||||
|
erreichbar=$(docker compose exec -T web wget -qO- "http://127.0.0.1:3000/api/gesundheit" >/dev/null 2>&1 && echo ja || echo nein)
|
||||||
|
else
|
||||||
|
erreichbar=$(curl -fsS -o /dev/null --max-time 2 "http://localhost:${PORT}/api/gesundheit" && echo ja || echo nein)
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ "$erreichbar" = "ja" ]; then
|
||||||
|
gruen "ProxLedger läuft wieder (nach ${versuch} s)."
|
||||||
|
echo
|
||||||
|
echo "Bitte jetzt anmelden und stichprobenartig prüfen, ob die Daten vollständig sind."
|
||||||
|
echo "Passt das Passwort nicht, stammt die Sicherung aus einer Zeit vor der letzten Änderung –"
|
||||||
|
echo "dann hilft: npm run benutzer -- passwort <email>"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
sleep 1
|
||||||
|
done
|
||||||
|
|
||||||
|
fehler "Das Dashboard antwortet nicht. Protokoll ansehen: docker compose logs -n 50 web"
|
||||||
@@ -237,6 +237,16 @@ mit Marge. Die vier Kästen oben fassen Hosts, Einkauf, Verkauf und Marge zusamm
|
|||||||
Standorte sind optional und lohnen sich bei Kunden mit mehreren Filialen: Sie erscheinen in der
|
Standorte sind optional und lohnen sich bei Kunden mit mehreren Filialen: Sie erscheinen in der
|
||||||
Host-Tabelle und lassen sich bei der Zuweisung mit angeben.
|
Host-Tabelle und lassen sich bei der Zuweisung mit angeben.
|
||||||
|
|
||||||
|
### Eigene Hosts
|
||||||
|
|
||||||
|
Auch die eigenen Hosts gehören einem Kunden zugeordnet – sonst fehlen sie in jeder Auswertung und
|
||||||
|
lösen die Warnung „Host ohne Kunde“ aus. Lege dafür dich selbst als Kunden an und setze im
|
||||||
|
Kundenformular den Haken **Eigenbedarf**.
|
||||||
|
|
||||||
|
Für solche Kunden entfällt alles, was mit Weiterberechnung zu tun hat: keine Kundenrechnung, keine
|
||||||
|
Warnungen darüber, keine Aufgabe „Kundenrechnung erstellen“ und keine Marge. Die Proxmox-Rechnung
|
||||||
|
wird weiterhin geführt und erinnert ans Bezahlen – die Kosten fallen ja an.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 6. Laufzeiten und Rechnungen
|
## 6. Laufzeiten und Rechnungen
|
||||||
@@ -279,6 +289,22 @@ Positionen und wird automatisch nachgeführt.
|
|||||||
Die Jahresbahn zeigt zwölf Monate mit einer Linie für „heute“, gruppiert nach Kunde. Darunter stehen die
|
Die Jahresbahn zeigt zwölf Monate mit einer Linie für „heute“, gruppiert nach Kunde. Darunter stehen die
|
||||||
Abläufe der nächsten 90 Tage und die bereits abgelaufenen Subscriptions.
|
Abläufe der nächsten 90 Tage und die bereits abgelaufenen Subscriptions.
|
||||||
|
|
||||||
|
### Woher Laufzeiten kommen
|
||||||
|
|
||||||
|
Eine Laufzeit ist ein Lizenzjahr eines Hosts. Es gibt zwei Wege:
|
||||||
|
|
||||||
|
1. **Vom Agent vorgeschlagen.** Meldet ein Host ein Ablaufdatum, legt ProxLedger eine Laufzeit von
|
||||||
|
diesem Datum bis ein Jahr später an – zunächst als *Vorschlag*. Unter *Rechnungen → Vorschläge*
|
||||||
|
bestätigst oder verwirfst du ihn.
|
||||||
|
2. **Von Hand angelegt** über *Rechnungen → Laufzeit von Hand anlegen*. Nötig, wenn ein Host noch kein
|
||||||
|
Ablaufdatum meldet – etwa weil der Key erst bestellt ist – oder wenn ein zurückliegendes Lizenzjahr
|
||||||
|
nachgetragen wird.
|
||||||
|
|
||||||
|
Jede Laufzeit lässt sich später ändern (*Laufzeit bearbeiten* in der Detailansicht): Key, Beginn, Ende
|
||||||
|
und Notiz. Stehen für denselben Host zwei Laufzeiten, die dasselbe Lizenzjahr meinen – etwa weil das
|
||||||
|
gemeldete Datum um einen Tag abweicht –, lassen sie sich **zusammenführen**. Die aufgelöste Laufzeit
|
||||||
|
gibt ihre Rechnungen an die verbleibende ab.
|
||||||
|
|
||||||
### Der übliche Ablauf
|
### Der übliche Ablauf
|
||||||
|
|
||||||
1. Proxmox schickt die Rechnung zunächst als **UNPAID**. Sie wird hier erfasst, Haken „bezahlt“ bleibt leer.
|
1. Proxmox schickt die Rechnung zunächst als **UNPAID**. Sie wird hier erfasst, Haken „bezahlt“ bleibt leer.
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 131 KiB After Width: | Height: | Size: 131 KiB |
|
Before Width: | Height: | Size: 120 KiB After Width: | Height: | Size: 121 KiB |
|
Before Width: | Height: | Size: 259 KiB After Width: | Height: | Size: 260 KiB |
|
Before Width: | Height: | Size: 121 KiB After Width: | Height: | Size: 121 KiB |
|
Before Width: | Height: | Size: 139 KiB After Width: | Height: | Size: 139 KiB |
|
Before Width: | Height: | Size: 103 KiB After Width: | Height: | Size: 103 KiB |
|
Before Width: | Height: | Size: 170 KiB After Width: | Height: | Size: 194 KiB |
|
Before Width: | Height: | Size: 177 KiB After Width: | Height: | Size: 178 KiB |
@@ -27,19 +27,51 @@ ruft es als Erstes auf und bricht ab, wenn es fehlschlägt.
|
|||||||
## Zurückspielen
|
## Zurückspielen
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Docker
|
bash deploy/restore.sh /var/backups/proxledger # jüngste Sicherung
|
||||||
docker compose down
|
bash deploy/restore.sh /var/backups/proxledger 20261010-192437 # bestimmter Stand
|
||||||
docker compose up -d postgres
|
```
|
||||||
gunzip -c sicherung.sql.gz | docker compose exec -T postgres psql -U proxledger proxledger
|
|
||||||
|
Das Skript hält Dashboard und Worker an, spielt Datenbank **und** hochgeladene PDFs zurück, startet
|
||||||
|
alles wieder und prüft, ob das Dashboard antwortet. Vorher fragt es nach; `--ja` überspringt die
|
||||||
|
Rückfrage (für Skripte).
|
||||||
|
|
||||||
|
Die `.env` fasst es bewusst **nicht** an: Sie trägt `APP_SECRET`, und ein falscher Wert dort macht die
|
||||||
|
gespeicherten Zugangsdaten für SMTP und Webhook unlesbar. Die zum Stand passende Fassung liegt als
|
||||||
|
`env-<Stand>.txt` in der Sicherung – im Zweifel von Hand vergleichen.
|
||||||
|
|
||||||
|
### Von Hand, wenn es sein muss
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose stop web worker
|
||||||
|
gunzip -c datenbank-20261010-192437.sql.gz | docker compose exec -T postgres psql -U proxledger proxledger
|
||||||
|
docker compose run --rm -T --entrypoint tar web -xzf - -C / < uploads-20261010-192437.tar.gz
|
||||||
docker compose up -d
|
docker compose up -d
|
||||||
```
|
```
|
||||||
|
|
||||||
Danach die `.env` mit **demselben** `APP_SECRET` zurücklegen wie zum Zeitpunkt der Sicherung. Ein
|
Bei der Installation als Dienst stattdessen `psql "$DATABASE_URL"` und `tar -xzf … -C /opt/proxledger/data`.
|
||||||
anderer Wert lässt die gespeicherten Zugangsdaten unlesbar werden; das Dashboard sagt das beim
|
|
||||||
nächsten Versand deutlich, aber die Werte müssen dann neu eingetragen werden.
|
## Geprobt am 10.10.2026
|
||||||
|
|
||||||
|
Nicht behauptet, sondern durchgespielt – mit einem eigenen Stapel:
|
||||||
|
|
||||||
|
1. Stand festgehalten (9 Hosts, 4 Kunden, 10 Laufzeiten), ein Kunde zur Wiedererkennung umbenannt,
|
||||||
|
eine PDF-Datei abgelegt
|
||||||
|
2. Sicherung angelegt
|
||||||
|
3. **Alle Daten zerstört** (`docker compose down -v` – Datenbank und Upload-Volume weg)
|
||||||
|
4. `deploy/restore.sh` aufgerufen
|
||||||
|
|
||||||
|
Ergebnis: Datenbank vollständig zurück (die umbenannte Kundin war wieder da), die PDF-Datei lag mit
|
||||||
|
den richtigen Rechten im Upload-Ordner, Anmeldung mit dem alten Passwort funktionierte.
|
||||||
|
|
||||||
|
Dabei zwei Lücken gefunden und geschlossen: Die Sicherung ließ sich nur in eine **leere** Datenbank
|
||||||
|
zurückspielen (jetzt `pg_dump --clean --if-exists`), und für die hochgeladenen PDFs gab es überhaupt
|
||||||
|
keinen beschriebenen Weg.
|
||||||
|
|
||||||
|
> Geprobt wurde der Docker-Weg. Für die Installation als Dienst ist derselbe Ablauf im Skript
|
||||||
|
> vorgesehen, aber noch nicht unter echten Bedingungen gelaufen.
|
||||||
|
|
||||||
## Das Wichtigste
|
## Das Wichtigste
|
||||||
|
|
||||||
**Eine Sicherung, die nie zurückgespielt wurde, ist keine Sicherung.** Spielen Sie sie einmal in eine
|
**Eine Sicherung, die nie zurückgespielt wurde, ist keine Sicherung.** Spielen Sie sie einmal in eine
|
||||||
leere Testumgebung ein und melden Sie sich dort an. Erst dann wissen Sie, dass Sicherung und
|
Testumgebung ein und melden Sie sich dort an. Erst dann wissen Sie, dass Sicherung und `APP_SECRET`
|
||||||
`APP_SECRET` zusammenpassen.
|
zusammenpassen.
|
||||||
|
|||||||
@@ -0,0 +1,40 @@
|
|||||||
|
# #042 Cluster-Knoten falsch gelesen, wenn ein QDevice im Spiel ist
|
||||||
|
**Status:** erledigt
|
||||||
|
**Meilenstein:** v0.24.1
|
||||||
|
**Labels:** bug, agent
|
||||||
|
**Geschätzt:** 1 h **Tatsächlich:** 0 h 20 min
|
||||||
|
|
||||||
|
## Auslöser
|
||||||
|
Florians Cluster hat drei Knoten (`hv-pve-01`, `hv-pve-02`, `hv-pve-05`). ProxLedger zeigte vier
|
||||||
|
„Cluster-Knoten“: `A,V,NMW`, `A,V,NMW`, `NR` und `Qdevice`.
|
||||||
|
|
||||||
|
## Ursache
|
||||||
|
Der Agent nahm aus `pvecm nodes` blind die dritte Spalte als Namen. Ohne QDevice stimmt das:
|
||||||
|
|
||||||
|
```
|
||||||
|
Nodeid Votes Name
|
||||||
|
1 1 hv-rz5-pve-01 (local)
|
||||||
|
```
|
||||||
|
|
||||||
|
Mit QDevice steht dort aber dessen Zustand, und der Name rutscht eine Spalte weiter:
|
||||||
|
|
||||||
|
```
|
||||||
|
Nodeid Votes Qdevice Name
|
||||||
|
1 1 A,V,NMW hv-pve-01
|
||||||
|
0 1 Qdevice (votes 1)
|
||||||
|
```
|
||||||
|
|
||||||
|
Gemeldet wurden damit die Zustandskürzel – und das QDevice selbst als vierter „Knoten“.
|
||||||
|
|
||||||
|
## Behebung
|
||||||
|
Die Spalte „Name“ wird aus der Kopfzeile bestimmt, statt sie zu erraten. Die Zeile des QDevice trägt
|
||||||
|
die Nodeid 0 und wird übersprungen. Beide Ausgabeformen sind als Testfall hinterlegt.
|
||||||
|
|
||||||
|
## Folgen
|
||||||
|
Die Warnregel „Cluster-Knoten ohne Agent“ hatte dadurch Warnungen für Knoten erzeugt, die es nicht
|
||||||
|
gibt. Sie schließen sich von selbst, sobald die Agents mit der neuen Fassung gemeldet haben.
|
||||||
|
|
||||||
|
## Lehre
|
||||||
|
Dritter Fall in dieser Reihe: Eine Ausgabe, die beim Feldtest auf **einem** Cluster richtig gelesen
|
||||||
|
wurde, ist nicht auf jedem Cluster gleich. Spalten gehören über ihre Überschrift angesprochen, nicht
|
||||||
|
über ihre Position.
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
# #043 Laufzeiten anlegen, bearbeiten und zusammenführen
|
||||||
|
**Status:** erledigt
|
||||||
|
**Meilenstein:** v0.25.0
|
||||||
|
**Labels:** backend, ui
|
||||||
|
**Geschätzt:** 4 h **Tatsächlich:** 1 h 05 min
|
||||||
|
|
||||||
|
## Auslöser
|
||||||
|
Drei Rückmeldungen aus dem Einsatz:
|
||||||
|
|
||||||
|
1. „In der Kundenübersicht wäre es auch cool, die Laufzeit / Rechnung zu überarbeiten.“
|
||||||
|
2. „Wie werden so Laufzeiten erfasst?“
|
||||||
|
3. „Auch die Laufzeit im Nachhinein zu bearbeiten geht nicht. Oder mit einer vorhandenen zu
|
||||||
|
verknüpfen oder zu vereinen.“
|
||||||
|
|
||||||
|
## Ausgangslage
|
||||||
|
Laufzeiten entstanden ausschließlich als **Vorschlag** aus dem Agenten-Report: Meldet ein Host ein
|
||||||
|
Ablaufdatum, legt ProxLedger eine Laufzeit von diesem Datum bis ein Jahr später an. Bestätigen oder
|
||||||
|
verwerfen war möglich – mehr nicht:
|
||||||
|
|
||||||
|
- **Anlegen von Hand** gab es als Server-Aktion, aber **nirgends in der Oberfläche**.
|
||||||
|
- **Ändern** war unmöglich. Weicht das gemeldete Datum von der Bestellung ab, blieb es falsch.
|
||||||
|
- **Zusammenführen** gab es nicht, obwohl der Fall zwangsläufig auftritt: Neben einer erfassten
|
||||||
|
Laufzeit legt der Agent eine zweite an, wenn das Ablaufdatum um einen Tag abweicht.
|
||||||
|
- Aus der **Kundenansicht** führte kein Weg in die Laufzeit.
|
||||||
|
|
||||||
|
## Behebung
|
||||||
|
- Formular *Laufzeit von Hand anlegen* auf der Rechnungsseite; der gemeldete Key wird vorgeschlagen,
|
||||||
|
das Ende aus dem Beginn vorbelegt (ein Jahr minus ein Tag – dieselbe Rechnung wie beim Vorschlag).
|
||||||
|
- *Laufzeit bearbeiten* im Laufzeit-Detail: Key, Beginn, Ende, Notiz. Eine Änderung bestätigt den
|
||||||
|
Vorschlag zugleich.
|
||||||
|
- *Zusammenführen* mit einer anderen Laufzeit desselben Hosts. Die aufgelöste Laufzeit gibt ihre
|
||||||
|
Rechnungen ab, sofern das Ziel dort nichts hat; haben beide eine Rechnung derselben Art, bricht der
|
||||||
|
Vorgang mit einer Erklärung ab.
|
||||||
|
- Kundenansicht: Laufzeit und Host sind verlinkt, Marge und Rabatt stehen dabei.
|
||||||
|
|
||||||
|
## Entscheidungen
|
||||||
|
- **Kein stilles Überschreiben beim Zusammenführen.** Zwei Einkaufsrechnungen auf einem Lizenzjahr
|
||||||
|
wären nicht mehr zuzuordnen – lieber ein Abbruch mit Hinweis.
|
||||||
|
- **Beim Ändern bleibt die Regel „ein Startdatum je Host“.** Kollidiert das neue Datum, verweist die
|
||||||
|
Meldung aufs Zusammenführen.
|
||||||
|
- Beim Zusammenführen muss die Quelle ihre Positionen zuerst loslassen: Eine Rechnungsposition gehört
|
||||||
|
zu genau einer Laufzeit, und die Datenbank wacht darüber. Erst dann darf das Ziel sie übernehmen.
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
# #044 Eigene Hosts ohne Weiterberechnung
|
||||||
|
**Status:** erledigt
|
||||||
|
**Meilenstein:** v0.26.0
|
||||||
|
**Labels:** backend, ui, alerts
|
||||||
|
**Geschätzt:** 2 h **Tatsächlich:** 0 h 40 min
|
||||||
|
|
||||||
|
## Auslöser
|
||||||
|
„Ich habe mich auch selbst als Kunden angelegt, da ich ja auch Hosts habe. Aber mir selbst schreibe
|
||||||
|
ich ja keine E-Mails.“
|
||||||
|
|
||||||
|
## Das Problem
|
||||||
|
Die eigenen Hosts brauchen einen Kunden – sonst stehen sie unter *Nicht zugeordnet* und lösen die
|
||||||
|
Warnung „Host ohne Kunde“ aus. Als gewöhnlicher Kunde erzeugen sie aber lauter Aufgaben, die es nicht
|
||||||
|
gibt: „Kundenrechnung erstellen“, „Kundenrechnung unbezahlt“, dazu eine negative Marge in jeder
|
||||||
|
Summe, weil dem Einkauf kein Verkauf gegenübersteht.
|
||||||
|
|
||||||
|
## Behebung
|
||||||
|
Kennzeichen **Eigenbedarf** am Kunden. Für dessen Hosts gilt:
|
||||||
|
|
||||||
|
| | Verhalten |
|
||||||
|
|---|---|
|
||||||
|
| Proxmox-Rechnung | unverändert – die Kosten fallen ja an |
|
||||||
|
| Kundenrechnung | entfällt; das Formular wird durch eine Erklärung ersetzt |
|
||||||
|
| Warnungen „Kundenrechnung fehlt / unbezahlt“ | entfallen |
|
||||||
|
| Warnung „Kunde hat bezahlt – Einkauf offen“ | entfällt |
|
||||||
|
| Aufgabe „Kundenrechnung erstellen“ | entfällt |
|
||||||
|
| Marge | bleibt leer statt negativ |
|
||||||
|
| Summen | Einkauf zählt mit, Verkauf und Marge nicht |
|
||||||
|
|
||||||
|
Dazu ein Abzeichen in der Kundenliste und auf der Laufzeit.
|
||||||
|
|
||||||
|
## Entscheidung
|
||||||
|
Die Einkaufsseite bleibt vollständig erhalten. Eigene Hosts kosten echtes Geld, und die Rechnung von
|
||||||
|
Proxmox will bezahlt werden – nur die Weiterberechnung entfällt.
|
||||||
|
|
||||||
|
Die Alternative wäre gewesen, eigene Hosts ohne Kunden zu lassen und die Warnung „Host ohne Kunde“
|
||||||
|
abzuschalten. Dann fehlten sie aber in jeder Auswertung nach Kunde, und der Einkauf ließe sich nicht
|
||||||
|
mehr zuordnen.
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
# #045 Wiederherstellung proben und Skript dafür
|
||||||
|
**Status:** erledigt
|
||||||
|
**Meilenstein:** v0.27.0
|
||||||
|
**Labels:** deployment, doku
|
||||||
|
**Geschätzt:** 2 h **Tatsächlich:** 0 h 50 min
|
||||||
|
|
||||||
|
## Ziel
|
||||||
|
Einer der vier Punkte bis 1.0.0: eine Sicherung **einmal wirklich zurückspielen**. Bis dahin war nur
|
||||||
|
geprüft, dass `deploy/backup.sh` Dateien schreibt.
|
||||||
|
|
||||||
|
## Ablauf der Probe
|
||||||
|
Mit einem eigenen Docker-Stapel, getrennt von allen anderen Daten:
|
||||||
|
|
||||||
|
1. Datenstand festgehalten (9 Hosts, 4 Kunden, 10 Laufzeiten), einen Kunden zur Wiedererkennung in
|
||||||
|
`Marke-192641` umbenannt, eine PDF-Datei in den Upload-Ordner gelegt
|
||||||
|
2. Sicherung angelegt
|
||||||
|
3. **Alle Daten zerstört** – `docker compose down -v`, also Datenbank- und Upload-Volume weg
|
||||||
|
4. Zurückgespielt und geprüft
|
||||||
|
|
||||||
|
**Ergebnis:** Datenbank vollständig zurück (die Marke war wieder da), die PDF-Datei lag mit den
|
||||||
|
richtigen Rechten im Upload-Ordner, Anmeldung mit dem alten Passwort funktionierte.
|
||||||
|
|
||||||
|
## Zwei Lücken, die erst die Probe gezeigt hat
|
||||||
|
1. **Die Sicherung ließ sich nur in eine leere Datenbank zurückspielen.** `pg_dump` ohne `--clean`
|
||||||
|
scheitert an bestehenden Tabellen – genau im Ernstfall, wenn man in eine laufende Installation
|
||||||
|
zurückspielen will. Jetzt `--clean --if-exists`; gegengeprüft gegen eine Datenbank mit Daten:
|
||||||
|
null Fehlerzeilen, der zwischenzeitlich geänderte Wert war wieder auf dem Stand der Sicherung.
|
||||||
|
2. **Für die hochgeladenen PDFs gab es keinen beschriebenen Weg.** Gesichert wurden sie, zurückspielen
|
||||||
|
musste man selbst herausfinden.
|
||||||
|
|
||||||
|
## Ergebnis
|
||||||
|
- `deploy/restore.sh`: hält die Dienste an, spielt Datenbank und PDFs zurück, startet wieder und prüft
|
||||||
|
die Erreichbarkeit. Fragt vorher nach, `--ja` überspringt das.
|
||||||
|
- Die `.env` wird bewusst **nicht** überschrieben: Sie trägt `APP_SECRET`, und ein falscher Wert dort
|
||||||
|
macht die gespeicherten Zugangsdaten unlesbar. Die passende Fassung liegt als `env-<Stand>.txt` daneben.
|
||||||
|
- `docs/betrieb/sicherung.md` beschreibt den geprobten Ablauf samt Handarbeit als Rückfallebene.
|
||||||
|
|
||||||
|
## Offen
|
||||||
|
Geprobt wurde der Docker-Weg. Für die Installation als Dienst ist derselbe Ablauf im Skript
|
||||||
|
vorgesehen, aber noch nicht unter echten Bedingungen gelaufen.
|
||||||
@@ -45,4 +45,8 @@ Details und Architektur: [../architektur.md](../architektur.md). Zeiten werden a
|
|||||||
| [#039](039-zugang-zur-laufzeit.md) | Auf dem Telefon führte kein Weg zur Laufzeit | erledigt | v0.22.2 | 1 h | 0 h 30 min |
|
| [#039](039-zugang-zur-laufzeit.md) | Auf dem Telefon führte kein Weg zur Laufzeit | erledigt | v0.22.2 | 1 h | 0 h 30 min |
|
||||||
| [#040](040-schlagworte-sichtbar.md) | Schlagworte und Notizen sichtbar machen | erledigt | v0.23.0 | 2 h | 0 h 40 min |
|
| [#040](040-schlagworte-sichtbar.md) | Schlagworte und Notizen sichtbar machen | erledigt | v0.23.0 | 2 h | 0 h 40 min |
|
||||||
| [#041](041-doppelte-hosts.md) | Gleichnamige Hosts erkennen und stilllegen | erledigt | v0.24.0 | 3 h | 0 h 50 min |
|
| [#041](041-doppelte-hosts.md) | Gleichnamige Hosts erkennen und stilllegen | erledigt | v0.24.0 | 3 h | 0 h 50 min |
|
||||||
| | **Summe** | | | **168 h** | **22 h 33 min** |
|
| [#042](042-qdevice-cluster.md) | Cluster-Knoten falsch gelesen bei QDevice | erledigt | v0.24.1 | 1 h | 0 h 20 min |
|
||||||
|
| [#043](043-laufzeiten-bearbeiten.md) | Laufzeiten anlegen, bearbeiten, zusammenführen | erledigt | v0.25.0 | 4 h | 1 h 05 min |
|
||||||
|
| [#044](044-eigenbedarf.md) | Eigene Hosts ohne Weiterberechnung | erledigt | v0.26.0 | 2 h | 0 h 40 min |
|
||||||
|
| [#045](045-wiederherstellung-geprobt.md) | Wiederherstellung proben und Skript dafür | erledigt | v0.27.0 | 2 h | 0 h 50 min |
|
||||||
|
| | **Summe** | | | **177 h** | **25 h 28 min** |
|
||||||
|
|||||||
@@ -1,72 +1,117 @@
|
|||||||
# Roadmap
|
# Roadmap
|
||||||
|
|
||||||
Stand: 19.09.2026, Fassung 0.17.0
|
Stand: 10.10.2026, Fassung 0.27.0
|
||||||
|
|
||||||
## Was steht
|
Die erste Fassung dieser Roadmap entstand vor dem produktiven Einsatz. Seitdem läuft ProxLedger mit
|
||||||
|
echten Daten, und das hat die Reihenfolge deutlich verändert: Was aus der Benutzung kam, war fast
|
||||||
|
immer wichtiger als das, was vorher auf der Liste stand.
|
||||||
|
|
||||||
Alles, was im ursprünglichen Auftrag stand, ist gebaut und in Betrieb erprobt:
|
## Was seit dem Produktivgang dazukam
|
||||||
|
|
||||||
- Agent für PVE, PBS und PMG; Push-Modell über systemd-Timer, Zwischenspeicher bei Ausfall
|
Rollen und Benutzerverwaltung, Passkeys, Einstellungen im Dashboard, Betriebsdokumentation, die
|
||||||
- Zuordnung Key → Host → Kunde, Laufzeiten je Lizenzjahr, Einkaufs- und Verkaufsrechnung mit Marge
|
mobile Bedienung, Schlagworte und Notizen in der Übersicht, Stilllegen doppelter Hosts, Laufzeiten
|
||||||
- Elf Warnregeln, Benachrichtigungen per E-Mail und Webhook, Zusammenfassung
|
anlegen/bearbeiten/zusammenführen, Rabatt je Rechnungsposition, Eigenbedarf.
|
||||||
- Anmeldung lokal und per OIDC, Audit-Log, verschlüsselte Zugangsdaten, Härtung nach OWASP
|
|
||||||
- Auslieferung als Docker-Stack und als LXC-Container (eigener Installer und Helper-Script-Weg)
|
|
||||||
- Handbuch mit Bildern, 190 Tests, CI bei jedem Push
|
|
||||||
|
|
||||||
## Was bis 1.0.0 fehlt
|
Ebenso die Fehler, die erst der Einsatz gezeigt hat: QDevice-Spalte im Cluster, PBS-Rahmentabelle,
|
||||||
|
Weiterleitungen auf die Lausch-Adresse, Standort beim Zuweisen gelöscht, Einkauf ohne Rabatt an drei
|
||||||
|
Stellen.
|
||||||
|
|
||||||
Nichts davon ist eine Baustelle im Code – es fehlt **Erprobung** und eine Handvoll Kanten:
|
---
|
||||||
|
|
||||||
| Punkt | Warum es zählt | Aufwand |
|
## Bis 1.0.0
|
||||||
|
|
||||||
|
Kein neuer Funktionsumfang – es fehlt Erprobung.
|
||||||
|
|
||||||
|
| Punkt | Warum | Aufwand |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| **Mail Gateway im Feldtest** (#023) | Der einzige Produktzweig, den nie ein echter Host bestätigt hat. Bei PBS lag die Annahme daneben, und der Agent hätte dort nichts gemeldet – bei PMG kann es genauso sein. | 1 h, sobald ein Host da ist |
|
| **PMG im Feldtest** ([#023](issues/023-feldtest-pbs-pmg.md)) | Der einzige Produktzweig ohne echten Host. Bei PBS lag die Annahme daneben, und der Agent hätte dort nichts gemeldet. | 1 h, sobald ein Host da ist |
|
||||||
| **PBS mit aktiver Subscription** (#023) | Bisher nur der Fall „keine Subscription“ geprüft. Ablaufdatum, Sockel und Level sind auf PBS ungeprüft. | 30 min |
|
| **PBS mit aktiver Lizenz** | Bisher nur „keine Subscription“ geprüft. | 30 min |
|
||||||
| **Ein Monat Dauerbetrieb** | Timer, Nachsendung, nächtliches Aufräumen und die Zusammenfassung laufen erst seit einem Tag. Ein Lizenzablauf in echt wäre der beste Beweis. | Zeit, keine Arbeit |
|
| ~~Sicherung einmal zurückspielen~~ | **Erledigt am 10.10.2026** ([#045](issues/045-wiederherstellung-geprobt.md)) – dabei kamen zwei Lücken heraus. Offen bleibt die Probe für die Installation als Dienst. | – |
|
||||||
| **Sicherung einmal zurückspielen** | `deploy/backup.sh` schreibt Sicherungen. Eine Sicherung, die nie zurückgespielt wurde, ist keine Sicherung. | 30 min |
|
| **Ein Lizenzjahr durchlaufen lassen** | Warnung → Verlängerung → Einkauf → Kundenrechnung → bezahlt, einmal vollständig in echt. Die nächsten Abläufe stehen am 17.10.2026 an. | nur Zeit |
|
||||||
|
|
||||||
Danach ist 1.0.0 gerechtfertigt. Meine Empfehlung: erst laufen lassen, dann das Etikett vergeben.
|
---
|
||||||
|
|
||||||
## Nach 1.0 – Vorschläge, nach Nutzen sortiert
|
## Danach – nach Nutzen sortiert
|
||||||
|
|
||||||
### Lohnt sich am ehesten
|
### 1. Proxmox-Rechnung einlesen statt abtippen
|
||||||
|
|
||||||
1. **Verlängerung mit einem Klick.** Heute wird eine Laufzeit bestätigt und die Rechnung von Hand
|
Die Rechnungen von Proxmox haben eine feste Form: je Key eine Zeile mit Zeitraum und Betrag, dazu die
|
||||||
erfasst. Ein Knopf „verlängern“, der die Folgelaufzeit anlegt, den Einkauf vormerkt und die
|
Rabattzeilen. Ein Import, der das PDF liest und die Positionen den Laufzeiten über den Key zuordnet,
|
||||||
Kundenrechnung mit dem eingestellten Aufschlag vorbereitet, spart bei jedem Lizenzjahr Klicks.
|
spart bei jeder Sammelrechnung das meiste Tippen – und vermeidet Zahlendreher.
|
||||||
2. **Rechnungsnummern automatisch.** Ein Schema wie `PL-{jahr}-{nummer}` mit fortlaufendem Zähler;
|
|
||||||
heute tippt man jede Nummer. (In #025 bewusst weggelassen, weil es eine Vergabelogik braucht.)
|
|
||||||
3. **Mehrere Benutzer mit Rollen.** Derzeit darf jeder Angemeldete alles – in `docs/sicherheit.md`
|
|
||||||
offen vermerkt. Für Buchhaltung (nur Rechnungen) und Technik (nur Hosts) wären zwei Rollen genug.
|
|
||||||
Dazu gehört eine Benutzerverwaltung in der Oberfläche statt auf der Kommandozeille.
|
|
||||||
4. **Währung und Zeitzone auch in der Browseransicht.** Beides steht schon in den Einstellungen, wirkt
|
|
||||||
aber nur in PDF, CSV und Benachrichtigungen; die Oberfläche rechnet fest in Euro.
|
|
||||||
|
|
||||||
### Sinnvoll, wenn es wächst
|
**Aufwand:** mittel (PDF-Text auslesen, Zeilen erkennen, Zuordnung bestätigen lassen).
|
||||||
|
**Voraussetzung:** zwei, drei echte Rechnungen als Vorlage.
|
||||||
|
|
||||||
5. **Kundenportal oder Bericht per E-Mail.** Ein monatlicher Stand je Kunde, automatisch verschickt –
|
### 2. Verlängerung mit einem Klick
|
||||||
spart Rückfragen.
|
|
||||||
6. **Angebot vor der Verlängerung.** Aus der Jahresbahn heraus ein PDF-Angebot für das kommende
|
|
||||||
Lizenzjahr erzeugen.
|
|
||||||
7. **Import der Proxmox-Shop-Rechnungen.** Wer dort per Sammelrechnung kauft, tippt heute jede Position
|
|
||||||
ab. Ein CSV- oder PDF-Import mit Zuordnung über den Key wäre eine echte Ersparnis.
|
|
||||||
8. **Metriken für Prometheus** (`/metrics`): Hosts je Zustand, Tage bis zum nächsten Ablauf, Alter des
|
|
||||||
letzten Reports. Wer schon Grafana betreibt, sieht ProxLedger damit im gewohnten Bild.
|
|
||||||
9. **Zwei-Faktor-Anmeldung** für den lokalen Zugang. Heute lautet die Antwort „dann nimm OIDC“ – das
|
|
||||||
ist vertretbar, aber nicht für jeden.
|
|
||||||
|
|
||||||
### Nur falls der Bedarf entsteht
|
Heute wird eine Laufzeit bestätigt und die Folgelaufzeit von Hand angelegt. Ein Knopf „verlängern“,
|
||||||
|
der die nächste Laufzeit anlegt, den Einkauf vormerkt und die Kundenrechnung mit dem eingestellten
|
||||||
|
Aufschlag vorbereitet, nimmt bei jedem Lizenzjahr vier Schritte ab.
|
||||||
|
|
||||||
10. **Mandantenfähigkeit.** Mehrere Systemhäuser in einer Installation. Das ist ein Umbau, kein
|
**Aufwand:** klein bis mittel.
|
||||||
Anbau – die Zugriffskontrolle müsste durchgängig auf Mandanten umgestellt werden.
|
|
||||||
11. **Agent für weitere Produkte** (Proxmox Datacenter Manager), sobald der Bedarf da ist.
|
|
||||||
12. **Hochverfügbarkeit.** Die Ratenbremse liegt im Prozessspeicher; für mehrere Instanzen müsste sie
|
|
||||||
in die Datenbank wandern. Bei einem Dashboard für ein Systemhaus ist das Aufwand ohne Gewinn.
|
|
||||||
|
|
||||||
## Was ich bewusst nicht vorschlage
|
### 3. Kundenrechnung als Dokument
|
||||||
|
|
||||||
- **Pull statt Push.** Das Dashboard würde dann Zugänge zu allen Kundenhosts brauchen. Der jetzige Weg
|
ProxLedger führt die Kundenrechnung bisher nur als Datensatz – geschrieben wird sie woanders. Mit
|
||||||
kommt ohne gespeicherte root-Zugänge aus; das ist ein Sicherheitsgewinn, den man nicht aufgeben sollte.
|
Nummernkreis, Firmenangaben (stehen schon in den Einstellungen) und PDF-Ausgabe wäre der Kreis
|
||||||
- **Eigene Benutzer- und Rechteverwaltung mit Gruppen, Vererbung und allem.** Wer das braucht, hat schon
|
geschlossen. Zu klären wäre, ob das die vorhandene Buchhaltung ersetzen soll oder nur ergänzt.
|
||||||
ein IdP – dafür gibt es OIDC.
|
|
||||||
- **Agent mit Abhängigkeiten** (Python, jq). Er kommt heute mit Bash und curl aus und läuft damit auf
|
**Aufwand:** mittel. **Rückfrage zuerst:** Soll ProxLedger Rechnungen *schreiben* oder nur *führen*?
|
||||||
jedem Proxmox-Host, auch auf einem alten.
|
|
||||||
|
### 4. Oberflächentests mit echtem Browser
|
||||||
|
|
||||||
|
Die letzten sechs Fehler hat keiner der 251 Tests gefunden – alle kamen aus der Benutzung: Menü ließ
|
||||||
|
sich nicht schließen, Seite zoomte heraus, kein Weg zur Laufzeit, falscher Betrag in einer Tabelle.
|
||||||
|
Ein knappes Dutzend Abläufe mit Playwright (anmelden, Host zuweisen, Rechnung erfassen, auf dem
|
||||||
|
Telefon navigieren) würde genau diese Klasse abdecken.
|
||||||
|
|
||||||
|
**Aufwand:** mittel. **Nutzen:** steigt mit jeder weiteren Änderung.
|
||||||
|
|
||||||
|
### 5. Verlauf statt einer Notiz
|
||||||
|
|
||||||
|
Die Notiz am Host ist ein Textfeld – was wann passiert ist, verschwindet darin. Datierte Einträge
|
||||||
|
(„17.10.2026 · Sockets von 1 auf 2 erhöht“) wären bei Wechseln und Hardwaretausch nützlich.
|
||||||
|
|
||||||
|
**Aufwand:** klein.
|
||||||
|
|
||||||
|
### 6. Ablauf-Kalender abonnieren
|
||||||
|
|
||||||
|
Ein ICS-Feed mit allen Ablaufdaten, den man in Outlook oder Nextcloud einbindet. Dann stehen die
|
||||||
|
Verlängerungen im gewohnten Kalender, nicht nur im Dashboard.
|
||||||
|
|
||||||
|
**Aufwand:** klein.
|
||||||
|
|
||||||
|
### 7. Kundenzugang wirklich nutzen
|
||||||
|
|
||||||
|
Die Rolle *Kunde* existiert samt eingeschränkter Sicht, aber es gibt keinen Weg, jemanden einzuladen:
|
||||||
|
kein Einladungslink, keine erste Anmeldung ohne Passwort. Ohne das bleibt die Rolle Theorie.
|
||||||
|
|
||||||
|
**Aufwand:** klein bis mittel.
|
||||||
|
|
||||||
|
### 8. Metriken für Prometheus
|
||||||
|
|
||||||
|
`/metrics` mit Hosts je Zustand, Tagen bis zum nächsten Ablauf und Alter des letzten Reports. Wer
|
||||||
|
Grafana betreibt, sieht ProxLedger damit im gewohnten Bild – und merkt auch, wenn das Dashboard
|
||||||
|
selbst steht.
|
||||||
|
|
||||||
|
**Aufwand:** klein.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Später, falls der Bedarf entsteht
|
||||||
|
|
||||||
|
- **Mandantenfähigkeit** – mehrere Systemhäuser in einer Installation. Ein Umbau, kein Anbau.
|
||||||
|
- **Agent für Proxmox Datacenter Manager**, sobald es dort Subscriptions gibt, die das rechtfertigen.
|
||||||
|
- **Mehrere Instanzen** (Hochverfügbarkeit). Die Ratenbremse liegt im Prozessspeicher und müsste in
|
||||||
|
die Datenbank wandern. Für ein Dashboard eines Systemhauses Aufwand ohne Gewinn.
|
||||||
|
- **Angebot vor der Verlängerung** als PDF aus der Jahresbahn heraus.
|
||||||
|
|
||||||
|
## Bewusst nicht
|
||||||
|
|
||||||
|
- **Pull statt Push.** Das Dashboard bräuchte Zugang zu allen Kundenhosts. Der jetzige Weg kommt ohne
|
||||||
|
gespeicherte root-Zugänge aus – ein Sicherheitsgewinn, den man nicht aufgeben sollte.
|
||||||
|
- **Eigene Rechteverwaltung mit Gruppen und Vererbung.** Wer das braucht, hat ein IdP; dafür gibt es OIDC.
|
||||||
|
- **Agent mit Abhängigkeiten** (Python, jq). Er kommt mit Bash und curl aus und läuft damit auf jedem
|
||||||
|
Proxmox-Host.
|
||||||
|
- **Hosts automatisch zusammenführen.** Beim Doppeleintrag müsste geraten werden, welcher Eintrag
|
||||||
|
welche Rechnung erbt. Die Warnung nennt beide; entscheiden soll der Mensch.
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "proxledger",
|
"name": "proxledger",
|
||||||
"version": "0.19.1",
|
"version": "0.26.0",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "proxledger",
|
"name": "proxledger",
|
||||||
"version": "0.19.1",
|
"version": "0.26.0",
|
||||||
"hasInstallScript": true,
|
"hasInstallScript": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@node-rs/argon2": "^2.2.1",
|
"@node-rs/argon2": "^2.2.1",
|
||||||
@@ -20,7 +20,7 @@
|
|||||||
"bootstrap-icons": "^1.13.1",
|
"bootstrap-icons": "^1.13.1",
|
||||||
"croner": "^10.0.1",
|
"croner": "^10.0.1",
|
||||||
"jose": "^6.2.12",
|
"jose": "^6.2.12",
|
||||||
"next": "^16.3.8",
|
"next": "^16.4.0",
|
||||||
"nodemailer": "^10.0.10",
|
"nodemailer": "^10.0.10",
|
||||||
"openid-client": "^6.8.8",
|
"openid-client": "^6.8.8",
|
||||||
"react": "19.2.8",
|
"react": "19.2.8",
|
||||||
@@ -35,11 +35,11 @@
|
|||||||
"dotenv": "^17.4.2",
|
"dotenv": "^17.4.2",
|
||||||
"esbuild": "^0.28.2",
|
"esbuild": "^0.28.2",
|
||||||
"eslint": "^9",
|
"eslint": "^9",
|
||||||
"eslint-config-next": "^16.3.8",
|
"eslint-config-next": "^16.4.0",
|
||||||
"eslint-config-prettier": "^10.1.8",
|
"eslint-config-prettier": "^10.1.8",
|
||||||
"prettier": "^3.9.7",
|
"prettier": "^3.9.7",
|
||||||
"prisma": "^7.10.0",
|
"prisma": "^7.10.0",
|
||||||
"sharp": "^0.35.4",
|
"sharp": "^0.35.5",
|
||||||
"tsx": "^4.23.13",
|
"tsx": "^4.23.13",
|
||||||
"typescript": "^5",
|
"typescript": "^5",
|
||||||
"vitest": "^5.0.1"
|
"vitest": "^5.0.1"
|
||||||
@@ -1029,9 +1029,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@img/sharp-darwin-arm64": {
|
"node_modules/@img/sharp-darwin-arm64": {
|
||||||
"version": "0.35.4",
|
"version": "0.35.5",
|
||||||
"resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.35.4.tgz",
|
"resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.35.5.tgz",
|
||||||
"integrity": "sha512-Uhfl4V4lhP2nbUVF9+hyH1+luj86f1gUFeo8ALYxFoULoU+G87D43BfeMP8XHsk9boxAnCY/bf2EHwhA7MuGsA==",
|
"integrity": "sha512-QRUlFQ0WxvdWyqqG/WtI3iupfD5rBzmCHXSdPsY91sAtVtTo7Q4cb6zOccZ3gqEqkr0f1As1ehLqmEpDsRf+lg==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
@@ -1047,13 +1047,13 @@
|
|||||||
"url": "https://opencollective.com/libvips"
|
"url": "https://opencollective.com/libvips"
|
||||||
},
|
},
|
||||||
"optionalDependencies": {
|
"optionalDependencies": {
|
||||||
"@img/sharp-libvips-darwin-arm64": "1.3.3"
|
"@img/sharp-libvips-darwin-arm64": "1.3.4"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@img/sharp-darwin-x64": {
|
"node_modules/@img/sharp-darwin-x64": {
|
||||||
"version": "0.35.4",
|
"version": "0.35.5",
|
||||||
"resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.35.4.tgz",
|
"resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.35.5.tgz",
|
||||||
"integrity": "sha512-hWniXY3bG5qKpkKrAwPe4y+VTPmf086YQAnkxWh7uA1YrlRouWGa0M0Mxj3ZjnXFkv7/TD1bTy9lGUK26vRvWw==",
|
"integrity": "sha512-+BR255RhDlpygUpOc/Jdt1nT6DQ3XG/ERo5wbcdOf5Q320dKtPCKPLR1LJs9VGXRaMa8l1uUa0tkCNOXiAxZUw==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
@@ -1069,20 +1069,20 @@
|
|||||||
"url": "https://opencollective.com/libvips"
|
"url": "https://opencollective.com/libvips"
|
||||||
},
|
},
|
||||||
"optionalDependencies": {
|
"optionalDependencies": {
|
||||||
"@img/sharp-libvips-darwin-x64": "1.3.3"
|
"@img/sharp-libvips-darwin-x64": "1.3.4"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@img/sharp-freebsd-wasm32": {
|
"node_modules/@img/sharp-freebsd-wasm32": {
|
||||||
"version": "0.35.4",
|
"version": "0.35.5",
|
||||||
"resolved": "https://registry.npmjs.org/@img/sharp-freebsd-wasm32/-/sharp-freebsd-wasm32-0.35.4.tgz",
|
"resolved": "https://registry.npmjs.org/@img/sharp-freebsd-wasm32/-/sharp-freebsd-wasm32-0.35.5.tgz",
|
||||||
"integrity": "sha512-lIsKw/BU+kjB4eZjxrYrZmwOJYi3Ajrv66iAlBmUPyKc3HpnloevB1g3wxGD9P/5BbQ1brBGl65VRRrCvQDEqA==",
|
"integrity": "sha512-Y/z91nEZ4uIBX5X3nfTovjU9lHNKFYbL2lpHCLVNmXQK03VIZvXBBt0KxbPGp2SdGSF+2mQU4e+hQaWOt86iAw==",
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
"freebsd"
|
"freebsd"
|
||||||
],
|
],
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@img/sharp-wasm32": "0.35.4"
|
"@img/sharp-wasm32": "0.35.5"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=20.9.0"
|
"node": ">=20.9.0"
|
||||||
@@ -1092,9 +1092,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@img/sharp-libvips-darwin-arm64": {
|
"node_modules/@img/sharp-libvips-darwin-arm64": {
|
||||||
"version": "1.3.3",
|
"version": "1.3.4",
|
||||||
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.3.3.tgz",
|
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.3.4.tgz",
|
||||||
"integrity": "sha512-suTBPTDGrI9WodccaDdwZItTSaBYASlBk1NSfElSHrUfzu3szG6lvIF58+WiFvnfzuK8ZBFS5zE00PxqxnRiPg==",
|
"integrity": "sha512-5R89nBYiRdUlSWJxPhO+GVtaXzXSxKnRu/xqMn3KTA3L9EB9Oy/P+Nn2f2vlhPuUdy/Zusb2DarbyTpGCfEDuw==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
@@ -1108,9 +1108,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@img/sharp-libvips-darwin-x64": {
|
"node_modules/@img/sharp-libvips-darwin-x64": {
|
||||||
"version": "1.3.3",
|
"version": "1.3.4",
|
||||||
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.3.3.tgz",
|
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.3.4.tgz",
|
||||||
"integrity": "sha512-FVJZ5mITMobmXIz/hPDTw0EintTW5H3WfrxwLqEqjiIihlu+hVRyGrFQ60xl0Lxn7Bt3zdpevPaQi0HEzqz9fw==",
|
"integrity": "sha512-iR2OKH80yi0U+dUplyh3/xdpFvps6YkCwsXenIJxqxR1v9o+xtKTGbS9H7cps+2Vxjc8B1j96p75NmTGjIhtpQ==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
@@ -1124,9 +1124,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@img/sharp-libvips-linux-arm": {
|
"node_modules/@img/sharp-libvips-linux-arm": {
|
||||||
"version": "1.3.3",
|
"version": "1.3.4",
|
||||||
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.3.3.tgz",
|
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.3.4.tgz",
|
||||||
"integrity": "sha512-3rbU4vqXXc3hY/OiXdl52xZvT0F1yEngWfvqudtPJg/KkyiaQw2DRsFrNzpmLvfavbwOq3qXn36GP8obHRULQA==",
|
"integrity": "sha512-LmRtTsOHuvM2+wlO2Db37dx5MiZhB0FvSunciw48YjdOkZz9KAiRbm8ujeMOA1INqmei5NapFxYEK1D1ZSidmw==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm"
|
"arm"
|
||||||
],
|
],
|
||||||
@@ -1143,9 +1143,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@img/sharp-libvips-linux-arm64": {
|
"node_modules/@img/sharp-libvips-linux-arm64": {
|
||||||
"version": "1.3.3",
|
"version": "1.3.4",
|
||||||
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.3.3.tgz",
|
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.3.4.tgz",
|
||||||
"integrity": "sha512-0DaL0A6Xu6sQSQFwe4iVCrKWU2cCTItnRsYsCdxAMm9NF6twAA9BKnoqy4hqz4+azQ0JHuA26qiUKsf1XJ/v5A==",
|
"integrity": "sha512-Y3dgX/6lE2QhQb+Gxy0WZxfg9MEm/JBjamZpS2IklP7xIQoKN4hzAm7KcMVGtaVDt3neE9OKBC7vAfonA/Lr1A==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
@@ -1162,9 +1162,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@img/sharp-libvips-linux-ppc64": {
|
"node_modules/@img/sharp-libvips-linux-ppc64": {
|
||||||
"version": "1.3.3",
|
"version": "1.3.4",
|
||||||
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.3.3.tgz",
|
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.3.4.tgz",
|
||||||
"integrity": "sha512-cdn1OvUBwsXhbC0zSzJnNzf5MZ/mTrobawDvNXBTxe8VtqKAm0sRuEY2Evzovb/w9JMk4TvRxqt1mekSuJz64w==",
|
"integrity": "sha512-Le6boB8Tai0Nis+gIxIpKx68UDVVIqdR8Tin5Yf1z2LJJQLDJvCDRqRu+jC2qCoD+eIomonmOwB4smBRxfVpYQ==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"ppc64"
|
"ppc64"
|
||||||
],
|
],
|
||||||
@@ -1181,9 +1181,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@img/sharp-libvips-linux-riscv64": {
|
"node_modules/@img/sharp-libvips-linux-riscv64": {
|
||||||
"version": "1.3.3",
|
"version": "1.3.4",
|
||||||
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.3.3.tgz",
|
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.3.4.tgz",
|
||||||
"integrity": "sha512-HjPVx7yKz+0lqdhDlTw1tt90wamBoxhiXpvl1XZpJLiHH4RCJ5yDTqH+VlYPv2fwFs89JFw4c1IexYOcQUi4IQ==",
|
"integrity": "sha512-aHkkIEHPRdQEegJN20MLmGtxYD9R2wQr3Cwpddnu5+YKMt6Uzax7S9h5gpZTo8wyrGuZSlfQ63OevL5mTyOC7Q==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"riscv64"
|
"riscv64"
|
||||||
],
|
],
|
||||||
@@ -1200,9 +1200,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@img/sharp-libvips-linux-s390x": {
|
"node_modules/@img/sharp-libvips-linux-s390x": {
|
||||||
"version": "1.3.3",
|
"version": "1.3.4",
|
||||||
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.3.3.tgz",
|
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.3.4.tgz",
|
||||||
"integrity": "sha512-neWLh+3yCNThxnfy3c4BbVBeGgt9aftno+XbT56iK28RgeDs3UOFWviLWlUu0bArYVYJaFDK+RRohbicUNCm8Q==",
|
"integrity": "sha512-ra/mB6MikESDUO7Yg+Mi95bFBb9GsObURuhnOv3OqknjGe9sZrG8tCe9q0xSIGrtLgvgw0gKnFWcK4blSgQOuQ==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"s390x"
|
"s390x"
|
||||||
],
|
],
|
||||||
@@ -1219,9 +1219,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@img/sharp-libvips-linux-x64": {
|
"node_modules/@img/sharp-libvips-linux-x64": {
|
||||||
"version": "1.3.3",
|
"version": "1.3.4",
|
||||||
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.3.3.tgz",
|
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.3.4.tgz",
|
||||||
"integrity": "sha512-4vKmvAst9nrowcqquKFAyZJUDolUaIp8uRiN0mWFguJ1IplC9/pitXtlnnlU4aa/eJw3J7i67V+pwUL+wZGdsA==",
|
"integrity": "sha512-GJ//SSXbnwSDes02umB3nDJLFcQzw8a18V8fyhqr6tV515tOEMdImjjxj1AoafMRz56F3PHgftnj1QEKSU1zkw==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
@@ -1238,9 +1238,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@img/sharp-libvips-linuxmusl-arm64": {
|
"node_modules/@img/sharp-libvips-linuxmusl-arm64": {
|
||||||
"version": "1.3.3",
|
"version": "1.3.4",
|
||||||
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.3.3.tgz",
|
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.3.4.tgz",
|
||||||
"integrity": "sha512-Y9kQaLMuNoB0bPYOOdcZMaseNrFpPodIWWMrx+CZyydf2xn68j9WYc6sWWRrDwNkzCQjKYfc68L7jKjGlHMibw==",
|
"integrity": "sha512-hvulFwtjUcagsis6BBxHwGFwWoNZjgYmULGVrZcyfNbjA8hKILbRxGg15/7w5HDyXHXUos/j6baAWqnCyQ2DWA==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
@@ -1257,9 +1257,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@img/sharp-libvips-linuxmusl-x64": {
|
"node_modules/@img/sharp-libvips-linuxmusl-x64": {
|
||||||
"version": "1.3.3",
|
"version": "1.3.4",
|
||||||
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.3.3.tgz",
|
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.3.4.tgz",
|
||||||
"integrity": "sha512-fj8Mv0HHfD1Rr+4I68+3agJynxDWtBFgicTbSOb9Bke6pIwzGcJ+RX/yHjmiEGFMCavY/dxvem7MyNaJF+wDiw==",
|
"integrity": "sha512-6zXKeE/p39I1AmA3cJG35eyBGNqNddLnUXjhwBnsGjFPWqf5VKkDBEqaEkPDoTEtkxwi2vv8Tcr2mDyP4So7Fg==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
@@ -1276,9 +1276,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@img/sharp-linux-arm": {
|
"node_modules/@img/sharp-linux-arm": {
|
||||||
"version": "0.35.4",
|
"version": "0.35.5",
|
||||||
"resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.35.4.tgz",
|
"resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.35.5.tgz",
|
||||||
"integrity": "sha512-7OAS8gI0EReKGVN2HssHlM6umJgxF5VI3xN0p9FA91p/YO+ou5hiNghLdZ5BEHztwaaK5+bLKRf8x/o2L2nk9A==",
|
"integrity": "sha512-LEaXK2WdXVK5ykcw0buWyPMsmLLL2vpHLD6yrNSW+JGEL3BZPA4tpKN6iaMc4AxTTAoaX/sU1rOL51lcIz48ZQ==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm"
|
"arm"
|
||||||
],
|
],
|
||||||
@@ -1297,13 +1297,13 @@
|
|||||||
"url": "https://opencollective.com/libvips"
|
"url": "https://opencollective.com/libvips"
|
||||||
},
|
},
|
||||||
"optionalDependencies": {
|
"optionalDependencies": {
|
||||||
"@img/sharp-libvips-linux-arm": "1.3.3"
|
"@img/sharp-libvips-linux-arm": "1.3.4"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@img/sharp-linux-arm64": {
|
"node_modules/@img/sharp-linux-arm64": {
|
||||||
"version": "0.35.4",
|
"version": "0.35.5",
|
||||||
"resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.35.4.tgz",
|
"resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.35.5.tgz",
|
||||||
"integrity": "sha512-De4jpEnAU8Hd5oT0j1G3uL4ZvTuipVMn7YC6vPaJhy6/7EwEae0SVAoBrUMYQbkLGDm85taVWwuPc1a44LTzCQ==",
|
"integrity": "sha512-LYVx5JTsOM2CBzmxreh+nl64/3H6Xb09iSLknqH47z2T2DFFxDeFLP5y4dJwe6H7uGQlHPyEEtIqyo3DYsRwdQ==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
@@ -1322,13 +1322,13 @@
|
|||||||
"url": "https://opencollective.com/libvips"
|
"url": "https://opencollective.com/libvips"
|
||||||
},
|
},
|
||||||
"optionalDependencies": {
|
"optionalDependencies": {
|
||||||
"@img/sharp-libvips-linux-arm64": "1.3.3"
|
"@img/sharp-libvips-linux-arm64": "1.3.4"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@img/sharp-linux-ppc64": {
|
"node_modules/@img/sharp-linux-ppc64": {
|
||||||
"version": "0.35.4",
|
"version": "0.35.5",
|
||||||
"resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.35.4.tgz",
|
"resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.35.5.tgz",
|
||||||
"integrity": "sha512-2oYZJeIl4kCcMGk4ouZVjnkCtFrpQFlNEtJ6GbxzhHQchwH0NH/qEb9ykmOl29dqwMq+JhFdZn+1ak2FKhI9fQ==",
|
"integrity": "sha512-QVxAAq8evVRI9ia2vqgwrmWucn5Dfv+JdWzj75pD8omHLPSP7f8p20O8jxzjCcuCEQEOtYOZUmX1hkiZ0kdevA==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"ppc64"
|
"ppc64"
|
||||||
],
|
],
|
||||||
@@ -1347,13 +1347,13 @@
|
|||||||
"url": "https://opencollective.com/libvips"
|
"url": "https://opencollective.com/libvips"
|
||||||
},
|
},
|
||||||
"optionalDependencies": {
|
"optionalDependencies": {
|
||||||
"@img/sharp-libvips-linux-ppc64": "1.3.3"
|
"@img/sharp-libvips-linux-ppc64": "1.3.4"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@img/sharp-linux-riscv64": {
|
"node_modules/@img/sharp-linux-riscv64": {
|
||||||
"version": "0.35.4",
|
"version": "0.35.5",
|
||||||
"resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.35.4.tgz",
|
"resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.35.5.tgz",
|
||||||
"integrity": "sha512-cPbNChoRURAWdebDIHSenxRpgEdy7JkPydSnUxRm9VvKD7m0/xVaR/8Fzlu81pk5nHEvHH87UZUA7cTtwnbJSA==",
|
"integrity": "sha512-LtdreXguaavKODPIfzJ4kffx7UNt1omwtK0rch4EBbbSTXPnxWmYSayXdLJw0fJzQ97kHt1gL/yh4tvU+nCyRQ==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"riscv64"
|
"riscv64"
|
||||||
],
|
],
|
||||||
@@ -1372,13 +1372,13 @@
|
|||||||
"url": "https://opencollective.com/libvips"
|
"url": "https://opencollective.com/libvips"
|
||||||
},
|
},
|
||||||
"optionalDependencies": {
|
"optionalDependencies": {
|
||||||
"@img/sharp-libvips-linux-riscv64": "1.3.3"
|
"@img/sharp-libvips-linux-riscv64": "1.3.4"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@img/sharp-linux-s390x": {
|
"node_modules/@img/sharp-linux-s390x": {
|
||||||
"version": "0.35.4",
|
"version": "0.35.5",
|
||||||
"resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.35.4.tgz",
|
"resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.35.5.tgz",
|
||||||
"integrity": "sha512-RY0JFY8Fd6RonCBtHz+DvadaPkXDSI1AUn6yWL9TipqkZ1vY8w8evqdgyDFnkm4/K1ve1TvZiaePP5oSd4+WVQ==",
|
"integrity": "sha512-UZasTOFiYzotTsGOCu42BfUzP6Tu6Do/947iRm1RsLKvlllxwGcn4RN27LibGWceix4Y+Pmw3jsnTcCQIgWjqA==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"s390x"
|
"s390x"
|
||||||
],
|
],
|
||||||
@@ -1397,13 +1397,13 @@
|
|||||||
"url": "https://opencollective.com/libvips"
|
"url": "https://opencollective.com/libvips"
|
||||||
},
|
},
|
||||||
"optionalDependencies": {
|
"optionalDependencies": {
|
||||||
"@img/sharp-libvips-linux-s390x": "1.3.3"
|
"@img/sharp-libvips-linux-s390x": "1.3.4"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@img/sharp-linux-x64": {
|
"node_modules/@img/sharp-linux-x64": {
|
||||||
"version": "0.35.4",
|
"version": "0.35.5",
|
||||||
"resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.35.4.tgz",
|
"resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.35.5.tgz",
|
||||||
"integrity": "sha512-9qvvEAuk8k89TfWUoX2htWjbAMX8p+NxCppjpcg5k6xMsjhBQPTsoIh36h9Qde4WRuGpJeYnOjdosDn/cnv+OA==",
|
"integrity": "sha512-SxFtLTeJInhAA9Q836kux2vZNeOBQEx658qvbboZScr0wIARym3IcGmW7KpVD5sbVg0Ojy+udFQdayYIZyoNog==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
@@ -1422,13 +1422,13 @@
|
|||||||
"url": "https://opencollective.com/libvips"
|
"url": "https://opencollective.com/libvips"
|
||||||
},
|
},
|
||||||
"optionalDependencies": {
|
"optionalDependencies": {
|
||||||
"@img/sharp-libvips-linux-x64": "1.3.3"
|
"@img/sharp-libvips-linux-x64": "1.3.4"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@img/sharp-linuxmusl-arm64": {
|
"node_modules/@img/sharp-linuxmusl-arm64": {
|
||||||
"version": "0.35.4",
|
"version": "0.35.5",
|
||||||
"resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.35.4.tgz",
|
"resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.35.5.tgz",
|
||||||
"integrity": "sha512-KB5jxpfWQTr0nc3xdHtWChdbifHrBGsd2SM62Eyxrl8afikm+f5qGBU75SJIZBT/S1MC8XyacdlXBMSWq6OURA==",
|
"integrity": "sha512-9HbMclmI1zlNkFRs3z9/eBtDjfD0sGlrX1z6b1qwmiFY5ElDLh4BC0LPBdVp7z1DXFiKlIcznf+ZlsuZzLxQqg==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
@@ -1447,13 +1447,13 @@
|
|||||||
"url": "https://opencollective.com/libvips"
|
"url": "https://opencollective.com/libvips"
|
||||||
},
|
},
|
||||||
"optionalDependencies": {
|
"optionalDependencies": {
|
||||||
"@img/sharp-libvips-linuxmusl-arm64": "1.3.3"
|
"@img/sharp-libvips-linuxmusl-arm64": "1.3.4"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@img/sharp-linuxmusl-x64": {
|
"node_modules/@img/sharp-linuxmusl-x64": {
|
||||||
"version": "0.35.4",
|
"version": "0.35.5",
|
||||||
"resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.35.4.tgz",
|
"resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.35.5.tgz",
|
||||||
"integrity": "sha512-f+eZJZIQNEEd26RPSW+76chwOf1XtA2Y/O+5ocVyLliHkeih3e+jhLVBdNTd2rS3IbNXK8+ug93Vf5ZXtF5Lxg==",
|
"integrity": "sha512-4KOphqB035HrVdqLZfCgMzzERrQkkzOwRhl4OAkRO1YCldbaFjySXMaK534Mo0V+LndnlJk+sbUyLeU0ULyD1A==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
@@ -1472,13 +1472,13 @@
|
|||||||
"url": "https://opencollective.com/libvips"
|
"url": "https://opencollective.com/libvips"
|
||||||
},
|
},
|
||||||
"optionalDependencies": {
|
"optionalDependencies": {
|
||||||
"@img/sharp-libvips-linuxmusl-x64": "1.3.3"
|
"@img/sharp-libvips-linuxmusl-x64": "1.3.4"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@img/sharp-wasm32": {
|
"node_modules/@img/sharp-wasm32": {
|
||||||
"version": "0.35.4",
|
"version": "0.35.5",
|
||||||
"resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.35.4.tgz",
|
"resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.35.5.tgz",
|
||||||
"integrity": "sha512-zQnl4Kwp7Q6NHsENtU2T/00Zi+w3AQNwz3+UaTyVBy2FpXrzXzGjndpK61onhZjRtRpQXxCTeqw19bVyXOh7jA==",
|
"integrity": "sha512-Ptsga1su4tQx+LLF1ECS9U6nz5kmrXKo6XVbtR48Ke3ZRxxgaWBu7IDtEe1quo8hiupwm6WFqxVlXaSf7IINGQ==",
|
||||||
"license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT",
|
"license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
@@ -1492,16 +1492,16 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@img/sharp-webcontainers-wasm32": {
|
"node_modules/@img/sharp-webcontainers-wasm32": {
|
||||||
"version": "0.35.4",
|
"version": "0.35.5",
|
||||||
"resolved": "https://registry.npmjs.org/@img/sharp-webcontainers-wasm32/-/sharp-webcontainers-wasm32-0.35.4.tgz",
|
"resolved": "https://registry.npmjs.org/@img/sharp-webcontainers-wasm32/-/sharp-webcontainers-wasm32-0.35.5.tgz",
|
||||||
"integrity": "sha512-ESfNkywmCfPNyaZjxooddJQiQ+l/nTpGEOGthxiLnIHXC/CmcBixnfwUleX9mCz9ovrUUvKMap/pm8RYbzfwaA==",
|
"integrity": "sha512-hfhF/FmoQyTUkA0bIKFOtw536BQSeBMe6BF6QyWlrPxT754+TFLaZ7sKKTfvvM0yJgKgaYTwnFCIZ/GuDw5SUA==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"wasm32"
|
"wasm32"
|
||||||
],
|
],
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@img/sharp-wasm32": "0.35.4"
|
"@img/sharp-wasm32": "0.35.5"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=20.9.0"
|
"node": ">=20.9.0"
|
||||||
@@ -1511,9 +1511,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@img/sharp-win32-arm64": {
|
"node_modules/@img/sharp-win32-arm64": {
|
||||||
"version": "0.35.4",
|
"version": "0.35.5",
|
||||||
"resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.35.4.tgz",
|
"resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.35.5.tgz",
|
||||||
"integrity": "sha512-iNdlBX9gLVvqe2I3uIJSIKTq6wckP/DYxZtcqxm09x5Gi24DnFBmPAWZmr60ZyYMG0xlzo6goG3670ar+RXvRw==",
|
"integrity": "sha512-X4t7g+7ZA5DKblCBEXGjUqqemj4vczING/5viFwAL8h4N3qYeyjwdCvRLHi4EdOUI+2Z7UFlp1VM+p/AuEtm6Q==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
@@ -1530,9 +1530,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@img/sharp-win32-ia32": {
|
"node_modules/@img/sharp-win32-ia32": {
|
||||||
"version": "0.35.4",
|
"version": "0.35.5",
|
||||||
"resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.35.4.tgz",
|
"resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.35.5.tgz",
|
||||||
"integrity": "sha512-kqRsbaa5CS6KHlpxnN7WhE6vAAugXyZButpRdvDWetlv6Qv4N9WTcrWzF7tXfB9T7MsoadqdI8hmwLq6UlLvtw==",
|
"integrity": "sha512-5Zm82LoBc43nhwNybZlG7Y1KO//Zhsn306fQl29ZOuStHLGTo3BWL83q3cznX0poxSAMuYL1On/BHBxkBeKr6A==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"ia32"
|
"ia32"
|
||||||
],
|
],
|
||||||
@@ -1549,9 +1549,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@img/sharp-win32-x64": {
|
"node_modules/@img/sharp-win32-x64": {
|
||||||
"version": "0.35.4",
|
"version": "0.35.5",
|
||||||
"resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.35.4.tgz",
|
"resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.35.5.tgz",
|
||||||
"integrity": "sha512-XtmnYhBcrORsJ4XJngyzr/EWP0hRZLAZRFaApdKuviyqF78+ylxh2y06ZmtULAMOnObJ3ucpN0AcwSWnMowTRg==",
|
"integrity": "sha512-x76eH0vEiHlcMQu8Y8IenntaACtddpT6W0wmXtWrnKcnKI7ME5DdgqhAD6SEWOEl1v2zDvkZDhFA9KnURwpfqg==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
@@ -1646,15 +1646,15 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@next/env": {
|
"node_modules/@next/env": {
|
||||||
"version": "16.3.8",
|
"version": "16.4.0",
|
||||||
"resolved": "https://registry.npmjs.org/@next/env/-/env-16.3.8.tgz",
|
"resolved": "https://registry.npmjs.org/@next/env/-/env-16.4.0.tgz",
|
||||||
"integrity": "sha512-Al9zqHVV7TJv0eFuOU4U7Lvv74PTih4Ch63sk2xCIpSTkE3udFnaOcnzP2lQVymiL7yS9Cj2iClUXlR3EQ5sEw==",
|
"integrity": "sha512-pyLlT5oafGlX8X7xjgGNXz5dNIDSxREm+qD6i9VLSZiZFopA/XWl8NM/KPmLx/8EG/++c49iid3gNWSQW1+2dQ==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/@next/eslint-plugin-next": {
|
"node_modules/@next/eslint-plugin-next": {
|
||||||
"version": "16.3.8",
|
"version": "16.4.0",
|
||||||
"resolved": "https://registry.npmjs.org/@next/eslint-plugin-next/-/eslint-plugin-next-16.3.8.tgz",
|
"resolved": "https://registry.npmjs.org/@next/eslint-plugin-next/-/eslint-plugin-next-16.4.0.tgz",
|
||||||
"integrity": "sha512-eCR9RcLTZrVS+K3+VOhi0xMdUUGNuriCHHhU1ZoABC/QQ/Gp0OWbLKQxV2/9DkmihYdYRcQWFmZXW8M1AMUXYw==",
|
"integrity": "sha512-ffeflB/dL8RrvWejQximw01pOpKHN692Y3FjDB82DCd0YVvXvBD6vY7mcZp27yvSVFVM6wA73xVodEKF4spizA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
@@ -1695,9 +1695,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@next/swc-darwin-arm64": {
|
"node_modules/@next/swc-darwin-arm64": {
|
||||||
"version": "16.3.8",
|
"version": "16.4.0",
|
||||||
"resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-16.3.8.tgz",
|
"resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-16.4.0.tgz",
|
||||||
"integrity": "sha512-2JPRMh2nmQG5CiL7cXGL9AGwnPWJQ//cTtAUCT+w511QHk79SYz3LGv/pc5X643B/WEO0rvu3Yww0hqwt3kgeA==",
|
"integrity": "sha512-yyZ+9MgViAKLLb5l1gPBGjRwnq3teNLb5Zx6JEw8LzNSTjlsnp+baUHFxTgl7PpjCmKf48VQfyipG1eBflb48Q==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
@@ -1711,9 +1711,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@next/swc-darwin-x64": {
|
"node_modules/@next/swc-darwin-x64": {
|
||||||
"version": "16.3.8",
|
"version": "16.4.0",
|
||||||
"resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-16.3.8.tgz",
|
"resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-16.4.0.tgz",
|
||||||
"integrity": "sha512-GZtCCOBKJ4leVIT/Th0llWKhD1ca92lzbQiS5R5ON9QkoiFnilFsebDae1JU2a3HWoKMEmEZWGs1AGLavVM72Q==",
|
"integrity": "sha512-RePEEejEj1Auf5s9qEcG0JbcDP3ceNUS/ofNX8AhxzgIjTVlZIHMatlp16YplcSMpsVo/ZMQHJTjbMYJznjGUg==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
@@ -1727,9 +1727,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@next/swc-linux-arm64-gnu": {
|
"node_modules/@next/swc-linux-arm64-gnu": {
|
||||||
"version": "16.3.8",
|
"version": "16.4.0",
|
||||||
"resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-16.3.8.tgz",
|
"resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-16.4.0.tgz",
|
||||||
"integrity": "sha512-O659ygeQYqneJ1fBKMpFxIFqYkYswu8IAS1OCKK/4f3ZgJJm1dRz4fVJZRi/kLLWjnBKnebOePA4WNv+sV1pVA==",
|
"integrity": "sha512-HhsyCqd+NpmvDDVY/LqkAN24f+QVvXh3ie+PfcwKinWL2Mu826JymQxJXGjAIfmJ4VlHjp4Pe/nZtXtUoEMAqw==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
@@ -1746,9 +1746,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@next/swc-linux-arm64-musl": {
|
"node_modules/@next/swc-linux-arm64-musl": {
|
||||||
"version": "16.3.8",
|
"version": "16.4.0",
|
||||||
"resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-16.3.8.tgz",
|
"resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-16.4.0.tgz",
|
||||||
"integrity": "sha512-dSjKSyWpzxoO1d3DIZZcP4XJcNaKeLmxQMFOiYl5vuBRMmweIqnAhty8tAmRsvTss779cK1FtYnDMj40e4TQlg==",
|
"integrity": "sha512-sN9Pf+Zh+RpZAOmbR7d8KAA8T61CVhawtbEJ2Bk6QrRzx6Z/6haaF4UiwBzytM96GRRRWZh9/WALIEO3npMamA==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
@@ -1765,9 +1765,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@next/swc-linux-x64-gnu": {
|
"node_modules/@next/swc-linux-x64-gnu": {
|
||||||
"version": "16.3.8",
|
"version": "16.4.0",
|
||||||
"resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-16.3.8.tgz",
|
"resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-16.4.0.tgz",
|
||||||
"integrity": "sha512-lbqOuz3RPRcv+o9msNsJw5x4+Y1ZwPTs6vmL6DCf7i0fZfvng/F59wyeDwqHIvV0mK//RBy/jJkZ+nCKsSMXjQ==",
|
"integrity": "sha512-D5M5xLWQVd1CvR+L7BickM1weRfmM41x3L1NWcnKY/oyJJqr1qwaCgmu3dMAhLBOMdZwSuDCRV6DJRRpiUkbDQ==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
@@ -1784,9 +1784,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@next/swc-linux-x64-musl": {
|
"node_modules/@next/swc-linux-x64-musl": {
|
||||||
"version": "16.3.8",
|
"version": "16.4.0",
|
||||||
"resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-16.3.8.tgz",
|
"resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-16.4.0.tgz",
|
||||||
"integrity": "sha512-+316WswI8ScVgZeUd+1KGaXkHhaYQzCjvH/05TZSpJ8zBizb1a4G7DtO7F12jcBIqMOtsz9ji1t48fmKtzqsGA==",
|
"integrity": "sha512-8d5WLS6a9YalqpOgXdlbvonFu+sIgjA+CO6tGzetrpPVcUL56foa7B7664h41rAlbLi5fJwK4vWji3zf4QDy+w==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
@@ -1803,9 +1803,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@next/swc-win32-arm64-msvc": {
|
"node_modules/@next/swc-win32-arm64-msvc": {
|
||||||
"version": "16.3.8",
|
"version": "16.4.0",
|
||||||
"resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-16.3.8.tgz",
|
"resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-16.4.0.tgz",
|
||||||
"integrity": "sha512-ji0gd4kMYUxO+1fJBIbiBVRCjzG/lloiyCccnlebvb1ZJ5qXCPZqYg4Jl1DrrixnWNMKylzgpmMWx0yNDYXlzw==",
|
"integrity": "sha512-sQuOIiW3snb48UwrBcDMvFA/u03qtw2LeuSIxFIQ9AJuW9UG/4Ddsfo+eV73PnZwBhyncMtsKVh7PrzMol2/fA==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
@@ -1819,9 +1819,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@next/swc-win32-x64-msvc": {
|
"node_modules/@next/swc-win32-x64-msvc": {
|
||||||
"version": "16.3.8",
|
"version": "16.4.0",
|
||||||
"resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-16.3.8.tgz",
|
"resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-16.4.0.tgz",
|
||||||
"integrity": "sha512-WcTlaKt/TWkh5kUjdJcUmB1XgZ+1c6fz4Y9fDHL73YNSdGaUWjceeWrrlwF0nv19iABYWC4iAq1oX1w4Bn0vfg==",
|
"integrity": "sha512-6/33lCuxpdHUy27RegyGM/5uTpF7YZd3UR4D0gpkOA/tWY5YR7msbQjqPgCZmSmxrrTLGCXYA4A8vT2H5DPGTQ==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
@@ -5889,21 +5889,21 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/eslint-config-next": {
|
"node_modules/eslint-config-next": {
|
||||||
"version": "16.3.8",
|
"version": "16.4.0",
|
||||||
"resolved": "https://registry.npmjs.org/eslint-config-next/-/eslint-config-next-16.3.8.tgz",
|
"resolved": "https://registry.npmjs.org/eslint-config-next/-/eslint-config-next-16.4.0.tgz",
|
||||||
"integrity": "sha512-81vovwMe6NGnoFsl0KUJWzlS+y239i3fdsxs49gSEX3pDQW2cuKy+fxUPXNkQqqx6OCPheYPtBlSufc9aZ0k+w==",
|
"integrity": "sha512-2RHLYswzc+hNRktWAjIL6XAvwb1cKcjLawJ5KWywAxn/JivNHtG42ZigL1yobVXDqsimuuL2F9jcEcnHFzbppQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@next/eslint-plugin-next": "16.3.8",
|
"@next/eslint-plugin-next": "16.4.0",
|
||||||
"eslint-import-resolver-node": "^0.3.6",
|
"eslint-import-resolver-node": "^0.3.6",
|
||||||
"eslint-import-resolver-typescript": "^3.5.2",
|
"eslint-import-resolver-typescript": "^3.5.2",
|
||||||
"eslint-plugin-import": "^2.32.0",
|
"eslint-plugin-import": "^2.32.0",
|
||||||
"eslint-plugin-jsx-a11y": "^6.10.0",
|
"eslint-plugin-jsx-a11y": "^6.10.0",
|
||||||
"eslint-plugin-react": "^7.37.0",
|
"eslint-plugin-react": "^7.37.0",
|
||||||
"eslint-plugin-react-hooks": "^7.0.0",
|
"eslint-plugin-react-hooks": "^7.1.0",
|
||||||
"globals": "16.4.0",
|
"globals": "16.4.0",
|
||||||
"typescript-eslint": "^8.46.0"
|
"typescript-eslint": "^8.56.0"
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"eslint": ">=9.0.0",
|
"eslint": ">=9.0.0",
|
||||||
@@ -8184,12 +8184,12 @@
|
|||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/next": {
|
"node_modules/next": {
|
||||||
"version": "16.3.8",
|
"version": "16.4.0",
|
||||||
"resolved": "https://registry.npmjs.org/next/-/next-16.3.8.tgz",
|
"resolved": "https://registry.npmjs.org/next/-/next-16.4.0.tgz",
|
||||||
"integrity": "sha512-U7QEZaTini6wKrb8A8hqLLqYQyCetegKjCpJOyxk642vWoMoU1x5PyZCJFvgYgiptA8xc5j/9xYlZFO7w9Sjmw==",
|
"integrity": "sha512-N2C+9q89Z/oLrsepSRS4jRMtwmUZAoZuAHU0e9T6+sAB5DdhADmrnOW+zjYijlrYFmVfmQhGVGy9eMFoB7UgKg==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@next/env": "16.3.8",
|
"@next/env": "16.4.0",
|
||||||
"@swc/helpers": "0.5.23",
|
"@swc/helpers": "0.5.23",
|
||||||
"baseline-browser-mapping": "^2.9.19",
|
"baseline-browser-mapping": "^2.9.19",
|
||||||
"caniuse-lite": "^1.0.30001579",
|
"caniuse-lite": "^1.0.30001579",
|
||||||
@@ -8203,14 +8203,14 @@
|
|||||||
"node": ">=20.9.0"
|
"node": ">=20.9.0"
|
||||||
},
|
},
|
||||||
"optionalDependencies": {
|
"optionalDependencies": {
|
||||||
"@next/swc-darwin-arm64": "16.3.8",
|
"@next/swc-darwin-arm64": "16.4.0",
|
||||||
"@next/swc-darwin-x64": "16.3.8",
|
"@next/swc-darwin-x64": "16.4.0",
|
||||||
"@next/swc-linux-arm64-gnu": "16.3.8",
|
"@next/swc-linux-arm64-gnu": "16.4.0",
|
||||||
"@next/swc-linux-arm64-musl": "16.3.8",
|
"@next/swc-linux-arm64-musl": "16.4.0",
|
||||||
"@next/swc-linux-x64-gnu": "16.3.8",
|
"@next/swc-linux-x64-gnu": "16.4.0",
|
||||||
"@next/swc-linux-x64-musl": "16.3.8",
|
"@next/swc-linux-x64-musl": "16.4.0",
|
||||||
"@next/swc-win32-arm64-msvc": "16.3.8",
|
"@next/swc-win32-arm64-msvc": "16.4.0",
|
||||||
"@next/swc-win32-x64-msvc": "16.3.8",
|
"@next/swc-win32-x64-msvc": "16.4.0",
|
||||||
"sharp": "^0.35.4"
|
"sharp": "^0.35.4"
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
@@ -9422,9 +9422,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/sharp": {
|
"node_modules/sharp": {
|
||||||
"version": "0.35.4",
|
"version": "0.35.5",
|
||||||
"resolved": "https://registry.npmjs.org/sharp/-/sharp-0.35.4.tgz",
|
"resolved": "https://registry.npmjs.org/sharp/-/sharp-0.35.5.tgz",
|
||||||
"integrity": "sha512-n++8XWcj+jCOr2IOl7h8LbKnGBDY4aPbmprMONBNFdn0ImXqpGVv5zliDs0V9HbmbCQLpbuo2ej9rAoOQTvMDA==",
|
"integrity": "sha512-Ywn4OnzGukp7CDMrp08RQ50YKmuwG47brZgIVPTvBaaAfQlRlygrRqSrxdCiL9M+LlzLBiJ68IR1QqvzHyjC7g==",
|
||||||
"devOptional": true,
|
"devOptional": true,
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
@@ -9439,31 +9439,31 @@
|
|||||||
"url": "https://opencollective.com/libvips"
|
"url": "https://opencollective.com/libvips"
|
||||||
},
|
},
|
||||||
"optionalDependencies": {
|
"optionalDependencies": {
|
||||||
"@img/sharp-darwin-arm64": "0.35.4",
|
"@img/sharp-darwin-arm64": "0.35.5",
|
||||||
"@img/sharp-darwin-x64": "0.35.4",
|
"@img/sharp-darwin-x64": "0.35.5",
|
||||||
"@img/sharp-freebsd-wasm32": "0.35.4",
|
"@img/sharp-freebsd-wasm32": "0.35.5",
|
||||||
"@img/sharp-libvips-darwin-arm64": "1.3.3",
|
"@img/sharp-libvips-darwin-arm64": "1.3.4",
|
||||||
"@img/sharp-libvips-darwin-x64": "1.3.3",
|
"@img/sharp-libvips-darwin-x64": "1.3.4",
|
||||||
"@img/sharp-libvips-linux-arm": "1.3.3",
|
"@img/sharp-libvips-linux-arm": "1.3.4",
|
||||||
"@img/sharp-libvips-linux-arm64": "1.3.3",
|
"@img/sharp-libvips-linux-arm64": "1.3.4",
|
||||||
"@img/sharp-libvips-linux-ppc64": "1.3.3",
|
"@img/sharp-libvips-linux-ppc64": "1.3.4",
|
||||||
"@img/sharp-libvips-linux-riscv64": "1.3.3",
|
"@img/sharp-libvips-linux-riscv64": "1.3.4",
|
||||||
"@img/sharp-libvips-linux-s390x": "1.3.3",
|
"@img/sharp-libvips-linux-s390x": "1.3.4",
|
||||||
"@img/sharp-libvips-linux-x64": "1.3.3",
|
"@img/sharp-libvips-linux-x64": "1.3.4",
|
||||||
"@img/sharp-libvips-linuxmusl-arm64": "1.3.3",
|
"@img/sharp-libvips-linuxmusl-arm64": "1.3.4",
|
||||||
"@img/sharp-libvips-linuxmusl-x64": "1.3.3",
|
"@img/sharp-libvips-linuxmusl-x64": "1.3.4",
|
||||||
"@img/sharp-linux-arm": "0.35.4",
|
"@img/sharp-linux-arm": "0.35.5",
|
||||||
"@img/sharp-linux-arm64": "0.35.4",
|
"@img/sharp-linux-arm64": "0.35.5",
|
||||||
"@img/sharp-linux-ppc64": "0.35.4",
|
"@img/sharp-linux-ppc64": "0.35.5",
|
||||||
"@img/sharp-linux-riscv64": "0.35.4",
|
"@img/sharp-linux-riscv64": "0.35.5",
|
||||||
"@img/sharp-linux-s390x": "0.35.4",
|
"@img/sharp-linux-s390x": "0.35.5",
|
||||||
"@img/sharp-linux-x64": "0.35.4",
|
"@img/sharp-linux-x64": "0.35.5",
|
||||||
"@img/sharp-linuxmusl-arm64": "0.35.4",
|
"@img/sharp-linuxmusl-arm64": "0.35.5",
|
||||||
"@img/sharp-linuxmusl-x64": "0.35.4",
|
"@img/sharp-linuxmusl-x64": "0.35.5",
|
||||||
"@img/sharp-webcontainers-wasm32": "0.35.4",
|
"@img/sharp-webcontainers-wasm32": "0.35.5",
|
||||||
"@img/sharp-win32-arm64": "0.35.4",
|
"@img/sharp-win32-arm64": "0.35.5",
|
||||||
"@img/sharp-win32-ia32": "0.35.4",
|
"@img/sharp-win32-ia32": "0.35.5",
|
||||||
"@img/sharp-win32-x64": "0.35.4"
|
"@img/sharp-win32-x64": "0.35.5"
|
||||||
},
|
},
|
||||||
"peerDependenciesMeta": {
|
"peerDependenciesMeta": {
|
||||||
"@types/node": {
|
"@types/node": {
|
||||||
@@ -9604,9 +9604,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/source-map-js": {
|
"node_modules/source-map-js": {
|
||||||
"version": "1.2.1",
|
"version": "1.2.2",
|
||||||
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
|
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.2.tgz",
|
||||||
"integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==",
|
"integrity": "sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw==",
|
||||||
"license": "BSD-3-Clause",
|
"license": "BSD-3-Clause",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=0.10.0"
|
"node": ">=0.10.0"
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "proxledger",
|
"name": "proxledger",
|
||||||
"version": "0.24.0",
|
"version": "0.27.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "next dev",
|
"dev": "next dev",
|
||||||
@@ -41,7 +41,7 @@
|
|||||||
"bootstrap-icons": "^1.13.1",
|
"bootstrap-icons": "^1.13.1",
|
||||||
"croner": "^10.0.1",
|
"croner": "^10.0.1",
|
||||||
"jose": "^6.2.12",
|
"jose": "^6.2.12",
|
||||||
"next": "^16.3.8",
|
"next": "^16.4.0",
|
||||||
"nodemailer": "^10.0.10",
|
"nodemailer": "^10.0.10",
|
||||||
"openid-client": "^6.8.8",
|
"openid-client": "^6.8.8",
|
||||||
"react": "19.2.8",
|
"react": "19.2.8",
|
||||||
@@ -56,11 +56,11 @@
|
|||||||
"dotenv": "^17.4.2",
|
"dotenv": "^17.4.2",
|
||||||
"esbuild": "^0.28.2",
|
"esbuild": "^0.28.2",
|
||||||
"eslint": "^9",
|
"eslint": "^9",
|
||||||
"eslint-config-next": "^16.3.8",
|
"eslint-config-next": "^16.4.0",
|
||||||
"eslint-config-prettier": "^10.1.8",
|
"eslint-config-prettier": "^10.1.8",
|
||||||
"prettier": "^3.9.7",
|
"prettier": "^3.9.7",
|
||||||
"prisma": "^7.10.0",
|
"prisma": "^7.10.0",
|
||||||
"sharp": "^0.35.4",
|
"sharp": "^0.35.5",
|
||||||
"tsx": "^4.23.13",
|
"tsx": "^4.23.13",
|
||||||
"typescript": "^5",
|
"typescript": "^5",
|
||||||
"vitest": "^5.0.1"
|
"vitest": "^5.0.1"
|
||||||
@@ -71,6 +71,7 @@
|
|||||||
},
|
},
|
||||||
"overrides": {
|
"overrides": {
|
||||||
"mysql2": "^3.24.4",
|
"mysql2": "^3.24.4",
|
||||||
"deepmerge-ts": "^8.0.0"
|
"deepmerge-ts": "^8.0.0",
|
||||||
|
"source-map-js": "^1.2.2"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,3 @@
|
|||||||
|
-- Eigenbedarf: Kunden, die man selbst ist (#044).
|
||||||
|
-- Für sie gibt es keine Kundenrechnung – und damit auch keine Warnungen darüber und keine Marge.
|
||||||
|
ALTER TABLE "Kunde" ADD COLUMN "eigenbedarf" BOOLEAN NOT NULL DEFAULT false;
|
||||||
@@ -61,6 +61,9 @@ model Kunde {
|
|||||||
ansprechpartner String?
|
ansprechpartner String?
|
||||||
email String?
|
email String?
|
||||||
telefon String?
|
telefon String?
|
||||||
|
/// Eigenbedarf: eigene Hosts, die nicht weiterberechnet werden. Für sie entfallen
|
||||||
|
/// Kundenrechnung, die zugehörigen Warnungen und die Marge (#044).
|
||||||
|
eigenbedarf Boolean @default(false)
|
||||||
notizen String?
|
notizen String?
|
||||||
erstelltAm DateTime @default(now())
|
erstelltAm DateTime @default(now())
|
||||||
geaendertAm DateTime @updatedAt
|
geaendertAm DateTime @updatedAt
|
||||||
|
|||||||
@@ -38,6 +38,7 @@ export default async function KundeBearbeitenSeite({ params }: { params: Promise
|
|||||||
ansprechpartner: kunde.ansprechpartner,
|
ansprechpartner: kunde.ansprechpartner,
|
||||||
email: kunde.email,
|
email: kunde.email,
|
||||||
telefon: kunde.telefon,
|
telefon: kunde.telefon,
|
||||||
|
eigenbedarf: kunde.eigenbedarf,
|
||||||
notizen: kunde.notizen,
|
notizen: kunde.notizen,
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -204,7 +204,7 @@ export default async function KundenDetailSeite({ params }: { params: Promise<{
|
|||||||
|
|
||||||
<Card titel="Laufzeiten und Rechnungen" rahmen="info" tabelle>
|
<Card titel="Laufzeiten und Rechnungen" rahmen="info" tabelle>
|
||||||
<div className="table-responsive">
|
<div className="table-responsive">
|
||||||
<table className="table table-striped align-middle">
|
<table className="table table-striped align-middle pl-karten-tabelle">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th scope="col">Laufzeit</th>
|
<th scope="col">Laufzeit</th>
|
||||||
@@ -213,26 +213,46 @@ export default async function KundenDetailSeite({ params }: { params: Promise<{
|
|||||||
<th scope="col">Einkauf</th>
|
<th scope="col">Einkauf</th>
|
||||||
<th scope="col">Kundenrechnung</th>
|
<th scope="col">Kundenrechnung</th>
|
||||||
<th scope="col">Verkauf</th>
|
<th scope="col">Verkauf</th>
|
||||||
|
<th scope="col">Marge</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{laufzeiten.map((laufzeit) => (
|
{laufzeiten.map((laufzeit) => (
|
||||||
<tr key={laufzeit.id}>
|
<tr key={laufzeit.id}>
|
||||||
<td>
|
{/* Von hier aus werden die Rechnungen erfasst – ohne Link führte die
|
||||||
|
Kundenansicht in eine Sackgasse (#043). */}
|
||||||
|
<td className="pl-karten-kopf">
|
||||||
|
<Link
|
||||||
|
href={`/rechnungen/${laufzeit.id}`}
|
||||||
|
className="fw-semibold text-decoration-none"
|
||||||
|
>
|
||||||
{formatDatum(laufzeit.start)} – {formatDatum(laufzeit.ende)}
|
{formatDatum(laufzeit.start)} – {formatDatum(laufzeit.ende)}
|
||||||
|
</Link>
|
||||||
{laufzeit.vorschlag ? (
|
{laufzeit.vorschlag ? (
|
||||||
<span className="badge pl-bg-info ms-2">Vorschlag</span>
|
<span className="badge pl-bg-info ms-2">Vorschlag</span>
|
||||||
) : null}
|
) : null}
|
||||||
</td>
|
</td>
|
||||||
<td>{laufzeit.hostname}</td>
|
<td data-label="Host">
|
||||||
<td>
|
<Link href={`/hosts/${laufzeit.hostId}`} className="text-decoration-none">
|
||||||
|
{laufzeit.hostname}
|
||||||
|
</Link>
|
||||||
|
</td>
|
||||||
|
<td data-label="Proxmox-Rechnung">
|
||||||
<CopyableCode wert={laufzeit.einkauf?.nummer ?? null} />
|
<CopyableCode wert={laufzeit.einkauf?.nummer ?? null} />
|
||||||
<div>
|
<div>
|
||||||
<EinkaufBadge bezahlt={laufzeit.einkauf ? laufzeit.einkauf.bezahlt : null} />
|
<EinkaufBadge bezahlt={laufzeit.einkauf ? laufzeit.einkauf.bezahlt : null} />
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td>{formatEuro(laufzeit.einkauf?.nettoBetrag)}</td>
|
<td data-label="Einkauf">
|
||||||
<td>
|
{formatEuro(laufzeit.einkauf?.nettoBetrag)}
|
||||||
|
{laufzeit.einkauf && laufzeit.einkauf.rabatt > 0 ? (
|
||||||
|
<div className="small text-body-secondary">
|
||||||
|
{formatEuro(laufzeit.einkauf.listenpreis)} − {formatEuro(laufzeit.einkauf.rabatt)}{" "}
|
||||||
|
Rabatt
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</td>
|
||||||
|
<td data-label="Kundenrechnung">
|
||||||
<CopyableCode wert={laufzeit.verkauf?.nummer ?? null} />
|
<CopyableCode wert={laufzeit.verkauf?.nummer ?? null} />
|
||||||
<div>
|
<div>
|
||||||
<VerkaufBadge
|
<VerkaufBadge
|
||||||
@@ -241,12 +261,26 @@ export default async function KundenDetailSeite({ params }: { params: Promise<{
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td>{formatEuro(laufzeit.verkauf?.nettoBetrag)}</td>
|
<td data-label="Verkauf">{formatEuro(laufzeit.verkauf?.nettoBetrag)}</td>
|
||||||
|
<td
|
||||||
|
data-label="Marge"
|
||||||
|
className={
|
||||||
|
laufzeit.einkauf && laufzeit.verkauf
|
||||||
|
? (laufzeit.verkauf.nettoBetrag ?? 0) - laufzeit.einkauf.nettoBetrag >= 0
|
||||||
|
? "pl-text-success"
|
||||||
|
: "pl-text-danger"
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{laufzeit.einkauf && laufzeit.verkauf
|
||||||
|
? formatEuro((laufzeit.verkauf.nettoBetrag ?? 0) - laufzeit.einkauf.nettoBetrag)
|
||||||
|
: "–"}
|
||||||
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
))}
|
||||||
{laufzeiten.length === 0 ? (
|
{laufzeiten.length === 0 ? (
|
||||||
<tr>
|
<tr>
|
||||||
<td colSpan={6} className="text-center text-body-secondary py-4">
|
<td colSpan={7} className="text-center text-body-secondary py-4">
|
||||||
Noch keine Laufzeiten erfasst.
|
Noch keine Laufzeiten erfasst.
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
|||||||
@@ -60,6 +60,9 @@ export default async function KundenSeite() {
|
|||||||
<Link href={`/kunden/${kunde.id}`} className="fw-semibold text-decoration-none">
|
<Link href={`/kunden/${kunde.id}`} className="fw-semibold text-decoration-none">
|
||||||
{kunde.name}
|
{kunde.name}
|
||||||
</Link>
|
</Link>
|
||||||
|
{kunde.eigenbedarf ? (
|
||||||
|
<span className="badge pl-bg-neutral ms-2">Eigenbedarf</span>
|
||||||
|
) : null}
|
||||||
<div className="small text-body-secondary">{kunde.ansprechpartner ?? "–"}</div>
|
<div className="small text-body-secondary">{kunde.ansprechpartner ?? "–"}</div>
|
||||||
</td>
|
</td>
|
||||||
<td className="pl-mono" data-label="Kundennummer">
|
<td className="pl-mono" data-label="Kundennummer">
|
||||||
|
|||||||
@@ -11,6 +11,8 @@ import { formatDatum, formatEuro } from "@/lib/format";
|
|||||||
import { laufzeitBestaetigenAktion, laufzeitVerwerfenAktion } from "@/server/actions/rechnungen";
|
import { laufzeitBestaetigenAktion, laufzeitVerwerfenAktion } from "@/server/actions/rechnungen";
|
||||||
import { einstellungen, faelligkeitsVorschlag } from "@/server/einstellungen";
|
import { einstellungen, faelligkeitsVorschlag } from "@/server/einstellungen";
|
||||||
import { laufzeitDetail } from "@/server/queries/rechnungen";
|
import { laufzeitDetail } from "@/server/queries/rechnungen";
|
||||||
|
import { LaufzeitBearbeiten } from "@/components/rechnungen/LaufzeitBearbeiten";
|
||||||
|
import { prisma } from "@/lib/db";
|
||||||
|
|
||||||
export async function generateMetadata({ params }: { params: Promise<{ id: string }> }) {
|
export async function generateMetadata({ params }: { params: Promise<{ id: string }> }) {
|
||||||
const { id } = await params;
|
const { id } = await params;
|
||||||
@@ -33,11 +35,27 @@ export default async function LaufzeitSeite({ params }: { params: Promise<{ id:
|
|||||||
const marge = einkaufBetrag !== null && verkaufBetrag !== null ? verkaufBetrag - einkaufBetrag : null;
|
const marge = einkaufBetrag !== null && verkaufBetrag !== null ? verkaufBetrag - einkaufBetrag : null;
|
||||||
|
|
||||||
// Vorgaben aus den Einstellungen füllen leere Felder vor (#025).
|
// Vorgaben aus den Einstellungen füllen leere Felder vor (#025).
|
||||||
const [vorgaben, faelligVorschlag] = await Promise.all([einstellungen(), faelligkeitsVorschlag()]);
|
const [vorgaben, faelligVorschlag, nachbarn] = await Promise.all([
|
||||||
|
einstellungen(),
|
||||||
|
faelligkeitsVorschlag(),
|
||||||
|
prisma.laufzeit.findMany({
|
||||||
|
where: { hostId: laufzeit.host.id, id: { not: laufzeit.id } },
|
||||||
|
orderBy: { start: "desc" },
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
key: true,
|
||||||
|
start: true,
|
||||||
|
ende: true,
|
||||||
|
proxmoxPositionId: true,
|
||||||
|
kundenPositionId: true,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
]);
|
||||||
const verkaufVorschlag =
|
const verkaufVorschlag =
|
||||||
verkaufBetrag ??
|
verkaufBetrag ??
|
||||||
(einkaufBetrag === null ? null : Math.round(einkaufBetrag * (1 + vorgaben.margeProzent / 100) * 100) / 100);
|
(einkaufBetrag === null ? null : Math.round(einkaufBetrag * (1 + vorgaben.margeProzent / 100) * 100) / 100);
|
||||||
|
|
||||||
|
const eigenbedarf = laufzeit.host.kunde?.eigenbedarf ?? false;
|
||||||
const zeitraum = `${formatDatum(laufzeit.start)} – ${formatDatum(laufzeit.ende)}`;
|
const zeitraum = `${formatDatum(laufzeit.start)} – ${formatDatum(laufzeit.ende)}`;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -127,6 +145,23 @@ export default async function LaufzeitSeite({ params }: { params: Promise<{ id:
|
|||||||
{marge === null ? "–" : formatEuro(marge)}
|
{marge === null ? "–" : formatEuro(marge)}
|
||||||
</dd>
|
</dd>
|
||||||
</dl>
|
</dl>
|
||||||
|
|
||||||
|
<hr />
|
||||||
|
<LaufzeitBearbeiten
|
||||||
|
laufzeitId={laufzeit.id}
|
||||||
|
hostId={laufzeit.host.id}
|
||||||
|
schluessel={laufzeit.key}
|
||||||
|
start={laufzeit.start.toISOString().slice(0, 10)}
|
||||||
|
ende={laufzeit.ende.toISOString().slice(0, 10)}
|
||||||
|
notizen={laufzeit.notizen}
|
||||||
|
nachbarn={nachbarn.map((nachbar) => ({
|
||||||
|
id: nachbar.id,
|
||||||
|
zeitraum: `${formatDatum(nachbar.start)} – ${formatDatum(nachbar.ende)}`,
|
||||||
|
key: nachbar.key,
|
||||||
|
hatEinkauf: nachbar.proxmoxPositionId !== null,
|
||||||
|
hatVerkauf: nachbar.kundenPositionId !== null,
|
||||||
|
}))}
|
||||||
|
/>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
{einkauf && einkauf.positionen.length > 1 ? (
|
{einkauf && einkauf.positionen.length > 1 ? (
|
||||||
@@ -192,11 +227,22 @@ export default async function LaufzeitSeite({ params }: { params: Promise<{ id:
|
|||||||
|
|
||||||
<Card
|
<Card
|
||||||
titel="Kundenrechnung (Verkauf)"
|
titel="Kundenrechnung (Verkauf)"
|
||||||
rahmen={verkauf?.erstellt ? "info" : "warning"}
|
rahmen={eigenbedarf ? "keiner" : verkauf?.erstellt ? "info" : "warning"}
|
||||||
werkzeuge={
|
werkzeuge={
|
||||||
|
eigenbedarf ? (
|
||||||
|
<span className="badge pl-bg-neutral">Eigenbedarf</span>
|
||||||
|
) : (
|
||||||
<VerkaufBadge erstellt={verkauf?.erstellt ?? false} bezahlt={verkauf?.bezahlt ?? false} />
|
<VerkaufBadge erstellt={verkauf?.erstellt ?? false} bezahlt={verkauf?.bezahlt ?? false} />
|
||||||
|
)
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
|
{eigenbedarf ? (
|
||||||
|
<p className="mb-0 text-body-secondary">
|
||||||
|
<strong>{laufzeit.host.kunde?.name}</strong> ist als Eigenbedarf gekennzeichnet – diese
|
||||||
|
Hosts werden nicht weiterberechnet. Deshalb gibt es hier keine Kundenrechnung, keine
|
||||||
|
Warnung darüber und keine Marge. Die Proxmox-Rechnung wird weiterhin geführt.
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
<KundenrechnungFormular
|
<KundenrechnungFormular
|
||||||
laufzeitId={laufzeit.id}
|
laufzeitId={laufzeit.id}
|
||||||
hostId={laufzeit.host.id}
|
hostId={laufzeit.host.id}
|
||||||
@@ -213,6 +259,7 @@ export default async function LaufzeitSeite({ params }: { params: Promise<{ id:
|
|||||||
anhangName: verkauf?.anhang?.dateiname ?? null,
|
anhangName: verkauf?.anhang?.dateiname ?? null,
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
)}
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import { CopyableCode } from "@/components/ui/CopyableCode";
|
|||||||
import { InfoBox } from "@/components/ui/SmallBox";
|
import { InfoBox } from "@/components/ui/SmallBox";
|
||||||
import { prisma } from "@/lib/db";
|
import { prisma } from "@/lib/db";
|
||||||
import { formatDatum, formatEuro, tageBis } from "@/lib/format";
|
import { formatDatum, formatEuro, tageBis } from "@/lib/format";
|
||||||
|
import { LaufzeitAnlegen } from "@/components/rechnungen/LaufzeitAnlegen";
|
||||||
import {
|
import {
|
||||||
anzahlVorschlaege,
|
anzahlVorschlaege,
|
||||||
laufzeitJahre,
|
laufzeitJahre,
|
||||||
@@ -33,11 +34,16 @@ export default async function RechnungenSeite({ searchParams }: { searchParams:
|
|||||||
q: einzeln(roh.q),
|
q: einzeln(roh.q),
|
||||||
};
|
};
|
||||||
|
|
||||||
const [{ zeilen, summen }, jahre, kunden, vorschlaege] = await Promise.all([
|
const [{ zeilen, summen }, jahre, kunden, vorschlaege, hostsFuerLaufzeit] = await Promise.all([
|
||||||
rechnungsListe(filter),
|
rechnungsListe(filter),
|
||||||
laufzeitJahre(),
|
laufzeitJahre(),
|
||||||
prisma.kunde.findMany({ orderBy: { name: "asc" }, select: { id: true, name: true } }),
|
prisma.kunde.findMany({ orderBy: { name: "asc" }, select: { id: true, name: true } }),
|
||||||
anzahlVorschlaege(),
|
anzahlVorschlaege(),
|
||||||
|
prisma.host.findMany({
|
||||||
|
where: { archiviertAm: null },
|
||||||
|
orderBy: { hostname: "asc" },
|
||||||
|
select: { id: true, hostname: true, subKey: true, kunde: { select: { name: true } } },
|
||||||
|
}),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const heute = new Date();
|
const heute = new Date();
|
||||||
@@ -106,6 +112,19 @@ export default async function RechnungenSeite({ searchParams }: { searchParams:
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Laufzeiten entstehen sonst aus den Agenten-Reports; von Hand braucht man es für
|
||||||
|
Hosts ohne gemeldetes Ablaufdatum oder für Nachträge (#043). */}
|
||||||
|
<Card titel="Laufzeit von Hand anlegen" rahmen="keiner">
|
||||||
|
<LaufzeitAnlegen
|
||||||
|
hosts={hostsFuerLaufzeit.map((host) => ({
|
||||||
|
id: host.id,
|
||||||
|
hostname: host.hostname,
|
||||||
|
kundeName: host.kunde?.name ?? null,
|
||||||
|
subKey: host.subKey,
|
||||||
|
}))}
|
||||||
|
/>
|
||||||
|
</Card>
|
||||||
|
|
||||||
<Card
|
<Card
|
||||||
titel="Laufzeiten und Rechnungen"
|
titel="Laufzeiten und Rechnungen"
|
||||||
rahmen="primary"
|
rahmen="primary"
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ export interface KundeFormularWerte {
|
|||||||
ansprechpartner: string | null;
|
ansprechpartner: string | null;
|
||||||
email: string | null;
|
email: string | null;
|
||||||
telefon: string | null;
|
telefon: string | null;
|
||||||
|
eigenbedarf: boolean;
|
||||||
notizen: string | null;
|
notizen: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -98,6 +99,26 @@ export function KundeFormular({ werte }: { werte?: KundeFormularWerte }) {
|
|||||||
<input id="telefon" name="telefon" className="form-control" defaultValue={werte?.telefon ?? ""} />
|
<input id="telefon" name="telefon" className="form-control" defaultValue={werte?.telefon ?? ""} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="col-12">
|
||||||
|
<div className="form-check">
|
||||||
|
<input
|
||||||
|
className="form-check-input pl-touch-target"
|
||||||
|
type="checkbox"
|
||||||
|
id="eigenbedarf"
|
||||||
|
name="eigenbedarf"
|
||||||
|
value="an"
|
||||||
|
defaultChecked={werte?.eigenbedarf ?? false}
|
||||||
|
/>
|
||||||
|
<label className="form-check-label" htmlFor="eigenbedarf">
|
||||||
|
Eigenbedarf – diese Hosts werden nicht weiterberechnet
|
||||||
|
</label>
|
||||||
|
<div className="form-text">
|
||||||
|
Für solche Kunden entfällt die Kundenrechnung: keine Warnungen darüber, keine Marge. Die
|
||||||
|
Einkaufsrechnung von Proxmox wird weiterhin geführt.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="col-12">
|
<div className="col-12">
|
||||||
<label className="form-label" htmlFor="notizen">
|
<label className="form-label" htmlFor="notizen">
|
||||||
Notizen
|
Notizen
|
||||||
|
|||||||
@@ -0,0 +1,131 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useActionState, useState } from "react";
|
||||||
|
import type { AktionsErgebnis } from "@/server/actions/kunden";
|
||||||
|
import { laufzeitAnlegenAktion } from "@/server/actions/rechnungen";
|
||||||
|
|
||||||
|
const LEER: AktionsErgebnis = {};
|
||||||
|
|
||||||
|
/** Ein Jahr minus einen Tag – dieselbe Rechnung wie beim Vorschlag aus dem Agenten-Report. */
|
||||||
|
function endeAus(start: string): string {
|
||||||
|
if (!start) return "";
|
||||||
|
const datum = new Date(`${start}T00:00:00Z`);
|
||||||
|
datum.setUTCFullYear(datum.getUTCFullYear() + 1);
|
||||||
|
datum.setUTCDate(datum.getUTCDate() - 1);
|
||||||
|
return datum.toISOString().slice(0, 10);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Laufzeit von Hand anlegen (#043).
|
||||||
|
*
|
||||||
|
* Der Normalfall ist der Vorschlag aus dem Agenten-Report. Von Hand braucht man es, wenn ein Host
|
||||||
|
* (noch) kein Ablaufdatum meldet – etwa weil der Key erst bestellt ist – oder wenn ein
|
||||||
|
* zurückliegendes Lizenzjahr nachgetragen werden soll.
|
||||||
|
*/
|
||||||
|
export function LaufzeitAnlegen({
|
||||||
|
hosts,
|
||||||
|
}: {
|
||||||
|
hosts: { id: string; hostname: string; kundeName: string | null; subKey: string | null }[];
|
||||||
|
}) {
|
||||||
|
const [ergebnis, aktion, laeuft] = useActionState(laufzeitAnlegenAktion, LEER);
|
||||||
|
const [hostId, setHostId] = useState("");
|
||||||
|
const [key, setKey] = useState("");
|
||||||
|
const [start, setStart] = useState("");
|
||||||
|
const [ende, setEnde] = useState("");
|
||||||
|
|
||||||
|
const gewaehlt = hosts.find((host) => host.id === hostId);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form action={aktion} className="row g-3 align-items-start">
|
||||||
|
{ergebnis.fehler ? <div className="alert alert-danger py-2">{ergebnis.fehler}</div> : null}
|
||||||
|
{ergebnis.erfolg ? <div className="alert alert-success py-2">{ergebnis.erfolg}</div> : null}
|
||||||
|
|
||||||
|
<div className="col-12 col-md-4">
|
||||||
|
<label className="form-label" htmlFor="lz-host">
|
||||||
|
Host
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
className="form-select"
|
||||||
|
id="lz-host"
|
||||||
|
name="hostId"
|
||||||
|
value={hostId}
|
||||||
|
onChange={(e) => {
|
||||||
|
setHostId(e.target.value);
|
||||||
|
// Den gemeldeten Key vorschlagen – abtippen ist hier die häufigste Fehlerquelle.
|
||||||
|
const host = hosts.find((h) => h.id === e.target.value);
|
||||||
|
if (host?.subKey) setKey(host.subKey);
|
||||||
|
}}
|
||||||
|
required
|
||||||
|
>
|
||||||
|
<option value="">– bitte wählen –</option>
|
||||||
|
{hosts.map((host) => (
|
||||||
|
<option key={host.id} value={host.id}>
|
||||||
|
{host.hostname}
|
||||||
|
{host.kundeName ? ` · ${host.kundeName}` : " · nicht zugeordnet"}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="col-12 col-md-3">
|
||||||
|
<label className="form-label" htmlFor="lz-key">
|
||||||
|
Subscription-Key
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
className="form-control pl-mono"
|
||||||
|
id="lz-key"
|
||||||
|
name="key"
|
||||||
|
value={key}
|
||||||
|
onChange={(e) => setKey(e.target.value)}
|
||||||
|
placeholder="pve2s-…"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
{gewaehlt?.subKey ? (
|
||||||
|
<div className="form-text">Gemeldet: {gewaehlt.subKey}</div>
|
||||||
|
) : (
|
||||||
|
<div className="form-text">Dieser Host meldet keinen Key – Nummer aus der Bestellung eintragen.</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="col-6 col-md-2">
|
||||||
|
<label className="form-label" htmlFor="lz-start">
|
||||||
|
Beginn
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
className="form-control"
|
||||||
|
id="lz-start"
|
||||||
|
name="start"
|
||||||
|
value={start}
|
||||||
|
onChange={(e) => {
|
||||||
|
setStart(e.target.value);
|
||||||
|
setEnde(endeAus(e.target.value));
|
||||||
|
}}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="col-6 col-md-2">
|
||||||
|
<label className="form-label" htmlFor="lz-ende">
|
||||||
|
Ende
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
className="form-control"
|
||||||
|
id="lz-ende"
|
||||||
|
name="ende"
|
||||||
|
value={ende}
|
||||||
|
onChange={(e) => setEnde(e.target.value)}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
<div className="form-text">Ein Jahr, vorbelegt.</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="col-12">
|
||||||
|
<button type="submit" className="btn btn-outline-primary" disabled={laeuft}>
|
||||||
|
{laeuft ? "Wird angelegt …" : "Laufzeit anlegen"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,160 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useActionState, useState } from "react";
|
||||||
|
import type { AktionsErgebnis } from "@/server/actions/kunden";
|
||||||
|
import {
|
||||||
|
laufzeitBearbeitenAktion,
|
||||||
|
laufzeitenZusammenfuehrenAktion,
|
||||||
|
} from "@/server/actions/rechnungen";
|
||||||
|
|
||||||
|
const LEER: AktionsErgebnis = {};
|
||||||
|
|
||||||
|
export interface NachbarLaufzeit {
|
||||||
|
id: string;
|
||||||
|
zeitraum: string;
|
||||||
|
key: string;
|
||||||
|
hatEinkauf: boolean;
|
||||||
|
hatVerkauf: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Laufzeit nachträglich ändern und mit einer anderen desselben Hosts zusammenführen (#043).
|
||||||
|
*
|
||||||
|
* Nötig, weil Laufzeiten meist aus einem Agenten-Report entstehen: Weicht das gemeldete
|
||||||
|
* Ablaufdatum von der Bestellung ab, steht die Laufzeit falsch da – oder gleich doppelt.
|
||||||
|
*/
|
||||||
|
export function LaufzeitBearbeiten({
|
||||||
|
laufzeitId,
|
||||||
|
hostId,
|
||||||
|
// Nicht `key` nennen: Das ist in React reserviert und käme hier nie an.
|
||||||
|
schluessel,
|
||||||
|
start,
|
||||||
|
ende,
|
||||||
|
notizen,
|
||||||
|
nachbarn,
|
||||||
|
}: {
|
||||||
|
laufzeitId: string;
|
||||||
|
hostId: string;
|
||||||
|
schluessel: string;
|
||||||
|
start: string;
|
||||||
|
ende: string;
|
||||||
|
notizen: string | null;
|
||||||
|
nachbarn: NachbarLaufzeit[];
|
||||||
|
}) {
|
||||||
|
const [ergebnis, aktion, laeuft] = useActionState(laufzeitBearbeitenAktion, LEER);
|
||||||
|
const [zusammen, zusammenAktion, fuehrtZusammen] = useActionState(laufzeitenZusammenfuehrenAktion, LEER);
|
||||||
|
const [offen, setOffen] = useState(false);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-sm btn-outline-secondary"
|
||||||
|
onClick={() => setOffen(!offen)}
|
||||||
|
aria-expanded={offen}
|
||||||
|
>
|
||||||
|
<i className="bi bi-pencil me-1" aria-hidden="true" />
|
||||||
|
{offen ? "Bearbeiten schließen" : "Laufzeit bearbeiten"}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{offen ? (
|
||||||
|
<div className="mt-3">
|
||||||
|
{ergebnis.fehler ? <div className="alert alert-danger py-2">{ergebnis.fehler}</div> : null}
|
||||||
|
{ergebnis.erfolg ? <div className="alert alert-success py-2">{ergebnis.erfolg}</div> : null}
|
||||||
|
|
||||||
|
<form action={aktion} className="row g-3 align-items-start">
|
||||||
|
<input type="hidden" name="laufzeitId" value={laufzeitId} />
|
||||||
|
<input type="hidden" name="hostId" value={hostId} />
|
||||||
|
|
||||||
|
<div className="col-12 col-md-4">
|
||||||
|
<label className="form-label" htmlFor="lzb-key">
|
||||||
|
Subscription-Key
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
className="form-control pl-mono"
|
||||||
|
id="lzb-key"
|
||||||
|
name="key"
|
||||||
|
defaultValue={schluessel}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="col-6 col-md-3">
|
||||||
|
<label className="form-label" htmlFor="lzb-start">
|
||||||
|
Beginn
|
||||||
|
</label>
|
||||||
|
<input type="date" className="form-control" id="lzb-start" name="start" defaultValue={start} required />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="col-6 col-md-3">
|
||||||
|
<label className="form-label" htmlFor="lzb-ende">
|
||||||
|
Ende
|
||||||
|
</label>
|
||||||
|
<input type="date" className="form-control" id="lzb-ende" name="ende" defaultValue={ende} required />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="col-12">
|
||||||
|
<label className="form-label" htmlFor="lzb-notizen">
|
||||||
|
Notiz
|
||||||
|
</label>
|
||||||
|
<textarea
|
||||||
|
className="form-control"
|
||||||
|
id="lzb-notizen"
|
||||||
|
name="notizen"
|
||||||
|
rows={2}
|
||||||
|
defaultValue={notizen ?? ""}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="col-12">
|
||||||
|
<button type="submit" className="btn btn-primary" disabled={laeuft}>
|
||||||
|
{laeuft ? "Wird gespeichert …" : "Änderung speichern"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
{nachbarn.length > 0 ? (
|
||||||
|
<div className="mt-4 pt-3 border-top">
|
||||||
|
<h4 className="h6">Mit einer anderen Laufzeit zusammenführen</h4>
|
||||||
|
<p className="small text-body-secondary">
|
||||||
|
Die gewählte Laufzeit wird aufgelöst; ihre Rechnungen wandern hierher, sofern hier noch
|
||||||
|
keine stehen. Nützlich, wenn der Agent neben einer erfassten Laufzeit einen Vorschlag
|
||||||
|
mit abweichendem Datum angelegt hat.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{zusammen.fehler ? <div className="alert alert-danger py-2">{zusammen.fehler}</div> : null}
|
||||||
|
{zusammen.erfolg ? <div className="alert alert-success py-2">{zusammen.erfolg}</div> : null}
|
||||||
|
|
||||||
|
<form action={zusammenAktion} className="row g-2 align-items-end">
|
||||||
|
<input type="hidden" name="zielId" value={laufzeitId} />
|
||||||
|
<input type="hidden" name="hostId" value={hostId} />
|
||||||
|
|
||||||
|
<div className="col-12 col-md-8">
|
||||||
|
<label className="form-label" htmlFor="lzb-quelle">
|
||||||
|
Diese Laufzeit auflösen
|
||||||
|
</label>
|
||||||
|
<select className="form-select" id="lzb-quelle" name="quelleId" required>
|
||||||
|
<option value="">– bitte wählen –</option>
|
||||||
|
{nachbarn.map((nachbar) => (
|
||||||
|
<option key={nachbar.id} value={nachbar.id}>
|
||||||
|
{nachbar.zeitraum} · {nachbar.key}
|
||||||
|
{nachbar.hatEinkauf ? " · mit Einkauf" : ""}
|
||||||
|
{nachbar.hatVerkauf ? " · mit Verkauf" : ""}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="col-12 col-md-4">
|
||||||
|
<button type="submit" className="btn btn-outline-danger" disabled={fuehrtZusammen}>
|
||||||
|
{fuehrtZusammen ? "Wird zusammengeführt …" : "Zusammenführen"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -25,6 +25,7 @@ const kundeSchema = z.object({
|
|||||||
ansprechpartner: z.string().nullable(),
|
ansprechpartner: z.string().nullable(),
|
||||||
email: z.union([z.string().email("Keine gültige E-Mail-Adresse."), z.null()]),
|
email: z.union([z.string().email("Keine gültige E-Mail-Adresse."), z.null()]),
|
||||||
telefon: z.string().nullable(),
|
telefon: z.string().nullable(),
|
||||||
|
eigenbedarf: z.boolean().default(false),
|
||||||
notizen: z.string().nullable(),
|
notizen: z.string().nullable(),
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -38,6 +39,7 @@ export async function kundeSpeichern(_vorher: AktionsErgebnis, formular: FormDat
|
|||||||
ansprechpartner: leerZuNull(formular.get("ansprechpartner")),
|
ansprechpartner: leerZuNull(formular.get("ansprechpartner")),
|
||||||
email: leerZuNull(formular.get("email")),
|
email: leerZuNull(formular.get("email")),
|
||||||
telefon: leerZuNull(formular.get("telefon")),
|
telefon: leerZuNull(formular.get("telefon")),
|
||||||
|
eigenbedarf: formular.get("eigenbedarf") === "an",
|
||||||
notizen: leerZuNull(formular.get("notizen")),
|
notizen: leerZuNull(formular.get("notizen")),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -7,6 +7,8 @@ import {
|
|||||||
einkaufBezahltSetzen,
|
einkaufBezahltSetzen,
|
||||||
kundenrechnungErfassen,
|
kundenrechnungErfassen,
|
||||||
laufzeitAnlegen,
|
laufzeitAnlegen,
|
||||||
|
laufzeitBearbeiten,
|
||||||
|
laufzeitenZusammenfuehren,
|
||||||
laufzeitBestaetigen,
|
laufzeitBestaetigen,
|
||||||
laufzeitVerwerfen,
|
laufzeitVerwerfen,
|
||||||
proxmoxRechnungErfassen,
|
proxmoxRechnungErfassen,
|
||||||
@@ -173,3 +175,46 @@ export async function laufzeitAnlegenAktion(
|
|||||||
return "Laufzeit angelegt.";
|
return "Laufzeit angelegt.";
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function laufzeitBearbeitenAktion(
|
||||||
|
_vorher: AktionsErgebnis,
|
||||||
|
formular: FormData,
|
||||||
|
): Promise<AktionsErgebnis> {
|
||||||
|
await sitzungErforderlich("fachlichSchreiben");
|
||||||
|
|
||||||
|
const id = formular.get("laufzeitId")?.toString();
|
||||||
|
const hostId = formular.get("hostId")?.toString();
|
||||||
|
const key = formular.get("key")?.toString().trim();
|
||||||
|
const start = datum(formular.get("start"));
|
||||||
|
const ende = datum(formular.get("ende"));
|
||||||
|
const notizen = formular.get("notizen")?.toString().trim() || null;
|
||||||
|
|
||||||
|
if (!id || !hostId || !key || !start || !ende) {
|
||||||
|
return { fehler: "Bitte Key, Beginn und Ende angeben." };
|
||||||
|
}
|
||||||
|
|
||||||
|
return ausfuehren(async () => {
|
||||||
|
await laufzeitBearbeiten(id, { key, start, ende, notizen });
|
||||||
|
seitenNeuLaden(hostId);
|
||||||
|
return "Laufzeit geändert.";
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function laufzeitenZusammenfuehrenAktion(
|
||||||
|
_vorher: AktionsErgebnis,
|
||||||
|
formular: FormData,
|
||||||
|
): Promise<AktionsErgebnis> {
|
||||||
|
await sitzungErforderlich("fachlichSchreiben");
|
||||||
|
|
||||||
|
const zielId = formular.get("zielId")?.toString();
|
||||||
|
const quelleId = formular.get("quelleId")?.toString();
|
||||||
|
const hostId = formular.get("hostId")?.toString();
|
||||||
|
|
||||||
|
if (!zielId || !quelleId || !hostId) return { fehler: "Bitte eine Laufzeit zum Zusammenführen wählen." };
|
||||||
|
|
||||||
|
return ausfuehren(async () => {
|
||||||
|
await laufzeitenZusammenfuehren(zielId, quelleId);
|
||||||
|
seitenNeuLaden(hostId);
|
||||||
|
return "Laufzeiten zusammengeführt.";
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { tokenAusHeader } from "@/lib/tokens";
|
|||||||
import { AgentApiFehler } from "./service";
|
import { AgentApiFehler } from "./service";
|
||||||
|
|
||||||
/** Version des Agent-Skripts, die das Dashboard ausliefert (siehe #005). */
|
/** Version des Agent-Skripts, die das Dashboard ausliefert (siehe #005). */
|
||||||
export const AGENT_VERSION = "0.2.1";
|
export const AGENT_VERSION = "0.2.2";
|
||||||
|
|
||||||
export function fehlerAntwort(status: number, code: string, nachricht: string, extra?: HeadersInit) {
|
export function fehlerAntwort(status: number, code: string, nachricht: string, extra?: HeadersInit) {
|
||||||
return Response.json({ ok: false, code, fehler: nachricht }, { status, headers: extra });
|
return Response.json({ ok: false, code, fehler: nachricht }, { status, headers: extra });
|
||||||
|
|||||||
@@ -19,7 +19,13 @@ export async function kontextLaden(jetzt: Date = new Date()): Promise<RegelKonte
|
|||||||
prisma.laufzeit.findMany({
|
prisma.laufzeit.findMany({
|
||||||
where: { status: "BESTAETIGT", host: { archiviertAm: null } },
|
where: { status: "BESTAETIGT", host: { archiviertAm: null } },
|
||||||
include: {
|
include: {
|
||||||
host: { select: { hostname: true, kundeId: true, kunde: { select: { name: true } } } },
|
host: {
|
||||||
|
select: {
|
||||||
|
hostname: true,
|
||||||
|
kundeId: true,
|
||||||
|
kunde: { select: { name: true, eigenbedarf: true } },
|
||||||
|
},
|
||||||
|
},
|
||||||
proxmoxPosition: { include: { rechnung: true } },
|
proxmoxPosition: { include: { rechnung: true } },
|
||||||
kundenPosition: { include: { rechnung: true } },
|
kundenPosition: { include: { rechnung: true } },
|
||||||
},
|
},
|
||||||
@@ -51,6 +57,7 @@ export async function kontextLaden(jetzt: Date = new Date()): Promise<RegelKonte
|
|||||||
hostname: laufzeit.host.hostname,
|
hostname: laufzeit.host.hostname,
|
||||||
kundeId: laufzeit.host.kundeId,
|
kundeId: laufzeit.host.kundeId,
|
||||||
kundeName: laufzeit.host.kunde?.name ?? null,
|
kundeName: laufzeit.host.kunde?.name ?? null,
|
||||||
|
kundeEigenbedarf: laufzeit.host.kunde?.eigenbedarf ?? false,
|
||||||
key: laufzeit.key,
|
key: laufzeit.key,
|
||||||
start: laufzeit.start,
|
start: laufzeit.start,
|
||||||
ende: laufzeit.ende,
|
ende: laufzeit.ende,
|
||||||
|
|||||||
@@ -41,6 +41,8 @@ export interface LaufzeitDaten {
|
|||||||
hostId: string;
|
hostId: string;
|
||||||
hostname: string;
|
hostname: string;
|
||||||
kundeId: string | null;
|
kundeId: string | null;
|
||||||
|
/** Eigene Hosts werden nicht weiterberechnet – die Verkaufsseite entfällt (#044). */
|
||||||
|
kundeEigenbedarf: boolean;
|
||||||
kundeName: string | null;
|
kundeName: string | null;
|
||||||
key: string;
|
key: string;
|
||||||
start: Date;
|
start: Date;
|
||||||
@@ -359,7 +361,10 @@ const kundenrechnungFehlt: Regel = {
|
|||||||
standardSchwellwerte: {},
|
standardSchwellwerte: {},
|
||||||
pruefe(kontext) {
|
pruefe(kontext) {
|
||||||
return kontext.laufzeiten
|
return kontext.laufzeiten
|
||||||
.filter((laufzeit) => laufzeit.einkaufNummer !== null && !laufzeit.verkaufErstellt)
|
.filter(
|
||||||
|
(laufzeit) =>
|
||||||
|
laufzeit.einkaufNummer !== null && !laufzeit.verkaufErstellt && !laufzeit.kundeEigenbedarf,
|
||||||
|
)
|
||||||
.filter((laufzeit) => laufzeit.kundeId !== null)
|
.filter((laufzeit) => laufzeit.kundeId !== null)
|
||||||
.map((laufzeit) => ({
|
.map((laufzeit) => ({
|
||||||
fingerprint: `kundenrechnung-fehlt:${laufzeit.id}`,
|
fingerprint: `kundenrechnung-fehlt:${laufzeit.id}`,
|
||||||
@@ -384,6 +389,7 @@ const kundenrechnungUnbezahlt: Regel = {
|
|||||||
const tage = schwellwert(kontext, "kundenrechnung-unbezahlt", "tage", 30);
|
const tage = schwellwert(kontext, "kundenrechnung-unbezahlt", "tage", 30);
|
||||||
|
|
||||||
return kontext.laufzeiten.flatMap((laufzeit) => {
|
return kontext.laufzeiten.flatMap((laufzeit) => {
|
||||||
|
if (laufzeit.kundeEigenbedarf) return [];
|
||||||
if (!laufzeit.verkaufErstellt || laufzeit.verkaufBezahlt || !laufzeit.verkaufDatum) return [];
|
if (!laufzeit.verkaufErstellt || laufzeit.verkaufBezahlt || !laufzeit.verkaufDatum) return [];
|
||||||
const offenSeit = -tageBis(laufzeit.verkaufDatum, kontext.jetzt);
|
const offenSeit = -tageBis(laufzeit.verkaufDatum, kontext.jetzt);
|
||||||
if (offenSeit < tage) return [];
|
if (offenSeit < tage) return [];
|
||||||
@@ -463,6 +469,8 @@ const kundeBezahltEinkaufOffen: Regel = {
|
|||||||
standardSchwellwerte: {},
|
standardSchwellwerte: {},
|
||||||
pruefe(kontext) {
|
pruefe(kontext) {
|
||||||
return kontext.laufzeiten.flatMap((laufzeit) => {
|
return kontext.laufzeiten.flatMap((laufzeit) => {
|
||||||
|
// Beim Eigenbedarf gibt es keine Kundenzahlung, auf die man warten würde.
|
||||||
|
if (laufzeit.kundeEigenbedarf) return [];
|
||||||
if (!laufzeit.verkaufBezahlt || laufzeit.einkaufBezahlt !== false) return [];
|
if (!laufzeit.verkaufBezahlt || laufzeit.einkaufBezahlt !== false) return [];
|
||||||
|
|
||||||
return [
|
return [
|
||||||
|
|||||||
@@ -72,7 +72,7 @@ export async function offeneAufgaben(grenze = 12) {
|
|||||||
},
|
},
|
||||||
orderBy: { start: "asc" },
|
orderBy: { start: "asc" },
|
||||||
include: {
|
include: {
|
||||||
host: { select: { id: true, hostname: true, kunde: { select: { name: true } } } },
|
host: { select: { id: true, hostname: true, kunde: { select: { name: true, eigenbedarf: true } } } },
|
||||||
proxmoxPosition: { include: { rechnung: true } },
|
proxmoxPosition: { include: { rechnung: true } },
|
||||||
kundenPosition: { include: { rechnung: true } },
|
kundenPosition: { include: { rechnung: true } },
|
||||||
},
|
},
|
||||||
@@ -111,7 +111,8 @@ export async function offeneAufgaben(grenze = 12) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if (einkauf && !laufzeit.kundenPosition?.rechnung.erstellt) {
|
// Für Eigenbedarf gibt es keine Kundenrechnung zu schreiben (#044).
|
||||||
|
if (einkauf && !laufzeit.host.kunde?.eigenbedarf && !laufzeit.kundenPosition?.rechnung.erstellt) {
|
||||||
aufgaben.push({
|
aufgaben.push({
|
||||||
id: `${laufzeit.id}-verkauf`,
|
id: `${laufzeit.id}-verkauf`,
|
||||||
titel: "Kundenrechnung erstellen",
|
titel: "Kundenrechnung erstellen",
|
||||||
|
|||||||
@@ -67,6 +67,8 @@ export async function kennzahlen(jetzt: Date = new Date()) {
|
|||||||
...laufzeitSicht,
|
...laufzeitSicht,
|
||||||
status: "BESTAETIGT",
|
status: "BESTAETIGT",
|
||||||
proxmoxPosition: { isNot: null },
|
proxmoxPosition: { isNot: null },
|
||||||
|
// Eigenbedarf wird nicht weiterberechnet – hier ist nichts offen (#044).
|
||||||
|
host: { kunde: { eigenbedarf: false } },
|
||||||
OR: [{ kundenPosition: null }, { kundenPosition: { rechnung: { erstellt: false } } }],
|
OR: [{ kundenPosition: null }, { kundenPosition: { rechnung: { erstellt: false } } }],
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ export async function kundenListe() {
|
|||||||
return {
|
return {
|
||||||
id: kunde.id,
|
id: kunde.id,
|
||||||
name: kunde.name,
|
name: kunde.name,
|
||||||
|
eigenbedarf: kunde.eigenbedarf,
|
||||||
kundennummer: kunde.kundennummer,
|
kundennummer: kunde.kundennummer,
|
||||||
ansprechpartner: kunde.ansprechpartner,
|
ansprechpartner: kunde.ansprechpartner,
|
||||||
hosts: kunde._count.hosts,
|
hosts: kunde._count.hosts,
|
||||||
|
|||||||
@@ -67,7 +67,13 @@ export async function rechnungsListe(filter: RechnungsFilter) {
|
|||||||
where,
|
where,
|
||||||
orderBy: [{ start: "desc" }],
|
orderBy: [{ start: "desc" }],
|
||||||
include: {
|
include: {
|
||||||
host: { select: { id: true, hostname: true, kunde: { select: { id: true, name: true } } } },
|
host: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
hostname: true,
|
||||||
|
kunde: { select: { id: true, name: true, eigenbedarf: true } },
|
||||||
|
},
|
||||||
|
},
|
||||||
proxmoxPosition: { include: { rechnung: true } },
|
proxmoxPosition: { include: { rechnung: true } },
|
||||||
kundenPosition: { include: { rechnung: true } },
|
kundenPosition: { include: { rechnung: true } },
|
||||||
},
|
},
|
||||||
@@ -82,6 +88,8 @@ export async function rechnungsListe(filter: RechnungsFilter) {
|
|||||||
? Number(laufzeit.proxmoxPosition.nettoBetrag) - Number(laufzeit.proxmoxPosition.rabatt ?? 0)
|
? Number(laufzeit.proxmoxPosition.nettoBetrag) - Number(laufzeit.proxmoxPosition.rabatt ?? 0)
|
||||||
: null;
|
: null;
|
||||||
const verkauf = laufzeit.kundenPosition ? Number(laufzeit.kundenPosition.nettoBetrag) : null;
|
const verkauf = laufzeit.kundenPosition ? Number(laufzeit.kundenPosition.nettoBetrag) : null;
|
||||||
|
// Eigene Hosts werden nicht weiterberechnet; eine Marge daraus wäre eine erfundene Zahl (#044).
|
||||||
|
const eigenbedarf = laufzeit.host.kunde?.eigenbedarf ?? false;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: laufzeit.id,
|
id: laufzeit.id,
|
||||||
@@ -101,21 +109,26 @@ export async function rechnungsListe(filter: RechnungsFilter) {
|
|||||||
verkaufBetrag: verkauf,
|
verkaufBetrag: verkauf,
|
||||||
verkaufErstellt: laufzeit.kundenPosition?.rechnung.erstellt ?? false,
|
verkaufErstellt: laufzeit.kundenPosition?.rechnung.erstellt ?? false,
|
||||||
verkaufBezahlt: laufzeit.kundenPosition?.rechnung.bezahlt ?? false,
|
verkaufBezahlt: laufzeit.kundenPosition?.rechnung.bezahlt ?? false,
|
||||||
marge: einkauf !== null && verkauf !== null ? verkauf - einkauf : null,
|
eigenbedarf,
|
||||||
|
marge: !eigenbedarf && einkauf !== null && verkauf !== null ? verkauf - einkauf : null,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Eigenbedarf zählt beim Einkauf mit – es sind echte Kosten –, bleibt bei Verkauf und Marge
|
||||||
|
// aber außen vor.
|
||||||
const summen = zeilen.reduce(
|
const summen = zeilen.reduce(
|
||||||
(summe, zeile) => ({
|
(summe, zeile) => ({
|
||||||
einkauf: summe.einkauf + (zeile.einkaufBetrag ?? 0),
|
einkauf: summe.einkauf + (zeile.einkaufBetrag ?? 0),
|
||||||
verkauf: summe.verkauf + (zeile.verkaufBetrag ?? 0),
|
verkauf: summe.verkauf + (zeile.verkaufBetrag ?? 0),
|
||||||
|
einkaufVerkauft: summe.einkaufVerkauft + (zeile.eigenbedarf ? 0 : (zeile.einkaufBetrag ?? 0)),
|
||||||
|
eigenbedarf: summe.eigenbedarf + (zeile.eigenbedarf ? (zeile.einkaufBetrag ?? 0) : 0),
|
||||||
}),
|
}),
|
||||||
{ einkauf: 0, verkauf: 0 },
|
{ einkauf: 0, verkauf: 0, einkaufVerkauft: 0, eigenbedarf: 0 },
|
||||||
);
|
);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
zeilen,
|
zeilen,
|
||||||
summen: { ...summen, marge: summen.verkauf - summen.einkauf },
|
summen: { ...summen, marge: summen.verkauf - summen.einkaufVerkauft },
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -145,7 +158,7 @@ export async function laufzeitDetail(id: string) {
|
|||||||
id: true,
|
id: true,
|
||||||
hostname: true,
|
hostname: true,
|
||||||
produkt: true,
|
produkt: true,
|
||||||
kunde: { select: { id: true, name: true } },
|
kunde: { select: { id: true, name: true, eigenbedarf: true } },
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
proxmoxPosition: {
|
proxmoxPosition: {
|
||||||
|
|||||||
@@ -324,3 +324,104 @@ export async function laufzeitAnlegen(daten: {
|
|||||||
await protokolliere("laufzeit.angelegt", "Laufzeit", laufzeit.id, daten);
|
await protokolliere("laufzeit.angelegt", "Laufzeit", laufzeit.id, daten);
|
||||||
return laufzeit;
|
return laufzeit;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Ändert Key, Zeitraum oder Notiz einer Laufzeit (#043). */
|
||||||
|
export async function laufzeitBearbeiten(
|
||||||
|
id: string,
|
||||||
|
daten: { key: string; start: Date; ende: Date; notizen?: string | null },
|
||||||
|
) {
|
||||||
|
const laufzeit = await prisma.laufzeit.findUnique({ where: { id } });
|
||||||
|
if (!laufzeit) throw new RechnungsFehler("Laufzeit nicht gefunden.");
|
||||||
|
if (daten.ende <= daten.start) throw new RechnungsFehler("Das Ende muss nach dem Beginn liegen.");
|
||||||
|
|
||||||
|
// Je Host und Startdatum darf es nur eine Laufzeit geben – sonst entstünden zwei Lizenzjahre
|
||||||
|
// für denselben Zeitraum, und die Rechnungen ließen sich nicht mehr zuordnen.
|
||||||
|
const kollision = await prisma.laufzeit.findUnique({
|
||||||
|
where: { hostId_start: { hostId: laufzeit.hostId, start: daten.start } },
|
||||||
|
});
|
||||||
|
if (kollision && kollision.id !== id) {
|
||||||
|
throw new RechnungsFehler(
|
||||||
|
"Für dieses Startdatum gibt es an diesem Host schon eine Laufzeit. Beide lassen sich zusammenführen.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const geaendert = await prisma.laufzeit.update({
|
||||||
|
where: { id },
|
||||||
|
data: {
|
||||||
|
key: daten.key,
|
||||||
|
start: daten.start,
|
||||||
|
ende: daten.ende,
|
||||||
|
notizen: daten.notizen ?? null,
|
||||||
|
// Von Hand geändert heißt: der Vorschlag ist angenommen.
|
||||||
|
status: "BESTAETIGT",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await protokolliere("laufzeit.geaendert", "Laufzeit", id, {
|
||||||
|
key: daten.key,
|
||||||
|
start: daten.start,
|
||||||
|
ende: daten.ende,
|
||||||
|
});
|
||||||
|
return geaendert;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Führt zwei Laufzeiten desselben Hosts zusammen (#043).
|
||||||
|
*
|
||||||
|
* Gedacht für den Fall, dass neben einer von Hand erfassten Laufzeit ein Vorschlag des Agenten
|
||||||
|
* steht – etwa weil das gemeldete Ablaufdatum einen Tag abweicht. Die Rechnungspositionen der
|
||||||
|
* aufgelösten Laufzeit wandern mit, sofern das Ziel dort noch nichts hat; andernfalls bricht der
|
||||||
|
* Vorgang ab. Zwei Einkaufsrechnungen auf einem Lizenzjahr wären nicht mehr zuzuordnen.
|
||||||
|
*/
|
||||||
|
export async function laufzeitenZusammenfuehren(zielId: string, quelleId: string) {
|
||||||
|
if (zielId === quelleId) throw new RechnungsFehler("Das ist dieselbe Laufzeit.");
|
||||||
|
|
||||||
|
return prisma.$transaction(async (tx) => {
|
||||||
|
const [ziel, quelle] = await Promise.all([
|
||||||
|
tx.laufzeit.findUnique({ where: { id: zielId } }),
|
||||||
|
tx.laufzeit.findUnique({ where: { id: quelleId } }),
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (!ziel || !quelle) throw new RechnungsFehler("Laufzeit nicht gefunden.");
|
||||||
|
if (ziel.hostId !== quelle.hostId) {
|
||||||
|
throw new RechnungsFehler("Zusammenführen geht nur innerhalb desselben Hosts.");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (ziel.proxmoxPositionId && quelle.proxmoxPositionId) {
|
||||||
|
throw new RechnungsFehler(
|
||||||
|
"Beide Laufzeiten haben eine Proxmox-Rechnung. Bitte zuerst die falsche Rechnung entfernen.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (ziel.kundenPositionId && quelle.kundenPositionId) {
|
||||||
|
throw new RechnungsFehler(
|
||||||
|
"Beide Laufzeiten haben eine Kundenrechnung. Bitte zuerst die falsche Rechnung entfernen.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Zuerst muss die Quelle ihre Positionen loslassen: Eine Position gehört zu genau einer
|
||||||
|
// Laufzeit, und die Datenbank wacht darüber. Erst danach darf das Ziel sie übernehmen.
|
||||||
|
await tx.laufzeit.update({
|
||||||
|
where: { id: quelleId },
|
||||||
|
data: { proxmoxPositionId: null, kundenPositionId: null },
|
||||||
|
});
|
||||||
|
|
||||||
|
await tx.laufzeit.update({
|
||||||
|
where: { id: zielId },
|
||||||
|
data: {
|
||||||
|
proxmoxPositionId: ziel.proxmoxPositionId ?? quelle.proxmoxPositionId,
|
||||||
|
kundenPositionId: ziel.kundenPositionId ?? quelle.kundenPositionId,
|
||||||
|
status: "BESTAETIGT",
|
||||||
|
notizen: [ziel.notizen, quelle.notizen].filter(Boolean).join(" · ") || null,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await tx.laufzeit.delete({ where: { id: quelleId } });
|
||||||
|
|
||||||
|
await protokolliere("laufzeit.zusammengefuehrt", "Laufzeit", zielId, {
|
||||||
|
quelle: quelleId,
|
||||||
|
quelleStart: quelle.start,
|
||||||
|
});
|
||||||
|
|
||||||
|
return ziel;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
@@ -241,6 +241,25 @@ productname: Proxmox "VE" \\ Subscription mit Tab`,
|
|||||||
expect(report.host.clusterMitglieder).toEqual(["hv-rz5-pve-01", "hv-rz5-pve-02", "hv-rz5-pve-03"]);
|
expect(report.host.clusterMitglieder).toEqual(["hv-rz5-pve-01", "hv-rz5-pve-02", "hv-rz5-pve-03"]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("liest die Knoten auch mit QDevice-Spalte richtig", () => {
|
||||||
|
// Mit einem QDevice steht zwischen Votes und Name dessen Zustand. Vorher landeten
|
||||||
|
// „A,V,NMW“ und „NR“ als Knotennamen im Report (#042).
|
||||||
|
befehlErsetzen("pvesubscription", PVE_SUBSCRIPTION);
|
||||||
|
befehlErsetzen(
|
||||||
|
"pvecm",
|
||||||
|
`Membership information
|
||||||
|
----------------------
|
||||||
|
Nodeid Votes Qdevice Name
|
||||||
|
1 1 A,V,NMW hv-pve-01
|
||||||
|
2 1 A,V,NMW hv-pve-02 (local)
|
||||||
|
3 1 NR hv-pve-05
|
||||||
|
0 1 Qdevice (votes 1)`,
|
||||||
|
);
|
||||||
|
|
||||||
|
const report = reportErzeugen() as { host: { clusterMitglieder: string[] } };
|
||||||
|
expect(report.host.clusterMitglieder).toEqual(["hv-pve-01", "hv-pve-02", "hv-pve-05"]);
|
||||||
|
});
|
||||||
|
|
||||||
it("meldet eine leere Liste, wenn der Host in keinem Cluster ist", () => {
|
it("meldet eine leere Liste, wenn der Host in keinem Cluster ist", () => {
|
||||||
befehlErsetzen("pvesubscription", PVE_SUBSCRIPTION);
|
befehlErsetzen("pvesubscription", PVE_SUBSCRIPTION);
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,79 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { alleRegelnPruefen, type LaufzeitDaten, type RegelKontext } from "@/server/alerts/regeln";
|
||||||
|
|
||||||
|
/** Eigenbedarf: eigene Hosts ohne Weiterberechnung (#044). */
|
||||||
|
|
||||||
|
const JETZT = new Date("2026-10-04T12:00:00Z");
|
||||||
|
|
||||||
|
function laufzeit(überschreibungen: Partial<LaufzeitDaten> = {}): LaufzeitDaten {
|
||||||
|
return {
|
||||||
|
id: "l1",
|
||||||
|
hostId: "h1",
|
||||||
|
hostname: "hv-ste-pve-01",
|
||||||
|
kundeId: "k1",
|
||||||
|
kundeEigenbedarf: false,
|
||||||
|
kundeName: "Florian Wilde IT SysOp",
|
||||||
|
key: "pve2s-aaaa",
|
||||||
|
start: new Date("2026-06-03"),
|
||||||
|
ende: new Date("2027-06-02"),
|
||||||
|
einkaufNummer: "291017",
|
||||||
|
einkaufBetrag: 990,
|
||||||
|
einkaufBezahlt: true,
|
||||||
|
einkaufFaelligAm: new Date("2026-10-17"),
|
||||||
|
verkaufNummer: null,
|
||||||
|
verkaufBetrag: null,
|
||||||
|
verkaufErstellt: false,
|
||||||
|
verkaufBezahlt: false,
|
||||||
|
verkaufDatum: null,
|
||||||
|
...überschreibungen,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function kontext(laufzeiten: LaufzeitDaten[]): RegelKontext {
|
||||||
|
return { jetzt: JETZT, hosts: [], laufzeiten, einstellungen: new Map() };
|
||||||
|
}
|
||||||
|
|
||||||
|
function regeln(k: RegelKontext, name: string) {
|
||||||
|
return alleRegelnPruefen(k).filter((kandidat) => kandidat.regel === name);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("Eigenbedarf", () => {
|
||||||
|
it("mahnt bei einem normalen Kunden die fehlende Kundenrechnung an", () => {
|
||||||
|
expect(regeln(kontext([laufzeit()]), "kundenrechnung-fehlt")).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("schweigt beim Eigenbedarf – es gibt niemanden, dem man schreibt", () => {
|
||||||
|
expect(regeln(kontext([laufzeit({ kundeEigenbedarf: true })]), "kundenrechnung-fehlt")).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("mahnt beim Eigenbedarf keine unbezahlte Kundenrechnung an", () => {
|
||||||
|
const k = kontext([
|
||||||
|
laufzeit({
|
||||||
|
kundeEigenbedarf: true,
|
||||||
|
verkaufErstellt: true,
|
||||||
|
verkaufDatum: new Date("2026-01-01"),
|
||||||
|
verkaufBezahlt: false,
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
expect(regeln(k, "kundenrechnung-unbezahlt")).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("meldet beim Eigenbedarf nicht „Kunde hat bezahlt“", () => {
|
||||||
|
const k = kontext([
|
||||||
|
laufzeit({ kundeEigenbedarf: true, verkaufBezahlt: true, einkaufBezahlt: false }),
|
||||||
|
]);
|
||||||
|
expect(regeln(k, "kunde-bezahlt-einkauf-offen")).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("lässt die Einkaufsseite unberührt", () => {
|
||||||
|
// Die Proxmox-Rechnung muss auch für eigene Hosts bezahlt werden.
|
||||||
|
const k = kontext([
|
||||||
|
laufzeit({
|
||||||
|
kundeEigenbedarf: true,
|
||||||
|
einkaufBezahlt: false,
|
||||||
|
einkaufFaelligAm: new Date("2026-10-06"),
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
expect(regeln(k, "proxmox-rechnung-faellig")).toHaveLength(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,128 @@
|
|||||||
|
import { afterAll, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import { datenbankLeeren, datenbankVerfuegbar, testPrisma } from "./helpers/db";
|
||||||
|
|
||||||
|
/** Laufzeiten nachträglich ändern und zusammenführen (#043). */
|
||||||
|
vi.mock("@/server/auth/sitzungspflicht", () => ({
|
||||||
|
sitzungErforderlich: async () => ({ benutzerId: "test", rolle: "ADMIN", kundeId: null }),
|
||||||
|
sichtbarerKunde: async () => null,
|
||||||
|
darfEinkaufSehen: async () => true,
|
||||||
|
angemeldet: async () => null,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const { laufzeitBearbeiten, laufzeitenZusammenfuehren, RechnungsFehler } = await import(
|
||||||
|
"@/server/services/rechnungen"
|
||||||
|
);
|
||||||
|
|
||||||
|
async function host(name = "hv-pve-01") {
|
||||||
|
return testPrisma.host.create({ data: { agentId: `a-${name}`, hostname: name, produkt: "PVE" } });
|
||||||
|
}
|
||||||
|
|
||||||
|
async function laufzeit(hostId: string, start: string, key = "pve2s-aaaa") {
|
||||||
|
return testPrisma.laufzeit.create({
|
||||||
|
data: {
|
||||||
|
hostId,
|
||||||
|
key,
|
||||||
|
start: new Date(start),
|
||||||
|
ende: new Date(new Date(start).setUTCFullYear(new Date(start).getUTCFullYear() + 1)),
|
||||||
|
status: "VORSCHLAG",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function mitEinkauf(laufzeitId: string, nummer: string) {
|
||||||
|
const rechnung = await testPrisma.proxmoxRechnung.create({
|
||||||
|
data: { nummer, datum: new Date("2026-09-17"), nettoBetrag: 990, ustSatz: 0 },
|
||||||
|
});
|
||||||
|
const position = await testPrisma.proxmoxRechnungPosition.create({
|
||||||
|
data: { rechnungId: rechnung.id, bezeichnung: "Position", nettoBetrag: 1100, rabatt: 110 },
|
||||||
|
});
|
||||||
|
await testPrisma.laufzeit.update({
|
||||||
|
where: { id: laufzeitId },
|
||||||
|
data: { proxmoxPosition: { connect: { id: position.id } } },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
describe.runIf(datenbankVerfuegbar)("Laufzeit bearbeiten", () => {
|
||||||
|
beforeEach(async () => {
|
||||||
|
await datenbankLeeren();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
await testPrisma.$disconnect();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ändert Key und Zeitraum und bestätigt dabei den Vorschlag", async () => {
|
||||||
|
const h = await host();
|
||||||
|
const l = await laufzeit(h.id, "2026-10-17");
|
||||||
|
|
||||||
|
await laufzeitBearbeiten(l.id, {
|
||||||
|
key: "pve2s-neu",
|
||||||
|
start: new Date("2026-10-18"),
|
||||||
|
ende: new Date("2027-10-17"),
|
||||||
|
notizen: "Datum laut Bestellung",
|
||||||
|
});
|
||||||
|
|
||||||
|
const danach = await testPrisma.laufzeit.findUniqueOrThrow({ where: { id: l.id } });
|
||||||
|
expect(danach.key).toBe("pve2s-neu");
|
||||||
|
expect(danach.start.toISOString().slice(0, 10)).toBe("2026-10-18");
|
||||||
|
expect(danach.status).toBe("BESTAETIGT");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("weist ein Ende vor dem Beginn ab", async () => {
|
||||||
|
const h = await host();
|
||||||
|
const l = await laufzeit(h.id, "2026-10-17");
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
laufzeitBearbeiten(l.id, { key: "pve2s-aaaa", start: new Date("2026-10-17"), ende: new Date("2026-10-01") }),
|
||||||
|
).rejects.toBeInstanceOf(RechnungsFehler);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("weist ein Startdatum ab, das am selben Host schon belegt ist", async () => {
|
||||||
|
const h = await host();
|
||||||
|
const ersteLaufzeit = await laufzeit(h.id, "2026-10-17");
|
||||||
|
await laufzeit(h.id, "2025-10-17");
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
laufzeitBearbeiten(ersteLaufzeit.id, {
|
||||||
|
key: "pve2s-aaaa",
|
||||||
|
start: new Date("2025-10-17"),
|
||||||
|
ende: new Date("2026-10-16"),
|
||||||
|
}),
|
||||||
|
).rejects.toBeInstanceOf(RechnungsFehler);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("führt zwei Laufzeiten zusammen und nimmt die Rechnung mit", async () => {
|
||||||
|
const h = await host();
|
||||||
|
const ziel = await laufzeit(h.id, "2026-10-17");
|
||||||
|
const quelle = await laufzeit(h.id, "2026-10-18");
|
||||||
|
await mitEinkauf(quelle.id, "291017");
|
||||||
|
|
||||||
|
await laufzeitenZusammenfuehren(ziel.id, quelle.id);
|
||||||
|
|
||||||
|
const danach = await testPrisma.laufzeit.findUniqueOrThrow({ where: { id: ziel.id } });
|
||||||
|
expect(danach.proxmoxPositionId).not.toBeNull();
|
||||||
|
expect(danach.status).toBe("BESTAETIGT");
|
||||||
|
expect(await testPrisma.laufzeit.count()).toBe(1);
|
||||||
|
// Die Rechnung selbst bleibt erhalten – nur die doppelte Laufzeit verschwindet.
|
||||||
|
expect(await testPrisma.proxmoxRechnungPosition.count()).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("verweigert das Zusammenführen, wenn beide eine Einkaufsrechnung haben", async () => {
|
||||||
|
const h = await host();
|
||||||
|
const ziel = await laufzeit(h.id, "2026-10-17");
|
||||||
|
const quelle = await laufzeit(h.id, "2026-10-18");
|
||||||
|
await mitEinkauf(ziel.id, "291017");
|
||||||
|
await mitEinkauf(quelle.id, "291018");
|
||||||
|
|
||||||
|
await expect(laufzeitenZusammenfuehren(ziel.id, quelle.id)).rejects.toBeInstanceOf(RechnungsFehler);
|
||||||
|
expect(await testPrisma.laufzeit.count()).toBe(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("führt nichts über Hostgrenzen hinweg zusammen", async () => {
|
||||||
|
const [a, b] = await Promise.all([host("hv-pve-01"), host("hv-pve-02")]);
|
||||||
|
const ziel = await laufzeit(a.id, "2026-10-17");
|
||||||
|
const quelle = await laufzeit(b.id, "2026-10-17");
|
||||||
|
|
||||||
|
await expect(laufzeitenZusammenfuehren(ziel.id, quelle.id)).rejects.toBeInstanceOf(RechnungsFehler);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -41,6 +41,7 @@ function laufzeit(überschreibungen: Partial<LaufzeitDaten> = {}): LaufzeitDaten
|
|||||||
hostname: "pve-01",
|
hostname: "pve-01",
|
||||||
kundeId: "k1",
|
kundeId: "k1",
|
||||||
kundeName: "Brandl Elektrotechnik GmbH",
|
kundeName: "Brandl Elektrotechnik GmbH",
|
||||||
|
kundeEigenbedarf: false,
|
||||||
key: "pve1c-aaaa",
|
key: "pve1c-aaaa",
|
||||||
start: inTagen(-100),
|
start: inTagen(-100),
|
||||||
ende: inTagen(265),
|
ende: inTagen(265),
|
||||||
|
|||||||