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 = ''; + foreach ($cols as $c) { + $h .= ''; + } + $h .= ''; + foreach ($list as $item) { + $h .= ''; + foreach (array_keys($cols) as $k) { + $h .= ''; + } + $h .= ''; + } + return $h.'
'.htmlspecialchars($c).'
'.htmlspecialchars(isset($item[$k]) ? (is_bool($item[$k]) ? ($item[$k] ? 'ja' : '') : (string)$item[$k]) : '').'
'; + }; + return $this->displayConfirmation($this->l('Verbindung zur Kasse und Kellner-Anmeldung erfolgreich.')) + .'

'.$this->l('Tischbereiche (Service Areas)').'

' + .$rows($areas, array('id' => 'ID', 'name' => $this->l('Name'))) + .'

'.$this->l('Artikel').'

' + .$rows($articles, array('id' => 'ID', 'plu' => 'PLU', 'name' => $this->l('Name'), 'mustEnterPrice' => $this->l('Preis eingeben'))) + .'
'; + } + + protected function renderForm() + { + $mapInputs = array(); + $mapping = CasposBridgeService::getMapping(); + $values = array(); + foreach ((array)$this->roomTypes() as $rt) { + $key = 'CASPOSBRIDGE_MAP_'.(int)$rt['id_product']; + $mapInputs[] = array('type' => 'text', 'name' => $key, 'label' => $rt['name']); + $values[$key] = isset($mapping[(int)$rt['id_product']]) ? $mapping[(int)$rt['id_product']] : ''; + } + + $fields = array( + array('form' => array( + 'legend' => array('title' => $this->l('Verbindung zur CASPOS-Kasse'), 'icon' => 'icon-link'), + 'input' => array( + array('type' => 'switch', 'name' => 'CASPOSBRIDGE_ENABLED', 'label' => $this->l('Anbindung aktiv'), 'is_bool' => true, + 'values' => array(array('id' => 'on', 'value' => 1, 'label' => $this->l('Ja')), array('id' => 'off', 'value' => 0, 'label' => $this->l('Nein')))), + array('type' => 'text', 'name' => 'CASPOSBRIDGE_HOST', 'label' => $this->l('Kasse: IP oder Hostname'), 'desc' => $this->l('Wie in der OrderApp, z. B. 192.168.178.30')), + array('type' => 'text', 'name' => 'CASPOSBRIDGE_PORT', 'label' => $this->l('API-Port'), 'class' => 'fixed-width-sm'), + array('type' => 'text', 'name' => 'CASPOSBRIDGE_FINGERPRINT', 'label' => $this->l('Zertifikats-Fingerabdruck (SHA1)'), + 'desc' => $this->l('Leer = HTTP. Mit Fingerabdruck = HTTPS, das Zertifikat der Kasse muss genau passen (wie in der OrderApp).')), + array('type' => 'text', 'name' => 'CASPOSBRIDGE_CLIENT_NAME', 'label' => $this->l('Gerätename an der Kasse')), + array('type' => 'text', 'name' => 'CASPOSBRIDGE_CLIENT_ID', 'label' => $this->l('Geräte-ID'), 'readonly' => true, + 'desc' => $this->l('Wird beim ersten Kontakt an der Kasse registriert; ggf. in der OrderApp-Verwaltung der Kasse freischalten.')), + array('type' => 'text', 'name' => 'CASPOSBRIDGE_WAITER_ID', 'label' => $this->l('Kellner-Nummer'), 'class' => 'fixed-width-sm'), + array('type' => 'password', 'name' => 'CASPOSBRIDGE_WAITER_PASSWORD', 'label' => $this->l('Kellner-Passwort'), + 'desc' => $this->l('Leer lassen, um das gespeicherte Passwort zu behalten.')), + array('type' => 'text', 'name' => 'CASPOSBRIDGE_SERVICE_AREA', 'label' => $this->l('Tischbereich-ID'), + 'desc' => $this->l('ID des Bereichs mit den Zimmer-Tischen – „Verbindung testen“ zeigt alle IDs.')), + array('type' => 'text', 'name' => 'CASPOSBRIDGE_TABLE_TEMPLATE', 'label' => $this->l('Tischname'), 'class' => 'fixed-width-md', + 'desc' => $this->l('{room} = Zimmernummer aus QloApps, z. B. 1001')), + ), + 'submit' => array('title' => $this->l('Speichern'), 'name' => 'submitCasposBridge'), + 'buttons' => array( + array('title' => $this->l('Verbindung testen'), 'name' => 'casposTest', 'type' => 'submit', 'icon' => 'process-icon-refresh', 'class' => 'pull-right'), + array('title' => $this->l('Jetzt buchen'), 'name' => 'casposRun', 'type' => 'submit', 'icon' => 'process-icon-upload', 'class' => 'pull-right'), + ), + )), + array('form' => array( + 'legend' => array('title' => $this->l('Artikel pro Nacht je Zimmertyp'), 'icon' => 'icon-list'), + 'description' => $this->l('Format: ARTIKEL-ID[*MENGE][@preis], mehrere mit Komma. MENGE = Zahl, erwachsene, kinder oder gaeste. @preis sendet den Zimmerpreis pro Nacht (für Artikel mit Preiseingabe). Beispiel: A-UEB-DZ, A-FRUEH*erwachsene'), + 'input' => $mapInputs, + 'submit' => array('title' => $this->l('Speichern'), 'name' => 'submitCasposBridge'), + )), + ); + + foreach (self::CONFIG_KEYS as $k) { + $values[$k] = $k === 'CASPOSBRIDGE_WAITER_PASSWORD' ? '' : Configuration::get($k); + } + + $helper = new HelperForm(); + $helper->module = $this; + $helper->name_controller = $this->name; + $helper->token = Tools::getAdminTokenLite('AdminModules'); + $helper->currentIndex = AdminController::$currentIndex.'&configure='.$this->name; + $helper->default_form_language = (int)Configuration::get('PS_LANG_DEFAULT'); + $helper->show_toolbar = false; + $helper->submit_action = 'submitCasposBridge'; + $helper->tpl_vars = array('fields_value' => $values); + return $helper->generateForm($fields); + } + + protected function renderJobs() + { + $statusLabel = array( + CasposBridgeService::STATUS_PENDING => $this->l('wartet'), + CasposBridgeService::STATUS_ADDED => $this->l('auf Tisch, Abschluss offen'), + CasposBridgeService::STATUS_DONE => $this->l('gebucht'), + CasposBridgeService::STATUS_FAILED => $this->l('fehlgeschlagen'), + ); + $base = AdminController::$currentIndex.'&configure='.$this->name.'&token='.Tools::getAdminTokenLite('AdminModules'); + $html = '

