diff --git a/.env b/.env index f248bded..0938e5ac 100644 --- a/.env +++ b/.env @@ -48,6 +48,12 @@ MAILJET_DEFAULT_LIST_ID= MAILJET_WEBHOOK_BASIC_PASSWORD=secret APP_NEWSLETTER_CONFIRMATION_TTL_HOURS=1 +# Dedicated signing secret for accommodation booking customer access links (kept separate +# from APP_SECRET so it can be rotated independently). Unlike lazily-used API keys, UriSigner +# validates this is non-empty at construction time, so a real dev value is required here +# (override in .env.local/production secrets as usual). +ACCOMMODATION_OFFER_LINK_SECRET=abf8ec28cc2162d9121d35d6f8cbf7c29728cdacce0303e22e1bac854501728a + BPN_CONNECT_BASE_URL=https://bpn-connect.ep-reisen.app BPN_CONNECT_API_KEY= @@ -78,10 +84,17 @@ APP_TRAVEL_SNAPSHOT_RETENTION_BUFFER_DAYS=14 # Emails APP_DEFAULT_EMAIL_FROM=info@ep-reisen.de APP_DEFAULT_EMAIL_TO=info@ep-reisen.de +ACCOMMODATION_INQUIRY_EMAIL=info@ep-reisen.de # Global common defaults APP_SEASON_WINTER_FROM=2026-10-01 APP_SEASON_SUMMER_FROM=2026-05-01 +APP_RUNNING_COSTS_FACTOR_EUR=2.75 +APP_RUNNING_COSTS_FACTOR_CHF=3.2 +APP_UNDERSUBSCRIPTION_SURCHARGE_30_EUR=5.0 +APP_UNDERSUBSCRIPTION_SURCHARGE_30_CHF=5.0 +APP_UNDERSUBSCRIPTION_SURCHARGE_40_EUR=2.5 +APP_UNDERSUBSCRIPTION_SURCHARGE_40_CHF=2.5 # Booking Configuration # Status for new bookings: 'F' (Fixed/Final), 'O' (Option - requires agency confirmation) diff --git a/api.http b/api.http index 2045a02d..9a76f284 100644 --- a/api.http +++ b/api.http @@ -149,3 +149,10 @@ GET {{base_url}}/api/pickups-planning/DPWWTS191125 Accept: application/json Content-Type: application/json Authorization: Bearer {{$auth.token("oauth2_api")}} + +### API accommodation booking +# @no-cookie-jar +GET {{base_url}}/api/accommodation-bookings/e4591076-eb65-41e8-a115-09d18d15604a +Accept: application/json +Content-Type: application/json +Authorization: Bearer {{$auth.token("oauth2_api")}} diff --git a/assets/controllers/price_calendar_controller.js b/assets/controllers/price_calendar_controller.js new file mode 100644 index 00000000..a94fdddb --- /dev/null +++ b/assets/controllers/price_calendar_controller.js @@ -0,0 +1,166 @@ +import { Controller } from '@hotwired/stimulus' + +export default class extends Controller { + + static targets = ['mount', 'error', 'message', 'resetButton', 'day', 'dateFrom', 'dateTo', 'submit'] + + static values = { + hotelCode: String, + selectedFrom: String, + selectedTo: String, + calendarRefreshUrl: String, + } + + connect() { + this.startDate = null + this.endDate = null + this.sessionRestored = false + + // Re-apply highlights after every HTMX month swap (navigation or initial load). + this.mountTarget.addEventListener('htmx:afterSwap', () => requestAnimationFrame(() => this.initAfterLoad())) + this.mountTarget.addEventListener('htmx:responseError', () => this.showError()) + } + + initAfterLoad() { + // Restore from session values exactly once on the first swap. + if (!this.sessionRestored) { + this.sessionRestored = true + const from = this.selectedFromValue + const to = this.selectedToValue + if (from && to) { + this.startDate = from + this.endDate = to + if (this.hasResetButtonTarget) this.resetButtonTarget.classList.remove('hidden') + } + } + this.updateDisplay() + } + + selectDate(event) { + const btn = event.currentTarget + btn.blur() + const date = btn.dataset.date + const status = btn.dataset.status + + if (!status || status === 'blocked' || status === 'past') return + + if (this.startDate && this.endDate) { + this.resetSelection() + } + + if (!this.startDate) { + if (status === 'checkout-only') { + this.showMessage('Bitte wähle einen Anreisetag.') + return + } + this.startDate = date + this.endDate = null + this.updateDisplay() + this.showMessage('Bitte wähle das Abreisedatum.') + return + } + + if (date === this.startDate) { + this.resetSelection() + return + } + + const [from, to] = date < this.startDate ? [date, this.startDate] : [this.startDate, date] + + if (this.hasBlockedInRange(from, to)) { + this.resetSelection() + this.showMessage('Dieser Zeitraum enthält nicht verfügbare Daten.') + return + } + + const startBtn = this.dayTargets.find(b => b.dataset.date === from) + const minNights = parseInt(startBtn?.dataset.minNights) || 0 + const nights = (new Date(to) - new Date(from)) / 86400000 + + if (minNights > 0 && nights < minNights) { + this.showMessage(`Weniger als ${minNights} Nächte nur auf Anfrage.`) + } else { + this.hideMessage() + } + + this.startDate = from + this.endDate = to + this.updateDisplay() + this.activateSubmit(from, to) + this.saveAndRefreshSummary(from, to) + this.dispatch('range-select', { detail: { dateFrom: from, dateTo: to } }) + } + + hasBlockedInRange(from, to) { + for (const btn of this.dayTargets) { + const d = btn.dataset.date + const s = btn.dataset.status + if (d >= from && d < to && (s === 'blocked' || s === 'past' || s === 'checkout-only')) return true + } + return false + } + + updateDisplay() { + for (const btn of this.dayTargets) { + const d = btn.dataset.date + btn.classList.remove('pc-day--pending-start', 'pc-day--selected-start', 'pc-day--selected-end', 'pc-day--in-range') + if (!this.startDate) continue + if (d === this.startDate) { + btn.classList.add(this.endDate ? 'pc-day--selected-start' : 'pc-day--pending-start') + } else if (this.endDate) { + if (d === this.endDate) btn.classList.add('pc-day--selected-end') + else if (d > this.startDate && d < this.endDate) btn.classList.add('pc-day--in-range') + } + } + } + + activateSubmit(dateFrom, dateTo) { + if (this.hasDateFromTarget) this.dateFromTarget.value = dateFrom + if (this.hasDateToTarget) this.dateToTarget.value = dateTo + if (this.hasSubmitTarget) { + this.submitTarget.disabled = false + this.submitTarget.classList.remove('opacity-50', 'cursor-not-allowed', 'pointer-events-none') + } + if (this.hasResetButtonTarget) this.resetButtonTarget.classList.remove('hidden') + } + + resetSelection() { + this.startDate = null + this.endDate = null + this.updateDisplay() + this.hideMessage() + if (this.hasDateFromTarget) this.dateFromTarget.value = '' + if (this.hasDateToTarget) this.dateToTarget.value = '' + if (this.hasSubmitTarget) { + this.submitTarget.disabled = true + this.submitTarget.classList.add('opacity-50', 'cursor-not-allowed', 'pointer-events-none') + } + if (this.hasResetButtonTarget) this.resetButtonTarget.classList.add('hidden') + this.saveAndRefreshSummary() + } + + reset() { + this.resetSelection() + } + + saveAndRefreshSummary(dateFrom = '', dateTo = '') { + if (!this.hasCalendarRefreshUrlValue) return + htmx.ajax('POST', this.calendarRefreshUrlValue, { + values: { date_from: dateFrom, date_to: dateTo }, + swap: 'none', + target: this.element, + }) + } + + showMessage(text) { + if (this.hasMessageTarget) this.messageTarget.textContent = text + } + + hideMessage() { + if (this.hasMessageTarget) this.messageTarget.textContent = '' + } + + showError() { + if (this.hasErrorTarget) this.errorTarget.classList.remove('hidden') + } +} diff --git a/assets/controllers/sortable_controller.js b/assets/controllers/sortable_controller.js deleted file mode 100644 index cc2722b2..00000000 --- a/assets/controllers/sortable_controller.js +++ /dev/null @@ -1,42 +0,0 @@ -import { Controller } from '@hotwired/stimulus' -import Sortable from 'sortablejs' -import { useFetch } from '../mixins/use_fetch' - -/* stimulusFetch: 'lazy' */ -export default class extends Controller { - - static targets = [ 'item', 'handle', 'positionField', 'positionLabel' ] - static values = { url: String } - - connect() { - useFetch(this) - new Sortable(this.element, { - item: '[data-sortable-target="item"]', - handle: '[data-sortable-target="handle"]', - sort: true, - onEnd: () => { - this.updateOrdering() - if (this.urlValue) { - this.saveOrdering() - } - } - }) - } - - updateOrdering() { - this.positionFieldTargets.forEach((field, index) => { - field.value = index + 1 - }) - this.positionLabelTargets.forEach((label, index) => { - label.innerText = index + 1 - }) - } - - saveOrdering() { - const body = new FormData() - this.positionFieldTargets.forEach(field => { - body.append(`sorting[${field.dataset.id}]`, field.value) - }) - fetch(this.urlValue, this.getInitObject('post', body)) - } -} diff --git a/assets/images/bg_summer.jpg b/assets/images/bg_summer.jpg new file mode 100644 index 00000000..0854c798 Binary files /dev/null and b/assets/images/bg_summer.jpg differ diff --git a/assets/styles/_base.css b/assets/styles/_base.css index 7d1f8e91..a91a5225 100644 --- a/assets/styles/_base.css +++ b/assets/styles/_base.css @@ -53,6 +53,10 @@ background-image: url('../images/bg_5.jpg'), linear-gradient(190deg, rgba(24, 82, 123, 1) 0%, rgba(22, 88, 131, 1) 62%, rgba(0, 112, 224, 1) 100%);; } +.bg-outer--summer { + background-image: url('../images/bg_summer.jpg'), linear-gradient(190deg, rgba(24, 82, 123, 1) 0%, rgba(22, 88, 131, 1) 62%, rgba(0, 112, 224, 1) 100%);; +} + .sbw .bg-outer--2 { background-image: url('../images/bg_7.jpg'), linear-gradient(190deg, rgba(24, 82, 123, 1) 0%, rgba(22, 88, 131, 1) 62%, rgba(0, 112, 224, 1) 100%);; } diff --git a/assets/styles/_components.css b/assets/styles/_components.css index cfcf3d03..5f1f0273 100644 --- a/assets/styles/_components.css +++ b/assets/styles/_components.css @@ -1,6 +1,7 @@ @import "components/button.css"; @import "components/forms.css"; @import "components/pagination.css"; +@import "components/price_calendar.css"; @import "components/toast.css"; @import "components/tooltip.css"; @import "components/typography.css"; diff --git a/assets/styles/components/price_calendar.css b/assets/styles/components/price_calendar.css new file mode 100644 index 00000000..fba8126d --- /dev/null +++ b/assets/styles/components/price_calendar.css @@ -0,0 +1,41 @@ +.pc-day { + @apply relative w-full text-center py-1 px-0.5 text-sm leading-tight rounded text-white cursor-pointer; +} + +.pc-day--out-of-month { + @apply pointer-events-none opacity-0; +} + +.pc-day[data-status="ok"] { + @apply bg-emerald-500; +} + +.pc-day[data-status="blocked-to-ok"] { + background: linear-gradient(to bottom right, theme('colors.gray.400') 50%, theme('colors.emerald.500') 50%); +} + +.pc-day[data-status="checkout-only"] { + background: linear-gradient(to bottom right, theme('colors.emerald.500') 50%, theme('colors.gray.400') 50%); +} + +.pc-day[data-status="past"] { + @apply bg-transparent text-gray-400 cursor-default; +} + +.pc-day[data-status="blocked"] { + @apply bg-gray-400 cursor-default; +} + +.pc-day[data-status=""], .pc-day:not([data-status]) { + @apply text-gray-400 cursor-default; +} + +.pc-day.pc-day--pending-start, +.pc-day.pc-day--selected-start, +.pc-day.pc-day--selected-end { + @apply bg-none outline-none bg-secondary text-gray-700; +} + +.pc-day.pc-day--in-range { + @apply bg-none bg-secondary text-gray-700 opacity-55; +} diff --git a/config/services.yaml b/config/services.yaml index b6b470b4..f6db3931 100644 --- a/config/services.yaml +++ b/config/services.yaml @@ -13,6 +13,7 @@ parameters: travel_snapshot_retention_buffer_days: '%env(int:APP_TRAVEL_SNAPSHOT_RETENTION_BUFFER_DAYS)%' default_email_from: '%env(APP_DEFAULT_EMAIL_FROM)%' default_email_to: '%env(APP_DEFAULT_EMAIL_TO)%' + accommodation_inquiry_email: '%env(ACCOMMODATION_INQUIRY_EMAIL)%' # MailJet list ids and their labels mailjet_lists: @@ -254,6 +255,20 @@ services: from: '%default_email_from%' to: '%default_email_to%' + App\Service\AccommodationBookingService: + arguments: + $officeEmail: '%accommodation_inquiry_email%' + + App\Service\GroupsPriceCalculator: + arguments: + $config: + runningCostsEur: '%env(float:APP_RUNNING_COSTS_FACTOR_EUR)%' + runningCostsChf: '%env(float:APP_RUNNING_COSTS_FACTOR_CHF)%' + undersubscription30Eur: '%env(float:APP_UNDERSUBSCRIPTION_SURCHARGE_30_EUR)%' + undersubscription30Chf: '%env(float:APP_UNDERSUBSCRIPTION_SURCHARGE_30_CHF)%' + undersubscription40Eur: '%env(float:APP_UNDERSUBSCRIPTION_SURCHARGE_40_EUR)%' + undersubscription40Chf: '%env(float:APP_UNDERSUBSCRIPTION_SURCHARGE_40_CHF)%' + App\Validator\Constraints\ParticipantValidator: arguments: $bodyDimensionRanges: '%body_dimension_ranges%' diff --git a/migrations/Version20260706120805.php b/migrations/Version20260706120805.php new file mode 100644 index 00000000..ba84883c --- /dev/null +++ b/migrations/Version20260706120805.php @@ -0,0 +1,33 @@ +addSql('ALTER TABLE accommodation DROP min_pax_summer'); + $this->addSql('ALTER TABLE accommodation_price ADD accept_short_term TINYINT(1) NOT NULL'); + } + + public function down(Schema $schema): void + { + // this down() migration is auto-generated, please modify it to your needs + $this->addSql('ALTER TABLE accommodation ADD min_pax_summer INT NOT NULL'); + $this->addSql('ALTER TABLE accommodation_price DROP accept_short_term'); + } +} diff --git a/migrations/Version20260706141648.php b/migrations/Version20260706141648.php new file mode 100644 index 00000000..bb5e2350 --- /dev/null +++ b/migrations/Version20260706141648.php @@ -0,0 +1,41 @@ +addSql('CREATE TABLE accommodation_inquiry (id INT AUTO_INCREMENT NOT NULL, accommodation_id INT NOT NULL, created_by_id INT DEFAULT NULL, updated_by_id INT DEFAULT NULL, date_from DATE NOT NULL COMMENT \'(DC2Type:date_immutable)\', date_to DATE NOT NULL COMMENT \'(DC2Type:date_immutable)\', pax_count INT NOT NULL, minors_count INT NOT NULL, children_count INT NOT NULL, adolescents_count INT NOT NULL, board_service_label VARCHAR(255) DEFAULT NULL, board_service_price INT DEFAULT NULL, board_service_original_id INT DEFAULT NULL, is_inquiry TINYINT(1) NOT NULL, salutation VARCHAR(10) DEFAULT NULL, first_name VARCHAR(100) NOT NULL, last_name VARCHAR(100) NOT NULL, email VARCHAR(255) NOT NULL, phone VARCHAR(50) DEFAULT NULL, street VARCHAR(255) DEFAULT NULL, zip VARCHAR(20) DEFAULT NULL, city VARCHAR(100) DEFAULT NULL, country VARCHAR(5) DEFAULT NULL, remarks LONGTEXT DEFAULT NULL, created_at DATETIME NOT NULL COMMENT \'(DC2Type:datetime_immutable)\', updated_at DATETIME DEFAULT NULL COMMENT \'(DC2Type:datetime_immutable)\', INDEX IDX_160D770A8F3692CD (accommodation_id), INDEX IDX_160D770AB03A8386 (created_by_id), INDEX IDX_160D770A896DBBDE (updated_by_id), PRIMARY KEY(id)) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_unicode_ci` ENGINE = InnoDB'); + $this->addSql('CREATE TABLE accommodation_inquiry_additional_service (id INT AUTO_INCREMENT NOT NULL, inquiry_id INT NOT NULL, label VARCHAR(255) NOT NULL, price INT NOT NULL, type VARCHAR(20) NOT NULL, original_service_id INT DEFAULT NULL, INDEX IDX_B28A7FD8A7AD6D71 (inquiry_id), PRIMARY KEY(id)) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_unicode_ci` ENGINE = InnoDB'); + $this->addSql('ALTER TABLE accommodation_booking ADD CONSTRAINT FK_160D770A8F3692CD FOREIGN KEY (accommodation_id) REFERENCES accommodation (id)'); + $this->addSql('ALTER TABLE accommodation_booking ADD CONSTRAINT FK_160D770AB03A8386 FOREIGN KEY (created_by_id) REFERENCES user (id) ON DELETE SET NULL'); + $this->addSql('ALTER TABLE accommodation_booking ADD CONSTRAINT FK_160D770A896DBBDE FOREIGN KEY (updated_by_id) REFERENCES user (id) ON DELETE SET NULL'); + $this->addSql('ALTER TABLE accommodation_inquiry_additional_service ADD CONSTRAINT FK_B28A7FD8A7AD6D71 FOREIGN KEY (inquiry_id) REFERENCES accommodation_booking (id)'); + } + + public function down(Schema $schema): void + { + // this down() migration is auto-generated, please modify it to your needs + $this->addSql('ALTER TABLE accommodation_booking DROP FOREIGN KEY FK_160D770A8F3692CD'); + $this->addSql('ALTER TABLE accommodation_booking DROP FOREIGN KEY FK_160D770AB03A8386'); + $this->addSql('ALTER TABLE accommodation_booking DROP FOREIGN KEY FK_160D770A896DBBDE'); + $this->addSql('ALTER TABLE accommodation_inquiry_additional_service DROP FOREIGN KEY FK_B28A7FD8A7AD6D71'); + $this->addSql('DROP TABLE accommodation_booking'); + $this->addSql('DROP TABLE accommodation_inquiry_additional_service'); + } +} diff --git a/migrations/Version20260707094812.php b/migrations/Version20260707094812.php new file mode 100644 index 00000000..74a900ed --- /dev/null +++ b/migrations/Version20260707094812.php @@ -0,0 +1,31 @@ +addSql('ALTER TABLE accommodation_booking DROP COLUMN children_count'); + $this->addSql('ALTER TABLE accommodation_booking RENAME COLUMN adolescents_count TO children_count'); + } + + public function down(Schema $schema): void + { + $this->addSql('ALTER TABLE accommodation_booking RENAME COLUMN children_count TO adolescents_count'); + $this->addSql('ALTER TABLE accommodation_booking ADD COLUMN children_count INT NOT NULL DEFAULT 0'); + } +} diff --git a/migrations/Version20260707150000.php b/migrations/Version20260707150000.php new file mode 100644 index 00000000..ae72baca --- /dev/null +++ b/migrations/Version20260707150000.php @@ -0,0 +1,27 @@ +addSql("ALTER TABLE accommodation_booking ADD COLUMN group_name VARCHAR(255) NOT NULL DEFAULT ''"); + $this->addSql("ALTER TABLE accommodation_booking ALTER COLUMN group_name DROP DEFAULT"); + } + + public function down(Schema $schema): void + { + $this->addSql('ALTER TABLE accommodation_booking DROP COLUMN group_name'); + } +} diff --git a/migrations/Version20260708071724.php b/migrations/Version20260708071724.php new file mode 100644 index 00000000..b907d14d --- /dev/null +++ b/migrations/Version20260708071724.php @@ -0,0 +1,37 @@ +addSql('ALTER TABLE accommodation_inquiry_additional_service DROP FOREIGN KEY FK_B28A7FD8A7AD6D71'); + $this->addSql('DROP TABLE accommodation_inquiry_additional_service'); + $this->addSql('ALTER TABLE accommodation_booking ADD additional_services JSON NOT NULL'); + $this->addSql('CREATE UNIQUE INDEX UNIQ_160D770AD17F50A6 ON accommodation_booking (uuid)'); + } + + public function down(Schema $schema): void + { + // this down() migration is auto-generated, please modify it to your needs + $this->addSql('CREATE TABLE accommodation_inquiry_additional_service (id INT AUTO_INCREMENT NOT NULL, inquiry_id INT NOT NULL, label VARCHAR(255) CHARACTER SET utf8mb4 NOT NULL COLLATE `utf8mb4_unicode_ci`, price INT NOT NULL, type VARCHAR(20) CHARACTER SET utf8mb4 NOT NULL COLLATE `utf8mb4_unicode_ci`, original_service_id INT DEFAULT NULL, INDEX IDX_B28A7FD8A7AD6D71 (inquiry_id), PRIMARY KEY(id)) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_unicode_ci` ENGINE = InnoDB COMMENT = \'\' '); + $this->addSql('ALTER TABLE accommodation_inquiry_additional_service ADD CONSTRAINT FK_B28A7FD8A7AD6D71 FOREIGN KEY (inquiry_id) REFERENCES accommodation_booking (id) ON UPDATE NO ACTION ON DELETE NO ACTION'); + $this->addSql('DROP INDEX UNIQ_160D770AD17F50A6 ON accommodation_booking'); + $this->addSql('ALTER TABLE accommodation_booking DROP additional_services'); + } +} diff --git a/migrations/Version20260708081559.php b/migrations/Version20260708081559.php new file mode 100644 index 00000000..dca7a804 --- /dev/null +++ b/migrations/Version20260708081559.php @@ -0,0 +1,33 @@ +addSql('ALTER TABLE additional_service ADD description LONGTEXT DEFAULT NULL'); + $this->addSql('ALTER TABLE board_service ADD description LONGTEXT DEFAULT NULL'); + } + + public function down(Schema $schema): void + { + // this down() migration is auto-generated, please modify it to your needs + $this->addSql('ALTER TABLE additional_service DROP description'); + $this->addSql('ALTER TABLE board_service DROP description'); + } +} diff --git a/migrations/Version20260708102807.php b/migrations/Version20260708102807.php new file mode 100644 index 00000000..c0f1fb66 --- /dev/null +++ b/migrations/Version20260708102807.php @@ -0,0 +1,31 @@ +addSql('ALTER TABLE accommodation_booking ADD discount INT DEFAULT NULL'); + } + + public function down(Schema $schema): void + { + // this down() migration is auto-generated, please modify it to your needs + $this->addSql('ALTER TABLE accommodation_booking DROP discount'); + } +} diff --git a/migrations/Version20260709133922.php b/migrations/Version20260709133922.php new file mode 100644 index 00000000..631351c1 --- /dev/null +++ b/migrations/Version20260709133922.php @@ -0,0 +1,31 @@ +addSql('ALTER TABLE additional_service DROP sorting'); + $this->addSql('ALTER TABLE board_service DROP sorting'); + } + + public function down(Schema $schema): void + { + $this->addSql('ALTER TABLE additional_service ADD sorting INT NOT NULL'); + $this->addSql('ALTER TABLE board_service ADD sorting INT NOT NULL'); + } +} diff --git a/migrations/Version20260709141929.php b/migrations/Version20260709141929.php new file mode 100644 index 00000000..21c76bbf --- /dev/null +++ b/migrations/Version20260709141929.php @@ -0,0 +1,34 @@ +addSql(<<<'SQL' + ALTER TABLE + accommodation_booking + ADD + accepted_at DATETIME DEFAULT NULL COMMENT '(DC2Type:datetime_immutable)' + SQL); + } + + public function down(Schema $schema): void + { + $this->addSql('ALTER TABLE accommodation_booking DROP accepted_at'); + } +} diff --git a/migrations/Version20260709162705.php b/migrations/Version20260709162705.php new file mode 100644 index 00000000..5c7828be --- /dev/null +++ b/migrations/Version20260709162705.php @@ -0,0 +1,34 @@ +addSql(<<<'SQL' + ALTER TABLE + accommodation_booking + ADD + access_link_issued_at DATETIME DEFAULT NULL COMMENT '(DC2Type:datetime_immutable)' + SQL); + } + + public function down(Schema $schema): void + { + $this->addSql('ALTER TABLE accommodation_booking DROP access_link_issued_at'); + } +} diff --git a/src/Command/ImportGroupsLegacyDataCommand.php b/src/Command/ImportGroupsLegacyDataCommand.php new file mode 100644 index 00000000..9d9e1098 --- /dev/null +++ b/src/Command/ImportGroupsLegacyDataCommand.php @@ -0,0 +1,582 @@ + maps groupspriceoption "type" column to AdditionalServiceType */ + private const array OPTION_TYPE_MAP = [ + 1 => AdditionalServiceType::Flat, + 2 => AdditionalServiceType::PerPerson, + 3 => AdditionalServiceType::PerNight, + 4 => AdditionalServiceType::PerPersonPerNight, + ]; + + /** @var array maps seasons.csv "token" column to Season, per translations/messages.de.yaml */ + private const array SEASON_TOKEN_MAP = [ + 'HS' => Season::PEAK, + 'NS' => Season::SECONDARY, + 'VNS' => Season::ADV_SECONDARY, + ]; + + public function __construct( + private readonly AccommodationRepository $accommodationRepository, + private readonly EntityManagerInterface $entityManager, + ) { + parent::__construct(); + } + + protected function configure(): void + { + $this + ->addArgument('path', InputArgument::OPTIONAL, 'Directory containing hotels.csv, groupspriceconfig.csv, groupspriceboard.csv, groupspriceoption.csv, seasons.csv', 'temp') + ->addOption('dry-run', null, InputOption::VALUE_NONE, 'Parse and report without persisting anything') + ; + } + + protected function execute(InputInterface $input, OutputInterface $output): int + { + $io = new SymfonyStyle($input, $output); + $dryRun = (bool) $input->getOption('dry-run'); + $path = rtrim((string) $input->getArgument('path'), '/'); + + foreach (['hotels.csv', 'groupspriceconfig.csv', 'groupspriceboard.csv', 'groupspriceoption.csv', 'seasons.csv'] as $file) { + if (!is_readable($path.'/'.$file)) { + $io->error(sprintf('Cannot read %s/%s', $path, $file)); + + return Command::FAILURE; + } + } + + $hotels = $this->readCsv($path.'/hotels.csv'); + $configRows = $this->readCsv($path.'/groupspriceconfig.csv'); + $boardRows = $this->readCsv($path.'/groupspriceboard.csv'); + $optionRows = $this->readCsv($path.'/groupspriceoption.csv'); + $seasonRows = $this->readCsv($path.'/seasons.csv', ','); + + $currencyByHotelUid = $this->deriveCurrencies($configRows); + $seasonRanges = $this->parseSeasonRanges($seasonRows); + + $created = []; + $updated = []; + /** @var array $accommodationByUid */ + $accommodationByUid = []; + + foreach ($hotels as $row) { + $uid = (int) $row['uid']; + $code = trim($row['code']); + $name = trim($row['name']); + $currency = $currencyByHotelUid[$uid] ?? 'EUR'; + + $existing = $this->accommodationRepository->findOneByCmsCode($code) + ?? $this->accommodationRepository->findOneByCalendarCode($code); + + if (null !== $existing) { + $existing->setName($name); + $existing->setCurrency($currency); + + foreach ($existing->getAccommodationPrices()->toArray() as $price) { + $existing->removeAccommodationPrice($price); + if (!$dryRun) { + $this->entityManager->remove($price); + } + } + foreach ($existing->getBoardServices()->toArray() as $board) { + $existing->removeBoardService($board); + if (!$dryRun) { + $this->entityManager->remove($board); + } + } + foreach ($existing->getAdditionalServices()->toArray() as $service) { + $existing->removeAdditionalService($service); + if (!$dryRun) { + $this->entityManager->remove($service); + } + } + + $accommodationByUid[$uid] = $existing; + $updated[] = sprintf('%s (%s) — replacing existing prices/board/services', $name, $code); + + continue; + } + + $existingByCalendarCode = null; + foreach ($accommodationByUid as $a) { + // guard against double-import: a prior run may already have created this one + if ($a->getCalendarCode() === $code) { + $existingByCalendarCode = $a; + break; + } + } + + if (null !== $existingByCalendarCode) { + $accommodationByUid[$uid] = $existingByCalendarCode; + continue; + } + + $accommodation = new Accommodation(); + $accommodation->setCalendarCode($code); + $accommodation->setName($name); + $accommodation->setCurrency($currency); + $accommodation->setMaxAdolescentAge(0); + + if (!$dryRun) { + $this->entityManager->persist($accommodation); + } + + $accommodationByUid[$uid] = $accommodation; + $created[] = sprintf('%s (%s, %s)', $name, $code, $currency); + } + + $knownUids = array_keys($accommodationByUid); + + [$priceCount, $skippedPriceUids, $unmatchedSeasonCount] = $this->importPrices($configRows, $accommodationByUid, $knownUids, $seasonRanges, $dryRun); + [$boardCount, $skippedBoardUids] = $this->importBoardServices($boardRows, $accommodationByUid, $knownUids, $dryRun); + [$optionCount, $skippedOptionUids, $optionsNeedingDefaultRange] = $this->importAdditionalServices($optionRows, $accommodationByUid, $knownUids, $dryRun); + + // BoardService/AdditionalService need a date range; derive it from each + // accommodation's freshly-imported AccommodationPrice rows now that they exist. + // AdditionalServices whose title carried its own year keep the scoped range set above. + $this->applyDateRangeToServices($accommodationByUid, $optionsNeedingDefaultRange); + + if (!$dryRun) { + $this->entityManager->flush(); + } + + $io->section($dryRun ? 'Dry run summary' : 'Import summary'); + $io->writeln(sprintf('Accommodations created: %d', \count($created))); + foreach ($created as $line) { + $io->writeln(' + '.$line); + } + $io->writeln(sprintf('Accommodations updated: %d', \count($updated))); + foreach ($updated as $line) { + $io->writeln(' ~ '.$line); + } + $io->writeln(sprintf('AccommodationPrice rows imported: %d', $priceCount)); + if ($unmatchedSeasonCount > 0) { + $io->writeln(sprintf(' of which %d fell outside all seasons.csv ranges and defaulted to %s', $unmatchedSeasonCount, self::DEFAULT_SEASON->value)); + } + $io->writeln(sprintf('BoardService rows imported: %d', $boardCount)); + $io->writeln(sprintf('AdditionalService rows imported: %d', $optionCount)); + + $skippedUids = array_unique(array_merge($skippedPriceUids, $skippedBoardUids, $skippedOptionUids)); + sort($skippedUids); + if ([] !== $skippedUids) { + $io->warning(sprintf( + 'Skipped rows referencing hotel uid(s) not present in hotels.csv: %s', + implode(', ', $skippedUids), + )); + } + + $io->note('Dropped legacy columns with no entity equivalent (verified all-zero or unused): bonus_card_included, price_additional_person_group_1/2/3[_chf], ignore_with_board.'); + + if ($dryRun) { + $io->note('Dry run — nothing was persisted.'); + } + + $io->success('Done.'); + + return Command::SUCCESS; + } + + /** + * @return list> + */ + private function readCsv(string $file, string $separator = ';'): array + { + $handle = fopen($file, 'r'); + if (false === $handle) { + throw new \RuntimeException(sprintf('Unable to open %s', $file)); + } + + $header = fgetcsv($handle, escape: '\\', separator: $separator, enclosure: '"'); + if (false === $header) { + fclose($handle); + + return []; + } + + $rows = []; + while (false !== ($row = fgetcsv($handle, escape: '\\', separator: $separator, enclosure: '"'))) { + $rows[] = array_combine($header, $row); + } + + fclose($handle); + + return $rows; + } + + /** + * Derives EUR/CHF per hotel uid from groupspriceconfig.csv: if any row for that hotel + * has a non-zero price_chf, the hotel is priced in CHF, otherwise EUR. + * + * @param list> $configRows + * + * @return array + */ + private function deriveCurrencies(array $configRows): array + { + $currencies = []; + foreach ($configRows as $row) { + $uid = (int) $row['hotel']; + if ($this->parseDecimal($row['price_chf']) > 0.0) { + $currencies[$uid] = 'CHF'; + } elseif (!isset($currencies[$uid])) { + $currencies[$uid] = 'EUR'; + } + } + + return $currencies; + } + + private function parseDecimal(string $value): float + { + $value = trim($value); + if ('' === $value) { + return 0.0; + } + + return (float) str_replace(',', '.', $value); + } + + private function toCents(string $value): int + { + return (int) round($this->parseDecimal($value) * 100); + } + + private function priceColumn(string $currency): string + { + return 'CHF' === $currency ? 'price_chf' : 'price'; + } + + /** + * Parses seasons.csv (token,date_from,date_to; date_to exclusive, same convention as + * groupspriceconfig.csv) into Season-tagged ranges, skipping unrecognized tokens. + * + * @param list> $rows + * + * @return list + */ + private function parseSeasonRanges(array $rows): array + { + $ranges = []; + foreach ($rows as $row) { + $season = self::SEASON_TOKEN_MAP[trim($row['token'])] ?? null; + if (null === $season) { + continue; + } + + $ranges[] = [ + 'season' => $season, + 'dateFrom' => new \DateTimeImmutable(trim($row['date_from'])), + 'dateTo' => new \DateTimeImmutable(trim($row['date_to'])), + ]; + } + + return $ranges; + } + + /** + * Resolves the season with the largest night-count overlap with [$dateFrom, $dateToExclusive) + * among $seasonRanges. Falls back to DEFAULT_SEASON (with matched=false) when the price + * period doesn't overlap any season range at all (e.g. a gap in seasons.csv). + * + * @param list $seasonRanges + * + * @return array{0: Season, 1: bool} + */ + private function resolveSeason(\DateTimeImmutable $dateFrom, \DateTimeImmutable $dateToExclusive, array $seasonRanges): array + { + $bestSeason = null; + $bestOverlapDays = 0; + + foreach ($seasonRanges as $range) { + $overlapStart = max($dateFrom, $range['dateFrom']); + $overlapEnd = min($dateToExclusive, $range['dateTo']); + + if ($overlapStart >= $overlapEnd) { + continue; + } + + $overlapDays = $overlapStart->diff($overlapEnd)->days; + if ($overlapDays > $bestOverlapDays) { + $bestOverlapDays = $overlapDays; + $bestSeason = $range['season']; + } + } + + return [$bestSeason ?? self::DEFAULT_SEASON, null !== $bestSeason]; + } + + /** + * @param list> $rows + * @param array $accommodationByUid + * @param list $knownUids + * @param list $seasonRanges + * + * @return array{0: int, 1: list, 2: int} + */ + private function importPrices(array $rows, array $accommodationByUid, array $knownUids, array $seasonRanges, bool $dryRun): array + { + $count = 0; + $skipped = []; + $unmatchedSeasonCount = 0; + + foreach ($rows as $row) { + $uid = (int) $row['hotel']; + if (!\in_array($uid, $knownUids, true)) { + $skipped[] = $uid; + continue; + } + + $accommodation = $accommodationByUid[$uid]; + $column = $this->priceColumn($accommodation->getCurrency()); + $additionalColumn = 'CHF' === $accommodation->getCurrency() + ? 'price_additional_person_chf' + : 'price_additional_person'; + + $rawDateFrom = (new \DateTimeImmutable('@'.$row['date_from']))->setTime(0, 0); + // Source date_to is exclusive; AccommodationPrice::dateTo is the last included night. + $rawDateToExclusive = (new \DateTimeImmutable('@'.$row['date_to']))->setTime(0, 0); + + [$season, $matched] = $this->resolveSeason($rawDateFrom, $rawDateToExclusive, $seasonRanges); + if (!$matched) { + ++$unmatchedSeasonCount; + } + + $price = new AccommodationPrice(); + $price->setDateFrom($rawDateFrom); + $price->setDateTo($rawDateToExclusive->modify('-1 day')); + $price->setSeason($season); + $price->setIncludedPax((int) $row['persons_included']); + $price->setPricePerNight($this->toCents($row[$column])); + $price->setPriceAdditionalPerson($this->toCents($row[$additionalColumn])); + $price->setMinNights(self::DEFAULT_MIN_NIGHTS); + $price->setType(null); + $price->setAcceptUndersubscription(false); + $price->setAcceptShortTerm(false); + + $accommodation->addAccommodationPrice($price); + if (!$dryRun) { + $this->entityManager->persist($price); + } + ++$count; + } + + return [$count, $skipped, $unmatchedSeasonCount]; + } + + /** + * @param list> $rows + * @param array $accommodationByUid + * @param list $knownUids + * + * @return array{0: int, 1: list} + */ + private function importBoardServices(array $rows, array $accommodationByUid, array $knownUids, bool $dryRun): array + { + $count = 0; + $skipped = []; + + foreach ($rows as $row) { + $uid = (int) $row['hotel']; + if (!\in_array($uid, $knownUids, true)) { + $skipped[] = $uid; + continue; + } + + $accommodation = $accommodationByUid[$uid]; + $column = $this->priceColumn($accommodation->getCurrency()); + + $board = new BoardService(); + $board->setLabel(trim($row['title'])); + $board->setPrice($this->toCents($row[$column])); + // dateFrom/dateTo filled in applyDateRangeToServices() once prices are known. + $board->setDateFrom(new \DateTimeImmutable('today')); + $board->setDateTo(new \DateTimeImmutable('today')); + + $accommodation->addBoardService($board); + if (!$dryRun) { + $this->entityManager->persist($board); + } + ++$count; + } + + return [$count, $skipped]; + } + + /** + * @param list> $rows + * @param array $accommodationByUid + * @param list $knownUids + * + * @return array{0: int, 1: list, 2: list} + */ + private function importAdditionalServices(array $rows, array $accommodationByUid, array $knownUids, bool $dryRun): array + { + $count = 0; + $skipped = []; + $needsDefaultRange = []; + + foreach ($rows as $row) { + $uid = (int) $row['hotel']; + if (!\in_array($uid, $knownUids, true)) { + $skipped[] = $uid; + continue; + } + + $accommodation = $accommodationByUid[$uid]; + $column = $this->priceColumn($accommodation->getCurrency()); + $type = self::OPTION_TYPE_MAP[(int) $row['type']] ?? AdditionalServiceType::Flat; + [$year, $label] = $this->stripYearPrefix(trim($row['title'])); + + $service = new AdditionalService(); + $service->setLabel($label); + $service->setPrice($this->toCents($row[$column])); + $service->setType($type); + + if (null !== $year) { + // Title carried its own year (e.g. "2026 Endreinigung ..."); scope validity to + // that accommodation's own price rows within that year, not the calendar year. + [$yearDateFrom, $yearDateTo] = $this->findYearPriceRange($accommodation, $year); + $service->setDateFrom($yearDateFrom ?? new \DateTimeImmutable($year.'-01-01')); + $service->setDateTo($yearDateTo ?? new \DateTimeImmutable($year.'-12-31')); + } else { + // dateFrom/dateTo filled in applyDateRangeToServices() once prices are known. + $service->setDateFrom(new \DateTimeImmutable('today')); + $service->setDateTo(new \DateTimeImmutable('today')); + $needsDefaultRange[] = $service; + } + + $accommodation->addAdditionalService($service); + if (!$dryRun) { + $this->entityManager->persist($service); + } + ++$count; + } + + return [$count, $skipped, $needsDefaultRange]; + } + + /** + * Splits a leading "YYYY " prefix off a title, e.g. "2026 Endreinigung Küche" → + * [2026, "Endreinigung Küche"]. Returns [null, $label unchanged] when no such prefix + * is present. + * + * @return array{0: ?int, 1: string} + */ + private function stripYearPrefix(string $label): array + { + if (preg_match('/^(\d{4})\s+(.+)$/', $label, $matches)) { + return [(int) $matches[1], $matches[2]]; + } + + return [null, $label]; + } + + /** + * Finds the earliest dateFrom / latest dateTo among an accommodation's already-imported + * AccommodationPrice rows that fall within the given calendar year. + * + * @return array{0: ?\DateTimeImmutable, 1: ?\DateTimeImmutable} + */ + private function findYearPriceRange(Accommodation $accommodation, int $year): array + { + $dateFrom = null; + $dateTo = null; + + foreach ($accommodation->getAccommodationPrices() as $price) { + if ((int) $price->getDateFrom()->format('Y') !== $year) { + continue; + } + if (null === $dateFrom || $price->getDateFrom() < $dateFrom) { + $dateFrom = $price->getDateFrom(); + } + if (null === $dateTo || $price->getDateTo() > $dateTo) { + $dateTo = $price->getDateTo(); + } + } + + return [$dateFrom, $dateTo]; + } + + /** + * @param array $accommodationByUid + * @param list $additionalServicesNeedingDefaultRange only these + * (titles without a leading year) get the accommodation- + * wide fallback range; year-prefixed ones already have + * their scoped range set in importAdditionalServices(). + */ + private function applyDateRangeToServices(array $accommodationByUid, array $additionalServicesNeedingDefaultRange): void + { + $needsDefaultRange = new \SplObjectStorage(); + foreach ($additionalServicesNeedingDefaultRange as $service) { + $needsDefaultRange->attach($service); + } + + $seen = []; + foreach ($accommodationByUid as $accommodation) { + $id = spl_object_id($accommodation); + if (isset($seen[$id])) { + continue; + } + $seen[$id] = true; + + $prices = $accommodation->getAccommodationPrices(); + if ($prices->isEmpty()) { + continue; + } + + $dateFrom = null; + $dateTo = null; + foreach ($prices as $price) { + if (null === $dateFrom || $price->getDateFrom() < $dateFrom) { + $dateFrom = $price->getDateFrom(); + } + if (null === $dateTo || $price->getDateTo() > $dateTo) { + $dateTo = $price->getDateTo(); + } + } + + if (null === $dateFrom || null === $dateTo) { + continue; + } + + foreach ($accommodation->getBoardServices() as $board) { + $board->setDateFrom($dateFrom); + $board->setDateTo($dateTo); + } + foreach ($accommodation->getAdditionalServices() as $service) { + if ($needsDefaultRange->contains($service)) { + $service->setDateFrom($dateFrom); + $service->setDateTo($dateTo); + } + } + } + } +} diff --git a/src/Controller/Admin/AccommodationBooking/CreateController.php b/src/Controller/Admin/AccommodationBooking/CreateController.php new file mode 100644 index 00000000..324ca647 --- /dev/null +++ b/src/Controller/Admin/AccommodationBooking/CreateController.php @@ -0,0 +1,57 @@ +createForm(AccommodationBookingType::class, $booking, [ + 'with_accommodation' => true, + ]); + $form->handleRequest($request); + + if ($form->isSubmitted() && $form->isValid()) { + $this->entityManager->persist($booking); + $this->entityManager->flush(); + + $this->bookingService->issueAccessLinkForDirectBooking($booking); + $this->bookingService->sendCustomerConfirmationEmail($booking); + + $this->addFlash('success', 'Die Buchung wurde erstellt'); + + $this->logger->info('Created accommodation booking', [ + 'id' => $booking->getId(), + 'groupName' => $booking->getGroupName(), + ]); + + return $this->redirectToRoute('app_admin_accommodationbooking_edit', ['id' => $booking->getId()]); + } + + return $this->render('admin/accommodation_booking/create.html.twig', [ + 'form' => $form, + ]); + } +} diff --git a/src/Controller/Admin/AccommodationBooking/EditController.php b/src/Controller/Admin/AccommodationBooking/EditController.php new file mode 100644 index 00000000..5c2e53ac --- /dev/null +++ b/src/Controller/Admin/AccommodationBooking/EditController.php @@ -0,0 +1,129 @@ +getAccommodation(); + $boardServices = []; + $additionalServices = []; + $currentBoardService = null; + $currentAdditionalServices = []; + + if (null !== $accommodation && null !== $booking->getDateFrom() && null !== $booking->getDateTo()) { + $boardServices = $this->boardServiceRepo->findByAccommodationAndDateRange( + $accommodation, + $booking->getDateFrom(), + $booking->getDateTo(), + ); + $additionalServices = $this->additionalServiceRepo->findByAccommodationAndDateRange( + $accommodation, + $booking->getDateFrom(), + $booking->getDateTo(), + ); + + // Pre-select current board service if it is still in the available choices + foreach ($boardServices as $bs) { + if ($bs->getId() === $booking->getBoardServiceOriginalId()) { + $currentBoardService = $bs; + break; + } + } + + // Pre-select current additional services that are still in the available choices + $currentIds = array_column($booking->getAdditionalServices(), 'originalServiceId'); + $currentAdditionalServices = array_values(array_filter( + $additionalServices, + fn(AdditionalService $s) => in_array($s->getId(), $currentIds, true), + )); + } + + $form = $this->createForm(AccommodationBookingType::class, $booking, [ + 'max_adolescent_age' => $accommodation?->getMaxAdolescentAge() ?? 0, + 'board_services' => $boardServices, + 'additional_services' => $additionalServices, + 'current_board_service' => $currentBoardService, + 'current_additional_services' => $currentAdditionalServices, + ]); + $wasInquiry = $booking->isInquiry(); + + $form->handleRequest($request); + + if ($form->isSubmitted() && $form->isValid()) { + if ($form->has('boardService')) { + $selectedBoardService = $form->get('boardService')->getData(); + if ($selectedBoardService instanceof BoardService) { + $booking->setBoardServiceLabel($selectedBoardService->getLabel()); + $booking->setBoardServicePrice($selectedBoardService->getPrice()); + $booking->setBoardServiceOriginalId($selectedBoardService->getId()); + } else { + $booking->setBoardServiceLabel(null); + $booking->setBoardServicePrice(null); + $booking->setBoardServiceOriginalId(null); + } + } + + if ($form->has('selectedAdditionalServices')) { + $booking->setAdditionalServices([]); + foreach ($form->get('selectedAdditionalServices')->getData() as $service) { + $booking->addAdditionalServiceSnapshot( + $service->getLabel() ?? '', + $service->getPrice() ?? 0, + $service->getType(), + $service->getId(), + ); + } + } + + $this->entityManager->persist($booking); + $this->entityManager->flush(); + + if ($wasInquiry && !$booking->isInquiry()) { + $this->bookingService->issueAccessLinkForDirectBooking($booking); + $this->bookingService->sendCustomerConfirmationEmail($booking); + } + + $this->addFlash('success', 'Die Buchung wurde aktualisiert'); + + $this->logger->info('Updated accommodation booking', [ + 'id' => $booking->getId(), + 'groupName' => $booking->getGroupName(), + ]); + + return $this->redirectToRoute('app_admin_accommodationbooking_edit', ['id' => $booking->getId()]); + } + + return $this->render('admin/accommodation_booking/edit.html.twig', [ + 'booking' => $booking, + 'form' => $form, + ]); + } +} diff --git a/src/Controller/Admin/AccommodationBooking/GenerateAccessLinkController.php b/src/Controller/Admin/AccommodationBooking/GenerateAccessLinkController.php new file mode 100644 index 00000000..82004b81 --- /dev/null +++ b/src/Controller/Admin/AccommodationBooking/GenerateAccessLinkController.php @@ -0,0 +1,40 @@ +isMethod(Request::METHOD_POST)) { + $this->bookingService->regenerateAccessLinkAndNotifyCustomer($booking); + + $this->addFlash('success', 'Der Zugangslink wurde generiert und dem Kunden per E-Mail zugestellt.'); + + $this->logger->info('Generated accommodation booking access link', [ + 'id' => $booking->getId(), + ]); + + return $this->redirectToRoute('app_admin_accommodationbooking_show', ['id' => $booking->getId()]); + } + + return $this->redirectToRoute('app_admin_accommodationbooking_show', ['id' => $booking->getId()]); + } +} diff --git a/src/Controller/Admin/AccommodationBooking/IndexController.php b/src/Controller/Admin/AccommodationBooking/IndexController.php new file mode 100644 index 00000000..bc7048df --- /dev/null +++ b/src/Controller/Admin/AccommodationBooking/IndexController.php @@ -0,0 +1,50 @@ +bookingRepository + ->createQueryBuilder('booking') + ->leftJoin('booking.accommodation', 'accommodation') + ->addSelect('accommodation') + ->where('booking.dateTo >= :today') + ->setParameter('today', $today) + ; + + $pagination = $this->paginator->paginate( + $qb, + $request->query->getInt('page', 1), + $request->query->getInt('limit', 20), + [ + 'defaultSortFieldName' => 'booking.dateFrom', + 'defaultSortDirection' => 'ASC', + ] + ); + + return $this->render('admin/accommodation_booking/index.html.twig', [ + 'pagination' => $pagination, + ]); + } +} diff --git a/src/Controller/Admin/AccommodationBooking/ShowController.php b/src/Controller/Admin/AccommodationBooking/ShowController.php new file mode 100644 index 00000000..0e63eece --- /dev/null +++ b/src/Controller/Admin/AccommodationBooking/ShowController.php @@ -0,0 +1,39 @@ +getAccessLinkIssuedAt(); + + return $this->render('admin/accommodation_booking/show.html.twig', [ + 'booking' => $booking, + 'priceBreakdown' => $this->breakdownCalculator->compute($booking), + 'returnUrl' => $this->getReturnUrl($request, 'app_admin_accommodationbooking'), + 'accessLink' => $hasAccessLink ? $this->linkSigner->sign($booking) : null, + 'accessLinkExpiresAt' => $hasAccessLink ? $this->linkSigner->expiresAt($booking) : null, + ]); + } +} diff --git a/src/Controller/Admin/AccommodationPrice/CreateController.php b/src/Controller/Admin/AccommodationPrice/CreateController.php index 7ab1fda3..8767b861 100644 --- a/src/Controller/Admin/AccommodationPrice/CreateController.php +++ b/src/Controller/Admin/AccommodationPrice/CreateController.php @@ -47,9 +47,11 @@ class CreateController extends AbstractController ] ]); - return $this->redirectToRoute('app_admin_accommodation_edit', [ + $returnUrl = $this->generateUrl('app_admin_accommodation_edit', [ 'id' => $accommodation->getId(), ]); + + return $this->redirect($returnUrl.'#prices'); } return $this->render('admin/accommodation_price/create.html.twig', [ diff --git a/src/Controller/Admin/AccommodationPrice/DeleteController.php b/src/Controller/Admin/AccommodationPrice/DeleteController.php index 10fb23f0..9bec201f 100644 --- a/src/Controller/Admin/AccommodationPrice/DeleteController.php +++ b/src/Controller/Admin/AccommodationPrice/DeleteController.php @@ -44,7 +44,7 @@ class DeleteController extends AbstractController 'id' => $accommodation->getId(), ]); - return new HxRedirectResponse($returnUrl); + return new HxRedirectResponse($returnUrl.'#prices'); } return $this->render('admin/accommodation_price/modal_delete.html.twig', [ diff --git a/src/Controller/Admin/AccommodationPrice/EditController.php b/src/Controller/Admin/AccommodationPrice/EditController.php index b5d5f8c9..3da1bf11 100644 --- a/src/Controller/Admin/AccommodationPrice/EditController.php +++ b/src/Controller/Admin/AccommodationPrice/EditController.php @@ -53,9 +53,11 @@ class EditController extends AbstractController ] ]); - return $this->redirectToRoute('app_admin_accommodation_edit', [ + $returnUrl = $this->generateUrl('app_admin_accommodation_edit', [ 'id' => $accommodation->getId(), ]); + + return $this->redirect($returnUrl.'#prices'); } return $this->render('admin/accommodation_price/edit.html.twig', [ diff --git a/src/Controller/Admin/AdditionalService/CreateController.php b/src/Controller/Admin/AdditionalService/CreateController.php index 39c668aa..f7a9bfb6 100644 --- a/src/Controller/Admin/AdditionalService/CreateController.php +++ b/src/Controller/Admin/AdditionalService/CreateController.php @@ -4,7 +4,6 @@ declare(strict_types=1); namespace App\Controller\Admin\AdditionalService; -use App\Controller\Traits\SortingControllerTrait; use App\Entity\Groups\Accommodation; use App\Entity\Groups\AdditionalService; use App\Form\Admin\Groups\AdditionalServiceType; @@ -17,8 +16,6 @@ use Symfony\Component\Routing\Attribute\Route; class CreateController extends AbstractController { - use SortingControllerTrait; - public function __construct( private readonly EntityManagerInterface $entityManager, private readonly LoggerInterface $logger, @@ -47,9 +44,11 @@ class CreateController extends AbstractController 'label' => $additionalService->getLabel(), ]); - return $this->redirectToRoute('app_admin_accommodation_edit', [ + $returnUrl = $this->generateUrl('app_admin_accommodation_edit', [ 'id' => $accommodation->getId(), ]); + + return $this->redirect($returnUrl.'#additional-services'); } return $this->render('admin/additional_service/create.html.twig', [ diff --git a/src/Controller/Admin/AdditionalService/DeleteController.php b/src/Controller/Admin/AdditionalService/DeleteController.php index c464f517..9b595291 100644 --- a/src/Controller/Admin/AdditionalService/DeleteController.php +++ b/src/Controller/Admin/AdditionalService/DeleteController.php @@ -4,7 +4,6 @@ declare(strict_types=1); namespace App\Controller\Admin\AdditionalService; -use App\Controller\Traits\SortingControllerTrait; use App\Entity\Groups\AdditionalService; use App\Htmx\HxRedirectResponse; use Doctrine\ORM\EntityManagerInterface; @@ -16,8 +15,6 @@ use Symfony\Component\Routing\Attribute\Route; class DeleteController extends AbstractController { - use SortingControllerTrait; - public function __construct( private readonly EntityManagerInterface $entityManager, private readonly LoggerInterface $logger, @@ -45,7 +42,7 @@ class DeleteController extends AbstractController 'id' => $accommodation->getId(), ]); - return new HxRedirectResponse($returnUrl); + return new HxRedirectResponse($returnUrl.'#additional-services'); } return $this->render('admin/additional_service/modal_delete.html.twig', [ diff --git a/src/Controller/Admin/AdditionalService/EditController.php b/src/Controller/Admin/AdditionalService/EditController.php index a8f498f8..10ed0200 100644 --- a/src/Controller/Admin/AdditionalService/EditController.php +++ b/src/Controller/Admin/AdditionalService/EditController.php @@ -4,7 +4,6 @@ declare(strict_types=1); namespace App\Controller\Admin\AdditionalService; -use App\Controller\Traits\SortingControllerTrait; use App\Entity\Groups\Accommodation; use App\Entity\Groups\AdditionalService; use App\Form\Admin\Groups\AdditionalServiceType; @@ -19,8 +18,6 @@ use Symfony\Component\Routing\Attribute\Route; class EditController extends AbstractController { - use SortingControllerTrait; - public function __construct( private readonly EntityManagerInterface $entityManager, private readonly LoggerInterface $logger, @@ -56,9 +53,11 @@ class EditController extends AbstractController 'label' => $additionalService->getLabel(), ]); - return $this->redirectToRoute('app_admin_accommodation_edit', [ + $returnUrl = $this->generateUrl('app_admin_accommodation_edit', [ 'id' => $accommodation->getId(), ]); + + return $this->redirect($returnUrl.'#additional-services'); } return $this->render('admin/additional_service/edit.html.twig', [ diff --git a/src/Controller/Admin/AdditionalService/SortingController.php b/src/Controller/Admin/AdditionalService/SortingController.php deleted file mode 100644 index 0f7ce3e9..00000000 --- a/src/Controller/Admin/AdditionalService/SortingController.php +++ /dev/null @@ -1,32 +0,0 @@ -applySorting($accommodation->getAdditionalServices(), $request->request->all('sorting')); - - $this->entityManager->flush(); - - return new Response('', Response::HTTP_NO_CONTENT); - } -} diff --git a/src/Controller/Admin/BoardService/CreateController.php b/src/Controller/Admin/BoardService/CreateController.php index 6ae65d27..77bf468c 100644 --- a/src/Controller/Admin/BoardService/CreateController.php +++ b/src/Controller/Admin/BoardService/CreateController.php @@ -4,7 +4,6 @@ declare(strict_types=1); namespace App\Controller\Admin\BoardService; -use App\Controller\Traits\SortingControllerTrait; use App\Entity\Groups\Accommodation; use App\Entity\Groups\BoardService; use App\Form\Admin\Groups\BoardServiceType; @@ -17,7 +16,6 @@ use Symfony\Component\Routing\Attribute\Route; class CreateController extends AbstractController { - use SortingControllerTrait; public function __construct( private readonly EntityManagerInterface $entityManager, private readonly LoggerInterface $logger, @@ -46,9 +44,11 @@ class CreateController extends AbstractController 'label' => $boardService->getLabel(), ]); - return $this->redirectToRoute('app_admin_accommodation_edit', [ + $returnUrl = $this->redirectToRoute('app_admin_accommodation_edit', [ 'id' => $accommodation->getId(), ]); + + return $this->redirect($returnUrl.'#board-services'); } return $this->render('admin/board_service/create.html.twig', [ diff --git a/src/Controller/Admin/BoardService/DeleteController.php b/src/Controller/Admin/BoardService/DeleteController.php index 13270289..73bd4896 100644 --- a/src/Controller/Admin/BoardService/DeleteController.php +++ b/src/Controller/Admin/BoardService/DeleteController.php @@ -4,7 +4,6 @@ declare(strict_types=1); namespace App\Controller\Admin\BoardService; -use App\Controller\Traits\SortingControllerTrait; use App\Entity\Groups\BoardService; use App\Htmx\HxRedirectResponse; use Doctrine\ORM\EntityManagerInterface; @@ -16,7 +15,6 @@ use Symfony\Component\Routing\Attribute\Route; class DeleteController extends AbstractController { - use SortingControllerTrait; public function __construct( private readonly EntityManagerInterface $entityManager, private readonly LoggerInterface $logger, @@ -44,7 +42,7 @@ class DeleteController extends AbstractController 'id' => $accommodation->getId(), ]); - return new HxRedirectResponse($returnUrl); + return new HxRedirectResponse($returnUrl.'#board-services'); } return $this->render('admin/board_service/modal_delete.html.twig', [ diff --git a/src/Controller/Admin/BoardService/EditController.php b/src/Controller/Admin/BoardService/EditController.php index cfdcab06..dbbc99a1 100644 --- a/src/Controller/Admin/BoardService/EditController.php +++ b/src/Controller/Admin/BoardService/EditController.php @@ -4,7 +4,6 @@ declare(strict_types=1); namespace App\Controller\Admin\BoardService; -use App\Controller\Traits\SortingControllerTrait; use App\Entity\Groups\BoardService; use App\Form\Admin\Groups\BoardServiceType; use Doctrine\ORM\EntityManagerInterface; @@ -16,7 +15,6 @@ use Symfony\Component\Routing\Attribute\Route; class EditController extends AbstractController { - use SortingControllerTrait; public function __construct( private readonly EntityManagerInterface $entityManager, private readonly LoggerInterface $logger, @@ -52,9 +50,11 @@ class EditController extends AbstractController 'label' => $boardService->getLabel(), ]); - return $this->redirectToRoute('app_admin_accommodation_edit', [ + $returnUrl = $this->redirectToRoute('app_admin_accommodation_edit', [ 'id' => $accommodation->getId(), ]); + + return $this->redirect($returnUrl.'#board-services'); } return $this->render('admin/board_service/edit.html.twig', [ diff --git a/src/Controller/Admin/BoardService/SortingController.php b/src/Controller/Admin/BoardService/SortingController.php deleted file mode 100644 index ef5fef3e..00000000 --- a/src/Controller/Admin/BoardService/SortingController.php +++ /dev/null @@ -1,32 +0,0 @@ -applySorting($accommodation->getBoardServices(), $request->request->all('sorting')); - - $this->entityManager->flush(); - - return new Response('', Response::HTTP_NO_CONTENT); - } -} diff --git a/src/Controller/Api/AccommodationBookingController.php b/src/Controller/Api/AccommodationBookingController.php new file mode 100644 index 00000000..5a115186 --- /dev/null +++ b/src/Controller/Api/AccommodationBookingController.php @@ -0,0 +1,62 @@ +bookingRepository->findOneBy(['uuid' => $uuid]); + + if (null === $booking) { + return new JsonResponse(['message' => 'Not found'], Response::HTTP_NOT_FOUND); + } + + return $this->bookingResponse($booking); + } + + #[Route(path: '/accommodation-bookings/{uuid}/accept', name: 'api_accommodation_bookings_accept', methods: ['POST'])] + public function accept(string $uuid): JsonResponse + { + $booking = $this->bookingRepository->findOneBy(['uuid' => $uuid]); + + if (null === $booking) { + return new JsonResponse(['message' => 'Not found'], Response::HTTP_NOT_FOUND); + } + + $this->bookingService->acceptBooking($booking); + + return $this->bookingResponse($booking); + } + + private function bookingResponse(AccommodationBooking $booking): JsonResponse + { + $response = new AccommodationBookingApiResponse($booking, $this->breakdownCalculator->compute($booking)); + $json = $this->serializer->serialize($response, 'json', ['groups' => ['api:single']]); + + return new JsonResponse($json, Response::HTTP_OK, [], true); + } +} diff --git a/src/Controller/Api/ContingentController.php b/src/Controller/Api/ContingentController.php index c42e2cd5..70d1e26a 100644 --- a/src/Controller/Api/ContingentController.php +++ b/src/Controller/Api/ContingentController.php @@ -114,7 +114,7 @@ class ContingentController extends AbstractController /** * @param AccommodationPrice[] $prices - * @return array{date: string, status: string, type: string|null, pricePerNight: float|null, defaultPricePerNight: float|null, priceAdditionalPerson: float|null, defaultPriceAdditionalPerson: float|null, includedPax: int|null, minPax: int|null, minNights: int|null} + * @return array{date: string, status: string, type: string|null, pricePerNight: float|null, defaultPricePerNight: float|null, priceAdditionalPerson: float|null, defaultPriceAdditionalPerson: float|null, includedPax: int|null, minNights: int|null} */ private function enrichEntry(string $date, string $status, array $prices, string $currency): array { @@ -144,7 +144,6 @@ class ContingentController extends AbstractController 'defaultPriceAdditionalPerson' => $defaultPrice !== null ? round($defaultPrice->getPriceAdditionalPerson() / 100, 2) : null, 'currency' => $currency, 'includedPax' => $winner?->getIncludedPax(), - 'minPax' => $winner?->getAccommodation()->getMinPaxSummer(), 'minNights' => $winner?->getMinNights(), ]; } diff --git a/src/Controller/Booking/Create/IndexController.php b/src/Controller/Booking/Create/IndexController.php index e34155e3..de7bf819 100644 --- a/src/Controller/Booking/Create/IndexController.php +++ b/src/Controller/Booking/Create/IndexController.php @@ -49,7 +49,7 @@ class IndexController extends AbstractController path: '/bookings/create', name: 'app_booking_create', )] - public function index(Request $request, #[MapQueryString] ?BookingQueryParams $params): Response + public function index(#[MapQueryString] ?BookingQueryParams $params): Response { if (null === $params) { throw $this->createNotFoundException('Invalid booking parameters provided'); diff --git a/src/Controller/Groups/AbstractAccommodationController.php b/src/Controller/Groups/AbstractAccommodationController.php new file mode 100644 index 00000000..bfdc5472 --- /dev/null +++ b/src/Controller/Groups/AbstractAccommodationController.php @@ -0,0 +1,51 @@ + $dto->currentStep) { + $this->addFlash('error', 'Bitte erst die vorherigen Schritte abschließen.'); + + return $this->redirectToCurrentStep($dto); + } + + return null; + } + + protected function createFailureResponse(\Throwable $exception, bool $htmx): Response + { + if ($htmx) { + return new Response('', 400); + } + + $message = $exception instanceof AccommodationSessionNotFoundException + ? 'Deine Sitzung ist abgelaufen. Bitte starte die Anfrage erneut.' + : 'Die Anfrage konnte nicht geladen werden.'; + + $this->addFlash('error', $message); + + return $this->redirectToRoute('app_groups_booking_error'); + } + + private function redirectToCurrentStep(AccommodationBookingDto $dto): RedirectResponse + { + $route = match ($dto->currentStep) { + 2 => 'app_groups_booking_step_2', + 3 => 'app_groups_booking_step_3', + default => 'app_groups_booking_step_1', + }; + + return $this->redirectToRoute($route); + } +} diff --git a/src/Controller/Groups/IndexController.php b/src/Controller/Groups/IndexController.php new file mode 100644 index 00000000..e006b24c --- /dev/null +++ b/src/Controller/Groups/IndexController.php @@ -0,0 +1,77 @@ +addFlash('error', 'Ungültige oder fehlende Parameter. Bitte überprüfe den Link.'); + + return $this->redirectToRoute('app_groups_booking_error'); + } + + try { + $this->sessionManager->clear($request); + $dto = $this->bookingService->initFromParams($params); + $this->sessionManager->save($request, $dto); + } catch (\InvalidArgumentException $e) { + $this->addFlash('error', $e->getMessage()); + + return $this->redirectToRoute('app_groups_booking_error'); + } + + return $this->redirectToRoute('app_groups_booking_step_1'); + } + + #[Route('/groups/booking/success', name: 'app_groups_booking_success')] + public function success(Request $request): Response + { + $session = $request->getSession(); + $resultType = $session instanceof FlashBagAwareSessionInterface + ? $session->getFlashBag()->get('groups_booking_result')[0] ?? null + : null; + + // Flash is consumed after the first read — reload/direct access ends up here with none. + if (null === $resultType) { + return $this->redirectToRoute('app_login'); + } + + return $this->render('groups/booking/success.html.twig', [ + 'resultType' => $resultType, + ]); + } + + #[Route('/groups/booking/error', name: 'app_groups_booking_error')] + public function error(): Response + { + return $this->render('groups/booking/error.html.twig'); + } +} diff --git a/src/Controller/Groups/OfferController.php b/src/Controller/Groups/OfferController.php new file mode 100644 index 00000000..13be392a --- /dev/null +++ b/src/Controller/Groups/OfferController.php @@ -0,0 +1,47 @@ +bookingRepository->findOneBy(['uuid' => $uuid]); + + if (null === $booking || false === $this->linkSigner->isValidLinkRequest($request, $booking)) { + return $this->render('groups/booking/offer_unavailable.html.twig'); + } + + if ($request->isMethod(Request::METHOD_POST) && $booking->isInquiry()) { + $this->bookingService->acceptBooking($booking); + + return new RedirectResponse($request->getUri()); + } + + return $this->render('groups/booking/offer.html.twig', [ + 'booking' => $booking, + 'priceBreakdown' => $this->breakdownCalculator->compute($booking), + ]); + } +} diff --git a/src/Controller/Groups/Step1Controller.php b/src/Controller/Groups/Step1Controller.php new file mode 100644 index 00000000..bb38b22e --- /dev/null +++ b/src/Controller/Groups/Step1Controller.php @@ -0,0 +1,292 @@ +sessionManager->getOrFail($request); + } catch (AccommodationSessionNotFoundException $e) { + return $this->createFailureResponse($e, false); + } + + if ($request->isMethod('POST')) { + $dateFromRaw = $request->request->getString('date_from'); + $dateToRaw = $request->request->getString('date_to'); + + try { + $this->bookingService->applyDates($dto, $dateFromRaw, $dateToRaw); + } catch (\InvalidArgumentException $e) { + $this->addFlash('error', $e->getMessage()); + + return $this->redirectToRoute('app_groups_booking_step_1'); + } + + $dateFrom = $dto->dateFrom; + $dateTo = $dto->dateTo; + if (null !== $dateFrom && null !== $dateTo) { + $accommodation = $this->bookingService->loadAccommodation($dto->accommodationId); + if (null === $accommodation) { + throw $this->createNotFoundException(); + } + $dto->paxCount = $this->bookingService->computeInitialPaxCount($accommodation, $dateFrom, $dateTo); + } + + // Reset service selections — no longer valid for the new date range + $dto->selectedBoardServiceId = null; + $dto->selectedAdditionalServiceIds = []; + + $dto->currentStep = 2; + $this->sessionManager->save($request, $dto); + + return $this->redirectToRoute('app_groups_booking_step_2'); + } + + $accommodation = $this->bookingService->loadAccommodation($dto->accommodationId); + if (null === $accommodation) { + throw $this->createNotFoundException(); + } + + $priceBreakdown = null; + if ($dto->dateFrom !== null && $dto->dateTo !== null) { + $prices = $this->bookingService->loadPrices($dto, $accommodation); + $priceBreakdown = $this->priceCalculator->calculate( + $dto->paxCount, + $dto->minorsCount, + $dto->getNights(), + $dto->dateFrom, + $dto->dateTo, + $prices, + null, + [], + $accommodation->getCurrency() ?? 'EUR', + ); + } + + $ctx = new AccommodationBookingContext( + accommodation: $accommodation, + hotelCmsData: $this->bookingService->loadHotelCmsData($accommodation), + priceBreakdown: $priceBreakdown, + ); + + return $this->render('groups/booking/step_1.html.twig', [ + 'dto' => $dto, + 'ctx' => $ctx, + 'hotelCode' => $accommodation->getCalendarCode() ?? '', + ]); + } + + #[Route('/groups/booking/calendar-refresh', name: 'app_groups_booking_calendar_refresh', methods: ['POST'])] + public function calendarRefresh(Request $request): Response + { + try { + $dto = $this->sessionManager->getOrFail($request); + } catch (AccommodationSessionNotFoundException) { + return new Response('', Response::HTTP_BAD_REQUEST); + } + + $dateFromRaw = $request->request->getString('date_from'); + $dateToRaw = $request->request->getString('date_to'); + + if ('' !== $dateFromRaw && '' !== $dateToRaw) { + try { + $this->bookingService->applyDates($dto, $dateFromRaw, $dateToRaw); + } catch (\InvalidArgumentException) { + // Invalid dates — fall through with original dto state + } + } else { + $dto->dateFrom = null; + $dto->dateTo = null; + } + + $this->sessionManager->save($request, $dto); + + $accommodation = $this->bookingService->loadAccommodation($dto->accommodationId); + if (null === $accommodation) { + return new Response('', Response::HTTP_NOT_FOUND); + } + + $priceBreakdown = null; + if ($dto->dateFrom !== null && $dto->dateTo !== null) { + $prices = $this->bookingService->loadPrices($dto, $accommodation); + $priceBreakdown = $this->priceCalculator->calculate( + $dto->paxCount, + $dto->minorsCount, + $dto->getNights(), + $dto->dateFrom, + $dto->dateTo, + $prices, + null, + [], + $accommodation->getCurrency() ?? 'EUR', + ); + } + + $ctx = new AccommodationBookingContext( + accommodation: $accommodation, + hotelCmsData: $this->bookingService->loadHotelCmsData($accommodation), + priceBreakdown: $priceBreakdown, + ); + + return $this->htmxOobResponse( + 'groups/booking/_summary.html.twig', + ['booking_summary'], + ['dto' => $dto, 'ctx' => $ctx], + ); + } + + #[Route('/groups/booking/calendar-grid', name: 'app_groups_booking_calendar_grid', methods: ['GET'])] + public function calendarGrid(Request $request): Response + { + try { + $dto = $this->sessionManager->getOrFail($request); + } catch (AccommodationSessionNotFoundException $e) { + return new Response('', Response::HTTP_BAD_REQUEST); + } + + $accommodation = $this->bookingService->loadAccommodation($dto->accommodationId); + $hotelCode = $accommodation?->getCalendarCode() ?? ''; + + $now = new \DateTimeImmutable('today'); + $calendarStart = $now->modify('first day of this month'); + $calendarEnd = $calendarStart->modify('+'.(self::CALENDAR_MONTHS - 1).' months')->modify('last day of this month'); + $todayStr = $now->format('Y-m-d'); + + $maxOffset = self::CALENDAR_MONTHS - 2; + if ($request->query->has('offset')) { + $offset = max(0, min($request->query->getInt('offset'), $maxOffset)); + } else { + $offset = 0; + if ($dto->dateFrom !== null) { + $monthsDiff = ((int) $dto->dateFrom->format('Y') - (int) $calendarStart->format('Y')) * 12 + + ((int) $dto->dateFrom->format('n') - (int) $calendarStart->format('n')); + $offset = max(0, min($monthsDiff, $maxOffset)); + } + } + + $displayFrom = $calendarStart->modify("+{$offset} months"); + $months = $this->calendarGridBuilder->buildMonths($displayFrom, 2); + + $enrichedByDate = $this->buildEnrichedDayData( + $hotelCode, + $calendarStart, + $calendarEnd, + $calendarStart->format('Y-m-d'), + $calendarEnd->format('Y-m-d'), + ); + + return $this->render('groups/booking/_price_calendar_grid.html.twig', [ + 'months' => $months, + 'enrichedByDate' => $enrichedByDate, + 'todayStr' => $todayStr, + 'offset' => $offset, + 'totalMonths' => self::CALENDAR_MONTHS, + ]); + } + + /** + * Fetches contingent + price data and returns a map of date → ['status', 'minNights']. + * + * Returns an empty array on API failure; the template treats missing dates as blocked. + * + * @return array + */ + private function buildEnrichedDayData( + string $hotelCode, + \DateTimeImmutable $dateFrom, + \DateTimeImmutable $dateTo, + string $dateFromStr, + string $dateToStr, + ): array { + try { + $cacheKey = sprintf('contingents_calendar_%s_%s_%s', $hotelCode, $dateFromStr, $dateToStr); + $calendar = $this->cache->get( + $cacheKey, + function (ItemInterface $item) use ($hotelCode, $dateFromStr, $dateToStr): mixed { + $item->expiresAfter(3600); + + return $this->contingentsClient->getContingentCalendar($hotelCode, $dateFromStr, $dateToStr); + }, + ); + } catch (BpnConnectException|\Psr\Cache\InvalidArgumentException) { + return []; + } + + $prices = $this->priceRepository->findByHotelCodeAndDateRange($hotelCode, $dateFrom, $dateTo); + + $availableDates = []; + foreach ($calendar->data as $entry) { + if (ContingentStatus::Ok === $entry->status) { + $availableDates[$entry->date] = true; + } + } + + $enriched = []; + foreach ($calendar->data as $entry) { + $isAvailable = ContingentStatus::Ok === $entry->status; + $prevDate = (new \DateTimeImmutable($entry->date))->modify('-1 day')->format('Y-m-d'); + $prevAvailable = isset($availableDates[$prevDate]); + + $status = match (true) { + $isAvailable && $prevAvailable => 'ok', + $isAvailable => 'blocked-to-ok', + $prevAvailable => 'checkout-only', + default => 'blocked', + }; + + $day = (new \DateTimeImmutable($entry->date))->setTime(0, 0); + $candidates = array_values(array_filter( + $prices, + fn(AccommodationPrice $p): bool => $p->getDateFrom() <= $day && $p->getDateTo() >= $day, + )); + $winner = $this->priceTimelineBuilder->resolveWinner($candidates); + + $enriched[$entry->date] = [ + 'status' => $status, + 'minNights' => $winner?->getMinNights() ?? 0, + ]; + } + + return $enriched; + } +} diff --git a/src/Controller/Groups/Step2Controller.php b/src/Controller/Groups/Step2Controller.php new file mode 100644 index 00000000..8cddc404 --- /dev/null +++ b/src/Controller/Groups/Step2Controller.php @@ -0,0 +1,191 @@ +sessionManager->getOrFail($request); + } catch (AccommodationSessionNotFoundException $e) { + return $this->createFailureResponse($e, false); + } + + if ($redirect = $this->validateStepAccess($dto, 2)) { + return $redirect; + } + + [$ctx, $form] = $this->buildStep2Context($request, $dto, true); + + if ($form->isSubmitted() && $form->isValid()) { + $dto->currentStep = 3; + $this->sessionManager->save($request, $dto); + + return $this->redirectToRoute('app_groups_booking_step_3'); + } + + return $this->render('groups/booking/step_2.html.twig', [ + 'dto' => $dto, + 'ctx' => $ctx, + 'form' => $form, + ]); + } + + #[Route('/groups/booking/step-2/refresh', name: 'app_groups_booking_step_2_refresh', methods: ['POST'])] + public function refresh(Request $request): Response + { + try { + $dto = $this->sessionManager->getOrFail($request); + } catch (AccommodationSessionNotFoundException $e) { + return $this->createFailureResponse($e, true); + } + + [$ctx, $form] = $this->buildStep2Context($request, $dto, false); + + return $this->htmxOobResponse( + 'groups/booking/step_2.html.twig', + ['accommodation_form', 'accommodation_summary'], + ['dto' => $dto, 'ctx' => $ctx, 'form' => $form->createView()], + ); + } + + /** + * @return array{AccommodationBookingContext, FormInterface} + */ + private function buildStep2Context(Request $request, AccommodationBookingDto $dto, bool $validate): array + { + $accommodation = $this->bookingService->loadAccommodation($dto->accommodationId); + if (null === $accommodation) { + throw $this->createNotFoundException(); + } + $prices = $this->bookingService->loadPrices($dto, $accommodation); + $services = $this->bookingService->loadAvailableServices($dto, $accommodation); + + $form = $this->createForm(AccommodationStep2Type::class, $dto, [ + 'board_service_choices' => $this->buildServiceChoices($services['boardServices']), + 'additional_service_choices' => $this->buildServiceChoices($services['additionalServices']), + 'max_adolescent_age' => (int) $accommodation->getMaxAdolescentAge(), + 'validation_groups' => $validate ? ['step_2'] : false, + ]); + $form->handleRequest($request); + + $status = $this->bookingService->computeInquiryStatus($dto, $prices); + $dto->isInquiry = $status->isInquiry; + $dto->inquiryReasons = $status->reasons; + + $boardService = $this->resolveSelectedBoardService($dto, $services['boardServices']); + $selectedAdditionalServices = $this->resolveSelectedAdditionalServices($dto, $services['additionalServices']); + + $priceBreakdown = null; + if ($dto->dateFrom !== null && $dto->dateTo !== null) { + $priceBreakdown = $this->priceCalculator->calculate( + $dto->paxCount, + $dto->minorsCount, + $dto->getNights(), + $dto->dateFrom, + $dto->dateTo, + $prices, + $boardService, + $selectedAdditionalServices, + $accommodation->getCurrency() ?? 'EUR', + ); + $dto->totalPrice = $priceBreakdown['total']; + $dto->priceBreakdown = $priceBreakdown; + } + + $this->sessionManager->save($request, $dto); + + $ctx = new AccommodationBookingContext( + accommodation: $accommodation, + hotelCmsData: $this->bookingService->loadHotelCmsData($accommodation), + boardServices: $services['boardServices'], + additionalServices: $services['additionalServices'], + groupedAdditionalServices: $services['groupedAdditionalServices'], + ungroupedAdditionalServices: $services['ungroupedAdditionalServices'], + priceBreakdown: $priceBreakdown, + ); + + return [$ctx, $form]; + } + + /** + * @param BoardService[] $boardServices + */ + private function resolveSelectedBoardService(AccommodationBookingDto $dto, array $boardServices): ?BoardService + { + if (null === $dto->selectedBoardServiceId) { + return null; + } + foreach ($boardServices as $service) { + if ($service->getId() === $dto->selectedBoardServiceId) { + return $service; + } + } + + return null; + } + + /** + * @param AdditionalService[] $additionalServices + * + * @return AdditionalService[] + */ + private function resolveSelectedAdditionalServices(AccommodationBookingDto $dto, array $additionalServices): array + { + if (empty($dto->selectedAdditionalServiceIds)) { + return []; + } + $selectedIds = array_flip($dto->selectedAdditionalServiceIds); + + return array_values(array_filter( + $additionalServices, + fn(AdditionalService $s) => isset($selectedIds[$s->getId()]), + )); + } + + /** + * Builds an ID-keyed choices array for Symfony form binding. + * Labels and price formatting are handled in Twig. + * + * @param BoardService[]|AdditionalService[] $services + * + * @return array + */ + private function buildServiceChoices(array $services): array + { + $choices = []; + foreach ($services as $service) { + $choices[(string) $service->getId()] = $service->getId(); + } + + return $choices; + } +} diff --git a/src/Controller/Groups/Step3Controller.php b/src/Controller/Groups/Step3Controller.php new file mode 100644 index 00000000..fa442136 --- /dev/null +++ b/src/Controller/Groups/Step3Controller.php @@ -0,0 +1,158 @@ +resolveDto($request, false); + if ($dto instanceof Response) { + return $dto; + } + + $accommodation = $this->loadAccommodationOrFail($dto); + $services = $this->bookingService->loadAvailableServices($dto, $accommodation); + + $ctx = new AccommodationBookingContext( + accommodation: $accommodation, + hotelCmsData: $this->bookingService->loadHotelCmsData($accommodation), + boardServices: $services['boardServices'], + additionalServices: $services['additionalServices'], + priceBreakdown: $dto->priceBreakdown ?: null, + ); + + $form = $this->createForm(AccommodationStep3Type::class, $dto); + $form->handleRequest($request); + + if ($form->isSubmitted() && $form->isValid()) { + $submitBookingClicked = false; + // booking or inquiry status depends on clicked button + if (true === $form->has('submitBooking')) { + $submitBookingButton = $form->get('submitBooking'); + $submitBookingClicked = $submitBookingButton instanceof ClickableInterface && true === $submitBookingButton->isClicked(); + } + + // Booking is only offered via the confirmation modal; a forced-inquiry DTO + // can never actually reach this branch since submitBooking is disabled for it. + if (true === $submitBookingClicked && false === $dto->isInquiry) { + $dto->forceInquiry = false; + $this->sessionManager->save($request, $dto); + + $confirmationForm = $this->createForm(AccommodationStep3ConfirmationType::class, $dto); + + return $this->render('groups/booking/step_3.html.twig', [ + 'dto' => $dto, + 'ctx' => $ctx, + 'form' => $form, + 'confirmationForm' => $confirmationForm, + ]); + } + + $dto->forceInquiry = true; + $this->persistBooking($request, $dto, $accommodation, $services); + + return $this->redirectToRoute('app_groups_booking_success'); + } + + return $this->render('groups/booking/step_3.html.twig', [ + 'dto' => $dto, + 'ctx' => $ctx, + 'form' => $form, + ]); + } + + #[Route('/groups/booking/step-3/confirm', name: 'app_groups_booking_step_3_confirm', methods: ['POST'])] + public function confirm(Request $request): Response + { + $dto = $this->resolveDto($request, true); + if ($dto instanceof Response) { + return $dto; + } + + $accommodation = $this->loadAccommodationOrFail($dto); + + $confirmationForm = $this->createForm(AccommodationStep3ConfirmationType::class, $dto); + $confirmationForm->handleRequest($request); + + if ($confirmationForm->isSubmitted() && $confirmationForm->isValid() && false === $dto->isInquiry) { + $services = $this->bookingService->loadAvailableServices($dto, $accommodation); + $this->persistBooking($request, $dto, $accommodation, $services); + + return $this->htmxRedirect($request, $this->generateUrl('app_groups_booking_success')); + } + + return $this->render('groups/booking/_step3_confirmation_modal.html.twig', [ + 'confirmationForm' => $confirmationForm, + ]); + } + + /** + * Loads the session DTO and checks step access, returning an early + * response (session failure or step-access redirect) if either fails. + */ + private function resolveDto(Request $request, bool $htmx): AccommodationBookingDto|Response + { + try { + $dto = $this->sessionManager->getOrFail($request); + } catch (AccommodationSessionNotFoundException $e) { + return $this->createFailureResponse($e, $htmx); + } + + if ($redirect = $this->validateStepAccess($dto, 3)) { + return $redirect; + } + + return $dto; + } + + private function loadAccommodationOrFail(AccommodationBookingDto $dto): Accommodation + { + $accommodation = $this->bookingService->loadAccommodation($dto->accommodationId); + if (null === $accommodation) { + throw $this->createNotFoundException(); + } + + return $accommodation; + } + + /** + * @param array{boardServices: array, additionalServices: array} $services + */ + private function persistBooking(Request $request, AccommodationBookingDto $dto, Accommodation $accommodation, array $services): void + { + $prices = $this->bookingService->loadPrices($dto, $accommodation); + + $booking = $this->bookingService->persist($dto, $accommodation, $prices, $services); + $this->bookingService->issueAccessLinkForDirectBooking($booking); + $this->bookingService->sendNotificationEmail($booking, $dto->priceBreakdown); + $this->bookingService->sendCustomerConfirmationEmail($booking); + $this->addFlash('groups_booking_result', $booking->isInquiry() ? 'inquiry' : 'booking'); + $this->sessionManager->clear($request); + } +} diff --git a/src/Controller/Traits/SortingControllerTrait.php b/src/Controller/Traits/SortingControllerTrait.php deleted file mode 100644 index 69bbbf00..00000000 --- a/src/Controller/Traits/SortingControllerTrait.php +++ /dev/null @@ -1,18 +0,0 @@ -getId()])) { - $entity->setSorting((int) $sortingMap[$entity->getId()]); - } - } - } -} diff --git a/src/Entity/Groups/Accommodation.php b/src/Entity/Groups/Accommodation.php index 4c92ceeb..09b22b6b 100644 --- a/src/Entity/Groups/Accommodation.php +++ b/src/Entity/Groups/Accommodation.php @@ -34,10 +34,6 @@ class Accommodation implements BlameableEntityInterface, TimestampableEntityInte #[ORM\Column(length: 16, nullable: true)] private ?string $cmsCode = null; - #[ORM\Column] - #[Assert\NotNull(message: 'required')] - private ?int $minPaxSummer = null; - /** * @var Collection */ @@ -49,14 +45,14 @@ class Accommodation implements BlameableEntityInterface, TimestampableEntityInte * @var Collection */ #[ORM\OneToMany(targetEntity: BoardService::class, mappedBy: 'accommodation', cascade: ['all'])] - #[ORM\OrderBy(['sorting' => 'ASC'])] + #[ORM\OrderBy(['dateFrom' => 'ASC', 'label' => 'ASC'])] private Collection $boardServices; /** * @var Collection */ #[ORM\OneToMany(targetEntity: AdditionalService::class, mappedBy: 'accommodation', cascade: ['all'])] - #[ORM\OrderBy(['sorting' => 'ASC'])] + #[ORM\OrderBy(['dateFrom' => 'ASC', 'label' => 'ASC'])] private Collection $additionalServices; #[ORM\Column] @@ -121,18 +117,6 @@ class Accommodation implements BlameableEntityInterface, TimestampableEntityInte return $this->cmsCode ?? $this->calendarCode; } - public function getMinPaxSummer(): ?int - { - return $this->minPaxSummer; - } - - public function setMinPaxSummer(int $minPaxSummer): self - { - $this->minPaxSummer = $minPaxSummer; - - return $this; - } - /** * @return Collection */ diff --git a/src/Entity/Groups/AccommodationBooking.php b/src/Entity/Groups/AccommodationBooking.php new file mode 100644 index 00000000..449a1ec2 --- /dev/null +++ b/src/Entity/Groups/AccommodationBooking.php @@ -0,0 +1,429 @@ +uuid = Uuid::v4()->toRfc4122(); + } + + public function getId(): ?int + { + return $this->id; + } + + public function getUuid(): string + { + return $this->uuid; + } + + public function getAccommodation(): ?Accommodation + { + return $this->accommodation; + } + + public function setAccommodation(?Accommodation $accommodation): self + { + $this->accommodation = $accommodation; + + return $this; + } + + public function getDateFrom(): ?\DateTimeImmutable + { + return $this->dateFrom; + } + + public function setDateFrom(\DateTimeImmutable $dateFrom): self + { + $this->dateFrom = $dateFrom; + + return $this; + } + + public function getDateTo(): ?\DateTimeImmutable + { + return $this->dateTo; + } + + public function setDateTo(\DateTimeImmutable $dateTo): self + { + $this->dateTo = $dateTo; + + return $this; + } + + public function getNights(): int + { + if (null === $this->dateFrom || null === $this->dateTo) { + return 0; + } + + return $this->dateFrom->diff($this->dateTo)->days; + } + + public function getPaxCount(): int + { + return $this->paxCount; + } + + public function setPaxCount(int $paxCount): self + { + $this->paxCount = $paxCount; + + return $this; + } + + public function getMinorsCount(): int + { + return $this->minorsCount; + } + + public function setMinorsCount(int $minorsCount): self + { + $this->minorsCount = $minorsCount; + + return $this; + } + + public function getChildrenCount(): int + { + return $this->childrenCount; + } + + public function setChildrenCount(int $childrenCount): self + { + $this->childrenCount = $childrenCount; + + return $this; + } + + public function getBoardServiceLabel(): ?string + { + return $this->boardServiceLabel; + } + + public function setBoardServiceLabel(?string $boardServiceLabel): self + { + $this->boardServiceLabel = $boardServiceLabel; + + return $this; + } + + public function getBoardServicePrice(): ?int + { + return $this->boardServicePrice; + } + + public function setBoardServicePrice(?int $boardServicePrice): self + { + $this->boardServicePrice = $boardServicePrice; + + return $this; + } + + public function getBoardServiceOriginalId(): ?int + { + return $this->boardServiceOriginalId; + } + + public function setBoardServiceOriginalId(?int $boardServiceOriginalId): self + { + $this->boardServiceOriginalId = $boardServiceOriginalId; + + return $this; + } + + public function getAdditionalServices(): array + { + return $this->additionalServices; + } + + public function setAdditionalServices(array $additionalServices): self + { + $this->additionalServices = $additionalServices; + + return $this; + } + + public function addAdditionalServiceSnapshot(string $label, int $price, AdditionalServiceType|string $type, ?int $originalServiceId): self + { + $this->additionalServices[] = [ + 'label' => $label, + 'price' => $price, + 'type' => $type instanceof AdditionalServiceType ? $type->value : $type, + 'originalServiceId' => $originalServiceId, + ]; + + return $this; + } + + public function isInquiry(): bool + { + return $this->isInquiry; + } + + public function setIsInquiry(bool $isInquiry): self + { + $this->isInquiry = $isInquiry; + + return $this; + } + + public function getAccessLinkIssuedAt(): ?\DateTimeImmutable + { + return $this->accessLinkIssuedAt; + } + + public function setAccessLinkIssuedAt(?\DateTimeImmutable $accessLinkIssuedAt): self + { + $this->accessLinkIssuedAt = $accessLinkIssuedAt; + + return $this; + } + + public function getAcceptedAt(): ?\DateTimeImmutable + { + return $this->acceptedAt; + } + + public function setAcceptedAt(?\DateTimeImmutable $acceptedAt): self + { + $this->acceptedAt = $acceptedAt; + + return $this; + } + + public function getGroupName(): ?string + { + return $this->groupName; + } + + public function setGroupName(string $groupName): self + { + $this->groupName = $groupName; + + return $this; + } + + public function getSalutation(): ?string + { + return $this->salutation; + } + + public function setSalutation(?string $salutation): self + { + $this->salutation = $salutation; + + return $this; + } + + public function getFirstName(): ?string + { + return $this->firstName; + } + + public function setFirstName(string $firstName): self + { + $this->firstName = $firstName; + + return $this; + } + + public function getLastName(): ?string + { + return $this->lastName; + } + + public function setLastName(string $lastName): self + { + $this->lastName = $lastName; + + return $this; + } + + public function getEmail(): ?string + { + return $this->email; + } + + public function setEmail(string $email): self + { + $this->email = $email; + + return $this; + } + + public function getPhone(): ?string + { + return $this->phone; + } + + public function setPhone(?string $phone): self + { + $this->phone = $phone; + + return $this; + } + + public function getStreet(): ?string + { + return $this->street; + } + + public function setStreet(?string $street): self + { + $this->street = $street; + + return $this; + } + + public function getZip(): ?string + { + return $this->zip; + } + + public function setZip(?string $zip): self + { + $this->zip = $zip; + + return $this; + } + + public function getCity(): ?string + { + return $this->city; + } + + public function setCity(?string $city): self + { + $this->city = $city; + + return $this; + } + + public function getRemarks(): ?string + { + return $this->remarks; + } + + public function setRemarks(?string $remarks): self + { + $this->remarks = $remarks; + + return $this; + } + + public function getDiscount(): ?int + { + return $this->discount; + } + + public function setDiscount(?int $discount): self + { + $this->discount = $discount; + + return $this; + } +} diff --git a/src/Entity/Groups/AccommodationPrice.php b/src/Entity/Groups/AccommodationPrice.php index 49a042c7..0a6a676e 100644 --- a/src/Entity/Groups/AccommodationPrice.php +++ b/src/Entity/Groups/AccommodationPrice.php @@ -32,7 +32,8 @@ class AccommodationPrice implements BlameableEntityInterface, TimestampableEntit #[Assert\NotNull(message: 'required')] private ?\DateTimeImmutable $dateFrom = null; - // Inclusive: the last night this price applies. A booking 01.01.–10.01. covers 9 nights (01.01.–09.01.); 10.01. is checkout. + // Inclusive: the last night this price applies. + // A booking 01.01.–10.01. covers 9 nights (01.01.–09.01.), 10.01. is checkout. #[ORM\Column(type: Types::DATE_IMMUTABLE)] #[Assert\NotNull(message: 'required')] #[Assert\GreaterThan(propertyPath: 'dateFrom', message: 'Das Bis-Datum muss nach dem Von-Datum liegen')] @@ -63,6 +64,9 @@ class AccommodationPrice implements BlameableEntityInterface, TimestampableEntit #[ORM\Column] private bool $acceptUndersubscription = false; + #[ORM\Column] + private bool $acceptShortTerm = false; + public function __clone() { $this->id = null; @@ -192,4 +196,16 @@ class AccommodationPrice implements BlameableEntityInterface, TimestampableEntit return $this; } + + public function isAcceptShortTerm(): bool + { + return $this->acceptShortTerm; + } + + public function setAcceptShortTerm(bool $acceptShortTerm): self + { + $this->acceptShortTerm = $acceptShortTerm; + + return $this; + } } diff --git a/src/Entity/Groups/AdditionalService.php b/src/Entity/Groups/AdditionalService.php index 35e1922f..097571f2 100644 --- a/src/Entity/Groups/AdditionalService.php +++ b/src/Entity/Groups/AdditionalService.php @@ -10,9 +10,10 @@ use App\Enum\Groups\AdditionalServiceType; use App\Repository\Groups\AdditionalServiceRepository; use Doctrine\DBAL\Types\Types; use Doctrine\ORM\Mapping as ORM; +use Symfony\Component\Validator\Constraints as Assert; #[ORM\Entity(repositoryClass: AdditionalServiceRepository::class)] -class AdditionalService implements BlameableEntityInterface, TimestampableEntityInterface, SortableEntityInterface +class AdditionalService implements BlameableEntityInterface, TimestampableEntityInterface { use BlameableEntity; use TimestampableEntity; @@ -27,26 +28,31 @@ class AdditionalService implements BlameableEntityInterface, TimestampableEntity private ?Accommodation $accommodation = null; #[ORM\Column(length: 255)] + #[Assert\NotBlank(message: 'required')] private ?string $label = null; + #[ORM\Column(type: 'text', nullable: true)] + private ?string $description = null; + #[ORM\Column] + #[Assert\NotNull(message: 'required')] private ?int $price = null; #[ORM\Column(length: 20, enumType: AdditionalServiceType::class)] + #[Assert\NotNull(message: 'required')] private ?AdditionalServiceType $type = null; #[ORM\Column(type: Types::DATE_IMMUTABLE)] + #[Assert\NotNull(message: 'required')] private ?\DateTimeImmutable $dateFrom = null; #[ORM\Column(type: Types::DATE_IMMUTABLE)] + #[Assert\NotNull(message: 'required')] private ?\DateTimeImmutable $dateTo = null; #[ORM\Column(length: 128, nullable: true)] private ?string $selectionGroup = null; - #[ORM\Column] - private ?int $sorting = 0; - public function __clone() { $this->id = null; @@ -81,6 +87,18 @@ class AdditionalService implements BlameableEntityInterface, TimestampableEntity return $this; } + public function getDescription(): ?string + { + return $this->description; + } + + public function setDescription(?string $description): self + { + $this->description = $description; + + return $this; + } + public function getPrice(): ?int { return $this->price; @@ -140,16 +158,4 @@ class AdditionalService implements BlameableEntityInterface, TimestampableEntity return $this; } - - public function getSorting(): ?int - { - return $this->sorting; - } - - public function setSorting(?int $sorting): self - { - $this->sorting = $sorting; - - return $this; - } } diff --git a/src/Entity/Groups/BoardService.php b/src/Entity/Groups/BoardService.php index 9f2ae0b6..bbcc5eb6 100644 --- a/src/Entity/Groups/BoardService.php +++ b/src/Entity/Groups/BoardService.php @@ -9,9 +9,10 @@ use App\Entity\TimestampableEntityInterface; use App\Repository\Groups\BoardServiceRepository; use Doctrine\DBAL\Types\Types; use Doctrine\ORM\Mapping as ORM; +use Symfony\Component\Validator\Constraints as Assert; #[ORM\Entity(repositoryClass: BoardServiceRepository::class)] -class BoardService implements BlameableEntityInterface, TimestampableEntityInterface, SortableEntityInterface +class BoardService implements BlameableEntityInterface, TimestampableEntityInterface { use BlameableEntity; use TimestampableEntity; @@ -26,20 +27,24 @@ class BoardService implements BlameableEntityInterface, TimestampableEntityInter private ?Accommodation $accommodation = null; #[ORM\Column(length: 255)] + #[Assert\NotBlank(message: 'required')] private ?string $label = null; + #[ORM\Column(type: 'text', nullable: true)] + private ?string $description = null; + #[ORM\Column] + #[Assert\NotNull(message: 'required')] private ?int $price = null; #[ORM\Column(type: Types::DATE_IMMUTABLE)] + #[Assert\NotNull(message: 'required')] private ?\DateTimeImmutable $dateFrom = null; #[ORM\Column(type: Types::DATE_IMMUTABLE)] + #[Assert\NotNull(message: 'required')] private ?\DateTimeImmutable $dateTo = null; - #[ORM\Column] - private ?int $sorting = 0; - public function __clone() { $this->id = null; @@ -74,6 +79,18 @@ class BoardService implements BlameableEntityInterface, TimestampableEntityInter return $this; } + public function getDescription(): ?string + { + return $this->description; + } + + public function setDescription(?string $description): self + { + $this->description = $description; + + return $this; + } + public function getPrice(): ?int { return $this->price; @@ -109,16 +126,4 @@ class BoardService implements BlameableEntityInterface, TimestampableEntityInter return $this; } - - public function getSorting(): ?int - { - return $this->sorting; - } - - public function setSorting(?int $sorting): self - { - $this->sorting = $sorting; - - return $this; - } } diff --git a/src/Entity/Groups/SortableEntityInterface.php b/src/Entity/Groups/SortableEntityInterface.php deleted file mode 100644 index 5bf3e26f..00000000 --- a/src/Entity/Groups/SortableEntityInterface.php +++ /dev/null @@ -1,10 +0,0 @@ - + */ +class AccommodationStep2Type extends AbstractType +{ + public function buildForm(FormBuilderInterface $builder, array $options): void + { + $builder + ->add('paxCount', IntegerType::class, [ + 'label' => 'Anzahl Personen', + 'attr' => ['min' => 1], + ]) + ->add('minorsCount', IntegerType::class, [ + 'label' => 'davon Kinder (0–3 Jahre)', + 'required' => false, + 'attr' => ['min' => 0], + ]) + ->add('childrenCount', IntegerType::class, [ + 'label' => sprintf('davon Kinder (4–%d Jahre)', $options['max_adolescent_age']), + 'required' => false, + 'attr' => ['min' => 0], + ]) + ->add('selectedBoardServiceId', ChoiceType::class, [ + 'label' => false, + 'choices' => $options['board_service_choices'], + 'expanded' => true, + 'multiple' => false, + 'required' => false, + 'placeholder' => 'Selbstversorgung', + ]) + ->add('selectedAdditionalServiceIds', ChoiceType::class, [ + 'label' => false, + 'choices' => $options['additional_service_choices'], + 'multiple' => true, + 'expanded' => false, + 'required' => false, + ]) + ; + + $builder->addEventListener(FormEvents::PRE_SUBMIT, function (FormEvent $event): void { + $data = $event->getData(); + + if (!is_array($data)) { + return; + } + + foreach (['minorsCount', 'childrenCount'] as $field) { + if (!isset($data[$field]) || '' === $data[$field]) { + $data[$field] = 0; + } + } + + // Board service rendered manually: empty string from the "Selbstversorgung" radio → null + if (isset($data['selectedBoardServiceId']) && '' === $data['selectedBoardServiceId']) { + unset($data['selectedBoardServiceId']); + } + + $event->setData($data); + }); + } + + public function configureOptions(OptionsResolver $resolver): void + { + $resolver->setDefaults([ + 'data_class' => AccommodationBookingDto::class, + 'validation_groups' => ['step_2'], + 'board_service_choices' => [], + 'additional_service_choices' => [], + 'max_adolescent_age' => 0, + ]); + $resolver->setAllowedTypes('max_adolescent_age', 'int'); + } +} diff --git a/src/Form/AccommodationStep3ConfirmationType.php b/src/Form/AccommodationStep3ConfirmationType.php new file mode 100644 index 00000000..b22035cf --- /dev/null +++ b/src/Form/AccommodationStep3ConfirmationType.php @@ -0,0 +1,44 @@ + + */ +class AccommodationStep3ConfirmationType extends AbstractType +{ + public function buildForm(FormBuilderInterface $builder, array $options): void + { + $termsLink = sprintf( + 'allgemeinen Geschäftsbedingungen (AGB)', + htmlspecialchars($options['terms_url'], ENT_QUOTES, 'UTF-8') + ); + + $builder->add('termsAccepted', CheckboxType::class, [ + 'label' => sprintf('Durch Anklicken des Absenden-Buttons bestätige ich, dass ich die %s gelesen habe und damit einverstanden bin, dass eine kostenpflichtige Buchung zustande kommt.', $termsLink), + 'label_html' => true, + 'mapped' => false, + 'required' => true, + 'constraints' => [ + new IsTrue(message: 'Bitte akzeptiere die AGB, um fortzufahren.'), + ], + ]); + } + + public function configureOptions(OptionsResolver $resolver): void + { + $resolver->setDefaults([ + 'data_class' => AccommodationBookingDto::class, + 'terms_url' => 'https://www.ep-reisen.de/skireisen/infos/allgemeine-infos/agb/', + ]); + } +} diff --git a/src/Form/AccommodationStep3Type.php b/src/Form/AccommodationStep3Type.php new file mode 100644 index 00000000..762b4170 --- /dev/null +++ b/src/Form/AccommodationStep3Type.php @@ -0,0 +1,74 @@ + + */ +class AccommodationStep3Type extends AbstractType +{ + public function buildForm(FormBuilderInterface $builder, array $options): void + { + $builder + ->add('groupName', TextType::class, [ + 'label' => 'Name der Gruppe', + ]) + ->add('salutation', ChoiceType::class, [ + 'label' => 'Anrede', + 'choices' => [ + 'Herr' => 'Herr', + 'Frau' => 'Frau', + 'divers' => 'divers', + ], + 'expanded' => false, + 'multiple' => false, + 'placeholder' => 'Bitte wählen', + ]) + ->add('firstName', TextType::class, [ + 'label' => 'Vorname', + ]) + ->add('lastName', TextType::class, [ + 'label' => 'Nachname', + ]) + ->add('email', EmailType::class, [ + 'label' => 'E-Mail-Adresse', + ]) + ->add('phone', TextType::class, [ + 'label' => 'Telefon', + ]) + ->add('street', TextType::class, [ + 'label' => 'Straße und Hausnummer', + ]) + ->add('zip', TextType::class, [ + 'label' => 'Postleitzahl', + ]) + ->add('city', TextType::class, [ + 'label' => 'Ort', + ]) + ->add('remarks', TextareaType::class, [ + 'label' => false, + 'required' => false, + 'attr' => ['rows' => 4], + ]) + ; + } + + public function configureOptions(OptionsResolver $resolver): void + { + $resolver->setDefaults([ + 'data_class' => AccommodationBookingDto::class, + 'validation_groups' => ['step_3'], + ]); + } +} diff --git a/src/Form/Admin/Groups/AccommodationBookingType.php b/src/Form/Admin/Groups/AccommodationBookingType.php new file mode 100644 index 00000000..244fb897 --- /dev/null +++ b/src/Form/Admin/Groups/AccommodationBookingType.php @@ -0,0 +1,160 @@ +add('accommodation', EntityType::class, [ + 'class' => Accommodation::class, + 'choice_label' => 'name', + 'label' => 'Gruppenhaus', + ]); + } + + $builder + ->add('groupName', TextType::class, [ + 'label' => 'Gruppenname', + ]) + ->add('salutation', ChoiceType::class, [ + 'label' => 'Anrede', + 'choices' => [ + 'Herr' => 'Herr', + 'Frau' => 'Frau', + 'divers' => 'divers', + ], + 'required' => false, + 'placeholder' => '', + ]) + ->add('firstName', TextType::class, [ + 'label' => 'Vorname', + ]) + ->add('lastName', TextType::class, [ + 'label' => 'Nachname', + ]) + ->add('email', EmailType::class, [ + 'label' => 'E-Mail', + ]) + ->add('phone', TextType::class, [ + 'label' => 'Telefon', + 'required' => false, + ]) + ->add('street', TextType::class, [ + 'label' => 'Straße', + 'required' => false, + ]) + ->add('zip', TextType::class, [ + 'label' => 'PLZ', + 'required' => false, + ]) + ->add('city', TextType::class, [ + 'label' => 'Ort', + 'required' => false, + ]) + ->add('dateFrom', DateType::class, [ + 'label' => 'Anreise', + 'widget' => 'single_text', + ]) + ->add('dateTo', DateType::class, [ + 'label' => 'Abreise', + 'widget' => 'single_text', + ]) + ->add('paxCount', IntegerType::class, [ + 'label' => 'Anzahl Personen', + ]) + ->add('minorsCount', IntegerType::class, [ + 'label' => 'davon Kinder (0–3 Jahre)', + ]) + ->add('childrenCount', IntegerType::class, [ + 'label' => $options['max_adolescent_age'] > 0 + ? sprintf('davon Kinder (4–%d Jahre)', $options['max_adolescent_age']) + : 'davon Kinder', + ]) + ; + + if (!empty($options['board_services'])) { + $builder->add('boardService', EntityType::class, [ + 'class' => BoardService::class, + 'mapped' => false, + 'required' => false, + 'choices' => $options['board_services'], + 'choice_label' => 'label', + 'placeholder' => 'keine', + 'label' => 'Verpflegung', + 'data' => $options['current_board_service'], + ]); + } + + if (!empty($options['additional_services'])) { + $builder->add('selectedAdditionalServices', EntityType::class, [ + 'class' => AdditionalService::class, + 'mapped' => false, + 'required' => false, + 'multiple' => true, + 'expanded' => true, + 'choices' => $options['additional_services'], + 'choice_label' => 'label', + 'label' => 'Zusatzleistungen', + 'data' => $options['current_additional_services'], + ]); + } + + $builder + ->add('isInquiry', CheckboxType::class, [ + 'label' => 'Anfrage (nicht bindend)', + 'required' => false, + ]) + ->add('discount', IntegerType::class, [ + 'label' => 'Rabatt (%)', + 'required' => false, + 'constraints' => [ + new Range(min: 1, max: 100), + ], + ]) + ->add('remarks', TextareaType::class, [ + 'label' => 'Bemerkungen', + 'required' => false, + ]) + ; + } + + public function configureOptions(OptionsResolver $resolver): void + { + $resolver->setDefaults([ + 'data_class' => AccommodationBooking::class, + 'with_accommodation' => false, + 'max_adolescent_age' => 0, + 'board_services' => [], + 'additional_services' => [], + 'current_board_service' => null, + 'current_additional_services' => [], + ]); + $resolver->setAllowedTypes('with_accommodation', 'bool'); + $resolver->setAllowedTypes('max_adolescent_age', 'int'); + $resolver->setAllowedTypes('board_services', 'array'); + $resolver->setAllowedTypes('additional_services', 'array'); + $resolver->setAllowedTypes('current_board_service', ['null', BoardService::class]); + $resolver->setAllowedTypes('current_additional_services', 'array'); + } +} diff --git a/src/Form/Admin/Groups/AccommodationPriceType.php b/src/Form/Admin/Groups/AccommodationPriceType.php index 4390b79e..78ac8710 100644 --- a/src/Form/Admin/Groups/AccommodationPriceType.php +++ b/src/Form/Admin/Groups/AccommodationPriceType.php @@ -7,9 +7,9 @@ namespace App\Form\Admin\Groups; use App\Entity\Groups\AccommodationPrice; use App\Enum\Groups\PriceType; use App\Enum\Groups\Season; -use App\Form\DatepickerType; use Symfony\Component\Form\AbstractType; use Symfony\Component\Form\Extension\Core\Type\CheckboxType; +use Symfony\Component\Form\Extension\Core\Type\DateType; use Symfony\Component\Form\Extension\Core\Type\EnumType; use Symfony\Component\Form\Extension\Core\Type\IntegerType; use Symfony\Component\Form\Extension\Core\Type\MoneyType; @@ -21,12 +21,12 @@ class AccommodationPriceType extends AbstractType public function buildForm(FormBuilderInterface $builder, array $options): void { $builder - ->add('dateFrom', DatepickerType::class, [ + ->add('dateFrom', DateType::class, [ 'label' => 'Datum von', 'html5' => true, 'widget' => 'single_text', ]) - ->add('dateTo', DatepickerType::class, [ + ->add('dateTo', DateType::class, [ 'label' => 'Datum bis', 'html5' => true, 'widget' => 'single_text', @@ -63,6 +63,10 @@ class AccommodationPriceType extends AbstractType 'label' => 'Unterbelegung akzeptieren', 'required' => false, ]) + ->add('acceptShortTerm', CheckboxType::class, [ + 'label' => 'Kurzzeit-Belegung akzeptieren', + 'required' => false, + ]) ; } diff --git a/src/Form/Admin/Groups/AccommodationType.php b/src/Form/Admin/Groups/AccommodationType.php index 681e44fd..01d97a46 100644 --- a/src/Form/Admin/Groups/AccommodationType.php +++ b/src/Form/Admin/Groups/AccommodationType.php @@ -27,11 +27,8 @@ class AccommodationType extends AbstractType 'label' => 'Code für CMS-Daten', 'required' => false, ]) - ->add('minPaxSummer', IntegerType::class, [ - 'label' => 'Mindestbelegung Sommer', - ]) ->add('maxAdolescentAge', IntegerType::class, [ - 'label' => 'Altersgrenze Jugendliche', + 'label' => 'Altersgrenze Kinder', ]) ->add('currency', ChoiceType::class, [ 'label' => 'Währung', diff --git a/src/Form/Admin/Groups/AdditionalServiceType.php b/src/Form/Admin/Groups/AdditionalServiceType.php index 241ec90b..185cb004 100644 --- a/src/Form/Admin/Groups/AdditionalServiceType.php +++ b/src/Form/Admin/Groups/AdditionalServiceType.php @@ -6,10 +6,11 @@ namespace App\Form\Admin\Groups; use App\Entity\Groups\AdditionalService; use App\Enum\Groups\AdditionalServiceType as AdditionalServiceTypeEnum; -use App\Form\DatepickerType; use Symfony\Component\Form\AbstractType; +use Symfony\Component\Form\Extension\Core\Type\DateType; use Symfony\Component\Form\Extension\Core\Type\EnumType; use Symfony\Component\Form\Extension\Core\Type\MoneyType; +use Symfony\Component\Form\Extension\Core\Type\TextareaType; use Symfony\Component\Form\Extension\Core\Type\TextType; use Symfony\Component\Form\FormBuilderInterface; use Symfony\Component\OptionsResolver\OptionsResolver; @@ -22,6 +23,13 @@ class AdditionalServiceType extends AbstractType ->add('label', TextType::class, [ 'label' => 'Bezeichnung', ]) + ->add('description', TextareaType::class, [ + 'label' => 'Beschreibung', + 'required' => false, + 'attr' => [ + 'rows' => 2, + ], + ]) ->add('type', EnumType::class, [ 'label' => 'Preistyp', 'class' => AdditionalServiceTypeEnum::class, @@ -35,13 +43,14 @@ class AdditionalServiceType extends AbstractType ->add('selectionGroup', TextType::class, [ 'label' => 'Auswahlgruppe', 'required' => false, + 'disabled' => true, ]) - ->add('dateFrom', DatepickerType::class, [ + ->add('dateFrom', DateType::class, [ 'label' => 'Datum von', 'html5' => true, 'widget' => 'single_text', ]) - ->add('dateTo', DatepickerType::class, [ + ->add('dateTo', DateType::class, [ 'label' => 'Datum bis', 'html5' => true, 'widget' => 'single_text', diff --git a/src/Form/Admin/Groups/BoardServiceType.php b/src/Form/Admin/Groups/BoardServiceType.php index c76f8972..a14ef5bd 100644 --- a/src/Form/Admin/Groups/BoardServiceType.php +++ b/src/Form/Admin/Groups/BoardServiceType.php @@ -5,9 +5,10 @@ declare(strict_types=1); namespace App\Form\Admin\Groups; use App\Entity\Groups\BoardService; -use App\Form\DatepickerType; use Symfony\Component\Form\AbstractType; +use Symfony\Component\Form\Extension\Core\Type\DateType; use Symfony\Component\Form\Extension\Core\Type\MoneyType; +use Symfony\Component\Form\Extension\Core\Type\TextareaType; use Symfony\Component\Form\Extension\Core\Type\TextType; use Symfony\Component\Form\FormBuilderInterface; use Symfony\Component\OptionsResolver\OptionsResolver; @@ -20,17 +21,24 @@ class BoardServiceType extends AbstractType ->add('label', TextType::class, [ 'label' => 'Bezeichnung', ]) + ->add('description', TextareaType::class, [ + 'label' => 'Beschreibung', + 'required' => false, + 'attr' => [ + 'rows' => 2, + ], + ]) ->add('price', MoneyType::class, [ 'label' => 'Preis pro Person/Nacht', 'currency' => $options['currency'], 'divisor' => 100, ]) - ->add('dateFrom', DatepickerType::class, [ + ->add('dateFrom', DateType::class, [ 'label' => 'Datum von', 'html5' => true, 'widget' => 'single_text', ]) - ->add('dateTo', DatepickerType::class, [ + ->add('dateTo', DateType::class, [ 'label' => 'Datum bis', 'html5' => true, 'widget' => 'single_text', diff --git a/src/Form/Model/AccommodationBookingDto.php b/src/Form/Model/AccommodationBookingDto.php new file mode 100644 index 00000000..a8e37baf --- /dev/null +++ b/src/Form/Model/AccommodationBookingDto.php @@ -0,0 +1,84 @@ +*/ + public array $selectedAdditionalServiceIds = []; + + public bool $isInquiry = false; + + /** @var string[] */ + public array $inquiryReasons = []; + + public bool $forceInquiry = false; + + public ?int $totalPrice = null; + + /** @var array */ + public array $priceBreakdown = []; + + #[Assert\NotBlank(message: 'required', groups: ['step_3'])] + public ?string $groupName = null; + + #[Assert\NotBlank(message: 'required', groups: ['step_3'])] + #[Assert\Choice(choices: ['Herr', 'Frau', 'divers'], groups: ['step_3'])] + public ?string $salutation = null; + + #[Assert\NotBlank(message: 'required', groups: ['step_3'])] + public ?string $firstName = null; + + #[Assert\NotBlank(message: 'required', groups: ['step_3'])] + public ?string $lastName = null; + + #[Assert\NotBlank(message: 'required', groups: ['step_3'])] + #[Assert\Email(message: 'invalid', groups: ['step_3'])] + public ?string $email = null; + + #[Assert\NotBlank(message: 'required', groups: ['step_3'])] + public ?string $phone = null; + + #[Assert\NotBlank(message: 'required', groups: ['step_3'])] + public ?string $street = null; + + #[Assert\NotBlank(message: 'required', groups: ['step_3'])] + public ?string $zip = null; + + #[Assert\NotBlank(message: 'required', groups: ['step_3'])] + public ?string $city = null; + + public ?string $remarks = null; + + public function getNights(): int + { + if (null === $this->dateFrom || null === $this->dateTo) { + return 0; + } + + return $this->dateFrom->diff($this->dateTo)->days; + } +} diff --git a/src/Menu/AdminMenuBuilder.php b/src/Menu/AdminMenuBuilder.php index ed526ae9..bf1493f8 100644 --- a/src/Menu/AdminMenuBuilder.php +++ b/src/Menu/AdminMenuBuilder.php @@ -32,13 +32,14 @@ class AdminMenuBuilder extends AbstractMenuBuilder 'routes' => [['pattern' => '/^app_admin_bookingeditdraft/']], ], ]); - $menu->addChild('Benutzer', [ - 'route' => 'app_admin_user', + $menu->addChild('Guppenbuchungen', [ + 'route' => 'app_admin_accommodationbooking', 'linkAttributes' => [ - 'title' => 'Benutzer', + 'title' => 'Buchungen', ], 'extras' => [ - 'icon' => 'users', + 'icon' => 'list', + 'routes' => [['pattern' => '/^app_admin_accommodationbooking\//']], ], ]); $menu->addChild('Gruppenhäuser', [ @@ -48,7 +49,16 @@ class AdminMenuBuilder extends AbstractMenuBuilder ], 'extras' => [ 'icon' => 'house', - 'routes' => [['pattern' => '/^app_admin_accommodation/']], + 'routes' => [['pattern' => '/^app_admin_accommodation\//']], + ], + ]); + $menu->addChild('Benutzer', [ + 'route' => 'app_admin_user', + 'linkAttributes' => [ + 'title' => 'Benutzer', + ], + 'extras' => [ + 'icon' => 'users', ], ]); $menu->addChild('Logs', [ diff --git a/src/Model/AccommodationBookingAdditionalServiceItem.php b/src/Model/AccommodationBookingAdditionalServiceItem.php new file mode 100644 index 00000000..a4d489e6 --- /dev/null +++ b/src/Model/AccommodationBookingAdditionalServiceItem.php @@ -0,0 +1,29 @@ +label = $snapshot['label'] ?? ''; + $this->price = $snapshot['price'] ?? 0; + $this->type = $snapshot['type'] ?? ''; + } +} diff --git a/src/Model/AccommodationBookingApiResponse.php b/src/Model/AccommodationBookingApiResponse.php new file mode 100644 index 00000000..708e32dc --- /dev/null +++ b/src/Model/AccommodationBookingApiResponse.php @@ -0,0 +1,95 @@ + 'Y-m-d'])] + public ?\DateTimeImmutable $dateFrom; + + #[Groups(['api:single'])] + #[Context([DateTimeNormalizer::FORMAT_KEY => 'Y-m-d'])] + public ?\DateTimeImmutable $dateTo; + + #[Groups(['api:single'])] + public int $nights; + + #[Groups(['api:single'])] + public int $paxCount; + + #[Groups(['api:single'])] + public int $minorsCount; + + #[Groups(['api:single'])] + public int $childrenCount; + + #[Groups(['api:single'])] + public ?string $groupName; + + #[Groups(['api:single'])] + public ?\DateTimeImmutable $acceptedAt; + + #[Groups(['api:single'])] + public AccommodationBookingPersonalData $personalData; + + #[Groups(['api:single'])] + public AccommodationBookingHotelInfo $accommodation; + + #[Groups(['api:single'])] + public AccommodationBookingBoardServiceInfo $boardService; + + /** + * @var list + */ + #[Groups(['api:single'])] + public array $additionalServices; + + #[Groups(['api:single'])] + public ?int $discount; + + /** + * @var array|null + */ + #[Groups(['api:single'])] + public ?array $priceBreakdown; + + /** + * @param array|null $priceBreakdown + */ + public function __construct(AccommodationBooking $booking, ?array $priceBreakdown) + { + $this->uuid = $booking->getUuid(); + $this->status = $booking->isInquiry() ? 'inquiry' : 'booking'; + $this->dateFrom = $booking->getDateFrom(); + $this->dateTo = $booking->getDateTo(); + $this->nights = $booking->getNights(); + $this->paxCount = $booking->getPaxCount(); + $this->minorsCount = $booking->getMinorsCount(); + $this->childrenCount = $booking->getChildrenCount(); + $this->groupName = $booking->getGroupName(); + $this->acceptedAt = $booking->getAcceptedAt(); + $this->personalData = new AccommodationBookingPersonalData($booking); + $this->accommodation = new AccommodationBookingHotelInfo($booking->getAccommodation()); + $this->boardService = new AccommodationBookingBoardServiceInfo($booking); + $this->additionalServices = array_map( + static fn (array $service) => new AccommodationBookingAdditionalServiceItem($service), + $booking->getAdditionalServices(), + ); + $this->discount = $booking->getDiscount(); + $this->priceBreakdown = $priceBreakdown; + } +} diff --git a/src/Model/AccommodationBookingBoardServiceInfo.php b/src/Model/AccommodationBookingBoardServiceInfo.php new file mode 100644 index 00000000..f0f063f1 --- /dev/null +++ b/src/Model/AccommodationBookingBoardServiceInfo.php @@ -0,0 +1,23 @@ +label = $booking->getBoardServiceLabel(); + $this->price = $booking->getBoardServicePrice(); + } +} diff --git a/src/Model/AccommodationBookingContext.php b/src/Model/AccommodationBookingContext.php new file mode 100644 index 00000000..63d7cce2 --- /dev/null +++ b/src/Model/AccommodationBookingContext.php @@ -0,0 +1,31 @@ + $groupedAdditionalServices + * @param AdditionalService[] $ungroupedAdditionalServices + * @param array|null $priceBreakdown + * @param array|null $hotelCmsData + */ + public function __construct( + public Accommodation $accommodation, + public ?array $hotelCmsData = null, + public array $boardServices = [], + public array $additionalServices = [], + public array $groupedAdditionalServices = [], + public array $ungroupedAdditionalServices = [], + public ?array $priceBreakdown = null, + ) { + } +} diff --git a/src/Model/AccommodationBookingHotelInfo.php b/src/Model/AccommodationBookingHotelInfo.php new file mode 100644 index 00000000..40be31fe --- /dev/null +++ b/src/Model/AccommodationBookingHotelInfo.php @@ -0,0 +1,23 @@ +calendarCode = $accommodation?->getCalendarCode(); + $this->cmsCode = $accommodation?->getCmsCode(); + } +} diff --git a/src/Model/AccommodationBookingPersonalData.php b/src/Model/AccommodationBookingPersonalData.php new file mode 100644 index 00000000..4c0fda46 --- /dev/null +++ b/src/Model/AccommodationBookingPersonalData.php @@ -0,0 +1,51 @@ +salutation = $booking->getSalutation(); + $this->firstName = $booking->getFirstName(); + $this->lastName = $booking->getLastName(); + $this->email = $booking->getEmail(); + $this->phone = $booking->getPhone(); + $this->street = $booking->getStreet(); + $this->zip = $booking->getZip(); + $this->city = $booking->getCity(); + $this->remarks = $booking->getRemarks(); + } +} diff --git a/src/Model/AccommodationBookingQueryParams.php b/src/Model/AccommodationBookingQueryParams.php new file mode 100644 index 00000000..43f95cec --- /dev/null +++ b/src/Model/AccommodationBookingQueryParams.php @@ -0,0 +1,24 @@ + + */ +class AccommodationBookingRepository extends ServiceEntityRepository +{ + public function __construct(ManagerRegistry $registry) + { + parent::__construct($registry, AccommodationBooking::class); + } +} diff --git a/src/Repository/Groups/AccommodationRepository.php b/src/Repository/Groups/AccommodationRepository.php index 02342e59..533be452 100644 --- a/src/Repository/Groups/AccommodationRepository.php +++ b/src/Repository/Groups/AccommodationRepository.php @@ -40,4 +40,24 @@ class AccommodationRepository extends ServiceEntityRepository // ->getOneOrNullResult() // ; // } + + public function findOneByCmsCode(string $cmsCode): ?Accommodation + { + return $this->createQueryBuilder('a') + ->andWhere('a.cmsCode = :cmsCode') + ->setParameter('cmsCode', $cmsCode) + ->getQuery() + ->getOneOrNullResult() + ; + } + + public function findOneByCalendarCode(string $calendarCode): ?Accommodation + { + return $this->createQueryBuilder('a') + ->andWhere('a.calendarCode = :calendarCode') + ->setParameter('calendarCode', $calendarCode) + ->getQuery() + ->getOneOrNullResult() + ; + } } diff --git a/src/Repository/Groups/AdditionalServiceRepository.php b/src/Repository/Groups/AdditionalServiceRepository.php index 0bc2160b..acad70f2 100644 --- a/src/Repository/Groups/AdditionalServiceRepository.php +++ b/src/Repository/Groups/AdditionalServiceRepository.php @@ -1,5 +1,7 @@ = dateFrom + */ + public function findByAccommodationAndDateRange( + Accommodation $accommodation, + \DateTimeImmutable $dateFrom, + \DateTimeImmutable $dateTo, + ): array { + return $this->createQueryBuilder('s') + ->where('s.accommodation = :accommodation') + ->andWhere('s.dateFrom <= :dateTo') + ->andWhere('s.dateTo >= :dateFrom') + ->setParameter('accommodation', $accommodation) + ->setParameter('dateFrom', $dateFrom) + ->setParameter('dateTo', $dateTo) + ->orderBy('s.dateFrom', 'ASC') + ->addOrderBy('s.label', 'ASC') + ->getQuery() + ->getResult(); + } +} diff --git a/src/Service/AccommodationBookingBreakdownCalculator.php b/src/Service/AccommodationBookingBreakdownCalculator.php new file mode 100644 index 00000000..e4fe8d4f --- /dev/null +++ b/src/Service/AccommodationBookingBreakdownCalculator.php @@ -0,0 +1,52 @@ +|null null when no accommodation or dates are set + */ + public function compute(AccommodationBooking $booking): ?array + { + $accommodation = $booking->getAccommodation(); + $dateFrom = $booking->getDateFrom(); + $dateTo = $booking->getDateTo(); + + if (null === $accommodation || null === $dateFrom || null === $dateTo) { + return null; + } + + $prices = $this->priceRepo->findByHotelCodeAndDateRange( + $accommodation->getCalendarCode() ?? '', + $dateFrom, + $dateTo, + ); + + return $this->priceCalculator->calculateFromSnapshots( + $booking->getPaxCount(), + $booking->getMinorsCount(), + $booking->getNights(), + $dateFrom, + $dateTo, + $prices, + $booking->getBoardServicePrice(), + $booking->getAdditionalServices(), + $accommodation->getCurrency(), + ); + } +} diff --git a/src/Service/AccommodationBookingLinkSigner.php b/src/Service/AccommodationBookingLinkSigner.php new file mode 100644 index 00000000..b9ee6a82 --- /dev/null +++ b/src/Service/AccommodationBookingLinkSigner.php @@ -0,0 +1,72 @@ +uriSigner = new UriSigner($secret); + } + + public function sign(AccommodationBooking $booking): string + { + $issuedAt = $booking->getAccessLinkIssuedAt(); + + if (null === $issuedAt) { + throw new \LogicException('Cannot sign an access link before accessLinkIssuedAt is set.'); + } + + $url = $this->urlGenerator->generate( + 'app_groups_booking_offer', + ['uuid' => $booking->getUuid(), self::TIMESTAMP_PARAM => $issuedAt->getTimestamp()], + UrlGeneratorInterface::ABSOLUTE_URL, + ); + + return $this->uriSigner->sign($url); + } + + public function expiresAt(AccommodationBooking $booking): ?\DateTimeImmutable + { + return $booking->getAccessLinkIssuedAt()?->modify(sprintf('+%d days', self::LINK_TTL_DAYS)); + } + + public function isValidLinkRequest(Request $request, AccommodationBooking $booking): bool + { + $issuedAt = $booking->getAccessLinkIssuedAt(); + + if (null === $issuedAt) { + return false; + } + + if (!$this->uriSigner->checkRequest($request)) { + return false; + } + + $timestampParam = $request->query->get(self::TIMESTAMP_PARAM); + + if (null === $timestampParam || (int) $timestampParam !== $issuedAt->getTimestamp()) { + return false; + } + + $expiresAt = $this->expiresAt($booking); + + return null !== $expiresAt && new \DateTimeImmutable() <= $expiresAt; + } +} diff --git a/src/Service/AccommodationBookingService.php b/src/Service/AccommodationBookingService.php new file mode 100644 index 00000000..41747082 --- /dev/null +++ b/src/Service/AccommodationBookingService.php @@ -0,0 +1,429 @@ +accommodationRepo->findOneBy(['calendarCode' => $params->hotelCode]); + + if (null === $accommodation) { + throw new \InvalidArgumentException(sprintf('Unterkunft mit Code "%s" nicht gefunden.', $params->hotelCode)); + } + + $dto = new AccommodationBookingDto(); + $dto->accommodationId = $accommodation->getId(); + + if (null !== $params->dateFrom && null !== $params->dateTo) { + $this->applyDates($dto, $params->dateFrom, $params->dateTo); + $dateFrom = $dto->dateFrom; + $dateTo = $dto->dateTo; + if (null !== $dateFrom && null !== $dateTo) { + $dto->paxCount = $this->resolveMinPax($params->hotelCode, $dateFrom, $dateTo); + } + } + + return $dto; + } + + /** + * Parses and validates raw date strings, then sets them on the DTO. + * + * @throws \InvalidArgumentException if dates are invalid or dateTo ≤ dateFrom + */ + public function applyDates(AccommodationBookingDto $dto, string $dateFromRaw, string $dateToRaw): void + { + $dateFrom = $this->parseDate($dateFromRaw); + $dateTo = $this->parseDate($dateToRaw); + + if (null === $dateFrom || null === $dateTo) { + throw new \InvalidArgumentException('Ungültiges Datumsformat. Erwartet: YYYY-MM-DD.'); + } + + if ($dateTo <= $dateFrom) { + throw new \InvalidArgumentException('Das Abreisedatum muss nach dem Anreisedatum liegen.'); + } + + $dto->dateFrom = $dateFrom->setTime(0, 0, 0); + $dto->dateTo = $dateTo->setTime(0, 0, 0); + } + + public function computeInitialPaxCount(Accommodation $accommodation, \DateTimeImmutable $dateFrom, \DateTimeImmutable $dateTo): int + { + return $this->resolveMinPax($accommodation->getCalendarCode() ?? '', $dateFrom, $dateTo); + } + + public function loadAccommodation(int $id): ?Accommodation + { + return $this->accommodationRepo->find($id); + } + + /** + * Fetches hotel name, image, and address from the CMS for display in the sidebar. + * + * @return array|null null on CMS error or missing code + */ + public function loadHotelCmsData(Accommodation $accommodation): ?array + { + $code = $accommodation->getEffectiveCmsCode(); + $data = $this->cmsDataProvider->getHotelDetails($code); + + return isset($data['success']) && false === $data['success'] ? null : $data; + } + + /** + * @return AccommodationPrice[] + */ + public function loadPrices(AccommodationBookingDto $dto, Accommodation $accommodation): array + { + return $this->priceRepo->findByHotelCodeAndDateRange( + $accommodation->getCalendarCode() ?? '', + $dto->dateFrom, + $dto->dateTo, + ); + } + + /** + * @return array{ + * boardServices: BoardService[], + * additionalServices: AdditionalService[], + * groupedAdditionalServices: array, + * ungroupedAdditionalServices: AdditionalService[] + * } + */ + public function loadAvailableServices(AccommodationBookingDto $dto, Accommodation $accommodation): array + { + $boardServices = $this->boardServiceRepo->findByAccommodationAndDateRange( + $accommodation, + $dto->dateFrom, + $dto->dateTo, + ); + + $additionalServices = $this->additionalServiceRepo->findByAccommodationAndDateRange( + $accommodation, + $dto->dateFrom, + $dto->dateTo, + ); + + $grouped = []; + $ungrouped = []; + + foreach ($additionalServices as $service) { + if (null !== $service->getSelectionGroup()) { + $grouped[$service->getSelectionGroup()][] = $service; + } else { + $ungrouped[] = $service; + } + } + + return [ + 'boardServices' => $boardServices, + 'additionalServices' => $additionalServices, + 'groupedAdditionalServices' => $grouped, + 'ungroupedAdditionalServices' => $ungrouped, + ]; + } + + /** + * Determines whether the booking must be treated as non-binding and why. + * + * The effective price for the first day of the booking window is used as the authoritative + * source for minNights and includedPax rules. This mirrors how the pricing side works — + * per-day prices are iterated for cost calculation, but the booking-mode decision is + * anchored to the first night's effective price. + * + * PriceTimelineBuilder::resolveWinner() resolves the DISCOUNT > OVERRIDE > base priority. + * + * @param AccommodationPrice[] $prices + */ + public function computeInquiryStatus(AccommodationBookingDto $dto, array $prices): InquiryStatus + { + if (empty($prices)) { + return new InquiryStatus(true, ['Für den gewählten Zeitraum ist kein Preis hinterlegt.']); + } + + $nights = $dto->getNights(); + $firstDay = $dto->dateFrom; + + $candidates = array_values(array_filter( + $prices, + fn(AccommodationPrice $p) => $p->getDateFrom() <= $firstDay && $p->getDateTo() >= $firstDay, + )); + + $winner = $this->priceTimelineBuilder->resolveWinner($candidates); + + if (null === $winner) { + return new InquiryStatus(true, ['Für den Anreisezeitpunkt ist kein Preis hinterlegt.']); + } + + $reasons = []; + + $adultPax = $dto->paxCount - $dto->minorsCount; + if ($adultPax < $winner->getIncludedPax() && !$winner->isAcceptUndersubscription()) { + $reasons[] = sprintf( + 'Mindestpersonenzahl: %d (gebucht: %d)', + $winner->getIncludedPax(), + $adultPax, + ); + } + + if ($nights < $winner->getMinNights() && !$winner->isAcceptShortTerm()) { + $reasons[] = sprintf( + 'Mindestaufenthalt: %d Nächte (gebucht: %d)', + $winner->getMinNights(), + $nights, + ); + } + + return new InquiryStatus(!empty($reasons), $reasons); + } + + /** + * Persists the completed booking to the database. + * + * Board and additional services are stored as frozen snapshots — independent of + * the catalog entities so that future catalog edits do not affect existing bookings. + * + * @param AccommodationPrice[] $prices + * @param array{additionalServices: AdditionalService[], boardServices: mixed[]} $services + */ + public function persist( + AccommodationBookingDto $dto, + Accommodation $accommodation, + array $prices, + array $services, + ): AccommodationBooking { + $booking = new AccommodationBooking(); + $booking->setAccommodation($accommodation); + $booking->setGroupName($dto->groupName); + $booking->setDateFrom($dto->dateFrom); + $booking->setDateTo($dto->dateTo); + $booking->setPaxCount($dto->paxCount); + $booking->setMinorsCount($dto->minorsCount); + $booking->setChildrenCount($dto->childrenCount); + $booking->setIsInquiry($dto->forceInquiry || $this->computeInquiryStatus($dto, $prices)->isInquiry); + + // Freeze board service as scalar fields (no FK) + if (null !== $dto->selectedBoardServiceId) { + foreach ($services['boardServices'] as $boardService) { + if ($boardService->getId() === $dto->selectedBoardServiceId) { + $booking->setBoardServiceLabel($boardService->getLabel()); + $booking->setBoardServicePrice($boardService->getPrice()); + $booking->setBoardServiceOriginalId($boardService->getId()); + break; + } + } + } + + // Freeze additional services as JSON snapshots (no FK to catalog) + $selectedIds = array_flip($dto->selectedAdditionalServiceIds); + foreach ($services['additionalServices'] as $additionalService) { + if (!isset($selectedIds[$additionalService->getId()])) { + continue; + } + + $booking->addAdditionalServiceSnapshot( + $additionalService->getLabel(), + $additionalService->getPrice(), + $additionalService->getType(), + $additionalService->getId(), + ); + } + + // Personal data + $booking->setSalutation($dto->salutation); + $booking->setFirstName($dto->firstName); + $booking->setLastName($dto->lastName); + $booking->setEmail($dto->email); + $booking->setPhone($dto->phone); + $booking->setStreet($dto->street); + $booking->setZip($dto->zip); + $booking->setCity($dto->city); + $booking->setRemarks($dto->remarks); + + $this->entityManager->persist($booking); + $this->entityManager->flush(); + + return $booking; + } + + /** @param array $priceBreakdown */ + public function sendNotificationEmail(AccommodationBooking $booking, array $priceBreakdown = []): void + { + try { + $this->mailer->createAndSendEmail( + [ + 'booking' => $booking, + 'priceBreakdown' => $priceBreakdown, + 'accessLink' => $this->accessLinkOrNull($booking), + ], + [ + 'to' => $this->officeEmail, + 'subject' => sprintf( + 'Neue Unterkunfts%s: %s', + $booking->isInquiry() ? 'anfrage' : 'buchung', + $booking->getAccommodation()?->getName(), + ), + 'template' => 'email/accommodation_booking.html.twig', + 'attachments' => [], + ], + ); + } catch (\Throwable $e) { + $this->logger->error('Failed to send accommodation booking notification email', [ + 'booking_id' => $booking->getId(), + 'error' => $e->getMessage(), + ]); + } + } + + /** + * Sets accessLinkIssuedAt for a direct (non-inquiry) booking if it doesn't have one yet. + * No email side effect — the confirmation email is always sent separately via + * sendCustomerConfirmationEmail(), regardless of whether a link exists. + */ + public function issueAccessLinkForDirectBooking(AccommodationBooking $booking): void + { + if ($booking->isInquiry() || null !== $booking->getAccessLinkIssuedAt()) { + return; + } + + $booking->setAccessLinkIssuedAt(new \DateTimeImmutable()); + $this->entityManager->flush(); + } + + /** + * Always sends a confirmation email to the customer, whether or not an access link + * exists yet — the template renders differently depending on accessLink being present. + */ + public function sendCustomerConfirmationEmail(AccommodationBooking $booking): void + { + try { + $this->mailer->createAndSendEmail( + [ + 'booking' => $booking, + 'priceBreakdown' => $this->breakdownCalculator->compute($booking) ?? [], + 'accessLink' => $this->accessLinkOrNull($booking), + ], + [ + 'to' => $booking->getEmail(), + 'subject' => $booking->isInquiry() + ? 'Deine Anfrage ist bei uns eingegangen' + : 'Deine Buchung ist bestätigt', + 'template' => 'email/accommodation_booking_customer.html.twig', + 'attachments' => [], + ], + ); + } catch (\Throwable $e) { + $this->logger->error('Failed to send accommodation booking customer confirmation email', [ + 'booking_id' => $booking->getId(), + 'error' => $e->getMessage(), + ]); + } + } + + /** + * Explicit admin action: (re)issues the access link — invalidating any previously issued + * link for this booking — and sends the customer confirmation email, now including it. + */ + public function regenerateAccessLinkAndNotifyCustomer(AccommodationBooking $booking): void + { + $booking->setAccessLinkIssuedAt(new \DateTimeImmutable()); + $this->entityManager->flush(); + + $this->sendCustomerConfirmationEmail($booking); + } + + /** + * Transitions a booking from inquiry to booking. Idempotent — a no-op if the booking + * is already accepted. + */ + public function acceptBooking(AccommodationBooking $booking): void + { + if (!$booking->isInquiry()) { + return; + } + + $booking->setIsInquiry(false); + $booking->setAcceptedAt(new \DateTimeImmutable()); + $this->entityManager->flush(); + } + + private function accessLinkOrNull(AccommodationBooking $booking): ?string + { + return null !== $booking->getAccessLinkIssuedAt() ? $this->linkSigner->sign($booking) : null; + } + + private function parseDate(string $value): ?\DateTimeImmutable + { + $date = \DateTimeImmutable::createFromFormat('!Y-m-d', $value); + $errors = \DateTimeImmutable::getLastErrors(); + + if (false === $date) { + return null; + } + + if (false !== $errors && ($errors['warning_count'] > 0 || $errors['error_count'] > 0)) { + return null; + } + + if ($date->format('Y-m-d') !== $value) { + return null; + } + + return $date; + } + + private function resolveMinPax( + string $hotelCode, + \DateTimeImmutable $dateFrom, + \DateTimeImmutable $dateTo, + ): int { + $prices = $this->priceRepo->findByHotelCodeAndDateRange($hotelCode, $dateFrom, $dateTo); + $candidates = array_values(array_filter( + $prices, + fn(AccommodationPrice $price): bool => $price->getDateFrom() <= $dateFrom && $price->getDateTo() >= $dateFrom, + )); + + return $this->priceTimelineBuilder->resolveWinner($candidates)?->getIncludedPax() ?? 1; + } +} diff --git a/src/Service/AccommodationSessionManager.php b/src/Service/AccommodationSessionManager.php new file mode 100644 index 00000000..5cff9193 --- /dev/null +++ b/src/Service/AccommodationSessionManager.php @@ -0,0 +1,53 @@ +getSession(); + + if (!$session->has(self::SESSION_KEY)) { + return null; + } + + $dto = $session->get(self::SESSION_KEY); + + if (!$dto instanceof AccommodationBookingDto) { + return null; + } + + return $dto; + } + + public function getOrFail(Request $request): AccommodationBookingDto + { + $dto = $this->getDto($request); + + if (null === $dto) { + throw new AccommodationSessionNotFoundException(); + } + + return $dto; + } + + public function save(Request $request, AccommodationBookingDto $dto): void + { + $request->getSession()->set(self::SESSION_KEY, $dto); + } + + public function clear(Request $request): void + { + $request->getSession()->remove(self::SESSION_KEY); + } +} diff --git a/src/Service/CalendarGridBuilder.php b/src/Service/CalendarGridBuilder.php new file mode 100644 index 00000000..5d80310f --- /dev/null +++ b/src/Service/CalendarGridBuilder.php @@ -0,0 +1,69 @@ +>}> + */ + public function buildMonths(\DateTimeImmutable $from, int $count = 18): array + { + $months = []; + $base = $from->modify('first day of this month')->setTime(0, 0, 0); + + for ($i = 0; $i < $count; $i++) { + $monthStart = $base->modify("+{$i} months"); + $months[] = [ + 'month' => $monthStart, + 'label' => self::MONTH_NAMES[(int) $monthStart->format('n') - 1].' '.$monthStart->format('Y'), + 'weeks' => $this->buildWeeks($monthStart), + ]; + } + + return $months; + } + + /** + * @return list> + */ + private function buildWeeks(\DateTimeImmutable $monthStart): array + { + $monthEnd = $monthStart->modify('last day of this month'); + $monthKey = $monthStart->format('Y-m'); + + // Monday of the week containing the 1st (ISO week: Mon=1) + $isoDay = (int) $monthStart->format('N'); + $gridStart = $monthStart->modify('-'.($isoDay - 1).' days'); + + // Sunday of the week containing the last day + $isoDay = (int) $monthEnd->format('N'); + $gridEnd = $monthEnd->modify('+'.(7 - $isoDay).' days'); + + $weeks = []; + $current = $gridStart; + + while ($current <= $gridEnd) { + $week = []; + for ($d = 0; $d < 7; $d++) { + $week[] = [ + 'date' => $current, + 'inMonth' => $current->format('Y-m') === $monthKey, + ]; + $current = $current->modify('+1 day'); + } + $weeks[] = $week; + } + + return $weeks; + } +} diff --git a/src/Service/GroupsPriceCalculator.php b/src/Service/GroupsPriceCalculator.php new file mode 100644 index 00000000..21881ea3 --- /dev/null +++ b/src/Service/GroupsPriceCalculator.php @@ -0,0 +1,305 @@ + short-term surcharge percentages (nights → percent) */ + private const array SHORT_TERM_FACTORS = [1 => 30, 2 => 20, 3 => 10]; + + /** @var array */ + private array $config; + + public function __construct( + private readonly PriceTimelineBuilder $priceTimelineBuilder, + array $config, + ) { + $this->config = $this->resolveConfig($config); + } + + /** + * @param array $config + * @return array + */ + private function resolveConfig(array $config): array + { + $resolver = new OptionsResolver(); + $resolver->setRequired(['runningCostsEur', 'runningCostsChf', 'undersubscription30Eur', 'undersubscription30Chf', 'undersubscription40Eur', 'undersubscription40Chf']); + $resolver->setAllowedTypes('runningCostsEur', ['int', 'float']); + $resolver->setAllowedTypes('runningCostsChf', ['int', 'float']); + $resolver->setAllowedTypes('undersubscription30Eur', ['int', 'float']); // per-person/night surcharge when effectivePax < 40 + $resolver->setAllowedTypes('undersubscription30Chf', ['int', 'float']); + $resolver->setAllowedTypes('undersubscription40Eur', ['int', 'float']); // per-person/night surcharge when 40 ≤ effectivePax < 50 + $resolver->setAllowedTypes('undersubscription40Chf', ['int', 'float']); + + return $resolver->resolve($config); + } + + /** + * @param AccommodationPrice[] $prices all prices overlapping the booking window + * @param AdditionalService[] $additionalServices only the selected services + * + * @return array{ + * effectivePax: int, + * totalPax: int, + * includedPax: int, + * nights: int, + * basePrice: int, + * additionalPersonsPrice: int, + * shortTermSurcharge: int, + * undersubscriptionSurcharge: int, + * undersubscriptionThreshold: int|null, + * boardPrice: int, + * serviceDetails: list, + * servicesPrice: int, + * runningCosts: int, + * total: int, + * currency: string, + * } + */ + public function calculate( + int $paxCount, + int $minorsCount, + int $nights, + \DateTimeImmutable $dateFrom, + \DateTimeImmutable $dateTo, + array $prices, + ?BoardService $boardService, + array $additionalServices, + string $currency, + ): array { + return $this->doCalculate( + $paxCount, + $minorsCount, + $nights, + $dateFrom, + $dateTo, + $prices, + $boardService?->getPrice(), + array_map( + fn(AdditionalService $s) => [ + 'label' => $s->getLabel() ?? '', + 'price' => $s->getPrice() ?? 0, + 'type' => $s->getType(), + ], + $additionalServices, + ), + $currency, + ); + } + + /** + * Same calculation but using frozen snapshot data from an AccommodationBooking entity + * instead of live catalog entities. + * + * @param AccommodationPrice[] $prices all prices overlapping the booking window + * @param list $additionalServiceSnapshots + * + * @return array{ + * effectivePax: int, + * totalPax: int, + * includedPax: int, + * nights: int, + * basePrice: int, + * additionalPersonsPrice: int, + * shortTermSurcharge: int, + * undersubscriptionSurcharge: int, + * undersubscriptionThreshold: int|null, + * boardPrice: int, + * serviceDetails: list, + * servicesPrice: int, + * runningCosts: int, + * total: int, + * currency: string, + * } + */ + public function calculateFromSnapshots( + int $paxCount, + int $minorsCount, + int $nights, + \DateTimeImmutable $dateFrom, + \DateTimeImmutable $dateTo, + array $prices, + ?int $boardServicePricePerPersonNight, + array $additionalServiceSnapshots, + string $currency, + ): array { + $items = array_map( + fn(array $s) => [ + 'label' => $s['label'] ?? '', + 'price' => $s['price'] ?? 0, + 'type' => AdditionalServiceType::tryFrom($s['type'] ?? '') ?? AdditionalServiceType::Flat, + ], + $additionalServiceSnapshots, + ); + + return $this->doCalculate( + $paxCount, + $minorsCount, + $nights, + $dateFrom, + $dateTo, + $prices, + $boardServicePricePerPersonNight, + $items, + $currency, + ); + } + + /** + * @param AccommodationPrice[] $prices + * @param list $additionalItems + * + * @return array{ + * effectivePax: int, + * totalPax: int, + * includedPax: int, + * nights: int, + * basePrice: int, + * additionalPersonsPrice: int, + * shortTermSurcharge: int, + * undersubscriptionSurcharge: int, + * undersubscriptionThreshold: int|null, + * boardPrice: int, + * serviceDetails: list, + * servicesPrice: int, + * runningCosts: int, + * total: int, + * currency: string, + * } + */ + private function doCalculate( + int $paxCount, + int $minorsCount, + int $nights, + \DateTimeImmutable $dateFrom, + \DateTimeImmutable $dateTo, + array $prices, + ?int $boardServicePricePerPersonNight, + array $additionalItems, + string $currency, + ): array { + // Derive effectivePax: children 0–3 don't count, but never drop below includedPax + $firstCandidates = array_values(array_filter( + $prices, + fn(AccommodationPrice $p) => $p->getDateFrom() <= $dateFrom && $p->getDateTo() >= $dateFrom, + )); + $firstWinner = $this->priceTimelineBuilder->resolveWinner($firstCandidates); + $includedPaxFloor = $firstWinner?->getIncludedPax() ?? 1; + $effectivePax = max($paxCount - $minorsCount, $includedPaxFloor); + + // Rules 1 & 2: per-night base price + additional persons. + $boundaryMap = [$dateFrom->format('Y-m-d') => $dateFrom, $dateTo->format('Y-m-d') => $dateTo]; + foreach ($prices as $p) { + $from = $p->getDateFrom(); + $toNext = $p->getDateTo()->modify('+1 day'); + if ($from > $dateFrom && $from < $dateTo) { + $boundaryMap[$from->format('Y-m-d')] = $from; + } + if ($toNext > $dateFrom && $toNext < $dateTo) { + $boundaryMap[$toNext->format('Y-m-d')] = $toNext; + } + } + ksort($boundaryMap); + $sortedBoundaries = array_values($boundaryMap); + + $basePrice = 0; + $additionalPersonsPrice = 0; + $lastWinner = null; + + for ($i = 0, $end = count($sortedBoundaries) - 1; $i < $end; ++$i) { + $segStart = $sortedBoundaries[$i]; + $segNights = $segStart->diff($sortedBoundaries[$i + 1])->days; + + $candidates = array_values(array_filter( + $prices, + fn(AccommodationPrice $p) => $p->getDateFrom() <= $segStart && $p->getDateTo() >= $segStart, + )); + $winner = $this->priceTimelineBuilder->resolveWinner($candidates) ?? $lastWinner; + + if ($winner !== null) { + $lastWinner = $winner; + $basePrice += ($winner->getPricePerNight() ?? 0) * $segNights; + $includedPax = $winner->getIncludedPax() ?? 0; + if ($effectivePax > $includedPax) { + $additionalPersonsPrice += ($effectivePax - $includedPax) * ($winner->getPriceAdditionalPerson() ?? 0) * $segNights; + } + } + } + + // Rule 3: short-term surcharge + $shortTermSurcharge = 0; + if (isset(self::SHORT_TERM_FACTORS[$nights])) { + $shortTermSurcharge = (int) round(($basePrice + $additionalPersonsPrice) * self::SHORT_TERM_FACTORS[$nights] / 100); + } + + // Rule 4: undersubscription surcharge (only when board is selected) + $undersubscriptionSurcharge = 0; + $undersubscriptionThreshold = null; + if ($boardServicePricePerPersonNight !== null) { + $surcharge30 = (int) round(('CHF' === $currency ? $this->config['undersubscription30Chf'] : $this->config['undersubscription30Eur']) * 100); + $surcharge40 = (int) round(('CHF' === $currency ? $this->config['undersubscription40Chf'] : $this->config['undersubscription40Eur']) * 100); + + if ($effectivePax < 40) { + $undersubscriptionSurcharge = $effectivePax * $surcharge30 * $nights; + $undersubscriptionThreshold = 40; + } elseif ($effectivePax < 50) { + $undersubscriptionSurcharge = $effectivePax * $surcharge40 * $nights; + $undersubscriptionThreshold = 50; + } + } + + // Rule 5: board price + $boardPrice = 0; + if ($boardServicePricePerPersonNight !== null) { + $boardPrice = $boardServicePricePerPersonNight * $effectivePax * $nights; + } + + // Rule 6: additional services + $serviceDetails = []; + $servicesPrice = 0; + foreach ($additionalItems as $item) { + $price = match ($item['type']) { + AdditionalServiceType::Flat => $item['price'], + AdditionalServiceType::PerPerson => $item['price'] * $effectivePax, + AdditionalServiceType::PerNight => $item['price'] * $nights, + AdditionalServiceType::PerPersonPerNight => $item['price'] * $effectivePax * $nights, + }; + $servicesPrice += $price; + $serviceDetails[] = ['label' => $item['label'], 'price' => $price]; + } + + // Rule 7: running costs — uses full paxCount (all persons including 0–3 year olds) + $runningCostFactor = (int) round(('CHF' === $currency ? $this->config['runningCostsChf'] : $this->config['runningCostsEur']) * 100); + $runningCosts = $paxCount * $nights * $runningCostFactor; + + $total = $basePrice + $additionalPersonsPrice + $shortTermSurcharge + + $undersubscriptionSurcharge + $boardPrice + $servicesPrice + $runningCosts; + + return [ + 'effectivePax' => $effectivePax, + 'totalPax' => $paxCount, + 'includedPax' => $includedPaxFloor, + 'nights' => $nights, + 'basePrice' => $basePrice, + 'additionalPersonsPrice' => $additionalPersonsPrice, + 'shortTermSurcharge' => $shortTermSurcharge, + 'undersubscriptionSurcharge' => $undersubscriptionSurcharge, + 'undersubscriptionThreshold' => $undersubscriptionThreshold, + 'boardPrice' => $boardPrice, + 'serviceDetails' => $serviceDetails, + 'servicesPrice' => $servicesPrice, + 'runningCosts' => $runningCosts, + 'total' => $total, + 'currency' => $currency, + ]; + } +} diff --git a/templates/admin/accommodation/_form.html.twig b/templates/admin/accommodation/_form.html.twig index dc932de0..4c9e8553 100644 --- a/templates/admin/accommodation/_form.html.twig +++ b/templates/admin/accommodation/_form.html.twig @@ -8,7 +8,6 @@ {{ form_row(form.calendarCode) }} {{ form_row(form.cmsCode) }} - {{ form_row(form.minPaxSummer) }} {{ form_row(form.maxAdolescentAge) }} {{ form_row(form.currency) }} @@ -54,118 +53,128 @@
-

