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

370 lines
15 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]
* 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();
foreach (preg_split('/[,;\n]+/', (string)$rule) as $part) {
$part = trim($part);
if ($part === '') {
continue;
}
$price = false;
if (preg_match('/@preis$/i', $part)) {
$price = true;
$part = trim(preg_replace('/@preis$/i', '', $part));
}
$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'] ? $nightCents : 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;
}
}