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:
DanielS
2026-11-11 23:53:20 +01:00
parent b6eebfcb90
commit b3b79960e7
7 changed files with 142 additions and 10 deletions

View 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";

View 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";

View File

@@ -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);

View File

@@ -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.

View File

@@ -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'),
)),

View File

@@ -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);

View File

@@ -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,
);
}