Files
hotel/modules/casposbridge/casposbridge.php
DanielS b3b79960e7 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>
2026-11-11 23:53:20 +01:00

328 lines
18 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
/**
* CASPOS Bridge: posts the nights of checked-in guests to the CASPOS POS table named
* after the room number, via the CASPOS OrderApp API. Billing (receipt, TSE) stays in CASPOS.
*/
if (!defined('_PS_VERSION_')) {
exit;
}
require_once dirname(__FILE__).'/classes/CasposBridgeService.php';
class CasposBridge extends Module
{
const CONFIG_KEYS = array(
'CASPOSBRIDGE_ENABLED', 'CASPOSBRIDGE_HOST', 'CASPOSBRIDGE_PORT', 'CASPOSBRIDGE_FINGERPRINT',
'CASPOSBRIDGE_CLIENT_ID', 'CASPOSBRIDGE_CLIENT_NAME', 'CASPOSBRIDGE_WAITER_ID', 'CASPOSBRIDGE_WAITER_PASSWORD',
'CASPOSBRIDGE_SERVICE_AREA', 'CASPOSBRIDGE_TABLE_TEMPLATE', 'CASPOSBRIDGE_MAPPING', 'CASPOSBRIDGE_CRON_TOKEN',
);
public function __construct()
{
$this->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('<br>', 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('<br>', 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 = '<table class="table"><thead><tr>';
foreach ($cols as $c) {
$h .= '<th>'.htmlspecialchars($c).'</th>';
}
$h .= '</tr></thead><tbody>';
foreach ($list as $item) {
$h .= '<tr>';
foreach (array_keys($cols) as $k) {
$h .= '<td>'.htmlspecialchars(isset($item[$k]) ? ($k === 'mustEnterPrice' ? ($item[$k] ? 'ja' : '') : (string)$item[$k]) : '').'</td>';
}
$h .= '</tr>';
}
return $h.'</tbody></table>';
};
return $this->displayConfirmation($this->l('Verbindung zur Kasse und Kellner-Anmeldung erfolgreich.'))
.'<div class="panel"><h3>'.$this->l('Tischbereiche (Service Areas)').'</h3>'
.$rows($areas, array('id' => 'ID', 'name' => $this->l('Name')))
.'<h3>'.$this->l('Artikel').'</h3>'
.$rows($articles, array('id' => 'ID', 'plu' => 'PLU', 'name' => $this->l('Name'), 'mustEnterPrice' => $this->l('Preis eingeben')))
.'</div>';
}
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|@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'),
)),
);
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 = '<div class="panel"><h3><i class="icon-time"></i> '.$this->l('Letzte Buchungen an die Kasse').'</h3>';
$html .= '<p>'.sprintf($this->l('Zeitgesteuert über den QloApps Cron Task Manager oder per Kommandozeile: %s'),
'<code>php modules/casposbridge/cron.php</code>').'</p>';
$html .= '<table class="table"><thead><tr><th>'.$this->l('Nacht').'</th><th>'.$this->l('Tisch').'</th><th>'.$this->l('Buchung').'</th><th>'.$this->l('Status').'</th><th>'.$this->l('Versuche').'</th><th>'.$this->l('Positionen').'</th><th>'.$this->l('Fehler').'</th><th></th></tr></thead><tbody>';
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, ',', '.').' €)' : '')).'<br>';
}
$html .= '<tr><td>'.htmlspecialchars(date('d.m.Y', strtotime($j['night']))).'</td><td>'.htmlspecialchars($j['table_name']).'</td>'
.'<td>#'.(int)$j['id_order'].'</td><td>'.htmlspecialchars(isset($statusLabel[$j['status']]) ? $statusLabel[$j['status']] : $j['status']).'</td>'
.'<td>'.(int)$j['attempts'].'</td><td>'.$linesTxt.'</td><td>'.htmlspecialchars((string)$j['last_error']).'</td><td>'
.($j['status'] === CasposBridgeService::STATUS_FAILED ? '<a class="btn btn-default" href="'.$base.'&casposRetry='.(int)$j['id_job'].'">'.$this->l('Erneut senden').'</a>' : '')
.'</td></tr>';
}
return $html.'</tbody></table></div>';
}
}