Add casposbridge: post hotel nights to CASPOS tables via OrderApp API
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>
This commit is contained in:
220
docker/caspos-mock/server.php
Normal file
220
docker/caspos-mock/server.php
Normal file
@@ -0,0 +1,220 @@
|
||||
<?php
|
||||
/**
|
||||
* Minimal stand-in for the CASPOS OrderApp API (api/v2) used to develop and test
|
||||
* modules/casposbridge without a real POS. Behaviour mirrors what the CASPOS OrderApp
|
||||
* client (caspos-order-app, lib/api/api.dart) expects:
|
||||
* - registerClient sets a client cookie; 403 when the device is not activated
|
||||
* - 401 + extendedErrorCode 1001 (client not registered) / 1002 (waiter login required)
|
||||
* - tables are opened by name, order lines are added uncommitted and committed by finalizeOrder
|
||||
*
|
||||
* Run: php -S 0.0.0.0:8088 docker/caspos-mock/server.php
|
||||
* Env: MOCK_STATE (state file), MOCK_DEVICE_BLOCKED=1 (simulate a not activated device)
|
||||
* Inspect / reset: GET /mock/state, POST /mock/reset
|
||||
*/
|
||||
|
||||
$stateFile = getenv('MOCK_STATE') ?: sys_get_temp_dir().'/caspos-mock-state.json';
|
||||
|
||||
$users = array(array('id' => '7', 'name' => 'Hotel', 'password' => '1234', 'permissions' => array()));
|
||||
$serviceAreas = array(
|
||||
array('id' => 'sa-hotel', 'name' => 'Hotel', 'assignWaiter' => false, 'requiredPermission' => null),
|
||||
array('id' => 'sa-rest', 'name' => 'Restaurant', 'assignWaiter' => false, 'requiredPermission' => null),
|
||||
);
|
||||
$articles = array(
|
||||
array('id' => 'A-UEB-EZ', 'name' => 'Übernachtung EZ', 'plu' => '9001'),
|
||||
array('id' => 'A-UEB-DZ', 'name' => 'Übernachtung DZ', 'plu' => '9002'),
|
||||
array('id' => 'A-UEB-3B', 'name' => 'Übernachtung 3-Bett', 'plu' => '9003'),
|
||||
array('id' => 'A-FRUEH', 'name' => 'Frühstücksbuffet', 'plu' => '9010'),
|
||||
array('id' => 'A-KURTAXE', 'name' => 'Kurbeitrag', 'plu' => '9020'),
|
||||
array('id' => 'A-LOGIS', 'name' => 'Logis offen (Preis)', 'plu' => '9099', 'mustEnterPrice' => true),
|
||||
);
|
||||
|
||||
function loadState($file)
|
||||
{
|
||||
$s = is_file($file) ? json_decode(file_get_contents($file), true) : null;
|
||||
return is_array($s) ? $s : array('clients' => array(), 'sessions' => array(), 'tables' => array(), 'lines' => array(), 'seq' => 1, 'log' => array());
|
||||
}
|
||||
function saveState($file, $s)
|
||||
{
|
||||
file_put_contents($file, json_encode($s, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE), LOCK_EX);
|
||||
}
|
||||
function out($code, $data = null, $headers = array())
|
||||
{
|
||||
http_response_code($code);
|
||||
foreach ($headers as $h) {
|
||||
header($h, false);
|
||||
}
|
||||
if ($data !== null) {
|
||||
header('Content-Type: application/json; charset=utf-8');
|
||||
echo json_encode($data, JSON_UNESCAPED_UNICODE);
|
||||
}
|
||||
exit;
|
||||
}
|
||||
function cookieVal($name)
|
||||
{
|
||||
return isset($_COOKIE[$name]) ? $_COOKIE[$name] : null;
|
||||
}
|
||||
|
||||
$state = loadState($stateFile);
|
||||
$method = $_SERVER['REQUEST_METHOD'];
|
||||
$path = parse_url($_SERVER['REQUEST_URI'], PHP_URL_PATH);
|
||||
$body = json_decode(file_get_contents('php://input'), true) ?: array();
|
||||
$state['log'][] = $method.' '.$path;
|
||||
$state['log'] = array_slice($state['log'], -200);
|
||||
|
||||
if ($path === '/mock/state') {
|
||||
saveState($stateFile, $state);
|
||||
out(200, $state);
|
||||
}
|
||||
if ($path === '/mock/reset' && $method === 'POST') {
|
||||
@unlink($stateFile);
|
||||
out(200, array('reset' => true));
|
||||
}
|
||||
|
||||
$prefix = '/api/v2/';
|
||||
if (strpos($path, $prefix) !== 0) {
|
||||
out(404, array('errorMsg' => 'not found'));
|
||||
}
|
||||
$route = substr($path, strlen($prefix));
|
||||
|
||||
// --- registration (no cookie needed) ---
|
||||
if ($route === 'actions/init/registerClient' && $method === 'POST') {
|
||||
if (getenv('MOCK_DEVICE_BLOCKED') === '1') {
|
||||
out(403, array('errorMsg' => 'Device not activated'));
|
||||
}
|
||||
if (empty($body['id']) || empty($body['clientName'])) {
|
||||
out(400, array('errorMsg' => 'id and clientName required'));
|
||||
}
|
||||
$token = bin2hex(random_bytes(8));
|
||||
$state['clients'][$token] = array('id' => $body['id'], 'name' => $body['clientName']);
|
||||
saveState($stateFile, $state);
|
||||
out(200, array('utcTime' => time(), 'clientName' => $body['clientName'], 'casposVersion' => '4.12-mock'),
|
||||
array('Set-Cookie: CasposClient='.$token.'; Path=/; HttpOnly'));
|
||||
}
|
||||
|
||||
$client = cookieVal('CasposClient');
|
||||
if (!$client || !isset($state['clients'][$client])) {
|
||||
saveState($stateFile, $state);
|
||||
out(401, array('errorMsg' => 'Client not registered', 'extendedErrorCode' => 1001));
|
||||
}
|
||||
|
||||
if ($route === 'actions/auth/login' && $method === 'POST') {
|
||||
foreach ($users as $u) {
|
||||
if ($u['id'] === (string)($body['id'] ?? '') && $u['password'] === (string)($body['password'] ?? '')) {
|
||||
$state['sessions'][$client] = $u['id'];
|
||||
saveState($stateFile, $state);
|
||||
out(204);
|
||||
}
|
||||
}
|
||||
saveState($stateFile, $state);
|
||||
out(401, array('errorMsg' => 'Wrong waiter id or password'));
|
||||
}
|
||||
|
||||
if ($route === 'hostStatus') {
|
||||
out(200, array('online' => true));
|
||||
}
|
||||
if ($route === 'data/serviceAreas') {
|
||||
out(200, $serviceAreas);
|
||||
}
|
||||
|
||||
// everything below needs a logged-in waiter
|
||||
if (empty($state['sessions'][$client])) {
|
||||
saveState($stateFile, $state);
|
||||
out(401, array('errorMsg' => 'Waiter login required', 'extendedErrorCode' => 1002));
|
||||
}
|
||||
|
||||
if ($route === 'actions/auth/logout' && $method === 'POST') {
|
||||
unset($state['sessions'][$client]);
|
||||
saveState($stateFile, $state);
|
||||
out(204);
|
||||
}
|
||||
if ($route === 'data/articles') {
|
||||
out(200, $articles);
|
||||
}
|
||||
if ($route === 'data/users') {
|
||||
out(200, array_map(function ($u) { unset($u['password']); return $u; }, $users));
|
||||
}
|
||||
|
||||
if (preg_match('#^actions/tables/openByName/(.+)$#', $route, $m) && $method === 'POST') {
|
||||
$name = urldecode($m[1]);
|
||||
$sa = $_GET['serviceAreaId'] ?? '';
|
||||
if (!in_array($sa, array_column($serviceAreas, 'id'), true)) {
|
||||
out(400, array('errorMsg' => 'Unknown serviceAreaId'));
|
||||
}
|
||||
$tableId = null;
|
||||
foreach ($state['tables'] as $id => $t) {
|
||||
if ($t['name'] === $name && $t['open']) {
|
||||
$tableId = $id;
|
||||
}
|
||||
}
|
||||
if ($tableId === null) {
|
||||
$tableId = 'T-'.$state['seq']++;
|
||||
$state['tables'][$tableId] = array('name' => $name, 'serviceAreaId' => $sa, 'open' => true,
|
||||
'subTables' => array(array('id' => $tableId.'-1', 'name' => $name.'/1', 'isSelected' => true)));
|
||||
}
|
||||
$t = $state['tables'][$tableId];
|
||||
$booked = 0;
|
||||
foreach ($state['lines'] as $l) {
|
||||
if ($l['tableId'] === $tableId && $l['committed']) {
|
||||
$booked += $l['quantity'] * ($l['enteredPrice'] ?? 0);
|
||||
}
|
||||
}
|
||||
saveState($stateFile, $state);
|
||||
out(200, array('id' => $tableId, 'name' => $t['name'], 'lastActivityTime' => time(), 'bookedAmount' => $booked,
|
||||
'serviceAreaId' => $t['serviceAreaId'], 'subTables' => $t['subTables'], 'courses' => array()));
|
||||
}
|
||||
|
||||
if (preg_match('#^actions/orderLines/add/(.+)$#', $route, $m) && $method === 'POST') {
|
||||
$subId = urldecode($m[1]);
|
||||
$tableId = null;
|
||||
foreach ($state['tables'] as $id => $t) {
|
||||
foreach ($t['subTables'] as $st) {
|
||||
if ($st['id'] === $subId) {
|
||||
$tableId = $id;
|
||||
}
|
||||
}
|
||||
}
|
||||
if ($tableId === null) {
|
||||
out(404, array('errorMsg' => 'Unknown subTable'));
|
||||
}
|
||||
$art = null;
|
||||
foreach ($articles as $a) {
|
||||
if ($a['id'] === ($body['articleId'] ?? '')) {
|
||||
$art = $a;
|
||||
}
|
||||
}
|
||||
if ($art === null) {
|
||||
out(400, array('errorMsg' => 'Unknown articleId'));
|
||||
}
|
||||
if (!empty($art['mustEnterPrice']) && !isset($body['enteredPrice'])) {
|
||||
out(400, array('errorMsg' => 'Article needs enteredPrice'));
|
||||
}
|
||||
$lineId = 'L-'.$state['seq']++;
|
||||
$state['lines'][] = array('id' => $lineId, 'tableId' => $tableId, 'subTableId' => $subId, 'articleId' => $art['id'],
|
||||
'quantity' => (int)($body['quantity'] ?? 1), 'enteredPrice' => $body['enteredPrice'] ?? null,
|
||||
'enteredText' => $body['enteredText'] ?? null, 'committed' => false);
|
||||
saveState($stateFile, $state);
|
||||
out(200, array('id' => $lineId, 'singlePrice' => $body['enteredPrice'] ?? 0, 'name' => $art['name'],
|
||||
'receiptName' => $art['name'], 'modifiers' => array(), 'courseId' => null));
|
||||
}
|
||||
|
||||
if (preg_match('#^actions/tables/(finalizeOrder|cancelOrder)/(.+)$#', $route, $m) && $method === 'POST') {
|
||||
$tableId = urldecode($m[2]);
|
||||
if (!isset($state['tables'][$tableId])) {
|
||||
out(404, array('errorMsg' => 'Unknown table'));
|
||||
}
|
||||
foreach ($state['lines'] as $i => $l) {
|
||||
if ($l['tableId'] === $tableId && !$l['committed']) {
|
||||
if ($m[1] === 'finalizeOrder') {
|
||||
$state['lines'][$i]['committed'] = true;
|
||||
} else {
|
||||
unset($state['lines'][$i]);
|
||||
}
|
||||
}
|
||||
}
|
||||
$state['lines'] = array_values($state['lines']);
|
||||
saveState($stateFile, $state);
|
||||
out(204);
|
||||
}
|
||||
|
||||
saveState($stateFile, $state);
|
||||
out(404, array('errorMsg' => 'Route not implemented in mock: '.$method.' '.$route));
|
||||
167
docker/test-casposbridge.php
Normal file
167
docker/test-casposbridge.php
Normal file
@@ -0,0 +1,167 @@
|
||||
<?php
|
||||
/**
|
||||
* End-to-end test for modules/casposbridge against docker/caspos-mock (no real POS needed).
|
||||
* docker compose exec -d app php -S 127.0.0.1:8088 docker/caspos-mock/server.php
|
||||
* docker compose exec -u www-data app php docker/test-casposbridge.php
|
||||
* Creates a throw-away booking on room "T-1001", checks it in, and checks what reached the
|
||||
* mock. Leaves the module configured for the mock afterwards only if --keep is given.
|
||||
*/
|
||||
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';
|
||||
|
||||
$mock = 'http://127.0.0.1:8088';
|
||||
$fail = 0;
|
||||
function check($cond, $msg)
|
||||
{
|
||||
global $fail;
|
||||
echo ($cond ? ' [ok] ' : ' [FAIL] ').$msg.PHP_EOL;
|
||||
if (!$cond) {
|
||||
$fail++;
|
||||
}
|
||||
}
|
||||
function mock($path, $method = 'GET')
|
||||
{
|
||||
global $mock;
|
||||
$ctx = stream_context_create(array('http' => array('method' => $method, 'ignore_errors' => true)));
|
||||
return json_decode((string)@file_get_contents($mock.$path, false, $ctx), true);
|
||||
}
|
||||
function committed($state, $tableName)
|
||||
{
|
||||
$ids = array();
|
||||
foreach ($state['tables'] as $id => $t) {
|
||||
if ($t['name'] === $tableName) {
|
||||
$ids[] = $id;
|
||||
}
|
||||
}
|
||||
return array_values(array_filter($state['lines'], function ($l) use ($ids) {
|
||||
return in_array($l['tableId'], $ids, true) && $l['committed'];
|
||||
}));
|
||||
}
|
||||
|
||||
echo "Setup\n";
|
||||
check(is_array(mock('/mock/reset', 'POST')), 'mock reachable and reset');
|
||||
$module = Module::getInstanceByName('casposbridge');
|
||||
if (!Module::isInstalled('casposbridge')) {
|
||||
check($module->install(), 'module installed');
|
||||
} else {
|
||||
check(true, 'module already installed');
|
||||
}
|
||||
$backup = array();
|
||||
foreach (CasposBridge::CONFIG_KEYS as $k) {
|
||||
$backup[$k] = Configuration::get($k);
|
||||
}
|
||||
Configuration::updateValue('CASPOSBRIDGE_ENABLED', 1);
|
||||
Configuration::updateValue('CASPOSBRIDGE_HOST', '127.0.0.1');
|
||||
Configuration::updateValue('CASPOSBRIDGE_PORT', '8088');
|
||||
Configuration::updateValue('CASPOSBRIDGE_FINGERPRINT', '');
|
||||
Configuration::updateValue('CASPOSBRIDGE_WAITER_ID', '7');
|
||||
Configuration::updateValue('CASPOSBRIDGE_WAITER_PASSWORD', '1234');
|
||||
Configuration::updateValue('CASPOSBRIDGE_SERVICE_AREA', 'sa-hotel');
|
||||
Configuration::updateValue('CASPOSBRIDGE_TABLE_TEMPLATE', '{room}');
|
||||
Configuration::updateValue('CASPOSBRIDGE_MAPPING', json_encode(array(2 => 'A-UEB-DZ, A-FRUEH*erwachsene', 4 => 'A-LOGIS@preis, A-FRUEH*gaeste')));
|
||||
|
||||
// throw-away booking: Doppelzimmer, 2 adults, arrived yesterday, 3 nights
|
||||
$today = date('Y-m-d');
|
||||
$from = date('Y-m-d', strtotime('-1 day'));
|
||||
$to = date('Y-m-d', strtotime('+2 day'));
|
||||
$db = Db::getInstance();
|
||||
$db->insert('htl_booking_detail', array(
|
||||
'id_product' => 2, 'id_order' => 999001, 'id_order_detail' => 0, 'id_cart' => 0, 'id_room' => 6, 'id_hotel' => 1,
|
||||
'id_customer' => 0, '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' => 406.54, 'total_price_tax_incl' => 435, 'total_paid_amount' => 0, 'is_back_order' => 0,
|
||||
'hotel_name' => 'Test', 'room_type_name' => 'Doppelzimmer', 'room_num' => 'T-1001', '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', 'id_customer' => 1, 'id_cart' => 1, 'id_order_detail' => 1,
|
||||
'date_add' => date('Y-m-d H:i:s'), 'date_upd' => date('Y-m-d H:i:s'),
|
||||
));
|
||||
$idBooking = (int)$db->Insert_ID();
|
||||
check($idBooking > 0, 'test booking created (id '.$idBooking.')');
|
||||
|
||||
try {
|
||||
echo "Check-in posts the nights up to today\n";
|
||||
$b = new HotelBookingDetail($idBooking);
|
||||
check($b->changeStatus(HotelBookingDetail::STATUS_CHECKED_IN), 'check-in status change ok');
|
||||
$jobs = $db->executeS('SELECT * FROM `'._DB_PREFIX_.'casposbridge_job` WHERE id_htl_booking = '.$idBooking.' ORDER BY night');
|
||||
check(count($jobs) === 2, '2 jobs (yesterday + today), got '.count($jobs));
|
||||
check(count(array_filter($jobs, function ($j) { return $j['status'] === 'done'; })) === 2, 'both jobs done');
|
||||
$lines = committed(mock('/mock/state'), 'T-1001');
|
||||
check(count($lines) === 4, '4 committed lines on table T-1001, got '.count($lines));
|
||||
$byArt = array();
|
||||
foreach ($lines as $l) {
|
||||
$byArt[$l['articleId']] = (isset($byArt[$l['articleId']]) ? $byArt[$l['articleId']] : 0) + $l['quantity'];
|
||||
}
|
||||
check(isset($byArt['A-UEB-DZ']) && $byArt['A-UEB-DZ'] === 2, '2 x Übernachtung DZ');
|
||||
check(isset($byArt['A-FRUEH']) && $byArt['A-FRUEH'] === 4, '4 x Frühstück (2 adults x 2 nights)');
|
||||
check(strpos($lines[0]['enteredText'], 'Buchung #999001') !== false, 'line text names the booking');
|
||||
|
||||
echo "Cron run is idempotent\n";
|
||||
$service = new CasposBridgeService();
|
||||
$service->ensureJobsForCheckedInGuests();
|
||||
$res = $service->processJobs();
|
||||
check(count(committed(mock('/mock/state'), 'T-1001')) === 4, 'no duplicate lines after cron');
|
||||
|
||||
echo "Next day: one more night\n";
|
||||
check($service->ensureJobsForBooking($idBooking, date('Y-m-d', strtotime('+1 day'))) === 1, '1 new job for tomorrow');
|
||||
$service->processJobs($idBooking);
|
||||
check(count(committed(mock('/mock/state'), 'T-1001')) === 6, '6 lines after the 3rd night');
|
||||
check($service->ensureJobsForBooking($idBooking, date('Y-m-d', strtotime('+5 day'))) === 0, 'no jobs beyond departure');
|
||||
|
||||
echo "POS offline: job stays queued\n";
|
||||
Configuration::updateValue('CASPOSBRIDGE_PORT', '8099');
|
||||
$db->update('casposbridge_job', array('status' => 'pending', 'caspos_table_id' => null, 'lines_json' => null),
|
||||
'id_htl_booking = '.$idBooking.' AND night = \''.pSQL($to).'\'');
|
||||
$db->insert('casposbridge_job', array('id_htl_booking' => $idBooking, 'id_order' => 999001, 'night' => date('Y-m-d', strtotime('+3 day')),
|
||||
'table_name' => 'T-1001', 'status' => 'pending', 'date_add' => date('Y-m-d H:i:s'), 'date_upd' => date('Y-m-d H:i:s')));
|
||||
$res = (new CasposBridgeService())->processJobs($idBooking);
|
||||
check($res['retry'] === 1 && $res['done'] === 0, 'offline -> retry later, nothing lost');
|
||||
$j = $db->getRow('SELECT * FROM `'._DB_PREFIX_.'casposbridge_job` WHERE id_htl_booking = '.$idBooking.' AND status = \'pending\'');
|
||||
check($j && (int)$j['attempts'] === 1 && strpos($j['last_error'], 'nicht erreichbar') !== false, 'error recorded: '.($j ? $j['last_error'] : '-'));
|
||||
Configuration::updateValue('CASPOSBRIDGE_PORT', '8088');
|
||||
(new CasposBridgeService())->processJobs($idBooking);
|
||||
check(count(committed(mock('/mock/state'), 'T-1001')) === 8, 'posted after the POS is back');
|
||||
|
||||
echo "Unknown article: job fails permanently, retry button resets it\n";
|
||||
Configuration::updateValue('CASPOSBRIDGE_MAPPING', json_encode(array(2 => 'A-GIBTSNICHT')));
|
||||
$db->insert('casposbridge_job', array('id_htl_booking' => $idBooking, 'id_order' => 999001, 'night' => date('Y-m-d', strtotime('+4 day')),
|
||||
'table_name' => 'T-1001', 'status' => 'pending', 'date_add' => date('Y-m-d H:i:s'), 'date_upd' => date('Y-m-d H:i:s')));
|
||||
$res = (new CasposBridgeService())->processJobs($idBooking);
|
||||
$j = $db->getRow('SELECT * FROM `'._DB_PREFIX_.'casposbridge_job` WHERE id_htl_booking = '.$idBooking.' AND status = \'failed\'');
|
||||
check($res['failed'] === 1 && $j && strpos($j['last_error'], 'Unknown articleId') !== false, 'failed with POS message: '.($j ? $j['last_error'] : '-'));
|
||||
$state = mock('/mock/state');
|
||||
check(count(array_filter($state['lines'], function ($l) { return !$l['committed']; })) === 0, 'no half-booked lines left on the table');
|
||||
CasposBridgeService::retryJob((int)$j['id_job']);
|
||||
check($db->getValue('SELECT status FROM `'._DB_PREFIX_.'casposbridge_job` WHERE id_job = '.(int)$j['id_job']) === 'pending', 'retry resets to pending');
|
||||
|
||||
echo "Open-price article sends the nightly room price\n";
|
||||
Configuration::updateValue('CASPOSBRIDGE_MAPPING', json_encode(array(2 => 'A-UEB-DZ, A-FRUEH*erwachsene', 4 => 'A-LOGIS@preis, A-FRUEH*gaeste')));
|
||||
$row = $db->getRow('SELECT * FROM `'._DB_PREFIX_.'htl_booking_detail` WHERE id = '.$idBooking);
|
||||
$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');
|
||||
|
||||
echo "Undo check-in drops unsent nights\n";
|
||||
$b = new HotelBookingDetail($idBooking);
|
||||
$b->changeStatus(HotelBookingDetail::STATUS_ASSIGNED);
|
||||
check((int)$db->getValue('SELECT COUNT(*) FROM `'._DB_PREFIX_.'casposbridge_job` WHERE id_htl_booking = '.$idBooking.' AND status IN (\'pending\',\'failed\')') === 0, 'pending/failed jobs removed');
|
||||
} catch (Throwable $e) {
|
||||
check(false, 'unexpected '.get_class($e).': '.$e->getMessage().' @ '.basename($e->getFile()).':'.$e->getLine());
|
||||
} finally {
|
||||
$db->delete('casposbridge_job', 'id_htl_booking = '.$idBooking);
|
||||
$db->delete('htl_booking_status_history', 'id_htl_booking = '.$idBooking);
|
||||
$db->delete('htl_booking_detail', 'id = '.$idBooking);
|
||||
if (!in_array('--keep', $argv, true)) {
|
||||
foreach ($backup as $k => $v) {
|
||||
Configuration::updateValue($k, $v);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
echo $fail ? "\n$fail check(s) FAILED\n" : "\nAll checks passed\n";
|
||||
exit($fail ? 1 : 0);
|
||||
82
modules/casposbridge/README.md
Normal file
82
modules/casposbridge/README.md
Normal file
@@ -0,0 +1,82 @@
|
||||
# CASPOS Kassenanbindung (casposbridge)
|
||||
|
||||
Bucht die Übernachtungen eingecheckter Gäste auf den CASPOS-Tisch mit der Zimmernummer
|
||||
(Zimmer 1001 → Tisch 1001). Abrechnung, Beleg und TSE bleiben in CASPOS; QloApps erstellt dafür
|
||||
keine eigenen Rechnungen.
|
||||
|
||||
```
|
||||
Check-in in QloApps ──> Auftrag je Nacht ──> OrderApp-API der Kasse
|
||||
registerClient → Kellner-Login
|
||||
→ tables/openByName/1001
|
||||
→ orderLines/add (Übernachtung, Frühstück …)
|
||||
→ tables/finalizeOrder
|
||||
Check-out ──> Gast zahlt an der Kasse wie gewohnt
|
||||
```
|
||||
|
||||
## Ablauf
|
||||
|
||||
- **Check-in** (Buchungsstatus → „eingecheckt“): Aufträge für alle Nächte von der Anreise bis heute
|
||||
werden angelegt und sofort gesendet. Ein Fehler an der Kasse blockiert den Check-in nie.
|
||||
- **Jede Stunde** (Cron): für alle eingecheckten Gäste wird die jeweils neue Nacht gebucht; offene oder
|
||||
fehlgeschlagene Sendungen werden wiederholt (max. 10 Versuche).
|
||||
- **Keine Doppelbuchung:** pro Zimmer-Buchung und Nacht gibt es genau einen Auftrag. Stehen die
|
||||
Positionen schon auf dem Tisch und nur der Abschluss fehlt, wird beim nächsten Versuch nur abgeschlossen.
|
||||
Schlägt das Hinzufügen mittendrin fehl, werden die halben Positionen per `cancelOrder` verworfen.
|
||||
- **Check-in zurückgenommen:** noch nicht gesendete Nächte werden gelöscht; bereits gebuchte müssen an der
|
||||
Kasse storniert werden (Hinweis im QloApps-Log).
|
||||
|
||||
## Einrichtung
|
||||
|
||||
1. In CASPOS: Zimmer als Tische im eigenen Tischbereich anlegen (Name = Zimmernummer), Artikel für
|
||||
Übernachtung/Frühstück/Kurtaxe anlegen, einen Kellner „Hotel“ mit Passwort anlegen, OrderApp-API aktivieren.
|
||||
2. In QloApps: Zimmernummern (Hotel Reservation System → Zimmertypen → Zimmer) auf die Tischnamen setzen.
|
||||
3. *Module → CASPOS Kassenanbindung → Konfigurieren*: IP und API-Port der Kasse (wie in der OrderApp),
|
||||
bei HTTPS den SHA1-Fingerabdruck, Kellner-Nummer und Passwort.
|
||||
4. **Verbindung testen** – zeigt Tischbereiche und Artikel mit IDs. Meldet die Kasse „Gerät nicht
|
||||
freigeschaltet“, das Gerät (Name/ID wie im Formular) in der OrderApp-Verwaltung der Kasse freigeben.
|
||||
5. Tischbereich-ID eintragen und je Zimmertyp die Artikel pro Nacht:
|
||||
|
||||
| Eingabe | Bedeutung |
|
||||
|---|---|
|
||||
| `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) |
|
||||
| `A-UEB-DZ, A-FRUEH*erwachsene` | mehrere Artikel mit Komma |
|
||||
|
||||
Für die Mehrwertsteuer (Übernachtung 7 %, Frühstück 19 %) getrennte CASPOS-Artikel verwenden.
|
||||
6. **Anbindung aktiv** einschalten.
|
||||
|
||||
## Zeitsteuerung
|
||||
|
||||
- Mit dem Modul *QloApps Cron Task Manager* läuft die Aufgabe `casposbridge_post_nights` stündlich.
|
||||
- Ohne Cron (z. B. Windows Server, Docker): Windows-Aufgabenplanung stündlich ausführen lassen
|
||||
(im Repo-Ordner):
|
||||
|
||||
```powershell
|
||||
docker compose -f docker-compose.yml -f docker-compose.server.yml exec -T -u www-data app php modules/casposbridge/cron.php
|
||||
```
|
||||
|
||||
Rückgabewert 0 = alles gebucht, 2 = etwas wartet noch oder ist fehlgeschlagen.
|
||||
|
||||
Die Seite *Konfigurieren* zeigt die letzten 50 Aufträge mit Status, Positionen und Fehlermeldung der Kasse;
|
||||
fehlgeschlagene lassen sich dort erneut senden. Mit **Jetzt buchen** läuft der Abgleich sofort.
|
||||
|
||||
## Entwickeln und testen ohne Kasse
|
||||
|
||||
`docker/caspos-mock/server.php` bildet die benötigten OrderApp-API-Aufrufe nach (inkl. Fehlercodes 403,
|
||||
401/1001, 401/1002). `docker/test-casposbridge.php` legt eine Testbuchung an, checkt ein und prüft
|
||||
Buchung, Wiederholung, Ausfall der Kasse, unbekannte Artikel und das Zurücknehmen des Check-ins:
|
||||
|
||||
```bash
|
||||
docker compose -f docker-compose.yml -f docker-compose.server.yml exec -d -u www-data app php -S 127.0.0.1:8088 docker/caspos-mock/server.php
|
||||
docker compose -f docker-compose.yml -f docker-compose.server.yml exec -u www-data app php docker/test-casposbridge.php
|
||||
```
|
||||
|
||||
## Gegen eine echte Kasse noch zu prüfen
|
||||
|
||||
Die API ist aus dem Quellcode der CASPOS OrderApp (`lib/api/api.dart`) abgeleitet, nicht aus einer Doku:
|
||||
|
||||
- Einheit von `enteredPrice` (Annahme: Cent) – nur relevant für `@preis`.
|
||||
- Legt `openByName` unbekannte Tische an oder müssen sie existieren?
|
||||
- Muss der Client einmalig in der Kasse freigeschaltet werden?
|
||||
- Verhalten von `finalizeOrder` gegenüber TSE und Bonausgabe (Bestellbon für Zimmer-Tische ggf. abschalten).
|
||||
327
modules/casposbridge/casposbridge.php
Normal file
327
modules/casposbridge/casposbridge.php
Normal file
@@ -0,0 +1,327 @@
|
||||
<?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>';
|
||||
}
|
||||
}
|
||||
269
modules/casposbridge/classes/CasposApiClient.php
Normal file
269
modules/casposbridge/classes/CasposApiClient.php
Normal file
@@ -0,0 +1,269 @@
|
||||
<?php
|
||||
/**
|
||||
* Client for the CASPOS OrderApp API (http(s)://host:port/api/v2), modelled on the
|
||||
* CASPOS OrderApp (lib/api/api.dart): registerClient -> waiter login -> tables/order lines.
|
||||
* Session state is a cookie jar kept in memory for one run.
|
||||
*/
|
||||
|
||||
class CasposApiException extends Exception
|
||||
{
|
||||
/** @var int HTTP status (0 = no connection) */
|
||||
public $httpCode;
|
||||
/** @var int|null CASPOS extendedErrorCode (1001 client not registered, 1002 waiter login required) */
|
||||
public $extendedCode;
|
||||
|
||||
public function __construct($message, $httpCode = 0, $extendedCode = null)
|
||||
{
|
||||
parent::__construct($message, (int)$httpCode);
|
||||
$this->httpCode = (int)$httpCode;
|
||||
$this->extendedCode = $extendedCode;
|
||||
}
|
||||
|
||||
/** Errors where a later retry can succeed (network, POS busy/offline). */
|
||||
public function isTemporary()
|
||||
{
|
||||
return $this->httpCode === 0 || $this->httpCode >= 500 || $this->httpCode === 408;
|
||||
}
|
||||
}
|
||||
|
||||
class CasposApiClient
|
||||
{
|
||||
const TIMEOUT = 15;
|
||||
|
||||
protected $baseUrl;
|
||||
protected $fingerprint;
|
||||
protected $clientId;
|
||||
protected $clientName;
|
||||
protected $waiterId;
|
||||
protected $waiterPassword;
|
||||
protected $cookies = array();
|
||||
protected $fingerprintChecked = false;
|
||||
protected $loggedIn = false;
|
||||
|
||||
/**
|
||||
* @param array $cfg host, port, fingerprint (SHA1 "AA:BB:.." -> https), client_id, client_name,
|
||||
* waiter_id, waiter_password
|
||||
*/
|
||||
public function __construct(array $cfg)
|
||||
{
|
||||
$host = trim((string)$cfg['host']);
|
||||
$port = (int)$cfg['port'];
|
||||
if ($host === '' || $port <= 0) {
|
||||
throw new CasposApiException('Kasse: Adresse oder Port fehlt in den Einstellungen.');
|
||||
}
|
||||
$this->fingerprint = self::normalizeFingerprint(isset($cfg['fingerprint']) ? $cfg['fingerprint'] : '');
|
||||
$scheme = $this->fingerprint ? 'https' : 'http';
|
||||
$this->baseUrl = $scheme.'://'.$host.':'.$port.'/api/v2/';
|
||||
$this->clientId = (string)$cfg['client_id'];
|
||||
$this->clientName = (string)$cfg['client_name'];
|
||||
$this->waiterId = (string)$cfg['waiter_id'];
|
||||
$this->waiterPassword = (string)$cfg['waiter_password'];
|
||||
}
|
||||
|
||||
public static function normalizeFingerprint($fp)
|
||||
{
|
||||
$hex = strtoupper(preg_replace('/[^0-9a-fA-F]/', '', (string)$fp));
|
||||
return strlen($hex) === 40 ? implode(':', str_split($hex, 2)) : '';
|
||||
}
|
||||
|
||||
/** registerClient + waiter login; safe to call repeatedly. */
|
||||
public function startSession($version = '1.0.0')
|
||||
{
|
||||
if ($this->loggedIn) {
|
||||
return;
|
||||
}
|
||||
$this->request('POST', 'actions/init/registerClient', array(
|
||||
'version' => $version,
|
||||
'id' => $this->clientId,
|
||||
'clientName' => $this->clientName,
|
||||
'printerPath' => null,
|
||||
));
|
||||
$this->request('POST', 'actions/auth/login', array('id' => $this->waiterId, 'password' => $this->waiterPassword));
|
||||
$this->loggedIn = true;
|
||||
}
|
||||
|
||||
public function endSession()
|
||||
{
|
||||
if ($this->loggedIn) {
|
||||
try {
|
||||
$this->request('POST', 'actions/auth/logout');
|
||||
} catch (CasposApiException $e) {
|
||||
// logout failures are irrelevant
|
||||
}
|
||||
$this->loggedIn = false;
|
||||
}
|
||||
}
|
||||
|
||||
public function getServiceAreas()
|
||||
{
|
||||
return (array)$this->request('GET', 'data/serviceAreas');
|
||||
}
|
||||
|
||||
public function getArticles()
|
||||
{
|
||||
return (array)$this->request('GET', 'data/articles');
|
||||
}
|
||||
|
||||
/** @return array table incl. 'id' and 'subTables' */
|
||||
public function openTableByName($name, $serviceAreaId)
|
||||
{
|
||||
$table = $this->request('POST', 'actions/tables/openByName/'.rawurlencode($name).'?'.http_build_query(array(
|
||||
'serviceAreaId' => $serviceAreaId,
|
||||
'prePayment' => 'false',
|
||||
'forMove' => 'false',
|
||||
)));
|
||||
if (empty($table['id'])) {
|
||||
throw new CasposApiException('Kasse: Tisch "'.$name.'" konnte nicht geöffnet werden (keine Tisch-ID).');
|
||||
}
|
||||
return $table;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param int|null $enteredPrice price in cents for open-price articles (unit as sent by the OrderApp)
|
||||
*/
|
||||
public function addOrderLine($subTableId, $articleId, $quantity, $enteredPrice = null, $enteredText = null)
|
||||
{
|
||||
$body = array('articleId' => (string)$articleId, 'quantity' => (int)$quantity, 'modifiers' => array());
|
||||
if ($enteredPrice !== null) {
|
||||
$body['enteredPrice'] = (int)$enteredPrice;
|
||||
}
|
||||
if ($enteredText !== null && $enteredText !== '') {
|
||||
$body['enteredText'] = (string)$enteredText;
|
||||
}
|
||||
return $this->request('POST', 'actions/orderLines/add/'.rawurlencode($subTableId), $body);
|
||||
}
|
||||
|
||||
public function finalizeOrder($tableId)
|
||||
{
|
||||
$this->request('POST', 'actions/tables/finalizeOrder/'.rawurlencode($tableId));
|
||||
}
|
||||
|
||||
public function cancelOrder($tableId)
|
||||
{
|
||||
$this->request('POST', 'actions/tables/cancelOrder/'.rawurlencode($tableId));
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array|null decoded JSON (null/empty array for 204)
|
||||
* @throws CasposApiException
|
||||
*/
|
||||
protected function request($method, $path, $body = null)
|
||||
{
|
||||
if ($this->fingerprint && !$this->fingerprintChecked) {
|
||||
$this->checkFingerprint();
|
||||
}
|
||||
|
||||
$headers = array('Accept: application/json');
|
||||
if ($body !== null) {
|
||||
$headers[] = 'Content-Type: application/json';
|
||||
}
|
||||
if ($this->cookies && strpos($path, 'registerClient') === false) {
|
||||
$pairs = array();
|
||||
foreach ($this->cookies as $k => $v) {
|
||||
$pairs[] = $k.'='.$v;
|
||||
}
|
||||
$headers[] = 'Cookie: '.implode('; ', $pairs);
|
||||
}
|
||||
|
||||
$ch = curl_init($this->baseUrl.$path);
|
||||
$opts = array(
|
||||
CURLOPT_CUSTOMREQUEST => $method,
|
||||
CURLOPT_RETURNTRANSFER => true,
|
||||
CURLOPT_HEADER => true,
|
||||
CURLOPT_HTTPHEADER => $headers,
|
||||
CURLOPT_CONNECTTIMEOUT => self::TIMEOUT,
|
||||
CURLOPT_TIMEOUT => self::TIMEOUT,
|
||||
CURLOPT_USERAGENT => 'QloApps-CasposBridge/1.0',
|
||||
);
|
||||
if ($body !== null) {
|
||||
$opts[CURLOPT_POSTFIELDS] = json_encode($body);
|
||||
} elseif ($method === 'POST') {
|
||||
$opts[CURLOPT_POSTFIELDS] = '';
|
||||
}
|
||||
if ($this->fingerprint) {
|
||||
// the POS uses a self-signed certificate; trust is established by the pinned fingerprint
|
||||
$opts[CURLOPT_SSL_VERIFYPEER] = false;
|
||||
$opts[CURLOPT_SSL_VERIFYHOST] = 0;
|
||||
}
|
||||
curl_setopt_array($ch, $opts);
|
||||
$raw = curl_exec($ch);
|
||||
if ($raw === false) {
|
||||
$err = curl_error($ch);
|
||||
curl_close($ch);
|
||||
throw new CasposApiException('Kasse nicht erreichbar ('.$this->baseUrl.'): '.$err, 0);
|
||||
}
|
||||
$code = (int)curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||
$headerSize = (int)curl_getinfo($ch, CURLINFO_HEADER_SIZE);
|
||||
curl_close($ch);
|
||||
|
||||
$this->storeCookies(substr($raw, 0, $headerSize));
|
||||
$responseBody = substr($raw, $headerSize);
|
||||
$data = $responseBody === '' ? array() : json_decode($responseBody, true);
|
||||
|
||||
if ($code >= 200 && $code < 300) {
|
||||
return $data;
|
||||
}
|
||||
throw $this->buildError($code, is_array($data) ? $data : array(), $path);
|
||||
}
|
||||
|
||||
protected function buildError($code, array $data, $path)
|
||||
{
|
||||
$ext = isset($data['extendedErrorCode']) ? (int)$data['extendedErrorCode'] : null;
|
||||
$detail = isset($data['errorMsg']) ? ' ('.$data['errorMsg'].')' : '';
|
||||
if ($code === 403 && strpos($path, 'registerClient') !== false) {
|
||||
$msg = 'Kasse: Dieses Gerät ist in der OrderApp-Verwaltung der Kasse nicht freigeschaltet.';
|
||||
} elseif ($code === 401 && $ext === 1001) {
|
||||
$msg = 'Kasse: Client nicht registriert.';
|
||||
$this->loggedIn = false;
|
||||
} elseif ($code === 401 && $ext === 1002) {
|
||||
$msg = 'Kasse: Kellner-Anmeldung erforderlich.';
|
||||
$this->loggedIn = false;
|
||||
} elseif ($code === 401) {
|
||||
$msg = 'Kasse: Anmeldung abgelehnt (Kellner-ID oder Passwort prüfen).';
|
||||
$this->loggedIn = false;
|
||||
} elseif ($code === 404) {
|
||||
$msg = 'Kasse: Adresse nicht gefunden – API-Port und Version (api/v2) prüfen.';
|
||||
} else {
|
||||
$msg = 'Kasse: Fehler HTTP '.$code;
|
||||
}
|
||||
return new CasposApiException($msg.$detail, $code, $ext);
|
||||
}
|
||||
|
||||
protected function storeCookies($rawHeaders)
|
||||
{
|
||||
if (preg_match_all('/^Set-Cookie:\s*([^=;\s]+)=([^;\r\n]*)/mi', $rawHeaders, $m, PREG_SET_ORDER)) {
|
||||
foreach ($m as $c) {
|
||||
$this->cookies[$c[1]] = $c[2];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Compare the server certificate's SHA1 fingerprint before any credentials are sent. */
|
||||
protected function checkFingerprint()
|
||||
{
|
||||
$ch = curl_init($this->baseUrl.'hostStatus');
|
||||
curl_setopt_array($ch, array(
|
||||
CURLOPT_RETURNTRANSFER => true,
|
||||
CURLOPT_NOBODY => true,
|
||||
CURLOPT_CERTINFO => true,
|
||||
CURLOPT_SSL_VERIFYPEER => false,
|
||||
CURLOPT_SSL_VERIFYHOST => 0,
|
||||
CURLOPT_CONNECTTIMEOUT => self::TIMEOUT,
|
||||
CURLOPT_TIMEOUT => self::TIMEOUT,
|
||||
));
|
||||
$ok = curl_exec($ch);
|
||||
$info = curl_getinfo($ch, CURLINFO_CERTINFO);
|
||||
$err = curl_error($ch);
|
||||
curl_close($ch);
|
||||
if ($ok === false && empty($info)) {
|
||||
throw new CasposApiException('Kasse nicht erreichbar: '.$err, 0);
|
||||
}
|
||||
$pem = isset($info[0]['Cert']) ? $info[0]['Cert'] : '';
|
||||
$der = base64_decode(preg_replace('/-----(BEGIN|END) CERTIFICATE-----|\s+/', '', $pem));
|
||||
$actual = $der ? implode(':', str_split(strtoupper(sha1($der)), 2)) : '';
|
||||
if ($actual !== $this->fingerprint) {
|
||||
throw new CasposApiException('Kasse: Zertifikat passt nicht zum hinterlegten Fingerabdruck (erhalten: '.($actual ?: 'keins').').', 495);
|
||||
}
|
||||
$this->fingerprintChecked = true;
|
||||
}
|
||||
}
|
||||
369
modules/casposbridge/classes/CasposBridgeService.php
Normal file
369
modules/casposbridge/classes/CasposBridgeService.php
Normal file
@@ -0,0 +1,369 @@
|
||||
<?php
|
||||
/**
|
||||
* Posts hotel nights to CASPOS tables.
|
||||
*
|
||||
* One job per booked room and night (unique id_htl_booking + night). Jobs are created for
|
||||
* every night of a checked-in stay up to today and posted to the table named after the
|
||||
* room number. Status flow: pending -> added (lines on the table, not committed yet) -> done.
|
||||
* A job in "added" is only finalized on retry, so lines are never booked twice.
|
||||
*/
|
||||
|
||||
require_once dirname(__FILE__).'/CasposApiClient.php';
|
||||
|
||||
class CasposBridgeService
|
||||
{
|
||||
const TABLE = 'casposbridge_job';
|
||||
const MAX_ATTEMPTS = 10;
|
||||
|
||||
const STATUS_PENDING = 'pending';
|
||||
const STATUS_ADDED = 'added';
|
||||
const STATUS_DONE = 'done';
|
||||
const STATUS_FAILED = 'failed';
|
||||
|
||||
/** @var CasposApiClient|null */
|
||||
protected $client;
|
||||
/** @var callable|null for tests */
|
||||
protected $clientFactory;
|
||||
|
||||
public function __construct($clientFactory = null)
|
||||
{
|
||||
$this->clientFactory = $clientFactory;
|
||||
}
|
||||
|
||||
public static function installDb()
|
||||
{
|
||||
return Db::getInstance()->execute('
|
||||
CREATE TABLE IF NOT EXISTS `'._DB_PREFIX_.self::TABLE.'` (
|
||||
`id_job` INT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
`id_htl_booking` INT UNSIGNED NOT NULL,
|
||||
`id_order` INT UNSIGNED NOT NULL DEFAULT 0,
|
||||
`night` DATE NOT NULL,
|
||||
`table_name` VARCHAR(64) NOT NULL,
|
||||
`status` VARCHAR(16) NOT NULL,
|
||||
`attempts` INT UNSIGNED NOT NULL DEFAULT 0,
|
||||
`caspos_table_id` VARCHAR(64) NULL,
|
||||
`lines_json` TEXT NULL,
|
||||
`last_error` VARCHAR(500) NULL,
|
||||
`date_add` DATETIME NOT NULL,
|
||||
`date_upd` DATETIME NOT NULL,
|
||||
PRIMARY KEY (`id_job`),
|
||||
UNIQUE KEY `booking_night` (`id_htl_booking`, `night`),
|
||||
KEY `status` (`status`)
|
||||
) ENGINE='._MYSQL_ENGINE_.' DEFAULT CHARSET=utf8mb4');
|
||||
}
|
||||
|
||||
public static function uninstallDb()
|
||||
{
|
||||
return Db::getInstance()->execute('DROP TABLE IF EXISTS `'._DB_PREFIX_.self::TABLE.'`');
|
||||
}
|
||||
|
||||
public static function isEnabled()
|
||||
{
|
||||
return (bool)Configuration::get('CASPOSBRIDGE_ENABLED');
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ configuration
|
||||
|
||||
public static function clientConfig()
|
||||
{
|
||||
return array(
|
||||
'host' => Configuration::get('CASPOSBRIDGE_HOST'),
|
||||
'port' => Configuration::get('CASPOSBRIDGE_PORT'),
|
||||
'fingerprint' => Configuration::get('CASPOSBRIDGE_FINGERPRINT'),
|
||||
'client_id' => Configuration::get('CASPOSBRIDGE_CLIENT_ID'),
|
||||
'client_name' => Configuration::get('CASPOSBRIDGE_CLIENT_NAME'),
|
||||
'waiter_id' => Configuration::get('CASPOSBRIDGE_WAITER_ID'),
|
||||
'waiter_password' => Configuration::get('CASPOSBRIDGE_WAITER_PASSWORD'),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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]
|
||||
* QTY: number, "erwachsene", "kinder" or "gaeste" (per night)
|
||||
* @preis: send the room's price per night (gross, cents) as enteredPrice (open-price article)
|
||||
*
|
||||
* @return array list of ['article' => id, 'qty' => int|string, 'price' => bool]
|
||||
*/
|
||||
public static function parseRule($rule)
|
||||
{
|
||||
$items = array();
|
||||
foreach (preg_split('/[,;\n]+/', (string)$rule) as $part) {
|
||||
$part = trim($part);
|
||||
if ($part === '') {
|
||||
continue;
|
||||
}
|
||||
$price = false;
|
||||
if (preg_match('/@preis$/i', $part)) {
|
||||
$price = true;
|
||||
$part = trim(preg_replace('/@preis$/i', '', $part));
|
||||
}
|
||||
$qty = 1;
|
||||
if (strpos($part, '*') !== false) {
|
||||
list($part, $q) = array_map('trim', explode('*', $part, 2));
|
||||
$q = Tools::strtolower($q);
|
||||
if (ctype_digit($q) && (int)$q > 0) {
|
||||
$qty = (int)$q;
|
||||
} elseif (in_array($q, array('erwachsene', 'kinder', 'gaeste', 'gäste'), true)) {
|
||||
$qty = $q === 'gäste' ? 'gaeste' : $q;
|
||||
} else {
|
||||
throw new InvalidArgumentException('Unbekannte Menge "'.$q.'" (Zahl, erwachsene, kinder oder gaeste).');
|
||||
}
|
||||
}
|
||||
if (!preg_match('/^[A-Za-z0-9._:\-{}]+$/', $part)) {
|
||||
throw new InvalidArgumentException('Ungültige Artikel-ID "'.$part.'".');
|
||||
}
|
||||
$items[] = array('article' => $part, 'qty' => $qty, 'price' => $price);
|
||||
}
|
||||
return $items;
|
||||
}
|
||||
|
||||
public static function getMapping()
|
||||
{
|
||||
$map = json_decode((string)Configuration::get('CASPOSBRIDGE_MAPPING'), true);
|
||||
return is_array($map) ? $map : array();
|
||||
}
|
||||
|
||||
public static function tableNameFor(array $booking)
|
||||
{
|
||||
$tpl = Configuration::get('CASPOSBRIDGE_TABLE_TEMPLATE') ?: '{room}';
|
||||
return trim(str_replace('{room}', trim((string)$booking['room_num']), $tpl));
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ jobs
|
||||
|
||||
/** Create jobs for every night of a checked-in stay up to today. Returns number of new jobs. */
|
||||
public function ensureJobsForBooking($idHtlBooking, $today = null)
|
||||
{
|
||||
$b = Db::getInstance()->getRow('SELECT * FROM `'._DB_PREFIX_.'htl_booking_detail` WHERE `id` = '.(int)$idHtlBooking);
|
||||
if (!$b || (int)$b['id_status'] !== HotelBookingDetail::STATUS_CHECKED_IN) {
|
||||
return 0;
|
||||
}
|
||||
$today = $today ?: date('Y-m-d');
|
||||
$from = Tools::substr($b['date_from'], 0, 10);
|
||||
$lastNight = date('Y-m-d', strtotime(Tools::substr($b['date_to'], 0, 10).' -1 day'));
|
||||
$until = min($today, $lastNight);
|
||||
$table = self::tableNameFor($b);
|
||||
$now = date('Y-m-d H:i:s');
|
||||
$created = 0;
|
||||
for ($night = $from; $night <= $until; $night = date('Y-m-d', strtotime($night.' +1 day'))) {
|
||||
Db::getInstance()->execute('INSERT IGNORE INTO `'._DB_PREFIX_.self::TABLE.'`
|
||||
(`id_htl_booking`, `id_order`, `night`, `table_name`, `status`, `date_add`, `date_upd`) VALUES ('
|
||||
.(int)$b['id'].', '.(int)$b['id_order'].', \''.pSQL($night).'\', \''.pSQL($table).'\', \''.self::STATUS_PENDING.'\', \''.$now.'\', \''.$now.'\')');
|
||||
$created += (int)Db::getInstance()->Affected_Rows();
|
||||
}
|
||||
return $created;
|
||||
}
|
||||
|
||||
/** Called by cron: create missing jobs for all guests currently checked in. */
|
||||
public function ensureJobsForCheckedInGuests($today = null)
|
||||
{
|
||||
$ids = Db::getInstance()->executeS('SELECT `id` FROM `'._DB_PREFIX_.'htl_booking_detail` WHERE `id_status` = '.(int)HotelBookingDetail::STATUS_CHECKED_IN);
|
||||
$n = 0;
|
||||
foreach ((array)$ids as $row) {
|
||||
$n += $this->ensureJobsForBooking((int)$row['id'], $today);
|
||||
}
|
||||
return $n;
|
||||
}
|
||||
|
||||
/**
|
||||
* Post open jobs to the POS.
|
||||
* @param int|null $idHtlBooking only this booking (check-in hook)
|
||||
* @return array ['done' => n, 'failed' => n, 'retry' => n, 'errors' => [..]]
|
||||
*/
|
||||
public function processJobs($idHtlBooking = null, $limit = 200)
|
||||
{
|
||||
$result = array('done' => 0, 'failed' => 0, 'retry' => 0, 'errors' => array());
|
||||
$where = '`status` IN (\''.self::STATUS_PENDING.'\', \''.self::STATUS_ADDED.'\') AND `attempts` < '.(int)self::MAX_ATTEMPTS;
|
||||
if ($idHtlBooking) {
|
||||
$where .= ' AND `id_htl_booking` = '.(int)$idHtlBooking;
|
||||
}
|
||||
$jobs = Db::getInstance()->executeS('SELECT * FROM `'._DB_PREFIX_.self::TABLE.'` WHERE '.$where.' ORDER BY `night`, `id_job` LIMIT '.(int)$limit);
|
||||
if (!$jobs) {
|
||||
return $result;
|
||||
}
|
||||
|
||||
try {
|
||||
$client = $this->client();
|
||||
$client->startSession();
|
||||
} catch (Exception $e) {
|
||||
// POS offline or login refused: count as a retry for every job, keep them queued
|
||||
foreach ($jobs as $job) {
|
||||
$this->touch($job, null, $e->getMessage());
|
||||
}
|
||||
$result['retry'] = count($jobs);
|
||||
$result['errors'][] = $e->getMessage();
|
||||
return $result;
|
||||
}
|
||||
|
||||
foreach ($jobs as $job) {
|
||||
try {
|
||||
$this->postJob($client, $job);
|
||||
$result['done']++;
|
||||
} catch (Exception $e) {
|
||||
$permanent = ($e instanceof InvalidArgumentException)
|
||||
|| ($e instanceof CasposApiException && !$e->isTemporary() && $e->httpCode !== 401);
|
||||
$this->touch($job, $permanent ? self::STATUS_FAILED : null, $e->getMessage());
|
||||
$result[$permanent ? 'failed' : 'retry']++;
|
||||
$result['errors'][] = 'Zimmer '.$job['table_name'].', Nacht '.$job['night'].': '.$e->getMessage();
|
||||
if ($e instanceof CasposApiException && ($e->httpCode === 0 || $e->httpCode === 401)) {
|
||||
// connection or session lost: stop this run, the rest is retried next time
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
$client->endSession();
|
||||
return $result;
|
||||
}
|
||||
|
||||
protected function postJob(CasposApiClient $client, array $job)
|
||||
{
|
||||
$booking = Db::getInstance()->getRow('SELECT * FROM `'._DB_PREFIX_.'htl_booking_detail` WHERE `id` = '.(int)$job['id_htl_booking']);
|
||||
if (!$booking) {
|
||||
throw new InvalidArgumentException('Buchung nicht mehr vorhanden.');
|
||||
}
|
||||
|
||||
if ($job['status'] === self::STATUS_ADDED && $job['caspos_table_id']) {
|
||||
// lines are already on the table, only the commit is missing
|
||||
$client->finalizeOrder($job['caspos_table_id']);
|
||||
$this->touch($job, self::STATUS_DONE, null, true);
|
||||
return;
|
||||
}
|
||||
|
||||
$lines = $this->buildLines($booking, $job['night']);
|
||||
$serviceArea = Configuration::get('CASPOSBRIDGE_SERVICE_AREA');
|
||||
if (!$serviceArea) {
|
||||
throw new InvalidArgumentException('Kein Tischbereich (Service Area) eingestellt.');
|
||||
}
|
||||
if ($job['table_name'] === '') {
|
||||
throw new InvalidArgumentException('Buchung hat keine Zimmernummer.');
|
||||
}
|
||||
|
||||
$table = $client->openTableByName($job['table_name'], $serviceArea);
|
||||
$sub = isset($table['subTables'][0]['id']) ? $table['subTables'][0]['id'] : null;
|
||||
foreach ((array)(isset($table['subTables']) ? $table['subTables'] : array()) as $st) {
|
||||
if (!empty($st['isSelected'])) {
|
||||
$sub = $st['id'];
|
||||
}
|
||||
}
|
||||
if (!$sub) {
|
||||
throw new CasposApiException('Kasse: Tisch '.$job['table_name'].' hat keinen Untertisch.', 422);
|
||||
}
|
||||
|
||||
try {
|
||||
foreach ($lines as $l) {
|
||||
$client->addOrderLine($sub, $l['article'], $l['quantity'], $l['enteredPrice'], $l['text']);
|
||||
}
|
||||
} catch (Exception $e) {
|
||||
// drop the half-added lines so a retry starts clean
|
||||
try {
|
||||
$client->cancelOrder($table['id']);
|
||||
} catch (Exception $ignored) {
|
||||
}
|
||||
throw $e;
|
||||
}
|
||||
|
||||
Db::getInstance()->update(self::TABLE, array(
|
||||
'status' => self::STATUS_ADDED,
|
||||
'caspos_table_id' => pSQL($table['id']),
|
||||
'lines_json' => pSQL(json_encode($lines, JSON_UNESCAPED_UNICODE)),
|
||||
'date_upd' => date('Y-m-d H:i:s'),
|
||||
), '`id_job` = '.(int)$job['id_job']);
|
||||
|
||||
$client->finalizeOrder($table['id']);
|
||||
$this->touch(array_merge($job, array('status' => self::STATUS_ADDED)), self::STATUS_DONE, null, true);
|
||||
}
|
||||
|
||||
/** @return array lines with article, quantity, enteredPrice (cents|null), text */
|
||||
public function buildLines(array $booking, $night)
|
||||
{
|
||||
$map = self::getMapping();
|
||||
$rule = isset($map[(int)$booking['id_product']]) ? $map[(int)$booking['id_product']] : '';
|
||||
$items = self::parseRule($rule);
|
||||
if (!$items) {
|
||||
throw new InvalidArgumentException('Für den Zimmertyp "'.$booking['room_type_name'].'" sind keine CASPOS-Artikel hinterlegt.');
|
||||
}
|
||||
$adults = (int)$booking['adults'];
|
||||
$children = (int)$booking['children'];
|
||||
$nights = max(1, (int)round((strtotime(Tools::substr($booking['date_to'], 0, 10)) - strtotime(Tools::substr($booking['date_from'], 0, 10))) / 86400));
|
||||
$nightCents = (int)round((float)$booking['total_price_tax_incl'] * 100 / $nights);
|
||||
$text = 'Nacht '.date('d.m.Y', strtotime($night)).' – Buchung #'.(int)$booking['id_order'];
|
||||
|
||||
$lines = array();
|
||||
foreach ($items as $it) {
|
||||
$qty = $it['qty'];
|
||||
if ($qty === 'erwachsene') {
|
||||
$qty = $adults;
|
||||
} elseif ($qty === 'kinder') {
|
||||
$qty = $children;
|
||||
} elseif ($qty === 'gaeste') {
|
||||
$qty = $adults + $children;
|
||||
}
|
||||
if ((int)$qty <= 0) {
|
||||
continue;
|
||||
}
|
||||
$lines[] = array(
|
||||
'article' => $it['article'],
|
||||
'quantity' => (int)$qty,
|
||||
'enteredPrice' => $it['price'] ? $nightCents : null,
|
||||
'text' => $text,
|
||||
);
|
||||
}
|
||||
if (!$lines) {
|
||||
throw new InvalidArgumentException('Keine Positionen für diese Nacht (Menge 0).');
|
||||
}
|
||||
return $lines;
|
||||
}
|
||||
|
||||
protected function touch(array $job, $status, $error, $resetError = false)
|
||||
{
|
||||
$data = array('date_upd' => date('Y-m-d H:i:s'));
|
||||
if ($status) {
|
||||
$data['status'] = pSQL($status);
|
||||
}
|
||||
if ($status !== self::STATUS_DONE) {
|
||||
$data['attempts'] = (int)$job['attempts'] + 1;
|
||||
}
|
||||
if ($error !== null) {
|
||||
$data['last_error'] = pSQL(Tools::substr($error, 0, 500));
|
||||
} elseif ($resetError) {
|
||||
$data['last_error'] = null;
|
||||
}
|
||||
Db::getInstance()->update(self::TABLE, $data, '`id_job` = '.(int)$job['id_job'], 0, true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove jobs that never reached the POS (check-in undone). Returns how many nights
|
||||
* were already posted and need a manual void at the POS.
|
||||
*/
|
||||
public static function dropOpenJobs($idHtlBooking)
|
||||
{
|
||||
Db::getInstance()->delete(self::TABLE, '`id_htl_booking` = '.(int)$idHtlBooking
|
||||
.' AND `status` IN (\''.self::STATUS_PENDING.'\', \''.self::STATUS_FAILED.'\') AND `caspos_table_id` IS NULL');
|
||||
return (int)Db::getInstance()->getValue('SELECT COUNT(*) FROM `'._DB_PREFIX_.self::TABLE.'` WHERE `id_htl_booking` = '.(int)$idHtlBooking
|
||||
.' AND `status` IN (\''.self::STATUS_ADDED.'\', \''.self::STATUS_DONE.'\')');
|
||||
}
|
||||
|
||||
/** Re-queue a failed job. If its lines already reached the table, only the commit is retried. */
|
||||
public static function retryJob($idJob)
|
||||
{
|
||||
return Db::getInstance()->execute('UPDATE `'._DB_PREFIX_.self::TABLE.'`
|
||||
SET `status` = IF(`caspos_table_id` IS NOT NULL AND `lines_json` IS NOT NULL, \''.self::STATUS_ADDED.'\', \''.self::STATUS_PENDING.'\'),
|
||||
`attempts` = 0, `date_upd` = NOW()
|
||||
WHERE `id_job` = '.(int)$idJob.' AND `status` = \''.self::STATUS_FAILED.'\'');
|
||||
}
|
||||
|
||||
public static function latestJobs($limit = 50)
|
||||
{
|
||||
return Db::getInstance()->executeS('SELECT * FROM `'._DB_PREFIX_.self::TABLE.'` ORDER BY `id_job` DESC LIMIT '.(int)$limit);
|
||||
}
|
||||
|
||||
protected function client()
|
||||
{
|
||||
if (!$this->client) {
|
||||
$this->client = $this->clientFactory ? call_user_func($this->clientFactory) : new CasposApiClient(self::clientConfig());
|
||||
}
|
||||
return $this->client;
|
||||
}
|
||||
}
|
||||
32
modules/casposbridge/classes/index.php
Normal file
32
modules/casposbridge/classes/index.php
Normal file
@@ -0,0 +1,32 @@
|
||||
<?php
|
||||
/**
|
||||
* NOTICE OF LICENSE
|
||||
*
|
||||
* This source file is subject to the Open Software License version 3.0
|
||||
* that is bundled with this package in the file LICENSE.md
|
||||
* It is also available through the world-wide-web at this URL:
|
||||
* https://opensource.org/license/osl-3-0-php
|
||||
* If you did not receive a copy of the license and are unable to
|
||||
* obtain it through the world-wide-web, please send an email
|
||||
* to support@qloapps.com so we can send you a copy immediately.
|
||||
*
|
||||
* DISCLAIMER
|
||||
*
|
||||
* Do not edit or add to this file if you wish to upgrade this module to a newer
|
||||
* versions in the future. If you wish to customize this module for your needs
|
||||
* please refer to https://store.webkul.com/customisation-guidelines for more information.
|
||||
*
|
||||
* @author Webkul IN
|
||||
* @copyright Since 2010 Webkul
|
||||
* @license https://opensource.org/license/osl-3-0-php Open Software License version 3.0
|
||||
*/
|
||||
|
||||
header('Expires: Mon, 26 Jul 1997 05:00:00 GMT');
|
||||
header('Last-Modified: '.gmdate('D, d M Y H:i:s').' GMT');
|
||||
|
||||
header('Cache-Control: no-store, no-cache, must-revalidate');
|
||||
header('Cache-Control: post-check=0, pre-check=0', false);
|
||||
header('Pragma: no-cache');
|
||||
|
||||
header('Location: ../../../');
|
||||
exit;
|
||||
28
modules/casposbridge/cron.php
Normal file
28
modules/casposbridge/cron.php
Normal file
@@ -0,0 +1,28 @@
|
||||
<?php
|
||||
/**
|
||||
* Post the nights of checked-in guests to CASPOS (command line only).
|
||||
* docker compose exec -u www-data app php modules/casposbridge/cron.php
|
||||
* Use this with Windows Task Scheduler if the QloApps Cron Task Manager is not running.
|
||||
*/
|
||||
if (PHP_SAPI !== 'cli') {
|
||||
header('HTTP/1.1 403 Forbidden');
|
||||
exit;
|
||||
}
|
||||
define('_PS_ADMIN_DIR_', __DIR__);
|
||||
require dirname(__FILE__).'/../../config/config.inc.php';
|
||||
|
||||
$module = Module::getInstanceByName('casposbridge');
|
||||
if (!$module || !Module::isEnabled('casposbridge')) {
|
||||
fwrite(STDERR, "Modul casposbridge ist nicht installiert oder deaktiviert.\n");
|
||||
exit(1);
|
||||
}
|
||||
$res = $module->cronPostNights();
|
||||
if ($res === true) {
|
||||
echo "Anbindung ist ausgeschaltet.\n";
|
||||
exit(0);
|
||||
}
|
||||
printf("gebucht: %d, erneut versuchen: %d, fehlgeschlagen: %d\n", $res['done'], $res['retry'], $res['failed']);
|
||||
foreach ($res['errors'] as $e) {
|
||||
echo ' - '.$e."\n";
|
||||
}
|
||||
exit($res['failed'] || $res['retry'] ? 2 : 0);
|
||||
32
modules/casposbridge/index.php
Normal file
32
modules/casposbridge/index.php
Normal file
@@ -0,0 +1,32 @@
|
||||
<?php
|
||||
/**
|
||||
* NOTICE OF LICENSE
|
||||
*
|
||||
* This source file is subject to the Open Software License version 3.0
|
||||
* that is bundled with this package in the file LICENSE.md
|
||||
* It is also available through the world-wide-web at this URL:
|
||||
* https://opensource.org/license/osl-3-0-php
|
||||
* If you did not receive a copy of the license and are unable to
|
||||
* obtain it through the world-wide-web, please send an email
|
||||
* to support@qloapps.com so we can send you a copy immediately.
|
||||
*
|
||||
* DISCLAIMER
|
||||
*
|
||||
* Do not edit or add to this file if you wish to upgrade this module to a newer
|
||||
* versions in the future. If you wish to customize this module for your needs
|
||||
* please refer to https://store.webkul.com/customisation-guidelines for more information.
|
||||
*
|
||||
* @author Webkul IN
|
||||
* @copyright Since 2010 Webkul
|
||||
* @license https://opensource.org/license/osl-3-0-php Open Software License version 3.0
|
||||
*/
|
||||
|
||||
header('Expires: Mon, 26 Jul 1997 05:00:00 GMT');
|
||||
header('Last-Modified: '.gmdate('D, d M Y H:i:s').' GMT');
|
||||
|
||||
header('Cache-Control: no-store, no-cache, must-revalidate');
|
||||
header('Cache-Control: post-check=0, pre-check=0', false);
|
||||
header('Pragma: no-cache');
|
||||
|
||||
header('Location: ../../../');
|
||||
exit;
|
||||
BIN
modules/casposbridge/logo.png
Normal file
BIN
modules/casposbridge/logo.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 5.7 KiB |
Reference in New Issue
Block a user