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>
221 lines
8.4 KiB
PHP
221 lines
8.4 KiB
PHP
<?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));
|