Files
hotel/docker/test-casposbridge.php
DanielS b6eebfcb90 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>
2026-11-11 23:07:28 +01:00

168 lines
9.5 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');
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);