Files
hotel/docker/test-casposbridge.php
DanielS b3b79960e7 casposbridge: verified against a real CASPOS 4.12.3
- send User-Agent "OrderApp/1.4.0 (linux) QloApps-CasposBridge/1.0" and
  version 1.4.0: CASPOS classifies non-OrderApp clients as "Orderman",
  which cannot be activated in the Order-App device manager
- fixed prices for open-price articles: ARTICLE@12.50 / @12,50 (cents)
- live test (docker/test-casposbridge-live.php) booked one night on
  table 101: 1 x 301 @ 145.00 + 2 x 101 @ 12.50, bookedAmount 17000,
  lines committed -> enteredPrice is in cents
- README: device activation, waiter key, per-area table number ranges
  ("Ungültige Tischnummer"), results
- docker/reset-admin-password.php: set a back-office password from the
  CLI (asked interactively, not echoed)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-11 23:53:20 +01:00

170 lines
9.8 KiB
PHP

<?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');
$rule = CasposBridgeService::parseRule('101*erwachsene@12,50, 301@preis');
check($rule[0]['price'] === 1250 && $rule[0]['qty'] === 'erwachsene' && $rule[1]['price'] === true, 'fixed price @12,50 -> 1250 cents, @preis kept');
echo "Undo check-in drops unsent nights\n";
$b = new HotelBookingDetail($idBooking);
$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);