Files
hotel/modules/casposbridge/classes/CasposApiClient.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

270 lines
9.9 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?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;
}
}