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>
This commit is contained in:
DanielS
2026-11-11 23:07:28 +01:00
parent 43a0b96fdf
commit b6eebfcb90
10 changed files with 1526 additions and 0 deletions

View File

@@ -0,0 +1,220 @@
<?php
/**
* Minimal stand-in for the CASPOS OrderApp API (api/v2) used to develop and test
* modules/casposbridge without a real POS. Behaviour mirrors what the CASPOS OrderApp
* client (caspos-order-app, lib/api/api.dart) expects:
* - registerClient sets a client cookie; 403 when the device is not activated
* - 401 + extendedErrorCode 1001 (client not registered) / 1002 (waiter login required)
* - tables are opened by name, order lines are added uncommitted and committed by finalizeOrder
*
* Run: php -S 0.0.0.0:8088 docker/caspos-mock/server.php
* Env: MOCK_STATE (state file), MOCK_DEVICE_BLOCKED=1 (simulate a not activated device)
* Inspect / reset: GET /mock/state, POST /mock/reset
*/
$stateFile = getenv('MOCK_STATE') ?: sys_get_temp_dir().'/caspos-mock-state.json';
$users = array(array('id' => '7', 'name' => 'Hotel', 'password' => '1234', 'permissions' => array()));
$serviceAreas = array(
array('id' => 'sa-hotel', 'name' => 'Hotel', 'assignWaiter' => false, 'requiredPermission' => null),
array('id' => 'sa-rest', 'name' => 'Restaurant', 'assignWaiter' => false, 'requiredPermission' => null),
);
$articles = array(
array('id' => 'A-UEB-EZ', 'name' => 'Übernachtung EZ', 'plu' => '9001'),
array('id' => 'A-UEB-DZ', 'name' => 'Übernachtung DZ', 'plu' => '9002'),
array('id' => 'A-UEB-3B', 'name' => 'Übernachtung 3-Bett', 'plu' => '9003'),
array('id' => 'A-FRUEH', 'name' => 'Frühstücksbuffet', 'plu' => '9010'),
array('id' => 'A-KURTAXE', 'name' => 'Kurbeitrag', 'plu' => '9020'),
array('id' => 'A-LOGIS', 'name' => 'Logis offen (Preis)', 'plu' => '9099', 'mustEnterPrice' => true),
);
function loadState($file)
{
$s = is_file($file) ? json_decode(file_get_contents($file), true) : null;
return is_array($s) ? $s : array('clients' => array(), 'sessions' => array(), 'tables' => array(), 'lines' => array(), 'seq' => 1, 'log' => array());
}
function saveState($file, $s)
{
file_put_contents($file, json_encode($s, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE), LOCK_EX);
}
function out($code, $data = null, $headers = array())
{
http_response_code($code);
foreach ($headers as $h) {
header($h, false);
}
if ($data !== null) {
header('Content-Type: application/json; charset=utf-8');
echo json_encode($data, JSON_UNESCAPED_UNICODE);
}
exit;
}
function cookieVal($name)
{
return isset($_COOKIE[$name]) ? $_COOKIE[$name] : null;
}
$state = loadState($stateFile);
$method = $_SERVER['REQUEST_METHOD'];
$path = parse_url($_SERVER['REQUEST_URI'], PHP_URL_PATH);
$body = json_decode(file_get_contents('php://input'), true) ?: array();
$state['log'][] = $method.' '.$path;
$state['log'] = array_slice($state['log'], -200);
if ($path === '/mock/state') {
saveState($stateFile, $state);
out(200, $state);
}
if ($path === '/mock/reset' && $method === 'POST') {
@unlink($stateFile);
out(200, array('reset' => true));
}
$prefix = '/api/v2/';
if (strpos($path, $prefix) !== 0) {
out(404, array('errorMsg' => 'not found'));
}
$route = substr($path, strlen($prefix));
// --- registration (no cookie needed) ---
if ($route === 'actions/init/registerClient' && $method === 'POST') {
if (getenv('MOCK_DEVICE_BLOCKED') === '1') {
out(403, array('errorMsg' => 'Device not activated'));
}
if (empty($body['id']) || empty($body['clientName'])) {
out(400, array('errorMsg' => 'id and clientName required'));
}
$token = bin2hex(random_bytes(8));
$state['clients'][$token] = array('id' => $body['id'], 'name' => $body['clientName']);
saveState($stateFile, $state);
out(200, array('utcTime' => time(), 'clientName' => $body['clientName'], 'casposVersion' => '4.12-mock'),
array('Set-Cookie: CasposClient='.$token.'; Path=/; HttpOnly'));
}
$client = cookieVal('CasposClient');
if (!$client || !isset($state['clients'][$client])) {
saveState($stateFile, $state);
out(401, array('errorMsg' => 'Client not registered', 'extendedErrorCode' => 1001));
}
if ($route === 'actions/auth/login' && $method === 'POST') {
foreach ($users as $u) {
if ($u['id'] === (string)($body['id'] ?? '') && $u['password'] === (string)($body['password'] ?? '')) {
$state['sessions'][$client] = $u['id'];
saveState($stateFile, $state);
out(204);
}
}
saveState($stateFile, $state);
out(401, array('errorMsg' => 'Wrong waiter id or password'));
}
if ($route === 'hostStatus') {
out(200, array('online' => true));
}
if ($route === 'data/serviceAreas') {
out(200, $serviceAreas);
}
// everything below needs a logged-in waiter
if (empty($state['sessions'][$client])) {
saveState($stateFile, $state);
out(401, array('errorMsg' => 'Waiter login required', 'extendedErrorCode' => 1002));
}
if ($route === 'actions/auth/logout' && $method === 'POST') {
unset($state['sessions'][$client]);
saveState($stateFile, $state);
out(204);
}
if ($route === 'data/articles') {
out(200, $articles);
}
if ($route === 'data/users') {
out(200, array_map(function ($u) { unset($u['password']); return $u; }, $users));
}
if (preg_match('#^actions/tables/openByName/(.+)$#', $route, $m) && $method === 'POST') {
$name = urldecode($m[1]);
$sa = $_GET['serviceAreaId'] ?? '';
if (!in_array($sa, array_column($serviceAreas, 'id'), true)) {
out(400, array('errorMsg' => 'Unknown serviceAreaId'));
}
$tableId = null;
foreach ($state['tables'] as $id => $t) {
if ($t['name'] === $name && $t['open']) {
$tableId = $id;
}
}
if ($tableId === null) {
$tableId = 'T-'.$state['seq']++;
$state['tables'][$tableId] = array('name' => $name, 'serviceAreaId' => $sa, 'open' => true,
'subTables' => array(array('id' => $tableId.'-1', 'name' => $name.'/1', 'isSelected' => true)));
}
$t = $state['tables'][$tableId];
$booked = 0;
foreach ($state['lines'] as $l) {
if ($l['tableId'] === $tableId && $l['committed']) {
$booked += $l['quantity'] * ($l['enteredPrice'] ?? 0);
}
}
saveState($stateFile, $state);
out(200, array('id' => $tableId, 'name' => $t['name'], 'lastActivityTime' => time(), 'bookedAmount' => $booked,
'serviceAreaId' => $t['serviceAreaId'], 'subTables' => $t['subTables'], 'courses' => array()));
}
if (preg_match('#^actions/orderLines/add/(.+)$#', $route, $m) && $method === 'POST') {
$subId = urldecode($m[1]);
$tableId = null;
foreach ($state['tables'] as $id => $t) {
foreach ($t['subTables'] as $st) {
if ($st['id'] === $subId) {
$tableId = $id;
}
}
}
if ($tableId === null) {
out(404, array('errorMsg' => 'Unknown subTable'));
}
$art = null;
foreach ($articles as $a) {
if ($a['id'] === ($body['articleId'] ?? '')) {
$art = $a;
}
}
if ($art === null) {
out(400, array('errorMsg' => 'Unknown articleId'));
}
if (!empty($art['mustEnterPrice']) && !isset($body['enteredPrice'])) {
out(400, array('errorMsg' => 'Article needs enteredPrice'));
}
$lineId = 'L-'.$state['seq']++;
$state['lines'][] = array('id' => $lineId, 'tableId' => $tableId, 'subTableId' => $subId, 'articleId' => $art['id'],
'quantity' => (int)($body['quantity'] ?? 1), 'enteredPrice' => $body['enteredPrice'] ?? null,
'enteredText' => $body['enteredText'] ?? null, 'committed' => false);
saveState($stateFile, $state);
out(200, array('id' => $lineId, 'singlePrice' => $body['enteredPrice'] ?? 0, 'name' => $art['name'],
'receiptName' => $art['name'], 'modifiers' => array(), 'courseId' => null));
}
if (preg_match('#^actions/tables/(finalizeOrder|cancelOrder)/(.+)$#', $route, $m) && $method === 'POST') {
$tableId = urldecode($m[2]);
if (!isset($state['tables'][$tableId])) {
out(404, array('errorMsg' => 'Unknown table'));
}
foreach ($state['lines'] as $i => $l) {
if ($l['tableId'] === $tableId && !$l['committed']) {
if ($m[1] === 'finalizeOrder') {
$state['lines'][$i]['committed'] = true;
} else {
unset($state['lines'][$i]);
}
}
}
$state['lines'] = array_values($state['lines']);
saveState($stateFile, $state);
out(204);
}
saveState($stateFile, $state);
out(404, array('errorMsg' => 'Route not implemented in mock: '.$method.' '.$route));

