feat: derive room type from occupancy instead of a mapping table

This commit is contained in:
2026-09-22 14:47:04 +02:00
parent e52f7dcff8
commit 942724fdd1
12 changed files with 199 additions and 342 deletions
@@ -30,12 +30,17 @@ namespace EP\EpProducts\Domain\Model;
class Room
{
const ROOM_TYPE_UNCLASSIFIED = 0;
const ROOM_TYPE_FOR_ONE = 1;
const ROOM_TYPE_FOR_TWO = 2;
const ROOM_TYPE_FOR_THREE = 3;
const ROOM_TYPE_FOR_FOUR = 4;
const ROOM_TYPE_WITH_OTHERS = 5;
const ROOM_TYPE_OTHER = 6;
const ROOM_TYPE_FOR_FIVE = 5;
const ROOM_TYPE_FOR_SIX = 6;
const ROOM_TYPE_FOR_SEVEN = 7;
const ROOM_TYPE_FOR_EIGHT = 8;
const ROOM_TYPE_FOR_NINE_PLUS = 9;
const ROOM_TYPE_WITH_OTHERS = 10;
const ROOM_STATUS_AVAILABLE = 1;
const ROOM_STATUS_ON_REQUEST = 2;
@@ -1,75 +0,0 @@
<?php
namespace EP\EpProducts\Domain\Model;
/***************************************************************
*
* Copyright notice
*
* (c) 2016 Björn Fromme <[email protected]>, dreipunktnull
*
* All rights reserved
*
* This script is part of the TYPO3 project. The TYPO3 project is
* free software; you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation; either version 3 of the License, or
* (at your option) any later version.
*
* The GNU General Public License can be found at
* http://www.gnu.org/copyleft/gpl.html.
*
* This script is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* This copyright notice MUST APPEAR in all copies of the script!
***************************************************************/
class RoomMapping extends \TYPO3\CMS\Extbase\DomainObject\AbstractEntity
{
/**
* @var string
*/
protected $code = '';
/**
* @var int
*/
protected $type = 0;
/**
* @return string
*/
public function getCode()
{
return $this->code;
}
/**
* @param string $code
*/
public function setCode($code)
{
$this->code = $code;
}
/**
* @return int
*/
public function getType()
{
return $this->type;
}
/**
* @param int $type
*/
public function setType($type)
{
$this->type = $type;
}
}
@@ -76,9 +76,11 @@ class ProductRepository extends AbstractRepository
'date.services_included as servicesIncluded', 'date.skipass_included as skiPassIncluded',
'date.bus_available as busAvailable', 'date.new as new',
'date.min_price as minPrice', 'date.nights as nights', 'date.available as available',
'date.pseudo_price as pseudoPrice', 'date.hide_booking_button as hideBookingButton',
'room.type as roomType'
'date.pseudo_price as pseudoPrice', 'date.hide_booking_button as hideBookingButton'
)
// The GROUP BY below collapses many room rows per group; an arbitrary
// room.type would hide most types from the filter facet.
->addSelectLiteral('GROUP_CONCAT(DISTINCT room.type) as roomTypes')
->from('tx_epproducts_domain_model_date', 'date')
->leftJoin('date', 'tx_epproducts_domain_model_concept', 'con', 'date.concept = con.uid')
->leftJoin('date', 'tx_epproducts_domain_model_room', 'room', 'room.date = date.uid')
@@ -1,31 +0,0 @@
<?php
namespace EP\EpProducts\Domain\Repository;
/***************************************************************
*
* Copyright notice
*
* (c) 2016 Björn Fromme <[email protected]>, dreipunktnull
*
* All rights reserved
*
* This script is part of the TYPO3 project. The TYPO3 project is
* free software; you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation; either version 3 of the License, or
* (at your option) any later version.
*
* The GNU General Public License can be found at
* http://www.gnu.org/copyleft/gpl.html.
*
* This script is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* This copyright notice MUST APPEAR in all copies of the script!
***************************************************************/
class RoomMappingRepository extends AbstractRepository
{}
@@ -60,11 +60,6 @@ class DateImportService implements SingletonInterface, LoggerAwareInterface
*/
protected $pid = 0;
/**
* @var array
*/
protected $roomMappings = [];
/**
* @var array
*/
@@ -90,6 +85,11 @@ class DateImportService implements SingletonInterface, LoggerAwareInterface
*/
protected $importSourceFactory;
/**
* @var RoomTypeResolver
*/
protected $roomTypeResolver;
/**
* @var array
*/
@@ -101,15 +101,18 @@ class DateImportService implements SingletonInterface, LoggerAwareInterface
* @param ConfigurationManagerInterface $configurationManager
* @param CacheService $cacheService
* @param ProductImportSourceFactory $importSourceFactory
* @param RoomTypeResolver $roomTypeResolver
*/
public function __construct(
ConfigurationManagerInterface $configurationManager,
CacheService $cacheService,
ProductImportSourceFactory $importSourceFactory
ProductImportSourceFactory $importSourceFactory,
RoomTypeResolver $roomTypeResolver
) {
$this->configurationManager = $configurationManager;
$this->cacheService = $cacheService;
$this->importSourceFactory = $importSourceFactory;
$this->roomTypeResolver = $roomTypeResolver;
$settings = GeneralUtility::removeDotsFromTS(
$this->configurationManager->getConfiguration(ConfigurationManagerInterface::CONFIGURATION_TYPE_FULL_TYPOSCRIPT)
@@ -130,7 +133,6 @@ class DateImportService implements SingletonInterface, LoggerAwareInterface
public function import($path): int
{
$this->db = $this->getDbConnection();
$this->getRoomMappings();
$this->getHotelMappings();
$this->importPickups($path);
$this->createTempTables();
@@ -594,13 +596,9 @@ class DateImportService implements SingletonInterface, LoggerAwareInterface
continue;
}
$roomTypeKey = mb_strtolower($code);
if (array_key_exists($roomTypeKey, $this->roomMappings)) {
$roomType = $this->roomMappings[$roomTypeKey];
} else {
$this->logger->warning('room type not found', ['code' => $code]);
continue;
}
$label = (string) $roomXml->attributes()['zimmertext'];
$roomMinPax = (int) $roomXml->attributes()['MinPax'];
$pax = (int) $roomXml->attributes()['MaxPax'];
$available = (int) $roomXml->attributes()['verfuegbar'];
$price = (int) $roomXml->attributes()['preis'];
@@ -610,12 +608,23 @@ class DateImportService implements SingletonInterface, LoggerAwareInterface
continue;
}
$pax = (int) $roomXml->attributes()['MinPax'];
if (0 === $minPax || $pax < $minPax) {
$minPax = $pax;
$roomType = $this->roomTypeResolver->resolve($code, $label, $pax, $roomMinPax);
if ($pax < 1 && $roomMinPax < 1) {
$this->logger->warning('room without occupancy', ['code' => $code, 'zimmertext' => $label]);
}
if ($this->roomTypeResolver->needsReview($code, $label, $pax)) {
$this->logger->warning(
'single-occupancy room without single-use marker',
['code' => $code, 'zimmertext' => $label]
);
}
if (0 === $minPax || $roomMinPax < $minPax) {
$minPax = $roomMinPax;
}
$pax = (int) $roomXml->attributes()['MaxPax'];
$availableTotal += $available * $pax;
// Determine lowest price of available rooms
@@ -648,7 +657,7 @@ class DateImportService implements SingletonInterface, LoggerAwareInterface
'bus_pro_id' => (int) $roomXml->attributes()['idbuspro_zimmer'],
'code' => $code,
'type' => $roomType,
'name' => (string) $roomXml->attributes()['zimmertext'],
'name' => $label,
'pax' => $pax,
'nights' => (int) $roomXml->attributes()['naechte'],
'price' => $price,
@@ -791,16 +800,6 @@ class DateImportService implements SingletonInterface, LoggerAwareInterface
}
}
protected function getRoomMappings(): void
{
$sql = 'SELECT code, type FROM tx_epproducts_domain_model_roommapping WHERE deleted = 0 AND hidden = 0';
$roomMappings = $this->db->fetchAllAssociative($sql);
foreach ($roomMappings as $mapping) {
$key = trim(mb_strtolower($mapping['code']));
$this->roomMappings[$key] = $mapping['type'];
}
}
protected function getHotelMappings(): void
{
$sql = '
@@ -3,6 +3,7 @@
namespace EP\EpProducts\Service;
use EP\EpProducts\Domain\Model\Dto\FilterOptions;
use EP\EpProducts\Domain\Model\Room;
use EP\EpProducts\Domain\Model\SearchResult;
use Symfony\Component\OptionsResolver\Options;
use Symfony\Component\OptionsResolver\OptionsResolver;
@@ -212,6 +213,12 @@ class FilterService implements SingletonInterface
}
}
foreach ($item['rooms'] as $roomType) {
// Type 0 is not a room (Baby) and must never become a filter option:
// it has no label, so it would render as an empty checkbox and leave
// a hole in the option list.
if ((int) $roomType === Room::ROOM_TYPE_UNCLASSIFIED) {
continue;
}
if (!\in_array($roomType, $roomTypes, false)) {
$roomTypes[] = $roomType;
}
@@ -0,0 +1,129 @@
<?php
namespace EP\EpProducts\Service;
/***************************************************************
*
* Copyright notice
*
* (c) 2016 Björn Fromme <[email protected]>, dreipunktnull
*
* All rights reserved
*
* This script is part of the TYPO3 project. The TYPO3 project is
* free software; you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation; either version 3 of the License, or
* (at your option) any later version.
*
* The GNU General Public License can be found at
* http://www.gnu.org/copyleft/gpl.html.
*
* This script is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* This copyright notice MUST APPEAR in all copies of the script!
***************************************************************/
use EP\EpProducts\Domain\Model\Room;
/**
* Derives the room type from the data each <preis> element carries.
*
* Replaces the former tx_epproducts_domain_model_roommapping lookup: zimmercode is
* neither unique per room nor a function of room type, so a code-keyed table cannot
* classify this data. MaxPax is the advertised occupancy and is present on every row.
*/
class RoomTypeResolver
{
/**
* Phrases in zimmertext that mark a bed shared with other customers.
*
* @var array
*/
const LABEL_PATTERNS_WITH_OTHERS = [
'bett im',
'mit ander',
];
/**
* Phrases in zimmertext that mark a deliberate single use of a larger room.
*
* @var array
*/
const LABEL_PATTERNS_SINGLE_USE = [
'einzel',
'1 person',
];
/**
* @param string $code zimmercode
* @param string $label zimmertext
* @param int $maxPax MaxPax
* @param int $minPax MinPax
* @return int one of the Room::ROOM_TYPE_* constants
*/
public function resolve(string $code, string $label, int $maxPax, int $minPax): int
{
// Not a room: "Babypreis für 0-2 Jährige". Kept in the date record for its
// price and availability, but hidden from the filter.
if ($code === DateImportService::CODE_BABYROOM) {
return Room::ROOM_TYPE_UNCLASSIFIED;
}
if ($this->matchesAny($label, static::LABEL_PATTERNS_WITH_OTHERS)) {
return Room::ROOM_TYPE_WITH_OTHERS;
}
$pax = $maxPax > 0 ? $maxPax : $minPax;
if ($pax >= 1) {
// Type numbering is chosen so that type === pax for pax 1-8.
return $pax >= 9 ? Room::ROOM_TYPE_FOR_NINE_PLUS : $pax;
}
// No usable occupancy - should never happen on current data, the caller logs it.
return Room::ROOM_TYPE_WITH_OTHERS;
}
/**
* Tripwire for an under-matching label pattern: a row classified as a single room
* whose label carries no single-use marker is most likely a shared bed phrased in
* a way LABEL_PATTERNS_WITH_OTHERS does not yet cover.
*
* @param string $code zimmercode
* @param string $label zimmertext
* @param int $maxPax MaxPax
* @return bool
*/
public function needsReview(string $code, string $label, int $maxPax): bool
{
if ($this->resolve($code, $label, $maxPax, $maxPax) !== Room::ROOM_TYPE_FOR_ONE) {
return false;
}
if ($maxPax !== 1) {
return false;
}
return !$this->matchesAny($label, static::LABEL_PATTERNS_SINGLE_USE);
}
/**
* @param string $label
* @param array $patterns
* @return bool
*/
protected function matchesAny(string $label, array $patterns): bool
{
foreach ($patterns as $pattern) {
if (mb_strpos(mb_strtolower($label), mb_strtolower($pattern)) !== false) {
return true;
}
}
return false;
}
}
@@ -31,6 +31,7 @@ use EP\EpProducts\Domain\Model\SearchResult;
use EP\EpProducts\Domain\Repository\ProductRepository;
use Symfony\Component\OptionsResolver\OptionsResolver;
use TYPO3\CMS\Core\SingletonInterface;
use TYPO3\CMS\Core\Utility\GeneralUtility;
class SearchResultService implements SingletonInterface
{
@@ -216,8 +217,10 @@ class SearchResultService implements SingletonInterface
$item['filterMetadata']['boardTypes'][] = $boardType;
}
if (isset($row['roomType']) && !\in_array($row['roomType'], $item['rooms'], false)) {
$item['rooms'][] = $row['roomType'];
foreach (GeneralUtility::trimExplode(',', (string) ($row['roomTypes'] ?? ''), true) as $roomType) {
if (!\in_array($roomType, $item['rooms'], false)) {
$item['rooms'][] = $roomType;
}
}
if (!\in_array($row['nights'], $item['nights'], false)) {
@@ -1,144 +0,0 @@
<?php
return [
'ctrl' => [
'title' => 'LLL:EXT:ep_products/Resources/Private/Language/locallang.xlf:tx_epproducts_domain_model_roommapping',
'label' => 'code',
'prependAtCopy' => '[COPY %s]',
'default_sortby' => 'ORDER BY code',
'tstamp' => 'tstamp',
'crdate' => 'crdate',
'cruser_id' => 'cruser_id',
'dividers2tabs' => true,
'versioningWS' => false,
'languageField' => 'sys_language_uid',
'transOrigPointerField' => 'l10n_parent',
'transOrigDiffSourceField' => 'l10n_diffsource',
'delete' => 'deleted',
'enablecolumns' => [
'disabled' => 'hidden',
'starttime' => 'starttime',
'endtime' => 'endtime',
],
'searchFields' => 'code',
'iconfile' => 'EXT:ep_theme/Resources/Public/images/icon_ce.svg',
],
'types' => [
'1' => ['showitem' => 'code, type'],
],
'palettes' => [
'1' => ['showitem' => ''],
],
'columns' => [
'sys_language_uid' => [
'exclude' => 0,
'label' => 'LLL:EXT:core/Resources/Private/Language/locallang_general.xlf:LGL.language',
'config' => [
'type' => 'select',
'default' => 0,
'renderType' => 'selectSingle',
'foreign_table' => 'sys_language',
'foreign_table_where' => 'ORDER BY sys_language.title',
'items' => [
['LLL:EXT:core/Resources/Private/Language/locallang_general.xlf:LGL.allLanguages', -1],
['LLL:EXT:core/Resources/Private/Language/locallang_general.xlf:LGL.default_value', 0]
],
],
],
'l10n_parent' => [
'displayCond' => 'FIELD:sys_language_uid:>:0',
'label' => 'LLL:EXT:core/Resources/Private/Language/locallang_general.xlf:LGL.l18n_parent',
'config' => [
'type' => 'select',
'default' => 0,
'renderType' => 'selectSingle',
'items' => [
['', 0],
],
'foreign_table' => 'tx_epproducts_domain_model_roommapping',
'foreign_table_where' => 'AND tx_epproducts_domain_model_roommapping.pid=###CURRENT_PID### AND tx_epproducts_domain_model_roommapping.sys_language_uid IN (-1,0)',
],
],
'l10n_diffsource' => [
'config' => [
'type' => 'passthrough',
],
],
't3ver_label' => [
'label' => 'LLL:EXT:core/Resources/Private/Language/locallang_general.xlf:LGL.versionLabel',
'config' => [
'type' => 'input',
'size' => 30,
'max' => 255,
]
],
'hidden' => [
'exclude' => 0,
'label' => 'LLL:EXT:core/Resources/Private/Language/locallang_general.xlf:LGL.hidden',
'config' => [
'type' => 'check',
],
],
'starttime' => [
'exclude' => 0,
'label' => 'LLL:EXT:core/Resources/Private/Language/locallang_general.xlf:LGL.starttime',
'config' => [
'type' => 'input',
'size' => 13,
'eval' => 'datetime',
'checkbox' => 0,
'default' => 0,
'behaviour' => [
'allowLanguageSynchronization' => true,
],
'renderType' => 'inputDateTime',
],
],
'endtime' => [
'exclude' => 0,
'label' => 'LLL:EXT:core/Resources/Private/Language/locallang_general.xlf:LGL.endtime',
'config' => [
'type' => 'input',
'size' => 13,
'eval' => 'datetime',
'checkbox' => 0,
'default' => 0,
'behaviour' => [
'allowLanguageSynchronization' => true,
],
'renderType' => 'inputDateTime',
],
],
'code' => [
'exclude' => 0,
'label' => 'Code',
'config' => [
'type' => 'input',
'size' => 30,
'eval' => 'trim'
],
],
'type' => [
'exclude' => 0,
'label' => 'Typ',
'config' => [
'type' => 'select',
'renderType' => 'selectSingle',
'items' => [
['Einzelzimmer', \EP\EpProducts\Domain\Model\Room::ROOM_TYPE_FOR_ONE],
['Doppelzimmer und Apartm. f. 2 Pers.', \EP\EpProducts\Domain\Model\Room::ROOM_TYPE_FOR_TWO],
['Zimmer und Apartm. f. 3 Pers.', \EP\EpProducts\Domain\Model\Room::ROOM_TYPE_FOR_THREE],
['Zimmer und Apartm. f. 4 Pers.', \EP\EpProducts\Domain\Model\Room::ROOM_TYPE_FOR_FOUR],
['Zimmer und Apartm. mit Anderen', \EP\EpProducts\Domain\Model\Room::ROOM_TYPE_WITH_OTHERS],
['weitere Zimmer und Apartments', \EP\EpProducts\Domain\Model\Room::ROOM_TYPE_OTHER],
],
'size' => 1,
'minItems' => 1,
'maxItems' => 1
],
],
],
];
@@ -39,9 +39,6 @@
<trans-unit id="tx_epproducts_domain_model_teammember">
<source>Mitarbeiter/Teamer</source>
</trans-unit>
<trans-unit id="tx_epproducts_domain_model_roommapping">
<source>Zimmermapping</source>
</trans-unit>
<trans-unit id="tx_epproducts_domain_model_journey">
<source>Anreise</source>
</trans-unit>
@@ -494,47 +494,6 @@ CREATE TABLE tx_epproducts_domain_model_room
KEY constraints (date, type)
);
#
# Table structure for table 'tx_epproducts_domain_model_roommapping'
#
CREATE TABLE tx_epproducts_domain_model_roommapping
(
uid int(11) NOT NULL auto_increment,
pid int(11) DEFAULT '0' NOT NULL,
code varchar(255) DEFAULT '' NOT NULL,
type int(11) DEFAULT '0' NOT NULL,
tstamp int(11) unsigned DEFAULT '0' NOT NULL,
crdate int(11) unsigned DEFAULT '0' NOT NULL,
cruser_id int(11) unsigned DEFAULT '0' NOT NULL,
deleted tinyint(4) unsigned DEFAULT '0' NOT NULL,
hidden tinyint(4) unsigned DEFAULT '0' NOT NULL,
starttime int(11) unsigned DEFAULT '0' NOT NULL,
endtime int(11) unsigned DEFAULT '0' NOT NULL,
t3ver_oid int(11) DEFAULT '0' NOT NULL,
t3ver_id int(11) DEFAULT '0' NOT NULL,
t3ver_wsid int(11) DEFAULT '0' NOT NULL,
t3ver_label varchar(255) DEFAULT '' NOT NULL,
t3ver_state tinyint(4) DEFAULT '0' NOT NULL,
t3ver_stage int(11) DEFAULT '0' NOT NULL,
t3ver_count int(11) DEFAULT '0' NOT NULL,
t3ver_tstamp int(11) DEFAULT '0' NOT NULL,
t3ver_move_id int(11) DEFAULT '0' NOT NULL,
sys_language_uid int(11) DEFAULT '0' NOT NULL,
l10n_parent int(11) DEFAULT '0' NOT NULL,
l10n_diffsource mediumblob,
PRIMARY KEY (uid),
KEY parent (pid),
KEY t3ver_oid (t3ver_oid, t3ver_wsid),
KEY language (l10n_parent, sys_language_uid)
);
#
# Table structure for table 'tx_epproducts_domain_model_concept'
#
@@ -274,8 +274,12 @@
2: 'DZ / 2er',
3: '3er',
4: '4er',
5: 'ab 5 Pers.',
6: 'mit anderen'
5: '5er',
6: '6er',
7: '7er',
8: '8er',
9: '9er und mehr',
10: 'mit anderen'
}"/>
<div class="py-2"
data-controller="collapse"
@@ -291,17 +295,19 @@
<div data-collapse-target="container">
<ul class="mb-0 p-0 pt-4 space-y-2">
<f:for each="{filterOptions.roomTypes}" as="type">
<li>
<label class="flex items-start">
<f:form.checkbox class="w-5 h-5 border-ep-primary-light ring-0 bg-transparent focus:ring-0 focus:border-none text-ep-primary"
property="roomTypes"
data="{action: '{stimulusController}#filter'}"
value="{type}"/>
<span class="leading-none pl-2">
{roomTypes.{type}}
</span>
</label>
</li>
<f:if condition="{roomTypes.{type}}">
<li>
<label class="flex items-start">
<f:form.checkbox class="w-5 h-5 border-ep-primary-light ring-0 bg-transparent focus:ring-0 focus:border-none text-ep-primary"
property="roomTypes"
data="{action: '{stimulusController}#filter'}"
value="{type}"/>
<span class="leading-none pl-2">
{roomTypes.{type}}
</span>
</label>
</li>
</f:if>
</f:for>
</ul>
</div>