Files
hotel/modules/occupancyplan/classes/OccupancyPlanRenderer.php
DanielS e4db9fae82 Add occupancyplan: Belegungsplan (rooms x days) with click-to-reserve
New back-office page "Hotel Reservation System > Belegungsplan":
- rooms grouped by room type as rows, 14-62 days as columns, weekends
  and today highlighted, sticky room column, daily occupancy footer
- bookings as bars (booked / checked in / checked out / no-show) with
  guest name, guests, tooltip with dates, order reference and price;
  click opens the order; stays outside the view are cut
- booking source badge from orders.id_source (W website, T phone,
  B Booking.com, E Expedia ...; portals highlighted), so channel-manager
  bookings show up with their source
- blocked periods (htl_room_disable_dates) hatched with reason
- CASPOS badge: nights already posted by casposbridge, "!" on failures
- reserve: click a free cell (first night) and a later one (last night)
  in the same row -> action bar -> "Book Now" opens with dates, hotel
  and room type prefilled; keyboard: Enter/Space, Escape
- docker/test-occupancyplan.php: 15 checks with throw-away bookings,
  optional HTML preview

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-12 00:14:14 +01:00

315 lines
18 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
/**
* Occupancy plan (tape chart): rooms as rows, days as columns, bookings as bars.
* Pure data + HTML rendering so it can be used by the admin controller and tested from CLI.
*/
class OccupancyPlanRenderer
{
const STATUS_ASSIGNED = 1;
const STATUS_CHECKED_IN = 2;
const STATUS_CHECKED_OUT = 3;
const STATUS_NO_SHOW = 4;
const STATUS_CANCELLED = 5;
protected $from;
protected $days;
protected $idHotel;
protected $idLang;
/**
* @param string $from first day (Y-m-d)
* @param int $days number of days shown
*/
public function __construct($from, $days, $idHotel = 0, $idLang = null)
{
$this->from = date('Y-m-d', strtotime($from));
$this->days = max(7, min(62, (int)$days));
$this->idHotel = (int)$idHotel;
$this->idLang = $idLang ?: (int)Configuration::get('PS_LANG_DEFAULT');
}
public function getTo()
{
return date('Y-m-d', strtotime($this->from.' +'.$this->days.' day'));
}
/** @return array rooms grouped by room type */
public function getRooms()
{
$sql = 'SELECT ri.`id`, ri.`room_num`, ri.`id_status`, ri.`id_product`, ri.`id_hotel`, pl.`name` AS room_type
FROM `'._DB_PREFIX_.'htl_room_information` ri
LEFT JOIN `'._DB_PREFIX_.'product_lang` pl ON pl.`id_product` = ri.`id_product` AND pl.`id_lang` = '.(int)$this->idLang.'
WHERE 1'.($this->idHotel ? ' AND ri.`id_hotel` = '.(int)$this->idHotel : '').'
ORDER BY ri.`id_hotel`, pl.`name`, ri.`room_num`';
return (array)Db::getInstance()->executeS($sql);
}
/** @return array bookings overlapping the shown period, keyed by id_room */
public function getBookings()
{
$to = $this->getTo();
$hasCaspos = (bool)Db::getInstance()->getValue('SELECT COUNT(*) FROM information_schema.tables
WHERE table_schema = DATABASE() AND table_name = \''._DB_PREFIX_.'casposbridge_job\'');
$sql = 'SELECT bd.`id`, bd.`id_room`, bd.`id_order`, bd.`id_status`, bd.`adults`, bd.`children`,
DATE(bd.`date_from`) AS date_from, DATE(bd.`date_to`) AS date_to, bd.`room_type_name`,
bd.`total_price_tax_incl`, c.`firstname`, c.`lastname`, o.`reference`,
src.`code` AS source_code, srcl.`name` AS source_name, src.`id_business_source`, o.`source` AS source_raw'
.($hasCaspos ? ', (SELECT COUNT(*) FROM `'._DB_PREFIX_.'casposbridge_job` j WHERE j.`id_htl_booking` = bd.`id` AND j.`status` = \'done\') AS pos_done,
(SELECT COUNT(*) FROM `'._DB_PREFIX_.'casposbridge_job` j WHERE j.`id_htl_booking` = bd.`id` AND j.`status` = \'failed\') AS pos_failed' : '').'
FROM `'._DB_PREFIX_.'htl_booking_detail` bd
LEFT JOIN `'._DB_PREFIX_.'customer` c ON c.`id_customer` = bd.`id_customer`
LEFT JOIN `'._DB_PREFIX_.'orders` o ON o.`id_order` = bd.`id_order`
LEFT JOIN `'._DB_PREFIX_.'source` src ON src.`id_source` = o.`id_source`
LEFT JOIN `'._DB_PREFIX_.'source_lang` srcl ON srcl.`id_source` = src.`id_source` AND srcl.`id_lang` = '.(int)$this->idLang.'
WHERE bd.`date_from` < \''.pSQL($to).'\' AND bd.`date_to` > \''.pSQL($this->from).'\'
AND bd.`id_status` <> '.self::STATUS_CANCELLED.' AND bd.`is_back_order` = 0'
.(method_exists('OrderReturn', 'getRefundedBookingIdsSubquery') ? ' AND bd.`id` NOT IN ('.OrderReturn::getRefundedBookingIdsSubquery().')' : '')
.($this->idHotel ? ' AND bd.`id_hotel` = '.(int)$this->idHotel : '').'
ORDER BY bd.`date_from`';
$out = array();
foreach ((array)Db::getInstance()->executeS($sql) as $b) {
$out[(int)$b['id_room']][] = $b;
}
return $out;
}
/** @return array blocked periods keyed by id_room */
public function getBlocked()
{
$to = $this->getTo();
$out = array();
foreach ((array)Db::getInstance()->executeS('SELECT `id_room`, `date_from`, `date_to`, `reason`
FROM `'._DB_PREFIX_.'htl_room_disable_dates`
WHERE `date_from` < \''.pSQL($to).'\' AND `date_to` > \''.pSQL($this->from).'\'') as $d) {
$out[(int)$d['id_room']][] = $d;
}
return $out;
}
/** column index (0-based) of a date, clamped to the visible period */
protected function col($date)
{
$d = (int)round((strtotime($date) - strtotime($this->from)) / 86400);
return max(0, min($this->days, $d));
}
protected static function e($s)
{
return htmlspecialchars((string)$s, ENT_QUOTES, 'UTF-8');
}
/**
* @param callable|null $orderLink function(int $idOrder): string
* @param array $labels translated UI strings
*/
public function render($orderLink = null, array $labels = array())
{
$L = array_merge(array(
'room' => 'Zimmer', 'occupied' => 'Belegt', 'blocked' => 'Gesperrt', 'assigned' => 'Gebucht',
'checked_in' => 'Eingecheckt', 'checked_out' => 'Ausgecheckt', 'no_show' => 'Nicht erschienen',
'inactive' => 'inaktiv', 'nights' => 'Nächte', 'order' => 'Buchung', 'guests' => 'Gäste',
'pos' => 'An Kasse gebucht', 'pos_failed' => 'Kassenbuchung fehlgeschlagen', 'no_rooms' => 'Keine Zimmer angelegt.',
), $labels);
$weekdays = array('So', 'Mo', 'Di', 'Mi', 'Do', 'Fr', 'Sa');
$months = array(1 => 'Januar', 'Februar', 'März', 'April', 'Mai', 'Juni', 'Juli', 'August', 'September', 'Oktober', 'November', 'Dezember');
$statusClass = array(
self::STATUS_ASSIGNED => array('op-assigned', $L['assigned']),
self::STATUS_CHECKED_IN => array('op-in', $L['checked_in']),
self::STATUS_CHECKED_OUT => array('op-out', $L['checked_out']),
self::STATUS_NO_SHOW => array('op-noshow', $L['no_show']),
);
$rooms = $this->getRooms();
if (!$rooms) {
return '<div class="alert alert-info">'.self::e($L['no_rooms']).'</div>';
}
$bookings = $this->getBookings();
$blocked = $this->getBlocked();
$today = date('Y-m-d');
$cols = 'grid-template-columns: 11rem repeat('.$this->days.', minmax(2.4rem, 1fr));';
// header: month row + day row
$h = '<div class="op-row op-head" style="'.$cols.'"><div class="op-label"></div>';
$monthStart = 0;
for ($i = 0; $i <= $this->days; $i++) {
$d = date('Y-m-d', strtotime($this->from.' +'.$i.' day'));
if ($i === $this->days || ($i > 0 && date('j', strtotime($d)) == 1)) {
$m = date('Y-m-d', strtotime($this->from.' +'.$monthStart.' day'));
$h .= '<div class="op-month" style="grid-column:'.($monthStart + 2).' / '.($i + 2).'">'
.self::e($months[(int)date('n', strtotime($m))].' '.date('Y', strtotime($m))).'</div>';
$monthStart = $i;
}
}
$h .= '</div><div class="op-row op-head" style="'.$cols.'"><div class="op-label">'.self::e($L['room']).'</div>';
for ($i = 0; $i < $this->days; $i++) {
$d = date('Y-m-d', strtotime($this->from.' +'.$i.' day'));
$w = (int)date('w', strtotime($d));
$cls = 'op-day'.($w === 0 || $w === 6 ? ' op-we' : '').($d === $today ? ' op-today' : '');
$h .= '<div class="'.$cls.'" title="'.self::e(date('d.m.Y', strtotime($d))).'"><span>'.$weekdays[$w].'</span><b>'.date('j', strtotime($d)).'</b></div>';
}
$h .= '</div>';
// rows
$occupied = array_fill(0, $this->days, 0);
$activeRooms = 0;
$body = '';
$lastType = null;
foreach ($rooms as $r) {
if ($r['room_type'] !== $lastType) {
$body .= '<div class="op-group">'.self::e($r['room_type']).'</div>';
$lastType = $r['room_type'];
}
$inactive = (int)$r['id_status'] === 2;
if (!$inactive) {
$activeRooms++;
}
$roomBookings = isset($bookings[(int)$r['id']]) ? $bookings[(int)$r['id']] : array();
$roomBlocked = isset($blocked[(int)$r['id']]) ? $blocked[(int)$r['id']] : array();
// nights that cannot be reserved (booked or blocked)
$busy = array();
foreach (array_merge($roomBookings, $roomBlocked) as $x) {
for ($i = $this->col($x['date_from']); $i < $this->col($x['date_to']); $i++) {
$busy[$i] = true;
}
}
$body .= '<div class="op-row'.($inactive ? ' op-inactive' : '').'" style="'.$cols.'" data-room="'.(int)$r['id'].'"'
.' data-room-num="'.self::e($r['room_num']).'" data-room-type="'.(int)$r['id_product'].'" data-hotel="'.(int)$r['id_hotel'].'">';
$body .= '<div class="op-label"><b>'.self::e($r['room_num']).'</b>'.($inactive ? ' <small>'.self::e($L['inactive']).'</small>' : '').'</div>';
for ($i = 0; $i < $this->days; $i++) {
$d = date('Y-m-d', strtotime($this->from.' +'.$i.' day'));
$w = (int)date('w', strtotime($d));
$free = !$inactive && empty($busy[$i]) && $d >= $today;
$body .= '<div class="op-cell'.($w === 0 || $w === 6 ? ' op-we' : '').($d === $today ? ' op-today' : '').($free ? ' op-free' : ' op-busy').'"'
.' style="grid-column:'.($i + 2).'" data-date="'.$d.'"'.($free ? ' tabindex="0" role="button" aria-label="'.self::e($r['room_num'].' '.date('d.m.Y', strtotime($d))).'"' : '').'></div>';
}
foreach ($roomBlocked as $bl) {
$s = $this->col($bl['date_from']);
$e = $this->col($bl['date_to']);
if ($e > $s) {
$body .= '<div class="op-bar op-blocked" style="grid-column:'.($s + 2).' / '.($e + 2).'" title="'.self::e($L['blocked'].': '.date('d.m.', strtotime($bl['date_from'])).'–'.date('d.m.Y', strtotime($bl['date_to'])).($bl['reason'] ? ' – '.$bl['reason'] : '')).'">'.self::e($L['blocked']).'</div>';
}
}
foreach ($roomBookings as $b) {
$s = $this->col($b['date_from']);
$e = $this->col($b['date_to']);
if ($e <= $s) {
continue;
}
for ($i = $s; $i < $e; $i++) {
$occupied[$i]++;
}
list($cls, $statusName) = isset($statusClass[(int)$b['id_status']]) ? $statusClass[(int)$b['id_status']] : array('op-assigned', '');
$name = trim($b['firstname'].' '.$b['lastname']) ?: '#'.$b['id_order'];
$guests = (int)$b['adults'].((int)$b['children'] ? '+'.(int)$b['children'] : '');
$nights = (int)round((strtotime($b['date_to']) - strtotime($b['date_from'])) / 86400);
$pos = '';
if (isset($b['pos_done'])) {
if ((int)$b['pos_failed']) {
$pos = '<i class="op-pos op-pos-failed" title="'.self::e($L['pos_failed']).'">!</i>';
} elseif ((int)$b['pos_done']) {
$pos = '<i class="op-pos" title="'.self::e($L['pos'].': '.(int)$b['pos_done'].' '.$L['nights']).'">€'.(int)$b['pos_done'].'</i>';
}
}
list($srcShort, $srcName, $srcClass) = self::source($b);
$title = $name.' – '.$statusName.($srcName ? ' · '.$srcName : '')."\n"
.date('d.m.', strtotime($b['date_from'])).' – '.date('d.m.Y', strtotime($b['date_to'])).' ('.$nights.' '.$L['nights'].')'."\n"
.$L['guests'].': '.$guests.' · '.$L['order'].' '.($b['reference'] ?: '#'.$b['id_order'])
.' · '.number_format((float)$b['total_price_tax_incl'], 2, ',', '.').' €';
$cut = ($b['date_from'] < $this->from ? ' op-cut-l' : '').($b['date_to'] > $this->getTo() ? ' op-cut-r' : '');
$tag = $orderLink && (int)$b['id_order'] ? 'a href="'.self::e(call_user_func($orderLink, (int)$b['id_order'])).'"' : 'div';
$src = $srcShort ? '<i class="op-src '.$srcClass.'">'.self::e($srcShort).'</i>' : '';
$body .= '<'.$tag.' class="op-bar '.$cls.$cut.'" style="grid-column:'.($s + 2).' / '.($e + 2).'" title="'.self::e($title).'">'
.$src.'<span>'.self::e($name).' <small>('.self::e($guests).')</small></span>'.$pos.'</'.explode(' ', $tag)[0].'>';
}
$body .= '</div>';
}
// occupancy footer
$f = '<div class="op-row op-foot" style="'.$cols.'"><div class="op-label">'.self::e($L['occupied']).'</div>';
for ($i = 0; $i < $this->days; $i++) {
$pct = $activeRooms ? round($occupied[$i] * 100 / $activeRooms) : 0;
$f .= '<div class="op-occ" title="'.$occupied[$i].' / '.$activeRooms.'" style="--p:'.$pct.'%"><span>'.$occupied[$i].'</span></div>';
}
$f .= '</div>';
$legend = '<div class="op-legend">';
foreach ($statusClass as $sc) {
$legend .= '<span class="op-bar '.$sc[0].'">'.self::e($sc[1]).'</span>';
}
$legend .= '<span class="op-bar op-blocked">'.self::e($L['blocked']).'</span>';
$legend .= '<span><i class="op-pos">€2</i> '.self::e($L['pos']).'</span>';
$legend .= '<span><i class="op-src op-src-ota">B</i> Booking.com</span><span><i class="op-src op-src-ota">E</i> Expedia</span>'
.'<span><i class="op-src">W</i> Website</span><span><i class="op-src">T</i> Telefon</span><span><i class="op-src">@</i> E-Mail</span></div>';
return '<div class="op-wrap"><div class="op-grid" style="min-width:calc(11rem + '.$this->days.' * 2.4rem)">'.$h.$body.$f.'</div></div>'.$legend.self::css();
}
/**
* Short badge for the booking source (orders.id_source -> source.code).
* @return array [short, name, css class]
*/
public static function source(array $b)
{
$map = array(
'DIRECT_WEBSITE' => 'W', 'WALKIN' => 'L', 'PHONE' => 'T', 'EMAIL' => '@', 'SOCIAL_MEDIA' => 'S', 'WHATSAPP' => 'WA',
'BOOKING_COM' => 'B', 'EXPEDIA' => 'E', 'AIRBNB' => 'A', 'AGODA' => 'AG', 'TRIP_COM' => 'TC',
);
$code = isset($b['source_code']) ? (string)$b['source_code'] : '';
if ($code === '') {
return array('', isset($b['source_raw']) ? (string)$b['source_raw'] : '', '');
}
$short = isset($map[$code]) ? $map[$code] : Tools::strtoupper(Tools::substr($code, 0, 2));
$ota = (int)(isset($b['id_business_source']) ? $b['id_business_source'] : 0) === 2;
return array($short, (string)$b['source_name'], $ota ? 'op-src-ota' : '');
}
public static function css()
{
return '<style>
.op-wrap{overflow-x:auto;border:1px solid #d9dee3;border-radius:4px;background:#fff}
.op-grid{font-size:12px}
.op-row{display:grid;align-items:stretch;border-bottom:1px solid #eef1f4;min-height:34px;position:relative}
.op-row:hover .op-label{background:#f3f6f9}
.op-label{position:sticky;left:0;z-index:3;background:#fff;padding:6px 8px;border-right:1px solid #d9dee3;display:flex;align-items:center;gap:6px;grid-row:1}
.op-head{min-height:0;background:#f8fafb}
.op-head .op-label{background:#f8fafb;font-weight:600}
.op-month{grid-row:1;padding:4px 6px;font-weight:600;border-left:1px solid #d9dee3;white-space:nowrap}
.op-day{grid-row:1;text-align:center;padding:3px 0;border-left:1px solid #eef1f4;line-height:1.2}
.op-day span{display:block;color:#7a8794;font-size:10px}
.op-cell{grid-row:1;border-left:1px solid #eef1f4}
.op-we{background:#f6f7f9}
.op-today{background:#fff4d6!important;box-shadow:inset 2px 0 0 #e0a800}
.op-group{padding:5px 8px;background:#eef2f6;font-weight:600;border-bottom:1px solid #d9dee3;position:sticky;left:0}
.op-inactive .op-label{color:#9aa5b1}
.op-bar{grid-row:1;z-index:2;margin:5px 2px;border-radius:4px;padding:2px 6px;color:#fff;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;display:flex;align-items:center;justify-content:space-between;gap:4px;text-decoration:none;font-weight:600;min-width:0}
a.op-bar:hover,a.op-bar:focus{color:#fff;text-decoration:none;filter:brightness(1.08);outline:2px solid #1f2d3d;outline-offset:1px}
.op-bar span{overflow:hidden;text-overflow:ellipsis}
.op-bar small{font-weight:400;opacity:.9}
.op-assigned{background:#2f7fc1}
.op-in{background:#2e8b57}
.op-out{background:#8a96a3}
.op-noshow{background:repeating-linear-gradient(135deg,#d9822b 0 6px,#c06f1f 6px 12px)}
.op-blocked{background:repeating-linear-gradient(135deg,#b8c0c8 0 5px,#a3acb6 5px 10px);color:#1f2d3d;font-weight:400}
.op-cut-l{border-top-left-radius:0;border-bottom-left-radius:0;margin-left:0}
.op-cut-r{border-top-right-radius:0;border-bottom-right-radius:0;margin-right:0}
.op-pos{font-style:normal;background:rgba(255,255,255,.25);border-radius:3px;padding:0 4px;font-size:10px;flex:none}
.op-pos-failed{background:#c0392b}
.op-src{font-style:normal;flex:none;background:#fff;color:#1f2d3d;border-radius:3px;padding:0 3px;font-size:10px;font-weight:700;line-height:15px}
.op-src-ota{background:#003580;color:#fff}
.op-free{cursor:pointer}
.op-free:hover,.op-free:focus{background:#e3f0fb;outline:none}
.op-sel{background:#cfe6fa!important;box-shadow:inset 0 0 0 1px #2f7fc1}
.op-actionbar{position:sticky;bottom:0;z-index:5;display:flex;flex-wrap:wrap;gap:8px;align-items:center;margin-top:10px;padding:10px 12px;background:#1f2d3d;color:#fff;border-radius:4px;box-shadow:0 -4px 12px rgba(0,0,0,.15)}
.op-actionbar[hidden]{display:none}
.op-actiontext{flex:1 1 20rem;font-weight:600}
.op-foot{background:#f8fafb;min-height:26px}
.op-foot .op-label{background:#f8fafb;font-weight:600}
.op-occ{grid-row:1;border-left:1px solid #eef1f4;display:flex;align-items:flex-end;justify-content:center;background:linear-gradient(to top,#cfe3f3 var(--p),transparent var(--p));font-size:11px;padding-bottom:2px}
.op-legend{display:flex;flex-wrap:wrap;gap:8px;margin:10px 0 0;align-items:center}
.op-legend .op-bar{display:inline-block;margin:0}
.op-legend .op-pos{background:#2e8b57;color:#fff}
@media (prefers-reduced-motion:reduce){*{transition:none!important}}
</style>';
}
}