Neuer Reiter mit Plan-Editor: Etagen anlegen, umbenennen, sortieren, loeschen und als Standard markieren; Grundrissbild hochladen; Raeume mit Name, Kuerzel und Thermostat-Zweig anlegen und bearbeiten; Kacheln im Plan ziehen oder einen Raum ohne Kachel mit "Platzieren" und einem Klick setzen. Loest tools/kachelpositionen.php ab - Raster, Geisterkachel und die Umrechnung Mitte -> linke obere Ecke sind von dort uebernommen. - restricted/grundriss.php: alle Pruefungen und Schreibzugriffe. Eine Etage mit Raeumen oder Automatiken laesst sich nicht loeschen, die Ablehnung nennt die Namen. Raeume loeschen laesst ihre Geraete ohne Raum. - Grundrissbilder nach tiles/grundriss/ (beschreibbar, nicht im Git), nur PNG/JPEG/WebP, am Inhalt geprueft; SVG bewusst nicht. Prüfsumme im Dateinamen, alte hochgeladene Bilder werden weggeraeumt. - ajax/settings.php: ?action=grundriss und sieben POST-Aktionen, jede antwortet mit dem ganzen neuen Stand. - "Werte" springt in den Reiter Home-Kacheln und oeffnet die Kachel; Home-Kacheln und Geraete laden nach einer Grundriss-Aenderung nach. - Startseite ohne Etage mit platziertem Raum: Hinweis auf die Einstellungen statt einer leeren Zeichenflaeche. - README: Grundriss als Inhalt statt Code, neue Dateien und Endpunkte, Reihenfolge der Schema-Dateien fuer ein neues Haus. Backend mit einer Probe-Etage durchgespielt (auch Fehlerfaelle, Upload, Aufraeumen); Oberflaeche auf einer Probeseite mit echten Daten und Zeiger-Ereignissen geprueft: Ziehen, Antippen, Platzieren, neuer Raum. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
448 lines
16 KiB
PHP
448 lines
16 KiB
PHP
<?php
|
|
/**
|
|
* Modell-Schicht für den Reiter "Grundriss": Etagen, Räume, Kachelpositionen,
|
|
* Grundrissbilder.
|
|
*
|
|
* Gelesen wird der Grundriss in rooms.php - dort, wo ihn Startseite, Menü und
|
|
* Editor ohnehin holen. Hier steht nur, was ihn ändert, und jede Prüfung
|
|
* dazu. Die Maske (js/solar/grundriss.js) prüft vorab nichts, was hier nicht
|
|
* auch geprüft würde; sie zeigt nur die Meldung, die von hier kommt.
|
|
*
|
|
* Tabellen: homeMesh.floors und homeMesh.rooms, siehe homeMesh_grundriss.sql.
|
|
*/
|
|
|
|
require_once(__DIR__ . "/rooms.php"); // grundriss(), meshDb()
|
|
|
|
/** Zeichenfläche und Kachel - dieselben Maße wie in restricted/home.php. */
|
|
const PLAN_BREIT = 400;
|
|
const PLAN_HOCH = 300;
|
|
const KACHEL_BREIT = 24;
|
|
const KACHEL_HOCH = 19;
|
|
|
|
/**
|
|
* Wohin hochgeladene Grundrisse kommen, relativ zum Web-Root.
|
|
*
|
|
* tiles/ ist das eine Verzeichnis, in das der Webserver schreiben darf - dort
|
|
* liegt schon der Zwischenspeicher der Kartenkacheln. Es steht nicht im Git;
|
|
* ein Grundriss ist Inhalt dieses Hauses, nicht Teil der Anwendung.
|
|
*/
|
|
const GRUNDRISS_ORDNER = "tiles/grundriss";
|
|
|
|
/** Größte Bilddatei. Ein Rendering in 2048 x 1536 hat gut drei Megabyte. */
|
|
const GRUNDRISS_MAX_BYTES = 16 * 1024 * 1024;
|
|
|
|
/** Web-Root als Dateipfad. */
|
|
function webRoot()
|
|
{
|
|
return dirname(__DIR__);
|
|
}
|
|
|
|
/**
|
|
* Alles, was der Reiter zum Zeichnen braucht, in einer Antwort.
|
|
*
|
|
* Je Etage steht dabei, wie viele Räume und Automatiken an ihr hängen, je
|
|
* Raum, wie viele Geräte - danach richtet sich, ob und mit welcher Rückfrage
|
|
* gelöscht werden darf. Die Maske soll die Namen nicht erst nachfragen müssen.
|
|
*/
|
|
function grundrissStand()
|
|
{
|
|
$db = meshDb();
|
|
[$etagen, $raeume] = grundriss();
|
|
|
|
$automatiken = [];
|
|
$res = $db->query("SELECT floor, name FROM automations ORDER BY name");
|
|
while ($res && $z = $res->fetch_assoc()) {
|
|
$automatiken[$z["floor"]][] = $z["name"];
|
|
}
|
|
$geraete = [];
|
|
$res = $db->query("SELECT room_id, COUNT(*) AS n FROM actors
|
|
WHERE room_id IS NOT NULL GROUP BY room_id");
|
|
while ($res && $z = $res->fetch_assoc()) {
|
|
$geraete[intval($z["room_id"])] = intval($z["n"]);
|
|
}
|
|
|
|
$liste = [];
|
|
foreach ($etagen as $code => $e) {
|
|
$liste[] = [
|
|
"code" => $code,
|
|
"label" => $e["label"],
|
|
"bild" => $e["bild"],
|
|
"standard" => $e["standard"],
|
|
"raeume" => count(array_filter($raeume, fn($r) => $r["floor"] === $code)),
|
|
"automatiken" => $automatiken[$code] ?? [],
|
|
];
|
|
}
|
|
$raumListe = [];
|
|
foreach ($raeume as $r) {
|
|
$raumListe[] = [
|
|
"nr" => $r["nr"],
|
|
"floor" => $r["floor"],
|
|
"name" => $r["mqtt"],
|
|
"kuerzel" => $r["id"],
|
|
"x" => $r["x"] ?? null,
|
|
"y" => $r["y"] ?? null,
|
|
"thermostat" => $r["thermostat"],
|
|
"geraete" => $geraete[$r["nr"]] ?? 0,
|
|
];
|
|
}
|
|
return [
|
|
"etagen" => $liste,
|
|
"raeume" => $raumListe,
|
|
"zweige" => thermostatZweige(),
|
|
"standard" => standardEtage(),
|
|
"mass" => ["planBreit" => PLAN_BREIT, "planHoch" => PLAN_HOCH,
|
|
"kachelBreit" => KACHEL_BREIT, "kachelHoch" => KACHEL_HOCH],
|
|
];
|
|
}
|
|
|
|
/**
|
|
* Vorschläge für das Thermostat-Feld: die Zweige, unter denen gerade ein
|
|
* Thermostat meldet.
|
|
*
|
|
* Erkannt an einem Messwert, dessen Topic auf "/Temp[degC]" endet - so
|
|
* schreiben die ESP32-Thermostate. Ein anderes Haus mit anderen Thermostaten
|
|
* tippt den Zweig von Hand; die Liste ist eine Abkürzung, keine Grenze.
|
|
*/
|
|
function thermostatZweige()
|
|
{
|
|
$zweige = [];
|
|
$res = meshDb()->query("SELECT DISTINCT url FROM actor_states WHERE url LIKE '%/Temp[degC]'");
|
|
while ($res && $z = $res->fetch_row()) {
|
|
$zweige[] = substr($z[0], 0, -strlen("/Temp[degC]"));
|
|
}
|
|
sort($zweige, SORT_NATURAL | SORT_FLAG_CASE);
|
|
return $zweige;
|
|
}
|
|
|
|
/* --- Etagen -------------------------------------------------------------- */
|
|
|
|
/**
|
|
* Eine Etage anlegen oder ändern.
|
|
*
|
|
* Das Kürzel ist nach dem Anlegen fest: es steht in URLs, SVG-Ids und
|
|
* automations.floor. Ändern lassen sich Name und "Standard".
|
|
*
|
|
* Kürzel aus bis zu vier Großbuchstaben oder Ziffern - so passt es in den
|
|
* Etagenschalter im Grundriss und braucht in keiner URL eine Maskierung.
|
|
*/
|
|
function etageSpeichern($daten)
|
|
{
|
|
$db = meshDb();
|
|
$code = strtoupper(trim(strval($daten["code"] ?? "")));
|
|
$label = trim(strval($daten["label"] ?? ""));
|
|
$neu = !empty($daten["neu"]);
|
|
$standard = !empty($daten["standard"]);
|
|
|
|
if (!preg_match('/^[A-Z0-9]{1,4}$/', $code)) {
|
|
throw new InvalidArgumentException(
|
|
"Das Kürzel besteht aus 1 bis 4 Großbuchstaben oder Ziffern, z. B. EG.");
|
|
}
|
|
if ($label === "" || mb_strlen($label) > 40) {
|
|
throw new InvalidArgumentException("Der Name der Etage braucht 1 bis 40 Zeichen.");
|
|
}
|
|
$da = isset(grundriss()[0][$code]);
|
|
if ($neu && $da) {
|
|
throw new InvalidArgumentException("Eine Etage " . $code . " gibt es schon.");
|
|
}
|
|
if (!$neu && !$da) {
|
|
throw new InvalidArgumentException("Die Etage " . $code . " gibt es nicht (mehr).");
|
|
}
|
|
|
|
$db->begin_transaction();
|
|
try {
|
|
if ($standard) {
|
|
// Höchstens eine - der Schalter an der einen nimmt ihn allen anderen.
|
|
$db->query("UPDATE floors SET standard = 0");
|
|
}
|
|
$std = $standard ? 1 : 0;
|
|
if ($neu) {
|
|
$pos = intval($db->query("SELECT COALESCE(MAX(position), -1) + 1 FROM floors")->fetch_row()[0]);
|
|
$stmt = $db->prepare("INSERT INTO floors (code, label, position, standard) VALUES (?,?,?,?)");
|
|
$stmt->bind_param("ssii", $code, $label, $pos, $std);
|
|
} else {
|
|
$stmt = $db->prepare("UPDATE floors SET label = ?, standard = ? WHERE code = ?");
|
|
$stmt->bind_param("sis", $label, $std, $code);
|
|
}
|
|
$stmt->execute();
|
|
$stmt->close();
|
|
$db->commit();
|
|
} catch (Throwable $e) {
|
|
$db->rollback();
|
|
throw $e;
|
|
}
|
|
return $code;
|
|
}
|
|
|
|
/** Neue Reihenfolge der Etagen: die Kürzel in der gewünschten Folge. */
|
|
function etagenReihenfolge($codes)
|
|
{
|
|
if (!is_array($codes)) {
|
|
throw new InvalidArgumentException("Die Reihenfolge ist unlesbar.");
|
|
}
|
|
$vorhanden = array_keys(grundriss()[0]);
|
|
$codes = array_values(array_map("strval", $codes));
|
|
if (count($codes) !== count($vorhanden) || array_diff($vorhanden, $codes)) {
|
|
throw new InvalidArgumentException(
|
|
"Die Reihenfolge passt nicht zu den Etagen - bitte neu laden.");
|
|
}
|
|
$db = meshDb();
|
|
$stmt = $db->prepare("UPDATE floors SET position = ? WHERE code = ?");
|
|
foreach ($codes as $i => $code) {
|
|
$stmt->bind_param("is", $i, $code);
|
|
$stmt->execute();
|
|
}
|
|
$stmt->close();
|
|
}
|
|
|
|
/**
|
|
* Eine Etage löschen - nur, wenn nichts mehr an ihr hängt.
|
|
*
|
|
* Räume und Automatiken mitzulöschen wäre ein stiller Datenverlust. Die
|
|
* Ablehnung nennt, was im Weg steht; aufräumen muss man bewusst.
|
|
*/
|
|
function etageLoeschen($code)
|
|
{
|
|
$code = strval($code);
|
|
$etage = grundriss()[0][$code] ?? null;
|
|
if (!$etage) {
|
|
throw new InvalidArgumentException("Die Etage " . $code . " gibt es nicht (mehr).");
|
|
}
|
|
$raeume = array_column(array_filter(grundriss()[1], fn($r) => $r["floor"] === $code), "mqtt");
|
|
if ($raeume) {
|
|
throw new InvalidArgumentException(
|
|
"Auf " . $etage["label"] . " liegen noch " . count($raeume) . " Räume: "
|
|
. implode(", ", $raeume) . ". Erst die Räume löschen oder verschieben.");
|
|
}
|
|
$db = meshDb();
|
|
$stmt = $db->prepare("SELECT name FROM automations WHERE floor = ? ORDER BY name");
|
|
$stmt->bind_param("s", $code);
|
|
$stmt->execute();
|
|
$namen = array_column($stmt->get_result()->fetch_all(MYSQLI_ASSOC), "name");
|
|
$stmt->close();
|
|
if ($namen) {
|
|
throw new InvalidArgumentException(
|
|
"Unter " . $etage["label"] . " stehen noch " . count($namen) . " Automatiken: "
|
|
. implode(", ", $namen) . ". Erst dort löschen oder einer anderen Etage zuordnen.");
|
|
}
|
|
$stmt = $db->prepare("DELETE FROM floors WHERE code = ?");
|
|
$stmt->bind_param("s", $code);
|
|
$stmt->execute();
|
|
$stmt->close();
|
|
grundrissBildWegraeumen($etage["bild"]);
|
|
}
|
|
|
|
/**
|
|
* Ein hochgeladenes Grundrissbild übernehmen.
|
|
*
|
|
* Erlaubt sind PNG, JPEG und WebP - geprüft am Inhalt (getimagesize), nicht an
|
|
* der Endung. SVG bewusst nicht: es kann Skript enthalten, und hier läge es
|
|
* direkt abrufbar im Web-Root derselben Seite.
|
|
*
|
|
* Der Dateiname trägt einen Teil der Prüfsumme. Ein neues Bild hat damit eine
|
|
* neue Adresse, und kein Browser zeigt nach dem Hochladen noch das alte aus
|
|
* seinem Zwischenspeicher.
|
|
*/
|
|
function etageBildSpeichern($code, $datei)
|
|
{
|
|
$code = strval($code);
|
|
$etage = grundriss()[0][$code] ?? null;
|
|
if (!$etage) {
|
|
throw new InvalidArgumentException("Die Etage " . $code . " gibt es nicht (mehr).");
|
|
}
|
|
if (!is_array($datei) || ($datei["error"] ?? UPLOAD_ERR_NO_FILE) !== UPLOAD_ERR_OK) {
|
|
$fehler = $datei["error"] ?? UPLOAD_ERR_NO_FILE;
|
|
throw new InvalidArgumentException($fehler === UPLOAD_ERR_INI_SIZE || $fehler === UPLOAD_ERR_FORM_SIZE
|
|
? "Das Bild ist zu groß." : "Es kam kein Bild an.");
|
|
}
|
|
if ($datei["size"] > GRUNDRISS_MAX_BYTES) {
|
|
throw new InvalidArgumentException("Das Bild ist größer als 16 MB.");
|
|
}
|
|
$info = @getimagesize($datei["tmp_name"]);
|
|
$endungen = [IMAGETYPE_PNG => "png", IMAGETYPE_JPEG => "jpg", IMAGETYPE_WEBP => "webp"];
|
|
if (!$info || !isset($endungen[$info[2]])) {
|
|
throw new InvalidArgumentException("Nur PNG, JPEG oder WebP.");
|
|
}
|
|
|
|
$ordner = webRoot() . "/" . GRUNDRISS_ORDNER;
|
|
if (!is_dir($ordner) && !@mkdir($ordner, 0775, true)) {
|
|
throw new RuntimeException("Der Ordner " . GRUNDRISS_ORDNER . " lässt sich nicht anlegen.");
|
|
}
|
|
$name = $code . "-" . substr(sha1_file($datei["tmp_name"]), 0, 10) . "." . $endungen[$info[2]];
|
|
$ziel = $ordner . "/" . $name;
|
|
if (!move_uploaded_file($datei["tmp_name"], $ziel)) {
|
|
throw new RuntimeException("Das Bild ließ sich nicht nach " . GRUNDRISS_ORDNER . " schreiben.");
|
|
}
|
|
@chmod($ziel, 0664);
|
|
|
|
$pfad = GRUNDRISS_ORDNER . "/" . $name;
|
|
$db = meshDb();
|
|
$stmt = $db->prepare("UPDATE floors SET bild = ? WHERE code = ?");
|
|
$stmt->bind_param("ss", $pfad, $code);
|
|
$stmt->execute();
|
|
$stmt->close();
|
|
|
|
if ($etage["bild"] !== $pfad) {
|
|
grundrissBildWegraeumen($etage["bild"]);
|
|
}
|
|
return ["bild" => $pfad, "breit" => $info[0], "hoch" => $info[1]];
|
|
}
|
|
|
|
/**
|
|
* Ein nicht mehr benutztes Bild löschen - aber nur ein hochgeladenes.
|
|
*
|
|
* Die mitgelieferten unter assets/img gehören zum Repository und bleiben.
|
|
* Benutzt eine andere Etage dasselbe Bild, bleibt es ebenfalls.
|
|
*/
|
|
function grundrissBildWegraeumen($pfad)
|
|
{
|
|
if (!$pfad || strpos($pfad, GRUNDRISS_ORDNER . "/") !== 0 || strpos($pfad, "..") !== false) {
|
|
return;
|
|
}
|
|
$stmt = meshDb()->prepare("SELECT COUNT(*) FROM floors WHERE bild = ?");
|
|
$stmt->bind_param("s", $pfad);
|
|
$stmt->execute();
|
|
$benutzt = intval($stmt->get_result()->fetch_row()[0]);
|
|
$stmt->close();
|
|
if ($benutzt === 0) {
|
|
@unlink(webRoot() . "/" . $pfad);
|
|
}
|
|
}
|
|
|
|
/* --- Räume --------------------------------------------------------------- */
|
|
|
|
/**
|
|
* Ein Kürzel aus einem Namen: klein, ohne Umlaute, nur a-z, 0-9 und _.
|
|
*
|
|
* Es steht in SVG-Element-Ids; Leerzeichen und Umlaute gehören dort nicht
|
|
* hinein.
|
|
*/
|
|
function raumKuerzelAus($name)
|
|
{
|
|
$k = mb_strtolower($name);
|
|
$k = strtr($k, ["ä" => "ae", "ö" => "oe", "ü" => "ue", "ß" => "ss"]);
|
|
$k = preg_replace('/[^a-z0-9]+/', "_", $k);
|
|
$k = trim($k, "_");
|
|
return substr($k !== "" ? $k : "raum", 0, 36);
|
|
}
|
|
|
|
/**
|
|
* Einen Raum anlegen oder ändern.
|
|
*
|
|
* Fehlt das Kürzel, entsteht es aus dem Namen - und wird durchnummeriert,
|
|
* falls es auf der Etage schon vergeben ist. Ein von Hand eingetragenes muss
|
|
* dagegen passen: wer es tippt, meint genau dieses.
|
|
*
|
|
* Wechselt ein Raum die Etage, behält er seine Kachel an derselben Stelle der
|
|
* Zeichenfläche - verschieben ist dann ein Zug im Plan.
|
|
*/
|
|
function raumSpeichern($daten)
|
|
{
|
|
$db = meshDb();
|
|
$nr = intval($daten["nr"] ?? 0);
|
|
$floor = strval($daten["floor"] ?? "");
|
|
$name = trim(strval($daten["name"] ?? ""));
|
|
$kuerzel = trim(strval($daten["kuerzel"] ?? ""));
|
|
$thermostat = trim(strval($daten["thermostat"] ?? ""), " /");
|
|
|
|
if (!isset(grundriss()[0][$floor])) {
|
|
throw new InvalidArgumentException("Bitte eine Etage wählen.");
|
|
}
|
|
if ($name === "" || mb_strlen($name) > 40) {
|
|
throw new InvalidArgumentException("Der Name des Raums braucht 1 bis 40 Zeichen.");
|
|
}
|
|
if ($thermostat !== "" && (mb_strlen($thermostat) > 120 || preg_match('/[#+\s]/', $thermostat))) {
|
|
throw new InvalidArgumentException(
|
|
"Der Thermostat-Zweig ist ein MQTT-Topic ohne Leerzeichen, # oder +, z. B. Raumtemp/EG/Bad.");
|
|
}
|
|
|
|
$andere = array_filter(grundriss()[1], fn($r) => $r["floor"] === $floor && $r["nr"] !== $nr);
|
|
foreach ($andere as $r) {
|
|
if (mb_strtolower($r["mqtt"]) === mb_strtolower($name)) {
|
|
throw new InvalidArgumentException("Auf dieser Etage gibt es schon einen Raum „" . $name . "“.");
|
|
}
|
|
}
|
|
$vergeben = array_column($andere, "id");
|
|
if ($kuerzel === "") {
|
|
$basis = raumKuerzelAus($name);
|
|
$kuerzel = $basis;
|
|
for ($i = 2; in_array($kuerzel, $vergeben, true); $i++) {
|
|
$kuerzel = $basis . "_" . $i;
|
|
}
|
|
} elseif (!preg_match('/^[a-z0-9_]{1,40}$/', $kuerzel)) {
|
|
throw new InvalidArgumentException("Das Kürzel besteht nur aus a-z, 0-9 und _.");
|
|
} elseif (in_array($kuerzel, $vergeben, true)) {
|
|
throw new InvalidArgumentException("Das Kürzel " . $kuerzel . " ist auf dieser Etage schon vergeben.");
|
|
}
|
|
|
|
$th = $thermostat === "" ? null : $thermostat;
|
|
if ($nr > 0) {
|
|
$alt = null;
|
|
foreach (grundriss()[1] as $r) {
|
|
if ($r["nr"] === $nr) {
|
|
$alt = $r;
|
|
}
|
|
}
|
|
if (!$alt) {
|
|
throw new InvalidArgumentException("Diesen Raum gibt es nicht (mehr).");
|
|
}
|
|
$stmt = $db->prepare("UPDATE rooms SET floor = ?, name = ?, kuerzel = ?, thermostat = ? WHERE id = ?");
|
|
$stmt->bind_param("ssssi", $floor, $name, $kuerzel, $th, $nr);
|
|
$stmt->execute();
|
|
$stmt->close();
|
|
return $nr;
|
|
}
|
|
$pos = intval($db->query("SELECT COALESCE(MAX(position), -1) + 1 FROM rooms")->fetch_row()[0]);
|
|
$stmt = $db->prepare("INSERT INTO rooms (floor, name, kuerzel, position, thermostat) VALUES (?,?,?,?,?)");
|
|
$stmt->bind_param("sssis", $floor, $name, $kuerzel, $pos, $th);
|
|
$stmt->execute();
|
|
$nr = $db->insert_id;
|
|
$stmt->close();
|
|
return $nr;
|
|
}
|
|
|
|
/**
|
|
* Die Kachel eines Raums setzen oder vom Plan nehmen ($x === null).
|
|
*
|
|
* x/y ist die linke obere Ecke, begrenzt auf die Zeichenfläche - eine Kachel,
|
|
* die halb über den Rand hängt, ließe sich im Plan nicht mehr greifen.
|
|
*/
|
|
function raumPosition($nr, $x, $y)
|
|
{
|
|
$nr = intval($nr);
|
|
$db = meshDb();
|
|
if ($x === null || $y === null) {
|
|
$stmt = $db->prepare("UPDATE rooms SET x = NULL, y = NULL WHERE id = ?");
|
|
$stmt->bind_param("i", $nr);
|
|
} else {
|
|
$x = max(0, min(PLAN_BREIT - KACHEL_BREIT, intval(round(floatval($x)))));
|
|
$y = max(0, min(PLAN_HOCH - KACHEL_HOCH, intval(round(floatval($y)))));
|
|
$stmt = $db->prepare("UPDATE rooms SET x = ?, y = ? WHERE id = ?");
|
|
$stmt->bind_param("iii", $x, $y, $nr);
|
|
}
|
|
$stmt->execute();
|
|
$geaendert = $stmt->affected_rows;
|
|
$stmt->close();
|
|
if ($geaendert === 0 && !array_filter(grundriss()[1], fn($r) => $r["nr"] === $nr)) {
|
|
throw new InvalidArgumentException("Diesen Raum gibt es nicht (mehr).");
|
|
}
|
|
return $x === null ? null : ["x" => $x, "y" => $y];
|
|
}
|
|
|
|
/**
|
|
* Einen Raum löschen. Seine Geräte bleiben und stehen danach ohne Raum da
|
|
* (fk_actors_room: ON DELETE SET NULL) - die Rückfrage in der Maske nennt,
|
|
* wie viele es sind.
|
|
*/
|
|
function raumLoeschen($nr)
|
|
{
|
|
$nr = intval($nr);
|
|
$stmt = meshDb()->prepare("DELETE FROM rooms WHERE id = ?");
|
|
$stmt->bind_param("i", $nr);
|
|
$stmt->execute();
|
|
$weg = $stmt->affected_rows;
|
|
$stmt->close();
|
|
if ($weg === 0) {
|
|
throw new InvalidArgumentException("Diesen Raum gibt es nicht (mehr).");
|
|
}
|
|
}
|