Files
hotel/modules/casposbridge/classes/CasposBridgeService.php
DanielS b3b79960e7 casposbridge: verified against a real CASPOS 4.12.3
- 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>
2026-11-11 23:53:20 +01:00

373 lines
16 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
/**
* Posts hotel nights to CASPOS tables.
*
* One job per booked room and night (unique id_htl_booking + night). Jobs are created for
* every night of a checked-in stay up to today and posted to the table named after the
* room number. Status flow: pending -> added (lines on the table, not committed yet) -> done.
* A job in "added" is only finalized on retry, so lines are never booked twice.
*/
require_once dirname(__FILE__).'/CasposApiClient.php';
class CasposBridgeService
{
const TABLE = 'casposbridge_job';
const MAX_ATTEMPTS = 10;
const STATUS_PENDING = 'pending';
const STATUS_ADDED = 'added';
const STATUS_DONE = 'done';
const STATUS_FAILED = 'failed';
/** @var CasposApiClient|null */
protected $client;
/** @var callable|null for tests */
protected $clientFactory;
public function __construct($clientFactory = null)
{
$this->clientFactory = $clientFactory;
}
public static function installDb()
{
return Db::getInstance()->execute('
CREATE TABLE IF NOT EXISTS `'._DB_PREFIX_.self::TABLE.'` (
`id_job` INT UNSIGNED NOT NULL AUTO_INCREMENT,
`id_htl_booking` INT UNSIGNED NOT NULL,
`id_order` INT UNSIGNED NOT NULL DEFAULT 0,
`night` DATE NOT NULL,
`table_name` VARCHAR(64) NOT NULL,
`status` VARCHAR(16) NOT NULL,
`attempts` INT UNSIGNED NOT NULL DEFAULT 0,
`caspos_table_id` VARCHAR(64) NULL,
`lines_json` TEXT NULL,
`last_error` VARCHAR(500) NULL,
`date_add` DATETIME NOT NULL,
`date_upd` DATETIME NOT NULL,
PRIMARY KEY (`id_job`),
UNIQUE KEY `booking_night` (`id_htl_booking`, `night`),
KEY `status` (`status`)
) ENGINE='._MYSQL_ENGINE_.' DEFAULT CHARSET=utf8mb4');
}
public static function uninstallDb()
{
return Db::getInstance()->execute('DROP TABLE IF EXISTS `'._DB_PREFIX_.self::TABLE.'`');
}
public static function isEnabled()
{
return (bool)Configuration::get('CASPOSBRIDGE_ENABLED');
}
// ------------------------------------------------------------------ configuration
public static function clientConfig()
{
return array(
'host' => Configuration::get('CASPOSBRIDGE_HOST'),
'port' => Configuration::get('CASPOSBRIDGE_PORT'),
'fingerprint' => Configuration::get('CASPOSBRIDGE_FINGERPRINT'),
'client_id' => Configuration::get('CASPOSBRIDGE_CLIENT_ID'),
'client_name' => Configuration::get('CASPOSBRIDGE_CLIENT_NAME'),
'waiter_id' => Configuration::get('CASPOSBRIDGE_WAITER_ID'),
'waiter_password' => Configuration::get('CASPOSBRIDGE_WAITER_PASSWORD'),
);
}
/**
* Mapping per room type, one rule string per id_product, e.g.
* "A-UEB-DZ, A-FRUEH*erwachsene, A-LOGIS@preis"
* Item syntax: ARTICLE_ID[*QTY][@preis|@12.50] (@12.50 = fixed gross price per unit)
* QTY: number, "erwachsene", "kinder" or "gaeste" (per night)
* @preis: send the room's price per night (gross, cents) as enteredPrice (open-price article)
*
* @return array list of ['article' => id, 'qty' => int|string, 'price' => bool]
*/
public static function parseRule($rule)
{
$items = array();
// "@12,50" is a German decimal, not a separator
$rule = preg_replace('/@\s*(\d+),(\d{1,2})(?!\d)/', '@$1.$2', (string)$rule);
foreach (preg_split('/[,;\n]+/', $rule) as $part) {
$part = trim($part);
if ($part === '') {
continue;
}
// @preis = room price per night, @12.50 = fixed price in euros (for open-price articles)
$price = false;
if (preg_match('/@\s*(preis|\d+(?:[.,]\d{1,2})?)$/i', $part, $pm)) {
$price = Tools::strtolower($pm[1]) === 'preis' ? true : (int)round((float)str_replace(',', '.', $pm[1]) * 100);
$part = trim(Tools::substr($part, 0, -Tools::strlen($pm[0])));
}
$qty = 1;
if (strpos($part, '*') !== false) {
list($part, $q) = array_map('trim', explode('*', $part, 2));
$q = Tools::strtolower($q);
if (ctype_digit($q) && (int)$q > 0) {
$qty = (int)$q;
} elseif (in_array($q, array('erwachsene', 'kinder', 'gaeste', 'gäste'), true)) {
$qty = $q === 'gäste' ? 'gaeste' : $q;
} else {
throw new InvalidArgumentException('Unbekannte Menge "'.$q.'" (Zahl, erwachsene, kinder oder gaeste).');
}
}
if (!preg_match('/^[A-Za-z0-9._:\-{}]+$/', $part)) {
throw new InvalidArgumentException('Ungültige Artikel-ID "'.$part.'".');
}
$items[] = array('article' => $part, 'qty' => $qty, 'price' => $price);
}
return $items;
}
public static function getMapping()
{
$map = json_decode((string)Configuration::get('CASPOSBRIDGE_MAPPING'), true);
return is_array($map) ? $map : array();
}
public static function tableNameFor(array $booking)
{
$tpl = Configuration::get('CASPOSBRIDGE_TABLE_TEMPLATE') ?: '{room}';
return trim(str_replace('{room}', trim((string)$booking['room_num']), $tpl));
}
// ------------------------------------------------------------------ jobs
/** Create jobs for every night of a checked-in stay up to today. Returns number of new jobs. */
public function ensureJobsForBooking($idHtlBooking, $today = null)
{
$b = Db::getInstance()->getRow('SELECT * FROM `'._DB_PREFIX_.'htl_booking_detail` WHERE `id` = '.(int)$idHtlBooking);
if (!$b || (int)$b['id_status'] !== HotelBookingDetail::STATUS_CHECKED_IN) {
return 0;
}
$today = $today ?: date('Y-m-d');
$from = Tools::substr($b['date_from'], 0, 10);
$lastNight = date('Y-m-d', strtotime(Tools::substr($b['date_to'], 0, 10).' -1 day'));
$until = min($today, $lastNight);
$table = self::tableNameFor($b);
$now = date('Y-m-d H:i:s');
$created = 0;
for ($night = $from; $night <= $until; $night = date('Y-m-d', strtotime($night.' +1 day'))) {
Db::getInstance()->execute('INSERT IGNORE INTO `'._DB_PREFIX_.self::TABLE.'`
(`id_htl_booking`, `id_order`, `night`, `table_name`, `status`, `date_add`, `date_upd`) VALUES ('
.(int)$b['id'].', '.(int)$b['id_order'].', \''.pSQL($night).'\', \''.pSQL($table).'\', \''.self::STATUS_PENDING.'\', \''.$now.'\', \''.$now.'\')');
$created += (int)Db::getInstance()->Affected_Rows();
}
return $created;
}
/** Called by cron: create missing jobs for all guests currently checked in. */
public function ensureJobsForCheckedInGuests($today = null)
{
$ids = Db::getInstance()->executeS('SELECT `id` FROM `'._DB_PREFIX_.'htl_booking_detail` WHERE `id_status` = '.(int)HotelBookingDetail::STATUS_CHECKED_IN);
$n = 0;
foreach ((array)$ids as $row) {
$n += $this->ensureJobsForBooking((int)$row['id'], $today);
}
return $n;
}
/**
* Post open jobs to the POS.
* @param int|null $idHtlBooking only this booking (check-in hook)
* @return array ['done' => n, 'failed' => n, 'retry' => n, 'errors' => [..]]
*/
public function processJobs($idHtlBooking = null, $limit = 200)
{
$result = array('done' => 0, 'failed' => 0, 'retry' => 0, 'errors' => array());
$where = '`status` IN (\''.self::STATUS_PENDING.'\', \''.self::STATUS_ADDED.'\') AND `attempts` < '.(int)self::MAX_ATTEMPTS;
if ($idHtlBooking) {
$where .= ' AND `id_htl_booking` = '.(int)$idHtlBooking;
}
$jobs = Db::getInstance()->executeS('SELECT * FROM `'._DB_PREFIX_.self::TABLE.'` WHERE '.$where.' ORDER BY `night`, `id_job` LIMIT '.(int)$limit);
if (!$jobs) {
return $result;
}
try {
$client = $this->client();
$client->startSession();
} catch (Exception $e) {
// POS offline or login refused: count as a retry for every job, keep them queued
foreach ($jobs as $job) {
$this->touch($job, null, $e->getMessage());
}
$result['retry'] = count($jobs);
$result['errors'][] = $e->getMessage();
return $result;
}
foreach ($jobs as $job) {
try {
$this->postJob($client, $job);
$result['done']++;
} catch (Exception $e) {
$permanent = ($e instanceof InvalidArgumentException)
|| ($e instanceof CasposApiException && !$e->isTemporary() && $e->httpCode !== 401);
$this->touch($job, $permanent ? self::STATUS_FAILED : null, $e->getMessage());
$result[$permanent ? 'failed' : 'retry']++;
$result['errors'][] = 'Zimmer '.$job['table_name'].', Nacht '.$job['night'].': '.$e->getMessage();
if ($e instanceof CasposApiException && ($e->httpCode === 0 || $e->httpCode === 401)) {
// connection or session lost: stop this run, the rest is retried next time
break;
}
}
}
$client->endSession();
return $result;
}
protected function postJob(CasposApiClient $client, array $job)
{
$booking = Db::getInstance()->getRow('SELECT * FROM `'._DB_PREFIX_.'htl_booking_detail` WHERE `id` = '.(int)$job['id_htl_booking']);
if (!$booking) {
throw new InvalidArgumentException('Buchung nicht mehr vorhanden.');
}
if ($job['status'] === self::STATUS_ADDED && $job['caspos_table_id']) {
// lines are already on the table, only the commit is missing
$client->finalizeOrder($job['caspos_table_id']);
$this->touch($job, self::STATUS_DONE, null, true);
return;
}
$lines = $this->buildLines($booking, $job['night']);
$serviceArea = Configuration::get('CASPOSBRIDGE_SERVICE_AREA');
if (!$serviceArea) {
throw new InvalidArgumentException('Kein Tischbereich (Service Area) eingestellt.');
}
if ($job['table_name'] === '') {
throw new InvalidArgumentException('Buchung hat keine Zimmernummer.');
}
$table = $client->openTableByName($job['table_name'], $serviceArea);
$sub = isset($table['subTables'][0]['id']) ? $table['subTables'][0]['id'] : null;
foreach ((array)(isset($table['subTables']) ? $table['subTables'] : array()) as $st) {
if (!empty($st['isSelected'])) {
$sub = $st['id'];
}
}
if (!$sub) {
throw new CasposApiException('Kasse: Tisch '.$job['table_name'].' hat keinen Untertisch.', 422);
}
try {
foreach ($lines as $l) {
$client->addOrderLine($sub, $l['article'], $l['quantity'], $l['enteredPrice'], $l['text']);
}
} catch (Exception $e) {
// drop the half-added lines so a retry starts clean
try {
$client->cancelOrder($table['id']);
} catch (Exception $ignored) {
}
throw $e;
}
Db::getInstance()->update(self::TABLE, array(
'status' => self::STATUS_ADDED,
'caspos_table_id' => pSQL($table['id']),
'lines_json' => pSQL(json_encode($lines, JSON_UNESCAPED_UNICODE)),
'date_upd' => date('Y-m-d H:i:s'),
), '`id_job` = '.(int)$job['id_job']);
$client->finalizeOrder($table['id']);
$this->touch(array_merge($job, array('status' => self::STATUS_ADDED)), self::STATUS_DONE, null, true);
}
/** @return array lines with article, quantity, enteredPrice (cents|null), text */
public function buildLines(array $booking, $night)
{
$map = self::getMapping();
$rule = isset($map[(int)$booking['id_product']]) ? $map[(int)$booking['id_product']] : '';
$items = self::parseRule($rule);
if (!$items) {
throw new InvalidArgumentException('Für den Zimmertyp "'.$booking['room_type_name'].'" sind keine CASPOS-Artikel hinterlegt.');
}
$adults = (int)$booking['adults'];
$children = (int)$booking['children'];
$nights = max(1, (int)round((strtotime(Tools::substr($booking['date_to'], 0, 10)) - strtotime(Tools::substr($booking['date_from'], 0, 10))) / 86400));
$nightCents = (int)round((float)$booking['total_price_tax_incl'] * 100 / $nights);
$text = 'Nacht '.date('d.m.Y', strtotime($night)).' – Buchung #'.(int)$booking['id_order'];
$lines = array();
foreach ($items as $it) {
$qty = $it['qty'];
if ($qty === 'erwachsene') {
$qty = $adults;
} elseif ($qty === 'kinder') {
$qty = $children;
} elseif ($qty === 'gaeste') {
$qty = $adults + $children;
}
if ((int)$qty <= 0) {
continue;
}
$lines[] = array(
'article' => $it['article'],
'quantity' => (int)$qty,
'enteredPrice' => $it['price'] === true ? $nightCents : ($it['price'] !== false ? $it['price'] : null),
'text' => $text,
);
}
if (!$lines) {
throw new InvalidArgumentException('Keine Positionen für diese Nacht (Menge 0).');
}
return $lines;
}
protected function touch(array $job, $status, $error, $resetError = false)
{
$data = array('date_upd' => date('Y-m-d H:i:s'));
if ($status) {
$data['status'] = pSQL($status);
}
if ($status !== self::STATUS_DONE) {
$data['attempts'] = (int)$job['attempts'] + 1;
}
if ($error !== null) {
$data['last_error'] = pSQL(Tools::substr($error, 0, 500));
} elseif ($resetError) {
$data['last_error'] = null;
}
Db::getInstance()->update(self::TABLE, $data, '`id_job` = '.(int)$job['id_job'], 0, true);
}
/**
* Remove jobs that never reached the POS (check-in undone). Returns how many nights
* were already posted and need a manual void at the POS.
*/
public static function dropOpenJobs($idHtlBooking)
{
Db::getInstance()->delete(self::TABLE, '`id_htl_booking` = '.(int)$idHtlBooking
.' AND `status` IN (\''.self::STATUS_PENDING.'\', \''.self::STATUS_FAILED.'\') AND `caspos_table_id` IS NULL');
return (int)Db::getInstance()->getValue('SELECT COUNT(*) FROM `'._DB_PREFIX_.self::TABLE.'` WHERE `id_htl_booking` = '.(int)$idHtlBooking
.' AND `status` IN (\''.self::STATUS_ADDED.'\', \''.self::STATUS_DONE.'\')');
}
/** Re-queue a failed job. If its lines already reached the table, only the commit is retried. */
public static function retryJob($idJob)
{
return Db::getInstance()->execute('UPDATE `'._DB_PREFIX_.self::TABLE.'`
SET `status` = IF(`caspos_table_id` IS NOT NULL AND `lines_json` IS NOT NULL, \''.self::STATUS_ADDED.'\', \''.self::STATUS_PENDING.'\'),
`attempts` = 0, `date_upd` = NOW()
WHERE `id_job` = '.(int)$idJob.' AND `status` = \''.self::STATUS_FAILED.'\'');
}
public static function latestJobs($limit = 50)
{
return Db::getInstance()->executeS('SELECT * FROM `'._DB_PREFIX_.self::TABLE.'` ORDER BY `id_job` DESC LIMIT '.(int)$limit);
}
protected function client()
{
if (!$this->client) {
$this->client = $this->clientFactory ? call_user_func($this->clientFactory) : new CasposApiClient(self::clientConfig());
}
return $this->client;
}
}