- Preise -

-
-
- - - - - - - - - - - - - - - - {% for price in accommodation.accommodationPrices %} - - - - - - - - - - - - {% else %} - - - - {% endfor %} - -
- von - - bis - - Typ - - Saison - - Pax bis - - Mindestmiete pro Nacht - - weitere Personen pro Nacht - - Unterbelegung -
- {{ price.dateFrom | date('d.m.Y') }} - - {{ price.dateTo | date('d.m.Y') }} - - {% if price.type is not null %} - {% if price.type.value == 'discount' %} - {{ icon('discount', 'w-5 h-5') }} - {% elseif price.type.value == 'override' %} - {{ icon('stack', 'w-5 h-5') }} - {% endif %} - {% endif %} - - {{ price.season.token | trans }} - - {{ price.includedPax }} - - {{ (price.pricePerNight / 100) | format_currency(accommodation.currency) }} - - {{ (price.priceAdditionalPerson / 100) | format_currency(accommodation.currency) }} - - {% if price.acceptUndersubscription %} - {{ icon('check', 'w-4 h-4') }} - {% endif %} - - - - bearbeiten - - - duplizieren - - - löschen - - -
- Keine Daten -
-
-
- - -
- -

- Verpflegungsleistungen -

-
+
+

+ Preise +

