On check-in (actionBookingStatusAfter -> checked in) and hourly via the QloApps Cron Task Manager or modules/casposbridge/cron.php, every night of a checked-in stay is posted to the CASPOS table named after the room number: registerClient -> waiter login -> tables/openByName -> orderLines/add -> tables/finalizeOrder (API derived from the CASPOS OrderApp client, lib/api/api.dart). - one job per booking and night; pending -> added -> done, so a retry only commits and never books lines twice; half-added lines are cancelled; POS offline keeps jobs queued (max 10 attempts) - mapping per room type: ARTICLE[*qty|erwachsene|kinder|gaeste][@preis] - optional HTTPS with pinned SHA1 certificate fingerprint, checked before credentials are sent - undoing a check-in drops unsent nights and logs posted ones for a void - config page with connection test (lists service areas and articles) and the last 50 jobs with retry - docker/caspos-mock: stand-in POS for the used endpoints and error codes; docker/test-casposbridge.php: 23 end-to-end checks Not yet verified against a real CASPOS: enteredPrice unit, auto-creation of tables, client activation, finalizeOrder/TSE behaviour. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
328 lines
18 KiB
PHP
328 lines
18 KiB
PHP
<?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]) ? (is_bool($item[$k]) ? ($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], 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 = '<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>';
|
||
}
|
||
}
|