diff --git a/docker/reset-admin-password.php b/docker/reset-admin-password.php new file mode 100644 index 000000000..8f8427843 --- /dev/null +++ b/docker/reset-admin-password.php @@ -0,0 +1,50 @@ +\n"); + foreach (Db::getInstance()->executeS('SELECT email, active FROM `'._DB_PREFIX_.'employee`') as $e) { + fwrite(STDERR, ' vorhandener Zugang: '.$e['email'].($e['active'] ? '' : ' (inaktiv)')."\n"); + } + exit(1); +} +$employee = new Employee(); +if (!$employee->getByEmail($email)) { + fwrite(STDERR, "Kein Zugang mit der E-Mail $email.\n"); + exit(1); +} + +function ask($prompt) +{ + fwrite(STDOUT, $prompt); + @shell_exec('stty -echo 2>/dev/null'); + $v = rtrim((string)fgets(STDIN), "\r\n"); + @shell_exec('stty echo 2>/dev/null'); + fwrite(STDOUT, "\n"); + return $v; +} +$p1 = ask('Neues Passwort (mind. 8 Zeichen): '); +$p2 = ask('Wiederholen: '); +if ($p1 !== $p2 || Tools::strlen($p1) < 8) { + fwrite(STDERR, "Passwörter verschieden oder kürzer als 8 Zeichen.\n"); + exit(1); +} +$employee->passwd = (new PasswordHashing())->passwordHash($p1); +$employee->last_passwd_gen = date('Y-m-d H:i:s'); +$employee->active = 1; +if (!$employee->update()) { + fwrite(STDERR, "Speichern fehlgeschlagen.\n"); + exit(1); +} +echo "Passwort für $email gesetzt. Anmeldung: /admin-dev/\n"; diff --git a/docker/test-casposbridge-live.php b/docker/test-casposbridge-live.php new file mode 100644 index 000000000..c0ef1a4bc --- /dev/null +++ b/docker/test-casposbridge-live.php @@ -0,0 +1,69 @@ + so the result can be inspected + * at the POS. Only run it against a test POS: lines are committed with finalizeOrder. + * + * docker compose ... exec -u www-data app php docker/test-casposbridge-live.php "301@preis, 101*erwachsene@12.50" [room] + */ +if (PHP_SAPI !== 'cli') { + exit; +} +define('_PS_ADMIN_DIR_', __DIR__); +require __DIR__.'/../config/config.inc.php'; +require_once _PS_MODULE_DIR_.'hotelreservationsystem/define.php'; +require_once _PS_MODULE_DIR_.'casposbridge/classes/CasposBridgeService.php'; + +$rule = isset($argv[1]) ? $argv[1] : ''; +$room = isset($argv[2]) ? $argv[2] : 'T-1001'; +if ($rule === '') { + fwrite(STDERR, "Usage: php docker/test-casposbridge-live.php \"
\" [room]\n"); + exit(1); +} +CasposBridgeService::parseRule($rule); + +$cfg = CasposBridgeService::clientConfig(); +printf("Kasse %s:%s, Kellner %s, Bereich %s, Tisch %s\n", $cfg['host'], $cfg['port'], $cfg['waiter_id'], + Configuration::get('CASPOSBRIDGE_SERVICE_AREA'), $room); + +$db = Db::getInstance(); +$oldMapping = Configuration::get('CASPOSBRIDGE_MAPPING'); +$oldEnabled = Configuration::get('CASPOSBRIDGE_ENABLED'); +$map = CasposBridgeService::getMapping(); +$map[2] = $rule; +Configuration::updateValue('CASPOSBRIDGE_MAPPING', json_encode($map)); +Configuration::updateValue('CASPOSBRIDGE_ENABLED', 1); + +$from = date('Y-m-d'); +$to = date('Y-m-d', strtotime('+2 day')); +$db->insert('htl_booking_detail', array( + 'id_product' => 2, 'id_order' => 999002, 'id_order_detail' => 1, 'id_cart' => 1, 'id_room' => 6, 'id_hotel' => 1, + 'id_customer' => 1, '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' => 271.03, 'total_price_tax_incl' => 290, 'total_paid_amount' => 0, 'is_back_order' => 0, + 'hotel_name' => 'Test', 'room_type_name' => 'Doppelzimmer', 'room_num' => $room, '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', + 'date_add' => date('Y-m-d H:i:s'), 'date_upd' => date('Y-m-d H:i:s'), +)); +$id = (int)$db->Insert_ID(); + +try { + $b = new HotelBookingDetail($id); + $b->changeStatus(HotelBookingDetail::STATUS_CHECKED_IN); + foreach ($db->executeS('SELECT * FROM `'._DB_PREFIX_.'casposbridge_job` WHERE id_htl_booking = '.$id) as $j) { + printf("Nacht %s -> Tisch %s: %s (Versuche %d) %s\n", $j['night'], $j['table_name'], $j['status'], $j['attempts'], $j['last_error']); + foreach ((array)json_decode((string)$j['lines_json'], true) as $l) { + printf(" %d x %s%s\n", $l['quantity'], $l['article'], $l['enteredPrice'] !== null ? ' @ '.number_format($l['enteredPrice'] / 100, 2, ',', '.').' €' : ''); + } + } +} finally { + $db->delete('casposbridge_job', 'id_htl_booking = '.$id); + $db->delete('htl_booking_status_history', 'id_htl_booking = '.$id); + $db->delete('htl_booking_detail', 'id = '.$id); + Configuration::updateValue('CASPOSBRIDGE_MAPPING', $oldMapping); + Configuration::updateValue('CASPOSBRIDGE_ENABLED', $oldEnabled); +} +echo "Testbuchung in QloApps wieder entfernt; der Tisch an der Kasse bleibt zur Kontrolle offen.\n"; diff --git a/docker/test-casposbridge.php b/docker/test-casposbridge.php index b54f29be8..0ccc6f10e 100644 --- a/docker/test-casposbridge.php +++ b/docker/test-casposbridge.php @@ -145,6 +145,8 @@ try { $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'); + $rule = CasposBridgeService::parseRule('101*erwachsene@12,50, 301@preis'); + check($rule[0]['price'] === 1250 && $rule[0]['qty'] === 'erwachsene' && $rule[1]['price'] === true, 'fixed price @12,50 -> 1250 cents, @preis kept'); echo "Undo check-in drops unsent nights\n"; $b = new HotelBookingDetail($idBooking); diff --git a/modules/casposbridge/README.md b/modules/casposbridge/README.md index 39156c9b4..788b3c8e2 100644 --- a/modules/casposbridge/README.md +++ b/modules/casposbridge/README.md @@ -41,6 +41,7 @@ Check-out ──> Gast zahlt an der Kasse wie gewohnt | `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) | + | `101*erwachsene@12.50` | Artikel mit Preiseingabe, fester Preis 12,50 € je Stück | | `A-UEB-DZ, A-FRUEH*erwachsene` | mehrere Artikel mit Komma | Für die Mehrwertsteuer (Übernachtung 7 %, Frühstück 19 %) getrennte CASPOS-Artikel verwenden. diff --git a/modules/casposbridge/casposbridge.php b/modules/casposbridge/casposbridge.php index 32f551165..2a46161da 100644 --- a/modules/casposbridge/casposbridge.php +++ b/modules/casposbridge/casposbridge.php @@ -221,7 +221,7 @@ class CasposBridge extends Module foreach ($list as $item) { $h .= ''; foreach (array_keys($cols) as $k) { - $h .= ''.htmlspecialchars(isset($item[$k]) ? (is_bool($item[$k]) ? ($item[$k] ? 'ja' : '') : (string)$item[$k]) : '').''; + $h .= ''.htmlspecialchars(isset($item[$k]) ? ($k === 'mustEnterPrice' ? ($item[$k] ? 'ja' : '') : (string)$item[$k]) : '').''; } $h .= ''; } @@ -275,7 +275,7 @@ class CasposBridge extends Module )), 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'), + 'description' => $this->l('Format: ARTIKEL-ID[*MENGE][@preis|@BETRAG], mehrere mit Komma. MENGE = Zahl, erwachsene, kinder oder gaeste. Für Artikel mit Preiseingabe: @preis = Zimmerpreis pro Nacht, @12.50 = fester Preis je Stück. Beispiel: 301@preis, 101*erwachsene@12.50'), 'input' => $mapInputs, 'submit' => array('title' => $this->l('Speichern'), 'name' => 'submitCasposBridge'), )), diff --git a/modules/casposbridge/classes/CasposApiClient.php b/modules/casposbridge/classes/CasposApiClient.php index 4cb6e1b43..e9dc07199 100644 --- a/modules/casposbridge/classes/CasposApiClient.php +++ b/modules/casposbridge/classes/CasposApiClient.php @@ -29,6 +29,13 @@ class CasposApiException extends Exception class CasposApiClient { const TIMEOUT = 15; + /** + * CASPOS decides the device type from the User-Agent. Anything not starting with + * "OrderApp/" is listed as "Orderman" in the Order-App device manager, where it cannot be + * activated. Version must satisfy the POS's minimum OrderApp version. + */ + const APP_VERSION = '1.4.0'; + const USER_AGENT = 'OrderApp/1.4.0 (linux) QloApps-CasposBridge/1.0'; protected $baseUrl; protected $fingerprint; @@ -67,7 +74,7 @@ class CasposApiClient } /** registerClient + waiter login; safe to call repeatedly. */ - public function startSession($version = '1.0.0') + public function startSession($version = self::APP_VERSION) { if ($this->loggedIn) { return; @@ -173,7 +180,7 @@ class CasposApiClient CURLOPT_HTTPHEADER => $headers, CURLOPT_CONNECTTIMEOUT => self::TIMEOUT, CURLOPT_TIMEOUT => self::TIMEOUT, - CURLOPT_USERAGENT => 'QloApps-CasposBridge/1.0', + CURLOPT_USERAGENT => self::USER_AGENT, ); if ($body !== null) { $opts[CURLOPT_POSTFIELDS] = json_encode($body); diff --git a/modules/casposbridge/classes/CasposBridgeService.php b/modules/casposbridge/classes/CasposBridgeService.php index 2d44123ef..42e543fe5 100644 --- a/modules/casposbridge/classes/CasposBridgeService.php +++ b/modules/casposbridge/classes/CasposBridgeService.php @@ -80,7 +80,7 @@ class CasposBridgeService /** * 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] + * Item syntax: ARTICLE_ID[*QTY][@preis|@12.50] (@12.50 = fixed gross price per unit) * QTY: number, "erwachsene", "kinder" or "gaeste" (per night) * @preis: send the room's price per night (gross, cents) as enteredPrice (open-price article) * @@ -89,15 +89,18 @@ class CasposBridgeService public static function parseRule($rule) { $items = array(); - foreach (preg_split('/[,;\n]+/', (string)$rule) as $part) { + // "@12,50" is a German decimal, not a separator + $rule = preg_replace('/@\s*(\d+),(\d{1,2})(?!\d)/', '@$1.$2', (string)$rule); + foreach (preg_split('/[,;\n]+/', $rule) as $part) { $part = trim($part); if ($part === '') { continue; } + // @preis = room price per night, @12.50 = fixed price in euros (for open-price articles) $price = false; - if (preg_match('/@preis$/i', $part)) { - $price = true; - $part = trim(preg_replace('/@preis$/i', '', $part)); + if (preg_match('/@\s*(preis|\d+(?:[.,]\d{1,2})?)$/i', $part, $pm)) { + $price = Tools::strtolower($pm[1]) === 'preis' ? true : (int)round((float)str_replace(',', '.', $pm[1]) * 100); + $part = trim(Tools::substr($part, 0, -Tools::strlen($pm[0]))); } $qty = 1; if (strpos($part, '*') !== false) { @@ -306,7 +309,7 @@ class CasposBridgeService $lines[] = array( 'article' => $it['article'], 'quantity' => (int)$qty, - 'enteredPrice' => $it['price'] ? $nightCents : null, + 'enteredPrice' => $it['price'] === true ? $nightCents : ($it['price'] !== false ? $it['price'] : null), 'text' => $text, ); }