+
+ + + + + + + + + + + + + {% for price in accommodation.accommodationPrices %} + + + + + + + + + + + + + {% else %} + + + + {% endfor %} + +
+ von + + bis + + Typ + + Saison + + Pax bis + + Mindestmiete pro Nacht + + weitere Personen pro Nacht + + Unterbel. + + Kurzzeit +
+ {{ price.dateFrom | date('d.m.Y') }} + + {{ price.dateTo | date('d.m.Y') }} + + {% if price.type is not null %} + {% if price.type.value == 'discount' %} + {{ icon('discount', 'w-5 h-5') }} + {% elseif price.type.value == 'override' %} + {{ icon('stack', 'w-5 h-5') }} + {% endif %} + {% endif %} + + {{ price.season.token | trans }} + + {{ price.includedPax }} + + {{ (price.pricePerNight / 100) | format_currency(accommodation.currency) }} + + {{ (price.priceAdditionalPerson / 100) | format_currency(accommodation.currency) }} + + {% if price.acceptUndersubscription %} + {{ icon('check', 'w-4 h-4') }} + {% endif %} + + {% if price.acceptShortterm %} + {{ icon('check', 'w-4 h-4') }} + {% endif %} + + + + bearbeiten + + + duplizieren + + + löschen + + +
+ Keine Daten +
+
+
+ +
+ +
+ +
+