View File

@@ -0,0 +1,167 @@
<?php
/**
* End-to-end test for modules/casposbridge against docker/caspos-mock (no real POS needed).
* docker compose exec -d app php -S 127.0.0.1:8088 docker/caspos-mock/server.php
* docker compose exec -u www-data app php docker/test-casposbridge.php
* Creates a throw-away booking on room "T-1001", checks it in, and checks what reached the
* mock. Leaves the module configured for the mock afterwards only if --keep is given.
*/
if (PHP_SAPI !== 'cli') {
exit;
}
define('_PS_ADMIN_DIR_', __DIR__);
require __DIR__.'/../config/config.inc.php';
require_once _PS_MODULE_DIR_.'hotelreservationsystem/define.php';
require_once _PS_MODULE_DIR_.'casposbridge/classes/CasposBridgeService.php';
$mock = 'http://127.0.0.1:8088';
$fail = 0;
function check($cond, $msg)
{
global $fail;
echo ($cond ? ' [ok] ' : ' [FAIL] ').$msg.PHP_EOL;
if (!$cond) {
$fail++;
}
}
function mock($path, $method = 'GET')
{
global $mock;
$ctx = stream_context_create(array('http' => array('method' => $method, 'ignore_errors' => true)));
return json_decode((string)@file_get_contents($mock.$path, false, $ctx), true);
}
function committed($state, $tableName)
{
$ids = array();
foreach ($state['tables'] as $id => $t) {
if ($t['name'] === $tableName) {
$ids[] = $id;
}
}
return array_values(array_filter($state['lines'], function ($l) use ($ids) {
return in_array($l['tableId'], $ids, true) && $l['committed'];
}));
}
echo "Setup\n";
check(is_array(mock('/mock/reset', 'POST')), 'mock reachable and reset');
$module = Module::getInstanceByName('casposbridge');
if (!Module::isInstalled('casposbridge')) {
check($module->install(), 'module installed');
} else {
check(true, 'module already installed');
}
$backup = array();
foreach (CasposBridge::CONFIG_KEYS as $k) {
$backup[$k] = Configuration::get($k);
}
Configuration::updateValue('CASPOSBRIDGE_ENABLED', 1);
Configuration::updateValue('CASPOSBRIDGE_HOST', '127.0.0.1');
Configuration::updateValue('CASPOSBRIDGE_PORT', '8088');
Configuration::updateValue('CASPOSBRIDGE_FINGERPRINT', '');
Configuration::updateValue('CASPOSBRIDGE_WAITER_ID', '7');
Configuration::updateValue('CASPOSBRIDGE_WAITER_PASSWORD', '1234');
Configuration::updateValue('CASPOSBRIDGE_SERVICE_AREA', 'sa-hotel');
Configuration::updateValue('CASPOSBRIDGE_TABLE_TEMPLATE', '{room}');
Configuration::updateValue('CASPOSBRIDGE_MAPPING', json_encode(array(2 => 'A-UEB-DZ, A-FRUEH*erwachsene', 4 => 'A-LOGIS@preis, A-FRUEH*gaeste')));
// throw-away booking: Doppelzimmer, 2 adults, arrived yesterday, 3 nights
$today = date('Y-m-d');
$from = date('Y-m-d', strtotime('-1 day'));
$to = date('Y-m-d', strtotime('+2 day'));
$db = Db::getInstance();
$db->insert('htl_booking_detail', array(
'id_product' => 2, 'id_order' => 999001, 'id_order_detail' => 0, 'id_cart' => 0, 'id_room' => 6, 'id_hotel' => 1,
'id_customer' => 0, 'booking_type' => 1, 'id_status' => HotelBookingDetail::STATUS_ASSIGNED,
'check_in' => '0000-00-00 00:00:00', 'check_out' => '0000-00-00 00:00:00', 'planned_check_out' => $to.' 11:00:00',
'date_from' => $from.' 00:00:00', 'date_to' => $to.' 00:00:00',
'total_price_tax_excl' => 406.54, 'total_price_tax_incl' => 435, 'total_paid_amount' => 0, 'is_back_order' => 0,
'hotel_name' => 'Test', 'room_type_name' => 'Doppelzimmer', 'room_num' => 'T-1001', 'adults' => 2, 'children' => 0,
'city' => 'Annweiler', 'country' => 'Deutschland', 'zipcode' => '76855', 'phone' => '0000', 'email' => 'test@hotel.test',
'check_in_time' => '15:00', 'check_out_time' => '11:00', 'id_customer' => 1, 'id_cart' => 1, 'id_order_detail' => 1,
'date_add' => date('Y-m-d H:i:s'), 'date_upd' => date('Y-m-d H:i:s'),
));
$idBooking = (int)$db->Insert_ID();
check($idBooking > 0, 'test booking created (id '.$idBooking.')');
try {
echo "Check-in posts the nights up to today\n";
$b = new HotelBookingDetail($idBooking);
check($b->changeStatus(HotelBookingDetail::STATUS_CHECKED_IN), 'check-in status change ok');
$jobs = $db->executeS('SELECT * FROM `'._DB_PREFIX_.'casposbridge_job` WHERE id_htl_booking = '.$idBooking.' ORDER BY night');
check(count($jobs) === 2, '2 jobs (yesterday + today), got '.count($jobs));
check(count(array_filter($jobs, function ($j) { return $j['status'] === 'done'; })) === 2, 'both jobs done');
$lines = committed(mock('/mock/state'), 'T-1001');
check(count($lines) === 4, '4 committed lines on table T-1001, got '.count($lines));
$byArt = array();
foreach ($lines as $l) {
$byArt[$l['articleId']] = (isset($byArt[$l['articleId']]) ? $byArt[$l['articleId']] : 0) + $l['quantity'];
}
check(isset($byArt['A-UEB-DZ']) && $byArt['A-UEB-DZ'] === 2, '2 x Übernachtung DZ');
check(isset($byArt['A-FRUEH']) && $byArt['A-FRUEH'] === 4, '4 x Frühstück (2 adults x 2 nights)');
check(strpos($lines[0]['enteredText'], 'Buchung #999001') !== false, 'line text names the booking');
echo "Cron run is idempotent\n";
$service = new CasposBridgeService();
$service->ensureJobsForCheckedInGuests();
$res = $service->processJobs();
check(count(committed(mock('/mock/state'), 'T-1001')) === 4, 'no duplicate lines after cron');
echo "Next day: one more night\n";
check($service->ensureJobsForBooking($idBooking, date('Y-m-d', strtotime('+1 day'))) === 1, '1 new job for tomorrow');
$service->processJobs($idBooking);
check(count(committed(mock('/mock/state'), 'T-1001')) === 6, '6 lines after the 3rd night');
check($service->ensureJobsForBooking($idBooking, date('Y-m-d', strtotime('+5 day'))) === 0, 'no jobs beyond departure');
echo "POS offline: job stays queued\n";
Configuration::updateValue('CASPOSBRIDGE_PORT', '8099');
$db->update('casposbridge_job', array('status' => 'pending', 'caspos_table_id' => null, 'lines_json' => null),
'id_htl_booking = '.$idBooking.' AND night = \''.pSQL($to).'\'');
$db->insert('casposbridge_job', array('id_htl_booking' => $idBooking, 'id_order' => 999001, 'night' => date('Y-m-d', strtotime('+3 day')),
'table_name' => 'T-1001', 'status' => 'pending', 'date_add' => date('Y-m-d H:i:s'), 'date_upd' => date('Y-m-d H:i:s')));
$res = (new CasposBridgeService())->processJobs($idBooking);
check($res['retry'] === 1 && $res['done'] === 0, 'offline -> retry later, nothing lost');
$j = $db->getRow('SELECT * FROM `'._DB_PREFIX_.'casposbridge_job` WHERE id_htl_booking = '.$idBooking.' AND status = \'pending\'');
check($j && (int)$j['attempts'] === 1 && strpos($j['last_error'], 'nicht erreichbar') !== false, 'error recorded: '.($j ? $j['last_error'] : '-'));
Configuration::updateValue('CASPOSBRIDGE_PORT', '8088');
(new CasposBridgeService())->processJobs($idBooking);
check(count(committed(mock('/mock/state'), 'T-1001')) === 8, 'posted after the POS is back');
echo "Unknown article: job fails permanently, retry button resets it\n";
Configuration::updateValue('CASPOSBRIDGE_MAPPING', json_encode(array(2 => 'A-GIBTSNICHT')));
$db->insert('casposbridge_job', array('id_htl_booking' => $idBooking, 'id_order' => 999001, 'night' => date('Y-m-d', strtotime('+4 day')),
'table_name' => 'T-1001', 'status' => 'pending', 'date_add' => date('Y-m-d H:i:s'), 'date_upd' => date('Y-m-d H:i:s')));
$res = (new CasposBridgeService())->processJobs($idBooking);
$j = $db->getRow('SELECT * FROM `'._DB_PREFIX_.'casposbridge_job` WHERE id_htl_booking = '.$idBooking.' AND status = \'failed\'');
check($res['failed'] === 1 && $j && strpos($j['last_error'], 'Unknown articleId') !== false, 'failed with POS message: '.($j ? $j['last_error'] : '-'));
$state = mock('/mock/state');
check(count(array_filter($state['lines'], function ($l) { return !$l['committed']; })) === 0, 'no half-booked lines left on the table');
CasposBridgeService::retryJob((int)$j['id_job']);
check($db->getValue('SELECT status FROM `'._DB_PREFIX_.'casposbridge_job` WHERE id_job = '.(int)$j['id_job']) === 'pending', 'retry resets to pending');
echo "Open-price article sends the nightly room price\n";
Configuration::updateValue('CASPOSBRIDGE_MAPPING', json_encode(array(2 => 'A-UEB-DZ, A-FRUEH*erwachsene', 4 => 'A-LOGIS@preis, A-FRUEH*gaeste')));
$row = $db->getRow('SELECT * FROM `'._DB_PREFIX_.'htl_booking_detail` WHERE id = '.$idBooking);
$lines = (new CasposBridgeService())->buildLines(array_merge($row, array('id_product' => 4, 'room_type_name' => 'Dreibett', 'adults' => 2, 'children' => 1)), $today);
check($lines[0]['article'] === 'A-LOGIS' && $lines[0]['enteredPrice'] === 14500, 'A-LOGIS with 145,00 € (14500 cents)');
check($lines[1]['quantity'] === 3, 'Frühstück x gaeste = 3');
echo "Undo check-in drops unsent nights\n";
$b = new HotelBookingDetail($idBooking);
$b->changeStatus(HotelBookingDetail::STATUS_ASSIGNED);
check((int)$db->getValue('SELECT COUNT(*) FROM `'._DB_PREFIX_.'casposbridge_job` WHERE id_htl_booking = '.$idBooking.' AND status IN (\'pending\',\'failed\')') === 0, 'pending/failed jobs removed');
} catch (Throwable $e) {
check(false, 'unexpected '.get_class($e).': '.$e->getMessage().' @ '.basename($e->getFile()).':'.$e->getLine());
} finally {
$db->delete('casposbridge_job', 'id_htl_booking = '.$idBooking);
$db->delete('htl_booking_status_history', 'id_htl_booking = '.$idBooking);
$db->delete('htl_booking_detail', 'id = '.$idBooking);
if (!in_array('--keep', $argv, true)) {
foreach ($backup as $k => $v) {
Configuration::updateValue($k, $v);
}
}
}
echo $fail ? "\n$fail check(s) FAILED\n" : "\nAll checks passed\n";
exit($fail ? 1 : 0);

