- 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>
373 lines
16 KiB
PHP
373 lines
16 KiB
PHP
<?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;
|
||
}
|
||
}
|