+ Verpflegungsleistungen +

+
+
+ + + @@ -178,18 +187,14 @@ - + {% for service in accommodation.boardServices %} - - + {% else %} - @@ -224,79 +229,77 @@ neu +
-

- Optionale Zusatzleistungen -

-
-
-
Datums-Bereich
- {{ icon('draggable', 'w-4 h-4') }} -
{{ service.dateFrom | date('d.m.Y') }} - {{ service.dateTo | date('d.m.Y') }} {{ service.label }} - {{ (service.price / 100) | format_currency(accommodation.currency) }} @@ -210,7 +215,7 @@
+ Keine Daten
- - - - - - - - - - - - {% for service in accommodation.additionalServices %} - - - - - - - - - {% else %} +
+

+ Optionale Zusatzleistungen +

+
+
+
- Datums-Bereich - - Bezeichnung - - Preistyp - - Preis -
- {{ icon('draggable', 'w-4 h-4') }} - - {{ service.dateFrom | date('d.m.Y') }} - {{ service.dateTo | date('d.m.Y') }} - - {{ service.label }} - - - {{ service.type.label | trans }} - - {{ (service.price / 100) | format_currency(accommodation.currency) }} - - - - bearbeiten - - - duplizieren - - - löschen - - -
+ - + + + + + - {% endfor %} - -
- Keine Daten - + Datums-Bereich + + Bezeichnung + + Preistyp + + Preis +
+ + + {% for service in accommodation.additionalServices %} + + + {{ service.dateFrom | date('d.m.Y') }} - {{ service.dateTo | date('d.m.Y') }} + + + {{ service.label }} + + + {{ service.type.label | trans }} + + + {{ (service.price / 100) | format_currency(accommodation.currency) }} + + + + + bearbeiten + + + duplizieren + + + löschen + + + + + {% else %} + + + Keine Daten + + + {% endfor %} + + +
-
- + + diff --git a/templates/admin/accommodation/index.html.twig b/templates/admin/accommodation/index.html.twig index 6a81a810..50475678 100644 --- a/templates/admin/accommodation/index.html.twig +++ b/templates/admin/accommodation/index.html.twig @@ -26,7 +26,9 @@ {% for accommodation in accommodations %} - {{ accommodation.name }} + + {{ accommodation.name }} + {{ accommodation.calendarCode }} diff --git a/templates/admin/accommodation/modal_create.html.twig b/templates/admin/accommodation/modal_create.html.twig index bf038563..b9bbb6bd 100644 --- a/templates/admin/accommodation/modal_create.html.twig +++ b/templates/admin/accommodation/modal_create.html.twig @@ -8,8 +8,7 @@ {{ form_start(form) }}
{{ form_row(form.name) }} - {{ form_row(form.code) }} - {{ form_row(form.minPaxSummer) }} + {{ form_row(form.calendarCode) }} {{ form_row(form.maxAdolescentAge) }} {{ form_row(form.currency) }}
diff --git a/templates/admin/accommodation_booking/create.html.twig b/templates/admin/accommodation_booking/create.html.twig new file mode 100644 index 00000000..b8ff94c5 --- /dev/null +++ b/templates/admin/accommodation_booking/create.html.twig @@ -0,0 +1,51 @@ +{% extends 'layout_admin.html.twig' %} + +{% block content %} + Neue Buchung + + {% form_theme form 'forms_admin.html.twig' %} + + {{ form_start(form) }} +
+
+ {{ form_row(form.accommodation) }} +
+ +