View File

@@ -0,0 +1,82 @@
# CASPOS Kassenanbindung (casposbridge)
Bucht die Übernachtungen eingecheckter Gäste auf den CASPOS-Tisch mit der Zimmernummer
(Zimmer 1001 → Tisch 1001). Abrechnung, Beleg und TSE bleiben in CASPOS; QloApps erstellt dafür
keine eigenen Rechnungen.
```
Check-in in QloApps ──> Auftrag je Nacht ──> OrderApp-API der Kasse
registerClient → Kellner-Login
→ tables/openByName/1001
→ orderLines/add (Übernachtung, Frühstück …)
→ tables/finalizeOrder
Check-out ──> Gast zahlt an der Kasse wie gewohnt
```
## Ablauf
- **Check-in** (Buchungsstatus → „eingecheckt“): Aufträge für alle Nächte von der Anreise bis heute
werden angelegt und sofort gesendet. Ein Fehler an der Kasse blockiert den Check-in nie.
- **Jede Stunde** (Cron): für alle eingecheckten Gäste wird die jeweils neue Nacht gebucht; offene oder
fehlgeschlagene Sendungen werden wiederholt (max. 10 Versuche).
- **Keine Doppelbuchung:** pro Zimmer-Buchung und Nacht gibt es genau einen Auftrag. Stehen die
Positionen schon auf dem Tisch und nur der Abschluss fehlt, wird beim nächsten Versuch nur abgeschlossen.
Schlägt das Hinzufügen mittendrin fehl, werden die halben Positionen per `cancelOrder` verworfen.
- **Check-in zurückgenommen:** noch nicht gesendete Nächte werden gelöscht; bereits gebuchte müssen an der
Kasse storniert werden (Hinweis im QloApps-Log).
## Einrichtung
1. In CASPOS: Zimmer als Tische im eigenen Tischbereich anlegen (Name = Zimmernummer), Artikel für
Übernachtung/Frühstück/Kurtaxe anlegen, einen Kellner „Hotel“ mit Passwort anlegen, OrderApp-API aktivieren.
2. In QloApps: Zimmernummern (Hotel Reservation System → Zimmertypen → Zimmer) auf die Tischnamen setzen.
3. *Module → CASPOS Kassenanbindung → Konfigurieren*: IP und API-Port der Kasse (wie in der OrderApp),
bei HTTPS den SHA1-Fingerabdruck, Kellner-Nummer und Passwort.
4. **Verbindung testen** – zeigt Tischbereiche und Artikel mit IDs. Meldet die Kasse „Gerät nicht
freigeschaltet“, das Gerät (Name/ID wie im Formular) in der OrderApp-Verwaltung der Kasse freigeben.
5. Tischbereich-ID eintragen und je Zimmertyp die Artikel pro Nacht:
| Eingabe | Bedeutung |
|---|---|
| `A-UEB-DZ` | 1 × Artikel pro Nacht |
| `A-FRUEH*erwachsene` | Menge = Erwachsene (auch `kinder`, `gaeste` oder eine Zahl) |
| `A-LOGIS@preis` | Artikel mit Preiseingabe, Preis = Zimmerpreis pro Nacht (brutto) |
| `A-UEB-DZ, A-FRUEH*erwachsene` | mehrere Artikel mit Komma |
Für die Mehrwertsteuer (Übernachtung 7 %, Frühstück 19 %) getrennte CASPOS-Artikel verwenden.
6. **Anbindung aktiv** einschalten.
## Zeitsteuerung
- Mit dem Modul *QloApps Cron Task Manager* läuft die Aufgabe `casposbridge_post_nights` stündlich.
- Ohne Cron (z. B. Windows Server, Docker): Windows-Aufgabenplanung stündlich ausführen lassen
(im Repo-Ordner):
```powershell
docker compose -f docker-compose.yml -f docker-compose.server.yml exec -T -u www-data app php modules/casposbridge/cron.php
```
Rückgabewert 0 = alles gebucht, 2 = etwas wartet noch oder ist fehlgeschlagen.
Die Seite *Konfigurieren* zeigt die letzten 50 Aufträge mit Status, Positionen und Fehlermeldung der Kasse;
fehlgeschlagene lassen sich dort erneut senden. Mit **Jetzt buchen** läuft der Abgleich sofort.
## Entwickeln und testen ohne Kasse
`docker/caspos-mock/server.php` bildet die benötigten OrderApp-API-Aufrufe nach (inkl. Fehlercodes 403,
401/1001, 401/1002). `docker/test-casposbridge.php` legt eine Testbuchung an, checkt ein und prüft
Buchung, Wiederholung, Ausfall der Kasse, unbekannte Artikel und das Zurücknehmen des Check-ins:
```bash
docker compose -f docker-compose.yml -f docker-compose.server.yml exec -d -u www-data app php -S 127.0.0.1:8088 docker/caspos-mock/server.php
docker compose -f docker-compose.yml -f docker-compose.server.yml exec -u www-data app php docker/test-casposbridge.php
```
## Gegen eine echte Kasse noch zu prüfen
Die API ist aus dem Quellcode der CASPOS OrderApp (`lib/api/api.dart`) abgeleitet, nicht aus einer Doku:
- Einheit von `enteredPrice` (Annahme: Cent) – nur relevant für `@preis`.
- Legt `openByName` unbekannte Tische an oder müssen sie existieren?
- Muss der Client einmalig in der Kasse freigeschaltet werden?
- Verhalten von `finalizeOrder` gegenüber TSE und Bonausgabe (Bestellbon für Zimmer-Tische ggf. abschalten).

