diff --git a/.env b/.env index d6ea33e..0938e5a 100644 --- a/.env +++ b/.env @@ -48,6 +48,15 @@ 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= + APP_BPN_USER= APP_BPN_PASSWORD= APP_BPN_IP= @@ -72,20 +81,26 @@ APP_TRAVEL_PREFER_REMOTE=false APP_TRAVEL_ENABLE_FALLBACK=true APP_TRAVEL_SNAPSHOT_RETENTION_BUFFER_DAYS=14 -# Feature Flags -FEATURE_TRAVEL_SNAPSHOT=true - # 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) # Use 'O' during beta phase, switch to 'F' for production DEFAULT_BOOKING_STATUS=F -API_KEYS= - XML_EXPORT_PATH="%kernel.project_dir%/var/xmlexport" XML_EXPORT_CONTINGENTS_PATH="%kernel.project_dir%/var/xmlexportzimmer" JSON_EXPORT_PATH="%kernel.project_dir%/var/jsonexport" diff --git a/api.http b/api.http index 3131572..9a76f28 100644 --- a/api.http +++ b/api.http @@ -121,6 +121,19 @@ Authorization: Bearer {{$auth.token("oauth2_newsletter")}} "listIds": [10321569,10321382,10321383] } +### API contingent calendar +# @no-cookie-jar +GET {{base_url}}/api/contingents/calendar?hotelCode=DGS&dateFrom=2026-06-01&dateTo=2026-06-30 +Accept: application/json +Authorization: Bearer {{$auth.token("oauth2_api")}} + +### API contintent prices +# @no-cookie-jar +GET {{base_url}}/api/contingents/prices?hotelCode=DGS&year=2026 +Accept: application/json +Content-Type: application/json +Authorization: Bearer {{$auth.token("oauth2_api")}} + ### API pickups planning webhook # @no-cookie-jar POST {{base_url}}/api/pickups-planning @@ -136,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/admin.js b/assets/admin.js new file mode 100644 index 0000000..7ce65f0 --- /dev/null +++ b/assets/admin.js @@ -0,0 +1,8 @@ +import htmx from 'htmx.org' +htmx.config.includeIndicatorStyles = false +htmx.config.historyCacheSize = 0 +htmx.config.allowScriptTags = false + +import './bootstrap.js' + +import './styles/admin.css' diff --git a/assets/controllers/autocomplete_controller.js b/assets/controllers/autocomplete_controller.js new file mode 100644 index 0000000..d75d401 --- /dev/null +++ b/assets/controllers/autocomplete_controller.js @@ -0,0 +1,84 @@ +import { Controller } from '@hotwired/stimulus' +import { useFetch } from '../mixins/use_fetch' +import Autocomplete from '@trevoreyre/autocomplete-js' + +export default class extends Controller { + + static targets = [ 'input', 'field', 'resetButton', 'searchIcon' ] + + static values = { url: String, choices: Array, selected: Boolean } + + initialize() { + useFetch(this) + } + + connect() { + const searchHandler = input => { + return new Promise(resolve => { + // Check input minimum length + if (input.length < 2) { + return resolve([]) + } + + // Search choices if provided + if (this.choicesValue.length > 0) { + return resolve(this.choicesValue.filter(choice => { + return -1 !== choice.text.toLowerCase().search(input.toLowerCase()) + })) + } + + // Use API otherwise + let data = { search: input } + fetch(this.urlValue, { method: 'POST', body: JSON.stringify(data), credentials: 'include' }) + .then(this.checkStatus) + .then(this.parseJSON) + .then(data => { + resolve(data) + }) + }) + } + + const submitHandler = result => { + this.fieldTarget.value = result.value + this.selectedValue = true + this.element.dispatchEvent(new CustomEvent('select', { + detail: { result } + })) + } + + this.autocomplete = new Autocomplete(this.element, { + search: searchHandler, + getResultValue: result => result.text, + submitOnEnter: true, + onSubmit: submitHandler, + debounceTime: 250, + }) + + if (this.fieldTarget.value) { + this.selectedValue = true + } + + // prevent form submission when selecting entry in autocomplete list with enter key + this.element.addEventListener('keypress', e => { + if (e.key === 'Enter') { + e.preventDefault() + } + }) + } + + reset() { + this.inputTarget.value = null + this.fieldTarget.value = null + this.selectedValue = false + this.element.dispatchEvent(new CustomEvent('reset')) + } + + selectedValueChanged() { + this.resetButtonTarget.classList.toggle('hidden', false === this.selectedValue) + this.searchIconTarget.classList.toggle('hidden', this.selectedValue) + } + + disconnect() { + this.autocomplete.destroy() + } +} diff --git a/assets/controllers/clipboard_controller.js b/assets/controllers/clipboard_controller.js new file mode 100644 index 0000000..231035d --- /dev/null +++ b/assets/controllers/clipboard_controller.js @@ -0,0 +1,37 @@ +import { Controller } from "@hotwired/stimulus" + +export default class extends Controller { + + static values = { + content: String, + label: String, + } + + connect() { + this.originalLabel = this.element.innerText + } + + disconnect() { + clearTimeout(this.resetTimer) + } + + copy() { + navigator.clipboard.writeText(this.contentValue).then(() => { + this.dispatch('copied', { + detail: { + content: this.contentValue, + } + }) + }).catch((error) => { + console.error('Failed to copy to clipboard:', error) + }) + + if (this.hasLabelValue) { + this.element.innerText = this.labelValue + clearTimeout(this.resetTimer) + this.resetTimer = setTimeout(() => { + this.element.innerText = this.originalLabel + }, 3000) + } + } +} diff --git a/assets/controllers/datepicker_controller.js b/assets/controllers/datepicker_controller.js new file mode 100644 index 0000000..b123bf2 --- /dev/null +++ b/assets/controllers/datepicker_controller.js @@ -0,0 +1,70 @@ +import { Controller } from '@hotwired/stimulus' +import { Calendar } from 'vanilla-calendar-pro' + +/* stimulusFetch: 'lazy' */ +export default class extends Controller { + + static targets = ['field', 'hiddenField'] + static values = { + minDate: String, + maxDate: String, + disableWeekends: Boolean, + disabledDates: Array, + } + + connect() { + const options = { + inputMode: true, + locale: 'de-DE', + selectedTheme: 'light', + selectedDates: this.hiddenFieldTarget.value ? [this.hiddenFieldTarget.value] : [], + onClickDate: (self) => { + const iso = self.context.selectedDates[0] ?? null + this.hiddenFieldTarget.value = iso ?? '' + if (iso) { + const [year, month, day] = iso.split('-') + this.fieldTarget.value = `${day}.${month}.${year}` + } else { + this.fieldTarget.value = '' + } + this.element.dispatchEvent(new CustomEvent('datepicker:picked', { + detail: { date: this.fieldTarget.value } + })) + }, + onHide: () => { + this.element.dispatchEvent(new CustomEvent('datepicker:closed')) + }, + } + + if (this.minDateValue) options.dateMin = this.minDateValue + if (this.maxDateValue) options.dateMax = this.maxDateValue + if (this.disableWeekendsValue) options.disableWeekdays = [0, 6] + if (this.disabledDatesValue.length) options.disableDates = this.disabledDatesValue + + this.picker = new Calendar(this.fieldTarget, options) + this.picker.init() + + this.handleManualInput = () => { + const value = this.fieldTarget.value.trim() + if (!value) { + this.hiddenFieldTarget.value = '' + this.picker.set({ selectedDates: [] }, { dates: true }) + } else { + const match = value.match(/^(\d{1,2})\.(\d{1,2})\.(\d{4})$/) + if (match) { + const [, day, month, year] = match + const iso = `${year}-${month.padStart(2, '0')}-${day.padStart(2, '0')}` + this.hiddenFieldTarget.value = iso + this.picker.set({ selectedDates: [iso] }, { dates: true }) + } + } + this.element.dispatchEvent(new CustomEvent('datepicker:closed')) + } + this.fieldTarget.addEventListener('change', this.handleManualInput) + } + + disconnect() { + this.fieldTarget.removeEventListener('change', this.handleManualInput) + this.picker.destroy() + } +} diff --git a/assets/controllers/gallery_controller.js b/assets/controllers/gallery_controller.js new file mode 100644 index 0000000..8525658 --- /dev/null +++ b/assets/controllers/gallery_controller.js @@ -0,0 +1,29 @@ +import { Controller } from '@hotwired/stimulus' +import GLightbox from 'glightbox' + +/* stimulusFetch: 'lazy' */ +export default class extends Controller { + + static targets = ['item'] + + connect() { + this.lightbox = GLightbox({ + elements: this.itemTargets.map((el) => ({ + href: el.dataset.galleryHref, + type: 'image', + alt: el.dataset.galleryAlt ?? '', + })), + }) + + this.itemTargets.forEach((el, index) => { + el.addEventListener('click', (e) => { + e.preventDefault() + this.lightbox.openAt(index) + }) + }) + } + + disconnect() { + this.lightbox.destroy() + } +} diff --git a/assets/controllers/multiselect_controller.js b/assets/controllers/multiselect_controller.js new file mode 100644 index 0000000..a822d63 --- /dev/null +++ b/assets/controllers/multiselect_controller.js @@ -0,0 +1,36 @@ +import { Controller } from '@hotwired/stimulus' +import { useClickOutside } from 'stimulus-use' + +export default class extends Controller { + + static targets = [ 'label', 'dropdown', 'choice' ] + static values = { placeholder: String } + + connect() { + this.update() + useClickOutside(this) + } + + toggle() { + this.dropdownTarget.classList.toggle('hidden') + } + + update() { + let labels = [] + + this.choiceTargets.forEach((choice) => { + if (choice.checked) { + labels.push(choice.dataset.label) + } + this.labelTarget.innerText = labels.join(', ') + }) + + if (0 === labels.length) { + this.labelTarget.innerText = this.placeholderValue + } + } + + clickOutside(e) { + this.dropdownTarget.classList.toggle('hidden', true) + } +} \ No newline at end of file diff --git a/assets/controllers/price_calendar_controller.js b/assets/controllers/price_calendar_controller.js new file mode 100644 index 0000000..a94fddd --- /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/tooltip_controller.js b/assets/controllers/tooltip_controller.js index 9658041..d834da8 100644 --- a/assets/controllers/tooltip_controller.js +++ b/assets/controllers/tooltip_controller.js @@ -1,5 +1,5 @@ import { Controller } from '@hotwired/stimulus' -import tippy from 'tippy.js' +import tippy, { hideAll } from 'tippy.js' export default class extends Controller { static targets = ['trigger', 'template'] @@ -27,6 +27,7 @@ export default class extends Controller { allowHTML: true, interactive: true, appendTo: document.body, + onShow(instance) { hideAll({ exclude: instance }) }, } } if (this.hasTriggerTarget) { diff --git a/assets/images/bg_summer.jpg b/assets/images/bg_summer.jpg new file mode 100644 index 0000000..0854c79 Binary files /dev/null and b/assets/images/bg_summer.jpg differ diff --git a/assets/images/icons.svg b/assets/images/icons.svg index 6d39de6..0dbe931 100644 --- a/assets/images/icons.svg +++ b/assets/images/icons.svg @@ -1,208 +1,69 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + @@ -210,41 +71,24 @@ - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + diff --git a/assets/mixins/use_fetch.js b/assets/mixins/use_fetch.js new file mode 100644 index 0000000..81b3545 --- /dev/null +++ b/assets/mixins/use_fetch.js @@ -0,0 +1,29 @@ +export const useFetch = controller => { + Object.assign(controller, { + checkStatus(response) { + if (response.status >= 200 && response.status < 300) { + return response + } else { + const error = new Error(response.statusText) + error.response = response + throw error + } + }, + parseJSON(response) { + return response.json() + }, + parseHTML(response) { + return response.text() + }, + getInitObject(method = 'get', body = null) { + return { + method, + body, + credentials: 'include', + headers: { + 'X-Requested-With': 'XMLHttpRequest' + } + } + } + }) +} diff --git a/assets/styles/_base.css b/assets/styles/_base.css index 7d1f8e9..a91a522 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 062c292..4e0898f 100644 --- a/assets/styles/_components.css +++ b/assets/styles/_components.css @@ -1,7 +1,9 @@ @import "components/button.css"; -@import "components/datatable.css"; @import "components/forms.css"; +@import "components/gallery.css"; @import "components/pagination.css"; +@import "components/price_calendar.css"; @import "components/toast.css"; @import "components/tooltip.css"; @import "components/typography.css"; +@import "components/rte-content.css"; diff --git a/assets/styles/_components_admin.css b/assets/styles/_components_admin.css new file mode 100644 index 0000000..a1a098b --- /dev/null +++ b/assets/styles/_components_admin.css @@ -0,0 +1,10 @@ +@import "components/autocomplete.css"; +@import "components/badge.css"; +@import "components/button.css"; +@import "components/datatable.css"; +@import "components/datepicker.css"; +@import "components/forms.css"; +@import "components/pagination.css"; +@import "components/toast.css"; +@import "components/tooltip.css"; +@import "components/typography.css"; diff --git a/assets/styles/admin.css b/assets/styles/admin.css new file mode 100644 index 0000000..cff7915 --- /dev/null +++ b/assets/styles/admin.css @@ -0,0 +1,7 @@ +@import "tailwindcss/base"; +@import "_base.css"; + +@import "tailwindcss/components"; +@import "_components_admin.css"; + +@import "tailwindcss/utilities"; diff --git a/assets/styles/components/autocomplete.css b/assets/styles/components/autocomplete.css new file mode 100644 index 0000000..dbccfef --- /dev/null +++ b/assets/styles/components/autocomplete.css @@ -0,0 +1,33 @@ +.autocomplete { + @apply relative w-full; +} + +.autocomplete[data-loading="true"]:after { + @apply block absolute top-0 right-0; + @apply absolute w-4 h-4 rounded-full right-0 mr-4; + content: ""; + border: 3px solid rgba(0, 0, 0, 0.12); + border-right: 3px solid rgba(0, 0, 0, 0.48); + top: 50%; + transform: translateY(-50%); + animation: rotate 1s infinite linear; +} + +.autocomplete-icon { + @apply absolute top-1/2 right-0 transform -translate-y-1/2 mr-4; +} +.autocomplete[data-loading="true"] .autocomplete-icon { + @apply hidden; +} + +.autocomplete-result-list { + @apply bg-white shadow-md max-h-64 overflow-y-auto; +} + +.autocomplete-result { + @apply p-2; +} +.autocomplete-result:hover, +.autocomplete-result[aria-selected="true"] { + @apply bg-gray-200; +} diff --git a/assets/styles/components/badge.css b/assets/styles/components/badge.css new file mode 100644 index 0000000..0fe7b42 --- /dev/null +++ b/assets/styles/components/badge.css @@ -0,0 +1,3 @@ +.badge { + @apply inline-flex items-center rounded-md px-1.5 py-0.5 text-xs font-medium whitespace-nowrap; +} diff --git a/assets/styles/components/datepicker.css b/assets/styles/components/datepicker.css new file mode 100644 index 0000000..ce0a53c --- /dev/null +++ b/assets/styles/components/datepicker.css @@ -0,0 +1,7 @@ +@import "vanilla-calendar-pro/styles/index.css"; +@import "vanilla-calendar-pro/styles/themes/light.css"; + +[data-vc-theme=light] .vc-date[data-vc-date-selected] .vc-date__btn, +[data-vc-theme=light] .vc-date[data-vc-date-selected] .vc-date__btn:hover { + background-color: theme('colors.secondary'); +} diff --git a/assets/styles/components/gallery.css b/assets/styles/components/gallery.css new file mode 100644 index 0000000..bc0fd92 --- /dev/null +++ b/assets/styles/components/gallery.css @@ -0,0 +1 @@ +@import "glightbox/dist/css/glightbox.min.css"; diff --git a/assets/styles/components/price_calendar.css b/assets/styles/components/price_calendar.css new file mode 100644 index 0000000..fba8126 --- /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/assets/styles/components/rte-content.css b/assets/styles/components/rte-content.css new file mode 100644 index 0000000..9df8be9 --- /dev/null +++ b/assets/styles/components/rte-content.css @@ -0,0 +1,13 @@ +.rte-content { + p { + @apply pb-4; + } + + ul { + @apply list-disc p-4; + } + + ol { + @apply list-decimal p-4; + } +} diff --git a/composer.json b/composer.json index 14d8f40..24528ee 100644 --- a/composer.json +++ b/composer.json @@ -56,6 +56,7 @@ "symfony/translation": "6.4.*", "symfony/twig-bundle": "6.4.*", "symfony/uid": "6.4.*", + "symfony/ux-twig-component": "^2.36", "symfony/validator": "6.4.*", "symfony/web-link": "6.4.*", "symfony/webpack-encore-bundle": "^2.2", diff --git a/composer.lock b/composer.lock index 07f7361..779dbee 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "aa69dee5968ec6f7c3f152b8a8a6100d", + "content-hash": "f04202a445d42ebff0dd0e8dce335163", "packages": [ { "name": "carbonphp/carbon-doctrine-types", @@ -3793,16 +3793,16 @@ }, { "name": "phpseclib/phpseclib", - "version": "3.0.55", + "version": "3.0.56", "source": { "type": "git", "url": "https://github.com/phpseclib/phpseclib.git", - "reference": "db9744e6d47e742b1f974e965ad49bdd041105af" + "reference": "7adbbe38cde25e2df2116dbf2673c407e24fa305" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/phpseclib/phpseclib/zipball/db9744e6d47e742b1f974e965ad49bdd041105af", - "reference": "db9744e6d47e742b1f974e965ad49bdd041105af", + "url": "https://api.github.com/repos/phpseclib/phpseclib/zipball/7adbbe38cde25e2df2116dbf2673c407e24fa305", + "reference": "7adbbe38cde25e2df2116dbf2673c407e24fa305", "shasum": "" }, "require": { @@ -3883,7 +3883,7 @@ ], "support": { "issues": "https://github.com/phpseclib/phpseclib/issues", - "source": "https://github.com/phpseclib/phpseclib/tree/3.0.55" + "source": "https://github.com/phpseclib/phpseclib/tree/3.0.56" }, "funding": [ { @@ -3899,7 +3899,7 @@ "type": "tidelift" } ], - "time": "2026-06-14T23:24:10+00:00" + "time": "2026-08-03T04:36:50+00:00" }, { "name": "phpstan/phpdoc-parser", @@ -10080,6 +10080,95 @@ ], "time": "2025-12-23T15:07:59+00:00" }, + { + "name": "symfony/ux-twig-component", + "version": "v2.36.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/ux-twig-component.git", + "reference": "d64b068d8339e905cd48974bdd6e9ba54dc8f247" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/ux-twig-component/zipball/d64b068d8339e905cd48974bdd6e9ba54dc8f247", + "reference": "d64b068d8339e905cd48974bdd6e9ba54dc8f247", + "shasum": "" + }, + "require": { + "php": ">=8.1", + "symfony/dependency-injection": "^5.4|^6.0|^7.0|^8.0", + "symfony/deprecation-contracts": "^2.2|^3.0", + "symfony/event-dispatcher": "^5.4|^6.0|^7.0|^8.0", + "symfony/property-access": "^5.4|^6.0|^7.0|^8.0", + "twig/twig": "^3.10.3" + }, + "conflict": { + "symfony/config": "<5.4.0" + }, + "require-dev": { + "symfony/console": "^5.4|^6.0|^7.0|^8.0", + "symfony/css-selector": "^5.4|^6.0|^7.0|^8.0", + "symfony/dom-crawler": "^5.4|^6.0|^7.0|^8.0", + "symfony/framework-bundle": "^5.4|^6.0|^7.0|^8.0", + "symfony/phpunit-bridge": "^6.0|^7.0|^8.0", + "symfony/stimulus-bundle": "^2.9.1|^3.0", + "symfony/twig-bundle": "^5.4|^6.0|^7.0|^8.0", + "symfony/webpack-encore-bundle": "^1.15|^2.3.0", + "twig/extra-bundle": "^3.10.3", + "twig/html-extra": "^3.10.3" + }, + "type": "symfony-bundle", + "extra": { + "thanks": { + "url": "https://github.com/symfony/ux", + "name": "symfony/ux" + } + }, + "autoload": { + "psr-4": { + "Symfony\\UX\\TwigComponent\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Twig components for Symfony", + "homepage": "https://symfony.com", + "keywords": [ + "components", + "symfony-ux", + "twig" + ], + "support": { + "source": "https://github.com/symfony/ux-twig-component/tree/v2.36.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-04-03T05:13:59+00:00" + }, { "name": "symfony/validator", "version": "v6.4.43", @@ -13028,16 +13117,16 @@ }, { "name": "rector/rector", - "version": "2.5.9", + "version": "2.6.0", "source": { "type": "git", "url": "https://github.com/rectorphp/rector.git", - "reference": "858b1fb95d94f658b54c01080bf7b6ae97274536" + "reference": "24ef17c06737a6272806c5cca32b860449efe1ba" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/rectorphp/rector/zipball/858b1fb95d94f658b54c01080bf7b6ae97274536", - "reference": "858b1fb95d94f658b54c01080bf7b6ae97274536", + "url": "https://api.github.com/repos/rectorphp/rector/zipball/24ef17c06737a6272806c5cca32b860449efe1ba", + "reference": "24ef17c06737a6272806c5cca32b860449efe1ba", "shasum": "" }, "require": { @@ -13076,7 +13165,7 @@ ], "support": { "issues": "https://github.com/rectorphp/rector/issues", - "source": "https://github.com/rectorphp/rector/tree/2.5.9" + "source": "https://github.com/rectorphp/rector/tree/2.6.0" }, "funding": [ { @@ -13084,7 +13173,7 @@ "type": "github" } ], - "time": "2026-07-30T22:10:00+00:00" + "time": "2026-08-03T09:01:59+00:00" }, { "name": "sebastian/cli-parser", diff --git a/config/bundles.php b/config/bundles.php index de9164d..7bc0cb7 100644 --- a/config/bundles.php +++ b/config/bundles.php @@ -19,4 +19,5 @@ return [ Flagception\Bundle\FlagceptionBundle\FlagceptionBundle::class => ['all' => true], Knp\Bundle\MenuBundle\KnpMenuBundle::class => ['all' => true], Knp\Bundle\PaginatorBundle\KnpPaginatorBundle::class => ['all' => true], + Symfony\UX\TwigComponent\TwigComponentBundle::class => ['all' => true], ]; diff --git a/config/packages/security.yaml b/config/packages/security.yaml index a87c278..1386ffa 100644 --- a/config/packages/security.yaml +++ b/config/packages/security.yaml @@ -14,7 +14,10 @@ security: users: mailjet: password: '%env(MAILJET_WEBHOOK_BASIC_PASSWORD)%' - roles: ['ROLE_MAILJET_WEBHOOK'] + roles: [ ROLE_MAILJET_WEBHOOK ] + role_hierarchy: + ROLE_ADMIN: [ ROLE_GROUPS_ADMIN ] + ROLE_GROUPS_ADMIN: [ ROLE_GROUPS_MANAGER ] firewalls: dev: pattern: ^/(_(profiler|wdt)|css|images|js)/ @@ -49,7 +52,6 @@ security: access_control: - { path: ^/webhooks/mailjet/newsletter$, roles: ROLE_MAILJET_WEBHOOK, requires_channel: https } - { path: ^/authorize, roles: IS_AUTHENTICATED_REMEMBERED, requires_channel: https } - - { path: ^/admin, roles: ROLE_ADMIN, requires_channel: https } - { path: ^/, roles: PUBLIC_ACCESS, requires_channel: https } when@test: diff --git a/config/packages/twig_component.yaml b/config/packages/twig_component.yaml new file mode 100644 index 0000000..fd17ac6 --- /dev/null +++ b/config/packages/twig_component.yaml @@ -0,0 +1,5 @@ +twig_component: + anonymous_template_directory: 'components/' + defaults: + # Namespace & directory for components + App\Twig\Components\: 'components/' diff --git a/config/services.yaml b/config/services.yaml index 81c7f1b..ebd8ae9 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: @@ -43,6 +44,10 @@ parameters: shoe_size_min: 35 shoe_size_max: 50 + # Season data + season_winter_from: '%env(APP_SEASON_WINTER_FROM)%' + season_summer_from: '%env(APP_SEASON_SUMMER_FROM)%' + # domain mapping for theme, gtm id and cmp url domain_config: ep-reisen.de: @@ -116,6 +121,14 @@ services: method: createMainMenu alias: admin_main + App\Menu\GroupsMenuBuilder: + arguments: + $factory: '@knp_menu.factory' + tags: + - name: knp_menu.menu_builder + method: createMainMenu + alias: groups_main + App\Service\BpnXmlSyncManager: arguments: $xmlSource: '@xml_source.storage' @@ -250,10 +263,33 @@ 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%' + App\BpnConnect\AbstractApiClient: + abstract: true + arguments: + $baseUrl: '%env(default::BPN_CONNECT_BASE_URL)%' + $apiKey: '%env(default::BPN_CONNECT_API_KEY)%' + + App\BpnConnect\ContingentsClient: + parent: App\BpnConnect\AbstractApiClient + App\Service\MailjetApiClient: arguments: $apiKey: '%env(default::MAILJET_API_KEY)%' diff --git a/deploy.php b/deploy.php index bc713a9..47e39c7 100644 --- a/deploy.php +++ b/deploy.php @@ -102,6 +102,26 @@ host('staging') ->set('rsync_src', __DIR__) ->set('rsync', $rsyncOptions) ->set('cachetool_args', '--web=SymfonyHttpClient --web-path={{current_path}}/public/ --web-url=https://my.ep-reisen.net --web-basic-auth=myep:staging') + ->add('shared_files', [ + 'public/.htpasswd', + ]) +; + +host('develop') + ->setHostname('185.243.135.29') + ->setRemoteUser('p704161') + ->setForwardAgent(true) + ->setSshMultiplexing(true) + ->setDeployPath('/home/www/p704161/html/myep-develop') + ->set('writable_mode', 'chmod') + ->set('http_user', 'p704161') + ->set('bin/php', '/usr/local/bin/php') + ->set('rsync_src', __DIR__) + ->set('rsync', $rsyncOptions) + ->set('cachetool_args', '--web=SymfonyHttpClient --web-path={{current_path}}/public/ --web-url=https://mydev.ep-reisen.net --web-basic-auth=myep:develop') + ->add('shared_files', [ + 'public/.htpasswd', + ]) ; task('deploy', [ diff --git a/migrations/Version20260625092525.php b/migrations/Version20260625092525.php new file mode 100644 index 0000000..24bbffa --- /dev/null +++ b/migrations/Version20260625092525.php @@ -0,0 +1,59 @@ +addSql('CREATE TABLE accommodation (id INT AUTO_INCREMENT NOT NULL, created_by_id INT DEFAULT NULL, updated_by_id INT DEFAULT NULL, name VARCHAR(255) NOT NULL, code VARCHAR(16) NOT NULL, min_pax_summer INT NOT NULL, max_adolescent_age INT NOT NULL, currency VARCHAR(3) NOT NULL, created_at DATETIME NOT NULL COMMENT \'(DC2Type:datetime_immutable)\', updated_at DATETIME DEFAULT NULL COMMENT \'(DC2Type:datetime_immutable)\', UNIQUE INDEX UNIQ_2D38541277153098 (code), INDEX IDX_2D385412B03A8386 (created_by_id), INDEX IDX_2D385412896DBBDE (updated_by_id), PRIMARY KEY(id)) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_unicode_ci` ENGINE = InnoDB'); + $this->addSql('CREATE TABLE accommodation_price (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)\', included_pax INT NOT NULL, price_per_night INT NOT NULL, price_additional_person INT NOT NULL, min_nights INT NOT NULL, created_at DATETIME NOT NULL COMMENT \'(DC2Type:datetime_immutable)\', updated_at DATETIME DEFAULT NULL COMMENT \'(DC2Type:datetime_immutable)\', INDEX IDX_C67701238F3692CD (accommodation_id), INDEX IDX_C6770123B03A8386 (created_by_id), INDEX IDX_C6770123896DBBDE (updated_by_id), PRIMARY KEY(id)) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_unicode_ci` ENGINE = InnoDB'); + $this->addSql('CREATE TABLE additional_service (id INT AUTO_INCREMENT NOT NULL, accommodation_id INT NOT NULL, created_by_id INT DEFAULT NULL, updated_by_id INT DEFAULT NULL, label VARCHAR(255) NOT NULL, price INT NOT NULL, type VARCHAR(20) NOT NULL, date_from DATE NOT NULL COMMENT \'(DC2Type:date_immutable)\', date_to DATE NOT NULL COMMENT \'(DC2Type:date_immutable)\', `grouping` VARCHAR(128) NOT NULL, sorting INT NOT NULL, created_at DATETIME NOT NULL COMMENT \'(DC2Type:datetime_immutable)\', updated_at DATETIME DEFAULT NULL COMMENT \'(DC2Type:datetime_immutable)\', INDEX IDX_E01749268F3692CD (accommodation_id), INDEX IDX_E0174926B03A8386 (created_by_id), INDEX IDX_E0174926896DBBDE (updated_by_id), PRIMARY KEY(id)) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_unicode_ci` ENGINE = InnoDB'); + $this->addSql('CREATE TABLE board_service (id INT AUTO_INCREMENT NOT NULL, accommodation_id INT NOT NULL, created_by_id INT DEFAULT NULL, updated_by_id INT DEFAULT NULL, label VARCHAR(255) NOT NULL, price INT NOT NULL, date_from DATE NOT NULL COMMENT \'(DC2Type:date_immutable)\', date_to DATE NOT NULL COMMENT \'(DC2Type:date_immutable)\', sorting INT NOT NULL, created_at DATETIME NOT NULL COMMENT \'(DC2Type:datetime_immutable)\', updated_at DATETIME DEFAULT NULL COMMENT \'(DC2Type:datetime_immutable)\', INDEX IDX_A983B2DD8F3692CD (accommodation_id), INDEX IDX_A983B2DDB03A8386 (created_by_id), INDEX IDX_A983B2DD896DBBDE (updated_by_id), PRIMARY KEY(id)) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_unicode_ci` ENGINE = InnoDB'); + $this->addSql('ALTER TABLE accommodation ADD CONSTRAINT FK_2D385412B03A8386 FOREIGN KEY (created_by_id) REFERENCES user (id) ON DELETE SET NULL'); + $this->addSql('ALTER TABLE accommodation ADD CONSTRAINT FK_2D385412896DBBDE FOREIGN KEY (updated_by_id) REFERENCES user (id) ON DELETE SET NULL'); + $this->addSql('ALTER TABLE accommodation_price ADD CONSTRAINT FK_C67701238F3692CD FOREIGN KEY (accommodation_id) REFERENCES accommodation (id)'); + $this->addSql('ALTER TABLE accommodation_price ADD CONSTRAINT FK_C6770123B03A8386 FOREIGN KEY (created_by_id) REFERENCES user (id) ON DELETE SET NULL'); + $this->addSql('ALTER TABLE accommodation_price ADD CONSTRAINT FK_C6770123896DBBDE FOREIGN KEY (updated_by_id) REFERENCES user (id) ON DELETE SET NULL'); + $this->addSql('ALTER TABLE additional_service ADD CONSTRAINT FK_E01749268F3692CD FOREIGN KEY (accommodation_id) REFERENCES accommodation (id)'); + $this->addSql('ALTER TABLE additional_service ADD CONSTRAINT FK_E0174926B03A8386 FOREIGN KEY (created_by_id) REFERENCES user (id) ON DELETE SET NULL'); + $this->addSql('ALTER TABLE additional_service ADD CONSTRAINT FK_E0174926896DBBDE FOREIGN KEY (updated_by_id) REFERENCES user (id) ON DELETE SET NULL'); + $this->addSql('ALTER TABLE board_service ADD CONSTRAINT FK_A983B2DD8F3692CD FOREIGN KEY (accommodation_id) REFERENCES accommodation (id)'); + $this->addSql('ALTER TABLE board_service ADD CONSTRAINT FK_A983B2DDB03A8386 FOREIGN KEY (created_by_id) REFERENCES user (id) ON DELETE SET NULL'); + $this->addSql('ALTER TABLE board_service ADD CONSTRAINT FK_A983B2DD896DBBDE FOREIGN KEY (updated_by_id) REFERENCES user (id) ON DELETE SET NULL'); + } + + public function down(Schema $schema): void + { + // this down() migration is auto-generated, please modify it to your needs + $this->addSql('ALTER TABLE accommodation DROP FOREIGN KEY FK_2D385412B03A8386'); + $this->addSql('ALTER TABLE accommodation DROP FOREIGN KEY FK_2D385412896DBBDE'); + $this->addSql('ALTER TABLE accommodation_price DROP FOREIGN KEY FK_C67701238F3692CD'); + $this->addSql('ALTER TABLE accommodation_price DROP FOREIGN KEY FK_C6770123B03A8386'); + $this->addSql('ALTER TABLE accommodation_price DROP FOREIGN KEY FK_C6770123896DBBDE'); + $this->addSql('ALTER TABLE additional_service DROP FOREIGN KEY FK_E01749268F3692CD'); + $this->addSql('ALTER TABLE additional_service DROP FOREIGN KEY FK_E0174926B03A8386'); + $this->addSql('ALTER TABLE additional_service DROP FOREIGN KEY FK_E0174926896DBBDE'); + $this->addSql('ALTER TABLE board_service DROP FOREIGN KEY FK_A983B2DD8F3692CD'); + $this->addSql('ALTER TABLE board_service DROP FOREIGN KEY FK_A983B2DDB03A8386'); + $this->addSql('ALTER TABLE board_service DROP FOREIGN KEY FK_A983B2DD896DBBDE'); + $this->addSql('DROP TABLE accommodation'); + $this->addSql('DROP TABLE accommodation_price'); + $this->addSql('DROP TABLE additional_service'); + $this->addSql('DROP TABLE board_service'); + } +} diff --git a/migrations/Version20260625154838.php b/migrations/Version20260625154838.php new file mode 100644 index 0000000..27076f2 --- /dev/null +++ b/migrations/Version20260625154838.php @@ -0,0 +1,31 @@ +addSql('ALTER TABLE accommodation_price ADD price_per_night_override 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_price DROP price_per_night_override'); + } +} diff --git a/migrations/Version20260625173941.php b/migrations/Version20260625173941.php new file mode 100644 index 0000000..05d216b --- /dev/null +++ b/migrations/Version20260625173941.php @@ -0,0 +1,31 @@ +addSql('ALTER TABLE additional_service ADD selection_group VARCHAR(128) DEFAULT NULL, DROP `grouping`'); + } + + public function down(Schema $schema): void + { + // this down() migration is auto-generated, please modify it to your needs + $this->addSql('ALTER TABLE additional_service ADD `grouping` VARCHAR(128) NOT NULL, DROP selection_group'); + } +} diff --git a/migrations/Version20260627121823.php b/migrations/Version20260627121823.php new file mode 100644 index 0000000..0932ce6 --- /dev/null +++ b/migrations/Version20260627121823.php @@ -0,0 +1,35 @@ +addSql('ALTER TABLE accommodation_price ADD accept_undersubscription TINYINT(1) NOT NULL'); + $this->addSql('DROP INDEX IDX_NEWSLETTER_CONSENT_EMAIL ON newsletter_consent'); + $this->addSql('ALTER TABLE newsletter_opt_in_request RENAME INDEX uniq_70505214b3bc57da TO UNIQ_EC0365DB3BC57DA'); + } + + public function down(Schema $schema): void + { + // this down() migration is auto-generated, please modify it to your needs + $this->addSql('ALTER TABLE accommodation_price DROP accept_undersubscription'); + $this->addSql('CREATE INDEX IDX_NEWSLETTER_CONSENT_EMAIL ON newsletter_consent (email)'); + $this->addSql('ALTER TABLE newsletter_opt_in_request RENAME INDEX uniq_ec0365db3bc57da TO UNIQ_70505214B3BC57DA'); + } +} diff --git a/migrations/Version20260627130625.php b/migrations/Version20260627130625.php new file mode 100644 index 0000000..75e2ab8 --- /dev/null +++ b/migrations/Version20260627130625.php @@ -0,0 +1,31 @@ +addSql('ALTER TABLE accommodation_price ADD price_additional_person_override 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_price DROP price_additional_person_override'); + } +} diff --git a/migrations/Version20260630080409.php b/migrations/Version20260630080409.php new file mode 100644 index 0000000..abb0d12 --- /dev/null +++ b/migrations/Version20260630080409.php @@ -0,0 +1,31 @@ +addSql('ALTER TABLE accommodation_price ADD season VARCHAR(20) 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_price DROP season'); + } +} diff --git a/migrations/Version20260630085856.php b/migrations/Version20260630085856.php new file mode 100644 index 0000000..ab5c192 --- /dev/null +++ b/migrations/Version20260630085856.php @@ -0,0 +1,35 @@ +addSql('DROP INDEX UNIQ_2D38541277153098 ON accommodation'); + $this->addSql('ALTER TABLE accommodation ADD cms_code VARCHAR(16) DEFAULT NULL, CHANGE code calendar_code VARCHAR(16) NOT NULL'); + $this->addSql('CREATE UNIQUE INDEX UNIQ_2D3854122583B3CC ON accommodation (calendar_code)'); + } + + public function down(Schema $schema): void + { + // this down() migration is auto-generated, please modify it to your needs + $this->addSql('DROP INDEX UNIQ_2D3854122583B3CC ON accommodation'); + $this->addSql('ALTER TABLE accommodation DROP cms_code, CHANGE calendar_code code VARCHAR(16) NOT NULL'); + $this->addSql('CREATE UNIQUE INDEX UNIQ_2D38541277153098 ON accommodation (code)'); + } +} diff --git a/migrations/Version20260702084714.php b/migrations/Version20260702084714.php new file mode 100644 index 0000000..dae94a9 --- /dev/null +++ b/migrations/Version20260702084714.php @@ -0,0 +1,43 @@ +addSql('ALTER TABLE accommodation_price ADD type VARCHAR(20) DEFAULT NULL'); + + $this->addSql(<<<'SQL' + INSERT INTO accommodation_price + (accommodation_id, date_from, date_to, season, included_pax, min_nights, + price_per_night, price_additional_person, accept_undersubscription, type, + created_at, updated_at, created_by_id, updated_by_id) + SELECT accommodation_id, date_from, date_to, season, included_pax, min_nights, + price_per_night_override, price_additional_person_override, accept_undersubscription, 'discount', + created_at, updated_at, created_by_id, updated_by_id + FROM accommodation_price + WHERE price_per_night_override IS NOT NULL + SQL); + + $this->addSql('ALTER TABLE accommodation_price DROP price_per_night_override, DROP price_additional_person_override'); + } + + public function down(Schema $schema): void + { + $this->addSql('ALTER TABLE accommodation_price ADD price_per_night_override INT DEFAULT NULL, ADD price_additional_person_override INT DEFAULT NULL, DROP type'); + } +} diff --git a/migrations/Version20260706120805.php b/migrations/Version20260706120805.php new file mode 100644 index 0000000..ba84883 --- /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 0000000..bb5e235 --- /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 0000000..74a900e --- /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 0000000..ae72bac --- /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 0000000..b907d14 --- /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 0000000..dca7a80 --- /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 0000000..c0f1fb6 --- /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 0000000..631351c --- /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 0000000..21c76bb --- /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 0000000..5c7828b --- /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/migrations/Version20260710130000.php b/migrations/Version20260710130000.php new file mode 100644 index 0000000..1e2dae3 --- /dev/null +++ b/migrations/Version20260710130000.php @@ -0,0 +1,26 @@ +addSql('ALTER TABLE accommodation_booking ADD price_breakdown JSON DEFAULT NULL, ADD total_price INT DEFAULT NULL, ADD pricing_currency VARCHAR(3) DEFAULT NULL, ADD pricing_version SMALLINT DEFAULT NULL'); + } + + public function down(Schema $schema): void + { + $this->addSql('ALTER TABLE accommodation_booking DROP price_breakdown, DROP total_price, DROP pricing_currency, DROP pricing_version'); + } +} diff --git a/migrations/Version20260716101949.php b/migrations/Version20260716101949.php new file mode 100644 index 0000000..4e729a5 --- /dev/null +++ b/migrations/Version20260716101949.php @@ -0,0 +1,26 @@ +addSql('ALTER TABLE accommodation_booking CHANGE discount accommodation_discount INT DEFAULT NULL, ADD board_service_discount INT DEFAULT NULL, ADD additional_services_discount INT DEFAULT NULL'); + } + + public function down(Schema $schema): void + { + $this->addSql('ALTER TABLE accommodation_booking CHANGE accommodation_discount discount INT DEFAULT NULL, DROP board_service_discount, DROP additional_services_discount'); + } +} diff --git a/migrations/Version20260717103431.php b/migrations/Version20260717103431.php new file mode 100644 index 0000000..fc9e50e --- /dev/null +++ b/migrations/Version20260717103431.php @@ -0,0 +1,35 @@ +addSql('ALTER TABLE accommodation_booking ADD managed_by_id INT DEFAULT NULL'); + $this->addSql('ALTER TABLE accommodation_booking ADD CONSTRAINT FK_AC389924873649CA FOREIGN KEY (managed_by_id) REFERENCES user (id) ON DELETE SET NULL'); + $this->addSql('CREATE INDEX IDX_AC389924873649CA ON accommodation_booking (managed_by_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_AC389924873649CA'); + $this->addSql('DROP INDEX IDX_AC389924873649CA ON accommodation_booking'); + $this->addSql('ALTER TABLE accommodation_booking DROP managed_by_id'); + } +} diff --git a/package-lock.json b/package-lock.json index 87b7509..602aa9c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -6,6 +6,13 @@ "": { "name": "myep", "license": "WTFPL", + "dependencies": { + "@trevoreyre/autocomplete-js": "^3.0.3", + "glightbox": "^3.3.1", + "sortablejs": "^1.15.7", + "stimulus-use": "^0.52.3", + "vanilla-calendar-pro": "^3.1.0" + }, "devDependencies": { "@babel/core": "^7.17.0", "@babel/preset-env": "^7.16.0", @@ -1645,7 +1652,6 @@ "version": "3.2.2", "resolved": "https://registry.npmjs.org/@hotwired/stimulus/-/stimulus-3.2.2.tgz", "integrity": "sha512-eGeIqNOQpXoPAIP7tC1+1Yc1yl1xnwYqg+3mzqxyrbE5pg5YFBZcA6YoTiByJB6DKAEsiWtl6tjTJS4IYtbB7A==", - "dev": true, "license": "MIT" }, "node_modules/@hotwired/stimulus-webpack-helpers": { @@ -2045,6 +2051,12 @@ "tailwindcss": ">=3.0.0 || >=4.0.0 || insiders" } }, + "node_modules/@trevoreyre/autocomplete-js": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@trevoreyre/autocomplete-js/-/autocomplete-js-3.0.3.tgz", + "integrity": "sha512-Z0KhA4EtMOixzYOFR+OiMc60IgNy9HkPnVH0kfxFkbP1I4my//sB/MRoPkqKmKgR+qHMujXhjdflMk5TpwVN2Q==", + "license": "MIT" + }, "node_modules/@types/estree": { "version": "1.0.9", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", @@ -4552,6 +4564,12 @@ "assert-plus": "^1.0.0" } }, + "node_modules/glightbox": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/glightbox/-/glightbox-3.3.1.tgz", + "integrity": "sha512-nXoKfJRnQTDaAFAw5799hjpfAAHx5aLvOLG0SIGudeMCwtHgO3P2/avNYapJ+SL4UEZxO2YNBPtq6pzMhSx42g==", + "license": "MIT" + }, "node_modules/glob-parent": { "version": "6.0.2", "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", @@ -4670,6 +4688,16 @@ "node": ">= 0.4" } }, + "node_modules/hotkeys-js": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/hotkeys-js/-/hotkeys-js-4.0.4.tgz", + "integrity": "sha512-hseNiqaskxSnujuGp8aRMLJfcjaFiTSS0I2GQhqru82N/sx6CGyUf6pvU5X1iycvw2EqmvILkFIb5OzYFXY+9A==", + "license": "MIT", + "peer": true, + "funding": { + "url": "https://jaywcjlove.github.io/#/sponsor" + } + }, "node_modules/htmlparser2": { "version": "6.1.0", "resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-6.1.0.tgz", @@ -7588,6 +7616,12 @@ "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, + "node_modules/sortablejs": { + "version": "1.15.7", + "resolved": "https://registry.npmjs.org/sortablejs/-/sortablejs-1.15.7.tgz", + "integrity": "sha512-Kk8wLQPlS+yi1ZEf48a4+fzHa4yxjC30M/Sr2AnQu+f/MPwvvX9XjZ6OWejiz8crBsLwSq8GHqaxaET7u6ux0A==", + "license": "MIT" + }, "node_modules/source-list-map": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/source-list-map/-/source-list-map-2.0.1.tgz", @@ -7659,6 +7693,16 @@ "dev": true, "license": "MIT" }, + "node_modules/stimulus-use": { + "version": "0.52.3", + "resolved": "https://registry.npmjs.org/stimulus-use/-/stimulus-use-0.52.3.tgz", + "integrity": "sha512-stZ5dID6FUrGCR/ChWUa0FT5Z8iqkzT6lputOAb50eF+Ayg7RzJj4U/HoRlp2NV333QfvoRidru9HLbom4hZVw==", + "license": "MIT", + "peerDependencies": { + "@hotwired/stimulus": ">= 3", + "hotkeys-js": ">= 3" + } + }, "node_modules/string-width": { "version": "4.2.3", "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", @@ -8520,6 +8564,16 @@ "dev": true, "license": "MIT" }, + "node_modules/vanilla-calendar-pro": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/vanilla-calendar-pro/-/vanilla-calendar-pro-3.1.0.tgz", + "integrity": "sha512-yXDtCaedcKz6i5OOdWGwui0C8MAmjXjj7JzKZyjDlkczSRqnhI8BDGFygqT2K+qL1uY7R2fLYlTlxA6oyFs2yg==", + "license": "MIT", + "funding": { + "type": "individual", + "url": "https://buymeacoffee.com/uvarov" + } + }, "node_modules/verror": { "version": "1.10.0", "resolved": "https://registry.npmjs.org/verror/-/verror-1.10.0.tgz", diff --git a/package.json b/package.json index dd7d9cf..4f5d424 100644 --- a/package.json +++ b/package.json @@ -30,5 +30,12 @@ "watch": "encore dev --watch", "build": "encore production --progress", "cy:open": "cypress open" + }, + "dependencies": { + "@trevoreyre/autocomplete-js": "^3.0.3", + "glightbox": "^3.3.1", + "sortablejs": "^1.15.7", + "stimulus-use": "^0.52.3", + "vanilla-calendar-pro": "^3.1.0" } } diff --git a/src/BpnConnect/AbstractApiClient.php b/src/BpnConnect/AbstractApiClient.php new file mode 100644 index 0000000..22adbdf --- /dev/null +++ b/src/BpnConnect/AbstractApiClient.php @@ -0,0 +1,56 @@ + $query + * @return array + */ + protected function request(string $path, array $query = []): array + { + $this->assertConfigured(); + + try { + $response = $this->httpClient->request('GET', $this->baseUrl.$path, [ + 'headers' => [ + 'X-API-KEY' => $this->apiKey, + ], + 'query' => $query, + ]); + + return $response->toArray(); + } catch (ExceptionInterface $e) { + $this->logger->error('BpnConnect request failed', [ + 'path' => $path, + 'query' => $query, + 'error' => $e->getMessage(), + ]); + + throw new BpnConnectException('BpnConnect request failed: '.$e->getMessage(), 0, $e); + } + } + + private function assertConfigured(): void + { + if (null === $this->baseUrl || null === $this->apiKey) { + throw new BpnConnectException('BpnConnect client is not configured (missing BPN_CONNECT_BASE_URL or BPN_CONNECT_API_KEY).'); + } + } +} diff --git a/src/BpnConnect/ContingentsClient.php b/src/BpnConnect/ContingentsClient.php new file mode 100644 index 0000000..6634706 --- /dev/null +++ b/src/BpnConnect/ContingentsClient.php @@ -0,0 +1,82 @@ +request(self::CALENDAR_PATH, [ + 'hotelCode' => $hotelCode, + 'dateFrom' => $dateFrom, + 'dateTo' => $dateTo, + 'mode' => ContingentMode::Days->value, + ]); + + $entries = array_map( + static fn (array $item) => new ContingentCalendarEntry( + date: $item['date'], + status: ContingentStatus::from($item['status']), + ), + $payload['data'] ?? [], + ); + + return new ContingentCalendarResponse($this->buildMeta($payload), $entries); + } + + public function getContingentRanges( + string $hotelCode, + string $dateFrom, + string $dateTo, + ): ContingentRangeResponse { + $payload = $this->request(self::CALENDAR_PATH, [ + 'hotelCode' => $hotelCode, + 'dateFrom' => $dateFrom, + 'dateTo' => $dateTo, + 'mode' => ContingentMode::Ranges->value, + ]); + + $entries = array_map( + static fn (array $item) => new ContingentRangeEntry( + dateFrom: $item['date_from'], + dateTo: $item['date_to'], + status: ContingentStatus::from($item['status']), + ), + $payload['data'] ?? [], + ); + + return new ContingentRangeResponse($this->buildMeta($payload), $entries); + } + + /** + * @param array $payload + */ + private function buildMeta(array $payload): ContingentCalendarMeta + { + $raw = $payload['meta'] ?? []; + + return new ContingentCalendarMeta( + dateFrom: $raw['date_from'] ?? '', + dateTo: $raw['date_to'] ?? '', + mode: $raw['mode'] ?? '', + hotelCode: $raw['hotel_code'] ?? '', + hotelId: (int) ($raw['hotel_id'] ?? 0), + rowCount: (int) ($raw['row_count'] ?? 0), + ); + } +} diff --git a/src/BpnConnect/Exception/BpnConnectException.php b/src/BpnConnect/Exception/BpnConnectException.php new file mode 100644 index 0000000..e30d225 --- /dev/null +++ b/src/BpnConnect/Exception/BpnConnectException.php @@ -0,0 +1,9 @@ +value); + } +} diff --git a/src/BusProNet/XmlParser/CrmAttributesResponseParser.php b/src/BusProNet/XmlParser/CrmAttributesResponseParser.php index 87ac380..f6c8a45 100644 --- a/src/BusProNet/XmlParser/CrmAttributesResponseParser.php +++ b/src/BusProNet/XmlParser/CrmAttributesResponseParser.php @@ -16,6 +16,8 @@ class CrmAttributesResponseParser private const BPN_CRM_ID_ADMIN = 1292; private const BPN_CRM_ID_MANAGER = 1293; private const BPN_CRM_ID_TEAMER = 1070; + private const BPN_CRM_ID_GROUPS_MANAGER = 1477; + private const BPN_CRM_ID_GROUPS_ADMIN = 1478; private const BPN_DEFAULT_HOTEL_CODE = 'SSL'; public function parse(Crawler $result): CrmAttributes @@ -57,6 +59,12 @@ class CrmAttributesResponseParser if (self::BPN_CRM_ID_TEAMER === $attribute->id && true === $attribute->selected) { $roles[] = 'ROLE_TEAMER'; } + if (self::BPN_CRM_ID_GROUPS_MANAGER === $attribute->id && true === $attribute->selected) { + $roles[] = 'ROLE_GROUPS_MANAGER'; + } + if (self::BPN_CRM_ID_GROUPS_ADMIN === $attribute->id && true === $attribute->selected) { + $roles[] = 'ROLE_GROUPS_ADMIN'; + } $attributes[] = $attribute; }) diff --git a/src/Command/ImportGroupsLegacyDataCommand.php b/src/Command/ImportGroupsLegacyDataCommand.php new file mode 100644 index 0000000..021304e --- /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/Accommodation/CalendarController.php b/src/Controller/Admin/Accommodation/CalendarController.php new file mode 100644 index 0000000..1e0e93d --- /dev/null +++ b/src/Controller/Admin/Accommodation/CalendarController.php @@ -0,0 +1,25 @@ +render('admin/accommodation/_calendar_section.html.twig', [ + 'accommodation' => $accommodation, + 'startMonth' => $request->query->get('month'), + ]); + } +} diff --git a/src/Controller/Admin/Accommodation/CreateController.php b/src/Controller/Admin/Accommodation/CreateController.php new file mode 100644 index 0000000..ee1983f --- /dev/null +++ b/src/Controller/Admin/Accommodation/CreateController.php @@ -0,0 +1,54 @@ +createForm(AccommodationType::class, $accommodation, ['hx_post' => $request->getRequestUri()]); + $form->handleRequest($request); + + if ($form->isSubmitted() && $form->isValid()) { + $this->entityManager->persist($accommodation); + $this->entityManager->flush(); + + $this->addFlash('success', 'Das Gruppenhaus wurde angelegt'); + + $this->logger->info('Created accommodation', [ + 'name' => $accommodation->getName(), + 'code' => $accommodation->getCalendarCode(), + ]); + + return new HxRedirectResponse($this->generateUrl('app_admin_accommodation')); + } + + return $this->render('admin/accommodation/modal_create.html.twig', [ + 'accommodation' => $accommodation, + 'form' => $form, + ]); + } +} diff --git a/src/Controller/Admin/Accommodation/DeleteController.php b/src/Controller/Admin/Accommodation/DeleteController.php new file mode 100644 index 0000000..3d9c0b0 --- /dev/null +++ b/src/Controller/Admin/Accommodation/DeleteController.php @@ -0,0 +1,52 @@ +isMethod(Request::METHOD_POST)) { + if (!$this->isCsrfTokenValid('delete_accommodation_'.$accommodation->getId(), $request->request->getString('_token'))) { + throw $this->createAccessDeniedException('Invalid CSRF token.'); + } + + $this->entityManager->remove($accommodation); + $this->entityManager->flush(); + + $this->addFlash('success', 'Das Gruppenhaus wurde gelöscht'); + + $this->logger->info('Deleted accommodation', [ + 'name' => $accommodation->getName(), + 'code' => $accommodation->getCalendarCode(), + ]); + + return new HxRedirectResponse($this->generateUrl('app_admin_accommodation')); + } + + return $this->render('admin/accommodation/modal_delete.html.twig', [ + 'accommodation' => $accommodation, + 'csrf_token_id' => 'delete_accommodation_'.$accommodation->getId(), + ]); + } +} diff --git a/src/Controller/Admin/Accommodation/EditController.php b/src/Controller/Admin/Accommodation/EditController.php new file mode 100644 index 0000000..d3ea043 --- /dev/null +++ b/src/Controller/Admin/Accommodation/EditController.php @@ -0,0 +1,61 @@ +getCalendarCode()) { + $cmsData = $this->cmsDataProvider->getHotelDetails($accommodation->getEffectiveCmsCode()); + } + + $form = $this->createForm(AccommodationType::class, $accommodation); + $form->handleRequest($request); + + if ($form->isSubmitted() && $form->isValid()) { + $this->entityManager->persist($accommodation); + $this->entityManager->flush(); + + $this->addFlash('success', 'Das Gruppenhaus wurde aktualisiert'); + + $this->logger->info('Updated accommodation', [ + 'name' => $accommodation->getName(), + 'code' => $accommodation->getCalendarCode(), + ]); + + return $this->redirectToRoute('app_admin_accommodation_edit', ['id' => $accommodation->getId()]); + } + + return $this->render('admin/accommodation/edit.html.twig', [ + 'accommodation' => $accommodation, + 'form' => $form, + 'cmsData' => $cmsData, + ]); + } +} diff --git a/src/Controller/Admin/Accommodation/IndexController.php b/src/Controller/Admin/Accommodation/IndexController.php new file mode 100644 index 0000000..25f6b04 --- /dev/null +++ b/src/Controller/Admin/Accommodation/IndexController.php @@ -0,0 +1,30 @@ +accommodationRepository->findBy([], ['calendarCode' => 'ASC']); + + return $this->render('admin/accommodation/index.html.twig', [ + 'accommodations' => $accommodations, + ]); + } +} diff --git a/src/Controller/Admin/AccommodationBooking/CreateController.php b/src/Controller/Admin/AccommodationBooking/CreateController.php new file mode 100644 index 0000000..2c74b71 --- /dev/null +++ b/src/Controller/Admin/AccommodationBooking/CreateController.php @@ -0,0 +1,60 @@ +createForm(AccommodationBookingType::class, $booking, [ + 'with_accommodation' => true, + ]); + $form->handleRequest($request); + + if ($form->isSubmitted() && $form->isValid()) { + $this->bookingService->refreshPriceSnapshot($booking); + $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 0000000..9475d59 --- /dev/null +++ b/src/Controller/Admin/AccommodationBooking/EditController.php @@ -0,0 +1,133 @@ +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->bookingService->refreshPriceSnapshot($booking); + + $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 0000000..22ff510 --- /dev/null +++ b/src/Controller/Admin/AccommodationBooking/GenerateAccessLinkController.php @@ -0,0 +1,50 @@ +isMethod(Request::METHOD_POST)) { + if (!$this->isCsrfTokenValid('generate_accommodation_booking_access_link_'.$booking->getId(), $request->request->getString('_token'))) { + throw $this->createAccessDeniedException('Invalid CSRF token.'); + } + + $this->bookingService->regenerateAccessLink($booking); + + $this->addFlash('success', 'Der Zugangslink wurde neu generiert.'); + + $this->logger->info('Generated accommodation booking access link', [ + 'id' => $booking->getId(), + ]); + + return new HxRedirectResponse($this->generateUrl('app_admin_accommodationbooking_show', ['id' => $booking->getId()])); + } + + return $this->render('admin/accommodation_booking/modal_generate_access_link.html.twig', [ + 'booking' => $booking, + 'csrf_token_id' => 'generate_accommodation_booking_access_link_'.$booking->getId(), + ]); + } +} diff --git a/src/Controller/Admin/AccommodationBooking/IndexController.php b/src/Controller/Admin/AccommodationBooking/IndexController.php new file mode 100644 index 0000000..9b69c96 --- /dev/null +++ b/src/Controller/Admin/AccommodationBooking/IndexController.php @@ -0,0 +1,52 @@ +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.createdAt', + 'defaultSortDirection' => 'DESC', + ] + ); + + return $this->render('admin/accommodation_booking/index.html.twig', [ + 'pagination' => $pagination, + ]); + } +} diff --git a/src/Controller/Admin/AccommodationBooking/SendAccessLinkController.php b/src/Controller/Admin/AccommodationBooking/SendAccessLinkController.php new file mode 100644 index 0000000..bba6f71 --- /dev/null +++ b/src/Controller/Admin/AccommodationBooking/SendAccessLinkController.php @@ -0,0 +1,54 @@ +getAccessLinkIssuedAt()) { + return $this->redirectToRoute('app_admin_accommodationbooking_show', ['id' => $booking->getId()]); + } + + if ($request->isMethod(Request::METHOD_POST)) { + if (!$this->isCsrfTokenValid('send_accommodation_booking_access_link_'.$booking->getId(), $request->request->getString('_token'))) { + throw $this->createAccessDeniedException('Invalid CSRF token.'); + } + + $this->bookingService->sendCustomerConfirmationEmail($booking); + + $this->addFlash('success', 'Der Zugangslink wurde dem Kunden per E-Mail zugestellt.'); + + $this->logger->info('Sent accommodation booking access link', [ + 'id' => $booking->getId(), + ]); + + return new HxRedirectResponse($this->generateUrl('app_admin_accommodationbooking_show', ['id' => $booking->getId()])); + } + + return $this->render('admin/accommodation_booking/modal_send_access_link.html.twig', [ + 'booking' => $booking, + 'csrf_token_id' => 'send_accommodation_booking_access_link_'.$booking->getId(), + ]); + } +} diff --git a/src/Controller/Admin/AccommodationBooking/ShowController.php b/src/Controller/Admin/AccommodationBooking/ShowController.php new file mode 100644 index 0000000..79d23b1 --- /dev/null +++ b/src/Controller/Admin/AccommodationBooking/ShowController.php @@ -0,0 +1,41 @@ +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 new file mode 100644 index 0000000..8cd82b5 --- /dev/null +++ b/src/Controller/Admin/AccommodationPrice/CreateController.php @@ -0,0 +1,64 @@ +createForm(AccommodationPriceType::class, $accommodationPrice, ['currency' => $accommodation->getCurrency()]); + $form->handleRequest($request); + + if ($form->isSubmitted() && $form->isValid()) { + $accommodation->addAccommodationPrice($accommodationPrice); + + $this->entityManager->persist($accommodationPrice); + $this->entityManager->flush(); + + $this->addFlash('success', 'Der Preis wurde angelegt'); + + $this->logger->info('Created accommodation price', [ + 'name' => $accommodation->getName(), + 'code' => $accommodation->getCalendarCode(), + 'date_range' => [ + 'from' => $accommodationPrice->getDateFrom()->format('Y-m-d'), + 'to' => $accommodationPrice->getDateTo()->format('Y-m-d'), + ] + ]); + + $returnUrl = $this->generateUrl('app_admin_accommodation_edit', [ + 'id' => $accommodation->getId(), + ]); + + return $this->redirect($returnUrl.'#prices'); + } + + return $this->render('admin/accommodation_price/create.html.twig', [ + 'accommodation' => $accommodation, + 'form' => $form, + ]); + } +} diff --git a/src/Controller/Admin/AccommodationPrice/DeleteController.php b/src/Controller/Admin/AccommodationPrice/DeleteController.php new file mode 100644 index 0000000..1c83c20 --- /dev/null +++ b/src/Controller/Admin/AccommodationPrice/DeleteController.php @@ -0,0 +1,63 @@ +getAccommodation(); + + if (true === $request->isMethod(Request::METHOD_POST)) { + if (!$this->isCsrfTokenValid('delete_accommodation_price_'.$accommodationPrice->getId(), $request->request->getString('_token'))) { + throw $this->createAccessDeniedException('Invalid CSRF token.'); + } + + $this->entityManager->remove($accommodationPrice); + $this->entityManager->flush(); + + $this->addFlash('success', 'Der Preis wurde gelöscht'); + + $this->logger->info('Deleted accommodation price', [ + 'name' => $accommodation->getName(), + 'code' => $accommodation->getCalendarCode(), + 'date_range' => [ + 'from' => $accommodationPrice->getDateFrom()->format('Y-m-d'), + 'to' => $accommodationPrice->getDateTo()->format('Y-m-d'), + ], + ]); + + $returnUrl = $this->generateUrl('app_admin_accommodation_edit', [ + 'id' => $accommodation->getId(), + ]); + + return new HxRedirectResponse($returnUrl.'#prices'); + } + + return $this->render('admin/accommodation_price/modal_delete.html.twig', [ + 'accommodationPrice' => $accommodationPrice, + 'accommodation' => $accommodation, + 'csrf_token_id' => 'delete_accommodation_price_'.$accommodationPrice->getId(), + ]); + } +} diff --git a/src/Controller/Admin/AccommodationPrice/EditController.php b/src/Controller/Admin/AccommodationPrice/EditController.php new file mode 100644 index 0000000..f08dfd5 --- /dev/null +++ b/src/Controller/Admin/AccommodationPrice/EditController.php @@ -0,0 +1,72 @@ + false])] + #[Route('/admin/accommodation-price/{id}/duplicate', name: 'app_admin_accommodationprice_duplicate', defaults: ['duplicate' => true])] + public function index(AccommodationPrice $accommodationPrice, bool $duplicate, Request $request): Response + { + $accommodation = $accommodationPrice->getAccommodation(); + + if (true === $duplicate) { + $accommodationPrice = clone $accommodationPrice; + } + + $form = $this->createForm(AccommodationPriceType::class, $accommodationPrice, ['currency' => $accommodation->getCurrency()]); + $form->handleRequest($request); + + if ($form->isSubmitted() && $form->isValid()) { + if (true === $duplicate) { + $accommodation->addAccommodationPrice($accommodationPrice); + } + + $this->entityManager->persist($accommodationPrice); + $this->entityManager->flush(); + + $this->addFlash('success', 'Der Preis wurde aktualisiert'); + + $this->logger->info('Edited accommodation price', [ + 'name' => $accommodation->getName(), + 'code' => $accommodation->getCalendarCode(), + 'date_range' => [ + 'from' => $accommodationPrice->getDateFrom()->format('Y-m-d'), + 'to' => $accommodationPrice->getDateTo()->format('Y-m-d'), + ] + ]); + + $returnUrl = $this->generateUrl('app_admin_accommodation_edit', [ + 'id' => $accommodation->getId(), + ]); + + return $this->redirect($returnUrl.'#prices'); + } + + return $this->render('admin/accommodation_price/edit.html.twig', [ + 'accommodation' => $accommodation, + 'accommodationPrice' => $accommodationPrice, + 'form' => $form, + 'duplicate' => $duplicate, + ]); + } +} diff --git a/src/Controller/Admin/AdditionalService/CreateController.php b/src/Controller/Admin/AdditionalService/CreateController.php new file mode 100644 index 0000000..5f3f4b6 --- /dev/null +++ b/src/Controller/Admin/AdditionalService/CreateController.php @@ -0,0 +1,61 @@ +createForm(AdditionalServiceType::class, $additionalService, ['currency' => $accommodation->getCurrency()]); + $form->handleRequest($request); + + if ($form->isSubmitted() && $form->isValid()) { + $accommodation->addAdditionalService($additionalService); + + $this->entityManager->persist($additionalService); + $this->entityManager->flush(); + + $this->addFlash('success', 'Die Zusatzleistung wurde angelegt'); + + $this->logger->info('Created additional service', [ + 'name' => $accommodation->getName(), + 'code' => $accommodation->getCalendarCode(), + 'label' => $additionalService->getLabel(), + ]); + + $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', [ + 'accommodation' => $accommodation, + 'form' => $form, + ]); + } +} diff --git a/src/Controller/Admin/AdditionalService/DeleteController.php b/src/Controller/Admin/AdditionalService/DeleteController.php new file mode 100644 index 0000000..59bbbb3 --- /dev/null +++ b/src/Controller/Admin/AdditionalService/DeleteController.php @@ -0,0 +1,60 @@ +getAccommodation(); + + if (true === $request->isMethod(Request::METHOD_POST)) { + if (!$this->isCsrfTokenValid('delete_additional_service_'.$additionalService->getId(), $request->request->getString('_token'))) { + throw $this->createAccessDeniedException('Invalid CSRF token.'); + } + + $this->entityManager->remove($additionalService); + $this->entityManager->flush(); + + $this->addFlash('success', 'Die Zusatzleistung wurde gelöscht'); + + $this->logger->info('Deleted additional service', [ + 'name' => $accommodation->getName(), + 'code' => $accommodation->getCalendarCode(), + 'label' => $additionalService->getLabel(), + ]); + + $returnUrl = $this->generateUrl('app_admin_accommodation_edit', [ + 'id' => $accommodation->getId(), + ]); + + return new HxRedirectResponse($returnUrl.'#additional-services'); + } + + return $this->render('admin/additional_service/modal_delete.html.twig', [ + 'additionalService' => $additionalService, + 'accommodation' => $accommodation, + 'csrf_token_id' => 'delete_additional_service_'.$additionalService->getId(), + ]); + } +} diff --git a/src/Controller/Admin/AdditionalService/EditController.php b/src/Controller/Admin/AdditionalService/EditController.php new file mode 100644 index 0000000..1db5b3e --- /dev/null +++ b/src/Controller/Admin/AdditionalService/EditController.php @@ -0,0 +1,72 @@ + false])] + #[Route('/admin/additional-service/{id}/duplicate', name: 'app_admin_additionalservice_duplicate', defaults: ['duplicate' => true])] + public function index(AdditionalService $additionalService, bool $duplicate, Request $request): Response + { + $accommodation = $additionalService->getAccommodation(); + + if (true === $duplicate) { + $additionalService = clone $additionalService; + } + + $form = $this->createForm(AdditionalServiceType::class, $additionalService, ['currency' => $accommodation->getCurrency()]); + $form->handleRequest($request); + + if ($form->isSubmitted() && $form->isValid()) { + if (true === $duplicate) { + $accommodation->addAdditionalService($additionalService); + } + + $this->entityManager->persist($additionalService); + $this->entityManager->flush(); + + $this->addFlash('success', 'Die Zusatzleistung wurde aktualisiert'); + + $this->logger->info('Edited additional service', [ + 'name' => $accommodation->getName(), + 'code' => $accommodation->getCalendarCode(), + 'label' => $additionalService->getLabel(), + ]); + + $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', [ + 'accommodation' => $accommodation, + 'additionalService' => $additionalService, + 'form' => $form, + 'duplicate' => $duplicate, + ]); + } +} diff --git a/src/Controller/Admin/BoardService/CreateController.php b/src/Controller/Admin/BoardService/CreateController.php new file mode 100644 index 0000000..3bc1864 --- /dev/null +++ b/src/Controller/Admin/BoardService/CreateController.php @@ -0,0 +1,61 @@ +createForm(BoardServiceType::class, $boardService, ['currency' => $accommodation->getCurrency()]); + $form->handleRequest($request); + + if ($form->isSubmitted() && $form->isValid()) { + $accommodation->addBoardService($boardService); + + $this->entityManager->persist($boardService); + $this->entityManager->flush(); + + $this->addFlash('success', 'Die Verpflegungsleistung wurde angelegt'); + + $this->logger->info('Created board service', [ + 'name' => $accommodation->getName(), + 'code' => $accommodation->getCalendarCode(), + 'label' => $boardService->getLabel(), + ]); + + $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', [ + 'accommodation' => $accommodation, + 'form' => $form, + ]); + } +} diff --git a/src/Controller/Admin/BoardService/DeleteController.php b/src/Controller/Admin/BoardService/DeleteController.php new file mode 100644 index 0000000..9685cf1 --- /dev/null +++ b/src/Controller/Admin/BoardService/DeleteController.php @@ -0,0 +1,60 @@ +getAccommodation(); + + if (true === $request->isMethod(Request::METHOD_POST)) { + if (!$this->isCsrfTokenValid('delete_board_service_'.$boardService->getId(), $request->request->getString('_token'))) { + throw $this->createAccessDeniedException('Invalid CSRF token.'); + } + + $this->entityManager->remove($boardService); + $this->entityManager->flush(); + + $this->addFlash('success', 'Die Verpflegungsleistung wurde gelöscht'); + + $this->logger->info('Deleted board service', [ + 'name' => $accommodation->getName(), + 'code' => $accommodation->getCalendarCode(), + 'label' => $boardService->getLabel(), + ]); + + $returnUrl = $this->generateUrl('app_admin_accommodation_edit', [ + 'id' => $accommodation->getId(), + ]); + + return new HxRedirectResponse($returnUrl.'#board-services'); + } + + return $this->render('admin/board_service/modal_delete.html.twig', [ + 'boardService' => $boardService, + 'accommodation' => $accommodation, + 'csrf_token_id' => 'delete_board_service_'.$boardService->getId(), + ]); + } +} diff --git a/src/Controller/Admin/BoardService/EditController.php b/src/Controller/Admin/BoardService/EditController.php new file mode 100644 index 0000000..5294adf --- /dev/null +++ b/src/Controller/Admin/BoardService/EditController.php @@ -0,0 +1,69 @@ + false])] + #[Route('/admin/board-service/{id}/duplicate', name: 'app_admin_boardservice_duplicate', defaults: ['duplicate' => true])] + public function index(BoardService $boardService, bool $duplicate, Request $request): Response + { + $accommodation = $boardService->getAccommodation(); + + if (true === $duplicate) { + $boardService = clone $boardService; + } + + $form = $this->createForm(BoardServiceType::class, $boardService, ['currency' => $accommodation->getCurrency()]); + $form->handleRequest($request); + + if ($form->isSubmitted() && $form->isValid()) { + if (true === $duplicate) { + $accommodation->addBoardService($boardService); + } + + $this->entityManager->persist($boardService); + $this->entityManager->flush(); + + $this->addFlash('success', 'Die Verpflegungsleistung wurde aktualisiert'); + + $this->logger->info('Edited board service', [ + 'name' => $accommodation->getName(), + 'code' => $accommodation->getCalendarCode(), + 'label' => $boardService->getLabel(), + ]); + + $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', [ + 'accommodation' => $accommodation, + 'boardService' => $boardService, + 'form' => $form, + 'duplicate' => $duplicate, + ]); + } +} diff --git a/src/Controller/Admin/BookingEditDraft/DeleteController.php b/src/Controller/Admin/BookingEditDraft/DeleteController.php new file mode 100644 index 0000000..d49c56c --- /dev/null +++ b/src/Controller/Admin/BookingEditDraft/DeleteController.php @@ -0,0 +1,55 @@ +isMethod(Request::METHOD_POST)) { + if (!$this->isCsrfTokenValid('delete_booking_edit_draft_'.$draft->getId(), $request->request->getString('_token'))) { + throw $this->createAccessDeniedException('Invalid CSRF token.'); + } + + $this->entityManager->remove($draft); + $this->entityManager->flush(); + + $this->adminLogger->info('Delete booking edit draft', [ + 'booking_number' => $draft->getBookingNumber(), + ]); + $this->addFlash('success', 'Der Buchungsentwurf wurde gelöscht'); + + $returnUrl = $this->getReturnUrl($request, 'app_admin_bookingeditdraft'); + + return new HxRedirectResponse($returnUrl); + } + + return $this->render('admin/booking_edit_draft/modal_delete.html.twig', [ + 'draft' => $draft, + 'csrf_token_id' => 'delete_booking_edit_draft_'.$draft->getId(), + ]); + } +} diff --git a/src/Controller/Admin/BookingEditDraft/ExportController.php b/src/Controller/Admin/BookingEditDraft/ExportController.php new file mode 100644 index 0000000..bc76b11 --- /dev/null +++ b/src/Controller/Admin/BookingEditDraft/ExportController.php @@ -0,0 +1,41 @@ +hasExportData()) { + $this->addFlash('warning', 'Der Export ist fehlgeschlagen'); + + return $this->redirectToRoute('app_admin_bookingeditdraft'); + } + + try { + return $this->bookingExporter->createExportResponse($draft); + } catch (\RuntimeException $e) { + $this->addFlash('warning', 'Der Export ist fehlgeschlagen: '.$e->getMessage()); + + return $this->redirectToRoute('app_admin_bookingeditdraft'); + } + } +} diff --git a/src/Controller/Admin/BookingEditDraft/IndexController.php b/src/Controller/Admin/BookingEditDraft/IndexController.php new file mode 100644 index 0000000..08a36b0 --- /dev/null +++ b/src/Controller/Admin/BookingEditDraft/IndexController.php @@ -0,0 +1,50 @@ +bookingEditDraftRepository + ->createQueryBuilder('booking_edit_draft') + ->leftJoin('booking_edit_draft.user', 'user') + ; + + $pagination = $this->paginator->paginate( + $qb, + $request->query->getInt('page', 1), + $request->query->getInt('limit', 20), + [ + 'defaultSortFieldName' => 'booking_edit_draft.createdAt', + 'defaultSortDirection' => 'DESC', + ] + ); + + return $this->render('admin/booking_edit_draft/index.html.twig', [ + 'pagination' => $pagination, + ]); + } +} diff --git a/src/Controller/Admin/BookingEditDraft/ShowController.php b/src/Controller/Admin/BookingEditDraft/ShowController.php new file mode 100644 index 0000000..2795f58 --- /dev/null +++ b/src/Controller/Admin/BookingEditDraft/ShowController.php @@ -0,0 +1,30 @@ +getReturnUrl($request, 'app_admin_bookingeditdraft'); + + return $this->render('admin/booking_edit_draft/show.html.twig', [ + 'draft' => $draft, + 'returnUrl' => $returnUrl, + ]); + } +} diff --git a/src/Controller/Admin/BookingEditDraftController.php b/src/Controller/Admin/BookingEditDraftController.php deleted file mode 100644 index b9e6349..0000000 --- a/src/Controller/Admin/BookingEditDraftController.php +++ /dev/null @@ -1,107 +0,0 @@ -bookingEditDraftRepository - ->createQueryBuilder('booking_edit_draft') - ->leftJoin('booking_edit_draft.user', 'user') - ; - - $pagination = $this->paginator->paginate( - $qb, - $request->query->getInt('page', 1), - $request->query->getInt('limit', 20), - [ - 'defaultSortFieldName' => 'booking_edit_draft.createdAt', - 'defaultSortDirection' => 'DESC', - ] - ); - - return $this->render('admin/booking_edit_draft/index.html.twig', [ - 'pagination' => $pagination, - ]); - } - - #[Route('/admin/booking-edit-draft/{id}/show', name: 'app_admin_bookingeditdraft_show')] - public function show(BookingEditDraft $draft, Request $request): Response - { - $returnUrl = $this->getReturnUrl($request, 'app_admin_bookingeditdraft'); - - return $this->render('admin/booking_edit_draft/show.html.twig', [ - 'draft' => $draft, - 'returnUrl' => $returnUrl, - ]); - } - - #[Route('/admin/booking-edit-draft/{id}/delete', name: 'app_admin_bookingeditdraft_delete')] - public function delete(BookingEditDraft $draft, Request $request): Response - { - if (true === $request->isMethod(Request::METHOD_POST)) { - $this->entityManager->remove($draft); - $this->entityManager->flush(); - - $this->adminLogger->info('Delete booking edit draft', [ - 'booking_number' => $draft->getBookingNumber(), - ]); - $this->addFlash('success', 'Der Buchungsentwurf wurde gelöscht'); - - $returnUrl = $this->getReturnUrl($request, 'app_admin_bookingeditdraft'); - - return new HxRedirectResponse($returnUrl); - } - - return $this->render('admin/booking_edit_draft/modal_delete.html.twig', [ - 'draft' => $draft, - ]); - } - - #[Route('/admin/booking-edit-draft/{id}/export', name: 'app_admin_bookingeditdraft_export')] - public function export(BookingEditDraft $draft): Response - { - if (false === $draft->hasExportData()) { - $this->addFlash('warning', 'Der Export ist fehlgeschlagen'); - - return $this->redirectToRoute('app_admin_bookingeditdraft'); - } - - try { - return $this->bookingExporter->createExportResponse($draft); - } catch (\RuntimeException $e) { - $this->addFlash('warning', 'Der Export ist fehlgeschlagen: '.$e->getMessage()); - - return $this->redirectToRoute('app_admin_bookingeditdraft'); - } - } -} diff --git a/src/Controller/Admin/DashboardController.php b/src/Controller/Admin/DashboardController.php index 513cc44..a6d3818 100644 --- a/src/Controller/Admin/DashboardController.php +++ b/src/Controller/Admin/DashboardController.php @@ -4,10 +4,13 @@ declare(strict_types=1); namespace App\Controller\Admin; +use App\Security\Voter\AdministrativeAccessVoter; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; use Symfony\Component\HttpFoundation\Response; use Symfony\Component\Routing\Attribute\Route; +use Symfony\Component\Security\Http\Attribute\IsGranted; +#[IsGranted(AdministrativeAccessVoter::ADMINISTRATIVE_ACCESS)] class DashboardController extends AbstractController { #[Route('/admin/dashboard', name: 'app_admin_dashboard')] diff --git a/src/Controller/Admin/Log/DownloadController.php b/src/Controller/Admin/Log/DownloadController.php new file mode 100644 index 0000000..ce026a0 --- /dev/null +++ b/src/Controller/Admin/Log/DownloadController.php @@ -0,0 +1,46 @@ + '.+'])] + public function index(string $filename): Response + { + try { + if (false === $this->xmlDumpReader->fileExists($filename)) { + throw $this->createNotFoundException('Dump file not found. It may have been cleaned up.'); + } + + $content = $this->xmlDumpReader->getContent($filename); + } catch (FilesystemException $e) { + throw $this->createNotFoundException('Failed to read dump file: '.$e->getMessage()); + } + + $response = new Response($content); + $response->headers->set('Content-Type', 'application/xml'); + + $disposition = $response->headers->makeDisposition( + ResponseHeaderBag::DISPOSITION_ATTACHMENT, + basename($filename) + ); + $response->headers->set('Content-Disposition', $disposition); + + return $response; + } +} diff --git a/src/Controller/Admin/Log/IndexController.php b/src/Controller/Admin/Log/IndexController.php new file mode 100644 index 0000000..b1c66da --- /dev/null +++ b/src/Controller/Admin/Log/IndexController.php @@ -0,0 +1,46 @@ +logEntryRepository + ->createQueryBuilder('log_entry') + ; + + $pagination = $this->paginator->paginate( + $qb, + $request->query->getInt('page', 1), + $request->query->getInt('limit', 50), + [ + 'defaultSortFieldName' => 'log_entry.createdAt', + 'defaultSortDirection' => 'DESC', + ] + ); + + return $this->render('admin/log/index.html.twig', [ + 'pagination' => $pagination, + ]); + } +} diff --git a/src/Controller/Admin/Log/XmlDumpController.php b/src/Controller/Admin/Log/XmlDumpController.php new file mode 100644 index 0000000..9d9c7cc --- /dev/null +++ b/src/Controller/Admin/Log/XmlDumpController.php @@ -0,0 +1,36 @@ +xmlDumpReader->findDumpsForRequestId($logEntry->getRequestId()); + } catch (FilesystemException) { + } + + return $this->render('admin/log/xml_dumps.html.twig', [ + 'logEntry' => $logEntry, + 'dumps' => $dumps, + ]); + } +} diff --git a/src/Controller/Admin/LogController.php b/src/Controller/Admin/LogController.php deleted file mode 100644 index 95bfe4d..0000000 --- a/src/Controller/Admin/LogController.php +++ /dev/null @@ -1,90 +0,0 @@ -logEntryRepository - ->createQueryBuilder('log_entry') - ; - - $pagination = $this->paginator->paginate( - $qb, - $request->query->getInt('page', 1), - $request->query->getInt('limit', 50), - [ - 'defaultSortFieldName' => 'log_entry.createdAt', - 'defaultSortDirection' => 'DESC', - ] - ); - - return $this->render('admin/log/index.html.twig', [ - 'pagination' => $pagination, - ]); - } - - #[Route('/admin/log/{id}/xml-dumps', name: 'app_admin_log_xmldumps')] - public function xmlDumps(LogEntry $logEntry): Response - { - $dumps = []; - try { - $dumps = $this->xmlDumpReader->findDumpsForRequestId($logEntry->getRequestId()); - } catch (FilesystemException) { - } - - return $this->render('admin/log/xml_dumps.html.twig', [ - 'logEntry' => $logEntry, - 'dumps' => $dumps, - ]); - } - - #[Route('/admin/log/download/{filename}', name: 'app_admin_log_xmldump_download', requirements: ['filename' => '.+'])] - public function downloadXmlDump(string $filename): Response - { - try { - if (false === $this->xmlDumpReader->fileExists($filename)) { - throw $this->createNotFoundException('Dump file not found. It may have been cleaned up.'); - } - - $content = $this->xmlDumpReader->getContent($filename); - } catch (FilesystemException $e) { - throw $this->createNotFoundException('Failed to read dump file: '.$e->getMessage()); - } - - $response = new Response($content); - $response->headers->set('Content-Type', 'application/xml'); - - $disposition = $response->headers->makeDisposition( - ResponseHeaderBag::DISPOSITION_ATTACHMENT, - basename($filename) - ); - $response->headers->set('Content-Disposition', $disposition); - - return $response; - } -} diff --git a/src/Controller/Admin/UserController.php b/src/Controller/Admin/User/IndexController.php similarity index 86% rename from src/Controller/Admin/UserController.php rename to src/Controller/Admin/User/IndexController.php index 385a387..1f6f212 100644 --- a/src/Controller/Admin/UserController.php +++ b/src/Controller/Admin/User/IndexController.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace App\Controller\Admin; +namespace App\Controller\Admin\User; use App\Repository\UserRepository; use Knp\Component\Pager\PaginatorInterface; @@ -10,8 +10,10 @@ use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; use Symfony\Component\Routing\Attribute\Route; +use Symfony\Component\Security\Http\Attribute\IsGranted; -class UserController extends AbstractController +#[IsGranted('ROLE_ADMIN')] +class IndexController extends AbstractController { public function __construct( private readonly UserRepository $userRepository, diff --git a/src/Controller/Api/AccommodationBookingController.php b/src/Controller/Api/AccommodationBookingController.php new file mode 100644 index 0000000..5a11518 --- /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 new file mode 100644 index 0000000..4bc8bb8 --- /dev/null +++ b/src/Controller/Api/ContingentController.php @@ -0,0 +1,133 @@ +accommodationRepository->findOneBy(['calendarCode' => $query->hotelCode]); + + if (null === $accommodation) { + return $this->json(['error' => 'hotel not found for hotelCode.'], Response::HTTP_BAD_REQUEST); + } + + $yearStart = new \DateTimeImmutable("{$query->year}-01-01"); + $yearEnd = new \DateTimeImmutable("{$query->year}-12-31"); + + $prices = $this->priceRepository->findByHotelCodeAndDateRange($query->hotelCode, $yearStart, $yearEnd); + + $currency = $accommodation->getCurrency(); + + return $this->json($this->priceTimelineBuilder->buildTimeline($prices, $yearStart, $yearEnd, $currency)); + } + + #[Route(path: '/contingents/calendar', name: 'api_contingents_calendar', methods: ['GET'])] + public function calendar( + #[MapQueryString(validationFailedStatusCode: Response::HTTP_BAD_REQUEST)] + ContingentCalendarQuery $query, + ): JsonResponse { + $dateFrom = $query->dateFromDate(); + $dateTo = $query->dateToDate(); + + $accommodation = $this->accommodationRepository->findOneBy(['calendarCode' => $query->hotelCode]); + + if (null === $accommodation) { + return $this->json(['error' => 'hotel not found for hotelCode.'], Response::HTTP_BAD_REQUEST); + } + + try { + $cacheKey = sprintf('contingents_calendar_%s_%s_%s', $query->hotelCode, $query->dateFrom, $query->dateTo); + $calendar = $this->cache->get($cacheKey, function (ItemInterface $item) use ($query) { + $item->expiresAfter(3600); + + return $this->contingentsClient->getContingentCalendar($query->hotelCode, $query->dateFrom, $query->dateTo); + }); + } catch (BpnConnectException|InvalidArgumentException $e) { + return $this->json(['error' => 'Failed to fetch contingent data.'], Response::HTTP_BAD_GATEWAY); + } + + $prices = $this->priceRepository->findByHotelCodeAndDateRange($query->hotelCode, $dateFrom, $dateTo); + + $currency = $accommodation->getCurrency(); + + $data = array_map( + fn ($entry) => $this->enrichEntry($entry->date, $entry->status->value, $prices, $currency), + $calendar->data, + ); + + return $this->json($data); + } + + /** + * @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, minNights: int|null} + */ + private function enrichEntry(string $date, string $status, array $prices, string $currency): array + { + $day = (new \DateTimeImmutable($date))->setTime(0, 0); + + // dateFrom and dateTo are both inclusive (last night, not checkout day) + $candidates = array_filter( + $prices, + fn ($p) => $p->getDateFrom() <= $day && $p->getDateTo() >= $day, + ); + + $winner = $this->priceTimelineBuilder->resolveWinner($candidates); + + $defaultPrice = null; + if (PriceType::DISCOUNT === $winner?->getType()) { + $defaults = array_filter($candidates, fn ($p) => null === $p->getType()); + $defaultPrice = $this->priceTimelineBuilder->resolveWinner($defaults); + } + + return [ + 'date' => $date, + 'status' => $status, + 'type' => $winner?->getType()?->value, + 'pricePerNight' => null !== $winner ? round($winner->getPricePerNight() / 100, 2) : null, + 'defaultPricePerNight' => null !== $defaultPrice ? round($defaultPrice->getPricePerNight() / 100, 2) : null, + 'priceAdditionalPerson' => null !== $winner ? round($winner->getPriceAdditionalPerson() / 100, 2) : null, + 'defaultPriceAdditionalPerson' => null !== $defaultPrice ? round($defaultPrice->getPriceAdditionalPerson() / 100, 2) : null, + 'currency' => $currency, + 'includedPax' => $winner?->getIncludedPax(), + 'minNights' => $winner?->getMinNights(), + ]; + } +} diff --git a/src/Controller/Booking/Create/IndexController.php b/src/Controller/Booking/Create/IndexController.php index e34155e..de7bf81 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 0000000..f8f0168 --- /dev/null +++ b/src/Controller/Groups/AbstractAccommodationController.php @@ -0,0 +1,52 @@ + $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', + 4 => 'app_groups_booking_step_4', + 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 0000000..e006b24 --- /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 0000000..9442a4e --- /dev/null +++ b/src/Controller/Groups/OfferController.php @@ -0,0 +1,119 @@ +bookingRepository->findOneBy(['uuid' => $uuid]); + + if (null === $booking || false === $this->linkSigner->isValidLinkRequest($request, $booking)) { + return $this->render('groups/booking/offer_unavailable.html.twig'); + } + + $this->linkSigner->authorizeSession($request, $booking); + + return new RedirectResponse($this->generateUrl('app_groups_booking_offer_view', ['uuid' => $uuid])); + } + + #[Route(path: '/groups/booking/offer/{uuid}/view', name: 'app_groups_booking_offer_view', methods: ['GET'])] + public function view(string $uuid, Request $request): Response + { + $booking = $this->bookingRepository->findOneBy(['uuid' => $uuid]); + + if (null === $booking || false === $this->linkSigner->isSessionAuthorized($request, $booking)) { + return $this->render('groups/booking/offer_unavailable.html.twig'); + } + + $accommodation = $booking->getAccommodation() ?? throw $this->createNotFoundException('Booking has no accommodation.'); + $priceBreakdown = $this->breakdownCalculator->compute($booking); + + $ctx = new AccommodationBookingContext( + accommodation: $accommodation, + hotelCmsData: $this->bookingService->loadHotelCmsData($accommodation), + priceBreakdown: $priceBreakdown, + ); + + return $this->render('groups/booking/offer.html.twig', [ + 'booking' => $booking, + 'priceBreakdown' => $priceBreakdown, + 'ctx' => $ctx, + ]); + } + + #[Route(path: '/groups/booking/offer/{uuid}/confirm', name: 'app_groups_booking_offer_confirm', methods: ['GET', 'POST'])] + public function confirm(string $uuid, Request $request): Response + { + $booking = $this->bookingRepository->findOneBy(['uuid' => $uuid]); + + if (null === $booking || false === $this->linkSigner->isSessionAuthorized($request, $booking)) { + return $this->redirectToOfferPage($request, $uuid); + } + + if (!$booking->isInquiry()) { + return $this->redirectToOfferPage($request, $uuid); + } + + $confirmationForm = $this->createForm(OfferAcceptConfirmationType::class, null, [ + 'terms_url' => $this->getParameter('terms_and_conditions_url'), + ]); + $confirmationForm->handleRequest($request); + + if ($confirmationForm->isSubmitted() && $confirmationForm->isValid()) { + $this->bookingService->acceptBooking($booking); + $this->addFlash('success', 'Deine Buchung ist bestätigt.'); + + return $this->redirectToOfferPage($request, $uuid); + } + + return $this->render('groups/booking/_offer_accept_confirmation_modal.html.twig', [ + 'booking' => $booking, + 'confirmationForm' => $confirmationForm, + ]); + } + + /** + * Sends the browser to a full reload of the (non-modal) offer page — this is + * triggered from an htmx-loaded modal, so a plain render/redirect here would + * get appended as an inert HTML fragment instead of actually navigating. + */ + private function redirectToOfferPage(Request $request, string $uuid): Response + { + $url = $this->generateUrl('app_groups_booking_offer_view', ['uuid' => $uuid]); + + return $this->htmxRedirect($request, $url); + } +} diff --git a/src/Controller/Groups/Step1Controller.php b/src/Controller/Groups/Step1Controller.php new file mode 100644 index 0000000..bb38b22 --- /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 0000000..8cddc40 --- /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 0000000..0a5cee2 --- /dev/null +++ b/src/Controller/Groups/Step3Controller.php @@ -0,0 +1,90 @@ +resolveDto($request); + 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()) { + $dto->currentStep = 4; + $this->sessionManager->save($request, $dto); + + return $this->redirectToRoute('app_groups_booking_step_4'); + } + + return $this->render('groups/booking/step_3.html.twig', [ + 'dto' => $dto, + 'ctx' => $ctx, + 'form' => $form, + ]); + } + + /** + * 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): AccommodationBookingDto|Response + { + try { + $dto = $this->sessionManager->getOrFail($request); + } catch (AccommodationSessionNotFoundException $e) { + return $this->createFailureResponse($e, false); + } + + 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; + } +} diff --git a/src/Controller/Groups/Step4Controller.php b/src/Controller/Groups/Step4Controller.php new file mode 100644 index 0000000..2c079d2 --- /dev/null +++ b/src/Controller/Groups/Step4Controller.php @@ -0,0 +1,157 @@ +resolveDto($request, false); + if ($dto instanceof Response) { + return $dto; + } + + $accommodation = $this->loadAccommodationOrFail($dto); + $services = $this->bookingService->loadAvailableServices($dto, $accommodation); + + if ($request->isMethod(Request::METHOD_POST)) { + $inquiryForm = $this->createForm(AccommodationInquiryConfirmationType::class); + $inquiryForm->handleRequest($request); + + if (!$inquiryForm->isSubmitted() || !$inquiryForm->isValid()) { + throw $this->createAccessDeniedException('Invalid CSRF token.'); + } + + $dto->forceInquiry = true; + $this->persistBooking($request, $dto, $accommodation, $services); + + return $this->redirectToRoute('app_groups_booking_success'); + } + + $ctx = new AccommodationBookingContext( + accommodation: $accommodation, + hotelCmsData: $this->bookingService->loadHotelCmsData($accommodation), + boardServices: $services['boardServices'], + additionalServices: $services['additionalServices'], + priceBreakdown: $dto->priceBreakdown ?: null, + ); + + return $this->render('groups/booking/step_4.html.twig', [ + 'dto' => $dto, + 'ctx' => $ctx, + ]); + } + + #[Route('/groups/booking/step-4/confirm-inquiry', name: 'app_groups_booking_step_4_confirm_inquiry', methods: ['GET'])] + public function confirmInquiry(Request $request): Response + { + $dto = $this->resolveDto($request, true); + if ($dto instanceof Response) { + return $dto; + } + + $inquiryForm = $this->createForm(AccommodationInquiryConfirmationType::class); + + return $this->render('groups/booking/_step4_inquiry_confirmation_modal.html.twig', [ + 'inquiryForm' => $inquiryForm, + ]); + } + + #[Route('/groups/booking/step-4/confirm', name: 'app_groups_booking_step_4_confirm', methods: ['GET', '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(AccommodationBookingConfirmationType::class, $dto, [ + 'terms_url' => $this->getParameter('terms_and_conditions_url'), + ]); + $confirmationForm->handleRequest($request); + + if ($confirmationForm->isSubmitted() && $confirmationForm->isValid()) { + $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/_step4_booking_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, 4)) { + 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: BoardService[], + * additionalServices: AdditionalService[], + * groupedAdditionalServices: array, + * ungroupedAdditionalServices: AdditionalService[] + * } $services + */ + private function persistBooking(Request $request, AccommodationBookingDto $dto, Accommodation $accommodation, array $services): void + { + $prices = $this->bookingService->loadPrices($dto, $accommodation); + $booking = $this->bookingService->finalizeBooking($dto, $accommodation, $prices, $services); + $this->addFlash('groups_booking_result', $booking->isInquiry() ? 'inquiry' : 'booking'); + $this->sessionManager->clear($request); + } +} diff --git a/src/Entity/BlameableEntity.php b/src/Entity/BlameableEntity.php new file mode 100644 index 0000000..ecf3320 --- /dev/null +++ b/src/Entity/BlameableEntity.php @@ -0,0 +1,40 @@ +createdBy; + } + + public function setCreatedBy(?User $createdBy): self + { + $this->createdBy = $createdBy; + + return $this; + } + + public function getUpdatedBy(): ?User + { + return $this->updatedBy; + } + + public function setUpdatedBy(?User $updatedBy): self + { + $this->updatedBy = $updatedBy; + + return $this; + } +} diff --git a/src/Entity/BlameableEntityInterface.php b/src/Entity/BlameableEntityInterface.php new file mode 100644 index 0000000..ee6d5fd --- /dev/null +++ b/src/Entity/BlameableEntityInterface.php @@ -0,0 +1,14 @@ + + */ + #[ORM\OneToMany(targetEntity: AccommodationPrice::class, mappedBy: 'accommodation', cascade: ['all'])] + #[ORM\OrderBy(['dateFrom' => 'ASC'])] + private Collection $accommodationPrices; + + /** + * @var Collection + */ + #[ORM\OneToMany(targetEntity: BoardService::class, mappedBy: 'accommodation', cascade: ['all'])] + #[ORM\OrderBy(['dateFrom' => 'ASC', 'label' => 'ASC'])] + private Collection $boardServices; + + /** + * @var Collection + */ + #[ORM\OneToMany(targetEntity: AdditionalService::class, mappedBy: 'accommodation', cascade: ['all'])] + #[ORM\OrderBy(['dateFrom' => 'ASC', 'label' => 'ASC'])] + private Collection $additionalServices; + + #[ORM\Column] + #[Assert\NotNull(message: 'required')] + private ?int $maxAdolescentAge = null; + + #[ORM\Column(length: 3)] + #[Assert\NotBlank(message: 'required')] + #[Assert\Choice(choices: ['EUR', 'CHF'], message: 'invalid')] + private ?string $currency = 'EUR'; + + public function __construct() + { + $this->accommodationPrices = new ArrayCollection(); + $this->boardServices = new ArrayCollection(); + $this->additionalServices = new ArrayCollection(); + } + + public function getId(): ?int + { + return $this->id; + } + + public function getName(): ?string + { + return $this->name; + } + + public function setName(string $name): self + { + $this->name = $name; + + return $this; + } + + public function getCalendarCode(): ?string + { + return $this->calendarCode; + } + + public function setCalendarCode(string $calendarCode): self + { + $this->calendarCode = $calendarCode; + + return $this; + } + + public function getCmsCode(): ?string + { + return $this->cmsCode; + } + + public function setCmsCode(?string $cmsCode): self + { + $this->cmsCode = $cmsCode; + + return $this; + } + + public function getEffectiveCmsCode(): string + { + return $this->cmsCode ?? $this->calendarCode; + } + + /** + * @return Collection + */ + public function getAccommodationPrices(): Collection + { + return $this->accommodationPrices; + } + + public function addAccommodationPrice(AccommodationPrice $accommodationPrice): self + { + if (!$this->accommodationPrices->contains($accommodationPrice)) { + $this->accommodationPrices->add($accommodationPrice); + $accommodationPrice->setAccommodation($this); + } + + return $this; + } + + public function removeAccommodationPrice(AccommodationPrice $accommodationPrice): self + { + if ($this->accommodationPrices->removeElement($accommodationPrice)) { + // set the owning side to null (unless already changed) + if ($accommodationPrice->getAccommodation() === $this) { + $accommodationPrice->setAccommodation(null); + } + } + + return $this; + } + + public function getMaxAdolescentAge(): ?int + { + return $this->maxAdolescentAge; + } + + public function setMaxAdolescentAge(int $maxAdolescentAge): self + { + $this->maxAdolescentAge = $maxAdolescentAge; + + return $this; + } + + public function getCurrency(): ?string + { + return $this->currency; + } + + public function setCurrency(string $currency): self + { + $this->currency = $currency; + + return $this; + } + + /** + * @return Collection + */ + public function getBoardServices(): Collection + { + return $this->boardServices; + } + + public function addBoardService(BoardService $boardService): self + { + if (!$this->boardServices->contains($boardService)) { + $this->boardServices->add($boardService); + $boardService->setAccommodation($this); + } + + return $this; + } + + public function removeBoardService(BoardService $boardService): self + { + if ($this->boardServices->removeElement($boardService)) { + if ($boardService->getAccommodation() === $this) { + $boardService->setAccommodation(null); + } + } + + return $this; + } + + /** + * @return Collection + */ + public function getAdditionalServices(): Collection + { + return $this->additionalServices; + } + + public function addAdditionalService(AdditionalService $additionalService): self + { + if (!$this->additionalServices->contains($additionalService)) { + $this->additionalServices->add($additionalService); + $additionalService->setAccommodation($this); + } + + return $this; + } + + public function removeAdditionalService(AdditionalService $additionalService): self + { + if ($this->additionalServices->removeElement($additionalService)) { + if ($additionalService->getAccommodation() === $this) { + $additionalService->setAccommodation(null); + } + } + + return $this; + } +} diff --git a/src/Entity/Groups/AccommodationBooking.php b/src/Entity/Groups/AccommodationBooking.php new file mode 100644 index 0000000..a854cd9 --- /dev/null +++ b/src/Entity/Groups/AccommodationBooking.php @@ -0,0 +1,536 @@ + */ + #[ORM\Column(type: Types::JSON)] + private array $additionalServices = []; + + /** @var array|null */ + #[ORM\Column(type: Types::JSON, nullable: true)] + private ?array $priceBreakdown = null; + + #[ORM\Column(nullable: true)] + private ?int $totalPrice = null; + + #[ORM\Column(length: 3, nullable: true)] + private ?string $pricingCurrency = null; + + #[ORM\Column(type: Types::SMALLINT, nullable: true)] + private ?int $pricingVersion = null; + + #[ORM\Column] + private bool $isInquiry = false; + + #[ORM\Column(type: Types::DATETIME_IMMUTABLE, nullable: true)] + private ?\DateTimeImmutable $accessLinkIssuedAt = null; + + #[ORM\Column(type: Types::DATETIME_IMMUTABLE, nullable: true)] + private ?\DateTimeImmutable $acceptedAt = null; + + #[ORM\Column(length: 255)] + private ?string $groupName = null; + + #[ORM\Column(length: 10, nullable: true)] + private ?string $salutation = null; + + #[ORM\Column(length: 100)] + private ?string $firstName = null; + + #[ORM\Column(length: 100)] + private ?string $lastName = null; + + #[ORM\Column(length: 255)] + private ?string $email = null; + + #[ORM\Column(length: 50, nullable: true)] + private ?string $phone = null; + + #[ORM\Column(length: 255, nullable: true)] + private ?string $street = null; + + #[ORM\Column(length: 20, nullable: true)] + private ?string $zip = null; + + #[ORM\Column(length: 100, nullable: true)] + private ?string $city = null; + + #[ORM\Column(type: Types::TEXT, nullable: true)] + private ?string $remarks = null; + + #[ORM\Column(nullable: true)] + #[Assert\Range(min: 1, max: 100)] + private ?int $accommodationDiscount = null; + + #[ORM\Column(nullable: true)] + #[Assert\Range(min: 1, max: 100)] + private ?int $boardServiceDiscount = null; + + #[ORM\Column(nullable: true)] + #[Assert\Range(min: 1, max: 100)] + private ?int $additionalServicesDiscount = null; + + #[ORM\ManyToOne] + #[ORM\JoinColumn(onDelete: 'SET NULL')] + private ?User $managedBy = null; + + public function __construct() + { + $this->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; + } + + /** @return list */ + public function getAdditionalServices(): array + { + return $this->additionalServices; + } + + /** @param list $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; + } + + /** @return array|null */ + public function getPriceBreakdown(): ?array + { + return $this->priceBreakdown; + } + + /** @param array $priceBreakdown */ + public function setPriceSnapshot(array $priceBreakdown, int $totalPrice, string $currency, int $version): self + { + $this->priceBreakdown = $priceBreakdown; + $this->totalPrice = $totalPrice; + $this->pricingCurrency = $currency; + $this->pricingVersion = $version; + + return $this; + } + + public function clearPriceSnapshot(): self + { + $this->priceBreakdown = null; + $this->totalPrice = null; + $this->pricingCurrency = null; + $this->pricingVersion = null; + + return $this; + } + + public function getTotalPrice(): ?int + { + return $this->totalPrice; + } + + public function getPricingCurrency(): ?string + { + return $this->pricingCurrency; + } + + public function getPricingVersion(): ?int + { + return $this->pricingVersion; + } + + 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 getAccommodationDiscount(): ?int + { + return $this->accommodationDiscount; + } + + public function setAccommodationDiscount(?int $accommodationDiscount): self + { + $this->accommodationDiscount = $accommodationDiscount; + + return $this; + } + + public function getBoardServiceDiscount(): ?int + { + return $this->boardServiceDiscount; + } + + public function setBoardServiceDiscount(?int $boardServiceDiscount): self + { + $this->boardServiceDiscount = $boardServiceDiscount; + + return $this; + } + + public function getAdditionalServicesDiscount(): ?int + { + return $this->additionalServicesDiscount; + } + + public function setAdditionalServicesDiscount(?int $additionalServicesDiscount): self + { + $this->additionalServicesDiscount = $additionalServicesDiscount; + + return $this; + } + + public function getManagedBy(): ?User + { + return $this->managedBy; + } + + public function setManagedBy(?User $managedBy): self + { + $this->managedBy = $managedBy; + + return $this; + } +} diff --git a/src/Entity/Groups/AccommodationPrice.php b/src/Entity/Groups/AccommodationPrice.php new file mode 100644 index 0000000..0a6a676 --- /dev/null +++ b/src/Entity/Groups/AccommodationPrice.php @@ -0,0 +1,211 @@ +id = null; + } + + public function getId(): ?int + { + return $this->id; + } + + 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 getSeason(): ?Season + { + return $this->season; + } + + public function setSeason(?Season $season): self + { + $this->season = $season; + + return $this; + } + + public function getIncludedPax(): ?int + { + return $this->includedPax; + } + + public function setIncludedPax(int $includedPax): self + { + $this->includedPax = $includedPax; + + return $this; + } + + public function getPricePerNight(): ?int + { + return $this->pricePerNight; + } + + public function setPricePerNight(int $pricePerNight): self + { + $this->pricePerNight = $pricePerNight; + + return $this; + } + + public function getPriceAdditionalPerson(): ?int + { + return $this->priceAdditionalPerson; + } + + public function setPriceAdditionalPerson(int $priceAdditionalPerson): self + { + $this->priceAdditionalPerson = $priceAdditionalPerson; + + return $this; + } + + public function getMinNights(): ?int + { + return $this->minNights; + } + + public function setMinNights(int $minNights): self + { + $this->minNights = $minNights; + + return $this; + } + + public function getType(): ?PriceType + { + return $this->type; + } + + public function setType(?PriceType $type): self + { + $this->type = $type; + + return $this; + } + + public function isAcceptUndersubscription(): bool + { + return $this->acceptUndersubscription; + } + + public function setAcceptUndersubscription(bool $acceptUndersubscription): self + { + $this->acceptUndersubscription = $acceptUndersubscription; + + 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 new file mode 100644 index 0000000..097571f --- /dev/null +++ b/src/Entity/Groups/AdditionalService.php @@ -0,0 +1,161 @@ +id = null; + } + + public function getId(): ?int + { + return $this->id; + } + + public function getAccommodation(): ?Accommodation + { + return $this->accommodation; + } + + public function setAccommodation(?Accommodation $accommodation): self + { + $this->accommodation = $accommodation; + + return $this; + } + + public function getLabel(): ?string + { + return $this->label; + } + + public function setLabel(string $label): self + { + $this->label = $label; + + 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; + } + + public function setPrice(int $price): self + { + $this->price = $price; + + return $this; + } + + public function getType(): ?AdditionalServiceType + { + return $this->type; + } + + public function setType(AdditionalServiceType $type): self + { + $this->type = $type; + + 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 getSelectionGroup(): ?string + { + return $this->selectionGroup; + } + + public function setSelectionGroup(?string $selectionGroup): self + { + $this->selectionGroup = $selectionGroup; + + return $this; + } +} diff --git a/src/Entity/Groups/BoardService.php b/src/Entity/Groups/BoardService.php new file mode 100644 index 0000000..bbcc5eb --- /dev/null +++ b/src/Entity/Groups/BoardService.php @@ -0,0 +1,129 @@ +id = null; + } + + public function getId(): ?int + { + return $this->id; + } + + public function getAccommodation(): ?Accommodation + { + return $this->accommodation; + } + + public function setAccommodation(?Accommodation $accommodation): self + { + $this->accommodation = $accommodation; + + return $this; + } + + public function getLabel(): ?string + { + return $this->label; + } + + public function setLabel(string $label): self + { + $this->label = $label; + + 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; + } + + public function setPrice(int $price): self + { + $this->price = $price; + + 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; + } +} diff --git a/src/Entity/TimestampableEntity.php b/src/Entity/TimestampableEntity.php new file mode 100644 index 0000000..06b3c9e --- /dev/null +++ b/src/Entity/TimestampableEntity.php @@ -0,0 +1,38 @@ +createdAt; + } + + public function setCreatedAt(\DateTimeImmutable $createdAt): self + { + $this->createdAt = $createdAt; + + return $this; + } + + public function getUpdatedAt(): ?\DateTimeImmutable + { + return $this->updatedAt; + } + + public function setUpdatedAt(\DateTimeImmutable $updatedAt): self + { + $this->updatedAt = $updatedAt; + + return $this; + } +} diff --git a/src/Entity/TimestampableEntityInterface.php b/src/Entity/TimestampableEntityInterface.php new file mode 100644 index 0000000..092b724 --- /dev/null +++ b/src/Entity/TimestampableEntityInterface.php @@ -0,0 +1,14 @@ +value; + } +} diff --git a/src/Enum/Groups/PriceType.php b/src/Enum/Groups/PriceType.php new file mode 100644 index 0000000..366bd59 --- /dev/null +++ b/src/Enum/Groups/PriceType.php @@ -0,0 +1,25 @@ + 1, + self::DISCOUNT => 2, + }; + } + + public function label(): string + { + return match($this) { + self::OVERRIDE => 'Override', + self::DISCOUNT => 'Rabatt', + }; + } +} diff --git a/src/Enum/Groups/Season.php b/src/Enum/Groups/Season.php new file mode 100644 index 0000000..6c8613e --- /dev/null +++ b/src/Enum/Groups/Season.php @@ -0,0 +1,20 @@ +value.'.label'; + } + + public function token(): string + { + return 'enum.season.'.$this->value.'.token'; + } +} diff --git a/src/EventListener/BlamableEntityListener.php b/src/EventListener/BlamableEntityListener.php new file mode 100644 index 0000000..49e1cb7 --- /dev/null +++ b/src/EventListener/BlamableEntityListener.php @@ -0,0 +1,54 @@ +getObject(); + + if (!$entity instanceof BlameableEntityInterface) { + return; + } + + $user = $this->security->getUser(); + + if (!$user instanceof User) { + return; + } + + $entity->setCreatedBy($user); + } + + public function preUpdate(PreUpdateEventArgs $args): void + { + $entity = $args->getObject(); + + if (!$entity instanceof BlameableEntityInterface) { + return; + } + + $user = $this->security->getUser(); + + if (!$user instanceof User) { + return; + } + + $entity->setUpdatedBy($user); + } +} diff --git a/src/EventListener/TimestampableEntityListener.php b/src/EventListener/TimestampableEntityListener.php new file mode 100644 index 0000000..02d7c7b --- /dev/null +++ b/src/EventListener/TimestampableEntityListener.php @@ -0,0 +1,34 @@ +getObject(); + + if ($entity instanceof TimestampableEntityInterface) { + $now = new \DateTimeImmutable(); + $entity->setCreatedAt($now); + } + } + + public function preUpdate(PreUpdateEventArgs $args): void + { + $entity = $args->getObject(); + + if ($entity instanceof TimestampableEntityInterface) { + $now = new \DateTimeImmutable(); + $entity->setUpdatedAt($now); + } + } +} diff --git a/src/Exception/AccommodationSessionNotFoundException.php b/src/Exception/AccommodationSessionNotFoundException.php new file mode 100644 index 0000000..ada8aba --- /dev/null +++ b/src/Exception/AccommodationSessionNotFoundException.php @@ -0,0 +1,15 @@ + + */ +class AccommodationBookingConfirmationType 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('Ich habe die %s gelesen und bin damit einverstanden, dass eine kostenpflichtige Buchung zustande kommt.', $termsLink), + 'label_html' => true, + 'mapped' => false, + 'required' => true, + 'constraints' => [ + new IsTrue(message: 'Bitte akzeptiere die AGB, um die Buchung abzuschließen.'), + ], + ]); + } + + public function configureOptions(OptionsResolver $resolver): void + { + $resolver->setDefaults([ + 'data_class' => AccommodationBookingDto::class, + ]); + $resolver->setRequired('terms_url'); + $resolver->setAllowedTypes('terms_url', 'string'); + } +} diff --git a/src/Form/AccommodationInquiryConfirmationType.php b/src/Form/AccommodationInquiryConfirmationType.php new file mode 100644 index 0000000..9a4a3f8 --- /dev/null +++ b/src/Form/AccommodationInquiryConfirmationType.php @@ -0,0 +1,21 @@ + + */ +class AccommodationInquiryConfirmationType extends AbstractType +{ + public function configureOptions(OptionsResolver $resolver): void + { + $resolver->setDefaults([ + 'csrf_token_id' => 'groups_booking_step4_inquiry', + ]); + } +} diff --git a/src/Form/AccommodationStep2Type.php b/src/Form/AccommodationStep2Type.php new file mode 100644 index 0000000..2d1fcd5 --- /dev/null +++ b/src/Form/AccommodationStep2Type.php @@ -0,0 +1,88 @@ + + */ +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/AccommodationStep3Type.php b/src/Form/AccommodationStep3Type.php new file mode 100644 index 0000000..762b417 --- /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 0000000..70faafc --- /dev/null +++ b/src/Form/Admin/Groups/AccommodationBookingType.php @@ -0,0 +1,175 @@ + */ +class AccommodationBookingType extends AbstractType +{ + public function buildForm(FormBuilderInterface $builder, array $options): void + { + if ($options['with_accommodation']) { + $builder->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('accommodationDiscount', IntegerType::class, [ + 'label' => 'Rabatt Unterkunft (%)', + 'required' => false, + 'constraints' => [ + new Range(min: 1, max: 100), + ], + ]) + ->add('boardServiceDiscount', IntegerType::class, [ + 'label' => 'Rabatt Verpflegung (%)', + 'required' => false, + 'constraints' => [ + new Range(min: 1, max: 100), + ], + ]) + ->add('additionalServicesDiscount', IntegerType::class, [ + 'label' => 'Rabatt Zusatzleistungen (%)', + '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 new file mode 100644 index 0000000..5700ecd --- /dev/null +++ b/src/Form/Admin/Groups/AccommodationPriceType.php @@ -0,0 +1,82 @@ + */ +class AccommodationPriceType extends AbstractType +{ + public function buildForm(FormBuilderInterface $builder, array $options): void + { + $builder + ->add('dateFrom', DateType::class, [ + 'label' => 'Datum von', + 'html5' => true, + 'widget' => 'single_text', + ]) + ->add('dateTo', DateType::class, [ + 'label' => 'Datum bis', + 'html5' => true, + 'widget' => 'single_text', + ]) + ->add('type', EnumType::class, [ + 'label' => 'Typ', + 'class' => PriceType::class, + 'required' => false, + 'placeholder' => 'Standard', + 'choice_label' => fn($item) => $item->label(), + ]) + ->add('season', EnumType::class, [ + 'label' => 'Saison', + 'class' => Season::class, + 'choice_label' => fn($item) => $item->token(), + ]) + ->add('includedPax', IntegerType::class, [ + 'label' => 'Inklusiv-Personen', + ]) + ->add('minNights', IntegerType::class, [ + 'label' => 'Mindestbelegung (Nächte)', + ]) + ->add('pricePerNight', MoneyType::class, [ + 'label' => 'Preis pro Nacht', + 'currency' => $options['currency'], + 'divisor' => 100, + ]) + ->add('priceAdditionalPerson', MoneyType::class, [ + 'label' => 'Preis weitere Person', + 'currency' => $options['currency'], + 'divisor' => 100, + ]) + ->add('acceptUndersubscription', CheckboxType::class, [ + 'label' => 'Unterbelegung akzeptieren', + 'required' => false, + ]) + ->add('acceptShortTerm', CheckboxType::class, [ + 'label' => 'Kurzzeit-Belegung akzeptieren', + 'required' => false, + ]) + ; + } + + public function configureOptions(OptionsResolver $resolver): void + { + $resolver->setDefaults([ + 'data_class' => AccommodationPrice::class, + 'currency' => 'EUR', + ]); + $resolver->setAllowedValues('currency', ['EUR', 'CHF']); + } +} diff --git a/src/Form/Admin/Groups/AccommodationType.php b/src/Form/Admin/Groups/AccommodationType.php new file mode 100644 index 0000000..0767852 --- /dev/null +++ b/src/Form/Admin/Groups/AccommodationType.php @@ -0,0 +1,50 @@ + */ +class AccommodationType extends AbstractType +{ + public function buildForm(FormBuilderInterface $builder, array $options): void + { + $builder + ->add('name', TextType::class, [ + 'label' => 'Name', + ]) + ->add('calendarCode', TextType::class, [ + 'label' => 'Code für Kalender', + ]) + ->add('cmsCode', TextType::class, [ + 'label' => 'Code für CMS-Daten', + 'required' => false, + ]) + ->add('maxAdolescentAge', IntegerType::class, [ + 'label' => 'Altersgrenze Kinder', + ]) + ->add('currency', ChoiceType::class, [ + 'label' => 'Währung', + 'choices' => [ + 'EUR' => 'EUR', + 'CHF' => 'CHF', + ], + ]) + ; + } + + public function configureOptions(OptionsResolver $resolver): void + { + $resolver->setDefaults([ + 'data_class' => Accommodation::class, + ]); + } +} diff --git a/src/Form/Admin/Groups/AdditionalServiceType.php b/src/Form/Admin/Groups/AdditionalServiceType.php new file mode 100644 index 0000000..d9dacb1 --- /dev/null +++ b/src/Form/Admin/Groups/AdditionalServiceType.php @@ -0,0 +1,70 @@ + */ +class AdditionalServiceType extends AbstractType +{ + public function buildForm(FormBuilderInterface $builder, array $options): void + { + $builder + ->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, + 'choice_label' => fn($item) => $item->label(), + ]) + ->add('price', MoneyType::class, [ + 'label' => 'Preis', + 'currency' => $options['currency'], + 'divisor' => 100, + ]) + ->add('selectionGroup', TextType::class, [ + 'label' => 'Auswahlgruppe', + 'required' => false, + 'disabled' => true, + ]) + ->add('dateFrom', DateType::class, [ + 'label' => 'Datum von', + 'html5' => true, + 'widget' => 'single_text', + ]) + ->add('dateTo', DateType::class, [ + 'label' => 'Datum bis', + 'html5' => true, + 'widget' => 'single_text', + ]) + ; + } + + public function configureOptions(OptionsResolver $resolver): void + { + $resolver->setDefaults([ + 'data_class' => AdditionalService::class, + 'currency' => 'EUR', + ]); + $resolver->setAllowedValues('currency', ['EUR', 'CHF']); + } +} diff --git a/src/Form/Admin/Groups/BoardServiceType.php b/src/Form/Admin/Groups/BoardServiceType.php new file mode 100644 index 0000000..f3cd98e --- /dev/null +++ b/src/Form/Admin/Groups/BoardServiceType.php @@ -0,0 +1,58 @@ + */ +class BoardServiceType extends AbstractType +{ + public function buildForm(FormBuilderInterface $builder, array $options): void + { + $builder + ->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', DateType::class, [ + 'label' => 'Datum von', + 'html5' => true, + 'widget' => 'single_text', + ]) + ->add('dateTo', DateType::class, [ + 'label' => 'Datum bis', + 'html5' => true, + 'widget' => 'single_text', + ]) + ; + } + + public function configureOptions(OptionsResolver $resolver): void + { + $resolver->setDefaults([ + 'data_class' => BoardService::class, + 'currency' => 'EUR', + ]); + $resolver->setAllowedValues('currency', ['EUR', 'CHF']); + } +} diff --git a/src/Form/DatepickerType.php b/src/Form/DatepickerType.php new file mode 100644 index 0000000..315dfa0 --- /dev/null +++ b/src/Form/DatepickerType.php @@ -0,0 +1,40 @@ + */ +class DatepickerType extends AbstractType +{ + public function configureOptions(OptionsResolver $resolver): void + { + $resolver->setDefaults([ + 'html5' => false, + 'widget' => 'single_text', + 'input' => 'datetime_immutable', + 'min_date' => null, + 'max_date' => null, + 'disable_weekends' => false, + ]); + $resolver->setAllowedTypes('min_date', ['null', \DateTimeImmutable::class]); + $resolver->setAllowedTypes('max_date', ['null', \DateTimeImmutable::class]); + $resolver->setAllowedTypes('disable_weekends', 'bool'); + } + + public function buildView(FormView $view, FormInterface $form, array $options): void + { + $view->vars['min_date'] = $options['min_date']; + $view->vars['max_date'] = $options['max_date']; + $view->vars['disable_weekends'] = $options['disable_weekends']; + } + + public function getParent(): string + { + return DateType::class; + } +} diff --git a/src/Form/Extension/ModalSubmitExtension.php b/src/Form/Extension/ModalSubmitExtension.php new file mode 100644 index 0000000..fe61f93 --- /dev/null +++ b/src/Form/Extension/ModalSubmitExtension.php @@ -0,0 +1,39 @@ +setDefaults([ + 'hx_post' => null, + 'hx_target' => '#htmx-modal', + 'hx_swap' => 'outerHTML', + ]); + } + + public function buildView(FormView $view, FormInterface $form, array $options): void + { + if (null !== $options['hx_post']) { + $attr = [ + 'hx-post' => $options['hx_post'], + 'hx-target' => $options['hx_target'], + 'hx-swap' => $options['hx_swap'], + 'hx-indicator' => '#htmx-modal-indicator', + ]; + $view->vars['attr'] = array_merge($view->vars['attr'], $attr); + } + } + + public static function getExtendedTypes(): iterable + { + return [FormType::class]; + } +} diff --git a/src/Form/Model/AccommodationBookingDto.php b/src/Form/Model/AccommodationBookingDto.php new file mode 100644 index 0000000..a8e37ba --- /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/Form/Model/BookingSummaryDto.php b/src/Form/Model/BookingSummaryDto.php index ed34d60..817b434 100644 --- a/src/Form/Model/BookingSummaryDto.php +++ b/src/Form/Model/BookingSummaryDto.php @@ -4,7 +4,7 @@ declare(strict_types=1); namespace App\Form\Model; -use App\Model\BookingSummaryCmsHotelData; +use App\Model\CmsHotelData; /** * DTO containing all booking summary data for sidebar display. @@ -19,7 +19,7 @@ class BookingSummaryDto public readonly int $participantCount, public readonly BookingSummaryPricingDto $pricing, public readonly BookingSummaryVoucherDto $vouchers, - public readonly ?BookingSummaryCmsHotelData $cmsData, + public readonly ?CmsHotelData $cmsData, ) { } } diff --git a/src/Form/OfferAcceptConfirmationType.php b/src/Form/OfferAcceptConfirmationType.php new file mode 100644 index 0000000..fefcf33 --- /dev/null +++ b/src/Form/OfferAcceptConfirmationType.php @@ -0,0 +1,41 @@ + + */ +class OfferAcceptConfirmationType 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('Ich habe die %s gelesen und bin damit einverstanden, dass eine kostenpflichtige Buchung zustande kommt.', $termsLink), + 'label_html' => true, + 'mapped' => false, + 'required' => true, + 'constraints' => [ + new IsTrue(message: 'Bitte akzeptiere die AGB, um die Buchung abzuschließen.'), + ], + ]); + } + + public function configureOptions(OptionsResolver $resolver): void + { + $resolver->setRequired('terms_url'); + $resolver->setAllowedTypes('terms_url', 'string'); + } +} diff --git a/src/Menu/AdminMenuBuilder.php b/src/Menu/AdminMenuBuilder.php index a480b02..b5ce1f0 100644 --- a/src/Menu/AdminMenuBuilder.php +++ b/src/Menu/AdminMenuBuilder.php @@ -29,6 +29,27 @@ class AdminMenuBuilder extends AbstractMenuBuilder ], 'extras' => [ 'icon' => 'edit', + 'routes' => [['pattern' => '/^app_admin_bookingeditdraft/']], + ], + ]); + $menu->addChild('Gruppenbuchungen', [ + 'route' => 'app_admin_accommodationbooking', + 'linkAttributes' => [ + 'title' => 'Buchungen', + ], + 'extras' => [ + 'icon' => 'list', + 'routes' => [['pattern' => '/^app_admin_accommodationbooking\//']], + ], + ]); + $menu->addChild('Gruppenhäuser', [ + 'route' => 'app_admin_accommodation', + 'linkAttributes' => [ + 'title' => 'Gruppenhäuser', + ], + 'extras' => [ + 'icon' => 'house', + 'routes' => [['pattern' => '/^app_admin_accommodation\//']], ], ]); $menu->addChild('Benutzer', [ @@ -47,6 +68,7 @@ class AdminMenuBuilder extends AbstractMenuBuilder ], 'extras' => [ 'icon' => 'list', + 'routes' => [['pattern' => '/^app_admin_log/']], ], ]); diff --git a/src/Menu/GroupsMenuBuilder.php b/src/Menu/GroupsMenuBuilder.php new file mode 100644 index 0000000..bb8e1fd --- /dev/null +++ b/src/Menu/GroupsMenuBuilder.php @@ -0,0 +1,50 @@ + $options + */ + public function createMainMenu(array $options): ItemInterface + { + $menu = $this->createRootElement(); + + $menu->addChild('Dashboard', [ + 'route' => 'app_admin_dashboard', + 'linkAttributes' => [ + 'title' => 'Dashboard', + ], + 'extras' => [ + 'icon' => 'chart', + ], + ]); + $menu->addChild('Gruppenbuchungen', [ + 'route' => 'app_admin_accommodationbooking', + 'linkAttributes' => [ + 'title' => 'Buchungen', + ], + 'extras' => [ + 'icon' => 'list', + 'routes' => [['pattern' => '/^app_admin_accommodationbooking\//']], + ], + ]); + $menu->addChild('Gruppenhäuser', [ + 'route' => 'app_admin_accommodation', + 'linkAttributes' => [ + 'title' => 'Gruppenhäuser', + ], + 'extras' => [ + 'icon' => 'house', + 'routes' => [['pattern' => '/^app_admin_accommodation\//']], + ], + ]); + + $this->addLogoutItem($menu); + + return $menu; + } +} diff --git a/src/Model/AccommodationBookingAdditionalServiceItem.php b/src/Model/AccommodationBookingAdditionalServiceItem.php new file mode 100644 index 0000000..a4d489e --- /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 0000000..10bee73 --- /dev/null +++ b/src/Model/AccommodationBookingApiResponse.php @@ -0,0 +1,115 @@ + '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 $accommodationDiscount; + + #[Groups(['api:single'])] + public ?int $boardServiceDiscount; + + #[Groups(['api:single'])] + public ?int $additionalServicesDiscount; + + #[Groups(['api:single'])] + public ?int $totalPrice; + + #[Groups(['api:single'])] + public ?string $pricingCurrency; + + #[Groups(['api:single'])] + public ?int $pricingVersion; + + /** + * @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->accommodationDiscount = $booking->getAccommodationDiscount(); + $this->boardServiceDiscount = $booking->getBoardServiceDiscount(); + $this->additionalServicesDiscount = $booking->getAdditionalServicesDiscount(); + $this->totalPrice = $booking->getTotalPrice(); + $this->pricingCurrency = $booking->getPricingCurrency(); + $this->pricingVersion = $booking->getPricingVersion(); + $this->priceBreakdown = $priceBreakdown; + } +} diff --git a/src/Model/AccommodationBookingBoardServiceInfo.php b/src/Model/AccommodationBookingBoardServiceInfo.php new file mode 100644 index 0000000..f0f063f --- /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 0000000..28dfc44 --- /dev/null +++ b/src/Model/AccommodationBookingContext.php @@ -0,0 +1,30 @@ + $groupedAdditionalServices + * @param AdditionalService[] $ungroupedAdditionalServices + * @param array|null $priceBreakdown + */ + public function __construct( + public Accommodation $accommodation, + public ?CmsHotelData $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 0000000..40be31f --- /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 0000000..4c0fda4 --- /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 0000000..43f95ce --- /dev/null +++ b/src/Model/AccommodationBookingQueryParams.php @@ -0,0 +1,24 @@ +|null $images - */ - public function __construct( - public ?string $name, - public ?string $address, - public ?array $images, - ) { - } -} diff --git a/src/Model/CalendarDay.php b/src/Model/CalendarDay.php new file mode 100644 index 0000000..2bc29bb --- /dev/null +++ b/src/Model/CalendarDay.php @@ -0,0 +1,19 @@ + $days keyed by day-of-month number + */ + public function __construct( + public string $label, + public int $firstDow, + public array $days, + ) { + } +} diff --git a/src/Model/CmsHotelData.php b/src/Model/CmsHotelData.php new file mode 100644 index 0000000..745c1ce --- /dev/null +++ b/src/Model/CmsHotelData.php @@ -0,0 +1,31 @@ +|null $images + * @param array|null $icons + */ + public function __construct( + public ?string $name, + public ?string $address, + public ?array $images, + public ?string $description = null, + public ?string $features = null, + public ?string $roomTypes = null, + public ?string $additionalInformation = null, + public ?array $icons = null, + public ?CmsRegionData $region = null, + ) { + } +} diff --git a/src/Model/CmsRegionData.php b/src/Model/CmsRegionData.php new file mode 100644 index 0000000..5335e99 --- /dev/null +++ b/src/Model/CmsRegionData.php @@ -0,0 +1,32 @@ +|null $images + * @param list|null $regionMaps + */ + public function __construct( + public ?string $name, + public ?float $latitude, + public ?float $longitude, + public ?string $webcam, + public ?string $skiArea, + public ?string $skiAreaExtended, + public ?string $description, + public ?string $news, + public ?int $length, + public ?int $altitude, + public ?int $lifts, + public ?array $images, + public ?array $regionMaps, + ) { + } +} diff --git a/src/Model/ContingentCalendarQuery.php b/src/Model/ContingentCalendarQuery.php new file mode 100644 index 0000000..bdf0529 --- /dev/null +++ b/src/Model/ContingentCalendarQuery.php @@ -0,0 +1,78 @@ +dateFrom); + $dateTo = self::parseDate($this->dateTo); + + if (null === $dateFrom || null === $dateTo) { + return; + } + + if ($dateTo < $dateFrom) { + $context->buildViolation('dateTo must not be before dateFrom.') + ->atPath('dateTo') + ->addViolation(); + + return; + } + + if ($dateFrom->diff($dateTo)->days > self::MAX_RANGE_DAYS) { + $context->buildViolation(sprintf('The date range must not exceed %d days.', self::MAX_RANGE_DAYS)) + ->atPath('dateTo') + ->addViolation(); + } + } + + public function dateFromDate(): \DateTimeImmutable + { + return self::parseDate($this->dateFrom) + ?? throw new \LogicException('ContingentCalendarQuery must be validated before use.'); + } + + public function dateToDate(): \DateTimeImmutable + { + return self::parseDate($this->dateTo) + ?? throw new \LogicException('ContingentCalendarQuery must be validated before use.'); + } + + private static function parseDate(string $value): ?\DateTimeImmutable + { + $date = \DateTimeImmutable::createFromFormat('!Y-m-d', $value); + $errors = \DateTimeImmutable::getLastErrors(); + + if (false === $date || (false !== $errors && ($errors['warning_count'] > 0 || $errors['error_count'] > 0))) { + return null; + } + + return $date->format('Y-m-d') === $value ? $date : null; + } +} diff --git a/src/Model/ContingentPricesQuery.php b/src/Model/ContingentPricesQuery.php new file mode 100644 index 0000000..c6d9d9f --- /dev/null +++ b/src/Model/ContingentPricesQuery.php @@ -0,0 +1,21 @@ + + */ +class AccommodationBookingRepository extends ServiceEntityRepository +{ + public function __construct(ManagerRegistry $registry) + { + parent::__construct($registry, AccommodationBooking::class); + } +} diff --git a/src/Repository/Groups/AccommodationPriceRepository.php b/src/Repository/Groups/AccommodationPriceRepository.php new file mode 100644 index 0000000..97c878a --- /dev/null +++ b/src/Repository/Groups/AccommodationPriceRepository.php @@ -0,0 +1,44 @@ + + */ +class AccommodationPriceRepository extends ServiceEntityRepository +{ + public function __construct(ManagerRegistry $registry) + { + parent::__construct($registry, AccommodationPrice::class); + } + + /** + * Returns all AccommodationPrice records whose period overlaps with [dateFrom, dateTo] + * for the accommodation identified by the given hotel code. + * + * @return AccommodationPrice[] + */ + public function findByHotelCodeAndDateRange( + string $hotelCode, + \DateTimeImmutable $dateFrom, + \DateTimeImmutable $dateTo, + ): array { + return $this->createQueryBuilder('ap') + ->join('ap.accommodation', 'a') + ->where('a.calendarCode = :hotelCode') + ->andWhere('ap.dateFrom <= :dateTo') + ->andWhere('ap.dateTo >= :dateFrom') + ->setParameter('hotelCode', $hotelCode) + ->setParameter('dateFrom', $dateFrom) + ->setParameter('dateTo', $dateTo) + ->orderBy('ap.dateFrom', 'ASC') + ->getQuery() + ->getResult(); + } +} diff --git a/src/Repository/Groups/AccommodationRepository.php b/src/Repository/Groups/AccommodationRepository.php new file mode 100644 index 0000000..533be45 --- /dev/null +++ b/src/Repository/Groups/AccommodationRepository.php @@ -0,0 +1,63 @@ + + */ +class AccommodationRepository extends ServiceEntityRepository +{ + public function __construct(ManagerRegistry $registry) + { + parent::__construct($registry, Accommodation::class); + } + + // /** + // * @return Accommodation[] Returns an array of Accommodation objects + // */ + // public function findByExampleField($value): array + // { + // return $this->createQueryBuilder('a') + // ->andWhere('a.exampleField = :val') + // ->setParameter('val', $value) + // ->orderBy('a.id', 'ASC') + // ->setMaxResults(10) + // ->getQuery() + // ->getResult() + // ; + // } + + // public function findOneBySomeField($value): ?Accommodation + // { + // return $this->createQueryBuilder('a') + // ->andWhere('a.exampleField = :val') + // ->setParameter('val', $value) + // ->getQuery() + // ->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 new file mode 100644 index 0000000..46e91d6 --- /dev/null +++ b/src/Repository/Groups/AdditionalServiceRepository.php @@ -0,0 +1,23 @@ + + */ +class AdditionalServiceRepository extends ServiceEntityRepository +{ + /** @use FindsByAccommodationAndDateRangeTrait */ + use FindsByAccommodationAndDateRangeTrait; + + public function __construct(ManagerRegistry $registry) + { + parent::__construct($registry, AdditionalService::class); + } +} diff --git a/src/Repository/Groups/BoardServiceRepository.php b/src/Repository/Groups/BoardServiceRepository.php new file mode 100644 index 0000000..d4de8f3 --- /dev/null +++ b/src/Repository/Groups/BoardServiceRepository.php @@ -0,0 +1,23 @@ + + */ +class BoardServiceRepository extends ServiceEntityRepository +{ + /** @use FindsByAccommodationAndDateRangeTrait */ + use FindsByAccommodationAndDateRangeTrait; + + public function __construct(ManagerRegistry $registry) + { + parent::__construct($registry, BoardService::class); + } +} diff --git a/src/Repository/Groups/FindsByAccommodationAndDateRangeTrait.php b/src/Repository/Groups/FindsByAccommodationAndDateRangeTrait.php new file mode 100644 index 0000000..a69e452 --- /dev/null +++ b/src/Repository/Groups/FindsByAccommodationAndDateRangeTrait.php @@ -0,0 +1,41 @@ + + */ +trait FindsByAccommodationAndDateRangeTrait +{ + /** + * Returns all records active during the given date range. + * + * Overlap condition: entity.dateFrom <= dateTo AND entity.dateTo >= dateFrom + * + * @return array + */ + 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/Security/Voter/AdministrativeAccessVoter.php b/src/Security/Voter/AdministrativeAccessVoter.php new file mode 100644 index 0000000..c0276e2 --- /dev/null +++ b/src/Security/Voter/AdministrativeAccessVoter.php @@ -0,0 +1,33 @@ + + */ +class AdministrativeAccessVoter extends Voter +{ + public const ADMINISTRATIVE_ACCESS = 'ADMINISTRATIVE_ACCESS'; + + public function __construct( + private readonly AuthorizationCheckerInterface $authorizationChecker, + ) { + } + + protected function supports(string $attribute, mixed $subject): bool + { + return self::ADMINISTRATIVE_ACCESS === $attribute; + } + + protected function voteOnAttribute(string $attribute, mixed $subject, TokenInterface $token): bool + { + return $this->authorizationChecker->isGranted('ROLE_ADMIN') + || $this->authorizationChecker->isGranted('ROLE_GROUPS_MANAGER'); + } +} diff --git a/src/Service/AccommodationBookingBreakdownCalculator.php b/src/Service/AccommodationBookingBreakdownCalculator.php new file mode 100644 index 0000000..f2eebe7 --- /dev/null +++ b/src/Service/AccommodationBookingBreakdownCalculator.php @@ -0,0 +1,67 @@ +|null null when no accommodation or dates are set + */ + public function compute(AccommodationBooking $booking): ?array + { + if (null !== $booking->getPriceBreakdown()) { + return $booking->getPriceBreakdown(); + } + + return $this->computeCurrent($booking); + } + + /** + * Calculates against the current catalog. Use only while creating or explicitly editing + * a booking, or as a compatibility fallback for records created before price snapshots. + * + * @return array|null + */ + public function computeCurrent(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 0000000..8b608ca --- /dev/null +++ b/src/Service/AccommodationBookingLinkSigner.php @@ -0,0 +1,107 @@ +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; + } + + /** + * Marks the current session as authorized to view/act on this booking's offer. + * Called once, after a successful {@see isValidLinkRequest()} check at the + * signed-link entry point — every other route in this flow then trusts the + * session instead of re-deriving cryptographic validity from its own URL + * (which wouldn't work anyway, since the signature is bound to the exact + * URI it was generated for). + */ + public function authorizeSession(Request $request, AccommodationBooking $booking): void + { + $request->getSession()->set($this->sessionKey($booking), $booking->getAccessLinkIssuedAt()?->getTimestamp()); + } + + public function isSessionAuthorized(Request $request, AccommodationBooking $booking): bool + { + $issuedAt = $booking->getAccessLinkIssuedAt(); + + if (null === $issuedAt) { + return false; + } + + if ($request->getSession()->get($this->sessionKey($booking)) !== $issuedAt->getTimestamp()) { + return false; + } + + $expiresAt = $this->expiresAt($booking); + + return null !== $expiresAt && new \DateTimeImmutable() <= $expiresAt; + } + + private function sessionKey(AccommodationBooking $booking): string + { + return 'accommodation_offer_access_'.$booking->getUuid(); + } +} diff --git a/src/Service/AccommodationBookingService.php b/src/Service/AccommodationBookingService.php new file mode 100644 index 0000000..8c0d481 --- /dev/null +++ b/src/Service/AccommodationBookingService.php @@ -0,0 +1,527 @@ +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 details from the CMS for display in the booking flow — name, images, + * address, descriptive text blocks, amenity icons, and the surrounding region's info + * (name, coordinates, webcam, ski area text, stats, images). + */ + public function loadHotelCmsData(Accommodation $accommodation): ?CmsHotelData + { + return $this->cmsDataProvider->getHotelDetails($accommodation->getEffectiveCmsCode()); + } + + /** + * @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->refreshPriceSnapshot($booking); + + $this->entityManager->persist($booking); + $this->entityManager->flush(); + + return $booking; + } + + /** + * Persists the booking and sends the notification/confirmation emails in one go. + * + * @param AccommodationPrice[] $prices + * @param array{additionalServices: AdditionalService[], boardServices: mixed[]} $services + */ + public function finalizeBooking( + AccommodationBookingDto $dto, + Accommodation $accommodation, + array $prices, + array $services, + ): AccommodationBooking { + $booking = $this->persist($dto, $accommodation, $prices, $services); + $this->issueAccessLinkForDirectBooking($booking); + $this->sendNotificationEmail($booking); + $this->sendCustomerConfirmationEmail($booking); + + return $booking; + } + + public function refreshPriceSnapshot(AccommodationBooking $booking): void + { + $breakdown = $this->breakdownCalculator->computeCurrent($booking); + if (null === $breakdown) { + $booking->clearPriceSnapshot(); + + return; + } + + $accommodationBase = (int) ($breakdown['basePrice'] ?? 0) + (int) ($breakdown['additionalPersonsPrice'] ?? 0); + $boardBase = (int) ($breakdown['boardPrice'] ?? 0); + $servicesBase = (int) ($breakdown['servicesPrice'] ?? 0); + + $total = (int) ($breakdown['total'] ?? 0) + - $this->discountAmount($accommodationBase, $booking->getAccommodationDiscount()) + - $this->discountAmount($boardBase, $booking->getBoardServiceDiscount()) + - $this->discountAmount($servicesBase, $booking->getAdditionalServicesDiscount()); + + $booking->setPriceSnapshot( + $breakdown, + $total, + (string) ($breakdown['currency'] ?? $booking->getAccommodation()?->getCurrency() ?? 'EUR'), + self::PRICING_VERSION, + ); + } + + private function discountAmount(int $base, ?int $percent): int + { + return null !== $percent ? (int) round($base * $percent / 100) : 0; + } + + public function sendNotificationEmail(AccommodationBooking $booking): void + { + try { + $this->mailer->createAndSendEmail( + [ + 'booking' => $booking, + '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, + '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. No email side effect — sending is a separate, explicit admin + * action via sendCustomerConfirmationEmail(). + */ + public function regenerateAccessLink(AccommodationBooking $booking): void + { + $booking->setAccessLinkIssuedAt(new \DateTimeImmutable()); + $this->entityManager->flush(); + } + + /** + * Transitions a booking from inquiry to booking and notifies office and customer. + * Idempotent — a no-op (including no emails) 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(); + + $this->sendOfferAcceptedNotificationEmail($booking); + $this->sendOfferAcceptedCustomerEmail($booking); + } + + public function sendOfferAcceptedNotificationEmail(AccommodationBooking $booking): void + { + try { + $this->mailer->createAndSendEmail( + [ + 'booking' => $booking, + 'accessLink' => $this->accessLinkOrNull($booking), + ], + [ + 'to' => $this->officeEmail, + 'subject' => sprintf('Angebot angenommen: %s', $booking->getAccommodation()?->getName()), + 'template' => 'email/offer_accepted.html.twig', + 'attachments' => [], + ], + ); + } catch (\Throwable $e) { + $this->logger->error('Failed to send offer accepted notification email', [ + 'booking_id' => $booking->getId(), + 'error' => $e->getMessage(), + ]); + } + } + + public function sendOfferAcceptedCustomerEmail(AccommodationBooking $booking): void + { + try { + $this->mailer->createAndSendEmail( + [ + 'booking' => $booking, + 'accessLink' => $this->accessLinkOrNull($booking), + ], + [ + 'to' => $booking->getEmail(), + 'subject' => 'Deine Buchung ist bestätigt', + 'template' => 'email/offer_accepted_customer.html.twig', + 'attachments' => [], + ], + ); + } catch (\Throwable $e) { + $this->logger->error('Failed to send offer accepted customer email', [ + 'booking_id' => $booking->getId(), + 'error' => $e->getMessage(), + ]); + } + } + + 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 0000000..5cff919 --- /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/BookingSummaryAssembler.php b/src/Service/BookingSummaryAssembler.php index 2684569..401f8f7 100644 --- a/src/Service/BookingSummaryAssembler.php +++ b/src/Service/BookingSummaryAssembler.php @@ -12,7 +12,7 @@ use App\Form\Model\BookingDto; use App\Form\Model\BookingSummaryDto; use App\Form\Model\BookingSummaryPricingDto; use App\Form\Model\BookingSummaryVoucherDto; -use App\Model\BookingSummaryCmsHotelData; +use App\Model\CmsHotelData; use Psr\Log\LoggerInterface; use Symfony\Contracts\Cache\CacheInterface; use Symfony\Contracts\Cache\ItemInterface; @@ -126,7 +126,7 @@ class BookingSummaryAssembler * Data is cached for 1 hour. This method can be called early in the booking * flow to warm the cache. */ - public function getCmsDataForProduct(?string $productCode, ?string $hotelCode): ?BookingSummaryCmsHotelData + public function getCmsDataForProduct(?string $productCode, ?string $hotelCode): ?CmsHotelData { if (null === $hotelCode) { return null; @@ -144,7 +144,7 @@ class BookingSummaryAssembler // Fetch CMS images (nice to have) $images = $this->cmsDataService->getProductImages($productCode, $hotelCode); - return new BookingSummaryCmsHotelData( + return new CmsHotelData( name: $baseHotel?->name, address: $this->formatHotelAddress($baseHotel), images: $images, diff --git a/src/Service/CalendarGridBuilder.php b/src/Service/CalendarGridBuilder.php new file mode 100644 index 0000000..5d80310 --- /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/CmsDataProvider.php b/src/Service/CmsDataProvider.php index 1d385eb..ab0342d 100644 --- a/src/Service/CmsDataProvider.php +++ b/src/Service/CmsDataProvider.php @@ -2,13 +2,20 @@ namespace App\Service; +use App\Model\CmsHotelData; +use App\Model\CmsRegionData; +use Symfony\Contracts\Cache\CacheInterface; +use Symfony\Contracts\Cache\ItemInterface; use Symfony\Contracts\HttpClient\Exception\ExceptionInterface; use Symfony\Contracts\HttpClient\HttpClientInterface; class CmsDataProvider { - public function __construct(private readonly HttpClientInterface $httpClient, private readonly string $apiKey) - { + public function __construct( + private readonly HttpClientInterface $httpClient, + private readonly CacheInterface $cache, + private readonly string $apiKey, + ) { } /** @@ -27,33 +34,181 @@ class CmsDataProvider return $result['hotel']['images'] ?? null; } + /** + * @return array|null + */ + public function getHotelImages(string $hotelCode): ?array + { + return $this->getHotelDetails($hotelCode)?->images; + } + + public function getHotelDetails(string $hotelCode): ?CmsHotelData + { + $data = $this->fetchHotelDetails($hotelCode); + + if (isset($data['success']) && false === $data['success']) { + return null; + } + + return $this->mapHotelData($data); + } + + /** + * @return array{ + * success?: bool, + * message?: string, + * name?: string, + * address?: string, + * description?: string, + * features?: string, + * room_types?: string, + * additional_information?: string, + * images?: array, + * icons?: array, + * region?: array{ + * name: string, + * latitude: float, + * longitude: float, + * webcam: string, + * ski_area: string, + * ski_area_extended: string, + * description: string, + * news: string, + * length: int, + * altitude: int, + * lifts: int, + * images: array, + * region_maps: list, + * }, + * } + */ + private function fetchHotelDetails(string $hotelCode): array + { + return $this->cache->get( + sprintf('cms_hotel_%s', $hotelCode), + function (ItemInterface $item) use ($hotelCode): array { + $item->expiresAfter(3600); + + try { + $request = $this->httpClient->request('GET', 'api/hotel', [ + 'query' => [ + 'hotel' => $hotelCode, + 'key' => $this->apiKey, + ], + ]); + } catch (ExceptionInterface $e) { + $item->expiresAfter(0); + + return [ + 'success' => false, + 'message' => $e->getMessage(), + ]; + } + + try { + $data = $request->toArray(); + } catch (ExceptionInterface $e) { + $item->expiresAfter(0); + + return [ + 'success' => false, + 'message' => $e->getMessage(), + ]; + } + + return $data; + } + ); + } + + /** + * @param array $data see {@see self::fetchHotelDetails()} for the shape + */ + private function mapHotelData(array $data): CmsHotelData + { + return new CmsHotelData( + name: $data['name'] ?? null, + address: $data['address'] ?? null, + images: $data['images'] ?? null, + description: $data['description'] ?? null, + features: $data['features'] ?? null, + roomTypes: $data['room_types'] ?? null, + additionalInformation: $data['additional_information'] ?? null, + icons: $data['icons'] ?? null, + region: isset($data['region']) ? $this->mapRegionData($data['region']) : null, + ); + } + + /** + * @param array $region see {@see self::fetchHotelDetails()} for the shape + */ + private function mapRegionData(array $region): CmsRegionData + { + return new CmsRegionData( + name: $region['name'] ?? null, + latitude: self::toFloatOrNull($region['latitude'] ?? null), + longitude: self::toFloatOrNull($region['longitude'] ?? null), + webcam: $region['webcam'] ?? null, + skiArea: $region['ski_area'] ?? null, + skiAreaExtended: $region['ski_area_extended'] ?? null, + description: $region['description'] ?? null, + news: $region['news'] ?? null, + length: self::toIntOrNull($region['length'] ?? null), + altitude: self::toIntOrNull($region['altitude'] ?? null), + lifts: self::toIntOrNull($region['lifts'] ?? null), + images: $region['images'] ?? null, + regionMaps: $region['region_maps'] ?? null, + ); + } + + private static function toIntOrNull(mixed $value): ?int + { + return is_numeric($value) ? (int) $value : null; + } + + private static function toFloatOrNull(mixed $value): ?float + { + return is_numeric($value) ? (float) $value : null; + } + /** @return array */ public function getProductDetails(string $productCode, ?string $hotelCode = null): array { - try { - $request = $this->httpClient->request('GET', 'api/product', [ - 'query' => [ - 'product' => $productCode, - 'hotel' => $hotelCode, - 'key' => $this->apiKey, - ], - ]); - } catch (ExceptionInterface $e) { - return [ - 'success' => false, - 'message' => $e->getMessage(), - ]; - } + return $this->cache->get( + sprintf('cms_product_%s_%s', $productCode, $hotelCode ?? ''), + function (ItemInterface $item) use ($productCode, $hotelCode): array { + $item->expiresAfter(3600); - try { - $data = $request->toArray(); - } catch (ExceptionInterface $e) { - return [ - 'success' => false, - 'message' => $e->getMessage(), - ]; - } + try { + $request = $this->httpClient->request('GET', 'api/product', [ + 'query' => [ + 'product' => $productCode, + 'hotel' => $hotelCode, + 'key' => $this->apiKey, + ], + ]); + } catch (ExceptionInterface $e) { + $item->expiresAfter(0); - return $data; + return [ + 'success' => false, + 'message' => $e->getMessage(), + ]; + } + + try { + $data = $request->toArray(); + } catch (ExceptionInterface $e) { + $item->expiresAfter(0); + + return [ + 'success' => false, + 'message' => $e->getMessage(), + ]; + } + + return $data; + } + ); } } diff --git a/src/Service/GroupsPriceCalculator.php b/src/Service/GroupsPriceCalculator.php new file mode 100644 index 0000000..833dbc8 --- /dev/null +++ b/src/Service/GroupsPriceCalculator.php @@ -0,0 +1,308 @@ + short-term surcharge percentages (nights → percent) */ + private const array SHORT_TERM_FACTORS = [1 => 30, 2 => 20, 3 => 10]; + + /** @var array */ + private array $config; + + /** + * @param 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'], + '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/src/Service/PriceTimelineBuilder.php b/src/Service/PriceTimelineBuilder.php new file mode 100644 index 0000000..d2c0aad --- /dev/null +++ b/src/Service/PriceTimelineBuilder.php @@ -0,0 +1,174 @@ + OVERRIDE > base) and shorter/later periods + * on ties. When the winner is a DISCOUNT, resolveWinner() is called a second time + * on the null-type candidates to obtain the original base price, which is exposed + * as defaultPricePerNight / defaultPriceAdditionalPerson. + * 3. Consecutive segments whose winner AND default-price entity are both unchanged + * are merged into one row. Tracking the default-price entity separately prevents + * a DISCOUNT row from being incorrectly extended when the underlying base price + * changes mid-discount period. A gap (no winner) resets both trackers. + * + * @param AccommodationPrice[] $prices + * + * @return list + */ + public function buildTimeline( + array $prices, + \DateTimeImmutable $yearStart, + \DateTimeImmutable $yearEnd, + string $currency, + ): array { + if (empty($prices)) { + return []; + } + + $yearEndNext = $yearEnd->modify('+1 day'); + + // Step 1: collect all boundary points, keyed by timestamp for deduplication. + $boundaries = [ + $yearStart->getTimestamp() => $yearStart, + $yearEndNext->getTimestamp() => $yearEndNext, + ]; + foreach ($prices as $price) { + $from = $price->getDateFrom(); + $toNext = $price->getDateTo()->modify('+1 day'); + $boundaries[$from->getTimestamp()] = $from; + $boundaries[$toNext->getTimestamp()] = $toNext; + } + + ksort($boundaries); + $boundaries = array_values($boundaries); + + $rows = []; + $lastWinner = null; + $lastDefault = null; + + // Step 2: walk each segment [start, end) and resolve the effective price. + for ($i = 0, $count = count($boundaries) - 1; $i < $count; $i++) { + $segStart = $boundaries[$i]; + $segEndNext = $boundaries[$i + 1]; + + // Boundaries from prices outside the year are included to correctly detect + // overlaps at the year edges, but the segments themselves are skipped. + if ($segStart < $yearStart || $segStart >= $yearEndNext) { + continue; + } + + $candidates = array_filter( + $prices, + fn($p) => $p->getDateFrom() <= $segStart && $p->getDateTo() >= $segStart, + ); + + $winner = $this->resolveWinner($candidates); + + if ($winner === null) { + // Gap: reset merge state so the next winner always opens a new row. + $lastWinner = null; + $lastDefault = null; + continue; + } + + $defaultWinner = null; + if ($winner->getType() === PriceType::DISCOUNT) { + $defaults = array_filter($candidates, fn($p) => $p->getType() === null); + $defaultWinner = $this->resolveWinner($defaults); + } + + $segDateTo = $segEndNext->modify('-1 day'); + + // Step 3: extend the previous row if both the winner and the default-price entity + // are unchanged; otherwise open a new row. + if ($lastWinner === $winner && $lastDefault === $defaultWinner) { + $lastRow = $rows[count($rows) - 1]; + $rows[count($rows) - 1] = new PriceTimelineItem( + dateFrom: $lastRow->dateFrom, + dateTo: $segDateTo->format('Y-m-d'), + season: $lastRow->season, + includedPax: $lastRow->includedPax, + pricePerNight: $lastRow->pricePerNight, + priceAdditionalPerson: $lastRow->priceAdditionalPerson, + defaultPricePerNight: $lastRow->defaultPricePerNight, + defaultPriceAdditionalPerson: $lastRow->defaultPriceAdditionalPerson, + currency: $lastRow->currency, + type: $lastRow->type, + ); + } else { + $rows[] = new PriceTimelineItem( + dateFrom: $segStart->format('Y-m-d'), + dateTo: $segDateTo->format('Y-m-d'), + season: $winner->getSeason()?->value, + includedPax: $winner->getIncludedPax(), + pricePerNight: round($winner->getPricePerNight() / 100, 2), + priceAdditionalPerson: round($winner->getPriceAdditionalPerson() / 100, 2), + defaultPricePerNight: $defaultWinner !== null ? round($defaultWinner->getPricePerNight() / 100, 2) : null, + defaultPriceAdditionalPerson: $defaultWinner !== null ? round($defaultWinner->getPriceAdditionalPerson() / 100, 2) : null, + currency: $currency, + type: $winner->getType()?->value, + ); + $lastWinner = $winner; + $lastDefault = $defaultWinner; + } + } + + return $rows; + } + + /** + * Resolves which price entity wins for a set of candidates active at the same point in time. + * + * Higher-priority types win: DISCOUNT (2) > OVERRIDE (1) > base/null (0). + * Ties within the same type are broken by preferring the shorter period, then the later start date. + * + * @param AccommodationPrice[] $candidates + */ + public function resolveWinner(array $candidates): ?AccommodationPrice + { + $winner = null; + foreach ($candidates as $candidate) { + if ($winner === null) { + $winner = $candidate; + continue; + } + $cp = $candidate->getType()?->priority() ?? 0; + $wp = $winner->getType()?->priority() ?? 0; + if ($cp > $wp) { + $winner = $candidate; + continue; + } + if ($cp === $wp) { + $cs = $candidate->getDateFrom()->diff($candidate->getDateTo())->days; + $ws = $winner->getDateFrom()->diff($winner->getDateTo())->days; + if ($cs < $ws || ($cs === $ws && $candidate->getDateFrom() > $winner->getDateFrom())) { + $winner = $candidate; + } + } + } + + return $winner; + } +} diff --git a/src/Twig/AppRuntime.php b/src/Twig/AppRuntime.php index afa7b3b..c80ff92 100644 --- a/src/Twig/AppRuntime.php +++ b/src/Twig/AppRuntime.php @@ -209,6 +209,11 @@ class AppRuntime implements RuntimeExtensionInterface $labels = []; foreach ($form->children as $child) { + // Buttons (e.g. submit buttons) aren't validatable fields and have no errors/valid vars. + if (!\array_key_exists('errors', $child->vars)) { + continue; + } + $hasErrors = \count($child->vars['errors']) > 0; $hasInvalidChildren = false === $child->vars['valid']; diff --git a/src/Twig/Components/Calendar.php b/src/Twig/Components/Calendar.php new file mode 100644 index 0000000..4da9a08 --- /dev/null +++ b/src/Twig/Components/Calendar.php @@ -0,0 +1,319 @@ + 'Januar', 2 => 'Februar', 3 => 'März', 4 => 'April', + 5 => 'Mai', 6 => 'Juni', 7 => 'Juli', 8 => 'August', + 9 => 'September', 10 => 'Oktober', 11 => 'November', 12 => 'Dezember', + ]; + + private const TYPE_LABELS = [ + 'flat' => 'pauschal', + 'per_person' => 'p.P.', + 'per_night' => 'p.Nacht', + 'per_person_per_night' => 'p.P./Nacht', + ]; + + public Accommodation $accommodation; + public ?string $startMonth = null; + + /** @var list */ + public array $months = []; + public string $prevMonth = ''; + public string $nextMonth = ''; + public string $prevLabel = ''; + public string $nextLabel = ''; + + public function __construct( + private readonly ContingentsClient $contingentsClient, + private readonly CacheInterface $cache, + ) { + } + + public function mount(Accommodation $accommodation, ?string $startMonth = null): void + { + $this->accommodation = $accommodation; + $this->startMonth = $startMonth; + + if ($startMonth !== null && preg_match('/^\d{4}-\d{2}$/', $startMonth)) { + $start = CarbonImmutable::createFromFormat('Y-m-d', $startMonth . '-01'); + } else { + $allDates = $this->collectAllDates(); + $start = empty($allDates) + ? CarbonImmutable::now()->startOfMonth() + : $this->determineStartMonth($allDates); + } + + $map = $this->buildCoverageMap(); + $blockedDates = $this->fetchBlockedDates( + $accommodation->getCalendarCode() ?? '', + $start->format('Y-m-d'), + $start->addMonths(3)->subDay()->format('Y-m-d'), + ); + + $months = []; + $current = $start; + for ($i = 0; $i < 3; $i++) { + $months[] = $this->buildMonth($current, $map, $blockedDates); + $current = $current->addMonth(); + } + + $this->months = $months; + + $prevStart = $start->subMonths(3); + $prevEnd = $prevStart->addMonths(2); + $this->prevMonth = $prevStart->format('Y-m'); + $this->prevLabel = self::MONTHS[$prevStart->month] . ' – ' . self::MONTHS[$prevEnd->month] . ' ' . $prevEnd->year; + + $nextStart = $start->addMonths(3); + $nextEnd = $nextStart->addMonths(2); + $this->nextMonth = $nextStart->format('Y-m'); + $this->nextLabel = self::MONTHS[$nextStart->month] . ' – ' . self::MONTHS[$nextEnd->month] . ' ' . $nextEnd->year; + } + + /** + * @return array keyed by Y-m-d, only BLOCKED dates present + */ + private function fetchBlockedDates(string $hotelCode, string $dateFrom, string $dateTo): array + { + if ($hotelCode === '') { + return []; + } + + try { + $cacheKey = sprintf('contingents_calendar_%s_%s_%s', $hotelCode, $dateFrom, $dateTo); + $response = $this->cache->get( + $cacheKey, + function (ItemInterface $item) use ($hotelCode, $dateFrom, $dateTo) { + $item->expiresAfter(3600); + + return $this->contingentsClient->getContingentCalendar($hotelCode, $dateFrom, $dateTo); + }, + ); + } catch (BpnConnectException|InvalidArgumentException) { + return []; + } + + $blocked = []; + foreach ($response->data as $entry) { + if ($entry->status === ContingentStatus::Blocked) { + $blocked[(new \DateTimeImmutable($entry->date))->format('Y-m-d')] = true; + } + } + + return $blocked; + } + + /** + * @return CarbonImmutable[] + */ + private function collectAllDates(): array + { + $dates = []; + + foreach ($this->accommodation->getAccommodationPrices() as $price) { + if ($price->getDateFrom() !== null) { + $dates[] = CarbonImmutable::instance($price->getDateFrom()); + } + if ($price->getDateTo() !== null) { + $dates[] = CarbonImmutable::instance($price->getDateTo()); + } + } + foreach ($this->accommodation->getBoardServices() as $service) { + if ($service->getDateFrom() !== null) { + $dates[] = CarbonImmutable::instance($service->getDateFrom()); + } + if ($service->getDateTo() !== null) { + $dates[] = CarbonImmutable::instance($service->getDateTo()); + } + } + foreach ($this->accommodation->getAdditionalServices() as $service) { + if ($service->getDateFrom() !== null) { + $dates[] = CarbonImmutable::instance($service->getDateFrom()); + } + if ($service->getDateTo() !== null) { + $dates[] = CarbonImmutable::instance($service->getDateTo()); + } + } + + return $dates; + } + + /** + * @param CarbonImmutable[] $dates + */ + private function determineStartMonth(array $dates): CarbonImmutable + { + $earliest = min($dates); + $cutoff = CarbonImmutable::now()->subDays(60); + + if ($earliest >= $cutoff) { + return $earliest->startOfMonth(); + } + + return CarbonImmutable::now()->startOfMonth(); + } + + /** + * @return array + */ + private function buildCoverageMap(): array + { + $map = []; + + foreach ($this->accommodation->getAccommodationPrices() as $price) { + if ($price->getDateFrom() === null || $price->getDateTo() === null) { + continue; + } + + $priceFormatted = number_format($price->getPricePerNight() / 100, 2, ',', '.') . ' €'; + $label = sprintf('Preis: %s/Nacht (min. %d Nächte)', $priceFormatted, $price->getMinNights()); + + $day = CarbonImmutable::instance($price->getDateFrom()); + $end = CarbonImmutable::instance($price->getDateTo()); + $span = $day->diffInDays($end); + $priority = $price->getType()?->priority() ?? 0; + $limit = $day->addYears(3); + + // $end is the last night (inclusive), so <= is correct + while ($day <= $end && $day <= $limit) { + $key = $day->format('Y-m-d'); + $map[$key] ??= ['price' => null, 'board' => null, 'additional' => null]; + $existing = $map[$key]['price']; + $replace = $existing === null + || $priority > $existing['priority'] + || ($priority === $existing['priority'] && $span < $existing['span']); + if ($replace) { + $map[$key]['price'] = ['label' => $label, 'span' => $span, 'priority' => $priority]; + } + $day = $day->addDay(); + } + } + + foreach ($this->accommodation->getBoardServices() as $service) { + if ($service->getDateFrom() === null || $service->getDateTo() === null) { + continue; + } + + $label = 'Verpflegung: ' . $service->getLabel(); + + $day = CarbonImmutable::instance($service->getDateFrom()); + $end = CarbonImmutable::instance($service->getDateTo()); + $span = $day->diffInDays($end); + $limit = $day->addYears(3); + + while ($day <= $end && $day <= $limit) { + $key = $day->format('Y-m-d'); + $map[$key] ??= ['price' => null, 'board' => null, 'additional' => null]; + if ($map[$key]['board'] === null || $span < $map[$key]['board']['span']) { + $map[$key]['board'] = ['label' => $label, 'span' => $span]; + } + $day = $day->addDay(); + } + } + + foreach ($this->accommodation->getAdditionalServices() as $service) { + if ($service->getDateFrom() === null || $service->getDateTo() === null) { + continue; + } + + $typeLabel = $service->getType() !== null + ? self::TYPE_LABELS[$service->getType()->value] + : ''; + $label = sprintf('Zusatz: %s%s', $service->getLabel(), $typeLabel !== '' ? ' (' . $typeLabel . ')' : ''); + + $day = CarbonImmutable::instance($service->getDateFrom()); + $end = CarbonImmutable::instance($service->getDateTo()); + $span = $day->diffInDays($end); + $limit = $day->addYears(3); + + while ($day <= $end && $day <= $limit) { + $key = $day->format('Y-m-d'); + $map[$key] ??= ['price' => null, 'board' => null, 'additional' => null]; + if ($map[$key]['additional'] === null || $span < $map[$key]['additional']['span']) { + $map[$key]['additional'] = ['label' => $label, 'span' => $span]; + } + $day = $day->addDay(); + } + } + + return $map; + } + + /** + * @param array $map + * @param array $blockedDates + */ + private function buildMonth(CarbonImmutable $month, array $map, array $blockedDates): CalendarMonth + { + $start = $month->startOfMonth(); + $today = CarbonImmutable::now()->format('Y-m-d'); + + // Mon=0 … Sun=6 (Carbon: 0=Sunday, 1=Monday … 6=Saturday) + $firstDow = ($start->dayOfWeek + 6) % 7; + + $days = []; + for ($d = 1; $d <= $start->daysInMonth; $d++) { + $dateStr = $month->format('Y-m') . '-' . str_pad((string) $d, 2, '0', STR_PAD_LEFT); + $coverage = $map[$dateStr] ?? []; + + $parts = []; + if (isset($coverage['price'])) { + $parts[] = $coverage['price']['label']; + } + if (isset($coverage['board'])) { + $parts[] = $coverage['board']['label']; + } + if (isset($coverage['additional'])) { + $parts[] = $coverage['additional']['label']; + } + + $isBlocked = isset($blockedDates[$dateStr]); + if ($isBlocked) { + $parts[] = 'Buchungsstopp'; + } + + $days[$d] = new CalendarDay( + date: $dateStr, + hasPrice: isset($coverage['price']), + hasBoard: isset($coverage['board']), + hasAdditional: isset($coverage['additional']), + isBlocked: $isBlocked, + tooltip: !empty($parts) ? implode(' | ', $parts) : null, + isToday: $dateStr === $today, + ); + } + + return new CalendarMonth( + label: self::MONTHS[$month->month] . ' ' . $month->year, + firstDow: $firstDow, + days: $days, + ); + } +} diff --git a/symfony.lock b/symfony.lock index 381463e..22dc9e2 100644 --- a/symfony.lock +++ b/symfony.lock @@ -358,6 +358,18 @@ "config/packages/uid.yaml" ] }, + "symfony/ux-twig-component": { + "version": "2.36", + "recipe": { + "repo": "github.com/symfony/recipes", + "branch": "main", + "version": "2.13", + "ref": "f367ae2a1faf01c503de2171f1ec22567febeead" + }, + "files": [ + "config/packages/twig_component.yaml" + ] + }, "symfony/validator": { "version": "6.4", "recipe": { diff --git a/templates/_partials/_flashes_admin.html.twig b/templates/_partials/_flashes_admin.html.twig new file mode 100644 index 0000000..ed4261b --- /dev/null +++ b/templates/_partials/_flashes_admin.html.twig @@ -0,0 +1,9 @@ +{% if app.session.flashBag.peek('error')|length > 0 %} + {% include '_partials/_toast.html.twig' with { level: 'warning', messages: app.flashes('error') } %} +{% endif %} +{% if app.session.flashBag.peek('success')|length > 0 %} + {% include '_partials/_toast.html.twig' with { level: 'success', messages: app.flashes('success') } %} +{% endif %} +{% if app.session.flashBag.peek('info')|length > 0 %} + {% include '_partials/_toast.html.twig' with { level: 'info', messages: app.flashes('info') } %} +{% endif %} diff --git a/templates/_partials/_menu.html.twig b/templates/_partials/_menu.html.twig index 47c2e74..e9e77c8 100644 --- a/templates/_partials/_menu.html.twig +++ b/templates/_partials/_menu.html.twig @@ -41,9 +41,10 @@ {% import _self as knp_menu %} {{ block('label') }} @@ -73,7 +74,7 @@ {% block label %} {% if item.extras.icon is defined %} - {{ icon(item.extras.icon, 'h-5 w-5 shrink-0 text-gray-400') }} + {{ icon(item.extras.icon, 'h-5 w-5 shrink-0 ' ~ ((matcher.isCurrent(item) or matcher.isAncestor(item)) ? 'text-gray-600' : 'text-gray-400')) }} {{ item.label|raw }} {% else %} {{ item.label|raw }} diff --git a/templates/_partials/_toast.html.twig b/templates/_partials/_toast.html.twig new file mode 100644 index 0000000..e5785c3 --- /dev/null +++ b/templates/_partials/_toast.html.twig @@ -0,0 +1,10 @@ +{% apply spaceless %} + {% for message in messages %} + {% if message is iterable %} + {% set message = message.id | trans(message.parameters | default({}), message.domain | default(null), message.locale | default(null)) | raw %} + {% else %} + {% set message = message | trans | raw %} + {% endif %} +
+ {% endfor %} +{% endapply %} diff --git a/templates/admin/accommodation/_calendar_section.html.twig b/templates/admin/accommodation/_calendar_section.html.twig new file mode 100644 index 0000000..e28bc0f --- /dev/null +++ b/templates/admin/accommodation/_calendar_section.html.twig @@ -0,0 +1 @@ + diff --git a/templates/admin/accommodation/_form.html.twig b/templates/admin/accommodation/_form.html.twig new file mode 100644 index 0000000..c86978b --- /dev/null +++ b/templates/admin/accommodation/_form.html.twig @@ -0,0 +1,305 @@ +{% form_theme form 'forms_admin.html.twig' %} + +{{ form_start(form) }} +
+
+
+ {{ form_row(form.name) }} +
+ {{ form_row(form.calendarCode) }} + {{ form_row(form.cmsCode) }} + {{ form_row(form.maxAdolescentAge) }} + {{ form_row(form.currency) }} +
+ {% if cmsData is not null and cmsData.name is not null %} +
+ {% if cmsData.images.resized.m | length > 0 %} + {% set image = cmsData.images.resized.m[0] %} +
+ {{ image.alt }} +
+ {% endif %} +
+ {{ cmsData.name }} +
+ {{ cmsData.address | default('-') | nl2br }} +
+
+ {% else %} +
+
+ +
+
+ CMS-Code falsch oder Haus in TYPO3 nicht angelegt +
+
+ {% endif %} +
+
+ + zurück + + +
+{{ form_rest(form) }} +{{ form_end(form) }} + +
+ + + +
+ +
+