Kontaktdaten

+ {{ form_row(form.groupName) }} + {{ form_row(form.salutation) }} + {{ form_row(form.firstName) }} + {{ form_row(form.lastName) }} + {{ form_row(form.email) }} + {{ form_row(form.phone) }} + {{ form_row(form.street) }} + {{ form_row(form.zip) }} + {{ form_row(form.city) }} + +

Reisedaten

+ {{ form_row(form.dateFrom) }} + {{ form_row(form.dateTo) }} + {{ form_row(form.paxCount) }} + {{ form_row(form.minorsCount) }} + {{ form_row(form.childrenCount) }} + +

Status & Rabatt

+
+ {{ form_row(form.isInquiry) }} +
+ {{ form_row(form.discount) }} +
+ {{ form_row(form.remarks) }} +
+
+
+ + zurück + + +
+ {{ form_rest(form) }} + {{ form_end(form) }} +{% endblock %} diff --git a/templates/admin/accommodation_booking/edit.html.twig b/templates/admin/accommodation_booking/edit.html.twig new file mode 100644 index 00000000..c7b3d0d3 --- /dev/null +++ b/templates/admin/accommodation_booking/edit.html.twig @@ -0,0 +1,60 @@ +{% extends 'layout_admin.html.twig' %} + +{% block content %} + Buchung {{ booking.groupName }} +