View File

@@ -0,0 +1,327 @@
<?php
/**
* CASPOS Bridge: posts the nights of checked-in guests to the CASPOS POS table named
* after the room number, via the CASPOS OrderApp API. Billing (receipt, TSE) stays in CASPOS.
*/
if (!defined('_PS_VERSION_')) {
exit;
}
require_once dirname(__FILE__).'/classes/CasposBridgeService.php';
class CasposBridge extends Module
{
const CONFIG_KEYS = array(
'CASPOSBRIDGE_ENABLED', 'CASPOSBRIDGE_HOST', 'CASPOSBRIDGE_PORT', 'CASPOSBRIDGE_FINGERPRINT',
'CASPOSBRIDGE_CLIENT_ID', 'CASPOSBRIDGE_CLIENT_NAME', 'CASPOSBRIDGE_WAITER_ID', 'CASPOSBRIDGE_WAITER_PASSWORD',
'CASPOSBRIDGE_SERVICE_AREA', 'CASPOSBRIDGE_TABLE_TEMPLATE', 'CASPOSBRIDGE_MAPPING', 'CASPOSBRIDGE_CRON_TOKEN',
);
public function __construct()
{
$this->name = 'casposbridge';
$this->tab = 'administration';
$this->version = '1.0.0';
$this->author = 'Zum alten Wasserrad';
$this->need_instance = 0;
$this->bootstrap = true;
parent::__construct();
$this->displayName = $this->l('CASPOS Kassenanbindung');
$this->description = $this->l('Bucht beim Check-in und jede Nacht die Übernachtung auf den CASPOS-Tisch mit der Zimmernummer (OrderApp-API).');
$this->ps_versions_compliancy = array('min' => '1.6', 'max' => '1.6.99.99');
}
public function install()
{
return parent::install()
&& CasposBridgeService::installDb()
&& $this->registerHook('actionBookingStatusAfter')
&& $this->registerHook('registerCronTasks')
&& Configuration::updateValue('CASPOSBRIDGE_ENABLED', 0)
&& Configuration::updateValue('CASPOSBRIDGE_PORT', '')
&& Configuration::updateValue('CASPOSBRIDGE_CLIENT_ID', self::uuid())
&& Configuration::updateValue('CASPOSBRIDGE_CLIENT_NAME', 'Hotel QloApps')
&& Configuration::updateValue('CASPOSBRIDGE_TABLE_TEMPLATE', '{room}')
&& Configuration::updateValue('CASPOSBRIDGE_MAPPING', '{}')
&& Configuration::updateValue('CASPOSBRIDGE_CRON_TOKEN', Tools::passwdGen(32));
}
public function uninstall()
{
foreach (self::CONFIG_KEYS as $k) {
Configuration::deleteByName($k);
}
return CasposBridgeService::uninstallDb() && parent::uninstall();
}
protected static function uuid()
{
$b = random_bytes(16);
$b[6] = chr((ord($b[6]) & 0x0f) | 0x40);
$b[8] = chr((ord($b[8]) & 0x3f) | 0x80);
return vsprintf('%s%s-%s-%s-%s-%s%s%s', str_split(bin2hex($b), 4));
}
// ------------------------------------------------------------------ hooks
/** Check-in: create the first night's job and post it right away. Never blocks the check-in. */
public function hookActionBookingStatusAfter($params)
{
if (!CasposBridgeService::isEnabled() || empty($params['object'])) {
return;
}
$to = (int)$params['id_status_to'];
if ((int)$params['id_status_from'] === HotelBookingDetail::STATUS_CHECKED_IN
&& in_array($to, array(HotelBookingDetail::STATUS_ASSIGNED, HotelBookingDetail::STATUS_NO_SHOW, HotelBookingDetail::STATUS_CANCELLED), true)) {
// check-in undone: drop nights not sent yet; nights already on the table must be voided at the POS
$posted = CasposBridgeService::dropOpenJobs((int)$params['object']->id);
if ($posted) {
PrestaShopLogger::addLog(sprintf('CASPOS: Check-in zurückgenommen, %d Nacht/Nächte sind schon auf Tisch %s gebucht und müssen an der Kasse storniert werden.',
$posted, $params['object']->room_num), 2, null, 'HotelBookingDetail', (int)$params['object']->id, true);
}
return;
}
if ($to !== HotelBookingDetail::STATUS_CHECKED_IN) {
return;
}
try {
$service = new CasposBridgeService();
$service->ensureJobsForBooking((int)$params['object']->id);
$res = $service->processJobs((int)$params['object']->id);
if ($res['errors']) {
PrestaShopLogger::addLog('CASPOS: '.implode(' | ', $res['errors']), 2, null, 'HotelBookingDetail', (int)$params['object']->id, true);
}
} catch (Exception $e) {
PrestaShopLogger::addLog('CASPOS: '.$e->getMessage(), 3, null, 'HotelBookingDetail', (int)$params['object']->id, true);
}
}
/** Picked up by the QloApps Cron Task Manager (qlocrontaskmanager). */
public function hookRegisterCronTasks()
{
return array(array(
'name' => 'casposbridge_post_nights',
'description' => $this->l('Übernachtungen eingecheckter Gäste an die CASPOS-Kasse buchen'),
'cron' => '15 * * * *',
'callback' => 'cronPostNights',
));
}
public function cronPostNights()
{
if (!CasposBridgeService::isEnabled()) {
return true;
}
$service = new CasposBridgeService();
$service->ensureJobsForCheckedInGuests();
$res = $service->processJobs();
if ($res['errors']) {
PrestaShopLogger::addLog('CASPOS: '.implode(' | ', array_slice($res['errors'], 0, 5)), 2, null, 'casposbridge', 0, true);
}
return $res;
}
// ------------------------------------------------------------------ configuration page
public function getContent()
{
$html = '';
$action = Tools::isSubmit('casposTest') ? 'test' : (Tools::isSubmit('casposRun') ? 'run' : null);
if (Tools::isSubmit('submitCasposBridge')) {
// HelperForm posts the save flag with every button: save first, then run the requested action
$saved = $this->saveSettings();
if (!$action || strpos($saved, 'alert-danger') !== false) {
return $saved.$this->renderForm().$this->renderJobs();
}
}
if ($action === 'test') {
$html .= $this->connectionTest();
} elseif ($action === 'run') {
$res = $this->cronPostNights();
$html .= is_array($res)
? $this->displayConfirmation(sprintf($this->l('Gebucht: %d, erneut versuchen: %d, fehlgeschlagen: %d'), $res['done'], $res['retry'], $res['failed']))
.($res['errors'] ? $this->displayWarning(implode('<br>', array_map('htmlspecialchars', $res['errors']))) : '')
: $this->displayWarning($this->l('Die Anbindung ist ausgeschaltet.'));
} elseif (Tools::getValue('casposRetry')) {
CasposBridgeService::retryJob((int)Tools::getValue('casposRetry'));
$html .= $this->displayConfirmation($this->l('Auftrag wird beim nächsten Lauf erneut gesendet.'));
}
return $html.$this->renderForm().$this->renderJobs();
}
protected function roomTypes()
{
return Db::getInstance()->executeS('SELECT p.`id_product`, pl.`name` FROM `'._DB_PREFIX_.'product` p
JOIN `'._DB_PREFIX_.'product_lang` pl ON pl.`id_product` = p.`id_product` AND pl.`id_lang` = '.(int)$this->context->language->id.'
WHERE p.`booking_product` = 1 ORDER BY p.`id_product`');
}
protected function saveSettings()
{
$errors = array();
$port = trim(Tools::getValue('CASPOSBRIDGE_PORT'));
if ($port !== '' && (!ctype_digit($port) || (int)$port > 65535)) {
$errors[] = $this->l('Port muss eine Zahl zwischen 1 und 65535 sein.');
}
$fp = trim(Tools::getValue('CASPOSBRIDGE_FINGERPRINT'));
if ($fp !== '' && !CasposApiClient::normalizeFingerprint($fp)) {
$errors[] = $this->l('Fingerabdruck muss ein SHA1-Wert sein (40 Hex-Zeichen, z. B. AB:CD:…).');
}
$mapping = array();
foreach ((array)$this->roomTypes() as $rt) {
$rule = trim(Tools::getValue('CASPOSBRIDGE_MAP_'.(int)$rt['id_product']));
if ($rule === '') {
continue;
}
try {
CasposBridgeService::parseRule($rule);
$mapping[(int)$rt['id_product']] = $rule;
} catch (InvalidArgumentException $e) {
$errors[] = $rt['name'].': '.$e->getMessage();
}
}
if ($errors) {
return $this->displayError(implode('<br>', array_map('htmlspecialchars', $errors)));
}
Configuration::updateValue('CASPOSBRIDGE_ENABLED', (int)Tools::getValue('CASPOSBRIDGE_ENABLED'));
Configuration::updateValue('CASPOSBRIDGE_HOST', trim(Tools::getValue('CASPOSBRIDGE_HOST')));
Configuration::updateValue('CASPOSBRIDGE_PORT', $port);
Configuration::updateValue('CASPOSBRIDGE_FINGERPRINT', CasposApiClient::normalizeFingerprint($fp));
Configuration::updateValue('CASPOSBRIDGE_CLIENT_NAME', trim(Tools::getValue('CASPOSBRIDGE_CLIENT_NAME')) ?: 'Hotel QloApps');
Configuration::updateValue('CASPOSBRIDGE_WAITER_ID', trim(Tools::getValue('CASPOSBRIDGE_WAITER_ID')));
if (Tools::getValue('CASPOSBRIDGE_WAITER_PASSWORD') !== '') {
Configuration::updateValue('CASPOSBRIDGE_WAITER_PASSWORD', Tools::getValue('CASPOSBRIDGE_WAITER_PASSWORD'));
}
Configuration::updateValue('CASPOSBRIDGE_SERVICE_AREA', trim(Tools::getValue('CASPOSBRIDGE_SERVICE_AREA')));
Configuration::updateValue('CASPOSBRIDGE_TABLE_TEMPLATE', trim(Tools::getValue('CASPOSBRIDGE_TABLE_TEMPLATE')) ?: '{room}');
Configuration::updateValue('CASPOSBRIDGE_MAPPING', json_encode($mapping, JSON_UNESCAPED_UNICODE));
return $this->displayConfirmation($this->l('Einstellungen gespeichert.'));
}
protected function connectionTest()
{
try {
$client = new CasposApiClient(CasposBridgeService::clientConfig());
$client->startSession();
$areas = $client->getServiceAreas();
$articles = $client->getArticles();
$client->endSession();
} catch (Exception $e) {
return $this->displayError(htmlspecialchars($e->getMessage()));
}
$rows = function ($list, $cols) {
$h = '<table class="table"><thead><tr>';
foreach ($cols as $c) {
$h .= '<th>'.htmlspecialchars($c).'</th>';
}
$h .= '</tr></thead><tbody>';
foreach ($list as $item) {
$h .= '<tr>';
foreach (array_keys($cols) as $k) {
$h .= '<td>'.htmlspecialchars(isset($item[$k]) ? (is_bool($item[$k]) ? ($item[$k] ? 'ja' : '') : (string)$item[$k]) : '').'</td>';
}
$h .= '</tr>';
}
return $h.'</tbody></table>';
};
return $this->displayConfirmation($this->l('Verbindung zur Kasse und Kellner-Anmeldung erfolgreich.'))
.'<div class="panel"><h3>'.$this->l('Tischbereiche (Service Areas)').'</h3>'
.$rows($areas, array('id' => 'ID', 'name' => $this->l('Name')))
.'<h3>'.$this->l('Artikel').'</h3>'
.$rows($articles, array('id' => 'ID', 'plu' => 'PLU', 'name' => $this->l('Name'), 'mustEnterPrice' => $this->l('Preis eingeben')))
.'</div>';
}
protected function renderForm()
{
$mapInputs = array();
$mapping = CasposBridgeService::getMapping();
$values = array();
foreach ((array)$this->roomTypes() as $rt) {
$key = 'CASPOSBRIDGE_MAP_'.(int)$rt['id_product'];
$mapInputs[] = array('type' => 'text', 'name' => $key, 'label' => $rt['name']);
$values[$key] = isset($mapping[(int)$rt['id_product']]) ? $mapping[(int)$rt['id_product']] : '';
}
$fields = array(
array('form' => array(
'legend' => array('title' => $this->l('Verbindung zur CASPOS-Kasse'), 'icon' => 'icon-link'),
'input' => array(
array('type' => 'switch', 'name' => 'CASPOSBRIDGE_ENABLED', 'label' => $this->l('Anbindung aktiv'), 'is_bool' => true,
'values' => array(array('id' => 'on', 'value' => 1, 'label' => $this->l('Ja')), array('id' => 'off', 'value' => 0, 'label' => $this->l('Nein')))),
array('type' => 'text', 'name' => 'CASPOSBRIDGE_HOST', 'label' => $this->l('Kasse: IP oder Hostname'), 'desc' => $this->l('Wie in der OrderApp, z. B. 192.168.178.30')),
array('type' => 'text', 'name' => 'CASPOSBRIDGE_PORT', 'label' => $this->l('API-Port'), 'class' => 'fixed-width-sm'),
array('type' => 'text', 'name' => 'CASPOSBRIDGE_FINGERPRINT', 'label' => $this->l('Zertifikats-Fingerabdruck (SHA1)'),
'desc' => $this->l('Leer = HTTP. Mit Fingerabdruck = HTTPS, das Zertifikat der Kasse muss genau passen (wie in der OrderApp).')),
array('type' => 'text', 'name' => 'CASPOSBRIDGE_CLIENT_NAME', 'label' => $this->l('Gerätename an der Kasse')),
array('type' => 'text', 'name' => 'CASPOSBRIDGE_CLIENT_ID', 'label' => $this->l('Geräte-ID'), 'readonly' => true,
'desc' => $this->l('Wird beim ersten Kontakt an der Kasse registriert; ggf. in der OrderApp-Verwaltung der Kasse freischalten.')),
array('type' => 'text', 'name' => 'CASPOSBRIDGE_WAITER_ID', 'label' => $this->l('Kellner-Nummer'), 'class' => 'fixed-width-sm'),
array('type' => 'password', 'name' => 'CASPOSBRIDGE_WAITER_PASSWORD', 'label' => $this->l('Kellner-Passwort'),
'desc' => $this->l('Leer lassen, um das gespeicherte Passwort zu behalten.')),
array('type' => 'text', 'name' => 'CASPOSBRIDGE_SERVICE_AREA', 'label' => $this->l('Tischbereich-ID'),
'desc' => $this->l('ID des Bereichs mit den Zimmer-Tischen – „Verbindung testen“ zeigt alle IDs.')),
array('type' => 'text', 'name' => 'CASPOSBRIDGE_TABLE_TEMPLATE', 'label' => $this->l('Tischname'), 'class' => 'fixed-width-md',
'desc' => $this->l('{room} = Zimmernummer aus QloApps, z. B. 1001')),
),
'submit' => array('title' => $this->l('Speichern'), 'name' => 'submitCasposBridge'),
'buttons' => array(
array('title' => $this->l('Verbindung testen'), 'name' => 'casposTest', 'type' => 'submit', 'icon' => 'process-icon-refresh', 'class' => 'pull-right'),
array('title' => $this->l('Jetzt buchen'), 'name' => 'casposRun', 'type' => 'submit', 'icon' => 'process-icon-upload', 'class' => 'pull-right'),
),
)),
array('form' => array(
'legend' => array('title' => $this->l('Artikel pro Nacht je Zimmertyp'), 'icon' => 'icon-list'),
'description' => $this->l('Format: ARTIKEL-ID[*MENGE][@preis], mehrere mit Komma. MENGE = Zahl, erwachsene, kinder oder gaeste. @preis sendet den Zimmerpreis pro Nacht (für Artikel mit Preiseingabe). Beispiel: A-UEB-DZ, A-FRUEH*erwachsene'),
'input' => $mapInputs,
'submit' => array('title' => $this->l('Speichern'), 'name' => 'submitCasposBridge'),
)),
);
foreach (self::CONFIG_KEYS as $k) {
$values[$k] = $k === 'CASPOSBRIDGE_WAITER_PASSWORD' ? '' : Configuration::get($k);
}
$helper = new HelperForm();
$helper->module = $this;
$helper->name_controller = $this->name;
$helper->token = Tools::getAdminTokenLite('AdminModules');
$helper->currentIndex = AdminController::$currentIndex.'&configure='.$this->name;
$helper->default_form_language = (int)Configuration::get('PS_LANG_DEFAULT');
$helper->show_toolbar = false;
$helper->submit_action = 'submitCasposBridge';
$helper->tpl_vars = array('fields_value' => $values);
return $helper->generateForm($fields);
}
protected function renderJobs()
{
$statusLabel = array(
CasposBridgeService::STATUS_PENDING => $this->l('wartet'),
CasposBridgeService::STATUS_ADDED => $this->l('auf Tisch, Abschluss offen'),
CasposBridgeService::STATUS_DONE => $this->l('gebucht'),
CasposBridgeService::STATUS_FAILED => $this->l('fehlgeschlagen'),
);
$base = AdminController::$currentIndex.'&configure='.$this->name.'&token='.Tools::getAdminTokenLite('AdminModules');
$html = '<div class="panel"><h3><i class="icon-time"></i> '.$this->l('Letzte Buchungen an die Kasse').'</h3>';
$html .= '<p>'.sprintf($this->l('Zeitgesteuert über den QloApps Cron Task Manager oder per Kommandozeile: %s'),
'<code>php modules/casposbridge/cron.php</code>').'</p>';
$html .= '<table class="table"><thead><tr><th>'.$this->l('Nacht').'</th><th>'.$this->l('Tisch').'</th><th>'.$this->l('Buchung').'</th><th>'.$this->l('Status').'</th><th>'.$this->l('Versuche').'</th><th>'.$this->l('Positionen').'</th><th>'.$this->l('Fehler').'</th><th></th></tr></thead><tbody>';
foreach ((array)CasposBridgeService::latestJobs() as $j) {
$lines = json_decode((string)$j['lines_json'], true);
$linesTxt = '';
foreach ((array)$lines as $l) {
$linesTxt .= htmlspecialchars($l['quantity'].' × '.$l['article'].($l['enteredPrice'] !== null ? ' ('.number_format($l['enteredPrice'] / 100, 2, ',', '.').' €)' : '')).'<br>';
}
$html .= '<tr><td>'.htmlspecialchars(date('d.m.Y', strtotime($j['night']))).'</td><td>'.htmlspecialchars($j['table_name']).'</td>'
.'<td>#'.(int)$j['id_order'].'</td><td>'.htmlspecialchars(isset($statusLabel[$j['status']]) ? $statusLabel[$j['status']] : $j['status']).'</td>'
.'<td>'.(int)$j['attempts'].'</td><td>'.$linesTxt.'</td><td>'.htmlspecialchars((string)$j['last_error']).'</td><td>'
.($j['status'] === CasposBridgeService::STATUS_FAILED ? '<a class="btn btn-default" href="'.$base.'&casposRetry='.(int)$j['id_job'].'">'.$this->l('Erneut senden').'</a>' : '')
.'</td></tr>';
}
return $html.'</tbody></table></div>';
}
}

View File

@@ -0,0 +1,269 @@
<?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;
}
}