+ 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 +

+
+
+ + + + + + + + + + + {% for service in accommodation.boardServices %} + + + + + + + {% else %} + + + + {% endfor %} + +
+ Datums-Bereich + + Bezeichnung + + Preis pro Person/Nacht +
+ {{ service.dateFrom | date('d.m.Y') }} - {{ service.dateTo | date('d.m.Y') }} + + {{ service.label }} + + {{ (service.price / 100) | format_currency(accommodation.currency) }} + + + + bearbeiten + + + duplizieren + + + löschen + + +
+ Keine Daten +
+
+
+ +
+ +
+ +
+

+ Optionale Zusatzleistungen +

+
+
+ + + + + + + + + + + + {% for service in accommodation.additionalServices %} + + + + + + + + {% else %} + + + + {% endfor %} + +
+ Datums-Bereich + + Bezeichnung + + Preistyp + + Preis +
+ {{ 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 + + +
+ Keine Daten +
+
+
+ +
diff --git a/templates/admin/accommodation/create.html.twig b/templates/admin/accommodation/create.html.twig new file mode 100644 index 0000000..1b46b6d --- /dev/null +++ b/templates/admin/accommodation/create.html.twig @@ -0,0 +1,6 @@ +{% extends 'layout_admin.html.twig' %} + +{% block content %} + Gruppenhaus neu + {% include 'admin/accommodation/_form.html.twig' %} +{% endblock %} diff --git a/templates/admin/accommodation/edit.html.twig b/templates/admin/accommodation/edit.html.twig new file mode 100644 index 0000000..6cdc4e2 --- /dev/null +++ b/templates/admin/accommodation/edit.html.twig @@ -0,0 +1,6 @@ +{% extends 'layout_admin.html.twig' %} + +{% block content %} + Gruppenhaus {{ accommodation.name }} + {% include 'admin/accommodation/_form.html.twig' %} +{% endblock %} diff --git a/templates/admin/accommodation/index.html.twig b/templates/admin/accommodation/index.html.twig new file mode 100644 index 0000000..5047567 --- /dev/null +++ b/templates/admin/accommodation/index.html.twig @@ -0,0 +1,77 @@ +{% extends 'layout_admin.html.twig' %} + +{% block content %} + Gruppenhäuser +
+
+ + + + + + + + + + + + {% for accommodation in accommodations %} + + + + + + + + {% else %} + + + + {% endfor %} + +
+ Name + + Code + + erstellt + + aktualisiert +
+ + {{ accommodation.name }} + + + {{ accommodation.calendarCode }} + + {{ accommodation.createdAt | date('d.m.Y, H:i') }} + + {% if accommodation.updatedAt is not null %} + {{ accommodation.updatedAt | date('d.m.Y, H:i') }} + {% else %} + - + {% endif %} + + + + bearbeiten + + + löschen + + +
+ Keine Daten... +
+
+
+
+ +
+{% endblock %} diff --git a/templates/admin/accommodation/modal_create.html.twig b/templates/admin/accommodation/modal_create.html.twig new file mode 100644 index 0000000..b9bbb6b --- /dev/null +++ b/templates/admin/accommodation/modal_create.html.twig @@ -0,0 +1,22 @@ +{% extends 'htmx_modal_admin.html.twig' %} +{% form_theme form 'forms_admin.html.twig' %} + +{% block content %} +
+ Neues Gruppenhaus +
+ {{ form_start(form) }} +
+ {{ form_row(form.name) }} + {{ form_row(form.calendarCode) }} + {{ form_row(form.maxAdolescentAge) }} + {{ form_row(form.currency) }} +
+
+ +
+ {{ form_rest(form) }} + {{ form_end(form) }} +{% endblock %} diff --git a/templates/admin/accommodation/modal_delete.html.twig b/templates/admin/accommodation/modal_delete.html.twig new file mode 100644 index 0000000..730d676 --- /dev/null +++ b/templates/admin/accommodation/modal_delete.html.twig @@ -0,0 +1,7 @@ +{% extends 'htmx_confirmation_modal.html.twig' %} + +{% block content %} +
+ Möchtest du das Gruppenhaus {{ accommodation.name }} wirklich löschen? +
+{% endblock %} diff --git a/templates/admin/accommodation_booking/create.html.twig b/templates/admin/accommodation_booking/create.html.twig new file mode 100644 index 0000000..f9d11de --- /dev/null +++ b/templates/admin/accommodation_booking/create.html.twig @@ -0,0 +1,53 @@ +{% 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.accommodationDiscount) }} + {{ form_row(form.boardServiceDiscount) }} + {{ form_row(form.additionalServicesDiscount) }} +
+ {{ 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 0000000..172f15f --- /dev/null +++ b/templates/admin/accommodation_booking/edit.html.twig @@ -0,0 +1,67 @@ +{% 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) }} +
+ {% if booking.acceptedAt is not null %} +

