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>
270 lines
9.9 KiB
PHP
270 lines
9.9 KiB
PHP
<?php
|
||
/**
|
||
* Client for the CASPOS OrderApp API (http(s)://host:port/api/v2), modelled on the
|
||
* CASPOS OrderApp (lib/api/api.dart): registerClient -> waiter login -> tables/order lines.
|
||
* Session state is a cookie jar kept in memory for one run.
|
||
*/
|
||
|
||
class CasposApiException extends Exception
|
||
{
|
||
/** @var int HTTP status (0 = no connection) */
|
||
public $httpCode;
|
||
/** @var int|null CASPOS extendedErrorCode (1001 client not registered, 1002 waiter login required) */
|
||
public $extendedCode;
|
||
|
||
public function __construct($message, $httpCode = 0, $extendedCode = null)
|
||
{
|
||
parent::__construct($message, (int)$httpCode);
|
||
$this->httpCode = (int)$httpCode;
|
||
$this->extendedCode = $extendedCode;
|
||
}
|
||
|
||
/** Errors where a later retry can succeed (network, POS busy/offline). */
|
||
public function isTemporary()
|
||
{
|
||
return $this->httpCode === 0 || $this->httpCode >= 500 || $this->httpCode === 408;
|
||
}
|
||
}
|
||
|
||
class CasposApiClient
|
||
{
|
||
const TIMEOUT = 15;
|
||
|
||
protected $baseUrl;
|
||
protected $fingerprint;
|
||
protected $clientId;
|
||
protected $clientName;
|
||
protected $waiterId;
|
||
protected $waiterPassword;
|
||
protected $cookies = array();
|
||
protected $fingerprintChecked = false;
|
||
protected $loggedIn = false;
|
||
|
||
/**
|
||
* @param array $cfg host, port, fingerprint (SHA1 "AA:BB:.." -> https), client_id, client_name,
|
||
* waiter_id, waiter_password
|
||
*/
|
||
public function __construct(array $cfg)
|
||
{
|
||
$host = trim((string)$cfg['host']);
|
||
$port = (int)$cfg['port'];
|
||
if ($host === '' || $port <= 0) {
|
||
throw new CasposApiException('Kasse: Adresse oder Port fehlt in den Einstellungen.');
|
||
}
|
||
$this->fingerprint = self::normalizeFingerprint(isset($cfg['fingerprint']) ? $cfg['fingerprint'] : '');
|
||
$scheme = $this->fingerprint ? 'https' : 'http';
|
||
$this->baseUrl = $scheme.'://'.$host.':'.$port.'/api/v2/';
|
||
$this->clientId = (string)$cfg['client_id'];
|
||
$this->clientName = (string)$cfg['client_name'];
|
||
$this->waiterId = (string)$cfg['waiter_id'];
|
||
$this->waiterPassword = (string)$cfg['waiter_password'];
|
||
}
|
||
|
||
public static function normalizeFingerprint($fp)
|
||
{
|
||
$hex = strtoupper(preg_replace('/[^0-9a-fA-F]/', '', (string)$fp));
|
||
return strlen($hex) === 40 ? implode(':', str_split($hex, 2)) : '';
|
||
}
|
||
|
||
/** registerClient + waiter login; safe to call repeatedly. */
|
||
public function startSession($version = '1.0.0')
|
||
{
|
||
if ($this->loggedIn) {
|
||
return;
|
||
}
|
||
$this->request('POST', 'actions/init/registerClient', array(
|
||
'version' => $version,
|
||
'id' => $this->clientId,
|
||
'clientName' => $this->clientName,
|
||
'printerPath' => null,
|
||
));
|
||
$this->request('POST', 'actions/auth/login', array('id' => $this->waiterId, 'password' => $this->waiterPassword));
|
||
$this->loggedIn = true;
|
||
}
|
||
|
||
public function endSession()
|
||
{
|
||
if ($this->loggedIn) {
|
||
try {
|
||
$this->request('POST', 'actions/auth/logout');
|
||
} catch (CasposApiException $e) {
|
||
// logout failures are irrelevant
|
||
}
|
||
$this->loggedIn = false;
|
||
}
|
||
}
|
||
|
||
public function getServiceAreas()
|
||
{
|
||
return (array)$this->request('GET', 'data/serviceAreas');
|
||
}
|
||
|
||
public function getArticles()
|
||
{
|
||
return (array)$this->request('GET', 'data/articles');
|
||
}
|
||
|
||
/** @return array table incl. 'id' and 'subTables' */
|
||
public function openTableByName($name, $serviceAreaId)
|
||
{
|
||
$table = $this->request('POST', 'actions/tables/openByName/'.rawurlencode($name).'?'.http_build_query(array(
|
||
'serviceAreaId' => $serviceAreaId,
|
||
'prePayment' => 'false',
|
||
'forMove' => 'false',
|
||
)));
|
||
if (empty($table['id'])) {
|
||
throw new CasposApiException('Kasse: Tisch "'.$name.'" konnte nicht geöffnet werden (keine Tisch-ID).');
|
||
}
|
||
return $table;
|
||
}
|
||
|
||
/**
|
||
* @param int|null $enteredPrice price in cents for open-price articles (unit as sent by the OrderApp)
|
||
*/
|
||
public function addOrderLine($subTableId, $articleId, $quantity, $enteredPrice = null, $enteredText = null)
|
||
{
|
||
$body = array('articleId' => (string)$articleId, 'quantity' => (int)$quantity, 'modifiers' => array());
|
||
if ($enteredPrice !== null) {
|
||
$body['enteredPrice'] = (int)$enteredPrice;
|
||
}
|
||
if ($enteredText !== null && $enteredText !== '') {
|
||
$body['enteredText'] = (string)$enteredText;
|
||
}
|
||
return $this->request('POST', 'actions/orderLines/add/'.rawurlencode($subTableId), $body);
|
||
}
|
||
|
||
public function finalizeOrder($tableId)
|
||
{
|
||
$this->request('POST', 'actions/tables/finalizeOrder/'.rawurlencode($tableId));
|
||
}
|
||
|
||
public function cancelOrder($tableId)
|
||
{
|
||
$this->request('POST', 'actions/tables/cancelOrder/'.rawurlencode($tableId));
|
||
}
|
||
|
||
/**
|
||
* @return array|null decoded JSON (null/empty array for 204)
|
||
* @throws CasposApiException
|
||
*/
|
||
protected function request($method, $path, $body = null)
|
||
{
|
||
if ($this->fingerprint && !$this->fingerprintChecked) {
|
||
$this->checkFingerprint();
|
||
}
|
||
|
||
$headers = array('Accept: application/json');
|
||
if ($body !== null) {
|
||
$headers[] = 'Content-Type: application/json';
|
||
}
|
||
if ($this->cookies && strpos($path, 'registerClient') === false) {
|
||
$pairs = array();
|
||
foreach ($this->cookies as $k => $v) {
|
||
$pairs[] = $k.'='.$v;
|
||
}
|
||
$headers[] = 'Cookie: '.implode('; ', $pairs);
|
||
}
|
||
|
||
$ch = curl_init($this->baseUrl.$path);
|
||
$opts = array(
|
||
CURLOPT_CUSTOMREQUEST => $method,
|
||
CURLOPT_RETURNTRANSFER => true,
|
||
CURLOPT_HEADER => true,
|
||
CURLOPT_HTTPHEADER => $headers,
|
||
CURLOPT_CONNECTTIMEOUT => self::TIMEOUT,
|
||
CURLOPT_TIMEOUT => self::TIMEOUT,
|
||
CURLOPT_USERAGENT => 'QloApps-CasposBridge/1.0',
|
||
);
|
||
if ($body !== null) {
|
||
$opts[CURLOPT_POSTFIELDS] = json_encode($body);
|
||
} elseif ($method === 'POST') {
|
||
$opts[CURLOPT_POSTFIELDS] = '';
|
||
}
|
||
if ($this->fingerprint) {
|
||
// the POS uses a self-signed certificate; trust is established by the pinned fingerprint
|
||
$opts[CURLOPT_SSL_VERIFYPEER] = false;
|
||
$opts[CURLOPT_SSL_VERIFYHOST] = 0;
|
||
}
|
||
curl_setopt_array($ch, $opts);
|
||
$raw = curl_exec($ch);
|
||
if ($raw === false) {
|
||
$err = curl_error($ch);
|
||
curl_close($ch);
|
||
throw new CasposApiException('Kasse nicht erreichbar ('.$this->baseUrl.'): '.$err, 0);
|
||
}
|
||
$code = (int)curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||
$headerSize = (int)curl_getinfo($ch, CURLINFO_HEADER_SIZE);
|
||
curl_close($ch);
|
||
|
||
$this->storeCookies(substr($raw, 0, $headerSize));
|
||
$responseBody = substr($raw, $headerSize);
|
||
$data = $responseBody === '' ? array() : json_decode($responseBody, true);
|
||
|
||
if ($code >= 200 && $code < 300) {
|
||
return $data;
|
||
}
|
||
throw $this->buildError($code, is_array($data) ? $data : array(), $path);
|
||
}
|
||
|
||
protected function buildError($code, array $data, $path)
|
||
{
|
||
$ext = isset($data['extendedErrorCode']) ? (int)$data['extendedErrorCode'] : null;
|
||
$detail = isset($data['errorMsg']) ? ' ('.$data['errorMsg'].')' : '';
|
||
if ($code === 403 && strpos($path, 'registerClient') !== false) {
|
||
$msg = 'Kasse: Dieses Gerät ist in der OrderApp-Verwaltung der Kasse nicht freigeschaltet.';
|
||
} elseif ($code === 401 && $ext === 1001) {
|
||
$msg = 'Kasse: Client nicht registriert.';
|
||
$this->loggedIn = false;
|
||
} elseif ($code === 401 && $ext === 1002) {
|
||
$msg = 'Kasse: Kellner-Anmeldung erforderlich.';
|
||
$this->loggedIn = false;
|
||
} elseif ($code === 401) {
|
||
$msg = 'Kasse: Anmeldung abgelehnt (Kellner-ID oder Passwort prüfen).';
|
||
$this->loggedIn = false;
|
||
} elseif ($code === 404) {
|
||
$msg = 'Kasse: Adresse nicht gefunden – API-Port und Version (api/v2) prüfen.';
|
||
} else {
|
||
$msg = 'Kasse: Fehler HTTP '.$code;
|
||
}
|
||
return new CasposApiException($msg.$detail, $code, $ext);
|
||
}
|
||
|
||
protected function storeCookies($rawHeaders)
|
||
{
|
||
if (preg_match_all('/^Set-Cookie:\s*([^=;\s]+)=([^;\r\n]*)/mi', $rawHeaders, $m, PREG_SET_ORDER)) {
|
||
foreach ($m as $c) {
|
||
$this->cookies[$c[1]] = $c[2];
|
||
}
|
||
}
|
||
}
|
||
|
||
/** Compare the server certificate's SHA1 fingerprint before any credentials are sent. */
|
||
protected function checkFingerprint()
|
||
{
|
||
$ch = curl_init($this->baseUrl.'hostStatus');
|
||
curl_setopt_array($ch, array(
|
||
CURLOPT_RETURNTRANSFER => true,
|
||
CURLOPT_NOBODY => true,
|
||
CURLOPT_CERTINFO => true,
|
||
CURLOPT_SSL_VERIFYPEER => false,
|
||
CURLOPT_SSL_VERIFYHOST => 0,
|
||
CURLOPT_CONNECTTIMEOUT => self::TIMEOUT,
|
||
CURLOPT_TIMEOUT => self::TIMEOUT,
|
||
));
|
||
$ok = curl_exec($ch);
|
||
$info = curl_getinfo($ch, CURLINFO_CERTINFO);
|
||
$err = curl_error($ch);
|
||
curl_close($ch);
|
||
if ($ok === false && empty($info)) {
|
||
throw new CasposApiException('Kasse nicht erreichbar: '.$err, 0);
|
||
}
|
||
$pem = isset($info[0]['Cert']) ? $info[0]['Cert'] : '';
|
||
$der = base64_decode(preg_replace('/-----(BEGIN|END) CERTIFICATE-----|\s+/', '', $pem));
|
||
$actual = $der ? implode(':', str_split(strtoupper(sha1($der)), 2)) : '';
|
||
if ($actual !== $this->fingerprint) {
|
||
throw new CasposApiException('Kasse: Zertifikat passt nicht zum hinterlegten Fingerabdruck (erhalten: '.($actual ?: 'keins').').', 495);
|
||
}
|
||
$this->fingerprintChecked = true;
|
||
}
|
||
}
|