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>
315 lines
18 KiB
PHP
315 lines
18 KiB
PHP
<?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>';
|
||
}
|
||
}
|