+ Angenommen am {{ booking.acceptedAt | date('d.m.Y, H:i') }} +

+ {% endif %} + {{ form_row(form.accommodationDiscount) }} + {{ form_row(form.boardServiceDiscount) }} + {{ form_row(form.additionalServicesDiscount) }} +
+ {{ 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 0000000..04c88e6 --- /dev/null +++ b/templates/admin/accommodation_booking/index.html.twig @@ -0,0 +1,100 @@ +{% 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 %} + + {% set discounts = [] %} + {% if booking.accommodationDiscount is not null %} + {% set discounts = discounts | merge(['Unterkunft ' ~ booking.accommodationDiscount ~ '%']) %} + {% endif %} + {% if booking.boardServiceDiscount is not null %} + {% set discounts = discounts | merge(['Verpflegung ' ~ booking.boardServiceDiscount ~ '%']) %} + {% endif %} + {% if booking.additionalServicesDiscount is not null %} + {% set discounts = discounts | merge(['Zusatzleistungen ' ~ booking.additionalServicesDiscount ~ '%']) %} + {% endif %} + {{ discounts | length > 0 ? (discounts | join(', ')) : '–' }} + + + + Details + + + bearbeiten + + +
+ Keine Daten... +
+ {{ knp_pagination_render(pagination) }} +
+
+ +{% endblock %} diff --git a/templates/admin/accommodation_booking/modal_generate_access_link.html.twig b/templates/admin/accommodation_booking/modal_generate_access_link.html.twig new file mode 100644 index 0000000..edd3e83 --- /dev/null +++ b/templates/admin/accommodation_booking/modal_generate_access_link.html.twig @@ -0,0 +1,8 @@ +{% extends 'htmx_confirmation_modal.html.twig' %} + +{% block content %} +
+ Möchtest du für {{ booking.groupName }} einen neuen Zugangslink generieren? + Ein zuvor generierter Link wird dadurch ungültig. +
+{% endblock %} diff --git a/templates/admin/accommodation_booking/modal_send_access_link.html.twig b/templates/admin/accommodation_booking/modal_send_access_link.html.twig new file mode 100644 index 0000000..927091f --- /dev/null +++ b/templates/admin/accommodation_booking/modal_send_access_link.html.twig @@ -0,0 +1,8 @@ +{% extends 'htmx_confirmation_modal.html.twig' %} + +{% block content %} +
+ Möchtest du den aktuellen Zugangslink für {{ booking.groupName }} an + {{ booking.email }} senden? +
+{% endblock %} diff --git a/templates/admin/accommodation_booking/show.html.twig b/templates/admin/accommodation_booking/show.html.twig new file mode 100644 index 0000000..87f3913 --- /dev/null +++ b/templates/admin/accommodation_booking/show.html.twig @@ -0,0 +1,237 @@ +{% 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.acceptedAt is not null %} +
Angenommen am
+
{{ booking.acceptedAt | date('d.m.Y, H:i') }}
+ {% 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.pricingCurrency ?? priceBreakdown.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.accommodationDiscount is not null or booking.boardServiceDiscount is not null or booking.additionalServicesDiscount is not null %} + {% if booking.accommodationDiscount is not null %} + {% set accommodationDiscountAmount = ((priceBreakdown.basePrice + priceBreakdown.additionalPersonsPrice) * booking.accommodationDiscount / 100) | round %} + + + + + {% endif %} + {% if booking.boardServiceDiscount is not null %} + {% set boardDiscountAmount = (priceBreakdown.boardPrice * booking.boardServiceDiscount / 100) | round %} + + + + + {% endif %} + {% if booking.additionalServicesDiscount is not null %} + {% set servicesDiscountAmount = (priceBreakdown.servicesPrice * booking.additionalServicesDiscount / 100) | round %} + + + + + {% endif %} + + + + + {% 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 Unterkunft {{ booking.accommodationDiscount }} %– {{ (accommodationDiscountAmount / 100) | format_currency(currency) }}
Rabatt Verpflegung {{ booking.boardServiceDiscount }} %– {{ (boardDiscountAmount / 100) | format_currency(currency) }}
Rabatt Zusatzleistungen {{ booking.additionalServicesDiscount }} %– {{ (servicesDiscountAmount / 100) | format_currency(currency) }}
Gesamtpreis nach Rabatt{{ ((booking.totalPrice ?? priceBreakdown.total) / 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 %} + +

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

+ {% else %} +

Es wurde noch kein Zugangslink generiert.

+ {% endif %} +
+ + {% if accessLink %} + + {% endif %} +
+
+ + +{% endblock %} diff --git a/templates/admin/accommodation_price/_form.html.twig b/templates/admin/accommodation_price/_form.html.twig new file mode 100644 index 0000000..9c950a8 --- /dev/null +++ b/templates/admin/accommodation_price/_form.html.twig @@ -0,0 +1,29 @@ +{% form_theme form 'forms_admin.html.twig' %} + +{{ form_start(form) }} +
+
+ {{ form_row(form.dateFrom) }} + {{ form_row(form.dateTo) }} + {{ form_row(form.season) }} + {{ form_row(form.type) }} +
+ {{ form_row(form.pricePerNight) }} + {{ form_row(form.priceAdditionalPerson) }} + {{ form_row(form.includedPax) }} + {{ form_row(form.minNights) }} + {{ form_row(form.acceptUndersubscription) }} + {{ form_row(form.acceptShortTerm) }} +
+
+ + zurück + + +
+{{ form_rest(form) }} +{{ form_end(form) }} diff --git a/templates/admin/accommodation_price/create.html.twig b/templates/admin/accommodation_price/create.html.twig new file mode 100644 index 0000000..03b47f7 --- /dev/null +++ b/templates/admin/accommodation_price/create.html.twig @@ -0,0 +1,6 @@ +{% extends 'layout_admin.html.twig' %} + +{% block content %} + Preis neu für Gruppenhaus {{ accommodation.name }} + {% include 'admin/accommodation_price/_form.html.twig' %} +{% endblock %} diff --git a/templates/admin/accommodation_price/edit.html.twig b/templates/admin/accommodation_price/edit.html.twig new file mode 100644 index 0000000..b23557f --- /dev/null +++ b/templates/admin/accommodation_price/edit.html.twig @@ -0,0 +1,6 @@ +{% extends 'layout_admin.html.twig' %} + +{% block content %} + Preis für Gruppenhaus {{ accommodation.name }}{% if duplicate %} duplizieren{% endif %} + {% include 'admin/accommodation_price/_form.html.twig' %} +{% endblock %} diff --git a/templates/admin/accommodation_price/modal_delete.html.twig b/templates/admin/accommodation_price/modal_delete.html.twig new file mode 100644 index 0000000..17951be --- /dev/null +++ b/templates/admin/accommodation_price/modal_delete.html.twig @@ -0,0 +1,7 @@ +{% extends 'htmx_confirmation_modal.html.twig' %} + +{% block content %} +
+ Möchtest du den Gruppenhaus-Preis {{ accommodation.name }}, {{ accommodationPrice.dateFrom | date('d.m.Y') }} - {{ accommodationPrice.dateTo | date('d.m.Y') }} wirklich löschen? +
+{% endblock %} diff --git a/templates/admin/additional_service/_form.html.twig b/templates/admin/additional_service/_form.html.twig new file mode 100644 index 0000000..663e207 --- /dev/null +++ b/templates/admin/additional_service/_form.html.twig @@ -0,0 +1,26 @@ +{% form_theme form 'forms_admin.html.twig' %} + +{{ form_start(form) }} +
+ {{ 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 + + +
+{{ form_rest(form) }} +{{ form_end(form) }} diff --git a/templates/admin/additional_service/create.html.twig b/templates/admin/additional_service/create.html.twig new file mode 100644 index 0000000..9e30696 --- /dev/null +++ b/templates/admin/additional_service/create.html.twig @@ -0,0 +1,6 @@ +{% extends 'layout_admin.html.twig' %} + +{% block content %} + Zusatzleistung neu für Gruppenhaus {{ accommodation.name }} + {% include 'admin/additional_service/_form.html.twig' %} +{% endblock %} diff --git a/templates/admin/additional_service/edit.html.twig b/templates/admin/additional_service/edit.html.twig new file mode 100644 index 0000000..a38e479 --- /dev/null +++ b/templates/admin/additional_service/edit.html.twig @@ -0,0 +1,6 @@ +{% extends 'layout_admin.html.twig' %} + +{% block content %} + Zusatzleistung für Gruppenhaus {{ accommodation.name }}{% if duplicate %} duplizieren{% endif %} + {% include 'admin/additional_service/_form.html.twig' %} +{% endblock %} diff --git a/templates/admin/additional_service/modal_delete.html.twig b/templates/admin/additional_service/modal_delete.html.twig new file mode 100644 index 0000000..bca6b1a --- /dev/null +++ b/templates/admin/additional_service/modal_delete.html.twig @@ -0,0 +1,7 @@ +{% extends 'htmx_confirmation_modal.html.twig' %} + +{% block content %} +
+ Möchtest du die Zusatzleistung {{ additionalService.label }}, {{ accommodation.name }} wirklich löschen? +
+{% endblock %} diff --git a/templates/admin/board_service/_form.html.twig b/templates/admin/board_service/_form.html.twig new file mode 100644 index 0000000..bcf0faf --- /dev/null +++ b/templates/admin/board_service/_form.html.twig @@ -0,0 +1,24 @@ +{% form_theme form 'forms_admin.html.twig' %} + +{{ form_start(form) }} +
+ {{ form_row(form.label) }} + {{ form_row(form.price) }} +
+ {{ form_row(form.description) }} +
+ {{ form_row(form.dateFrom) }} + {{ form_row(form.dateTo) }} +
+
+ + zurück + + +
+{{ form_rest(form) }} +{{ form_end(form) }} diff --git a/templates/admin/board_service/create.html.twig b/templates/admin/board_service/create.html.twig new file mode 100644 index 0000000..1f13fec --- /dev/null +++ b/templates/admin/board_service/create.html.twig @@ -0,0 +1,6 @@ +{% extends 'layout_admin.html.twig' %} + +{% block content %} + Verpflegungsleistung neu für Gruppenhaus {{ accommodation.name }} + {% include 'admin/board_service/_form.html.twig' %} +{% endblock %} diff --git a/templates/admin/board_service/edit.html.twig b/templates/admin/board_service/edit.html.twig new file mode 100644 index 0000000..ce95848 --- /dev/null +++ b/templates/admin/board_service/edit.html.twig @@ -0,0 +1,6 @@ +{% extends 'layout_admin.html.twig' %} + +{% block content %} + Verpflegungsleistung für Gruppenhaus {{ accommodation.name }}{% if duplicate %} duplizieren{% endif %} + {% include 'admin/board_service/_form.html.twig' %} +{% endblock %} diff --git a/templates/admin/board_service/modal_delete.html.twig b/templates/admin/board_service/modal_delete.html.twig new file mode 100644 index 0000000..7da5ad0 --- /dev/null +++ b/templates/admin/board_service/modal_delete.html.twig @@ -0,0 +1,7 @@ +{% extends 'htmx_confirmation_modal.html.twig' %} + +{% block content %} +
+ Möchtest du die Verpflegungsleistung {{ boardService.label }}, {{ accommodation.name }} wirklich löschen? +
+{% endblock %} diff --git a/templates/admin/booking_edit_draft/index.html.twig b/templates/admin/booking_edit_draft/index.html.twig index b294ba5..6ff0270 100644 --- a/templates/admin/booking_edit_draft/index.html.twig +++ b/templates/admin/booking_edit_draft/index.html.twig @@ -1,9 +1,7 @@ {% extends 'layout_admin.html.twig' %} {% block content %} -

- Buchungsentwürfe -

+ Buchungsentwürfe
@@ -44,32 +42,17 @@ {% endif %} {% else %} diff --git a/templates/admin/booking_edit_draft/show.html.twig b/templates/admin/booking_edit_draft/show.html.twig index 7181cfd..4ca9df5 100644 --- a/templates/admin/booking_edit_draft/show.html.twig +++ b/templates/admin/booking_edit_draft/show.html.twig @@ -1,9 +1,7 @@ {% extends 'layout_admin.html.twig' %} {% block content %} -

- Buchungsentwurf -

+ Buchungsentwurf

Vorgang {{ draft.bookingNumber }}

diff --git a/templates/admin/log/index.html.twig b/templates/admin/log/index.html.twig index ad6a91b..d81698a 100644 --- a/templates/admin/log/index.html.twig +++ b/templates/admin/log/index.html.twig @@ -1,9 +1,7 @@ {% extends 'layout_admin.html.twig' %} {% block content %} -

- Logs -

+ Logs
- {% embed '_partials/_dropdown.html.twig' %} - {% block content %} - - Details - - - XLS-Export - - - {% endblock %} - {% endembed %} - + + + Details + + + XLS-Export + + + löschen + +
@@ -53,16 +51,11 @@ diff --git a/templates/admin/log/xml_dumps.html.twig b/templates/admin/log/xml_dumps.html.twig index ff13a3e..7b181c3 100644 --- a/templates/admin/log/xml_dumps.html.twig +++ b/templates/admin/log/xml_dumps.html.twig @@ -1,9 +1,7 @@ {% extends 'layout_admin.html.twig' %} {% block content %} -

- XML-Dumps für Request ID {{ logEntry.requestId }} -

+ XML-Dumps für Request ID {{ logEntry.requestId }} {% if dumps is empty %}
@@ -37,9 +35,9 @@
{% if entry.channel == 'bpn' %} - {% embed '_partials/_dropdown.html.twig' %} - {% block content %} - + + XML-Dumps - - {% endblock %} - {% endembed %} + + {% endif %}
{% if dump.type == 'request' %} - Request + Request {% else %} - Response + Response {% endif %} diff --git a/templates/admin/user/index.html.twig b/templates/admin/user/index.html.twig index d5b1d41..c4178cf 100644 --- a/templates/admin/user/index.html.twig +++ b/templates/admin/user/index.html.twig @@ -1,9 +1,7 @@ {% extends 'layout_admin.html.twig' %} {% block content %} -

- Benutzeraccounts -

+ Benutzeraccounts
diff --git a/templates/base_admin.html.twig b/templates/base_admin.html.twig index a22f927..eda8abd 100644 --- a/templates/base_admin.html.twig +++ b/templates/base_admin.html.twig @@ -9,10 +9,10 @@ {% block stylesheets %} - {{ encore_entry_link_tags('app') }} + {{ encore_entry_link_tags('admin') }} {% endblock %} {% block javascripts %} - {{ encore_entry_script_tags('app') }} + {{ encore_entry_script_tags('admin') }} {% endblock %} diff --git a/templates/components/badge.html.twig b/templates/components/badge.html.twig new file mode 100644 index 0000000..a5b5a49 --- /dev/null +++ b/templates/components/badge.html.twig @@ -0,0 +1,11 @@ +{% set variant = variant ?? 'primary' %} +{% set cva = html_cva('badge', { + variant: { + primary: 'bg-primary text-white', + success: 'bg-green-500 text-white', + warning: 'bg-red-500 text-white', + } +}, [], { variant: 'primary' }) %} + + {{ block('content') }} + diff --git a/templates/components/calendar/grid.html.twig b/templates/components/calendar/grid.html.twig new file mode 100644 index 0000000..dd0fff4 --- /dev/null +++ b/templates/components/calendar/grid.html.twig @@ -0,0 +1,71 @@ +{% set weekdays = ['Mo', 'Di', 'Mi', 'Do', 'Fr', 'Sa', 'So'] %} +
+ {% for month in months %} + {% set totalCols = month.firstDow + (month.days | length) %} +
+
{{ month.label }}
+
+
+
+
+ {% for col in 0..(totalCols - 1) %} + {% set dow = col % 7 %} +
+ {{ weekdays[dow] }} +
+ {% endfor %} +
+
+
+ {% if month.firstDow > 0 %} + {% for i in 1..month.firstDow %} +
+ {% endfor %} + {% endif %} + {% for dayNum, day in month.days %} +
+ {{ dayNum }} +
+ {% endfor %} +
+ {% for row in [ + {label: 'Preis', key: 'hasPrice', color: 'bg-blue-500'}, + {label: 'Verpfl.', key: 'hasBoard', color: 'bg-green-500'}, + {label: 'Zusatzl.', key: 'hasAdditional', color: 'bg-amber-500'}, + {label: 'n. v.', key: 'isBlocked', color: 'bg-red-500'}, + ] %} +
+
{{ row.label }}
+ {% if month.firstDow > 0 %} + {% for i in 1..month.firstDow %} +
+ {% endfor %} + {% endif %} + {% for dayNum, day in month.days %} +
+
+
+ {% endfor %} +
+ {% endfor %} +
+
+
+ {% endfor %} +
+
+
+ Preiskonfigurationen +
+
+ Verpflegungsleistungen +
+
+ optionale Zusatzleistungen +
+
+ nicht verfügbar +
+
diff --git a/templates/components/calendar/index.html.twig b/templates/components/calendar/index.html.twig new file mode 100644 index 0000000..ab195ee --- /dev/null +++ b/templates/components/calendar/index.html.twig @@ -0,0 +1,17 @@ +
+ +
+ + +
+
diff --git a/templates/components/dropdown/hxbutton.html.twig b/templates/components/dropdown/hxbutton.html.twig new file mode 100644 index 0000000..442a9cb --- /dev/null +++ b/templates/components/dropdown/hxbutton.html.twig @@ -0,0 +1,20 @@ +{% set warning = warning ?? false %} +{% set method = method ?? 'get' %} + diff --git a/templates/_partials/_dropdown.html.twig b/templates/components/dropdown/index.html.twig similarity index 50% rename from templates/_partials/_dropdown.html.twig rename to templates/components/dropdown/index.html.twig index d4abf09..39d5322 100644 --- a/templates/_partials/_dropdown.html.twig +++ b/templates/components/dropdown/index.html.twig @@ -1,9 +1,16 @@ -
- -
+ + + + + + + + + + + + +
Name{{ booking.accommodation.name }}
Anreise{{ booking.dateFrom|date('d.m.Y') }}
Abreise{{ booking.dateTo|date('d.m.Y') }}
+{% endblock %} diff --git a/templates/email/accommodation_booking_customer.html.twig b/templates/email/accommodation_booking_customer.html.twig new file mode 100644 index 0000000..775db76 --- /dev/null +++ b/templates/email/accommodation_booking_customer.html.twig @@ -0,0 +1,47 @@ +{% 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') }}
+ +

Bei Fragen antworte einfach auf diese E-Mail.

+{% endblock %} diff --git a/templates/email/offer_accepted.html.twig b/templates/email/offer_accepted.html.twig new file mode 100644 index 0000000..7c1cd55 --- /dev/null +++ b/templates/email/offer_accepted.html.twig @@ -0,0 +1,27 @@ +{% extends 'email/layout.html.twig' %} + +{% block body %} +

Angebot angenommen

+ + {% if accessLink %} +

+ Kundenansicht öffnen +

+ {% endif %} + +

Unterkunft

+ + + + + + + + + + + + + +
Name{{ booking.accommodation.name }}
Anreise{{ booking.dateFrom|date('d.m.Y') }}
Abreise{{ booking.dateTo|date('d.m.Y') }}
+{% endblock %} diff --git a/templates/email/offer_accepted_customer.html.twig b/templates/email/offer_accepted_customer.html.twig new file mode 100644 index 0000000..2d27e33 --- /dev/null +++ b/templates/email/offer_accepted_customer.html.twig @@ -0,0 +1,33 @@ +{% extends 'email/layout.html.twig' %} + +{% block body %} +

Deine Buchung ist bestätigt

+ +

+ Hallo {{ booking.firstName }}, vielen Dank! Deine Buchung ist jetzt verbindlich bestätigt. +

+ + {% if accessLink %} +

+ Buchung ansehen +

+ {% endif %} + +

Unterkunft

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

Bei Fragen antworte einfach auf diese E-Mail.

+{% endblock %} diff --git a/templates/forms_admin.html.twig b/templates/forms_admin.html.twig new file mode 100644 index 0000000..02d8682 --- /dev/null +++ b/templates/forms_admin.html.twig @@ -0,0 +1,245 @@ +{% use 'form_div_layout.html.twig' %} + +{%- block form_row -%} + {%- set widget_attr = {} -%} + {%- if help is not empty -%} + {%- set widget_attr = {attr: {'aria-describedby': id ~"_help"}} -%} + {%- endif -%} + + {{- form_label(form) -}} + {{- form_widget(form, widget_attr) -}} + {{- form_errors(form) -}} + {{- form_help(form) -}} +
+{%- endblock form_row -%} + +{%- block form_label -%} + {% set class = 'block font-bold pb-2' %} + {% if errors|length %} + {% set class = class ~ ' text-red-500' %} + {% endif %} + {% set label_attr = label_attr|merge({'class': (label_attr.class|default('') ~ class)|trim}) %} + {{ parent() }} +{%- endblock form_label -%} + +{%- block form_errors -%} + {%- if errors|length > 0 -%} +
    + {%- for error in errors -%} +
  • {{ error.message }}
  • + {%- endfor -%} +
+ {%- endif -%} +{%- endblock form_errors -%} + +{%- block form_widget_simple -%} + {%- set type = type|default('text') -%} + {%- if type != 'hidden' -%} + {%- set attr = attr|merge({'class': (attr.class|default('') ~ ' block w-full rounded-md border-0 py-1.5 text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 focus:ring-2 focus:ring-inset text-sm')|trim }) -%} + {%- if errors|length -%} + {%- set attr = attr|merge({'class': attr.class|default('') ~ ' ring-red-500 placeholder-red-500 focus:ring-red-500' }) -%} + {% else %} + {%- set attr = attr|merge({'class': attr.class|default('') ~ ' placeholder:text-gray-400 focus:ring-primary' }) -%} + {%- endif -%} + {%- if disabled is defined and disabled == true -%} + {%- set attr = attr|merge({'class': attr.class|default('') ~ ' cursor-not-allowed' }) -%} + {%- endif -%} + {%- endif -%} + {{ parent() }} + {%- if disabled is defined and disabled == true -%} + + {%- endif -%} +{%- endblock form_widget_simple -%} + +{%- block textarea_widget -%} + {%- set attr = attr|merge({'class': (attr.class|default('') ~ ' block w-full rounded-md border-0 py-1.5 text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 focus:ring-2 focus:ring-inset text-sm')|trim }) -%} + {%- if errors|length -%} + {%- set attr = attr|merge({'class': attr.class|default('') ~ ' ring-red-500 placeholder-red-500 focus:ring-red-500' }) -%} + {% else %} + {%- set attr = attr|merge({'class': attr.class|default('') ~ ' placeholder:text-gray-400 focus:ring-primary' }) -%} + {%- endif -%} + {%- if disabled is defined and disabled == true -%} + {%- set attr = attr|merge({'class': attr.class|default('') ~ ' cursor-not-allowed' }) -%} + {%- endif -%} + {{ parent() }} + {%- if disabled is defined and disabled == true -%} + + {%- endif -%} +{%- endblock textarea_widget -%} + +{%- block checkbox_widget -%} + {%- set attr = attr|merge({'class': (attr.class|default('') ~ ' h-4 w-4 rounded text-primary')|trim }) -%} + {%- if errors|length -%} + {%- set attr = attr|merge({'class': attr.class|default('') ~ ' border-red-500 text-red-500 ring-red-500 focus:ring-red-500' }) -%} + {% else %} + {%- set attr = attr|merge({'class': attr.class|default('') ~ ' border-gray-300 focus:ring-primary' }) -%} + {%- endif -%} + {%- if attr.disabled is defined and attr.disabled == true -%} + {%- set attr = attr|merge({'class': attr.class|default('') ~ ' cursor-not-allowed' }) -%} + {%- endif -%} + +{%- endblock checkbox_widget -%} + +{% block checkbox_row %} +
+
+ {{ form_widget(form) }} +
+
+ + {{- form_errors(form) -}} +
+
+{% endblock %} + +{%- block choice_widget_collapsed -%} + {%- set attr = attr|merge({'class': (attr.class|default('') ~ ' block w-full rounded-md border-0 py-1.5 text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 focus:ring-2 focus:ring-inset text-sm')|trim }) -%} + {%- if errors|length -%} + {%- set attr = attr|merge({'class': attr.class|default('') ~ ' ring-red-500 placeholder-red-500 focus:ring-red-500' }) -%} + {% else %} + {%- set attr = attr|merge({'class': attr.class|default('') ~ ' placeholder:text-gray-400 focus:ring-primary' }) -%} + {%- endif -%} + {%- if disabled is defined and disabled == true -%} + {%- set attr = attr|merge({'class': attr.class|default('') ~ ' cursor-not-allowed' }) -%} + {%- endif -%} + {{ parent() }} + {%- if disabled is defined and disabled == true -%} + + {%- endif -%} +{%- endblock choice_widget_collapsed -%} + +{%- block choice_widget_expanded -%} + {%- for child in form %} +
+
+ +
+
+ {{- form_widget(child) -}} +
+
+ {% endfor -%} +{%- endblock choice_widget_expanded -%} + +{%- block birthday_widget -%} +
+ {{ form_widget(form.children['day']) }} + {{ form_widget(form.children['month']) }} + {{ form_widget(form.children['year']) }} +
+{%- endblock -%} + +{% block abbreviated_date_widget %} + {%- set class = 'flex items-center space-x-1 ' ~ attr.class|default('') -%} + {%- do form.setRendered -%} + {%- if errors|length -%} + {%- set attr = attr|merge({'class': attr.class|default('') ~ ' ring-red-500 placeholder-red-500 focus:ring-red-500' }) -%} + {%- endif -%} +
+ {{ form_widget(form.month, { 'attr': attr })}} + / + {{ form_widget(form.year, { 'attr': attr })}} +
+{% endblock %} + +{%- block money_widget -%} + {% set currency_class = 'absolute top-1/2 transform -translate-y-1/2 right-0 mr-2' %} + {% if errors|length %} + {% set currency_class = currency_class ~ ' text-red-500' %} + {% else %} + {% set currency_class = currency_class ~ ' text-gray-600' %} + {% endif %} +
+ {{ block('form_widget_simple') }} + +
+{%- endblock money_widget -%} + +{%- block datepicker_widget -%} + {%- set minDate = form.vars.min_date ? form.vars.min_date | date('Y-m-d') : null -%} + {%- set maxDate = form.vars.max_date ? form.vars.max_date | date('Y-m-d') : null -%} + {%- set displayValue = value ? value|date('d.m.Y') : '' -%} + {%- set inputClass = 'block w-full rounded-md border-0 py-1.5 text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 focus:ring-2 focus:ring-inset text-sm' -%} + {%- if errors|length -%} + {%- set inputClass = inputClass ~ ' ring-red-500 placeholder:red-500 focus:ring-red-500' -%} + {%- else -%} + {%- set inputClass = inputClass ~ ' placeholder:text-gray-900 focus:ring-primary' -%} + {%- endif -%} + {%- if disabled is defined and disabled == true -%} + {%- set inputClass = inputClass ~ ' cursor-not-allowed' -%} + {%- endif -%} +
+ + +
+{%- endblock datepicker_widget %} + +{%- block autocomplete_widget -%} + {%- set attr = attr|merge({'class': (attr.class|default('') ~ ' autocomplete-input block w-full rounded-md border-0 py-1.5 text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 focus:ring-2 focus:ring-inset text-sm')|trim }) -%} + {%- if errors|length -%} + {%- set attr = attr|merge({'class': attr.class|default('') ~ ' ring-red-500 placeholder-red-500 focus:ring-red-500' }) -%} + {% else %} + {%- set attr = attr|merge({'class': attr.class|default('') ~ ' placeholder:text-gray-400 focus:ring-primary' }) -%} + {%- endif -%} + {%- if disabled is defined and disabled == true -%} + {%- set attr = attr|merge({'class': attr.class|default('') ~ ' cursor-not-allowed' }) -%} + {%- endif -%} +
+ +
    + + +
    + {{ icon('search', 'w-4 h-4 text-gray-700') }} +
    +
    +{%- endblock autocomplete_widget -%} + +{%- block multiselect_widget -%} +
    +
    +
    + {{ icon('dots', 'w-4 h-4 mt-2') }} +
    + +
    +{%- endblock multiselect_widget -%} diff --git a/templates/groups/booking/_offer_accept_confirmation_modal.html.twig b/templates/groups/booking/_offer_accept_confirmation_modal.html.twig new file mode 100644 index 0000000..780d987 --- /dev/null +++ b/templates/groups/booking/_offer_accept_confirmation_modal.html.twig @@ -0,0 +1,31 @@ +{% extends 'htmx_modal.html.twig' %} + +{% block title %}Angebot verbindlich buchen{% 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) }} + +

    + Möchtest du dieses Angebot jetzt verbindlich buchen? +

    + + {{ form_start(confirmationForm, { + 'action': path('app_groups_booking_offer_confirm', { uuid: booking.uuid }), + 'attr': { + 'hx-target': '#htmx-modal', + 'hx-swap': 'outerHTML', + } + }) }} + {{ form_row(confirmationForm.termsAccepted) }} + +
    + + +
    + {{ form_end(confirmationForm) }} +{% endblock %} diff --git a/templates/groups/booking/_offer_summary.html.twig b/templates/groups/booking/_offer_summary.html.twig new file mode 100644 index 0000000..aa9bdaf --- /dev/null +++ b/templates/groups/booking/_offer_summary.html.twig @@ -0,0 +1,196 @@ +{% block booking_summary %} +
    + {# Header #} +
    + {% if ctx.priceBreakdown is not null %} +
    + Gesamtpreis + {{ (displayTotal / 100)|format_currency(currency) }} +
    + {% endif %} +
    + Übersicht + +
    +
    + + {# Content #} + + +
    +{% endblock %} diff --git a/templates/groups/booking/_pagination.html.twig b/templates/groups/booking/_pagination.html.twig new file mode 100644 index 0000000..4cde89f --- /dev/null +++ b/templates/groups/booking/_pagination.html.twig @@ -0,0 +1,33 @@ +{% 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', + 4: 'app_groups_booking_step_4', +} %} + 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 0000000..68d298e --- /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/_step4_booking_confirmation_modal.html.twig b/templates/groups/booking/_step4_booking_confirmation_modal.html.twig new file mode 100644 index 0000000..f4b3811 --- /dev/null +++ b/templates/groups/booking/_step4_booking_confirmation_modal.html.twig @@ -0,0 +1,27 @@ +{% 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_4_confirm'), + 'attr': { + 'hx-target': '#htmx-modal', + 'hx-swap': 'outerHTML', + } + }) }} + {{ form_row(confirmationForm.termsAccepted) }} + +
    + + +
    + {{ form_end(confirmationForm) }} +{% endblock %} diff --git a/templates/groups/booking/_step4_inquiry_confirmation_modal.html.twig b/templates/groups/booking/_step4_inquiry_confirmation_modal.html.twig new file mode 100644 index 0000000..80e8fc5 --- /dev/null +++ b/templates/groups/booking/_step4_inquiry_confirmation_modal.html.twig @@ -0,0 +1,18 @@ +{% extends 'htmx_modal.html.twig' %} + +{% block title %}Anfrage bestätigen{% endblock %} + +{% block content %} +

    Möchtest du diese unverbindliche Anfrage jetzt absenden?

    + + {{ form_start(inquiryForm, { 'action': path('app_groups_booking_step_4') }) }} +
    + + +
    + {{ form_end(inquiryForm) }} +{% endblock %} diff --git a/templates/groups/booking/_step4_personal_data_recap.html.twig b/templates/groups/booking/_step4_personal_data_recap.html.twig new file mode 100644 index 0000000..051f09d --- /dev/null +++ b/templates/groups/booking/_step4_personal_data_recap.html.twig @@ -0,0 +1,31 @@ +
    +
    Persönliche Daten
    +
    +
    + Gruppenname: + {{ dto.groupName }} +
    +
    + Name: + {{ dto.salutation }} {{ dto.firstName }} {{ dto.lastName }} +
    +
    + E-Mail: + {{ dto.email }} +
    +
    + Telefon: + {{ dto.phone }} +
    +
    + Adresse: + {{ dto.street }}, {{ dto.zip }} {{ dto.city }} +
    + {% if dto.remarks %} +
    + Anmerkungen: + {{ dto.remarks }} +
    + {% endif %} +
    +
    diff --git a/templates/groups/booking/_step4_services_recap.html.twig b/templates/groups/booking/_step4_services_recap.html.twig new file mode 100644 index 0000000..ca4c190 --- /dev/null +++ b/templates/groups/booking/_step4_services_recap.html.twig @@ -0,0 +1,42 @@ +{% set selectedBoardService = null %} +{% for service in ctx.boardServices %} + {% if service.id == dto.selectedBoardServiceId %} + {% set selectedBoardService = service %} + {% endif %} +{% endfor %} + +{% set selectedAdditionalServices = ctx.additionalServices|filter(service => service.id in dto.selectedAdditionalServiceIds) %} + +
    +
    Ausgewählte Leistungen
    +
    +
    + Verpflegung: + {% if selectedBoardService %} + {{ selectedBoardService.label }} + {% if selectedBoardService.price > 0 %} + — {{ (selectedBoardService.price / 100)|format_currency(ctx.accommodation.currency) }} pro Person und Nacht + {% endif %} + {% if selectedBoardService.description %} +
    {{ selectedBoardService.description|nl2br }}
    + {% endif %} + {% else %} + Selbstversorgung + {% endif %} +
    + + {% if selectedAdditionalServices is not empty %} +
    + Zusatzleistungen: +
      + {% for service in selectedAdditionalServices %} +
    • + {{ service.label }} + — {{ (service.price / 100)|format_currency(ctx.accommodation.currency) }} {{ service.type.label|trans }} +
    • + {% endfor %} +
    +
    + {% endif %} +
    +
    diff --git a/templates/groups/booking/_summary.html.twig b/templates/groups/booking/_summary.html.twig new file mode 100644 index 0000000..7a34158 --- /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 0000000..b7c1848 --- /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 0000000..fdd2922 --- /dev/null +++ b/templates/groups/booking/offer.html.twig @@ -0,0 +1,216 @@ +{% extends 'layout_booking.html.twig' %} + +{% macro imageGallery(images, thumbImages, defaultAlt) %} + {% if images|length > 0 %} +
    + {% for image in images %} +
    + {{ image.alt ?? defaultAlt }} + {% if loop.index0 == 1 and images|length > 2 %} +
    + +{{ images|length - 2 }} +
    + {% endif %} +
    + {% endfor %} +
    + {% endif %} +{% endmacro %} + +{% import _self as macros %} + +{% block title %}{{ booking.inquiry ? 'Dein Angebot' : 'Deine Buchung' }}{% endblock %} + +{% block background %}bg-outer bg-outer--summer{% endblock %} + +{% block content %} + {% set currency = booking.pricingCurrency ?? priceBreakdown.currency %} + {% set displayTotal = booking.totalPrice ?? (priceBreakdown.total ?? null) %} + +
    + + {# Sidebar summary #} +
    + {% include 'groups/booking/_offer_summary.html.twig' with { + booking: booking, + ctx: ctx, + currency: currency, + displayTotal: displayTotal, + } %} +
    + + {# Main area #} +
    + {% include '_partials/_flashes.html.twig' %} + +
    +

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

    +

    + {{ ctx.accommodation.name }} +

    +
    + + {# Booked services #} + {% if booking.boardServiceLabel or booking.additionalServices | length > 0 %} +
    +

    + {{ booking.inquiry ? 'Angefragte' : 'Gebuchte' }} Leistungen +

    +
      + {% if booking.boardServiceLabel %} +
    • {{ booking.boardServiceLabel }}
    • + {% endif %} + {% for service in booking.additionalServices %} +
    • {{ service.label }}
    • + {% endfor %} +
    +
    + {% endif %} + + {% if booking.inquiry %} +
    + +
    + {% elseif booking.acceptedAt is not null %} +
    + {{ icon('info', 'w-5 h-5') }} +
    + Angebot angenommen am {{ booking.acceptedAt | date('d.m.Y') }} +
    +
    + {% endif %} + + {# hotel description #} + {% if ctx.hotelCmsData is not null %} +
    + + +
    + {% set icons = ctx.hotelCmsData.icons %} + {% if icons is not empty %} +
      + {% for icon, label in icons %} +
    • +
      + {{ icon(icon, 'w-8 h-8 shrink-0 text-gray-900') }} +
      + {{ label }} +
    • + {% endfor %} +
    + {% endif %} + + {{ ctx.hotelCmsData.description | raw }} + +

    + Details zur Unterkunft +

    + {{ ctx.hotelCmsData.features | raw }} + +

    + Die Zimmer +

    + {{ ctx.hotelCmsData.roomTypes | raw }} + +

    + Weitere Infos +

    + {{ ctx.hotelCmsData.additionalInformation | raw }} +
    +
    + + {{ macros.imageGallery(ctx.hotelCmsData.images.resized.l, ctx.hotelCmsData.images.resized.m, ctx.accommodation.name) }} + {% endif %} + + {# region description #} + {% if ctx.hotelCmsData is not null and ctx.hotelCmsData.region is not null %} + {% set region = ctx.hotelCmsData.region %} +
    + + +
    + {% if region.altitude or region.length or region.lifts %} +
      + {% if region.altitude %} +
    • {{ region.altitude }} m Höhe
    • + {% endif %} + {% if region.length %} +
    • {{ region.length }} km Pisten
    • + {% endif %} + {% if region.lifts %} +
    • {{ region.lifts }} Lifte
    • + {% endif %} +
    + {% endif %} + {% if region.webcam %} +

    + Webcam der Region +

    + {% endif %} + {{ region.description | default('') | raw }} + +

    + Skigebiet +

    + {{ region.skiArea | default('') | raw }} + {{ region.skiAreaExtended | default('') | raw }} + + {% if region.news %} +

    Aktuelles aus der Region

    + {{ region.news | raw }} + {% endif %} +
    +
    + + {% if region.regionMaps is not null and region.regionMaps | length > 0 %} + {% set regionMapImages = region.regionMaps | map(url => {url: url}) %} + {{ macros.imageGallery(regionMapImages, [], region.name) }} + {% endif %} + + {{ macros.imageGallery(region.images.resized.l ?? [], region.images.resized.m ?? [], region.name) }} + {% 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 0000000..af5f82e --- /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 0000000..ee7c65a --- /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 0000000..4970d39 --- /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 0000000..eef8608 --- /dev/null +++ b/templates/groups/booking/step_3.html.twig @@ -0,0 +1,108 @@ +{% 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) }} +{% endblock %} diff --git a/templates/groups/booking/step_4.html.twig b/templates/groups/booking/step_4.html.twig new file mode 100644 index 0000000..72ff1a5 --- /dev/null +++ b/templates/groups/booking/step_4.html.twig @@ -0,0 +1,74 @@ +{% extends 'layout_booking.html.twig' %} + +{% block title %}Buchung bestätigen{% 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': 4 } %} +
    + +
    + + {# Static sidebar recap #} +
    + {% include 'groups/booking/_summary.html.twig' with { 'dto': dto, 'ctx': ctx } %} +
    + + {# Confirmation recap #} +
    + {% include '_partials/_flashes.html.twig' %} + + {# Pagination - desktop only (fixed above scrollable content) #} + + +
    +

    + Bitte bestätige deine Angaben +

    + + {% if dto.isInquiry %} + {% include '_partials/_alert.html.twig' with { + level: 'warning', + title: 'Diese Buchung wird als unverbindliche Anfrage behandelt', + messages: dto.inquiryReasons + } %} + {% endif %} + + {% include 'groups/booking/_step4_services_recap.html.twig' with { 'dto': dto, 'ctx': ctx } %} + {% include 'groups/booking/_step4_personal_data_recap.html.twig' with { 'dto': dto } %} +
    +
    + +
    + +
    +
    + + Zurück + +
    + + +
    +
    +
    +{% endblock %} diff --git a/templates/groups/booking/success.html.twig b/templates/groups/booking/success.html.twig new file mode 100644 index 0000000..9e354a5 --- /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/templates/htmx_confirmation_modal.html.twig b/templates/htmx_confirmation_modal.html.twig index 7a3cd93..551814f 100644 --- a/templates/htmx_confirmation_modal.html.twig +++ b/templates/htmx_confirmation_modal.html.twig @@ -14,7 +14,12 @@
    {% block content %}{% endblock %}
    -
    -
    +
    {% block content %}{% endblock %}
    diff --git a/templates/htmx_modal_admin.html.twig b/templates/htmx_modal_admin.html.twig new file mode 100644 index 0000000..7cacb9e --- /dev/null +++ b/templates/htmx_modal_admin.html.twig @@ -0,0 +1,23 @@ +
    +
    +
    +
    +
    +
    + {% block title %}{% endblock %} +
    + +
    +
    +
    + {% block content %}{% endblock %} +
    +
    +
    +
    +
    diff --git a/templates/layout_admin.html.twig b/templates/layout_admin.html.twig index 1840214..aa8bb85 100644 --- a/templates/layout_admin.html.twig +++ b/templates/layout_admin.html.twig @@ -17,7 +17,7 @@
    {% endif %}
    - + {% include '_partials/_flashes_admin.html.twig' %} {% endblock %} diff --git a/tests/BusProNet/XmlParser/CrmAttributesResponseParserTest.php b/tests/BusProNet/XmlParser/CrmAttributesResponseParserTest.php new file mode 100644 index 0000000..21dced6 --- /dev/null +++ b/tests/BusProNet/XmlParser/CrmAttributesResponseParserTest.php @@ -0,0 +1,92 @@ +parser = new CrmAttributesResponseParser(); + } + + public function testParseAssignsGroupsManagerRoleWhenSelected(): void + { + $roles = $this->parseRoles($this->selectionXml(1477, true)); + + self::assertContains('ROLE_GROUPS_MANAGER', $roles); + self::assertNotContains('ROLE_GROUPS_ADMIN', $roles); + } + + public function testParseAssignsGroupsAdminRoleWhenSelected(): void + { + $roles = $this->parseRoles($this->selectionXml(1478, true)); + + self::assertContains('ROLE_GROUPS_ADMIN', $roles); + self::assertNotContains('ROLE_GROUPS_MANAGER', $roles); + } + + public function testParseAssignsNoGroupsRolesWhenNotSelected(): void + { + $roles = $this->parseRoles($this->selectionXml(1477, false)); + + self::assertNotContains('ROLE_GROUPS_MANAGER', $roles); + self::assertNotContains('ROLE_GROUPS_ADMIN', $roles); + self::assertSame(['ROLE_CUSTOMER'], $roles); + } + + public function testParseStillAssignsExistingAdminManagerTeamerRoles(): void + { + $xmlContent = ' + + + + + + + + + + +'; + + $roles = $this->parseRoles($xmlContent); + + self::assertContains('ROLE_ADMIN', $roles); + self::assertContains('ROLE_MANAGER', $roles); + self::assertContains('ROLE_TEAMER', $roles); + self::assertContains('ROLE_HOUSE_MANAGER', $roles); + } + + /** + * @return string[] + */ + private function parseRoles(string $xmlContent): array + { + $crawler = new Crawler($xmlContent); + $resultNode = $crawler->filterXPath('//ergebnis'); + + return $this->parser->parse($resultNode)->roles; + } + + private function selectionXml(int $id, bool $selected): string + { + return sprintf(' + + + + + + + + +', $id, $selected ? 'True' : 'False'); + } +} diff --git a/tests/Controller/Accommodation/IndexControllerTest.php b/tests/Controller/Accommodation/IndexControllerTest.php new file mode 100644 index 0000000..b8d05e5 --- /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 0000000..7de0762 --- /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 0000000..f4e3951 --- /dev/null +++ b/tests/Controller/Accommodation/Step2ControllerTest.php @@ -0,0 +1,137 @@ +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(new InquiryStatus(true, ['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 0000000..b2571c3 --- /dev/null +++ b/tests/Controller/Accommodation/Step3ControllerTest.php @@ -0,0 +1,224 @@ +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->createFormMock(submitted: false, valid: 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 testValidSubmitRedirectsToStep4WithoutPersisting(): 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); + + $form = $this->createFormMock(submitted: true, valid: true); + + $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('finalizeBooking'); + + $controller = new TestableAccommodationStep3Controller($bookingService, $sessionManager, $form); + + $response = $controller->index($request); + + self::assertInstanceOf(RedirectResponse::class, $response); + self::assertSame('/app_groups_booking_step_4', $response->getTargetUrl()); + self::assertSame(4, $dto->currentStep); + self::assertSame($dto, $sessionManager->getDto($request)); + } + + public function testInvalidSubmitReRendersFormWithErrors(): void + { + $dto = new AccommodationBookingDto(); + $dto->currentStep = 3; + $dto->accommodationId = 1; + + $session = new Session(new MockArraySessionStorage()); + $request = Request::create('/groups/booking/step-3', 'POST'); + $request->setSession($session); + + $sessionManager = new AccommodationSessionManager(); + $sessionManager->save($request, $dto); + + $form = $this->createFormMock(submitted: true, valid: false); + + $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' => [], + ]); + + $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(3, $dto->currentStep); + self::assertSame($dto, $sessionManager->getDto($request)); + } + + private function createFormMock(bool $submitted, bool $valid): FormInterface + { + $form = $this->createMock(FormInterface::class); + $form->method('handleRequest')->willReturnSelf(); + $form->method('isSubmitted')->willReturn($submitted); + $form->method('isValid')->willReturn($valid); + + return $form; + } +} + +final class TestableAccommodationStep3Controller extends Step3Controller +{ + /** + * @var array + */ + public array $renderedParameters = []; + + public string $renderedView = ''; + + public function __construct( + AccommodationBookingService $bookingService, + AccommodationSessionManager $sessionManager, + private readonly FormInterface $form, + ) { + parent::__construct($bookingService, $sessionManager); + } + + /** + * @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('/'.$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 0000000..a1eb6c2 --- /dev/null +++ b/tests/Controller/Api/AccommodationBookingControllerTest.php @@ -0,0 +1,304 @@ +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->setAccommodationDiscount(10); + $booking->setBoardServiceDiscount(20); + $booking->setAdditionalServicesDiscount(30); + $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']; + $booking->setPriceSnapshot($breakdown, 11111, 'EUR', 1); + $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', + ], + ], + 'accommodationDiscount' => 10, + 'boardServiceDiscount' => 20, + 'additionalServicesDiscount' => 30, + 'totalPrice' => 11111, + 'pricingCurrency' => 'EUR', + 'pricingVersion' => 1, + '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 0000000..42ebe1b --- /dev/null +++ b/tests/Controller/Api/ContingentControllerTest.php @@ -0,0 +1,45 @@ +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(3, $result['minNights']); + self::assertSame(123.45, $result['pricePerNight']); + self::assertSame('EUR', $result['currency']); + } +} diff --git a/tests/Controller/Groups/OfferControllerTest.php b/tests/Controller/Groups/OfferControllerTest.php new file mode 100644 index 0000000..4d7ad0d --- /dev/null +++ b/tests/Controller/Groups/OfferControllerTest.php @@ -0,0 +1,411 @@ +createMock(AccommodationBookingRepository::class); + $bookingRepository->method('findOneBy')->with(['uuid' => $booking->getUuid()])->willReturn($booking); + + $linkSigner = $this->createMock(AccommodationBookingLinkSigner::class); + $linkSigner->method('isValidLinkRequest')->willReturn(true); + $linkSigner->expects(self::once())->method('authorizeSession')->with(self::anything(), $booking); + + $controller = new TestableOfferController( + $bookingRepository, + $linkSigner, + $this->createMock(AccommodationBookingBreakdownCalculator::class), + $this->createMock(AccommodationBookingService::class), + ); + + $response = $controller->access($booking->getUuid(), Request::create('/groups/booking/offer/'.$booking->getUuid())); + + self::assertInstanceOf(RedirectResponse::class, $response); + self::assertSame('/app_groups_booking_offer_view?uuid='.$booking->getUuid(), $response->getTargetUrl()); + } + + public function testAccessRendersUnavailableForInvalidLink(): void + { + $booking = new AccommodationBooking(); + + $bookingRepository = $this->createMock(AccommodationBookingRepository::class); + $bookingRepository->method('findOneBy')->willReturn($booking); + + $linkSigner = $this->createMock(AccommodationBookingLinkSigner::class); + $linkSigner->method('isValidLinkRequest')->willReturn(false); + $linkSigner->expects(self::never())->method('authorizeSession'); + + $controller = new TestableOfferController( + $bookingRepository, + $linkSigner, + $this->createMock(AccommodationBookingBreakdownCalculator::class), + $this->createMock(AccommodationBookingService::class), + ); + + $response = $controller->access($booking->getUuid(), Request::create('/groups/booking/offer/'.$booking->getUuid())); + + self::assertSame(Response::HTTP_OK, $response->getStatusCode()); + self::assertSame('groups/booking/offer_unavailable.html.twig', $controller->renderedView); + } + + public function testAccessRendersUnavailableForUnknownUuid(): void + { + $bookingRepository = $this->createMock(AccommodationBookingRepository::class); + $bookingRepository->method('findOneBy')->willReturn(null); + + $linkSigner = $this->createMock(AccommodationBookingLinkSigner::class); + $linkSigner->expects(self::never())->method('isValidLinkRequest'); + + $controller = new TestableOfferController( + $bookingRepository, + $linkSigner, + $this->createMock(AccommodationBookingBreakdownCalculator::class), + $this->createMock(AccommodationBookingService::class), + ); + + $response = $controller->access('unknown-uuid', Request::create('/groups/booking/offer/unknown-uuid')); + + self::assertSame(Response::HTTP_OK, $response->getStatusCode()); + self::assertSame('groups/booking/offer_unavailable.html.twig', $controller->renderedView); + } + + public function testViewRendersOfferWhenSessionAuthorized(): void + { + $booking = new AccommodationBooking(); + $booking->setIsInquiry(true); + $booking->setAccommodation(new Accommodation()); + + $bookingRepository = $this->createMock(AccommodationBookingRepository::class); + $bookingRepository->method('findOneBy')->with(['uuid' => $booking->getUuid()])->willReturn($booking); + + $linkSigner = $this->createMock(AccommodationBookingLinkSigner::class); + $linkSigner->method('isSessionAuthorized')->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'); + $bookingService->method('loadHotelCmsData')->willReturn(null); + + $controller = new TestableOfferController($bookingRepository, $linkSigner, $breakdownCalculator, $bookingService); + + $request = Request::create('/groups/booking/offer/'.$booking->getUuid().'/view'); + $request->setSession(new Session(new MockArraySessionStorage())); + $response = $controller->view($booking->getUuid(), $request); + + self::assertSame(Response::HTTP_OK, $response->getStatusCode()); + self::assertSame('offer', $response->getContent()); + self::assertSame('groups/booking/offer.html.twig', $controller->renderedView); + self::assertSame($booking, $controller->renderedParameters['booking']); + self::assertSame(['total' => 1000], $controller->renderedParameters['priceBreakdown']); + self::assertSame($booking->getAccommodation(), $controller->renderedParameters['ctx']->accommodation); + } + + public function testViewRendersUnavailableWhenSessionNotAuthorized(): void + { + $booking = new AccommodationBooking(); + + $bookingRepository = $this->createMock(AccommodationBookingRepository::class); + $bookingRepository->method('findOneBy')->willReturn($booking); + + $linkSigner = $this->createMock(AccommodationBookingLinkSigner::class); + $linkSigner->method('isSessionAuthorized')->willReturn(false); + + $controller = new TestableOfferController( + $bookingRepository, + $linkSigner, + $this->createMock(AccommodationBookingBreakdownCalculator::class), + $this->createMock(AccommodationBookingService::class), + ); + + $response = $controller->view($booking->getUuid(), Request::create('/groups/booking/offer/'.$booking->getUuid().'/view')); + + self::assertSame(Response::HTTP_OK, $response->getStatusCode()); + self::assertSame('groups/booking/offer_unavailable.html.twig', $controller->renderedView); + } + + public function testConfirmGetRendersModalWithFreshForm(): void + { + $booking = new AccommodationBooking(); + $booking->setIsInquiry(true); + + $bookingRepository = $this->createMock(AccommodationBookingRepository::class); + $bookingRepository->method('findOneBy')->with(['uuid' => $booking->getUuid()])->willReturn($booking); + + $linkSigner = $this->createMock(AccommodationBookingLinkSigner::class); + $linkSigner->method('isSessionAuthorized')->willReturn(true); + + $confirmationForm = $this->createMock(FormInterface::class); + $confirmationForm->method('handleRequest')->willReturnSelf(); + $confirmationForm->method('isSubmitted')->willReturn(false); + + $controller = new TestableOfferController( + $bookingRepository, + $linkSigner, + $this->createMock(AccommodationBookingBreakdownCalculator::class), + $this->createMock(AccommodationBookingService::class), + $confirmationForm, + ); + + $response = $controller->confirm($booking->getUuid(), Request::create('/groups/booking/offer/'.$booking->getUuid().'/confirm')); + + self::assertSame(Response::HTTP_OK, $response->getStatusCode()); + self::assertSame('groups/booking/_offer_accept_confirmation_modal.html.twig', $controller->renderedView); + self::assertSame($booking, $controller->renderedParameters['booking']); + self::assertSame($confirmationForm, $controller->renderedParameters['confirmationForm']); + } + + public function testConfirmPostValidAcceptsBookingAndRedirects(): void + { + $booking = new AccommodationBooking(); + $booking->setIsInquiry(true); + + $bookingRepository = $this->createMock(AccommodationBookingRepository::class); + $bookingRepository->method('findOneBy')->willReturn($booking); + + $linkSigner = $this->createMock(AccommodationBookingLinkSigner::class); + $linkSigner->method('isSessionAuthorized')->willReturn(true); + + $bookingService = $this->createMock(AccommodationBookingService::class); + $bookingService->expects(self::once())->method('acceptBooking')->with($booking); + + $confirmationForm = $this->createMock(FormInterface::class); + $confirmationForm->method('handleRequest')->willReturnSelf(); + $confirmationForm->method('isSubmitted')->willReturn(true); + $confirmationForm->method('isValid')->willReturn(true); + + $controller = new TestableOfferController( + $bookingRepository, + $linkSigner, + $this->createMock(AccommodationBookingBreakdownCalculator::class), + $bookingService, + $confirmationForm, + ); + + $request = Request::create('/groups/booking/offer/'.$booking->getUuid().'/confirm', 'POST'); + $response = $controller->confirm($booking->getUuid(), $request); + + self::assertInstanceOf(RedirectResponse::class, $response); + self::assertSame('/app_groups_booking_offer_view?uuid='.$booking->getUuid(), $response->getTargetUrl()); + self::assertSame([['type' => 'success', 'message' => 'Deine Buchung ist bestätigt.']], $controller->flashes); + } + + public function testConfirmPostValidViaHtmxReturnsHxRedirect(): void + { + $booking = new AccommodationBooking(); + $booking->setIsInquiry(true); + + $bookingRepository = $this->createMock(AccommodationBookingRepository::class); + $bookingRepository->method('findOneBy')->willReturn($booking); + + $linkSigner = $this->createMock(AccommodationBookingLinkSigner::class); + $linkSigner->method('isSessionAuthorized')->willReturn(true); + + $bookingService = $this->createMock(AccommodationBookingService::class); + $bookingService->expects(self::once())->method('acceptBooking')->with($booking); + + $confirmationForm = $this->createMock(FormInterface::class); + $confirmationForm->method('handleRequest')->willReturnSelf(); + $confirmationForm->method('isSubmitted')->willReturn(true); + $confirmationForm->method('isValid')->willReturn(true); + + $controller = new TestableOfferController( + $bookingRepository, + $linkSigner, + $this->createMock(AccommodationBookingBreakdownCalculator::class), + $bookingService, + $confirmationForm, + ); + + $request = Request::create('/groups/booking/offer/'.$booking->getUuid().'/confirm', 'POST'); + $request->headers->set('HX-Request', 'true'); + $response = $controller->confirm($booking->getUuid(), $request); + + self::assertSame(Response::HTTP_OK, $response->getStatusCode()); + self::assertTrue($response->headers->has('HX-Redirect')); + } + + public function testConfirmPostInvalidReRendersModalWithoutAccepting(): void + { + $booking = new AccommodationBooking(); + $booking->setIsInquiry(true); + + $bookingRepository = $this->createMock(AccommodationBookingRepository::class); + $bookingRepository->method('findOneBy')->willReturn($booking); + + $linkSigner = $this->createMock(AccommodationBookingLinkSigner::class); + $linkSigner->method('isSessionAuthorized')->willReturn(true); + + $bookingService = $this->createMock(AccommodationBookingService::class); + $bookingService->expects(self::never())->method('acceptBooking'); + + $confirmationForm = $this->createMock(FormInterface::class); + $confirmationForm->method('handleRequest')->willReturnSelf(); + $confirmationForm->method('isSubmitted')->willReturn(true); + $confirmationForm->method('isValid')->willReturn(false); + + $controller = new TestableOfferController( + $bookingRepository, + $linkSigner, + $this->createMock(AccommodationBookingBreakdownCalculator::class), + $bookingService, + $confirmationForm, + ); + + $response = $controller->confirm($booking->getUuid(), Request::create('/groups/booking/offer/'.$booking->getUuid().'/confirm', 'POST')); + + self::assertSame(Response::HTTP_OK, $response->getStatusCode()); + self::assertFalse($response->headers->has('HX-Redirect')); + self::assertSame('groups/booking/_offer_accept_confirmation_modal.html.twig', $controller->renderedView); + } + + public function testConfirmRedirectsWhenAlreadyAccepted(): void + { + $booking = new AccommodationBooking(); + $booking->setIsInquiry(false); + + $bookingRepository = $this->createMock(AccommodationBookingRepository::class); + $bookingRepository->method('findOneBy')->willReturn($booking); + + $linkSigner = $this->createMock(AccommodationBookingLinkSigner::class); + $linkSigner->method('isSessionAuthorized')->willReturn(true); + + $controller = new TestableOfferController( + $bookingRepository, + $linkSigner, + $this->createMock(AccommodationBookingBreakdownCalculator::class), + $this->createMock(AccommodationBookingService::class), + ); + + $response = $controller->confirm($booking->getUuid(), Request::create('/groups/booking/offer/'.$booking->getUuid().'/confirm')); + + self::assertInstanceOf(RedirectResponse::class, $response); + } + + public function testConfirmRedirectsWhenSessionNotAuthorized(): void + { + $booking = new AccommodationBooking(); + + $bookingRepository = $this->createMock(AccommodationBookingRepository::class); + $bookingRepository->method('findOneBy')->willReturn($booking); + + $linkSigner = $this->createMock(AccommodationBookingLinkSigner::class); + $linkSigner->method('isSessionAuthorized')->willReturn(false); + + $controller = new TestableOfferController( + $bookingRepository, + $linkSigner, + $this->createMock(AccommodationBookingBreakdownCalculator::class), + $this->createMock(AccommodationBookingService::class), + ); + + $response = $controller->confirm($booking->getUuid(), Request::create('/groups/booking/offer/'.$booking->getUuid().'/confirm')); + + self::assertInstanceOf(RedirectResponse::class, $response); + } + + public function testConfirmRedirectsForUnknownUuid(): void + { + $bookingRepository = $this->createMock(AccommodationBookingRepository::class); + $bookingRepository->method('findOneBy')->willReturn(null); + + $linkSigner = $this->createMock(AccommodationBookingLinkSigner::class); + $linkSigner->expects(self::never())->method('isSessionAuthorized'); + + $controller = new TestableOfferController( + $bookingRepository, + $linkSigner, + $this->createMock(AccommodationBookingBreakdownCalculator::class), + $this->createMock(AccommodationBookingService::class), + ); + + $response = $controller->confirm('unknown-uuid', Request::create('/groups/booking/offer/unknown-uuid/confirm')); + + self::assertInstanceOf(RedirectResponse::class, $response); + } +} + +final class TestableOfferController extends OfferController +{ + public ?string $renderedView = null; + + /** @var array */ + public array $renderedParameters = []; + + public function __construct( + AccommodationBookingRepository $bookingRepository, + AccommodationBookingLinkSigner $linkSigner, + AccommodationBookingBreakdownCalculator $breakdownCalculator, + AccommodationBookingService $bookingService, + private readonly ?FormInterface $confirmationForm = null, + ) { + parent::__construct($bookingRepository, $linkSigner, $breakdownCalculator, $bookingService); + } + + protected function createForm(string $type, mixed $data = null, array $options = []): FormInterface + { + return $this->confirmationForm ?? throw new \LogicException('No confirmation form mock configured for this test.'); + } + + protected function getParameter(string $name): array|bool|string|int|float|\UnitEnum|null + { + return 'https://example.test/agb/'; + } + + /** + * @var array + */ + public array $flashes = []; + + protected function addFlash(string $type, mixed $message): void + { + $this->flashes[] = ['type' => $type, 'message' => $message]; + } + + /** + * @param array $parameters + */ + protected function generateUrl(string $route, array $parameters = [], int $referenceType = 1): string + { + return '/'.$route.'?'.http_build_query($parameters); + } + + protected function render(string $view, array $parameters = [], ?Response $response = null): Response + { + $this->renderedView = $view; + $this->renderedParameters = $parameters; + + if ('groups/booking/offer.html.twig' === $view) { + $content = $parameters['booking']->isInquiry() ? 'offer' : 'confirmed'; + } else { + $content = 'unavailable'; + } + + $response ??= new Response(); + $response->setContent($content); + + return $response; + } +} diff --git a/tests/Controller/Groups/Step4ControllerTest.php b/tests/Controller/Groups/Step4ControllerTest.php new file mode 100644 index 0000000..a4a89bc --- /dev/null +++ b/tests/Controller/Groups/Step4ControllerTest.php @@ -0,0 +1,402 @@ +currentStep = 4; + $dto->accommodationId = 1; + $dto->isInquiry = false; + + $session = new Session(new MockArraySessionStorage()); + $request = Request::create('/groups/booking/step-4', 'GET'); + $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('loadAccommodation')->with(1)->willReturn($accommodation); + $bookingService->method('loadAvailableServices')->willReturn([ + 'boardServices' => [], + 'additionalServices' => [], + 'groupedAdditionalServices' => [], + 'ungroupedAdditionalServices' => [], + ]); + + $controller = new TestableStep4Controller($bookingService, $sessionManager, $this->createMock(FormInterface::class)); + + $response = $controller->index($request); + + self::assertSame(200, $response->getStatusCode()); + self::assertSame('groups/booking/step_4.html.twig', $controller->renderedView); + self::assertSame($dto, $controller->renderedParameters['dto']); + } + + public function testIndexRedirectsToStep3WhenNotYetReached(): void + { + $dto = new AccommodationBookingDto(); + $dto->currentStep = 3; + $dto->accommodationId = 1; + + $session = new Session(new MockArraySessionStorage()); + $request = Request::create('/groups/booking/step-4', 'GET'); + $request->setSession($session); + + $sessionManager = new AccommodationSessionManager(); + $sessionManager->save($request, $dto); + + $controller = new TestableStep4Controller( + $this->createMock(AccommodationBookingService::class), + $sessionManager, + $this->createMock(FormInterface::class), + ); + + $response = $controller->index($request); + + self::assertInstanceOf(RedirectResponse::class, $response); + self::assertSame('/app_groups_booking_step_3', $response->getTargetUrl()); + } + + public function testIndexPostWithValidCsrfPersistsAsInquiryAndRedirects(): void + { + $dto = new AccommodationBookingDto(); + $dto->currentStep = 4; + $dto->accommodationId = 1; + + $session = new Session(new MockArraySessionStorage()); + $request = Request::create('/groups/booking/step-4', 'POST'); + $request->setSession($session); + + $sessionManager = new AccommodationSessionManager(); + $sessionManager->save($request, $dto); + + $accommodation = (new Accommodation()) + ->setName('Hotel') + ->setCalendarCode('HOTEL') + ->setMaxAdolescentAge(17); + + $booking = (new AccommodationBooking())->setIsInquiry(true); + + $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('finalizeBooking')->willReturn($booking); + + $inquiryForm = $this->createMock(FormInterface::class); + $inquiryForm->method('handleRequest')->willReturnSelf(); + $inquiryForm->method('isSubmitted')->willReturn(true); + $inquiryForm->method('isValid')->willReturn(true); + + $controller = new TestableStep4Controller($bookingService, $sessionManager, $inquiryForm); + + $response = $controller->index($request); + + self::assertInstanceOf(RedirectResponse::class, $response); + self::assertSame('/app_groups_booking_success', $response->getTargetUrl()); + self::assertTrue($dto->forceInquiry); + self::assertNull($sessionManager->getDto($request)); + } + + public function testIndexPostWithInvalidFormThrowsAccessDenied(): void + { + $dto = new AccommodationBookingDto(); + $dto->currentStep = 4; + $dto->accommodationId = 1; + + $session = new Session(new MockArraySessionStorage()); + $request = Request::create('/groups/booking/step-4', 'POST'); + $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('loadAccommodation')->with(1)->willReturn($accommodation); + $bookingService->method('loadAvailableServices')->willReturn([ + 'boardServices' => [], + 'additionalServices' => [], + 'groupedAdditionalServices' => [], + 'ungroupedAdditionalServices' => [], + ]); + $bookingService->expects(self::never())->method('finalizeBooking'); + + $inquiryForm = $this->createMock(FormInterface::class); + $inquiryForm->method('handleRequest')->willReturnSelf(); + $inquiryForm->method('isSubmitted')->willReturn(true); + $inquiryForm->method('isValid')->willReturn(false); + + $controller = new TestableStep4Controller($bookingService, $sessionManager, $inquiryForm); + + $this->expectException(\Symfony\Component\Security\Core\Exception\AccessDeniedException::class); + + $controller->index($request); + } + + public function testConfirmInquiryGetRendersModal(): void + { + $dto = new AccommodationBookingDto(); + $dto->currentStep = 4; + $dto->accommodationId = 1; + + $session = new Session(new MockArraySessionStorage()); + $request = Request::create('/groups/booking/step-4/confirm-inquiry', 'GET'); + $request->setSession($session); + + $sessionManager = new AccommodationSessionManager(); + $sessionManager->save($request, $dto); + + $controller = new TestableStep4Controller( + $this->createMock(AccommodationBookingService::class), + $sessionManager, + $this->createMock(FormInterface::class), + ); + + $response = $controller->confirmInquiry($request); + + self::assertSame(200, $response->getStatusCode()); + self::assertSame('groups/booking/_step4_inquiry_confirmation_modal.html.twig', $controller->renderedView); + } + + public function testConfirmInquiryRedirectsToStep3WhenNotYetReached(): void + { + $dto = new AccommodationBookingDto(); + $dto->currentStep = 3; + $dto->accommodationId = 1; + + $session = new Session(new MockArraySessionStorage()); + $request = Request::create('/groups/booking/step-4/confirm-inquiry', 'GET'); + $request->setSession($session); + + $sessionManager = new AccommodationSessionManager(); + $sessionManager->save($request, $dto); + + $controller = new TestableStep4Controller( + $this->createMock(AccommodationBookingService::class), + $sessionManager, + $this->createMock(FormInterface::class), + ); + + $response = $controller->confirmInquiry($request); + + self::assertInstanceOf(RedirectResponse::class, $response); + self::assertSame('/app_groups_booking_step_3', $response->getTargetUrl()); + } + + public function testConfirmGetRendersModalWithFreshForm(): void + { + $dto = new AccommodationBookingDto(); + $dto->currentStep = 4; + $dto->accommodationId = 1; + + $session = new Session(new MockArraySessionStorage()); + $request = Request::create('/groups/booking/step-4/confirm', 'GET'); + $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('loadAccommodation')->with(1)->willReturn($accommodation); + $bookingService->expects(self::never())->method('finalizeBooking'); + + $confirmationForm = $this->createMock(FormInterface::class); + $confirmationForm->method('handleRequest')->willReturnSelf(); + $confirmationForm->method('isSubmitted')->willReturn(false); + + $controller = new TestableStep4Controller($bookingService, $sessionManager, $confirmationForm); + + $response = $controller->confirm($request); + + self::assertSame(200, $response->getStatusCode()); + self::assertSame('groups/booking/_step4_booking_confirmation_modal.html.twig', $controller->renderedView); + self::assertSame($confirmationForm, $controller->renderedParameters['confirmationForm']); + self::assertSame($dto, $sessionManager->getDto($request)); + } + + public function testConfirmPostValidPersistsAndRedirects(): void + { + $dto = new AccommodationBookingDto(); + $dto->currentStep = 4; + $dto->accommodationId = 1; + + $session = new Session(new MockArraySessionStorage()); + $request = Request::create('/groups/booking/step-4/confirm', 'POST'); + $request->setSession($session); + $request->headers->set('HX-Request', 'true'); + + $sessionManager = new AccommodationSessionManager(); + $sessionManager->save($request, $dto); + + $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('finalizeBooking')->willReturn($booking); + + $confirmationForm = $this->createMock(FormInterface::class); + $confirmationForm->method('handleRequest')->willReturnSelf(); + $confirmationForm->method('isSubmitted')->willReturn(true); + $confirmationForm->method('isValid')->willReturn(true); + + $controller = new TestableStep4Controller($bookingService, $sessionManager, $confirmationForm); + + $response = $controller->confirm($request); + + self::assertSame(200, $response->getStatusCode()); + self::assertTrue($response->headers->has('HX-Redirect')); + self::assertNull($sessionManager->getDto($request)); + } + + public function testConfirmPostInvalidReRendersModalWithoutPersisting(): void + { + $dto = new AccommodationBookingDto(); + $dto->currentStep = 4; + $dto->accommodationId = 1; + + $session = new Session(new MockArraySessionStorage()); + $request = Request::create('/groups/booking/step-4/confirm', 'POST'); + $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('loadAccommodation')->with(1)->willReturn($accommodation); + $bookingService->expects(self::never())->method('finalizeBooking'); + + $confirmationForm = $this->createMock(FormInterface::class); + $confirmationForm->method('handleRequest')->willReturnSelf(); + $confirmationForm->method('isSubmitted')->willReturn(true); + $confirmationForm->method('isValid')->willReturn(false); + + $controller = new TestableStep4Controller($bookingService, $sessionManager, $confirmationForm); + + $response = $controller->confirm($request); + + self::assertSame(200, $response->getStatusCode()); + self::assertFalse($response->headers->has('HX-Redirect')); + self::assertSame('groups/booking/_step4_booking_confirmation_modal.html.twig', $controller->renderedView); + self::assertNotNull($sessionManager->getDto($request)); + } +} + +final class TestableStep4Controller extends Step4Controller +{ + /** + * @var array + */ + public array $renderedParameters = []; + + public string $renderedView = ''; + + public function __construct( + AccommodationBookingService $bookingService, + AccommodationSessionManager $sessionManager, + private readonly FormInterface $form, + ) { + parent::__construct($bookingService, $sessionManager); + } + + /** + * @return FormInterface + */ + protected function createForm(string $type, mixed $data = null, array $options = []): FormInterface + { + return $this->form; + } + + protected function getParameter(string $name): array|bool|string|int|float|\UnitEnum|null + { + return 'https://example.test/agb/'; + } + + /** + * @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/Form/AccommodationStep2TypeTest.php b/tests/Form/AccommodationStep2TypeTest.php new file mode 100644 index 0000000..ea3485d --- /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/Model/ContingentQueryTest.php b/tests/Model/ContingentQueryTest.php new file mode 100644 index 0000000..4995fb0 --- /dev/null +++ b/tests/Model/ContingentQueryTest.php @@ -0,0 +1,50 @@ +validator()->validate($query)); + self::assertSame('2026-01-01', $query->dateFromDate()->format('Y-m-d')); + } + + /** + * @dataProvider invalidCalendarQueries + */ + public function testCalendarQueryRejectsInvalidInput(ContingentCalendarQuery $query): void + { + self::assertGreaterThan(0, $this->validator()->validate($query)->count()); + } + + public function invalidCalendarQueries(): iterable + { + yield 'normalized invalid date' => [new ContingentCalendarQuery('HOTEL', '2026-02-31', '2026-03-01')]; + yield 'reversed range' => [new ContingentCalendarQuery('HOTEL', '2026-03-02', '2026-03-01')]; + yield 'range over 366 days' => [new ContingentCalendarQuery('HOTEL', '2026-01-01', '2027-01-03')]; + yield 'invalid hotel code' => [new ContingentCalendarQuery('HOTEL CODE', '2026-01-01', '2026-01-02')]; + } + + public function testPricesQueryRequiresFourDigitYear(): void + { + self::assertCount(0, $this->validator()->validate(new ContingentPricesQuery('HOTEL', 2026))); + self::assertGreaterThan(0, $this->validator()->validate(new ContingentPricesQuery('HOTEL', 26))->count()); + } + + private function validator(): \Symfony\Component\Validator\Validator\ValidatorInterface + { + return Validation::createValidatorBuilder() + ->enableAttributeMapping() + ->getValidator(); + } +} diff --git a/tests/Service/AccommodationBookingBreakdownCalculatorTest.php b/tests/Service/AccommodationBookingBreakdownCalculatorTest.php new file mode 100644 index 0000000..f736bc4 --- /dev/null +++ b/tests/Service/AccommodationBookingBreakdownCalculatorTest.php @@ -0,0 +1,39 @@ + 12345, 'currency' => 'EUR']; + $booking = new AccommodationBooking(); + $booking->setPriceSnapshot($snapshot, 12345, 'EUR', 1); + + $priceRepository = $this->createMock(AccommodationPriceRepository::class); + $priceRepository->expects(self::never())->method('findByHotelCodeAndDateRange'); + + $calculator = new AccommodationBookingBreakdownCalculator( + new GroupsPriceCalculator(new PriceTimelineBuilder(), [ + 'runningCostsEur' => 0, + 'runningCostsChf' => 0, + 'undersubscription30Eur' => 0, + 'undersubscription30Chf' => 0, + 'undersubscription40Eur' => 0, + 'undersubscription40Chf' => 0, + ]), + $priceRepository, + ); + + self::assertSame($snapshot, $calculator->compute($booking)); + } +} diff --git a/tests/Service/AccommodationBookingLinkSignerTest.php b/tests/Service/AccommodationBookingLinkSignerTest.php new file mode 100644 index 0000000..db91149 --- /dev/null +++ b/tests/Service/AccommodationBookingLinkSignerTest.php @@ -0,0 +1,192 @@ +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)); + } + + public function testSessionIsAuthorizedAfterAuthorizeSession(): void + { + $booking = $this->bookingWithAccessLink(); + $signer = $this->createSigner(); + $request = $this->requestWithSession(); + + self::assertFalse($signer->isSessionAuthorized($request, $booking)); + + $signer->authorizeSession($request, $booking); + + self::assertTrue($signer->isSessionAuthorized($request, $booking)); + } + + public function testSessionAuthorizationIsPerBooking(): void + { + $booking = $this->bookingWithAccessLink(); + $otherBooking = $this->bookingWithAccessLink(); + $signer = $this->createSigner(); + $request = $this->requestWithSession(); + + $signer->authorizeSession($request, $booking); + + self::assertTrue($signer->isSessionAuthorized($request, $booking)); + self::assertFalse($signer->isSessionAuthorized($request, $otherBooking)); + } + + public function testSessionAuthorizationIsRevokedWhenLinkIsRegenerated(): void + { + $booking = $this->bookingWithAccessLink(); + $signer = $this->createSigner(); + $request = $this->requestWithSession(); + + $signer->authorizeSession($request, $booking); + self::assertTrue($signer->isSessionAuthorized($request, $booking)); + + // Regenerating the access link overwrites accessLinkIssuedAt — the + // previously-authorized session no longer matches. + $booking->setAccessLinkIssuedAt(new \DateTimeImmutable('+1 minute')); + + self::assertFalse($signer->isSessionAuthorized($request, $booking)); + } + + public function testSessionAuthorizationFailsPastTtlEvenIfSessionMatches(): void + { + $booking = new AccommodationBooking(); + $booking->setAccessLinkIssuedAt(new \DateTimeImmutable('-91 days')); + $signer = $this->createSigner(); + $request = $this->requestWithSession(); + + $signer->authorizeSession($request, $booking); + + self::assertFalse($signer->isSessionAuthorized($request, $booking)); + } + + public function testSessionAuthorizationFailsWithoutAccessLinkIssuedAt(): void + { + $booking = new AccommodationBooking(); + $signer = $this->createSigner(); + $request = $this->requestWithSession(); + + self::assertFalse($signer->isSessionAuthorized($request, $booking)); + } + + private function requestWithSession(): Request + { + $request = Request::create('https://example.com/'); + $request->setSession(new Session(new MockArraySessionStorage())); + + return $request; + } + + 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 0000000..d44a79f --- /dev/null +++ b/tests/Service/AccommodationBookingServiceTest.php @@ -0,0 +1,359 @@ +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 testRegenerateAccessLinkOverwritesAccessLinkIssuedAt(): 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::never())->method('createAndSendEmail'); + + $service = $this->createServiceWithAccommodation( + entityManager: $entityManager, + mailer: $mailer, + breakdownCalculator: $breakdownCalculator, + ); + + $service->regenerateAccessLink($booking); + + self::assertNotSame($previousIssuedAt, $booking->getAccessLinkIssuedAt()); + } + + public function testAcceptBookingTransitionsInquiryToBookingAndSendsNotifications(): void + { + $entityManager = $this->createMock(EntityManagerInterface::class); + $entityManager->expects(self::once())->method('flush'); + + $booking = new AccommodationBooking(); + $booking->setIsInquiry(true); + $booking->setEmail('customer@example.com'); + + $mailer = $this->createMock(Mailer::class); + $mailer + ->expects(self::exactly(2)) + ->method('createAndSendEmail') + ->with( + self::anything(), + self::callback(static fn (array $options) => in_array($options['to'], ['office@example.com', 'customer@example.com'], true) + && in_array($options['template'], ['email/offer_accepted.html.twig', 'email/offer_accepted_customer.html.twig'], true)), + ); + + $service = $this->createServiceWithAccommodation(entityManager: $entityManager, mailer: $mailer); + + $service->acceptBooking($booking); + + self::assertFalse($booking->isInquiry()); + self::assertNotNull($booking->getAcceptedAt()); + } + + public function testAcceptBookingIsIdempotentAndSendsNoNotifications(): void + { + $entityManager = $this->createMock(EntityManagerInterface::class); + $entityManager->expects(self::never())->method('flush'); + + $mailer = $this->createMock(Mailer::class); + $mailer->expects(self::never())->method('createAndSendEmail'); + + $service = $this->createServiceWithAccommodation(entityManager: $entityManager, mailer: $mailer); + + $booking = new AccommodationBooking(); + $booking->setIsInquiry(false); + + $service->acceptBooking($booking); + + self::assertNull($booking->getAcceptedAt()); + } + + public function testSendOfferAcceptedNotificationEmailLogsAndSwallowsMailerFailures(): void + { + $booking = new AccommodationBooking(); + $booking->setIsInquiry(false); + + $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, logger: $logger); + + $service->sendOfferAcceptedNotificationEmail($booking); + } + + public function testSendOfferAcceptedCustomerEmailLogsAndSwallowsMailerFailures(): void + { + $booking = new AccommodationBooking(); + $booking->setEmail('customer@example.com'); + $booking->setIsInquiry(false); + + $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, logger: $logger); + + $service->sendOfferAcceptedCustomerEmail($booking); + } + + public function testRefreshPriceSnapshotStoresDiscountedFinalTotal(): void + { + $booking = new AccommodationBooking(); + $booking->setAccommodationDiscount(10); + $booking->setBoardServiceDiscount(20); + $booking->setAdditionalServicesDiscount(50); + + $breakdown = [ + 'total' => 12345, + 'currency' => 'CHF', + 'basePrice' => 8000, + 'additionalPersonsPrice' => 2000, + 'boardPrice' => 1000, + 'servicesPrice' => 500, + ]; + + $breakdownCalculator = $this->createMock(AccommodationBookingBreakdownCalculator::class); + $breakdownCalculator + ->expects(self::once()) + ->method('computeCurrent') + ->with($booking) + ->willReturn($breakdown); + + $service = $this->createServiceWithAccommodation(breakdownCalculator: $breakdownCalculator); + $service->refreshPriceSnapshot($booking); + + // accommodation: (8000+2000)*10% = 1000, board: 1000*20% = 200, services: 500*50% = 250 + self::assertSame($breakdown, $booking->getPriceBreakdown()); + self::assertSame(12345 - 1000 - 200 - 250, $booking->getTotalPrice()); + self::assertSame('CHF', $booking->getPricingCurrency()); + self::assertSame(1, $booking->getPricingVersion()); + } + + /** + * @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', + ); + } +} diff --git a/tests/Service/CmsDataProviderTest.php b/tests/Service/CmsDataProviderTest.php new file mode 100644 index 0000000..690cfee --- /dev/null +++ b/tests/Service/CmsDataProviderTest.php @@ -0,0 +1,74 @@ + new MockResponse(json_encode([ + 'success' => true, + 'name' => 'Hotel Alpin', + 'address' => "Musterstraße 1\n1234 Musterort", + 'description' => '

    Beschreibung

    ', + 'features' => '

    Ausstattung

    ', + 'room_types' => '

    Zimmer

    ', + 'additional_information' => '

    Weitere Infos

    ', + 'images' => ['resized' => ['l' => [['url' => 'l.jpg', 'alt' => 'Alt']]]], + 'icons' => ['sauna' => ['label' => 'Sauna', 'value' => true]], + 'region' => [ + 'name' => 'Alpenregion', + 'latitude' => 47.1, + 'longitude' => 11.2, + 'webcam' => 'https://webcam.test', + 'ski_area' => '

    Skigebiet

    ', + 'ski_area_extended' => '

    Mehr Skigebiet

    ', + 'description' => '

    Region

    ', + 'news' => '

    News

    ', + 'length' => '42', + 'altitude' => '1800', + 'lifts' => '5', + 'images' => ['resized' => ['l' => []]], + 'region_maps' => ['map1.jpg', 'map2.jpg'], + ], + ], JSON_THROW_ON_ERROR))); + + $provider = new CmsDataProvider($client, new ArrayAdapter(), 'test-key'); + + $data = $provider->getHotelDetails('HOTEL1'); + + self::assertNotNull($data); + self::assertSame('Hotel Alpin', $data->name); + self::assertSame("Musterstraße 1\n1234 Musterort", $data->address); + self::assertSame('

    Zimmer

    ', $data->roomTypes); + self::assertSame('

    Weitere Infos

    ', $data->additionalInformation); + self::assertSame(['sauna' => ['label' => 'Sauna', 'value' => true]], $data->icons); + self::assertNotNull($data->region); + self::assertSame('Alpenregion', $data->region->name); + self::assertSame('

    Skigebiet

    ', $data->region->skiArea); + self::assertSame('

    Mehr Skigebiet

    ', $data->region->skiAreaExtended); + self::assertSame(['map1.jpg', 'map2.jpg'], $data->region->regionMaps); + self::assertSame(42, $data->region->length); + self::assertSame(1800, $data->region->altitude); + self::assertSame(5, $data->region->lifts); + self::assertSame(47.1, $data->region->latitude); + self::assertSame(11.2, $data->region->longitude); + } + + public function testGetHotelDetailsReturnsNullOnFailure(): void + { + $client = new MockHttpClient(fn () => new MockResponse('', ['http_code' => 500])); + + $provider = new CmsDataProvider($client, new ArrayAdapter(), 'test-key'); + + self::assertNull($provider->getHotelDetails('UNKNOWN')); + } +} diff --git a/tests/Service/PriceTimelineBuilderTest.php b/tests/Service/PriceTimelineBuilderTest.php new file mode 100644 index 0000000..a77a4d5 --- /dev/null +++ b/tests/Service/PriceTimelineBuilderTest.php @@ -0,0 +1,261 @@ +builder = new PriceTimelineBuilder(); + } + + // --- buildTimeline --- + + public function testReturnsEmptyArrayForNoPrices(): void + { + $result = $this->builder->buildTimeline([], new \DateTimeImmutable('2026-01-01'), new \DateTimeImmutable('2026-12-31'), 'EUR'); + + $this->assertSame([], $result); + } + + public function testSingleBasePriceWithinYear(): void + { + $price = $this->makePrice('2026-01-01', '2026-03-31', pricePerNight: 15000, priceAdditionalPerson: 2000, includedPax: 2); + + $result = $this->builder->buildTimeline([$price], new \DateTimeImmutable('2026-01-01'), new \DateTimeImmutable('2026-12-31'), 'EUR'); + + $this->assertCount(1, $result); + $this->assertSame('2026-01-01', $result[0]->dateFrom); + $this->assertSame('2026-03-31', $result[0]->dateTo); + $this->assertSame(150.0, $result[0]->pricePerNight); + $this->assertSame(20.0, $result[0]->priceAdditionalPerson); + $this->assertSame(2, $result[0]->includedPax); + $this->assertNull($result[0]->type); + $this->assertNull($result[0]->defaultPricePerNight); + $this->assertNull($result[0]->defaultPriceAdditionalPerson); + $this->assertSame('EUR', $result[0]->currency); + } + + public function testBasePriceStartingBeforeYearIsClamped(): void + { + $price = $this->makePrice('2025-12-01', '2026-03-31', pricePerNight: 10000); + + $result = $this->builder->buildTimeline([$price], new \DateTimeImmutable('2026-01-01'), new \DateTimeImmutable('2026-12-31'), 'EUR'); + + $this->assertCount(1, $result); + $this->assertSame('2026-01-01', $result[0]->dateFrom); + $this->assertSame('2026-03-31', $result[0]->dateTo); + } + + public function testBasePriceEndingAfterYearIsClamped(): void + { + $price = $this->makePrice('2026-10-01', '2027-01-31', pricePerNight: 10000); + + $result = $this->builder->buildTimeline([$price], new \DateTimeImmutable('2026-01-01'), new \DateTimeImmutable('2026-12-31'), 'EUR'); + + $this->assertCount(1, $result); + $this->assertSame('2026-10-01', $result[0]->dateFrom); + $this->assertSame('2026-12-31', $result[0]->dateTo); + } + + public function testDiscountSplitsBasePeriodIntoThreeRows(): void + { + $base = $this->makePrice('2026-01-01', '2026-03-31', pricePerNight: 15000); + $discount = $this->makePrice('2026-01-15', '2026-01-31', pricePerNight: 12000, type: PriceType::DISCOUNT); + + $result = $this->builder->buildTimeline([$base, $discount], new \DateTimeImmutable('2026-01-01'), new \DateTimeImmutable('2026-12-31'), 'EUR'); + + $this->assertCount(3, $result); + $this->assertSame('2026-01-01', $result[0]->dateFrom); + $this->assertSame('2026-01-14', $result[0]->dateTo); + $this->assertNull($result[0]->type); + + $this->assertSame('2026-01-15', $result[1]->dateFrom); + $this->assertSame('2026-01-31', $result[1]->dateTo); + $this->assertSame('discount', $result[1]->type); + + $this->assertSame('2026-02-01', $result[2]->dateFrom); + $this->assertSame('2026-03-31', $result[2]->dateTo); + $this->assertNull($result[2]->type); + } + + public function testDiscountRowIncludesDefaultPrice(): void + { + $base = $this->makePrice('2026-01-01', '2026-03-31', pricePerNight: 15000, priceAdditionalPerson: 3000); + $discount = $this->makePrice('2026-01-15', '2026-01-31', pricePerNight: 12000, priceAdditionalPerson: 2000, type: PriceType::DISCOUNT); + + $result = $this->builder->buildTimeline([$base, $discount], new \DateTimeImmutable('2026-01-01'), new \DateTimeImmutable('2026-12-31'), 'EUR'); + + $discountRow = $result[1]; + $this->assertSame(120.0, $discountRow->pricePerNight); + $this->assertSame(150.0, $discountRow->defaultPricePerNight); + $this->assertSame(20.0, $discountRow->priceAdditionalPerson); + $this->assertSame(30.0, $discountRow->defaultPriceAdditionalPerson); + } + + public function testBaseRowsHaveNullDefaultPriceFields(): void + { + $base = $this->makePrice('2026-01-01', '2026-03-31', pricePerNight: 15000); + $discount = $this->makePrice('2026-01-15', '2026-01-31', pricePerNight: 12000, type: PriceType::DISCOUNT); + + $result = $this->builder->buildTimeline([$base, $discount], new \DateTimeImmutable('2026-01-01'), new \DateTimeImmutable('2026-12-31'), 'EUR'); + + $this->assertNull($result[0]->defaultPricePerNight); + $this->assertNull($result[0]->defaultPriceAdditionalPerson); + $this->assertNull($result[2]->defaultPricePerNight); + $this->assertNull($result[2]->defaultPriceAdditionalPerson); + } + + public function testDiscountWithNoBasePriceHasNullDefaultPrice(): void + { + $discount = $this->makePrice('2026-01-15', '2026-01-31', pricePerNight: 12000, type: PriceType::DISCOUNT); + + $result = $this->builder->buildTimeline([$discount], new \DateTimeImmutable('2026-01-01'), new \DateTimeImmutable('2026-12-31'), 'EUR'); + + $this->assertCount(1, $result); + $this->assertSame('discount', $result[0]->type); + $this->assertNull($result[0]->defaultPricePerNight); + } + + public function testGapBetweenTwoPricesIsOmitted(): void + { + $first = $this->makePrice('2026-01-01', '2026-01-31', pricePerNight: 10000); + $second = $this->makePrice('2026-03-01', '2026-03-31', pricePerNight: 20000); + + $result = $this->builder->buildTimeline([$first, $second], new \DateTimeImmutable('2026-01-01'), new \DateTimeImmutable('2026-12-31'), 'EUR'); + + $this->assertCount(2, $result); + $this->assertSame('2026-01-01', $result[0]->dateFrom); + $this->assertSame('2026-01-31', $result[0]->dateTo); + $this->assertSame('2026-03-01', $result[1]->dateFrom); + $this->assertSame('2026-03-31', $result[1]->dateTo); + } + + public function testOverrideSplitsBasePeriod(): void + { + $base = $this->makePrice('2026-01-01', '2026-03-31', pricePerNight: 15000); + $override = $this->makePrice('2026-01-15', '2026-01-31', pricePerNight: 18000, type: PriceType::OVERRIDE); + + $result = $this->builder->buildTimeline([$base, $override], new \DateTimeImmutable('2026-01-01'), new \DateTimeImmutable('2026-12-31'), 'EUR'); + + $this->assertCount(3, $result); + $this->assertNull($result[0]->type); + $this->assertSame('override', $result[1]->type); + $this->assertSame(180.0, $result[1]->pricePerNight); + $this->assertNull($result[2]->type); + } + + public function testDiscountOverTwoDifferentBasePricesProducesTwoDiscountRows(): void + { + // The discount spans two different base price periods. Even though the discount entity + // is the same, the rows must NOT be merged because defaultPricePerNight differs. + $baseA = $this->makePrice('2026-01-01', '2026-01-20', pricePerNight: 10000); + $baseB = $this->makePrice('2026-01-21', '2026-03-31', pricePerNight: 12000); + $discount = $this->makePrice('2026-01-15', '2026-01-31', pricePerNight: 8000, type: PriceType::DISCOUNT); + + $result = $this->builder->buildTimeline([$baseA, $baseB, $discount], new \DateTimeImmutable('2026-01-01'), new \DateTimeImmutable('2026-12-31'), 'EUR'); + + $this->assertCount(4, $result); + + $this->assertSame('2026-01-01', $result[0]->dateFrom); + $this->assertSame('2026-01-14', $result[0]->dateTo); + $this->assertNull($result[0]->type); + + $this->assertSame('2026-01-15', $result[1]->dateFrom); + $this->assertSame('2026-01-20', $result[1]->dateTo); + $this->assertSame('discount', $result[1]->type); + $this->assertSame(100.0, $result[1]->defaultPricePerNight); // baseA + + $this->assertSame('2026-01-21', $result[2]->dateFrom); + $this->assertSame('2026-01-31', $result[2]->dateTo); + $this->assertSame('discount', $result[2]->type); + $this->assertSame(120.0, $result[2]->defaultPricePerNight); // baseB + + $this->assertSame('2026-02-01', $result[3]->dateFrom); + $this->assertSame('2026-03-31', $result[3]->dateTo); + $this->assertNull($result[3]->type); + } + + // --- resolveWinner --- + + public function testResolveWinnerReturnsNullForEmptyArray(): void + { + $this->assertNull($this->builder->resolveWinner([])); + } + + public function testResolveWinnerReturnsSingleCandidate(): void + { + $price = $this->makePrice('2026-01-01', '2026-03-31', pricePerNight: 10000); + + $this->assertSame($price, $this->builder->resolveWinner([$price])); + } + + public function testResolveWinnerPrefersDiscountOverBase(): void + { + $base = $this->makePrice('2026-01-01', '2026-03-31', pricePerNight: 15000); + $discount = $this->makePrice('2026-01-15', '2026-01-31', pricePerNight: 12000, type: PriceType::DISCOUNT); + + $this->assertSame($discount, $this->builder->resolveWinner([$base, $discount])); + } + + public function testResolveWinnerPrefersOverrideOverBase(): void + { + $base = $this->makePrice('2026-01-01', '2026-03-31', pricePerNight: 15000); + $override = $this->makePrice('2026-01-15', '2026-01-31', pricePerNight: 18000, type: PriceType::OVERRIDE); + + $this->assertSame($override, $this->builder->resolveWinner([$base, $override])); + } + + public function testResolveWinnerPrefersDiscountOverOverride(): void + { + $override = $this->makePrice('2026-01-01', '2026-01-31', pricePerNight: 18000, type: PriceType::OVERRIDE); + $discount = $this->makePrice('2026-01-01', '2026-01-31', pricePerNight: 12000, type: PriceType::DISCOUNT); + + $this->assertSame($discount, $this->builder->resolveWinner([$override, $discount])); + } + + public function testResolveWinnerPrefersShorterPeriodOnEqualTypeTie(): void + { + $wide = $this->makePrice('2026-01-01', '2026-03-31', pricePerNight: 15000); + $narrow = $this->makePrice('2026-01-15', '2026-01-31', pricePerNight: 12000); + + $this->assertSame($narrow, $this->builder->resolveWinner([$wide, $narrow])); + } + + public function testResolveWinnerPrefersLaterStartOnEqualLengthTie(): void + { + // Both cover 30 days; the one starting later should win. + $earlier = $this->makePrice('2026-01-01', '2026-01-30', pricePerNight: 15000); + $later = $this->makePrice('2026-02-01', '2026-03-02', pricePerNight: 12000); + + $this->assertSame($later, $this->builder->resolveWinner([$earlier, $later])); + } + + private function makePrice( + string $dateFrom, + string $dateTo, + int $pricePerNight, + int $priceAdditionalPerson = 0, + int $includedPax = 2, + ?PriceType $type = null, + ): AccommodationPrice { + $price = new AccommodationPrice(); + $price->setDateFrom(new \DateTimeImmutable($dateFrom)); + $price->setDateTo(new \DateTimeImmutable($dateTo)); + $price->setPricePerNight($pricePerNight); + $price->setPriceAdditionalPerson($priceAdditionalPerson); + $price->setIncludedPax($includedPax); + $price->setType($type); + + return $price; + } +} diff --git a/translations/messages.de.yaml b/translations/messages.de.yaml index 8e50e5f..090fe23 100644 --- a/translations/messages.de.yaml +++ b/translations/messages.de.yaml @@ -13,6 +13,23 @@ service: mixed: separator: ' und ' +enum: + additional_service: + flat: pauschal + per_person: pro Person + per_night: pro Nacht + per_person_per_night: pro Person und Nacht + season: + peak: + label: Hauptsaison + token: HS + secondary: + label: Nebensaison + token: NS + adv_secondary: + label: Vorteils-Nebensaison + token: VNS + paginator: previous: vorherige next: nächste diff --git a/translations/validators.de.yaml b/translations/validators.de.yaml new file mode 100644 index 0000000..12d9768 --- /dev/null +++ b/translations/validators.de.yaml @@ -0,0 +1,2 @@ +required: Bitte angeben +invalid: Bitte einen gültigen Wert angeben diff --git a/webpack.config.js b/webpack.config.js index 8bc39b0..15222e6 100644 --- a/webpack.config.js +++ b/webpack.config.js @@ -8,6 +8,7 @@ Encore .setOutputPath('public/build/') .setPublicPath('/build') .addEntry('app', './assets/app.js') + .addEntry('admin', './assets/admin.js') .splitEntryChunks() // enables the Symfony UX Stimulus bridge (used in assets/bootstrap.js)