'.$this->l('Letzte Buchungen an die Kasse').'

'; + $html .= '

'.sprintf($this->l('Zeitgesteuert über den QloApps Cron Task Manager oder per Kommandozeile: %s'), + 'php modules/casposbridge/cron.php').'

'; + $html .= ''; + foreach ((array)CasposBridgeService::latestJobs() as $j) { + $lines = json_decode((string)$j['lines_json'], true); + $linesTxt = ''; + foreach ((array)$lines as $l) { + $linesTxt .= htmlspecialchars($l['quantity'].' × '.$l['article'].($l['enteredPrice'] !== null ? ' ('.number_format($l['enteredPrice'] / 100, 2, ',', '.').' €)' : '')).'
'; + } + $html .= '' + .'' + .''; + } + return $html.'
'.$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').'' : '') + .'
'; + } +} diff --git a/modules/casposbridge/classes/CasposApiClient.php b/modules/casposbridge/classes/CasposApiClient.php new file mode 100644 index 000000000..4cb6e1b43 --- /dev/null +++ b/modules/casposbridge/classes/CasposApiClient.php @@ -0,0 +1,269 @@ + waiter login -> tables/order lines. + * Session state is a cookie jar kept in memory for one run. + */ + +class CasposApiException extends Exception +{ + /** @var int HTTP status (0 = no connection) */ + public $httpCode; + /** @var int|null CASPOS extendedErrorCode (1001 client not registered, 1002 waiter login required) */ + public $extendedCode; + + public function __construct($message, $httpCode = 0, $extendedCode = null) + { + parent::__construct($message, (int)$httpCode); + $this->httpCode = (int)$httpCode; + $this->extendedCode = $extendedCode; + } + + /** Errors where a later retry can succeed (network, POS busy/offline). */ + public function isTemporary() + { + return $this->httpCode === 0 || $this->httpCode >= 500 || $this->httpCode === 408; + } +} + +class CasposApiClient +{ + const TIMEOUT = 15; + + protected $baseUrl; + protected $fingerprint; + protected $clientId; + protected $clientName; + protected $waiterId; + protected $waiterPassword; + protected $cookies = array(); + protected $fingerprintChecked = false; + protected $loggedIn = false; + + /** + * @param array $cfg host, port, fingerprint (SHA1 "AA:BB:.." -> https), client_id, client_name, + * waiter_id, waiter_password + */ + public function __construct(array $cfg) + { + $host = trim((string)$cfg['host']); + $port = (int)$cfg['port']; + if ($host === '' || $port <= 0) { + throw new CasposApiException('Kasse: Adresse oder Port fehlt in den Einstellungen.'); + } + $this->fingerprint = self::normalizeFingerprint(isset($cfg['fingerprint']) ? $cfg['fingerprint'] : ''); + $scheme = $this->fingerprint ? 'https' : 'http'; + $this->baseUrl = $scheme.'://'.$host.':'.$port.'/api/v2/'; + $this->clientId = (string)$cfg['client_id']; + $this->clientName = (string)$cfg['client_name']; + $this->waiterId = (string)$cfg['waiter_id']; + $this->waiterPassword = (string)$cfg['waiter_password']; + } + + public static function normalizeFingerprint($fp) + { + $hex = strtoupper(preg_replace('/[^0-9a-fA-F]/', '', (string)$fp)); + return strlen($hex) === 40 ? implode(':', str_split($hex, 2)) : ''; + } + + /** registerClient + waiter login; safe to call repeatedly. */ + public function startSession($version = '1.0.0') + { + if ($this->loggedIn) { + return; + } + $this->request('POST', 'actions/init/registerClient', array( + 'version' => $version, + 'id' => $this->clientId, + 'clientName' => $this->clientName, + 'printerPath' => null, + )); + $this->request('POST', 'actions/auth/login', array('id' => $this->waiterId, 'password' => $this->waiterPassword)); + $this->loggedIn = true; + } + + public function endSession() + { + if ($this->loggedIn) { + try { + $this->request('POST', 'actions/auth/logout'); + } catch (CasposApiException $e) { + // logout failures are irrelevant + } + $this->loggedIn = false; + } + } + + public function getServiceAreas() + { + return (array)$this->request('GET', 'data/serviceAreas'); + } + + public function getArticles() + { + return (array)$this->request('GET', 'data/articles'); + } + + /** @return array table incl. 'id' and 'subTables' */ + public function openTableByName($name, $serviceAreaId) + { + $table = $this->request('POST', 'actions/tables/openByName/'.rawurlencode($name).'?'.http_build_query(array( + 'serviceAreaId' => $serviceAreaId, + 'prePayment' => 'false', + 'forMove' => 'false', + ))); + if (empty($table['id'])) { + throw new CasposApiException('Kasse: Tisch "'.$name.'" konnte nicht geöffnet werden (keine Tisch-ID).'); + } + return $table; + } + + /** + * @param int|null $enteredPrice price in cents for open-price articles (unit as sent by the OrderApp) + */ + public function addOrderLine($subTableId, $articleId, $quantity, $enteredPrice = null, $enteredText = null) + { + $body = array('articleId' => (string)$articleId, 'quantity' => (int)$quantity, 'modifiers' => array()); + if ($enteredPrice !== null) { + $body['enteredPrice'] = (int)$enteredPrice; + } + if ($enteredText !== null && $enteredText !== '') { + $body['enteredText'] = (string)$enteredText; + } + return $this->request('POST', 'actions/orderLines/add/'.rawurlencode($subTableId), $body); + } + + public function finalizeOrder($tableId) + { + $this->request('POST', 'actions/tables/finalizeOrder/'.rawurlencode($tableId)); + } + + public function cancelOrder($tableId) + { + $this->request('POST', 'actions/tables/cancelOrder/'.rawurlencode($tableId)); + } + + /** + * @return array|null decoded JSON (null/empty array for 204) + * @throws CasposApiException + */ + protected function request($method, $path, $body = null) + { + if ($this->fingerprint && !$this->fingerprintChecked) { + $this->checkFingerprint(); + } + + $headers = array('Accept: application/json'); + if ($body !== null) { + $headers[] = 'Content-Type: application/json'; + } + if ($this->cookies && strpos($path, 'registerClient') === false) { + $pairs = array(); + foreach ($this->cookies as $k => $v) { + $pairs[] = $k.'='.$v; + } + $headers[] = 'Cookie: '.implode('; ', $pairs); + } + + $ch = curl_init($this->baseUrl.$path); + $opts = array( + CURLOPT_CUSTOMREQUEST => $method, + CURLOPT_RETURNTRANSFER => true, + CURLOPT_HEADER => true, + CURLOPT_HTTPHEADER => $headers, + CURLOPT_CONNECTTIMEOUT => self::TIMEOUT, + CURLOPT_TIMEOUT => self::TIMEOUT, + CURLOPT_USERAGENT => 'QloApps-CasposBridge/1.0', + ); + if ($body !== null) { + $opts[CURLOPT_POSTFIELDS] = json_encode($body); + } elseif ($method === 'POST') { + $opts[CURLOPT_POSTFIELDS] = ''; + } + if ($this->fingerprint) { + // the POS uses a self-signed certificate; trust is established by the pinned fingerprint + $opts[CURLOPT_SSL_VERIFYPEER] = false; + $opts[CURLOPT_SSL_VERIFYHOST] = 0; + } + curl_setopt_array($ch, $opts); + $raw = curl_exec($ch); + if ($raw === false) { + $err = curl_error($ch); + curl_close($ch); + throw new CasposApiException('Kasse nicht erreichbar ('.$this->baseUrl.'): '.$err, 0); + } + $code = (int)curl_getinfo($ch, CURLINFO_HTTP_CODE); + $headerSize = (int)curl_getinfo($ch, CURLINFO_HEADER_SIZE); + curl_close($ch); + + $this->storeCookies(substr($raw, 0, $headerSize)); + $responseBody = substr($raw, $headerSize); + $data = $responseBody === '' ? array() : json_decode($responseBody, true); + + if ($code >= 200 && $code < 300) { + return $data; + } + throw $this->buildError($code, is_array($data) ? $data : array(), $path); + } + + protected function buildError($code, array $data, $path) + { + $ext = isset($data['extendedErrorCode']) ? (int)$data['extendedErrorCode'] : null; + $detail = isset($data['errorMsg']) ? ' ('.$data['errorMsg'].')' : ''; + if ($code === 403 && strpos($path, 'registerClient') !== false) { + $msg = 'Kasse: Dieses Gerät ist in der OrderApp-Verwaltung der Kasse nicht freigeschaltet.'; + } elseif ($code === 401 && $ext === 1001) { + $msg = 'Kasse: Client nicht registriert.'; + $this->loggedIn = false; + } elseif ($code === 401 && $ext === 1002) { + $msg = 'Kasse: Kellner-Anmeldung erforderlich.'; + $this->loggedIn = false; + } elseif ($code === 401) { + $msg = 'Kasse: Anmeldung abgelehnt (Kellner-ID oder Passwort prüfen).'; + $this->loggedIn = false; + } elseif ($code === 404) { + $msg = 'Kasse: Adresse nicht gefunden – API-Port und Version (api/v2) prüfen.'; + } else { + $msg = 'Kasse: Fehler HTTP '.$code; + } + return new CasposApiException($msg.$detail, $code, $ext); + } + + protected function storeCookies($rawHeaders) + { + if (preg_match_all('/^Set-Cookie:\s*([^=;\s]+)=([^;\r\n]*)/mi', $rawHeaders, $m, PREG_SET_ORDER)) { + foreach ($m as $c) { + $this->cookies[$c[1]] = $c[2]; + } + } + } + + /** Compare the server certificate's SHA1 fingerprint before any credentials are sent. */ + protected function checkFingerprint() + { + $ch = curl_init($this->baseUrl.'hostStatus'); + curl_setopt_array($ch, array( + CURLOPT_RETURNTRANSFER => true, + CURLOPT_NOBODY => true, + CURLOPT_CERTINFO => true, + CURLOPT_SSL_VERIFYPEER => false, + CURLOPT_SSL_VERIFYHOST => 0, + CURLOPT_CONNECTTIMEOUT => self::TIMEOUT, + CURLOPT_TIMEOUT => self::TIMEOUT, + )); + $ok = curl_exec($ch); + $info = curl_getinfo($ch, CURLINFO_CERTINFO); + $err = curl_error($ch); + curl_close($ch); + if ($ok === false && empty($info)) { + throw new CasposApiException('Kasse nicht erreichbar: '.$err, 0); + } + $pem = isset($info[0]['Cert']) ? $info[0]['Cert'] : ''; + $der = base64_decode(preg_replace('/-----(BEGIN|END) CERTIFICATE-----|\s+/', '', $pem)); + $actual = $der ? implode(':', str_split(strtoupper(sha1($der)), 2)) : ''; + if ($actual !== $this->fingerprint) { + throw new CasposApiException('Kasse: Zertifikat passt nicht zum hinterlegten Fingerabdruck (erhalten: '.($actual ?: 'keins').').', 495); + } + $this->fingerprintChecked = true; + } +} diff --git a/modules/casposbridge/classes/CasposBridgeService.php b/modules/casposbridge/classes/CasposBridgeService.php new file mode 100644 index 000000000..2d44123ef --- /dev/null +++ b/modules/casposbridge/classes/CasposBridgeService.php @@ -0,0 +1,369 @@ + added (lines on the table, not committed yet) -> done. + * A job in "added" is only finalized on retry, so lines are never booked twice. + */ + +require_once dirname(__FILE__).'/CasposApiClient.php'; + +class CasposBridgeService +{ + const TABLE = 'casposbridge_job'; + const MAX_ATTEMPTS = 10; + + const STATUS_PENDING = 'pending'; + const STATUS_ADDED = 'added'; + const STATUS_DONE = 'done'; + const STATUS_FAILED = 'failed'; + + /** @var CasposApiClient|null */ + protected $client; + /** @var callable|null for tests */ + protected $clientFactory; + + public function __construct($clientFactory = null) + { + $this->clientFactory = $clientFactory; + } + + public static function installDb() + { + return Db::getInstance()->execute(' + CREATE TABLE IF NOT EXISTS `'._DB_PREFIX_.self::TABLE.'` ( + `id_job` INT UNSIGNED NOT NULL AUTO_INCREMENT, + `id_htl_booking` INT UNSIGNED NOT NULL, + `id_order` INT UNSIGNED NOT NULL DEFAULT 0, + `night` DATE NOT NULL, + `table_name` VARCHAR(64) NOT NULL, + `status` VARCHAR(16) NOT NULL, + `attempts` INT UNSIGNED NOT NULL DEFAULT 0, + `caspos_table_id` VARCHAR(64) NULL, + `lines_json` TEXT NULL, + `last_error` VARCHAR(500) NULL, + `date_add` DATETIME NOT NULL, + `date_upd` DATETIME NOT NULL, + PRIMARY KEY (`id_job`), + UNIQUE KEY `booking_night` (`id_htl_booking`, `night`), + KEY `status` (`status`) + ) ENGINE='._MYSQL_ENGINE_.' DEFAULT CHARSET=utf8mb4'); + } + + public static function uninstallDb() + { + return Db::getInstance()->execute('DROP TABLE IF EXISTS `'._DB_PREFIX_.self::TABLE.'`'); + } + + public static function isEnabled() + { + return (bool)Configuration::get('CASPOSBRIDGE_ENABLED'); + } + + // ------------------------------------------------------------------ configuration + + public static function clientConfig() + { + return array( + 'host' => Configuration::get('CASPOSBRIDGE_HOST'), + 'port' => Configuration::get('CASPOSBRIDGE_PORT'), + 'fingerprint' => Configuration::get('CASPOSBRIDGE_FINGERPRINT'), + 'client_id' => Configuration::get('CASPOSBRIDGE_CLIENT_ID'), + 'client_name' => Configuration::get('CASPOSBRIDGE_CLIENT_NAME'), + 'waiter_id' => Configuration::get('CASPOSBRIDGE_WAITER_ID'), + 'waiter_password' => Configuration::get('CASPOSBRIDGE_WAITER_PASSWORD'), + ); + } + + /** + * Mapping per room type, one rule string per id_product, e.g. + * "A-UEB-DZ, A-FRUEH*erwachsene, A-LOGIS@preis" + * Item syntax: ARTICLE_ID[*QTY][@preis] + * QTY: number, "erwachsene", "kinder" or "gaeste" (per night) + * @preis: send the room's price per night (gross, cents) as enteredPrice (open-price article) + * + * @return array list of ['article' => id, 'qty' => int|string, 'price' => bool] + */ + public static function parseRule($rule) + { + $items = array(); + foreach (preg_split('/[,;\n]+/', (string)$rule) as $part) { + $part = trim($part); + if ($part === '') { + continue; + } + $price = false; + if (preg_match('/@preis$/i', $part)) { + $price = true; + $part = trim(preg_replace('/@preis$/i', '', $part)); + } + $qty = 1; + if (strpos($part, '*') !== false) { + list($part, $q) = array_map('trim', explode('*', $part, 2)); + $q = Tools::strtolower($q); + if (ctype_digit($q) && (int)$q > 0) { + $qty = (int)$q; + } elseif (in_array($q, array('erwachsene', 'kinder', 'gaeste', 'gäste'), true)) { + $qty = $q === 'gäste' ? 'gaeste' : $q; + } else { + throw new InvalidArgumentException('Unbekannte Menge "'.$q.'" (Zahl, erwachsene, kinder oder gaeste).'); + } + } + if (!preg_match('/^[A-Za-z0-9._:\-{}]+$/', $part)) { + throw new InvalidArgumentException('Ungültige Artikel-ID "'.$part.'".'); + } + $items[] = array('article' => $part, 'qty' => $qty, 'price' => $price); + } + return $items; + } + + public static function getMapping() + { + $map = json_decode((string)Configuration::get('CASPOSBRIDGE_MAPPING'), true); + return is_array($map) ? $map : array(); + } + + public static function tableNameFor(array $booking) + { + $tpl = Configuration::get('CASPOSBRIDGE_TABLE_TEMPLATE') ?: '{room}'; + return trim(str_replace('{room}', trim((string)$booking['room_num']), $tpl)); + } + + // ------------------------------------------------------------------ jobs + + /** Create jobs for every night of a checked-in stay up to today. Returns number of new jobs. */ + public function ensureJobsForBooking($idHtlBooking, $today = null) + { + $b = Db::getInstance()->getRow('SELECT * FROM `'._DB_PREFIX_.'htl_booking_detail` WHERE `id` = '.(int)$idHtlBooking); + if (!$b || (int)$b['id_status'] !== HotelBookingDetail::STATUS_CHECKED_IN) { + return 0; + } + $today = $today ?: date('Y-m-d'); + $from = Tools::substr($b['date_from'], 0, 10); + $lastNight = date('Y-m-d', strtotime(Tools::substr($b['date_to'], 0, 10).' -1 day')); + $until = min($today, $lastNight); + $table = self::tableNameFor($b); + $now = date('Y-m-d H:i:s'); + $created = 0; + for ($night = $from; $night <= $until; $night = date('Y-m-d', strtotime($night.' +1 day'))) { + Db::getInstance()->execute('INSERT IGNORE INTO `'._DB_PREFIX_.self::TABLE.'` + (`id_htl_booking`, `id_order`, `night`, `table_name`, `status`, `date_add`, `date_upd`) VALUES (' + .(int)$b['id'].', '.(int)$b['id_order'].', \''.pSQL($night).'\', \''.pSQL($table).'\', \''.self::STATUS_PENDING.'\', \''.$now.'\', \''.$now.'\')'); + $created += (int)Db::getInstance()->Affected_Rows(); + } + return $created; + } + + /** Called by cron: create missing jobs for all guests currently checked in. */ + public function ensureJobsForCheckedInGuests($today = null) + { + $ids = Db::getInstance()->executeS('SELECT `id` FROM `'._DB_PREFIX_.'htl_booking_detail` WHERE `id_status` = '.(int)HotelBookingDetail::STATUS_CHECKED_IN); + $n = 0; + foreach ((array)$ids as $row) { + $n += $this->ensureJobsForBooking((int)$row['id'], $today); + } + return $n; + } + + /** + * Post open jobs to the POS. + * @param int|null $idHtlBooking only this booking (check-in hook) + * @return array ['done' => n, 'failed' => n, 'retry' => n, 'errors' => [..]] + */ + public function processJobs($idHtlBooking = null, $limit = 200) + { + $result = array('done' => 0, 'failed' => 0, 'retry' => 0, 'errors' => array()); + $where = '`status` IN (\''.self::STATUS_PENDING.'\', \''.self::STATUS_ADDED.'\') AND `attempts` < '.(int)self::MAX_ATTEMPTS; + if ($idHtlBooking) { + $where .= ' AND `id_htl_booking` = '.(int)$idHtlBooking; + } + $jobs = Db::getInstance()->executeS('SELECT * FROM `'._DB_PREFIX_.self::TABLE.'` WHERE '.$where.' ORDER BY `night`, `id_job` LIMIT '.(int)$limit); + if (!$jobs) { + return $result; + } + + try { + $client = $this->client(); + $client->startSession(); + } catch (Exception $e) { + // POS offline or login refused: count as a retry for every job, keep them queued + foreach ($jobs as $job) { + $this->touch($job, null, $e->getMessage()); + } + $result['retry'] = count($jobs); + $result['errors'][] = $e->getMessage(); + return $result; + } + + foreach ($jobs as $job) { + try { + $this->postJob($client, $job); + $result['done']++; + } catch (Exception $e) { + $permanent = ($e instanceof InvalidArgumentException) + || ($e instanceof CasposApiException && !$e->isTemporary() && $e->httpCode !== 401); + $this->touch($job, $permanent ? self::STATUS_FAILED : null, $e->getMessage()); + $result[$permanent ? 'failed' : 'retry']++; + $result['errors'][] = 'Zimmer '.$job['table_name'].', Nacht '.$job['night'].': '.$e->getMessage(); + if ($e instanceof CasposApiException && ($e->httpCode === 0 || $e->httpCode === 401)) { + // connection or session lost: stop this run, the rest is retried next time + break; + } + } + } + $client->endSession(); + return $result; + } + + protected function postJob(CasposApiClient $client, array $job) + { + $booking = Db::getInstance()->getRow('SELECT * FROM `'._DB_PREFIX_.'htl_booking_detail` WHERE `id` = '.(int)$job['id_htl_booking']); + if (!$booking) { + throw new InvalidArgumentException('Buchung nicht mehr vorhanden.'); + } + + if ($job['status'] === self::STATUS_ADDED && $job['caspos_table_id']) { + // lines are already on the table, only the commit is missing + $client->finalizeOrder($job['caspos_table_id']); + $this->touch($job, self::STATUS_DONE, null, true); + return; + } + + $lines = $this->buildLines($booking, $job['night']); + $serviceArea = Configuration::get('CASPOSBRIDGE_SERVICE_AREA'); + if (!$serviceArea) { + throw new InvalidArgumentException('Kein Tischbereich (Service Area) eingestellt.'); + } + if ($job['table_name'] === '') { + throw new InvalidArgumentException('Buchung hat keine Zimmernummer.'); + } + + $table = $client->openTableByName($job['table_name'], $serviceArea); + $sub = isset($table['subTables'][0]['id']) ? $table['subTables'][0]['id'] : null; + foreach ((array)(isset($table['subTables']) ? $table['subTables'] : array()) as $st) { + if (!empty($st['isSelected'])) { + $sub = $st['id']; + } + } + if (!$sub) { + throw new CasposApiException('Kasse: Tisch '.$job['table_name'].' hat keinen Untertisch.', 422); + } + + try { + foreach ($lines as $l) { + $client->addOrderLine($sub, $l['article'], $l['quantity'], $l['enteredPrice'], $l['text']); + } + } catch (Exception $e) { + // drop the half-added lines so a retry starts clean + try { + $client->cancelOrder($table['id']); + } catch (Exception $ignored) { + } + throw $e; + } + + Db::getInstance()->update(self::TABLE, array( + 'status' => self::STATUS_ADDED, + 'caspos_table_id' => pSQL($table['id']), + 'lines_json' => pSQL(json_encode($lines, JSON_UNESCAPED_UNICODE)), + 'date_upd' => date('Y-m-d H:i:s'), + ), '`id_job` = '.(int)$job['id_job']); + + $client->finalizeOrder($table['id']); + $this->touch(array_merge($job, array('status' => self::STATUS_ADDED)), self::STATUS_DONE, null, true); + } + + /** @return array lines with article, quantity, enteredPrice (cents|null), text */ + public function buildLines(array $booking, $night) + { + $map = self::getMapping(); + $rule = isset($map[(int)$booking['id_product']]) ? $map[(int)$booking['id_product']] : ''; + $items = self::parseRule($rule); + if (!$items) { + throw new InvalidArgumentException('Für den Zimmertyp "'.$booking['room_type_name'].'" sind keine CASPOS-Artikel hinterlegt.'); + } + $adults = (int)$booking['adults']; + $children = (int)$booking['children']; + $nights = max(1, (int)round((strtotime(Tools::substr($booking['date_to'], 0, 10)) - strtotime(Tools::substr($booking['date_from'], 0, 10))) / 86400)); + $nightCents = (int)round((float)$booking['total_price_tax_incl'] * 100 / $nights); + $text = 'Nacht '.date('d.m.Y', strtotime($night)).' – Buchung #'.(int)$booking['id_order']; + + $lines = array(); + foreach ($items as $it) { + $qty = $it['qty']; + if ($qty === 'erwachsene') { + $qty = $adults; + } elseif ($qty === 'kinder') { + $qty = $children; + } elseif ($qty === 'gaeste') { + $qty = $adults + $children; + } + if ((int)$qty <= 0) { + continue; + } + $lines[] = array( + 'article' => $it['article'], + 'quantity' => (int)$qty, + 'enteredPrice' => $it['price'] ? $nightCents : null, + 'text' => $text, + ); + } + if (!$lines) { + throw new InvalidArgumentException('Keine Positionen für diese Nacht (Menge 0).'); + } + return $lines; + } + + protected function touch(array $job, $status, $error, $resetError = false) + { + $data = array('date_upd' => date('Y-m-d H:i:s')); + if ($status) { + $data['status'] = pSQL($status); + } + if ($status !== self::STATUS_DONE) { + $data['attempts'] = (int)$job['attempts'] + 1; + } + if ($error !== null) { + $data['last_error'] = pSQL(Tools::substr($error, 0, 500)); + } elseif ($resetError) { + $data['last_error'] = null; + } + Db::getInstance()->update(self::TABLE, $data, '`id_job` = '.(int)$job['id_job'], 0, true); + } + + /** + * Remove jobs that never reached the POS (check-in undone). Returns how many nights + * were already posted and need a manual void at the POS. + */ + public static function dropOpenJobs($idHtlBooking) + { + Db::getInstance()->delete(self::TABLE, '`id_htl_booking` = '.(int)$idHtlBooking + .' AND `status` IN (\''.self::STATUS_PENDING.'\', \''.self::STATUS_FAILED.'\') AND `caspos_table_id` IS NULL'); + return (int)Db::getInstance()->getValue('SELECT COUNT(*) FROM `'._DB_PREFIX_.self::TABLE.'` WHERE `id_htl_booking` = '.(int)$idHtlBooking + .' AND `status` IN (\''.self::STATUS_ADDED.'\', \''.self::STATUS_DONE.'\')'); + } + + /** Re-queue a failed job. If its lines already reached the table, only the commit is retried. */ + public static function retryJob($idJob) + { + return Db::getInstance()->execute('UPDATE `'._DB_PREFIX_.self::TABLE.'` + SET `status` = IF(`caspos_table_id` IS NOT NULL AND `lines_json` IS NOT NULL, \''.self::STATUS_ADDED.'\', \''.self::STATUS_PENDING.'\'), + `attempts` = 0, `date_upd` = NOW() + WHERE `id_job` = '.(int)$idJob.' AND `status` = \''.self::STATUS_FAILED.'\''); + } + + public static function latestJobs($limit = 50) + { + return Db::getInstance()->executeS('SELECT * FROM `'._DB_PREFIX_.self::TABLE.'` ORDER BY `id_job` DESC LIMIT '.(int)$limit); + } + + protected function client() + { + if (!$this->client) { + $this->client = $this->clientFactory ? call_user_func($this->clientFactory) : new CasposApiClient(self::clientConfig()); + } + return $this->client; + } +} diff --git a/modules/casposbridge/classes/index.php b/modules/casposbridge/classes/index.php new file mode 100644 index 000000000..409280877 --- /dev/null +++ b/modules/casposbridge/classes/index.php @@ -0,0 +1,32 @@ +cronPostNights(); +if ($res === true) { + echo "Anbindung ist ausgeschaltet.\n"; + exit(0); +} +printf("gebucht: %d, erneut versuchen: %d, fehlgeschlagen: %d\n", $res['done'], $res['retry'], $res['failed']); +foreach ($res['errors'] as $e) { + echo ' - '.$e."\n"; +} +exit($res['failed'] || $res['retry'] ? 2 : 0); diff --git a/modules/casposbridge/index.php b/modules/casposbridge/index.php new file mode 100644 index 000000000..409280877 --- /dev/null +++ b/modules/casposbridge/index.php @@ -0,0 +1,32 @@ +