diff --git a/docker/caspos-mock/server.php b/docker/caspos-mock/server.php
new file mode 100644
index 000000000..d3dcbd604
--- /dev/null
+++ b/docker/caspos-mock/server.php
@@ -0,0 +1,220 @@
+ '7', 'name' => 'Hotel', 'password' => '1234', 'permissions' => array()));
+$serviceAreas = array(
+ array('id' => 'sa-hotel', 'name' => 'Hotel', 'assignWaiter' => false, 'requiredPermission' => null),
+ array('id' => 'sa-rest', 'name' => 'Restaurant', 'assignWaiter' => false, 'requiredPermission' => null),
+);
+$articles = array(
+ array('id' => 'A-UEB-EZ', 'name' => 'Übernachtung EZ', 'plu' => '9001'),
+ array('id' => 'A-UEB-DZ', 'name' => 'Übernachtung DZ', 'plu' => '9002'),
+ array('id' => 'A-UEB-3B', 'name' => 'Übernachtung 3-Bett', 'plu' => '9003'),
+ array('id' => 'A-FRUEH', 'name' => 'Frühstücksbuffet', 'plu' => '9010'),
+ array('id' => 'A-KURTAXE', 'name' => 'Kurbeitrag', 'plu' => '9020'),
+ array('id' => 'A-LOGIS', 'name' => 'Logis offen (Preis)', 'plu' => '9099', 'mustEnterPrice' => true),
+);
+
+function loadState($file)
+{
+ $s = is_file($file) ? json_decode(file_get_contents($file), true) : null;
+ return is_array($s) ? $s : array('clients' => array(), 'sessions' => array(), 'tables' => array(), 'lines' => array(), 'seq' => 1, 'log' => array());
+}
+function saveState($file, $s)
+{
+ file_put_contents($file, json_encode($s, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE), LOCK_EX);
+}
+function out($code, $data = null, $headers = array())
+{
+ http_response_code($code);
+ foreach ($headers as $h) {
+ header($h, false);
+ }
+ if ($data !== null) {
+ header('Content-Type: application/json; charset=utf-8');
+ echo json_encode($data, JSON_UNESCAPED_UNICODE);
+ }
+ exit;
+}
+function cookieVal($name)
+{
+ return isset($_COOKIE[$name]) ? $_COOKIE[$name] : null;
+}
+
+$state = loadState($stateFile);
+$method = $_SERVER['REQUEST_METHOD'];
+$path = parse_url($_SERVER['REQUEST_URI'], PHP_URL_PATH);
+$body = json_decode(file_get_contents('php://input'), true) ?: array();
+$state['log'][] = $method.' '.$path;
+$state['log'] = array_slice($state['log'], -200);
+
+if ($path === '/mock/state') {
+ saveState($stateFile, $state);
+ out(200, $state);
+}
+if ($path === '/mock/reset' && $method === 'POST') {
+ @unlink($stateFile);
+ out(200, array('reset' => true));
+}
+
+$prefix = '/api/v2/';
+if (strpos($path, $prefix) !== 0) {
+ out(404, array('errorMsg' => 'not found'));
+}
+$route = substr($path, strlen($prefix));
+
+// --- registration (no cookie needed) ---
+if ($route === 'actions/init/registerClient' && $method === 'POST') {
+ if (getenv('MOCK_DEVICE_BLOCKED') === '1') {
+ out(403, array('errorMsg' => 'Device not activated'));
+ }
+ if (empty($body['id']) || empty($body['clientName'])) {
+ out(400, array('errorMsg' => 'id and clientName required'));
+ }
+ $token = bin2hex(random_bytes(8));
+ $state['clients'][$token] = array('id' => $body['id'], 'name' => $body['clientName']);
+ saveState($stateFile, $state);
+ out(200, array('utcTime' => time(), 'clientName' => $body['clientName'], 'casposVersion' => '4.12-mock'),
+ array('Set-Cookie: CasposClient='.$token.'; Path=/; HttpOnly'));
+}
+
+$client = cookieVal('CasposClient');
+if (!$client || !isset($state['clients'][$client])) {
+ saveState($stateFile, $state);
+ out(401, array('errorMsg' => 'Client not registered', 'extendedErrorCode' => 1001));
+}
+
+if ($route === 'actions/auth/login' && $method === 'POST') {
+ foreach ($users as $u) {
+ if ($u['id'] === (string)($body['id'] ?? '') && $u['password'] === (string)($body['password'] ?? '')) {
+ $state['sessions'][$client] = $u['id'];
+ saveState($stateFile, $state);
+ out(204);
+ }
+ }
+ saveState($stateFile, $state);
+ out(401, array('errorMsg' => 'Wrong waiter id or password'));
+}
+
+if ($route === 'hostStatus') {
+ out(200, array('online' => true));
+}
+if ($route === 'data/serviceAreas') {
+ out(200, $serviceAreas);
+}
+
+// everything below needs a logged-in waiter
+if (empty($state['sessions'][$client])) {
+ saveState($stateFile, $state);
+ out(401, array('errorMsg' => 'Waiter login required', 'extendedErrorCode' => 1002));
+}
+
+if ($route === 'actions/auth/logout' && $method === 'POST') {
+ unset($state['sessions'][$client]);
+ saveState($stateFile, $state);
+ out(204);
+}
+if ($route === 'data/articles') {
+ out(200, $articles);
+}
+if ($route === 'data/users') {
+ out(200, array_map(function ($u) { unset($u['password']); return $u; }, $users));
+}
+
+if (preg_match('#^actions/tables/openByName/(.+)$#', $route, $m) && $method === 'POST') {
+ $name = urldecode($m[1]);
+ $sa = $_GET['serviceAreaId'] ?? '';
+ if (!in_array($sa, array_column($serviceAreas, 'id'), true)) {
+ out(400, array('errorMsg' => 'Unknown serviceAreaId'));
+ }
+ $tableId = null;
+ foreach ($state['tables'] as $id => $t) {
+ if ($t['name'] === $name && $t['open']) {
+ $tableId = $id;
+ }
+ }
+ if ($tableId === null) {
+ $tableId = 'T-'.$state['seq']++;
+ $state['tables'][$tableId] = array('name' => $name, 'serviceAreaId' => $sa, 'open' => true,
+ 'subTables' => array(array('id' => $tableId.'-1', 'name' => $name.'/1', 'isSelected' => true)));
+ }
+ $t = $state['tables'][$tableId];
+ $booked = 0;
+ foreach ($state['lines'] as $l) {
+ if ($l['tableId'] === $tableId && $l['committed']) {
+ $booked += $l['quantity'] * ($l['enteredPrice'] ?? 0);
+ }
+ }
+ saveState($stateFile, $state);
+ out(200, array('id' => $tableId, 'name' => $t['name'], 'lastActivityTime' => time(), 'bookedAmount' => $booked,
+ 'serviceAreaId' => $t['serviceAreaId'], 'subTables' => $t['subTables'], 'courses' => array()));
+}
+
+if (preg_match('#^actions/orderLines/add/(.+)$#', $route, $m) && $method === 'POST') {
+ $subId = urldecode($m[1]);
+ $tableId = null;
+ foreach ($state['tables'] as $id => $t) {
+ foreach ($t['subTables'] as $st) {
+ if ($st['id'] === $subId) {
+ $tableId = $id;
+ }
+ }
+ }
+ if ($tableId === null) {
+ out(404, array('errorMsg' => 'Unknown subTable'));
+ }
+ $art = null;
+ foreach ($articles as $a) {
+ if ($a['id'] === ($body['articleId'] ?? '')) {
+ $art = $a;
+ }
+ }
+ if ($art === null) {
+ out(400, array('errorMsg' => 'Unknown articleId'));
+ }
+ if (!empty($art['mustEnterPrice']) && !isset($body['enteredPrice'])) {
+ out(400, array('errorMsg' => 'Article needs enteredPrice'));
+ }
+ $lineId = 'L-'.$state['seq']++;
+ $state['lines'][] = array('id' => $lineId, 'tableId' => $tableId, 'subTableId' => $subId, 'articleId' => $art['id'],
+ 'quantity' => (int)($body['quantity'] ?? 1), 'enteredPrice' => $body['enteredPrice'] ?? null,
+ 'enteredText' => $body['enteredText'] ?? null, 'committed' => false);
+ saveState($stateFile, $state);
+ out(200, array('id' => $lineId, 'singlePrice' => $body['enteredPrice'] ?? 0, 'name' => $art['name'],
+ 'receiptName' => $art['name'], 'modifiers' => array(), 'courseId' => null));
+}
+
+if (preg_match('#^actions/tables/(finalizeOrder|cancelOrder)/(.+)$#', $route, $m) && $method === 'POST') {
+ $tableId = urldecode($m[2]);
+ if (!isset($state['tables'][$tableId])) {
+ out(404, array('errorMsg' => 'Unknown table'));
+ }
+ foreach ($state['lines'] as $i => $l) {
+ if ($l['tableId'] === $tableId && !$l['committed']) {
+ if ($m[1] === 'finalizeOrder') {
+ $state['lines'][$i]['committed'] = true;
+ } else {
+ unset($state['lines'][$i]);
+ }
+ }
+ }
+ $state['lines'] = array_values($state['lines']);
+ saveState($stateFile, $state);
+ out(204);
+}
+
+saveState($stateFile, $state);
+out(404, array('errorMsg' => 'Route not implemented in mock: '.$method.' '.$route));
diff --git a/docker/test-casposbridge.php b/docker/test-casposbridge.php
new file mode 100644
index 000000000..b54f29be8
--- /dev/null
+++ b/docker/test-casposbridge.php
@@ -0,0 +1,167 @@
+ array('method' => $method, 'ignore_errors' => true)));
+ return json_decode((string)@file_get_contents($mock.$path, false, $ctx), true);
+}
+function committed($state, $tableName)
+{
+ $ids = array();
+ foreach ($state['tables'] as $id => $t) {
+ if ($t['name'] === $tableName) {
+ $ids[] = $id;
+ }
+ }
+ return array_values(array_filter($state['lines'], function ($l) use ($ids) {
+ return in_array($l['tableId'], $ids, true) && $l['committed'];
+ }));
+}
+
+echo "Setup\n";
+check(is_array(mock('/mock/reset', 'POST')), 'mock reachable and reset');
+$module = Module::getInstanceByName('casposbridge');
+if (!Module::isInstalled('casposbridge')) {
+ check($module->install(), 'module installed');
+} else {
+ check(true, 'module already installed');
+}
+$backup = array();
+foreach (CasposBridge::CONFIG_KEYS as $k) {
+ $backup[$k] = Configuration::get($k);
+}
+Configuration::updateValue('CASPOSBRIDGE_ENABLED', 1);
+Configuration::updateValue('CASPOSBRIDGE_HOST', '127.0.0.1');
+Configuration::updateValue('CASPOSBRIDGE_PORT', '8088');
+Configuration::updateValue('CASPOSBRIDGE_FINGERPRINT', '');
+Configuration::updateValue('CASPOSBRIDGE_WAITER_ID', '7');
+Configuration::updateValue('CASPOSBRIDGE_WAITER_PASSWORD', '1234');
+Configuration::updateValue('CASPOSBRIDGE_SERVICE_AREA', 'sa-hotel');
+Configuration::updateValue('CASPOSBRIDGE_TABLE_TEMPLATE', '{room}');
+Configuration::updateValue('CASPOSBRIDGE_MAPPING', json_encode(array(2 => 'A-UEB-DZ, A-FRUEH*erwachsene', 4 => 'A-LOGIS@preis, A-FRUEH*gaeste')));
+
+// throw-away booking: Doppelzimmer, 2 adults, arrived yesterday, 3 nights
+$today = date('Y-m-d');
+$from = date('Y-m-d', strtotime('-1 day'));
+$to = date('Y-m-d', strtotime('+2 day'));
+$db = Db::getInstance();
+$db->insert('htl_booking_detail', array(
+ 'id_product' => 2, 'id_order' => 999001, 'id_order_detail' => 0, 'id_cart' => 0, 'id_room' => 6, 'id_hotel' => 1,
+ 'id_customer' => 0, 'booking_type' => 1, 'id_status' => HotelBookingDetail::STATUS_ASSIGNED,
+ 'check_in' => '0000-00-00 00:00:00', 'check_out' => '0000-00-00 00:00:00', 'planned_check_out' => $to.' 11:00:00',
+ 'date_from' => $from.' 00:00:00', 'date_to' => $to.' 00:00:00',
+ 'total_price_tax_excl' => 406.54, 'total_price_tax_incl' => 435, 'total_paid_amount' => 0, 'is_back_order' => 0,
+ 'hotel_name' => 'Test', 'room_type_name' => 'Doppelzimmer', 'room_num' => 'T-1001', 'adults' => 2, 'children' => 0,
+ 'city' => 'Annweiler', 'country' => 'Deutschland', 'zipcode' => '76855', 'phone' => '0000', 'email' => 'test@hotel.test',
+ 'check_in_time' => '15:00', 'check_out_time' => '11:00', 'id_customer' => 1, 'id_cart' => 1, 'id_order_detail' => 1,
+ 'date_add' => date('Y-m-d H:i:s'), 'date_upd' => date('Y-m-d H:i:s'),
+));
+$idBooking = (int)$db->Insert_ID();
+check($idBooking > 0, 'test booking created (id '.$idBooking.')');
+
+try {
+ echo "Check-in posts the nights up to today\n";
+ $b = new HotelBookingDetail($idBooking);
+ check($b->changeStatus(HotelBookingDetail::STATUS_CHECKED_IN), 'check-in status change ok');
+ $jobs = $db->executeS('SELECT * FROM `'._DB_PREFIX_.'casposbridge_job` WHERE id_htl_booking = '.$idBooking.' ORDER BY night');
+ check(count($jobs) === 2, '2 jobs (yesterday + today), got '.count($jobs));
+ check(count(array_filter($jobs, function ($j) { return $j['status'] === 'done'; })) === 2, 'both jobs done');
+ $lines = committed(mock('/mock/state'), 'T-1001');
+ check(count($lines) === 4, '4 committed lines on table T-1001, got '.count($lines));
+ $byArt = array();
+ foreach ($lines as $l) {
+ $byArt[$l['articleId']] = (isset($byArt[$l['articleId']]) ? $byArt[$l['articleId']] : 0) + $l['quantity'];
+ }
+ check(isset($byArt['A-UEB-DZ']) && $byArt['A-UEB-DZ'] === 2, '2 x Übernachtung DZ');
+ check(isset($byArt['A-FRUEH']) && $byArt['A-FRUEH'] === 4, '4 x Frühstück (2 adults x 2 nights)');
+ check(strpos($lines[0]['enteredText'], 'Buchung #999001') !== false, 'line text names the booking');
+
+ echo "Cron run is idempotent\n";
+ $service = new CasposBridgeService();
+ $service->ensureJobsForCheckedInGuests();
+ $res = $service->processJobs();
+ check(count(committed(mock('/mock/state'), 'T-1001')) === 4, 'no duplicate lines after cron');
+
+ echo "Next day: one more night\n";
+ check($service->ensureJobsForBooking($idBooking, date('Y-m-d', strtotime('+1 day'))) === 1, '1 new job for tomorrow');
+ $service->processJobs($idBooking);
+ check(count(committed(mock('/mock/state'), 'T-1001')) === 6, '6 lines after the 3rd night');
+ check($service->ensureJobsForBooking($idBooking, date('Y-m-d', strtotime('+5 day'))) === 0, 'no jobs beyond departure');
+
+ echo "POS offline: job stays queued\n";
+ Configuration::updateValue('CASPOSBRIDGE_PORT', '8099');
+ $db->update('casposbridge_job', array('status' => 'pending', 'caspos_table_id' => null, 'lines_json' => null),
+ 'id_htl_booking = '.$idBooking.' AND night = \''.pSQL($to).'\'');
+ $db->insert('casposbridge_job', array('id_htl_booking' => $idBooking, 'id_order' => 999001, 'night' => date('Y-m-d', strtotime('+3 day')),
+ 'table_name' => 'T-1001', 'status' => 'pending', 'date_add' => date('Y-m-d H:i:s'), 'date_upd' => date('Y-m-d H:i:s')));
+ $res = (new CasposBridgeService())->processJobs($idBooking);
+ check($res['retry'] === 1 && $res['done'] === 0, 'offline -> retry later, nothing lost');
+ $j = $db->getRow('SELECT * FROM `'._DB_PREFIX_.'casposbridge_job` WHERE id_htl_booking = '.$idBooking.' AND status = \'pending\'');
+ check($j && (int)$j['attempts'] === 1 && strpos($j['last_error'], 'nicht erreichbar') !== false, 'error recorded: '.($j ? $j['last_error'] : '-'));
+ Configuration::updateValue('CASPOSBRIDGE_PORT', '8088');
+ (new CasposBridgeService())->processJobs($idBooking);
+ check(count(committed(mock('/mock/state'), 'T-1001')) === 8, 'posted after the POS is back');
+
+ echo "Unknown article: job fails permanently, retry button resets it\n";
+ Configuration::updateValue('CASPOSBRIDGE_MAPPING', json_encode(array(2 => 'A-GIBTSNICHT')));
+ $db->insert('casposbridge_job', array('id_htl_booking' => $idBooking, 'id_order' => 999001, 'night' => date('Y-m-d', strtotime('+4 day')),
+ 'table_name' => 'T-1001', 'status' => 'pending', 'date_add' => date('Y-m-d H:i:s'), 'date_upd' => date('Y-m-d H:i:s')));
+ $res = (new CasposBridgeService())->processJobs($idBooking);
+ $j = $db->getRow('SELECT * FROM `'._DB_PREFIX_.'casposbridge_job` WHERE id_htl_booking = '.$idBooking.' AND status = \'failed\'');
+ check($res['failed'] === 1 && $j && strpos($j['last_error'], 'Unknown articleId') !== false, 'failed with POS message: '.($j ? $j['last_error'] : '-'));
+ $state = mock('/mock/state');
+ check(count(array_filter($state['lines'], function ($l) { return !$l['committed']; })) === 0, 'no half-booked lines left on the table');
+ CasposBridgeService::retryJob((int)$j['id_job']);
+ check($db->getValue('SELECT status FROM `'._DB_PREFIX_.'casposbridge_job` WHERE id_job = '.(int)$j['id_job']) === 'pending', 'retry resets to pending');
+
+ echo "Open-price article sends the nightly room price\n";
+ Configuration::updateValue('CASPOSBRIDGE_MAPPING', json_encode(array(2 => 'A-UEB-DZ, A-FRUEH*erwachsene', 4 => 'A-LOGIS@preis, A-FRUEH*gaeste')));
+ $row = $db->getRow('SELECT * FROM `'._DB_PREFIX_.'htl_booking_detail` WHERE id = '.$idBooking);
+ $lines = (new CasposBridgeService())->buildLines(array_merge($row, array('id_product' => 4, 'room_type_name' => 'Dreibett', 'adults' => 2, 'children' => 1)), $today);
+ check($lines[0]['article'] === 'A-LOGIS' && $lines[0]['enteredPrice'] === 14500, 'A-LOGIS with 145,00 € (14500 cents)');
+ check($lines[1]['quantity'] === 3, 'Frühstück x gaeste = 3');
+
+ echo "Undo check-in drops unsent nights\n";
+ $b = new HotelBookingDetail($idBooking);
+ $b->changeStatus(HotelBookingDetail::STATUS_ASSIGNED);
+ check((int)$db->getValue('SELECT COUNT(*) FROM `'._DB_PREFIX_.'casposbridge_job` WHERE id_htl_booking = '.$idBooking.' AND status IN (\'pending\',\'failed\')') === 0, 'pending/failed jobs removed');
+} catch (Throwable $e) {
+ check(false, 'unexpected '.get_class($e).': '.$e->getMessage().' @ '.basename($e->getFile()).':'.$e->getLine());
+} finally {
+ $db->delete('casposbridge_job', 'id_htl_booking = '.$idBooking);
+ $db->delete('htl_booking_status_history', 'id_htl_booking = '.$idBooking);
+ $db->delete('htl_booking_detail', 'id = '.$idBooking);
+ if (!in_array('--keep', $argv, true)) {
+ foreach ($backup as $k => $v) {
+ Configuration::updateValue($k, $v);
+ }
+ }
+}
+
+echo $fail ? "\n$fail check(s) FAILED\n" : "\nAll checks passed\n";
+exit($fail ? 1 : 0);
diff --git a/modules/casposbridge/README.md b/modules/casposbridge/README.md
new file mode 100644
index 000000000..39156c9b4
--- /dev/null
+++ b/modules/casposbridge/README.md
@@ -0,0 +1,82 @@
+# CASPOS Kassenanbindung (casposbridge)
+
+Bucht die Übernachtungen eingecheckter Gäste auf den CASPOS-Tisch mit der Zimmernummer
+(Zimmer 1001 → Tisch 1001). Abrechnung, Beleg und TSE bleiben in CASPOS; QloApps erstellt dafür
+keine eigenen Rechnungen.
+
+```
+Check-in in QloApps ──> Auftrag je Nacht ──> OrderApp-API der Kasse
+ registerClient → Kellner-Login
+ → tables/openByName/1001
+ → orderLines/add (Übernachtung, Frühstück …)
+ → tables/finalizeOrder
+Check-out ──> Gast zahlt an der Kasse wie gewohnt
+```
+
+## Ablauf
+
+- **Check-in** (Buchungsstatus → „eingecheckt“): Aufträge für alle Nächte von der Anreise bis heute
+ werden angelegt und sofort gesendet. Ein Fehler an der Kasse blockiert den Check-in nie.
+- **Jede Stunde** (Cron): für alle eingecheckten Gäste wird die jeweils neue Nacht gebucht; offene oder
+ fehlgeschlagene Sendungen werden wiederholt (max. 10 Versuche).
+- **Keine Doppelbuchung:** pro Zimmer-Buchung und Nacht gibt es genau einen Auftrag. Stehen die
+ Positionen schon auf dem Tisch und nur der Abschluss fehlt, wird beim nächsten Versuch nur abgeschlossen.
+ Schlägt das Hinzufügen mittendrin fehl, werden die halben Positionen per `cancelOrder` verworfen.
+- **Check-in zurückgenommen:** noch nicht gesendete Nächte werden gelöscht; bereits gebuchte müssen an der
+ Kasse storniert werden (Hinweis im QloApps-Log).
+
+## Einrichtung
+
+1. In CASPOS: Zimmer als Tische im eigenen Tischbereich anlegen (Name = Zimmernummer), Artikel für
+ Übernachtung/Frühstück/Kurtaxe anlegen, einen Kellner „Hotel“ mit Passwort anlegen, OrderApp-API aktivieren.
+2. In QloApps: Zimmernummern (Hotel Reservation System → Zimmertypen → Zimmer) auf die Tischnamen setzen.
+3. *Module → CASPOS Kassenanbindung → Konfigurieren*: IP und API-Port der Kasse (wie in der OrderApp),
+ bei HTTPS den SHA1-Fingerabdruck, Kellner-Nummer und Passwort.
+4. **Verbindung testen** – zeigt Tischbereiche und Artikel mit IDs. Meldet die Kasse „Gerät nicht
+ freigeschaltet“, das Gerät (Name/ID wie im Formular) in der OrderApp-Verwaltung der Kasse freigeben.
+5. Tischbereich-ID eintragen und je Zimmertyp die Artikel pro Nacht:
+
+ | Eingabe | Bedeutung |
+ |---|---|
+ | `A-UEB-DZ` | 1 × Artikel pro Nacht |
+ | `A-FRUEH*erwachsene` | Menge = Erwachsene (auch `kinder`, `gaeste` oder eine Zahl) |
+ | `A-LOGIS@preis` | Artikel mit Preiseingabe, Preis = Zimmerpreis pro Nacht (brutto) |
+ | `A-UEB-DZ, A-FRUEH*erwachsene` | mehrere Artikel mit Komma |
+
+ Für die Mehrwertsteuer (Übernachtung 7 %, Frühstück 19 %) getrennte CASPOS-Artikel verwenden.
+6. **Anbindung aktiv** einschalten.
+
+## Zeitsteuerung
+
+- Mit dem Modul *QloApps Cron Task Manager* läuft die Aufgabe `casposbridge_post_nights` stündlich.
+- Ohne Cron (z. B. Windows Server, Docker): Windows-Aufgabenplanung stündlich ausführen lassen
+ (im Repo-Ordner):
+
+ ```powershell
+ docker compose -f docker-compose.yml -f docker-compose.server.yml exec -T -u www-data app php modules/casposbridge/cron.php
+ ```
+
+ Rückgabewert 0 = alles gebucht, 2 = etwas wartet noch oder ist fehlgeschlagen.
+
+Die Seite *Konfigurieren* zeigt die letzten 50 Aufträge mit Status, Positionen und Fehlermeldung der Kasse;
+fehlgeschlagene lassen sich dort erneut senden. Mit **Jetzt buchen** läuft der Abgleich sofort.
+
+## Entwickeln und testen ohne Kasse
+
+`docker/caspos-mock/server.php` bildet die benötigten OrderApp-API-Aufrufe nach (inkl. Fehlercodes 403,
+401/1001, 401/1002). `docker/test-casposbridge.php` legt eine Testbuchung an, checkt ein und prüft
+Buchung, Wiederholung, Ausfall der Kasse, unbekannte Artikel und das Zurücknehmen des Check-ins:
+
+```bash
+docker compose -f docker-compose.yml -f docker-compose.server.yml exec -d -u www-data app php -S 127.0.0.1:8088 docker/caspos-mock/server.php
+docker compose -f docker-compose.yml -f docker-compose.server.yml exec -u www-data app php docker/test-casposbridge.php
+```
+
+## Gegen eine echte Kasse noch zu prüfen
+
+Die API ist aus dem Quellcode der CASPOS OrderApp (`lib/api/api.dart`) abgeleitet, nicht aus einer Doku:
+
+- Einheit von `enteredPrice` (Annahme: Cent) – nur relevant für `@preis`.
+- Legt `openByName` unbekannte Tische an oder müssen sie existieren?
+- Muss der Client einmalig in der Kasse freigeschaltet werden?
+- Verhalten von `finalizeOrder` gegenüber TSE und Bonausgabe (Bestellbon für Zimmer-Tische ggf. abschalten).
diff --git a/modules/casposbridge/casposbridge.php b/modules/casposbridge/casposbridge.php
new file mode 100644
index 000000000..32f551165
--- /dev/null
+++ b/modules/casposbridge/casposbridge.php
@@ -0,0 +1,327 @@
+name = 'casposbridge';
+ $this->tab = 'administration';
+ $this->version = '1.0.0';
+ $this->author = 'Zum alten Wasserrad';
+ $this->need_instance = 0;
+ $this->bootstrap = true;
+ parent::__construct();
+
+ $this->displayName = $this->l('CASPOS Kassenanbindung');
+ $this->description = $this->l('Bucht beim Check-in und jede Nacht die Übernachtung auf den CASPOS-Tisch mit der Zimmernummer (OrderApp-API).');
+ $this->ps_versions_compliancy = array('min' => '1.6', 'max' => '1.6.99.99');
+ }
+
+ public function install()
+ {
+ return parent::install()
+ && CasposBridgeService::installDb()
+ && $this->registerHook('actionBookingStatusAfter')
+ && $this->registerHook('registerCronTasks')
+ && Configuration::updateValue('CASPOSBRIDGE_ENABLED', 0)
+ && Configuration::updateValue('CASPOSBRIDGE_PORT', '')
+ && Configuration::updateValue('CASPOSBRIDGE_CLIENT_ID', self::uuid())
+ && Configuration::updateValue('CASPOSBRIDGE_CLIENT_NAME', 'Hotel QloApps')
+ && Configuration::updateValue('CASPOSBRIDGE_TABLE_TEMPLATE', '{room}')
+ && Configuration::updateValue('CASPOSBRIDGE_MAPPING', '{}')
+ && Configuration::updateValue('CASPOSBRIDGE_CRON_TOKEN', Tools::passwdGen(32));
+ }
+
+ public function uninstall()
+ {
+ foreach (self::CONFIG_KEYS as $k) {
+ Configuration::deleteByName($k);
+ }
+ return CasposBridgeService::uninstallDb() && parent::uninstall();
+ }
+
+ protected static function uuid()
+ {
+ $b = random_bytes(16);
+ $b[6] = chr((ord($b[6]) & 0x0f) | 0x40);
+ $b[8] = chr((ord($b[8]) & 0x3f) | 0x80);
+ return vsprintf('%s%s-%s-%s-%s-%s%s%s', str_split(bin2hex($b), 4));
+ }
+
+ // ------------------------------------------------------------------ hooks
+
+ /** Check-in: create the first night's job and post it right away. Never blocks the check-in. */
+ public function hookActionBookingStatusAfter($params)
+ {
+ if (!CasposBridgeService::isEnabled() || empty($params['object'])) {
+ return;
+ }
+ $to = (int)$params['id_status_to'];
+ if ((int)$params['id_status_from'] === HotelBookingDetail::STATUS_CHECKED_IN
+ && in_array($to, array(HotelBookingDetail::STATUS_ASSIGNED, HotelBookingDetail::STATUS_NO_SHOW, HotelBookingDetail::STATUS_CANCELLED), true)) {
+ // check-in undone: drop nights not sent yet; nights already on the table must be voided at the POS
+ $posted = CasposBridgeService::dropOpenJobs((int)$params['object']->id);
+ if ($posted) {
+ PrestaShopLogger::addLog(sprintf('CASPOS: Check-in zurückgenommen, %d Nacht/Nächte sind schon auf Tisch %s gebucht und müssen an der Kasse storniert werden.',
+ $posted, $params['object']->room_num), 2, null, 'HotelBookingDetail', (int)$params['object']->id, true);
+ }
+ return;
+ }
+ if ($to !== HotelBookingDetail::STATUS_CHECKED_IN) {
+ return;
+ }
+ try {
+ $service = new CasposBridgeService();
+ $service->ensureJobsForBooking((int)$params['object']->id);
+ $res = $service->processJobs((int)$params['object']->id);
+ if ($res['errors']) {
+ PrestaShopLogger::addLog('CASPOS: '.implode(' | ', $res['errors']), 2, null, 'HotelBookingDetail', (int)$params['object']->id, true);
+ }
+ } catch (Exception $e) {
+ PrestaShopLogger::addLog('CASPOS: '.$e->getMessage(), 3, null, 'HotelBookingDetail', (int)$params['object']->id, true);
+ }
+ }
+
+ /** Picked up by the QloApps Cron Task Manager (qlocrontaskmanager). */
+ public function hookRegisterCronTasks()
+ {
+ return array(array(
+ 'name' => 'casposbridge_post_nights',
+ 'description' => $this->l('Übernachtungen eingecheckter Gäste an die CASPOS-Kasse buchen'),
+ 'cron' => '15 * * * *',
+ 'callback' => 'cronPostNights',
+ ));
+ }
+
+ public function cronPostNights()
+ {
+ if (!CasposBridgeService::isEnabled()) {
+ return true;
+ }
+ $service = new CasposBridgeService();
+ $service->ensureJobsForCheckedInGuests();
+ $res = $service->processJobs();
+ if ($res['errors']) {
+ PrestaShopLogger::addLog('CASPOS: '.implode(' | ', array_slice($res['errors'], 0, 5)), 2, null, 'casposbridge', 0, true);
+ }
+ return $res;
+ }
+
+ // ------------------------------------------------------------------ configuration page
+
+ public function getContent()
+ {
+ $html = '';
+ $action = Tools::isSubmit('casposTest') ? 'test' : (Tools::isSubmit('casposRun') ? 'run' : null);
+ if (Tools::isSubmit('submitCasposBridge')) {
+ // HelperForm posts the save flag with every button: save first, then run the requested action
+ $saved = $this->saveSettings();
+ if (!$action || strpos($saved, 'alert-danger') !== false) {
+ return $saved.$this->renderForm().$this->renderJobs();
+ }
+ }
+ if ($action === 'test') {
+ $html .= $this->connectionTest();
+ } elseif ($action === 'run') {
+ $res = $this->cronPostNights();
+ $html .= is_array($res)
+ ? $this->displayConfirmation(sprintf($this->l('Gebucht: %d, erneut versuchen: %d, fehlgeschlagen: %d'), $res['done'], $res['retry'], $res['failed']))
+ .($res['errors'] ? $this->displayWarning(implode('
', array_map('htmlspecialchars', $res['errors']))) : '')
+ : $this->displayWarning($this->l('Die Anbindung ist ausgeschaltet.'));
+ } elseif (Tools::getValue('casposRetry')) {
+ CasposBridgeService::retryJob((int)Tools::getValue('casposRetry'));
+ $html .= $this->displayConfirmation($this->l('Auftrag wird beim nächsten Lauf erneut gesendet.'));
+ }
+ return $html.$this->renderForm().$this->renderJobs();
+ }
+
+ protected function roomTypes()
+ {
+ return Db::getInstance()->executeS('SELECT p.`id_product`, pl.`name` FROM `'._DB_PREFIX_.'product` p
+ JOIN `'._DB_PREFIX_.'product_lang` pl ON pl.`id_product` = p.`id_product` AND pl.`id_lang` = '.(int)$this->context->language->id.'
+ WHERE p.`booking_product` = 1 ORDER BY p.`id_product`');
+ }
+
+ protected function saveSettings()
+ {
+ $errors = array();
+ $port = trim(Tools::getValue('CASPOSBRIDGE_PORT'));
+ if ($port !== '' && (!ctype_digit($port) || (int)$port > 65535)) {
+ $errors[] = $this->l('Port muss eine Zahl zwischen 1 und 65535 sein.');
+ }
+ $fp = trim(Tools::getValue('CASPOSBRIDGE_FINGERPRINT'));
+ if ($fp !== '' && !CasposApiClient::normalizeFingerprint($fp)) {
+ $errors[] = $this->l('Fingerabdruck muss ein SHA1-Wert sein (40 Hex-Zeichen, z. B. AB:CD:…).');
+ }
+ $mapping = array();
+ foreach ((array)$this->roomTypes() as $rt) {
+ $rule = trim(Tools::getValue('CASPOSBRIDGE_MAP_'.(int)$rt['id_product']));
+ if ($rule === '') {
+ continue;
+ }
+ try {
+ CasposBridgeService::parseRule($rule);
+ $mapping[(int)$rt['id_product']] = $rule;
+ } catch (InvalidArgumentException $e) {
+ $errors[] = $rt['name'].': '.$e->getMessage();
+ }
+ }
+ if ($errors) {
+ return $this->displayError(implode('
', array_map('htmlspecialchars', $errors)));
+ }
+
+ Configuration::updateValue('CASPOSBRIDGE_ENABLED', (int)Tools::getValue('CASPOSBRIDGE_ENABLED'));
+ Configuration::updateValue('CASPOSBRIDGE_HOST', trim(Tools::getValue('CASPOSBRIDGE_HOST')));
+ Configuration::updateValue('CASPOSBRIDGE_PORT', $port);
+ Configuration::updateValue('CASPOSBRIDGE_FINGERPRINT', CasposApiClient::normalizeFingerprint($fp));
+ Configuration::updateValue('CASPOSBRIDGE_CLIENT_NAME', trim(Tools::getValue('CASPOSBRIDGE_CLIENT_NAME')) ?: 'Hotel QloApps');
+ Configuration::updateValue('CASPOSBRIDGE_WAITER_ID', trim(Tools::getValue('CASPOSBRIDGE_WAITER_ID')));
+ if (Tools::getValue('CASPOSBRIDGE_WAITER_PASSWORD') !== '') {
+ Configuration::updateValue('CASPOSBRIDGE_WAITER_PASSWORD', Tools::getValue('CASPOSBRIDGE_WAITER_PASSWORD'));
+ }
+ Configuration::updateValue('CASPOSBRIDGE_SERVICE_AREA', trim(Tools::getValue('CASPOSBRIDGE_SERVICE_AREA')));
+ Configuration::updateValue('CASPOSBRIDGE_TABLE_TEMPLATE', trim(Tools::getValue('CASPOSBRIDGE_TABLE_TEMPLATE')) ?: '{room}');
+ Configuration::updateValue('CASPOSBRIDGE_MAPPING', json_encode($mapping, JSON_UNESCAPED_UNICODE));
+ return $this->displayConfirmation($this->l('Einstellungen gespeichert.'));
+ }
+
+ protected function connectionTest()
+ {
+ try {
+ $client = new CasposApiClient(CasposBridgeService::clientConfig());
+ $client->startSession();
+ $areas = $client->getServiceAreas();
+ $articles = $client->getArticles();
+ $client->endSession();
+ } catch (Exception $e) {
+ return $this->displayError(htmlspecialchars($e->getMessage()));
+ }
+ $rows = function ($list, $cols) {
+ $h = '
| '.htmlspecialchars($c).' | '; + } + $h .= '
|---|
| '.htmlspecialchars(isset($item[$k]) ? (is_bool($item[$k]) ? ($item[$k] ? 'ja' : '') : (string)$item[$k]) : '').' | '; + } + $h .= '
'.sprintf($this->l('Zeitgesteuert über den QloApps Cron Task Manager oder per Kommandozeile: %s'),
+ 'php modules/casposbridge/cron.php').'
| '.$this->l('Nacht').' | '.$this->l('Tisch').' | '.$this->l('Buchung').' | '.$this->l('Status').' | '.$this->l('Versuche').' | '.$this->l('Positionen').' | '.$this->l('Fehler').' | |
|---|---|---|---|---|---|---|---|
| '.htmlspecialchars(date('d.m.Y', strtotime($j['night']))).' | '.htmlspecialchars($j['table_name']).' | ' + .'#'.(int)$j['id_order'].' | '.htmlspecialchars(isset($statusLabel[$j['status']]) ? $statusLabel[$j['status']] : $j['status']).' | ' + .''.(int)$j['attempts'].' | '.$linesTxt.' | '.htmlspecialchars((string)$j['last_error']).' | ' + .($j['status'] === CasposBridgeService::STATUS_FAILED ? ''.$this->l('Erneut senden').'' : '') + .' |