{{ booking.accommodation.name }}

+ + {% form_theme form 'forms_admin.html.twig' %} + + {{ form_start(form) }} +
+

Kontaktdaten

+ {{ form_row(form.groupName) }} + {{ form_row(form.salutation) }} + {{ form_row(form.firstName) }} + {{ form_row(form.lastName) }} + {{ form_row(form.email) }} + {{ form_row(form.phone) }} + {{ form_row(form.street) }} + {{ form_row(form.zip) }} + {{ form_row(form.city) }} + +

Reisedaten

+ {{ form_row(form.dateFrom) }} + {{ form_row(form.dateTo) }} + {{ form_row(form.paxCount) }} + {{ form_row(form.minorsCount) }} + {{ form_row(form.childrenCount) }} + + {% if form.boardService is defined or form.selectedAdditionalServices is defined %} +

Leistungen

+ {% if form.boardService is defined %} + {{ form_row(form.boardService) }} + {% endif %} + {% if form.selectedAdditionalServices is defined %} +
+ {{ form_row(form.selectedAdditionalServices) }} +
+ {% endif %} + {% endif %} + +

Status & Rabatt

+
+ {{ form_row(form.isInquiry) }} +
+ {{ form_row(form.discount) }} +
+ {{ form_row(form.remarks) }} +
+
+
+ + zurück + + +
+ {{ form_rest(form) }} + {{ form_end(form) }} +{% endblock %} diff --git a/templates/admin/accommodation_booking/index.html.twig b/templates/admin/accommodation_booking/index.html.twig new file mode 100644 index 00000000..3f2cf234 --- /dev/null +++ b/templates/admin/accommodation_booking/index.html.twig @@ -0,0 +1,94 @@ +{% extends 'layout_admin.html.twig' %} + +{% block content %} + Buchungen & Anfragen +
+
+ + + + + + + + + + + + + + + {% for booking in pagination %} + + + + + + + + + + + {% else %} + + + + {% endfor %} + +
+ {{ knp_pagination_sortable(pagination, 'Eingang', 'booking.createdAt') }} + + {{ knp_pagination_sortable(pagination, 'Gruppenhaus', 'accommodation.name') }} + + {{ knp_pagination_sortable(pagination, 'Gruppe', 'booking.groupName') }} + + {{ knp_pagination_sortable(pagination, 'Anreise', 'booking.dateFrom') }} + + Personen + + Status + + Rabatt +
+ {{ booking.createdAt | date('d.m.Y, H:i') }} + + {{ booking.accommodation.name }} + + {{ booking.groupName }} + + {{ booking.dateFrom | date('d.m.Y') }} – {{ booking.dateTo | date('d.m.Y') }} + + {{ booking.paxCount }} + + {% if booking.isInquiry %} + Anfrage + {% else %} + Buchung + {% endif %} + + {% if booking.discount is not null %} + {{ booking.discount }} % + {% else %} + – + {% endif %} + + + + Details + + + bearbeiten + + +
+ Keine Daten... +
+ {{ knp_pagination_render(pagination) }} +
+
+ +{% endblock %} diff --git a/templates/admin/accommodation_booking/show.html.twig b/templates/admin/accommodation_booking/show.html.twig new file mode 100644 index 00000000..ab7a7044 --- /dev/null +++ b/templates/admin/accommodation_booking/show.html.twig @@ -0,0 +1,200 @@ +{% extends 'layout_admin.html.twig' %} + +{% block content %} + + {% if booking.isInquiry %}Anfrage{% else %}Buchung{% endif %} + {{ booking.groupName }} + + +
+
+

Kontakt

+
+
Gruppenname
+
{{ booking.groupName }}
+
Anrede
+
{{ booking.salutation | default('–') }}
+
Name
+
{{ booking.firstName }} {{ booking.lastName }}
+
E-Mail
+
{{ booking.email }}
+
Telefon
+
{{ booking.phone | default('–') }}
+
Adresse
+
+ {% if booking.street %} + {{ booking.street }}
+ {{ booking.zip }} {{ booking.city }} + {% else %} + – + {% endif %} +
+
+
+ +
+

Reisedaten

+
+
Gruppenhaus
+
{{ booking.accommodation.name }}
+
Anreise
+
{{ booking.dateFrom | date('d.m.Y') }}
+
Abreise
+
{{ booking.dateTo | date('d.m.Y') }}
+
Nächte
+
{{ booking.nights }}
+
Anzahl Personen
+
{{ booking.paxCount }}
+ {% if booking.minorsCount > 0 %} +
davon Kinder (0–3 Jahre)
+
{{ booking.minorsCount }}
+ {% endif %} + {% if booking.childrenCount > 0 %} +
davon Kinder (4–{{ booking.accommodation.maxAdolescentAge }} Jahre)
+
{{ booking.childrenCount }}
+ {% endif %} +
Status
+
{% if booking.isInquiry %}Anfrage{% else %}Buchung{% endif %}
+
+
+ + {% if booking.boardServiceLabel or booking.additionalServices | length > 0 %} +
+

Gebuchte Leistungen

+
+ {% if booking.boardServiceLabel %} +
Verpflegung
+
{{ booking.boardServiceLabel }}
+ {% endif %} + {% for service in booking.additionalServices %} +
{{ loop.first ? 'Zusatzleistungen' : '' }}
+
{{ service.label }}
+ {% endfor %} +
+
+ {% endif %} + + {% if priceBreakdown is not null %} +
+

Preise

+ {% set currency = booking.accommodation.currency %} + + {% if priceBreakdown.basePrice > 0 %} + + + + + {% endif %} + {% if priceBreakdown.additionalPersonsPrice > 0 %} + + + + + {% endif %} + {% if priceBreakdown.shortTermSurcharge > 0 %} + + + + + {% endif %} + {% if priceBreakdown.boardPrice > 0 %} + + + + + {% endif %} + {% if priceBreakdown.undersubscriptionSurcharge > 0 %} + + + + + {% endif %} + {% for service in priceBreakdown.serviceDetails %} + {% if service.price > 0 %} + + + + + {% endif %} + {% endfor %} + {% if priceBreakdown.runningCosts > 0 %} + + + + + {% endif %} + + + + + {% if booking.discount is not null %} + {% set discountAmount = (priceBreakdown.total * booking.discount / 100) | round | number_format(0, '.', '') %} + {% set discountedTotal = priceBreakdown.total - discountAmount %} + + + + + + + + + {% endif %} +
+ Basispreis + für {{ priceBreakdown.includedPax }} Pers. + {{ (priceBreakdown.basePrice / 100) | format_currency(currency) }}
+ Aufpreis Personenzahl + für {{ priceBreakdown.effectivePax - priceBreakdown.includedPax }} Pers. + {{ (priceBreakdown.additionalPersonsPrice / 100) | format_currency(currency) }}
Aufpreis Kurzzeit{{ (priceBreakdown.shortTermSurcharge / 100) | format_currency(currency) }}
Verpflegung{{ (priceBreakdown.boardPrice / 100) | format_currency(currency) }}
Verpflegungs-Aufschlag für Gruppen unter {{ priceBreakdown.undersubscriptionThreshold }} Personen{{ (priceBreakdown.undersubscriptionSurcharge / 100) | format_currency(currency) }}
{{ service.label }}{{ (service.price / 100) | format_currency(currency) }}
Strom- und Abfallgebühren{{ (priceBreakdown.runningCosts / 100) | format_currency(currency) }}
+ Gesamtpreis + zzgl. ortsabhängiger Gebühren + {{ (priceBreakdown.total / 100) | format_currency(currency) }}
Rabatt {{ booking.discount }} %– {{ (discountAmount / 100) | format_currency(currency) }}
Gesamtpreis nach Rabatt{{ (discountedTotal / 100) | format_currency(currency) }}
+
+ {% endif %} + + {% if booking.remarks %} +
+

Bemerkungen

+

{{ booking.remarks | nl2br }}

+
+ {% endif %} +
+ +
+ Eingegangen: {{ booking.createdAt | date('d.m.Y, H:i') }} + {% if booking.updatedAt is not null and booking.updatedAt != booking.createdAt %} + · Aktualisiert: {{ booking.updatedAt | date('d.m.Y, H:i') }} + {% endif %} +
+ +
+

Zugangslink

+ {% if accessLink %} +

+ {{ accessLink }} +

+

+ Gültig bis {{ accessLinkExpiresAt | date('d.m.Y') }} + {% if accessLinkExpiresAt < date() %} + (abgelaufen) + {% endif %} +

+ {% else %} +

Es wurde noch kein Zugangslink generiert.

+ {% endif %} +
+ +
+
+ + +{% endblock %} diff --git a/templates/admin/accommodation_price/_form.html.twig b/templates/admin/accommodation_price/_form.html.twig index 46964b87..9c950a80 100644 --- a/templates/admin/accommodation_price/_form.html.twig +++ b/templates/admin/accommodation_price/_form.html.twig @@ -13,12 +13,15 @@ {{ form_row(form.includedPax) }} {{ form_row(form.minNights) }} {{ form_row(form.acceptUndersubscription) }} + {{ form_row(form.acceptShortTerm) }}
- + zurück -
diff --git a/templates/admin/additional_service/_form.html.twig b/templates/admin/additional_service/_form.html.twig index 54479804..663e2075 100644 --- a/templates/admin/additional_service/_form.html.twig +++ b/templates/admin/additional_service/_form.html.twig @@ -4,16 +4,21 @@
{{ form_row(form.label) }} {{ form_row(form.type) }} +
+ {{ form_row(form.description) }} +
{{ form_row(form.price) }} {{ form_row(form.selectionGroup) }} {{ form_row(form.dateFrom) }} {{ form_row(form.dateTo) }}
- + zurück -
diff --git a/templates/admin/board_service/_form.html.twig b/templates/admin/board_service/_form.html.twig index 0f5e5621..bcf0faf6 100644 --- a/templates/admin/board_service/_form.html.twig +++ b/templates/admin/board_service/_form.html.twig @@ -4,14 +4,19 @@
{{ form_row(form.label) }} {{ form_row(form.price) }} +
+ {{ form_row(form.description) }} +
{{ form_row(form.dateFrom) }} {{ form_row(form.dateTo) }}
- + zurück -
diff --git a/templates/email/accommodation_booking.html.twig b/templates/email/accommodation_booking.html.twig new file mode 100644 index 00000000..f46ea9d1 --- /dev/null +++ b/templates/email/accommodation_booking.html.twig @@ -0,0 +1,163 @@ +{% extends 'email/layout.html.twig' %} + +{% block body %} +

+ Neue Unterkunfts{% if booking.inquiry %}anfrage{% else %}buchung{% endif %} +

+ +

+ Art: + {% if booking.inquiry %} + Unverbindliche Anfrage + {% else %} + Verbindliche Buchung + {% endif %} +

+ + {% if accessLink %} +

+ Kundenansicht öffnen +

+ {% endif %} + +

Unterkunft

+ + + + + + + + + + + + + + + + + +
Name{{ booking.accommodation.name }}
Anreise{{ booking.dateFrom|date('d.m.Y') }}
Abreise{{ booking.dateTo|date('d.m.Y') }}
Nächte{{ booking.nights }}
+ +

Personen

+ + + + + + {% if booking.minorsCount > 0 %} + + + + + {% endif %} + {% if booking.childrenCount > 0 %} + + + + + {% endif %} +
Personen{{ booking.paxCount }}
Kinder (0–3 J.){{ booking.minorsCount }}
Kinder (4–{{ booking.accommodation.maxAdolescentAge }} J.){{ booking.childrenCount }}
+ + {% if booking.boardServiceLabel %} +

Verpflegung

+

{{ booking.boardServiceLabel }}

+ {% endif %} + + {% if booking.additionalServices is not empty %} +

Zusatzleistungen

+
    + {% for service in booking.additionalServices %} +
  • {{ service.label }}
  • + {% endfor %} +
+ {% endif %} + + {% if priceBreakdown is not empty %} + {% set currency = priceBreakdown.currency %} +

Preise

+ + {% if priceBreakdown.basePrice > 0 %} + + + + + {% endif %} + {% if priceBreakdown.additionalPersonsPrice > 0 %} + + + + + {% endif %} + {% if priceBreakdown.shortTermSurcharge > 0 %} + + + + + {% endif %} + {% if priceBreakdown.boardPrice > 0 %} + + + + + {% endif %} + {% if priceBreakdown.undersubscriptionSurcharge > 0 %} + + + + + {% endif %} + {% for service in priceBreakdown.serviceDetails %} + {% if service.price > 0 %} + + + + + {% endif %} + {% endfor %} + {% if priceBreakdown.runningCosts > 0 %} + + + + + {% endif %} + + + + +
Basispreis{% if priceBreakdown.includedPax is defined %} ({{ priceBreakdown.includedPax }} Pers.){% endif %}{{ (priceBreakdown.basePrice / 100)|format_currency(currency) }}
Aufpreis Personenzahl{% if priceBreakdown.includedPax is defined %} ({{ priceBreakdown.effectivePax - priceBreakdown.includedPax }} Pers.){% endif %}{{ (priceBreakdown.additionalPersonsPrice / 100)|format_currency(currency) }}
Aufpreis Kurzzeit{{ (priceBreakdown.shortTermSurcharge / 100)|format_currency(currency) }}
Verpflegung{{ (priceBreakdown.boardPrice / 100)|format_currency(currency) }}
Verpflegungs-Aufschlag (unter {{ priceBreakdown.undersubscriptionThreshold }} Pers.){{ (priceBreakdown.undersubscriptionSurcharge / 100)|format_currency(currency) }}
{{ service.label }}{{ (service.price / 100)|format_currency(currency) }}
Strom- und Abfallgebühren{{ (priceBreakdown.runningCosts / 100)|format_currency(currency) }}
Gesamtpreis (zzgl. ortsabhängiger Gebühren){{ (priceBreakdown.total / 100)|format_currency(currency) }}
+ {% endif %} + +

Kontakt

+ + + + + + + + + + {% if booking.phone %} + + + + + {% endif %} + {% if booking.street or booking.zip or booking.city %} + + + + + {% endif %} +
Name{{ booking.salutation }} {{ booking.firstName }} {{ booking.lastName }}
E-Mail{{ booking.email }}
Telefon{{ booking.phone }}
Adresse + {% if booking.street %}{{ booking.street }}
{% endif %} + {% if booking.zip or booking.city %}{{ booking.zip }} {{ booking.city }}{% endif %} +
+ + {% if booking.remarks %} +

Anmerkungen

+

{{ booking.remarks|nl2br }}

+ {% endif %} +{% endblock %} diff --git a/templates/email/accommodation_booking_customer.html.twig b/templates/email/accommodation_booking_customer.html.twig new file mode 100644 index 00000000..f40f0318 --- /dev/null +++ b/templates/email/accommodation_booking_customer.html.twig @@ -0,0 +1,66 @@ +{% extends 'email/layout.html.twig' %} + +{% block body %} + {% if accessLink %} +

{{ booking.inquiry ? 'Dein Angebot ist bereit' : 'Deine Buchung ist bestätigt' }}

+ +

+ Hallo {{ booking.firstName }}, + {% if booking.inquiry %} + vielen Dank für deine Anfrage! Wir haben sie geprüft — dein Angebot wartet auf dich. + {% else %} + vielen Dank! Deine Buchung ist bei uns eingegangen und bestätigt. + {% endif %} +

+ +

+ + {{ booking.inquiry ? 'Angebot ansehen & bestätigen' : 'Buchung ansehen' }} + +

+ {% else %} +

Deine Anfrage ist bei uns eingegangen

+ +

+ Hallo {{ booking.firstName }}, vielen Dank für deine Anfrage! Wir melden uns so schnell wie möglich + bei dir mit deinem persönlichen Angebot. +

+ {% endif %} + +

Unterkunft

+ + + + + + + + + + + + + + + + + + + + + +
Name{{ booking.accommodation.name }}
Anreise{{ booking.dateFrom|date('d.m.Y') }}
Abreise{{ booking.dateTo|date('d.m.Y') }}
Nächte{{ booking.nights }}
Personen{{ booking.paxCount }}
+ + {% if priceBreakdown is not empty %} + {% set currency = priceBreakdown.currency %} +

Preise

+ + + + + +
Gesamtpreis (zzgl. ortsabhängiger Gebühren){{ (priceBreakdown.total / 100)|format_currency(currency) }}
+ {% endif %} + +

Bei Fragen antworte einfach auf diese E-Mail.

+{% endblock %} diff --git a/templates/groups/booking/_pagination.html.twig b/templates/groups/booking/_pagination.html.twig new file mode 100644 index 00000000..5a8a4d62 --- /dev/null +++ b/templates/groups/booking/_pagination.html.twig @@ -0,0 +1,32 @@ +{% set current_step = current_step ?? 1 %} +{% set step_routes = { + 1: 'app_groups_booking_step_1', + 2: 'app_groups_booking_step_2', + 3: 'app_groups_booking_step_3', +} %} + diff --git a/templates/groups/booking/_price_calendar_grid.html.twig b/templates/groups/booking/_price_calendar_grid.html.twig new file mode 100644 index 00000000..68d298ea --- /dev/null +++ b/templates/groups/booking/_price_calendar_grid.html.twig @@ -0,0 +1,80 @@ +
+{% for monthData in months %} +
+ +

{{ monthData.label }}

