Files
hotel/docker/caspos-mock/server.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

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));