- 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>
170 lines
9.8 KiB
PHP
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);
|