+ + {# Weekday headers (Mon–Sun) #} +
+
Mo
+
Di
+
Mi
+
Do
+
Fr
+
Sa
+
So
+
+ + {# Day grid #} +
+ {% for week in monthData.weeks %} + {% for dayData in week %} + {% if dayData.inMonth %} + {% set dateStr = dayData.date|date('Y-m-d') %} + {% set entry = enrichedByDate[dateStr] ?? null %} + {% set status = (dateStr < todayStr) ? 'past' : (entry ? entry.status : 'ok') %} + {% set minNights = entry ? entry.minNights : 0 %} + + {% else %} +
+ {% endif %} + {% endfor %} + {% endfor %} +
+ +
+{% endfor %} +
+ +
+ {# Status message (arrival hint, short-stay warning, etc.) #} +
+ + {# Reset link — revealed once a start date is selected #} + +
+ +{# Navigation — prev/next trigger HTMX month swap; no Stimulus involvement #} +
+ + +
diff --git a/templates/groups/booking/_step3_confirmation_modal.html.twig b/templates/groups/booking/_step3_confirmation_modal.html.twig new file mode 100644 index 00000000..8dc17b7d --- /dev/null +++ b/templates/groups/booking/_step3_confirmation_modal.html.twig @@ -0,0 +1,24 @@ +{% extends 'htmx_modal.html.twig' %} + +{% block title %}Buchung bestätigen{% endblock %} + +{% block content %} + {% from '_partials/_validation_errors.html.twig' import validation_alert %} + {{ validation_alert(confirmationForm, 'Bitte akzeptiere die AGB, um die Buchung abzuschließen.', false) }} + + {{ form_start(confirmationForm, { + 'action': path('app_groups_booking_step_3_confirm'), + 'attr': { + 'hx-post': path('app_groups_booking_step_3_confirm'), + 'hx-target': '#htmx-modal', + 'hx-swap': 'outerHTML', + } + }) }} + {{ form_row(confirmationForm.termsAccepted) }} + +
+ + +
+ {{ form_end(confirmationForm) }} +{% endblock %} diff --git a/templates/groups/booking/_summary.html.twig b/templates/groups/booking/_summary.html.twig new file mode 100644 index 00000000..fb1ef3dd --- /dev/null +++ b/templates/groups/booking/_summary.html.twig @@ -0,0 +1,163 @@ +{% block booking_summary %} +
+ {# Header #} +
+ {% if ctx.priceBreakdown is not null %} +
+ Gesamtpreis + {{ (ctx.priceBreakdown.total / 100)|format_currency(ctx.accommodation.currency) }} +
+ {% endif %} +
+ Übersicht + +
+
+ + {# Content #} + +
+{% endblock %} diff --git a/templates/groups/booking/error.html.twig b/templates/groups/booking/error.html.twig new file mode 100644 index 00000000..b7c1848f --- /dev/null +++ b/templates/groups/booking/error.html.twig @@ -0,0 +1,18 @@ +{% extends 'layout.html.twig' %} + +{% block title %}Fehler{% endblock %} + +{% block background %}bg-outer bg-outer--summer{% endblock %} + +{% block content %} +
+
+ {% set errorMessages = app.flashes('error')|default(['Es ist ein Fehler aufgetreten. Bitte überprüfe den Link oder versuche es erneut.']) %} + {% include '_partials/_alert.html.twig' with { + level: 'error', + title: 'Das hat nicht geklappt', + messages: errorMessages + } %} +
+
+{% endblock %} diff --git a/templates/groups/booking/offer.html.twig b/templates/groups/booking/offer.html.twig new file mode 100644 index 00000000..0e41582f --- /dev/null +++ b/templates/groups/booking/offer.html.twig @@ -0,0 +1,82 @@ +{% extends 'layout.html.twig' %} + +{% block title %}{{ booking.inquiry ? 'Dein Angebot' : 'Deine Buchung' }}{% endblock %} + +{% block background %}bg-outer bg-outer--summer{% endblock %} + +{% block content %} +
+

+ {{ booking.inquiry ? 'Dein Angebot' : 'Deine Buchung' }} +

+

{{ booking.accommodation.name }}

+ +
+
Anreise
+
{{ booking.dateFrom | date('d.m.Y') }}
+
Abreise
+
{{ booking.dateTo | date('d.m.Y') }}
+
Nächte
+
{{ booking.nights }}
+
Personen
+
{{ booking.paxCount }}
+ {% if booking.boardServiceLabel %} +
Verpflegung
+
{{ booking.boardServiceLabel }}
+ {% endif %} +
+ + {% if booking.additionalServices | length > 0 %} +
+

Zusatzleistungen

+
    + {% for service in booking.additionalServices %} +
  • {{ service.label }}
  • + {% endfor %} +
+
+ {% endif %} + + {% if priceBreakdown is not null %} + {% set currency = booking.accommodation.currency %} +
+

Preise

+ + + + + + {% if booking.discount is not null %} + {% set discountAmount = (priceBreakdown.total * booking.discount / 100) | round | number_format(0, '.', '') %} + {% set discountedTotal = priceBreakdown.total - discountAmount %} + + + + + + + + + {% endif %} +
+ Gesamtpreis + zzgl. ortsabhängiger Gebühren + {{ (priceBreakdown.total / 100) | format_currency(currency) }}
Rabatt {{ booking.discount }} %– {{ (discountAmount / 100) | format_currency(currency) }}
Gesamtpreis nach Rabatt{{ (discountedTotal / 100) | format_currency(currency) }}
+
+ {% endif %} + + {% if booking.inquiry %} +
+ +
+ {% else %} + {% include '_partials/_alert.html.twig' with { + level: 'success', + title: 'Bestätigt', + messages: ['Deine Buchung ist verbindlich bestätigt.'], + } %} + {% endif %} +
+{% endblock %} diff --git a/templates/groups/booking/offer_unavailable.html.twig b/templates/groups/booking/offer_unavailable.html.twig new file mode 100644 index 00000000..af5f82e0 --- /dev/null +++ b/templates/groups/booking/offer_unavailable.html.twig @@ -0,0 +1,13 @@ +{% extends 'layout.html.twig' %} + +{% block title %}Link nicht verfügbar{% endblock %} + +{% block content %} +
+ {% include '_partials/_alert.html.twig' with { + level: 'error', + title: 'Dieser Link ist nicht (mehr) gültig', + messages: ['Bitte wende dich an uns, damit wir dir einen neuen Link zusenden können.'], + } %} +
+{% endblock %} diff --git a/templates/groups/booking/step_1.html.twig b/templates/groups/booking/step_1.html.twig new file mode 100644 index 00000000..ee7c65a4 --- /dev/null +++ b/templates/groups/booking/step_1.html.twig @@ -0,0 +1,89 @@ +{% extends 'layout_booking.html.twig' %} + +{% block title %}Schritt 1: Reisezeitraum wählen{% endblock %} + +{% block background %}bg-outer bg-outer--summer{% endblock %} + +{% block content %} +
+ + {# Pagination - mobile only (above summary) #} +
+ {% include 'groups/booking/_pagination.html.twig' with { 'current_step': 1 } %} +
+ +
+ + {# Sidebar summary #} +
+ {% include 'groups/booking/_summary.html.twig' with { 'dto': dto, 'ctx': ctx } %} +
+ + {# Calendar #} +
+ + {% include '_partials/_flashes.html.twig' %} + + {# Pagination - desktop only (fixed above scrollable content) #} + + +
+ +

+ Gewünschter Zeitraum +

+ + {# Calendar mount — HTMX loads the grid; Stimulus wires afterSwap → initAfterLoad() #} +
+
Kalender wird geladen…
+
+ + {# Error state shown by Stimulus on htmx:responseError #} + + +
+
+ +
+ +
+
+ + + +
+ +
+
+
+ +
+{% endblock %} diff --git a/templates/groups/booking/step_2.html.twig b/templates/groups/booking/step_2.html.twig new file mode 100644 index 00000000..4970d39b --- /dev/null +++ b/templates/groups/booking/step_2.html.twig @@ -0,0 +1,235 @@ +{% extends 'layout_booking.html.twig' %} + +{% block title %}Schritt 2: Personen & Leistungen{% endblock %} + +{% block background %}bg-outer bg-outer--summer{% endblock %} + +{% block content %} + {% form_theme form 'booking/_form_theme.html.twig' %} + + {{ form_start(form, { + 'attr': { + 'class': 'flex-1 flex flex-col min-h-0', + } + }) }} + + {# Pagination - mobile only (above summary) #} +
+ {% include 'groups/booking/_pagination.html.twig' with { 'current_step': 2 } %} +
+ +
+ + {# Sidebar summary #} + {% block accommodation_summary %} +
+ {% include 'groups/booking/_summary.html.twig' with { 'dto': dto, 'ctx': ctx } %} +
+ {% endblock %} + + {# Main form #} + {% block accommodation_form %} +
+ + {% include '_partials/_flashes.html.twig' %} + + {# Pagination - desktop only (fixed above scrollable content) #} + + +
+ + {# Validation errors #} + {% from '_partials/_validation_errors.html.twig' import validation_alert %} + {{ validation_alert(form) }} + + {% if dto.isInquiry %} + {% include '_partials/_alert.html.twig' with { + level: 'warning', + title: 'Diese Buchung wird als unverbindliche Anfrage behandelt', + messages: dto.inquiryReasons + } %} + {% endif %} + + {# Pax counts #} +

+ Personenanzahl und gewünschte Leistungen +

+ +
+ {{ form_row(form.paxCount) }} + {{ form_row(form.minorsCount) }} + {{ form_row(form.childrenCount) }} +
+ + {# Board services — rendered manually so price is formatted in Twig #} + {% do form.selectedBoardServiceId.setRendered %} + {% if ctx.boardServices is not empty %} +
+
+ + Verpflegungsleistungen + + + + + + + + {% for service in ctx.boardServices %} + + + + + + {% endfor %} +
+ + + +
+ + {% if service.description %} +
+ + +
+ {% endif %} +
+
{{ (service.price / 100)|format_currency(ctx.accommodation.currency) }}
+
pro Person und Nacht
+
+ +
+
+
+ {% endif %} + + {# Additional services — rendered manually to support checkbox/radio mix #} + {% do form.selectedAdditionalServiceIds.setRendered %} + {% set selectedAdditionalServiceBaseName = form.selectedAdditionalServiceIds.vars.full_name|replace({'[]': ''}) %} + + {% if ctx.ungroupedAdditionalServices is not empty or ctx.groupedAdditionalServices is not empty %} + + {# Ungrouped → checkboxes #} + {% if ctx.ungroupedAdditionalServices is not empty %} +
+
+ + Optionale Zusatzleistungen + + + {% for service in ctx.ungroupedAdditionalServices %} + + + + + + {% endfor %} +
+ + {% if service.description %} +
+ + +
+ {% endif %} +
+
{{ (service.price / 100)|format_currency(ctx.accommodation.currency) }}
+
{{ service.type.label | trans }}
+
+ +
+
+
+ {% endif %} + + {# Grouped → radio buttons per group, one fieldset per group #} + {% for groupName, services in ctx.groupedAdditionalServices %} +
+
+ {{ groupName }} + + {% for service in services %} + + + + + + {% endfor %} +
+ + {% if service.description %} +
+ + +
+ {% endif %} +
+
{{ (service.price / 100)|format_currency(ctx.accommodation.currency) }}
+
{{ service.type.label | trans }}
+
+ +
+
+
+ {% endfor %} + + {% endif %} + +
+
+ {% endblock %} + +
+ +
+
+ + Zurück + + +
+
+ + {{ form_rest(form) }} + {{ form_end(form) }} +{% endblock %} diff --git a/templates/groups/booking/step_3.html.twig b/templates/groups/booking/step_3.html.twig new file mode 100644 index 00000000..657eb63d --- /dev/null +++ b/templates/groups/booking/step_3.html.twig @@ -0,0 +1,111 @@ +{% extends 'layout_booking.html.twig' %} + +{% block title %}Schritt 3: Persönliche Daten{% endblock %} + +{% block background %}bg-outer bg-outer--summer{% endblock %} + +{% block content %} + {% form_theme form 'booking/_form_theme.html.twig' %} + + {{ form_start(form, { + 'attr': { + 'class': 'flex-1 flex flex-col min-h-0', + 'novalidate': 'novalidate', + } + }) }} + + {# Pagination - mobile only (above summary) #} +
+ {% include 'groups/booking/_pagination.html.twig' with { 'current_step': 3 } %} +
+ +
+ + {# Static sidebar recap #} +
+ {% include 'groups/booking/_summary.html.twig' with { 'dto': dto, 'ctx': ctx } %} +
+ + {# Personal data form #} +
+ {% include '_partials/_flashes.html.twig' %} + + {# Pagination - desktop only (fixed above scrollable content) #} + + +
+

+ Persönliche Daten +

+ + {# Validation errors #} + {% from '_partials/_validation_errors.html.twig' import validation_alert %} + {{ validation_alert(form, 'Bitte fülle alle Pflichtfelder aus.') }} + + {% if dto.isInquiry %} + {% include '_partials/_alert.html.twig' with { + level: 'warning', + title: 'Diese Buchung wird als unverbindliche Anfrage behandelt', + messages: dto.inquiryReasons + } %} + {% endif %} + +
+ {{ form_row(form.groupName) }} + {{ form_row(form.salutation) }} +
+ +
+ {{ form_row(form.firstName) }} + {{ form_row(form.lastName) }} +
+ +
+ {{ form_row(form.email) }} + {{ form_row(form.phone) }} +
+ +

Adresse

+ + {{ form_row(form.street) }} + +
+ {{ form_row(form.zip) }} + {{ form_row(form.city) }} +
+ +

Anmerkungen

+ {{ form_row(form.remarks) }} + +
+ {% if form.forceInquiry is defined %} + {{ form_row(form.forceInquiry) }} + {% endif %} +
+
+
+ +
+ +
+
+ + Zurück + +
+ + +
+
+
+ + {{ form_rest(form) }} + {{ form_end(form) }} + + {% if confirmationForm is defined %} + {% include 'groups/booking/_step3_confirmation_modal.html.twig' with { confirmationForm: confirmationForm } %} + {% endif %} +{% endblock %} diff --git a/templates/groups/booking/success.html.twig b/templates/groups/booking/success.html.twig new file mode 100644 index 00000000..9e354a58 --- /dev/null +++ b/templates/groups/booking/success.html.twig @@ -0,0 +1,19 @@ +{% extends 'layout.html.twig' %} + +{% block title %}{{ resultType == 'booking' ? 'Buchung erfolgreich' : 'Anfrage erfolgreich' }}{% endblock %} + +{% block background %}bg-outer bg-outer--summer{% endblock %} + +{% block content %} +
+ {% include '_partials/_alert.html.twig' with { + level: 'success', + title: 'Vielen Dank!', + messages: [ + resultType == 'booking' + ? 'Deine Buchung ist bei uns eingegangen. Du erhältst in Kürze eine Bestätigung.' + : 'Deine Anfrage ist bei uns eingegangen. Wir melden uns so schnell wie möglich bei dir.' + ] + } %} +
+{% endblock %} diff --git a/tests/Controller/Accommodation/IndexControllerTest.php b/tests/Controller/Accommodation/IndexControllerTest.php new file mode 100644 index 00000000..b8d05e52 --- /dev/null +++ b/tests/Controller/Accommodation/IndexControllerTest.php @@ -0,0 +1,89 @@ +getFlashBag()->add('groups_booking_result', 'booking'); + + $request = Request::create('/groups/booking/success', 'GET'); + $request->setSession($session); + + $controller = $this->makeController(); + + $response = $controller->success($request); + + self::assertSame(200, $response->getStatusCode()); + self::assertSame('groups/booking/success.html.twig', $controller->renderedView); + self::assertSame('booking', $controller->renderedParameters['resultType']); + // The flash is read-once: a second read on the same request returns nothing. + self::assertSame([], $session->getFlashBag()->get('groups_booking_result')); + } + + public function testSuccessRedirectsToLoginWhenFlashIsMissing(): void + { + $session = new Session(new MockArraySessionStorage()); + + $request = Request::create('/groups/booking/success', 'GET'); + $request->setSession($session); + + $controller = $this->makeController(); + + $response = $controller->success($request); + + self::assertInstanceOf(RedirectResponse::class, $response); + self::assertSame('/app_login', $response->headers->get('Location')); + } + + private function makeController(): TestableAccommodationIndexController + { + return new TestableAccommodationIndexController( + $this->createMock(AccommodationBookingService::class), + $this->createMock(AccommodationSessionManager::class), + ); + } +} + +final class TestableAccommodationIndexController extends IndexController +{ + /** + * @var array + */ + public array $renderedParameters = []; + + public string $renderedView = ''; + + /** + * @param array $parameters + */ + protected function redirectToRoute(string $route, array $parameters = [], int $status = 302): RedirectResponse + { + return new RedirectResponse('/'.$route, $status); + } + + /** + * @param array $parameters + */ + protected function render(string $view, array $parameters = [], ?Response $response = null): Response + { + $this->renderedView = $view; + $this->renderedParameters = $parameters; + + return new Response('ok'); + } +} diff --git a/tests/Controller/Accommodation/Step1ControllerTest.php b/tests/Controller/Accommodation/Step1ControllerTest.php new file mode 100644 index 00000000..7de07626 --- /dev/null +++ b/tests/Controller/Accommodation/Step1ControllerTest.php @@ -0,0 +1,164 @@ +accommodationId = 1; + + $session = new Session(new MockArraySessionStorage()); + $request = Request::create('/groups/booking/step-1', 'POST', [ + 'date_from' => '2026-08-03', + 'date_to' => '2026-08-07', + ]); + $request->setSession($session); + + $sessionManager = new AccommodationSessionManager(); + $sessionManager->save($request, $dto); + + $accommodation = (new Accommodation()) + ->setName('Hotel') + ->setCalendarCode('HOTEL') + ->setMaxAdolescentAge(17); + + $bookingService = $this->createMock(AccommodationBookingService::class); + $bookingService + ->method('applyDates') + ->willReturnCallback(static function (AccommodationBookingDto $dto): void { + $dto->dateFrom = new \DateTimeImmutable('2026-08-03'); + $dto->dateTo = new \DateTimeImmutable('2026-08-07'); + }); + $bookingService + ->method('loadAccommodation') + ->with(1) + ->willReturn($accommodation); + $bookingService + ->method('computeInitialPaxCount') + ->willReturn(4); + + $controller = $this->makeController($bookingService, $sessionManager); + + $response = $controller->index($request); + + self::assertInstanceOf(RedirectResponse::class, $response); + self::assertSame('/app_groups_booking_step_2', $response->headers->get('Location')); + + $savedDto = $sessionManager->getOrFail($request); + self::assertSame(2, $savedDto->currentStep); + self::assertSame(4, $savedDto->paxCount); + self::assertNull($savedDto->selectedBoardServiceId); + self::assertSame([], $savedDto->selectedAdditionalServiceIds); + } + + public function testPostWithInvalidDatesRedirectsBackWithFlash(): void + { + $dto = new AccommodationBookingDto(); + $dto->accommodationId = 1; + + $session = new Session(new MockArraySessionStorage()); + $request = Request::create('/groups/booking/step-1', 'POST', [ + 'date_from' => 'not-a-date', + 'date_to' => '2026-08-07', + ]); + $request->setSession($session); + + $sessionManager = new AccommodationSessionManager(); + $sessionManager->save($request, $dto); + + $bookingService = $this->createMock(AccommodationBookingService::class); + $bookingService + ->method('applyDates') + ->willThrowException(new \InvalidArgumentException('Ungültiges Datumsformat. Erwartet: YYYY-MM-DD.')); + + $controller = $this->makeController($bookingService, $sessionManager); + + $response = $controller->index($request); + + self::assertInstanceOf(RedirectResponse::class, $response); + self::assertSame('/app_groups_booking_step_1', $response->headers->get('Location')); + } + + private function makeController( + AccommodationBookingService $bookingService, + AccommodationSessionManager $sessionManager, + ): TestableAccommodationStep1Controller { + return new TestableAccommodationStep1Controller( + $bookingService, + $sessionManager, + $this->createMock(ContingentsClient::class), + $this->createMock(AccommodationPriceRepository::class), + new PriceTimelineBuilder(), + $this->createMock(CacheInterface::class), + new CalendarGridBuilder(), + new GroupsPriceCalculator(new PriceTimelineBuilder(), ['runningCostsEur' => 0, 'runningCostsChf' => 0, 'undersubscription30Eur' => 0, 'undersubscription30Chf' => 0, 'undersubscription40Eur' => 0, 'undersubscription40Chf' => 0]), + ); + } +} + +final class TestableAccommodationStep1Controller extends Step1Controller +{ + public function __construct( + AccommodationBookingService $bookingService, + AccommodationSessionManager $sessionManager, + ContingentsClient $contingentsClient, + AccommodationPriceRepository $priceRepository, + PriceTimelineBuilder $priceTimelineBuilder, + CacheInterface $cache, + CalendarGridBuilder $calendarGridBuilder, + GroupsPriceCalculator $priceCalculator, + ) { + parent::__construct( + $bookingService, + $sessionManager, + $contingentsClient, + $priceRepository, + $priceTimelineBuilder, + $cache, + $calendarGridBuilder, + $priceCalculator, + ); + } + + /** + * @param array $parameters + */ + protected function redirectToRoute(string $route, array $parameters = [], int $status = 302): RedirectResponse + { + return new RedirectResponse('/'.$route, $status); + } + + /** + * @param array $parameters + */ + protected function render(string $view, array $parameters = [], ?Response $response = null): Response + { + throw new \LogicException('Render should not be called in this test.'); + } + + protected function addFlash(string $type, mixed $message): void + { + } +} diff --git a/tests/Controller/Accommodation/Step2ControllerTest.php b/tests/Controller/Accommodation/Step2ControllerTest.php new file mode 100644 index 00000000..27a010b9 --- /dev/null +++ b/tests/Controller/Accommodation/Step2ControllerTest.php @@ -0,0 +1,136 @@ +currentStep = 2; + $dto->accommodationId = 1; + $dto->dateFrom = new \DateTimeImmutable('2026-03-01'); + $dto->dateTo = new \DateTimeImmutable('2026-03-05'); + $dto->paxCount = 1; + + $session = new Session(new MockArraySessionStorage()); + $request = Request::create('/groups/booking/step-2', 'POST'); + $request->setSession($session); + + $sessionManager = new AccommodationSessionManager(); + $sessionManager->save($request, $dto); + + $form = $this->createMock(FormInterface::class); + $form + ->method('handleRequest') + ->willReturnCallback(function () use ($dto, $form): FormInterface { + $dto->paxCount = 3; + + return $form; + }); + $form + ->method('isSubmitted') + ->willReturn(true); + $form + ->method('isValid') + ->willReturn(true); + + $accommodation = (new Accommodation()) + ->setName('Hotel') + ->setCalendarCode('HOTEL') + ->setMaxAdolescentAge(17); + + $bookingService = $this->createMock(AccommodationBookingService::class); + $bookingService + ->method('loadAccommodation') + ->with(1) + ->willReturn($accommodation); + $bookingService + ->method('loadPrices') + ->with($dto, $accommodation) + ->willReturn([]); + $bookingService + ->method('loadAvailableServices') + ->with($dto, $accommodation) + ->willReturn([ + 'boardServices' => [], + 'additionalServices' => [], + 'groupedAdditionalServices' => [], + 'ungroupedAdditionalServices' => [], + ]); + $bookingService + ->expects(self::once()) + ->method('computeInquiryStatus') + ->with(self::callback(static fn(AccommodationBookingDto $submittedDto): bool => 3 === $submittedDto->paxCount), []) + ->willReturn(['isInquiry' => true, 'reasons' => ['Mindestaufenthalt: 5 Nächte (gebucht: 4)']]); + + $controller = new TestableAccommodationStep2Controller( + $bookingService, + $sessionManager, + new GroupsPriceCalculator(new PriceTimelineBuilder(), ['runningCostsEur' => 0, 'runningCostsChf' => 0, 'undersubscription30Eur' => 0, 'undersubscription30Chf' => 0, 'undersubscription40Eur' => 0, 'undersubscription40Chf' => 0]), + $form, + ); + + $response = $controller->index($request); + + self::assertInstanceOf(RedirectResponse::class, $response); + self::assertSame('/groups/booking/step-3', $response->headers->get('Location')); + self::assertTrue($sessionManager->getOrFail($request)->isInquiry); + } +} + +final class TestableAccommodationStep2Controller extends Step2Controller +{ + /** + * @param FormInterface $form + */ + public function __construct( + AccommodationBookingService $bookingService, + AccommodationSessionManager $sessionManager, + GroupsPriceCalculator $priceCalculator, + private readonly FormInterface $form, + ) { + parent::__construct($bookingService, $sessionManager, $priceCalculator); + } + + /** + * @return FormInterface + */ + protected function createForm(string $type, mixed $data = null, array $options = []): FormInterface + { + return $this->form; + } + + /** + * @param array $parameters + */ + protected function redirectToRoute(string $route, array $parameters = [], int $status = 302): RedirectResponse + { + return new RedirectResponse('/groups/booking/step-3', $status); + } + + /** + * @param array $parameters + */ + protected function render(string $view, array $parameters = [], ?Response $response = null): Response + { + throw new \LogicException('Render should not be called in this test.'); + } +} diff --git a/tests/Controller/Accommodation/Step3ControllerTest.php b/tests/Controller/Accommodation/Step3ControllerTest.php new file mode 100644 index 00000000..a20cf9c8 --- /dev/null +++ b/tests/Controller/Accommodation/Step3ControllerTest.php @@ -0,0 +1,304 @@ +currentStep = 3; + $dto->accommodationId = 1; + $dto->dateFrom = new \DateTimeImmutable('2026-03-01'); + $dto->dateTo = new \DateTimeImmutable('2026-03-05'); + $dto->selectedBoardServiceId = 10; + $dto->selectedAdditionalServiceIds = [20]; + + $session = new Session(new MockArraySessionStorage()); + $request = Request::create('/groups/booking/step-3', 'GET'); + $request->setSession($session); + + $sessionManager = new AccommodationSessionManager(); + $sessionManager->save($request, $dto); + + $form = $this->createMock(FormInterface::class); + $form + ->method('handleRequest') + ->willReturnSelf(); + $form + ->method('isSubmitted') + ->willReturn(false); + + $accommodation = (new Accommodation()) + ->setName('Hotel') + ->setCalendarCode('HOTEL') + ->setMaxAdolescentAge(17); + + $boardService = (new BoardService())->setLabel('Halbpension')->setPrice(1000); + $additionalService = (new AdditionalService())->setLabel('Skipass')->setPrice(2000); + + $bookingService = $this->createMock(AccommodationBookingService::class); + $bookingService + ->method('loadAccommodation') + ->with(1) + ->willReturn($accommodation); + $bookingService + ->expects(self::once()) + ->method('loadAvailableServices') + ->with($dto, $accommodation) + ->willReturn([ + 'boardServices' => [$boardService], + 'additionalServices' => [$additionalService], + 'groupedAdditionalServices' => [], + 'ungroupedAdditionalServices' => [$additionalService], + ]); + + $controller = new TestableAccommodationStep3Controller($bookingService, $sessionManager, $form); + + $response = $controller->index($request); + + self::assertSame(200, $response->getStatusCode()); + self::assertSame('groups/booking/step_3.html.twig', $controller->renderedView); + self::assertSame([$boardService], $controller->renderedParameters['ctx']->boardServices); + self::assertSame([$additionalService], $controller->renderedParameters['ctx']->additionalServices); + } + + public function testClickingBuchenOpensConfirmationModalWithoutPersisting(): void + { + $dto = new AccommodationBookingDto(); + $dto->currentStep = 3; + $dto->accommodationId = 1; + $dto->isInquiry = false; + + $session = new Session(new MockArraySessionStorage()); + $request = Request::create('/groups/booking/step-3', 'POST'); + $request->setSession($session); + + $sessionManager = new AccommodationSessionManager(); + $sessionManager->save($request, $dto); + + $submitBookingButton = $this->getMockBuilder(SubmitButton::class) + ->disableOriginalConstructor() + ->onlyMethods(['isClicked']) + ->getMock(); + $submitBookingButton->method('isClicked')->willReturn(true); + + $mainForm = $this->createMock(FormInterface::class); + $mainForm->method('handleRequest')->willReturnSelf(); + $mainForm->method('isSubmitted')->willReturn(true); + $mainForm->method('isValid')->willReturn(true); + $mainForm->method('has')->with('submitBooking')->willReturn(true); + $mainForm->method('get')->with('submitBooking')->willReturn($submitBookingButton); + + $confirmationForm = $this->createMock(FormInterface::class); + + $accommodation = (new Accommodation()) + ->setName('Hotel') + ->setCalendarCode('HOTEL') + ->setMaxAdolescentAge(17); + + $bookingService = $this->createMock(AccommodationBookingService::class); + $bookingService->method('loadAccommodation')->with(1)->willReturn($accommodation); + $bookingService->method('loadAvailableServices')->willReturn([ + 'boardServices' => [], + 'additionalServices' => [], + 'groupedAdditionalServices' => [], + 'ungroupedAdditionalServices' => [], + ]); + $bookingService->expects(self::never())->method('persist'); + + $controller = new TestableAccommodationStep3Controller( + $bookingService, + $sessionManager, + [AccommodationStep3Type::class => $mainForm, AccommodationStep3ConfirmationType::class => $confirmationForm], + ); + + $response = $controller->index($request); + + self::assertSame(200, $response->getStatusCode()); + self::assertSame('groups/booking/step_3.html.twig', $controller->renderedView); + self::assertSame($confirmationForm, $controller->renderedParameters['confirmationForm']); + self::assertFalse($dto->forceInquiry); + self::assertSame($dto, $sessionManager->getDto($request)); + } + + public function testConfirmPersistsBookingOnValidConfirmationForm(): void + { + $dto = new AccommodationBookingDto(); + $dto->currentStep = 3; + $dto->accommodationId = 1; + $dto->isInquiry = false; + + $session = new Session(new MockArraySessionStorage()); + $request = Request::create('/groups/booking/step-3/confirm', 'POST'); + $request->setSession($session); + $request->headers->set('HX-Request', 'true'); + + $sessionManager = new AccommodationSessionManager(); + $sessionManager->save($request, $dto); + + $confirmationForm = $this->createMock(FormInterface::class); + $confirmationForm->method('handleRequest')->willReturnSelf(); + $confirmationForm->method('isSubmitted')->willReturn(true); + $confirmationForm->method('isValid')->willReturn(true); + + $accommodation = (new Accommodation()) + ->setName('Hotel') + ->setCalendarCode('HOTEL') + ->setMaxAdolescentAge(17); + + $booking = (new AccommodationBooking())->setIsInquiry(false); + + $bookingService = $this->createMock(AccommodationBookingService::class); + $bookingService->method('loadAccommodation')->with(1)->willReturn($accommodation); + $bookingService->method('loadAvailableServices')->willReturn([ + 'boardServices' => [], + 'additionalServices' => [], + 'groupedAdditionalServices' => [], + 'ungroupedAdditionalServices' => [], + ]); + $bookingService->method('loadPrices')->willReturn([]); + $bookingService->expects(self::once())->method('persist')->willReturn($booking); + $bookingService->expects(self::once())->method('sendNotificationEmail'); + + $controller = new TestableAccommodationStep3Controller( + $bookingService, + $sessionManager, + [AccommodationStep3ConfirmationType::class => $confirmationForm], + ); + + $response = $controller->confirm($request); + + self::assertSame(200, $response->getStatusCode()); + self::assertTrue($response->headers->has('HX-Redirect')); + self::assertNull($sessionManager->getDto($request)); + } + + public function testConfirmRerendersModalWhenTermsNotAccepted(): void + { + $dto = new AccommodationBookingDto(); + $dto->currentStep = 3; + $dto->accommodationId = 1; + $dto->isInquiry = false; + + $session = new Session(new MockArraySessionStorage()); + $request = Request::create('/groups/booking/step-3/confirm', 'POST'); + $request->setSession($session); + + $sessionManager = new AccommodationSessionManager(); + $sessionManager->save($request, $dto); + + $confirmationForm = $this->createMock(FormInterface::class); + $confirmationForm->method('handleRequest')->willReturnSelf(); + $confirmationForm->method('isSubmitted')->willReturn(true); + $confirmationForm->method('isValid')->willReturn(false); + + $accommodation = (new Accommodation()) + ->setName('Hotel') + ->setCalendarCode('HOTEL') + ->setMaxAdolescentAge(17); + + $bookingService = $this->createMock(AccommodationBookingService::class); + $bookingService->method('loadAccommodation')->with(1)->willReturn($accommodation); + $bookingService->expects(self::never())->method('persist'); + + $controller = new TestableAccommodationStep3Controller( + $bookingService, + $sessionManager, + [AccommodationStep3ConfirmationType::class => $confirmationForm], + ); + + $response = $controller->confirm($request); + + self::assertSame(200, $response->getStatusCode()); + self::assertFalse($response->headers->has('HX-Redirect')); + self::assertSame('groups/booking/_step3_confirmation_modal.html.twig', $controller->renderedView); + self::assertNotNull($sessionManager->getDto($request)); + } +} + +final class TestableAccommodationStep3Controller extends Step3Controller +{ + /** + * @var array + */ + public array $renderedParameters = []; + + public string $renderedView = ''; + + /** + * @param FormInterface|array> $form + */ + public function __construct( + AccommodationBookingService $bookingService, + AccommodationSessionManager $sessionManager, + private readonly FormInterface|array $form, + ) { + parent::__construct($bookingService, $sessionManager); + } + + /** + * @return FormInterface + */ + protected function createForm(string $type, mixed $data = null, array $options = []): FormInterface + { + if (is_array($this->form)) { + return $this->form[$type]; + } + + return $this->form; + } + + /** + * @param array $parameters + */ + protected function redirectToRoute(string $route, array $parameters = [], int $status = 302): RedirectResponse + { + return new RedirectResponse('/'.$route, $status); + } + + /** + * @param array $parameters + */ + protected function generateUrl(string $route, array $parameters = [], int $referenceType = 1): string + { + return '/'.$route; + } + + protected function addFlash(string $type, mixed $message): void + { + // no-op: avoids requiring a full service container in these unit tests + } + + /** + * @param array $parameters + */ + protected function render(string $view, array $parameters = [], ?Response $response = null): Response + { + $this->renderedView = $view; + $this->renderedParameters = $parameters; + + return new Response('ok'); + } +} diff --git a/tests/Controller/Api/AccommodationBookingControllerTest.php b/tests/Controller/Api/AccommodationBookingControllerTest.php new file mode 100644 index 00000000..55256fb2 --- /dev/null +++ b/tests/Controller/Api/AccommodationBookingControllerTest.php @@ -0,0 +1,296 @@ +setName('Berghotel'); + $accommodation->setCalendarCode('CAL123'); + $accommodation->setCmsCode('CMS456'); + $accommodation->setMaxAdolescentAge(15); + $accommodation->setCurrency('EUR'); + + $booking = new AccommodationBooking(); + $booking->setAccommodation($accommodation); + $booking->setDateFrom(new \DateTimeImmutable('2026-07-20')); + $booking->setDateTo(new \DateTimeImmutable('2026-07-25')); + $booking->setPaxCount(40); + $booking->setMinorsCount(2); + $booking->setChildrenCount(1); + $booking->setGroupName('Schulklasse 7b'); + $booking->setSalutation('Frau'); + $booking->setFirstName('Mia'); + $booking->setLastName('Muster'); + $booking->setEmail('mia@example.com'); + $booking->setPhone('+49123456789'); + $booking->setStreet('Musterstr. 1'); + $booking->setZip('12345'); + $booking->setCity('Musterstadt'); + $booking->setRemarks('Bitte am Bahnhof abholen'); + $booking->setBoardServiceLabel('Halbpension'); + $booking->setBoardServicePrice(1500); + $booking->addAdditionalServiceSnapshot('Bettwäsche', 500, 'flat', 3); + $booking->setDiscount(1000); + $booking->setIsInquiry(false); + $booking->setAcceptedAt(new \DateTimeImmutable('2026-07-15T10:00:00+00:00')); + + $bookingRepository = $this->createMock(AccommodationBookingRepository::class); + $bookingRepository + ->expects(self::once()) + ->method('findOneBy') + ->with(['uuid' => $booking->getUuid()]) + ->willReturn($booking); + + $breakdown = ['total' => 12345, 'currency' => 'EUR']; + $breakdownCalculator = $this->createMock(AccommodationBookingBreakdownCalculator::class); + $breakdownCalculator + ->expects(self::once()) + ->method('compute') + ->with($booking) + ->willReturn($breakdown); + + $bookingService = $this->createMock(AccommodationBookingService::class); + $bookingService->expects(self::never())->method('acceptBooking'); + + $controller = new AccommodationBookingController($bookingRepository, $breakdownCalculator, $this->serializer(), $bookingService); + + $response = $controller->single($booking->getUuid()); + $payload = json_decode((string) $response->getContent(), true, 512, JSON_THROW_ON_ERROR); + + self::assertSame(Response::HTTP_OK, $response->getStatusCode()); + self::assertSame([ + 'uuid' => $booking->getUuid(), + 'status' => 'booking', + 'dateFrom' => '2026-07-20', + 'dateTo' => '2026-07-25', + 'nights' => 5, + 'paxCount' => 40, + 'minorsCount' => 2, + 'childrenCount' => 1, + 'groupName' => 'Schulklasse 7b', + 'acceptedAt' => '2026-07-15T10:00:00+00:00', + 'personalData' => [ + 'salutation' => 'Frau', + 'firstName' => 'Mia', + 'lastName' => 'Muster', + 'email' => 'mia@example.com', + 'phone' => '+49123456789', + 'street' => 'Musterstr. 1', + 'zip' => '12345', + 'city' => 'Musterstadt', + 'remarks' => 'Bitte am Bahnhof abholen', + ], + 'accommodation' => [ + 'calendarCode' => 'CAL123', + 'cmsCode' => 'CMS456', + ], + 'boardService' => [ + 'label' => 'Halbpension', + 'price' => 1500, + ], + 'additionalServices' => [ + [ + 'label' => 'Bettwäsche', + 'price' => 500, + 'type' => 'flat', + ], + ], + 'discount' => 1000, + 'priceBreakdown' => $breakdown, + ], $payload); + + // CMS-only accommodation fields must not leak into the response. + self::assertArrayNotHasKey('name', $payload['accommodation']); + self::assertArrayNotHasKey('maxAdolescentAge', $payload['accommodation']); + self::assertArrayNotHasKey('currency', $payload['accommodation']); + } + + public function testSingleReturnsInquiryStatusWhenBookingIsInquiry(): void + { + $booking = new AccommodationBooking(); + $booking->setDateFrom(new \DateTimeImmutable('2026-08-01')); + $booking->setDateTo(new \DateTimeImmutable('2026-08-02')); + $booking->setGroupName('Verein e.V.'); + $booking->setFirstName('Tom'); + $booking->setLastName('Beispiel'); + $booking->setEmail('tom@example.com'); + $booking->setIsInquiry(true); + + $bookingRepository = $this->createMock(AccommodationBookingRepository::class); + $bookingRepository + ->method('findOneBy') + ->with(['uuid' => $booking->getUuid()]) + ->willReturn($booking); + + $breakdownCalculator = $this->createMock(AccommodationBookingBreakdownCalculator::class); + $breakdownCalculator->method('compute')->willReturn(null); + + $controller = new AccommodationBookingController($bookingRepository, $breakdownCalculator, $this->serializer(), $this->createMock(AccommodationBookingService::class)); + + $response = $controller->single($booking->getUuid()); + $payload = json_decode((string) $response->getContent(), true, 512, JSON_THROW_ON_ERROR); + + self::assertSame('inquiry', $payload['status']); + self::assertNull($payload['priceBreakdown']); + self::assertNull($payload['acceptedAt']); + } + + public function testSingleReturnsNotFoundForUnknownUuid(): void + { + $bookingRepository = $this->createMock(AccommodationBookingRepository::class); + $bookingRepository + ->expects(self::once()) + ->method('findOneBy') + ->with(['uuid' => 'unknown-uuid']) + ->willReturn(null); + + $breakdownCalculator = $this->createMock(AccommodationBookingBreakdownCalculator::class); + $breakdownCalculator->expects(self::never())->method('compute'); + + $controller = new AccommodationBookingController($bookingRepository, $breakdownCalculator, $this->serializer(), $this->createMock(AccommodationBookingService::class)); + + $response = $controller->single('unknown-uuid'); + $payload = json_decode((string) $response->getContent(), true, 512, JSON_THROW_ON_ERROR); + + self::assertSame(Response::HTTP_NOT_FOUND, $response->getStatusCode()); + self::assertSame(['message' => 'Not found'], $payload); + } + + public function testAcceptTransitionsInquiryToBookingAndSetsAcceptedAt(): void + { + $booking = new AccommodationBooking(); + $booking->setDateFrom(new \DateTimeImmutable('2026-08-01')); + $booking->setDateTo(new \DateTimeImmutable('2026-08-02')); + $booking->setGroupName('Verein e.V.'); + $booking->setFirstName('Tom'); + $booking->setLastName('Beispiel'); + $booking->setEmail('tom@example.com'); + $booking->setIsInquiry(true); + + $bookingRepository = $this->createMock(AccommodationBookingRepository::class); + $bookingRepository + ->expects(self::once()) + ->method('findOneBy') + ->with(['uuid' => $booking->getUuid()]) + ->willReturn($booking); + + $breakdownCalculator = $this->createMock(AccommodationBookingBreakdownCalculator::class); + $breakdownCalculator->method('compute')->willReturn(null); + + $bookingService = $this->createMock(AccommodationBookingService::class); + $bookingService + ->expects(self::once()) + ->method('acceptBooking') + ->with($booking) + ->willReturnCallback(static function (AccommodationBooking $b): void { + $b->setIsInquiry(false); + $b->setAcceptedAt(new \DateTimeImmutable()); + }); + + $controller = new AccommodationBookingController($bookingRepository, $breakdownCalculator, $this->serializer(), $bookingService); + + $response = $controller->accept($booking->getUuid()); + $payload = json_decode((string) $response->getContent(), true, 512, JSON_THROW_ON_ERROR); + + self::assertSame(Response::HTTP_OK, $response->getStatusCode()); + self::assertSame('booking', $payload['status']); + self::assertFalse($booking->isInquiry()); + self::assertNotNull($booking->getAcceptedAt()); + self::assertSame($booking->getAcceptedAt()->format(\DATE_ATOM), $payload['acceptedAt']); + } + + public function testAcceptIsIdempotentWhenBookingAlreadyAccepted(): void + { + $acceptedAt = new \DateTimeImmutable('2026-07-15T10:00:00+00:00'); + + $booking = new AccommodationBooking(); + $booking->setDateFrom(new \DateTimeImmutable('2026-08-01')); + $booking->setDateTo(new \DateTimeImmutable('2026-08-02')); + $booking->setGroupName('Verein e.V.'); + $booking->setFirstName('Tom'); + $booking->setLastName('Beispiel'); + $booking->setEmail('tom@example.com'); + $booking->setIsInquiry(false); + $booking->setAcceptedAt($acceptedAt); + + $bookingRepository = $this->createMock(AccommodationBookingRepository::class); + $bookingRepository + ->method('findOneBy') + ->with(['uuid' => $booking->getUuid()]) + ->willReturn($booking); + + $breakdownCalculator = $this->createMock(AccommodationBookingBreakdownCalculator::class); + $breakdownCalculator->method('compute')->willReturn(null); + + $bookingService = $this->createMock(AccommodationBookingService::class); + $bookingService->expects(self::once())->method('acceptBooking')->with($booking); + + $controller = new AccommodationBookingController($bookingRepository, $breakdownCalculator, $this->serializer(), $bookingService); + + $response = $controller->accept($booking->getUuid()); + $payload = json_decode((string) $response->getContent(), true, 512, JSON_THROW_ON_ERROR); + + self::assertSame(Response::HTTP_OK, $response->getStatusCode()); + self::assertSame('booking', $payload['status']); + self::assertSame($acceptedAt, $booking->getAcceptedAt()); + self::assertSame($acceptedAt->format(\DATE_ATOM), $payload['acceptedAt']); + } + + public function testAcceptReturnsNotFoundForUnknownUuid(): void + { + $bookingRepository = $this->createMock(AccommodationBookingRepository::class); + $bookingRepository + ->expects(self::once()) + ->method('findOneBy') + ->with(['uuid' => 'unknown-uuid']) + ->willReturn(null); + + $breakdownCalculator = $this->createMock(AccommodationBookingBreakdownCalculator::class); + $breakdownCalculator->expects(self::never())->method('compute'); + + $bookingService = $this->createMock(AccommodationBookingService::class); + $bookingService->expects(self::never())->method('acceptBooking'); + + $controller = new AccommodationBookingController($bookingRepository, $breakdownCalculator, $this->serializer(), $bookingService); + + $response = $controller->accept('unknown-uuid'); + $payload = json_decode((string) $response->getContent(), true, 512, JSON_THROW_ON_ERROR); + + self::assertSame(Response::HTTP_NOT_FOUND, $response->getStatusCode()); + self::assertSame(['message' => 'Not found'], $payload); + } + + private function serializer(): Serializer + { + $classMetadataFactory = new ClassMetadataFactory(new AttributeLoader()); + + return new Serializer( + [ + new DateTimeNormalizer(), + new ObjectNormalizer($classMetadataFactory, propertyTypeExtractor: new ReflectionExtractor()), + ], + [new JsonEncoder()], + ); + } +} diff --git a/tests/Controller/Api/ContingentControllerTest.php b/tests/Controller/Api/ContingentControllerTest.php new file mode 100644 index 00000000..17c6a471 --- /dev/null +++ b/tests/Controller/Api/ContingentControllerTest.php @@ -0,0 +1,43 @@ +createMock(ContingentsClient::class), + $this->createMock(AccommodationRepository::class), + $this->createMock(AccommodationPriceRepository::class), + $this->createMock(CacheInterface::class), + new PriceTimelineBuilder(), + ); + + $price = new AccommodationPrice(); + $price->setDateFrom(new \DateTimeImmutable('2026-07-01')); + $price->setDateTo(new \DateTimeImmutable('2026-07-31')); + $price->setIncludedPax(4); + $price->setPricePerNight(12345); + $price->setPriceAdditionalPerson(1500); + $price->setMinNights(3); + + $method = new \ReflectionMethod($controller, 'enrichEntry'); + + $result = $method->invoke($controller, '2026-07-06', 'available', [$price], 'EUR'); + + self::assertSame(4, $result['includedPax']); + self::assertSame(4, $result['minPax']); + } +} diff --git a/tests/Controller/Groups/OfferControllerTest.php b/tests/Controller/Groups/OfferControllerTest.php new file mode 100644 index 00000000..dd00b8a5 --- /dev/null +++ b/tests/Controller/Groups/OfferControllerTest.php @@ -0,0 +1,161 @@ +setIsInquiry(true); + + $bookingRepository = $this->createMock(AccommodationBookingRepository::class); + $bookingRepository->method('findOneBy')->with(['uuid' => $booking->getUuid()])->willReturn($booking); + + $linkSigner = $this->createMock(AccommodationBookingLinkSigner::class); + $linkSigner->method('isValidLinkRequest')->willReturn(true); + + $breakdownCalculator = $this->createMock(AccommodationBookingBreakdownCalculator::class); + $breakdownCalculator->method('compute')->with($booking)->willReturn(['total' => 1000]); + + $bookingService = $this->createMock(AccommodationBookingService::class); + $bookingService->expects(self::never())->method('acceptBooking'); + + $twig = $this->createMock(Environment::class); + $twig + ->expects(self::once()) + ->method('render') + ->with('groups/booking/offer.html.twig', ['booking' => $booking, 'priceBreakdown' => ['total' => 1000]]) + ->willReturn('offer'); + + $controller = new OfferController($bookingRepository, $linkSigner, $breakdownCalculator, $bookingService, $twig); + + $response = $controller->index($booking->getUuid(), Request::create('/groups/booking/offer/'.$booking->getUuid())); + + self::assertSame(Response::HTTP_OK, $response->getStatusCode()); + self::assertSame('offer', $response->getContent()); + } + + public function testPostAcceptsInquiryAndRedirectsToSameUrl(): void + { + $booking = new AccommodationBooking(); + $booking->setIsInquiry(true); + + $bookingRepository = $this->createMock(AccommodationBookingRepository::class); + $bookingRepository->method('findOneBy')->willReturn($booking); + + $linkSigner = $this->createMock(AccommodationBookingLinkSigner::class); + $linkSigner->method('isValidLinkRequest')->willReturn(true); + + $bookingService = $this->createMock(AccommodationBookingService::class); + $bookingService->expects(self::once())->method('acceptBooking')->with($booking); + + $controller = new OfferController( + $bookingRepository, + $linkSigner, + $this->createMock(AccommodationBookingBreakdownCalculator::class), + $bookingService, + $this->createMock(Environment::class), + ); + + $request = Request::create('/groups/booking/offer/'.$booking->getUuid().'?t=123&_hash=abc', 'POST'); + $response = $controller->index($booking->getUuid(), $request); + + self::assertSame(Response::HTTP_FOUND, $response->getStatusCode()); + self::assertSame($request->getUri(), $response->headers->get('Location')); + } + + public function testGetRendersConfirmationWithoutAcceptFormForBooking(): void + { + $booking = new AccommodationBooking(); + $booking->setIsInquiry(false); + + $bookingRepository = $this->createMock(AccommodationBookingRepository::class); + $bookingRepository->method('findOneBy')->willReturn($booking); + + $linkSigner = $this->createMock(AccommodationBookingLinkSigner::class); + $linkSigner->method('isValidLinkRequest')->willReturn(true); + + $bookingService = $this->createMock(AccommodationBookingService::class); + $bookingService->expects(self::never())->method('acceptBooking'); + + $twig = $this->createMock(Environment::class); + $twig->method('render')->with('groups/booking/offer.html.twig', self::anything())->willReturn('confirmed'); + + $controller = new OfferController( + $bookingRepository, + $linkSigner, + $this->createMock(AccommodationBookingBreakdownCalculator::class), + $bookingService, + $twig, + ); + + // A POST on an already-accepted booking should not trigger acceptBooking again — falls through to render. + $response = $controller->index($booking->getUuid(), Request::create('/groups/booking/offer/'.$booking->getUuid(), 'POST')); + + self::assertSame(Response::HTTP_OK, $response->getStatusCode()); + } + + public function testInvalidLinkReturnsUnavailableResponse(): void + { + $booking = new AccommodationBooking(); + + $bookingRepository = $this->createMock(AccommodationBookingRepository::class); + $bookingRepository->method('findOneBy')->willReturn($booking); + + $linkSigner = $this->createMock(AccommodationBookingLinkSigner::class); + $linkSigner->method('isValidLinkRequest')->willReturn(false); + + $twig = $this->createMock(Environment::class); + $twig->method('render')->with('groups/booking/offer_unavailable.html.twig')->willReturn('unavailable'); + + $controller = new OfferController( + $bookingRepository, + $linkSigner, + $this->createMock(AccommodationBookingBreakdownCalculator::class), + $this->createMock(AccommodationBookingService::class), + $twig, + ); + + $response = $controller->index($booking->getUuid(), Request::create('/groups/booking/offer/'.$booking->getUuid())); + + self::assertSame(Response::HTTP_NOT_FOUND, $response->getStatusCode()); + } + + public function testUnknownUuidReturnsUnavailableResponse(): void + { + $bookingRepository = $this->createMock(AccommodationBookingRepository::class); + $bookingRepository->method('findOneBy')->willReturn(null); + + $linkSigner = $this->createMock(AccommodationBookingLinkSigner::class); + $linkSigner->expects(self::never())->method('isValidLinkRequest'); + + $twig = $this->createMock(Environment::class); + $twig->method('render')->willReturn('unavailable'); + + $controller = new OfferController( + $bookingRepository, + $linkSigner, + $this->createMock(AccommodationBookingBreakdownCalculator::class), + $this->createMock(AccommodationBookingService::class), + $twig, + ); + + $response = $controller->index('unknown-uuid', Request::create('/groups/booking/offer/unknown-uuid')); + + self::assertSame(Response::HTTP_NOT_FOUND, $response->getStatusCode()); + } +} diff --git a/tests/Form/AccommodationStep2TypeTest.php b/tests/Form/AccommodationStep2TypeTest.php new file mode 100644 index 00000000..ea3485d8 --- /dev/null +++ b/tests/Form/AccommodationStep2TypeTest.php @@ -0,0 +1,74 @@ +getFormFactory() + ->create(AccommodationStep2Type::class, new AccommodationBookingDto(), [ + 'max_adolescent_age' => 15, + ]); + + self::assertSame( + 'davon Kinder (4–15 Jahre)', + $form->get('childrenCount')->getConfig()->getOption('label'), + ); + } + + public function testBlankOptionalChildCountersAreNormalizedToZero(): void + { + $dto = new AccommodationBookingDto(); + $form = Forms::createFormFactoryBuilder() + ->getFormFactory() + ->create(AccommodationStep2Type::class, $dto); + + $form->submit([ + 'paxCount' => '2', + 'minorsCount' => '', + 'childrenCount' => '', + 'selectedBoardServiceId' => '', + 'selectedAdditionalServiceIds' => [], + ]); + + self::assertTrue($form->isSynchronized()); + self::assertSame(0, $dto->minorsCount); + self::assertSame(0, $dto->childrenCount); + } + + public function testGroupedAdditionalServiceValuesCanSubmitUnderSelectionGroupKeys(): void + { + $dto = new AccommodationBookingDto(); + $form = Forms::createFormFactoryBuilder() + ->getFormFactory() + ->create(AccommodationStep2Type::class, $dto, [ + 'additional_service_choices' => [ + 'Ski service' => 101, + 'Board service' => 202, + ], + ]); + + $form->submit([ + 'paxCount' => '2', + 'minorsCount' => '0', + 'childrenCount' => '0', + 'selectedBoardServiceId' => '', + 'selectedAdditionalServiceIds' => [ + 'Ski extras' => '101', + 'Board extras' => '202', + ], + ]); + + self::assertTrue($form->isSynchronized()); + self::assertSame([101, 202], array_values($dto->selectedAdditionalServiceIds)); + } +} diff --git a/tests/Service/AccommodationBookingLinkSignerTest.php b/tests/Service/AccommodationBookingLinkSignerTest.php new file mode 100644 index 00000000..b0b599b5 --- /dev/null +++ b/tests/Service/AccommodationBookingLinkSignerTest.php @@ -0,0 +1,119 @@ +bookingWithAccessLink(); + $signer = $this->createSigner(); + + $signedUrl = $signer->sign($booking); + $request = Request::create($signedUrl); + + self::assertTrue($signer->isValidLinkRequest($request, $booking)); + } + + public function testTamperedQueryParamFails(): void + { + $booking = $this->bookingWithAccessLink(); + $signer = $this->createSigner(); + + $signedUrl = $signer->sign($booking); + self::assertTrue($signer->isValidLinkRequest(Request::create($signedUrl), $booking)); + + // Flip the `t` value while keeping the original _hash — signature no longer matches. + $tamperedUrl = preg_replace('/(?<=[?&]t=)\d+/', '999999999', $signedUrl); + self::assertNotNull($tamperedUrl); + + self::assertFalse($signer->isValidLinkRequest(Request::create($tamperedUrl), $booking)); + } + + public function testRegeneratedLinkInvalidatesThePreviousOne(): void + { + $booking = $this->bookingWithAccessLink(); + $signer = $this->createSigner(); + + $signedUrl = $signer->sign($booking); + $request = Request::create($signedUrl); + + // Regenerating overwrites accessLinkIssuedAt — the old signed `t` no longer matches. + $booking->setAccessLinkIssuedAt(new \DateTimeImmutable('+1 minute')); + + self::assertFalse($signer->isValidLinkRequest($request, $booking)); + } + + public function testExpiredLinkFails(): void + { + $booking = new AccommodationBooking(); + $booking->setAccessLinkIssuedAt(new \DateTimeImmutable('-91 days')); + $signer = $this->createSigner(); + + $signedUrl = $signer->sign($booking); + $request = Request::create($signedUrl); + + self::assertFalse($signer->isValidLinkRequest($request, $booking)); + } + + public function testMissingAccessLinkIssuedAtFails(): void + { + $booking = new AccommodationBooking(); + $signer = $this->createSigner(); + + $request = Request::create('https://example.com/groups/booking/offer/'.$booking->getUuid().'?t=123'); + + self::assertFalse($signer->isValidLinkRequest($request, $booking)); + } + + public function testSignThrowsWithoutAccessLinkIssuedAt(): void + { + $booking = new AccommodationBooking(); + $signer = $this->createSigner(); + + $this->expectException(\LogicException::class); + + $signer->sign($booking); + } + + public function testExpiresAtIsNinetyDaysAfterIssuedAt(): void + { + $issuedAt = new \DateTimeImmutable('2026-01-01T00:00:00+00:00'); + $booking = new AccommodationBooking(); + $booking->setAccessLinkIssuedAt($issuedAt); + $signer = $this->createSigner(); + + self::assertSame('2026-04-01T00:00:00+00:00', $signer->expiresAt($booking)?->format(\DATE_ATOM)); + } + + private function bookingWithAccessLink(): AccommodationBooking + { + $booking = new AccommodationBooking(); + $booking->setPaxCount(10); + $booking->setAccessLinkIssuedAt(new \DateTimeImmutable()); + + return $booking; + } + + private function createSigner(): AccommodationBookingLinkSigner + { + $urlGenerator = $this->createMock(UrlGeneratorInterface::class); + $urlGenerator + ->method('generate') + ->willReturnCallback(static fn (string $name, array $parameters) => sprintf( + 'https://example.com/groups/booking/offer/%s?t=%s', + $parameters['uuid'], + $parameters['t'], + )); + + return new AccommodationBookingLinkSigner($urlGenerator, 'test-secret'); + } +} diff --git a/tests/Service/AccommodationBookingServiceTest.php b/tests/Service/AccommodationBookingServiceTest.php new file mode 100644 index 00000000..84ed3e45 --- /dev/null +++ b/tests/Service/AccommodationBookingServiceTest.php @@ -0,0 +1,279 @@ +createServiceWithAccommodation(); + + $dto = $service->initFromParams(new AccommodationBookingQueryParams('HOTEL', '2026-02-28', '2026-03-01')); + + self::assertSame('2026-02-28', $dto->dateFrom?->format('Y-m-d')); + self::assertSame('2026-03-01', $dto->dateTo?->format('Y-m-d')); + } + + public function testInitFromParamsPrefillsPaxCountFromEffectiveMinPax(): void + { + $price = (new AccommodationPrice()) + ->setDateFrom(new \DateTimeImmutable('2026-02-01')) + ->setDateTo(new \DateTimeImmutable('2026-03-31')) + ->setIncludedPax(4); + $service = $this->createServiceWithAccommodation([$price]); + + $dto = $service->initFromParams(new AccommodationBookingQueryParams('HOTEL', '2026-02-28', '2026-03-01')); + + self::assertSame(4, $dto->paxCount); + } + + public function testInitFromParamsRejectsNormalizedInvalidCalendarDates(): void + { + $service = $this->createServiceWithAccommodation(); + + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('Ungültiges Datumsformat. Erwartet: YYYY-MM-DD.'); + + $service->initFromParams(new AccommodationBookingQueryParams('HOTEL', '2026-02-31', '2026-03-05')); + } + + public function testIssueAccessLinkForDirectBookingSetsTimestampForDirectBooking(): void + { + $entityManager = $this->createMock(EntityManagerInterface::class); + $entityManager->expects(self::once())->method('flush'); + + $service = $this->createServiceWithAccommodation(entityManager: $entityManager); + + $booking = new AccommodationBooking(); + $booking->setIsInquiry(false); + + $service->issueAccessLinkForDirectBooking($booking); + + self::assertNotNull($booking->getAccessLinkIssuedAt()); + } + + public function testIssueAccessLinkForDirectBookingNoOpsForInquiry(): void + { + $entityManager = $this->createMock(EntityManagerInterface::class); + $entityManager->expects(self::never())->method('flush'); + + $service = $this->createServiceWithAccommodation(entityManager: $entityManager); + + $booking = new AccommodationBooking(); + $booking->setIsInquiry(true); + + $service->issueAccessLinkForDirectBooking($booking); + + self::assertNull($booking->getAccessLinkIssuedAt()); + } + + public function testIssueAccessLinkForDirectBookingNoOpsWhenAlreadySet(): void + { + $entityManager = $this->createMock(EntityManagerInterface::class); + $entityManager->expects(self::never())->method('flush'); + + $service = $this->createServiceWithAccommodation(entityManager: $entityManager); + + $booking = new AccommodationBooking(); + $booking->setIsInquiry(false); + $issuedAt = new \DateTimeImmutable('2026-01-01'); + $booking->setAccessLinkIssuedAt($issuedAt); + + $service->issueAccessLinkForDirectBooking($booking); + + self::assertSame($issuedAt, $booking->getAccessLinkIssuedAt()); + } + + public function testSendCustomerConfirmationEmailAlwaysSendsWithLinkWhenIssued(): void + { + $booking = new AccommodationBooking(); + $booking->setEmail('customer@example.com'); + $booking->setIsInquiry(false); + $booking->setAccessLinkIssuedAt(new \DateTimeImmutable()); + + $linkSigner = $this->createMock(AccommodationBookingLinkSigner::class); + $linkSigner->method('sign')->with($booking)->willReturn('https://example.com/offer/signed-link'); + + $breakdownCalculator = $this->createMock(AccommodationBookingBreakdownCalculator::class); + $breakdownCalculator->method('compute')->willReturn(['total' => 1000, 'currency' => 'EUR']); + + $mailer = $this->createMock(Mailer::class); + $mailer + ->expects(self::once()) + ->method('createAndSendEmail') + ->with( + self::callback(static fn (array $context) => 'https://example.com/offer/signed-link' === $context['accessLink']), + self::callback(static fn (array $options) => 'customer@example.com' === $options['to'] + && 'email/accommodation_booking_customer.html.twig' === $options['template']), + ); + + $service = $this->createServiceWithAccommodation(mailer: $mailer, linkSigner: $linkSigner, breakdownCalculator: $breakdownCalculator); + + $service->sendCustomerConfirmationEmail($booking); + } + + public function testSendCustomerConfirmationEmailSendsWithoutLinkForInquiry(): void + { + $booking = new AccommodationBooking(); + $booking->setEmail('customer@example.com'); + $booking->setIsInquiry(true); + + $breakdownCalculator = $this->createMock(AccommodationBookingBreakdownCalculator::class); + $breakdownCalculator->method('compute')->willReturn(null); + + $mailer = $this->createMock(Mailer::class); + $mailer + ->expects(self::once()) + ->method('createAndSendEmail') + ->with( + self::callback(static fn (array $context) => null === $context['accessLink']), + self::anything(), + ); + + $service = $this->createServiceWithAccommodation(mailer: $mailer, breakdownCalculator: $breakdownCalculator); + + $service->sendCustomerConfirmationEmail($booking); + } + + public function testSendCustomerConfirmationEmailLogsAndSwallowsMailerFailures(): void + { + $booking = new AccommodationBooking(); + $booking->setEmail('customer@example.com'); + $booking->setIsInquiry(true); + + $breakdownCalculator = $this->createMock(AccommodationBookingBreakdownCalculator::class); + $breakdownCalculator->method('compute')->willReturn(null); + + $mailer = $this->createMock(Mailer::class); + $mailer->method('createAndSendEmail')->willThrowException(new \RuntimeException('SMTP down')); + + $logger = $this->createMock(LoggerInterface::class); + $logger->expects(self::once())->method('error'); + + $service = $this->createServiceWithAccommodation(mailer: $mailer, breakdownCalculator: $breakdownCalculator, logger: $logger); + + $service->sendCustomerConfirmationEmail($booking); + } + + public function testRegenerateAccessLinkAndNotifyCustomerAlwaysOverwritesAndSends(): void + { + $booking = new AccommodationBooking(); + $booking->setEmail('customer@example.com'); + $booking->setIsInquiry(true); + $previousIssuedAt = new \DateTimeImmutable('2026-01-01'); + $booking->setAccessLinkIssuedAt($previousIssuedAt); + + $entityManager = $this->createMock(EntityManagerInterface::class); + $entityManager->expects(self::once())->method('flush'); + + $breakdownCalculator = $this->createMock(AccommodationBookingBreakdownCalculator::class); + $breakdownCalculator->method('compute')->willReturn(null); + + $mailer = $this->createMock(Mailer::class); + $mailer->expects(self::once())->method('createAndSendEmail'); + + $service = $this->createServiceWithAccommodation( + entityManager: $entityManager, + mailer: $mailer, + breakdownCalculator: $breakdownCalculator, + ); + + $service->regenerateAccessLinkAndNotifyCustomer($booking); + + self::assertNotSame($previousIssuedAt, $booking->getAccessLinkIssuedAt()); + } + + public function testAcceptBookingTransitionsInquiryToBooking(): void + { + $entityManager = $this->createMock(EntityManagerInterface::class); + $entityManager->expects(self::once())->method('flush'); + + $service = $this->createServiceWithAccommodation(entityManager: $entityManager); + + $booking = new AccommodationBooking(); + $booking->setIsInquiry(true); + + $service->acceptBooking($booking); + + self::assertFalse($booking->isInquiry()); + self::assertNotNull($booking->getAcceptedAt()); + } + + public function testAcceptBookingIsIdempotent(): void + { + $entityManager = $this->createMock(EntityManagerInterface::class); + $entityManager->expects(self::never())->method('flush'); + + $service = $this->createServiceWithAccommodation(entityManager: $entityManager); + + $booking = new AccommodationBooking(); + $booking->setIsInquiry(false); + + $service->acceptBooking($booking); + + self::assertNull($booking->getAcceptedAt()); + } + + /** + * @param AccommodationPrice[] $prices + */ + private function createServiceWithAccommodation( + array $prices = [], + ?EntityManagerInterface $entityManager = null, + ?Mailer $mailer = null, + ?LoggerInterface $logger = null, + ?AccommodationBookingLinkSigner $linkSigner = null, + ?AccommodationBookingBreakdownCalculator $breakdownCalculator = null, + ): AccommodationBookingService { + $accommodation = (new Accommodation()) + ->setName('Hotel') + ->setCalendarCode('HOTEL') + ->setMaxAdolescentAge(17); + + $accommodationRepo = $this->createMock(AccommodationRepository::class); + $accommodationRepo + ->method('findOneBy') + ->with(['calendarCode' => 'HOTEL']) + ->willReturn($accommodation); + + $priceRepo = $this->createMock(AccommodationPriceRepository::class); + $priceRepo + ->method('findByHotelCodeAndDateRange') + ->willReturn($prices); + + return new AccommodationBookingService( + $accommodationRepo, + $priceRepo, + $this->createMock(AdditionalServiceRepository::class), + $this->createMock(BoardServiceRepository::class), + new PriceTimelineBuilder(), + $entityManager ?? $this->createMock(EntityManagerInterface::class), + $mailer ?? $this->createMock(Mailer::class), + $logger ?? $this->createMock(LoggerInterface::class), + $this->createMock(CmsDataProvider::class), + $linkSigner ?? $this->createMock(AccommodationBookingLinkSigner::class), + $breakdownCalculator ?? $this->createMock(AccommodationBookingBreakdownCalculator::class), + 'office@example.com', + ); + } +}