View File

@@ -0,0 +1,369 @@
<?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;
}
}

View File

@@ -0,0 +1,32 @@
<?php
/**
* NOTICE OF LICENSE
*
* This source file is subject to the Open Software License version 3.0
* that is bundled with this package in the file LICENSE.md
* It is also available through the world-wide-web at this URL:
* https://opensource.org/license/osl-3-0-php
* If you did not receive a copy of the license and are unable to
* obtain it through the world-wide-web, please send an email
* to support@qloapps.com so we can send you a copy immediately.
*
* DISCLAIMER
*
* Do not edit or add to this file if you wish to upgrade this module to a newer
* versions in the future. If you wish to customize this module for your needs
* please refer to https://store.webkul.com/customisation-guidelines for more information.
*
* @author Webkul IN
* @copyright Since 2010 Webkul
* @license https://opensource.org/license/osl-3-0-php Open Software License version 3.0
*/
header('Expires: Mon, 26 Jul 1997 05:00:00 GMT');
header('Last-Modified: '.gmdate('D, d M Y H:i:s').' GMT');
header('Cache-Control: no-store, no-cache, must-revalidate');
header('Cache-Control: post-check=0, pre-check=0', false);
header('Pragma: no-cache');
header('Location: ../../../');
exit;

View File

@@ -0,0 +1,28 @@
<?php
/**
* Post the nights of checked-in guests to CASPOS (command line only).
* docker compose exec -u www-data app php modules/casposbridge/cron.php
* Use this with Windows Task Scheduler if the QloApps Cron Task Manager is not running.
*/
if (PHP_SAPI !== 'cli') {
header('HTTP/1.1 403 Forbidden');
exit;
}
define('_PS_ADMIN_DIR_', __DIR__);
require dirname(__FILE__).'/../../config/config.inc.php';
$module = Module::getInstanceByName('casposbridge');
if (!$module || !Module::isEnabled('casposbridge')) {
fwrite(STDERR, "Modul casposbridge ist nicht installiert oder deaktiviert.\n");
exit(1);
}
$res = $module->cronPostNights();
if ($res === true) {
echo "Anbindung ist ausgeschaltet.\n";
exit(0);
}
printf("gebucht: %d, erneut versuchen: %d, fehlgeschlagen: %d\n", $res['done'], $res['retry'], $res['failed']);
foreach ($res['errors'] as $e) {
echo ' - '.$e."\n";
}
exit($res['failed'] || $res['retry'] ? 2 : 0);

View File

@@ -0,0 +1,32 @@
<?php
/**
* NOTICE OF LICENSE
*
* This source file is subject to the Open Software License version 3.0
* that is bundled with this package in the file LICENSE.md
* It is also available through the world-wide-web at this URL:
* https://opensource.org/license/osl-3-0-php
* If you did not receive a copy of the license and are unable to
* obtain it through the world-wide-web, please send an email
* to support@qloapps.com so we can send you a copy immediately.
*
* DISCLAIMER
*
* Do not edit or add to this file if you wish to upgrade this module to a newer
* versions in the future. If you wish to customize this module for your needs
* please refer to https://store.webkul.com/customisation-guidelines for more information.
*
* @author Webkul IN
* @copyright Since 2010 Webkul
* @license https://opensource.org/license/osl-3-0-php Open Software License version 3.0
*/
header('Expires: Mon, 26 Jul 1997 05:00:00 GMT');
header('Last-Modified: '.gmdate('D, d M Y H:i:s').' GMT');
header('Cache-Control: no-store, no-cache, must-revalidate');
header('Cache-Control: post-check=0, pre-check=0', false);
header('Pragma: no-cache');
header('Location: ../../../');
exit;

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.7 KiB