casposbridge: verified against a real CASPOS 4.12.3
- send User-Agent "OrderApp/1.4.0 (linux) QloApps-CasposBridge/1.0" and version 1.4.0: CASPOS classifies non-OrderApp clients as "Orderman", which cannot be activated in the Order-App device manager - fixed prices for open-price articles: ARTICLE@12.50 / @12,50 (cents) - live test (docker/test-casposbridge-live.php) booked one night on table 101: 1 x 301 @ 145.00 + 2 x 101 @ 12.50, bookedAmount 17000, lines committed -> enteredPrice is in cents - README: device activation, waiter key, per-area table number ranges ("Ungültige Tischnummer"), results - docker/reset-admin-password.php: set a back-office password from the CLI (asked interactively, not echoed) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
50
docker/reset-admin-password.php
Normal file
50
docker/reset-admin-password.php
Normal file
@@ -0,0 +1,50 @@
|
||||
<?php
|
||||
/**
|
||||
* Set a new password for a back-office employee (command line only).
|
||||
* docker compose -f docker-compose.yml -f docker-compose.server.yml exec -u www-data app php docker/reset-admin-password.php admin@hotel.test
|
||||
* The password is asked interactively (not echoed) so it does not end up in the shell history.
|
||||
* Use "exec" without -T so the prompt works.
|
||||
*/
|
||||
if (PHP_SAPI !== 'cli') {
|
||||
exit;
|
||||
}
|
||||
define('_PS_ADMIN_DIR_', __DIR__);
|
||||
require __DIR__.'/../config/config.inc.php';
|
||||
|
||||
$email = isset($argv[1]) ? trim($argv[1]) : '';
|
||||
if (!Validate::isEmail($email)) {
|
||||
fwrite(STDERR, "Usage: php docker/reset-admin-password.php <admin-email>\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";
|
||||
69
docker/test-casposbridge-live.php
Normal file
69
docker/test-casposbridge-live.php
Normal file
@@ -0,0 +1,69 @@
|
||||
<?php
|
||||
/**
|
||||
* Live check of modules/casposbridge against a real (test!) CASPOS POS.
|
||||
* Uses the connection settings saved in the module (host, port, waiter, password) and
|
||||
* books ONE throw-away checked-in stay on table T-<room> 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 \"<article rule>\" [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";
|
||||
@@ -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);
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -221,7 +221,7 @@ class CasposBridge extends Module
|
||||
foreach ($list as $item) {
|
||||
$h .= '<tr>';
|
||||
foreach (array_keys($cols) as $k) {
|
||||
$h .= '<td>'.htmlspecialchars(isset($item[$k]) ? (is_bool($item[$k]) ? ($item[$k] ? 'ja' : '') : (string)$item[$k]) : '').'</td>';
|
||||
$h .= '<td>'.htmlspecialchars(isset($item[$k]) ? ($k === 'mustEnterPrice' ? ($item[$k] ? 'ja' : '') : (string)$item[$k]) : '').'</td>';
|
||||
}
|
||||
$h .= '</tr>';
|
||||
}
|
||||
@@ -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'),
|
||||
)),
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user