wip: group inquiries and bookings
This commit is contained in:
1 parent
6a6051e92b
commit
505bda46cf
115 files changed
+8107
-424
No files matched your search
@@ -48,6 +48,12 @@ MAILJET_DEFAULT_LIST_ID=
|
||||
MAILJET_WEBHOOK_BASIC_PASSWORD=secret
|
||||
APP_NEWSLETTER_CONFIRMATION_TTL_HOURS=1
|
||||
|
||||
# Dedicated signing secret for accommodation booking customer access links (kept separate
|
||||
# from APP_SECRET so it can be rotated independently). Unlike lazily-used API keys, UriSigner
|
||||
# validates this is non-empty at construction time, so a real dev value is required here
|
||||
# (override in .env.local/production secrets as usual).
|
||||
ACCOMMODATION_OFFER_LINK_SECRET=abf8ec28cc2162d9121d35d6f8cbf7c29728cdacce0303e22e1bac854501728a
|
||||
|
||||
BPN_CONNECT_BASE_URL=https://bpn-connect.ep-reisen.app
|
||||
BPN_CONNECT_API_KEY=
|
||||
|
||||
@@ -77,10 +83,17 @@ APP_TRAVEL_SNAPSHOT_RETENTION_BUFFER_DAYS=14
|
||||
# Emails
|
||||
APP_DEFAULT_EMAIL_FROM=[email protected]
|
||||
APP_DEFAULT_EMAIL_TO=[email protected]
|
||||
ACCOMMODATION_INQUIRY_EMAIL=[email protected]
|
||||
|
||||
# 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)
|
||||
|
||||
@@ -149,3 +149,10 @@ GET {{base_url}}/api/pickups-planning/DPWWTS191125
|
||||
Accept: application/json
|
||||
Content-Type: application/json
|
||||
Authorization: Bearer {{$auth.token("oauth2_api")}}
|
||||
|
||||
### API accommodation booking
|
||||
# @no-cookie-jar
|
||||
GET {{base_url}}/api/accommodation-bookings/e4591076-eb65-41e8-a115-09d18d15604a
|
||||
Accept: application/json
|
||||
Content-Type: application/json
|
||||
Authorization: Bearer {{$auth.token("oauth2_api")}}
|
||||
@@ -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')
|
||||
}
|
||||
}
|
||||
@@ -1,42 +0,0 @@
|
||||
import { Controller } from '@hotwired/stimulus'
|
||||
import Sortable from 'sortablejs'
|
||||
import { useFetch } from '../mixins/use_fetch'
|
||||
|
||||
/* stimulusFetch: 'lazy' */
|
||||
export default class extends Controller {
|
||||
|
||||
static targets = [ 'item', 'handle', 'positionField', 'positionLabel' ]
|
||||
static values = { url: String }
|
||||
|
||||
connect() {
|
||||
useFetch(this)
|
||||
new Sortable(this.element, {
|
||||
item: '[data-sortable-target="item"]',
|
||||
handle: '[data-sortable-target="handle"]',
|
||||
sort: true,
|
||||
onEnd: () => {
|
||||
this.updateOrdering()
|
||||
if (this.urlValue) {
|
||||
this.saveOrdering()
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
updateOrdering() {
|
||||
this.positionFieldTargets.forEach((field, index) => {
|
||||
field.value = index + 1
|
||||
})
|
||||
this.positionLabelTargets.forEach((label, index) => {
|
||||
label.innerText = index + 1
|
||||
})
|
||||
}
|
||||
|
||||
saveOrdering() {
|
||||
const body = new FormData()
|
||||
this.positionFieldTargets.forEach(field => {
|
||||
body.append(`sorting[${field.dataset.id}]`, field.value)
|
||||
})
|
||||
fetch(this.urlValue, this.getInitObject('post', body))
|
||||
}
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 839 KiB |
@@ -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%);;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
@import "components/button.css";
|
||||
@import "components/forms.css";
|
||||
@import "components/pagination.css";
|
||||
@import "components/price_calendar.css";
|
||||
@import "components/toast.css";
|
||||
@import "components/tooltip.css";
|
||||
@import "components/typography.css";
|
||||
@@ -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;
|
||||
}
|
||||
@@ -13,6 +13,7 @@ parameters:
|
||||
travel_snapshot_retention_buffer_days: '%env(int:APP_TRAVEL_SNAPSHOT_RETENTION_BUFFER_DAYS)%'
|
||||
default_email_from: '%env(APP_DEFAULT_EMAIL_FROM)%'
|
||||
default_email_to: '%env(APP_DEFAULT_EMAIL_TO)%'
|
||||
accommodation_inquiry_email: '%env(ACCOMMODATION_INQUIRY_EMAIL)%'
|
||||
|
||||
# MailJet list ids and their labels
|
||||
mailjet_lists:
|
||||
@@ -254,6 +255,20 @@ services:
|
||||
from: '%default_email_from%'
|
||||
to: '%default_email_to%'
|
||||
|
||||
App\Service\AccommodationBookingService:
|
||||
arguments:
|
||||
$officeEmail: '%accommodation_inquiry_email%'
|
||||
|
||||
App\Service\GroupsPriceCalculator:
|
||||
arguments:
|
||||
$config:
|
||||
runningCostsEur: '%env(float:APP_RUNNING_COSTS_FACTOR_EUR)%'
|
||||
runningCostsChf: '%env(float:APP_RUNNING_COSTS_FACTOR_CHF)%'
|
||||
undersubscription30Eur: '%env(float:APP_UNDERSUBSCRIPTION_SURCHARGE_30_EUR)%'
|
||||
undersubscription30Chf: '%env(float:APP_UNDERSUBSCRIPTION_SURCHARGE_30_CHF)%'
|
||||
undersubscription40Eur: '%env(float:APP_UNDERSUBSCRIPTION_SURCHARGE_40_EUR)%'
|
||||
undersubscription40Chf: '%env(float:APP_UNDERSUBSCRIPTION_SURCHARGE_40_CHF)%'
|
||||
|
||||
App\Validator\Constraints\ParticipantValidator:
|
||||
arguments:
|
||||
$bodyDimensionRanges: '%body_dimension_ranges%'
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace DoctrineMigrations;
|
||||
|
||||
use Doctrine\DBAL\Schema\Schema;
|
||||
use Doctrine\Migrations\AbstractMigration;
|
||||
|
||||
/**
|
||||
* Auto-generated Migration: Please modify to your needs!
|
||||
*/
|
||||
final class Version20260706120805 extends AbstractMigration
|
||||
{
|
||||
public function getDescription(): string
|
||||
{
|
||||
return '';
|
||||
}
|
||||
|
||||
public function up(Schema $schema): void
|
||||
{
|
||||
// this up() migration is auto-generated, please modify it to your needs
|
||||
$this->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');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace DoctrineMigrations;
|
||||
|
||||
use Doctrine\DBAL\Schema\Schema;
|
||||
use Doctrine\Migrations\AbstractMigration;
|
||||
|
||||
/**
|
||||
* Auto-generated Migration: Please modify to your needs!
|
||||
*/
|
||||
final class Version20260706141648 extends AbstractMigration
|
||||
{
|
||||
public function getDescription(): string
|
||||
{
|
||||
return 'Add accommodation_inquiry and accommodation_inquiry_additional_service tables';
|
||||
}
|
||||
|
||||
public function up(Schema $schema): void
|
||||
{
|
||||
// this up() migration is auto-generated, please modify it to your needs
|
||||
$this->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');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace DoctrineMigrations;
|
||||
|
||||
use Doctrine\DBAL\Schema\Schema;
|
||||
use Doctrine\Migrations\AbstractMigration;
|
||||
|
||||
/**
|
||||
* Auto-generated Migration: Please modify to your needs!
|
||||
*/
|
||||
final class Version20260707094812 extends AbstractMigration
|
||||
{
|
||||
public function getDescription(): string
|
||||
{
|
||||
return 'Drop children_count (4–5 J.) and rename adolescents_count to children_count (now 4–X J.)';
|
||||
}
|
||||
|
||||
public function up(Schema $schema): void
|
||||
{
|
||||
$this->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');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace DoctrineMigrations;
|
||||
|
||||
use Doctrine\DBAL\Schema\Schema;
|
||||
use Doctrine\Migrations\AbstractMigration;
|
||||
|
||||
final class Version20260707150000 extends AbstractMigration
|
||||
{
|
||||
public function getDescription(): string
|
||||
{
|
||||
return 'Add group_name to accommodation_inquiry';
|
||||
}
|
||||
|
||||
public function up(Schema $schema): void
|
||||
{
|
||||
$this->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');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace DoctrineMigrations;
|
||||
|
||||
use Doctrine\DBAL\Schema\Schema;
|
||||
use Doctrine\Migrations\AbstractMigration;
|
||||
|
||||
/**
|
||||
* Auto-generated Migration: Please modify to your needs!
|
||||
*/
|
||||
final class Version20260708071724 extends AbstractMigration
|
||||
{
|
||||
public function getDescription(): string
|
||||
{
|
||||
return 'Replace accommodation_inquiry_additional_service table with JSON column; add uuid unique index';
|
||||
}
|
||||
|
||||
public function up(Schema $schema): void
|
||||
{
|
||||
// this up() migration is auto-generated, please modify it to your needs
|
||||
$this->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');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace DoctrineMigrations;
|
||||
|
||||
use Doctrine\DBAL\Schema\Schema;
|
||||
use Doctrine\Migrations\AbstractMigration;
|
||||
|
||||
/**
|
||||
* Auto-generated Migration: Please modify to your needs!
|
||||
*/
|
||||
final class Version20260708081559 extends AbstractMigration
|
||||
{
|
||||
public function getDescription(): string
|
||||
{
|
||||
return '';
|
||||
}
|
||||
|
||||
public function up(Schema $schema): void
|
||||
{
|
||||
// this up() migration is auto-generated, please modify it to your needs
|
||||
$this->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');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace DoctrineMigrations;
|
||||
|
||||
use Doctrine\DBAL\Schema\Schema;
|
||||
use Doctrine\Migrations\AbstractMigration;
|
||||
|
||||
/**
|
||||
* Auto-generated Migration: Please modify to your needs!
|
||||
*/
|
||||
final class Version20260708102807 extends AbstractMigration
|
||||
{
|
||||
public function getDescription(): string
|
||||
{
|
||||
return '';
|
||||
}
|
||||
|
||||
public function up(Schema $schema): void
|
||||
{
|
||||
// this up() migration is auto-generated, please modify it to your needs
|
||||
$this->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');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace DoctrineMigrations;
|
||||
|
||||
use Doctrine\DBAL\Schema\Schema;
|
||||
use Doctrine\Migrations\AbstractMigration;
|
||||
|
||||
/**
|
||||
* Auto-generated Migration: Please modify to your needs!
|
||||
*/
|
||||
final class Version20260709133922 extends AbstractMigration
|
||||
{
|
||||
public function getDescription(): string
|
||||
{
|
||||
return 'Drop manual sorting column from board_service/additional_service; they are now ordered by dateFrom then label.';
|
||||
}
|
||||
|
||||
public function up(Schema $schema): void
|
||||
{
|
||||
$this->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');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace DoctrineMigrations;
|
||||
|
||||
use Doctrine\DBAL\Schema\Schema;
|
||||
use Doctrine\Migrations\AbstractMigration;
|
||||
|
||||
/**
|
||||
* Auto-generated Migration: Please modify to your needs!
|
||||
*/
|
||||
final class Version20260709141929 extends AbstractMigration
|
||||
{
|
||||
public function getDescription(): string
|
||||
{
|
||||
return 'Add nullable accepted_at column to accommodation_booking for audit tracking of inquiry-to-booking acceptance.';
|
||||
}
|
||||
|
||||
public function up(Schema $schema): void
|
||||
{
|
||||
$this->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');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace DoctrineMigrations;
|
||||
|
||||
use Doctrine\DBAL\Schema\Schema;
|
||||
use Doctrine\Migrations\AbstractMigration;
|
||||
|
||||
/**
|
||||
* Auto-generated Migration: Please modify to your needs!
|
||||
*/
|
||||
final class Version20260709162705 extends AbstractMigration
|
||||
{
|
||||
public function getDescription(): string
|
||||
{
|
||||
return 'Add nullable access_link_issued_at column to accommodation_booking for the customer-facing offer/accept access link.';
|
||||
}
|
||||
|
||||
public function up(Schema $schema): void
|
||||
{
|
||||
$this->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');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,582 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Command;
|
||||
|
||||
use App\Entity\Groups\Accommodation;
|
||||
use App\Entity\Groups\AccommodationPrice;
|
||||
use App\Entity\Groups\AdditionalService;
|
||||
use App\Entity\Groups\BoardService;
|
||||
use App\Enum\Groups\AdditionalServiceType;
|
||||
use App\Enum\Groups\Season;
|
||||
use App\Repository\Groups\AccommodationRepository;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Symfony\Component\Console\Attribute\AsCommand;
|
||||
use Symfony\Component\Console\Command\Command;
|
||||
use Symfony\Component\Console\Input\InputArgument;
|
||||
use Symfony\Component\Console\Input\InputInterface;
|
||||
use Symfony\Component\Console\Input\InputOption;
|
||||
use Symfony\Component\Console\Output\OutputInterface;
|
||||
use Symfony\Component\Console\Style\SymfonyStyle;
|
||||
|
||||
#[AsCommand(
|
||||
name: 'app:groups:import-legacy-data',
|
||||
description: 'Imports Accommodation/AccommodationPrice/BoardService/AdditionalService data from the legacy TYPO3 CSV exports (hotels.csv, groupspriceconfig.csv, groupspriceboard.csv, groupspriceoption.csv, seasons.csv)',
|
||||
)]
|
||||
class ImportGroupsLegacyDataCommand extends Command
|
||||
{
|
||||
private const int DEFAULT_MIN_NIGHTS = 1;
|
||||
private const Season DEFAULT_SEASON = Season::SECONDARY;
|
||||
|
||||
/** @var array<int, string> 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<string, Season> 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<int, Accommodation> $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<array<string, string>>
|
||||
*/
|
||||
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<array<string, string>> $configRows
|
||||
*
|
||||
* @return array<int, string>
|
||||
*/
|
||||
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<array<string, string>> $rows
|
||||
*
|
||||
* @return list<array{season: Season, dateFrom: \DateTimeImmutable, dateTo: \DateTimeImmutable}>
|
||||
*/
|
||||
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<array{season: Season, dateFrom: \DateTimeImmutable, dateTo: \DateTimeImmutable}> $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<array<string, string>> $rows
|
||||
* @param array<int, Accommodation> $accommodationByUid
|
||||
* @param list<int> $knownUids
|
||||
* @param list<array{season: Season, dateFrom: \DateTimeImmutable, dateTo: \DateTimeImmutable}> $seasonRanges
|
||||
*
|
||||
* @return array{0: int, 1: list<int>, 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<array<string, string>> $rows
|
||||
* @param array<int, Accommodation> $accommodationByUid
|
||||
* @param list<int> $knownUids
|
||||
*
|
||||
* @return array{0: int, 1: list<int>}
|
||||
*/
|
||||
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<array<string, string>> $rows
|
||||
* @param array<int, Accommodation> $accommodationByUid
|
||||
* @param list<int> $knownUids
|
||||
*
|
||||
* @return array{0: int, 1: list<int>, 2: list<AdditionalService>}
|
||||
*/
|
||||
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<int, Accommodation> $accommodationByUid
|
||||
* @param list<AdditionalService> $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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Controller\Admin\AccommodationBooking;
|
||||
|
||||
use App\Entity\Groups\AccommodationBooking;
|
||||
use App\Form\Admin\Groups\AccommodationBookingType;
|
||||
use App\Service\AccommodationBookingService;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Psr\Log\LoggerInterface;
|
||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\Routing\Attribute\Route;
|
||||
|
||||
class CreateController extends AbstractController
|
||||
{
|
||||
public function __construct(
|
||||
private readonly EntityManagerInterface $entityManager,
|
||||
private readonly LoggerInterface $logger,
|
||||
private readonly AccommodationBookingService $bookingService,
|
||||
) {
|
||||
}
|
||||
|
||||
#[Route('/admin/accommodation-booking/create', name: 'app_admin_accommodationbooking_create')]
|
||||
public function index(Request $request): Response
|
||||
{
|
||||
$booking = new AccommodationBooking();
|
||||
|
||||
$form = $this->createForm(AccommodationBookingType::class, $booking, [
|
||||
'with_accommodation' => true,
|
||||
]);
|
||||
$form->handleRequest($request);
|
||||
|
||||
if ($form->isSubmitted() && $form->isValid()) {
|
||||
$this->entityManager->persist($booking);
|
||||
$this->entityManager->flush();
|
||||
|
||||
$this->bookingService->issueAccessLinkForDirectBooking($booking);
|
||||
$this->bookingService->sendCustomerConfirmationEmail($booking);
|
||||
|
||||
$this->addFlash('success', 'Die Buchung wurde erstellt');
|
||||
|
||||
$this->logger->info('Created accommodation booking', [
|
||||
'id' => $booking->getId(),
|
||||
'groupName' => $booking->getGroupName(),
|
||||
]);
|
||||
|
||||
return $this->redirectToRoute('app_admin_accommodationbooking_edit', ['id' => $booking->getId()]);
|
||||
}
|
||||
|
||||
return $this->render('admin/accommodation_booking/create.html.twig', [
|
||||
'form' => $form,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Controller\Admin\AccommodationBooking;
|
||||
|
||||
use App\Entity\Groups\AccommodationBooking;
|
||||
use App\Entity\Groups\AdditionalService;
|
||||
use App\Entity\Groups\BoardService;
|
||||
use App\Form\Admin\Groups\AccommodationBookingType;
|
||||
use App\Repository\Groups\AdditionalServiceRepository;
|
||||
use App\Repository\Groups\BoardServiceRepository;
|
||||
use App\Service\AccommodationBookingService;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Psr\Log\LoggerInterface;
|
||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\Routing\Attribute\Route;
|
||||
|
||||
class EditController extends AbstractController
|
||||
{
|
||||
public function __construct(
|
||||
private readonly EntityManagerInterface $entityManager,
|
||||
private readonly LoggerInterface $logger,
|
||||
private readonly BoardServiceRepository $boardServiceRepo,
|
||||
private readonly AdditionalServiceRepository $additionalServiceRepo,
|
||||
private readonly AccommodationBookingService $bookingService,
|
||||
) {
|
||||
}
|
||||
|
||||
#[Route('/admin/accommodation-booking/{id}/edit', name: 'app_admin_accommodationbooking_edit')]
|
||||
public function index(AccommodationBooking $booking, Request $request): Response
|
||||
{
|
||||
$accommodation = $booking->getAccommodation();
|
||||
$boardServices = [];
|
||||
$additionalServices = [];
|
||||
$currentBoardService = null;
|
||||
$currentAdditionalServices = [];
|
||||
|
||||
if (null !== $accommodation && null !== $booking->getDateFrom() && null !== $booking->getDateTo()) {
|
||||
$boardServices = $this->boardServiceRepo->findByAccommodationAndDateRange(
|
||||
$accommodation,
|
||||
$booking->getDateFrom(),
|
||||
$booking->getDateTo(),
|
||||
);
|
||||
$additionalServices = $this->additionalServiceRepo->findByAccommodationAndDateRange(
|
||||
$accommodation,
|
||||
$booking->getDateFrom(),
|
||||
$booking->getDateTo(),
|
||||
);
|
||||
|
||||
// Pre-select current board service if it is still in the available choices
|
||||
foreach ($boardServices as $bs) {
|
||||
if ($bs->getId() === $booking->getBoardServiceOriginalId()) {
|
||||
$currentBoardService = $bs;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Pre-select current additional services that are still in the available choices
|
||||
$currentIds = array_column($booking->getAdditionalServices(), 'originalServiceId');
|
||||
$currentAdditionalServices = array_values(array_filter(
|
||||
$additionalServices,
|
||||
fn(AdditionalService $s) => in_array($s->getId(), $currentIds, true),
|
||||
));
|
||||
}
|
||||
|
||||
$form = $this->createForm(AccommodationBookingType::class, $booking, [
|
||||
'max_adolescent_age' => $accommodation?->getMaxAdolescentAge() ?? 0,
|
||||
'board_services' => $boardServices,
|
||||
'additional_services' => $additionalServices,
|
||||
'current_board_service' => $currentBoardService,
|
||||
'current_additional_services' => $currentAdditionalServices,
|
||||
]);
|
||||
$wasInquiry = $booking->isInquiry();
|
||||
|
||||
$form->handleRequest($request);
|
||||
|
||||
if ($form->isSubmitted() && $form->isValid()) {
|
||||
if ($form->has('boardService')) {
|
||||
$selectedBoardService = $form->get('boardService')->getData();
|
||||
if ($selectedBoardService instanceof BoardService) {
|
||||
$booking->setBoardServiceLabel($selectedBoardService->getLabel());
|
||||
$booking->setBoardServicePrice($selectedBoardService->getPrice());
|
||||
$booking->setBoardServiceOriginalId($selectedBoardService->getId());
|
||||
} else {
|
||||
$booking->setBoardServiceLabel(null);
|
||||
$booking->setBoardServicePrice(null);
|
||||
$booking->setBoardServiceOriginalId(null);
|
||||
}
|
||||
}
|
||||
|
||||
if ($form->has('selectedAdditionalServices')) {
|
||||
$booking->setAdditionalServices([]);
|
||||
foreach ($form->get('selectedAdditionalServices')->getData() as $service) {
|
||||
$booking->addAdditionalServiceSnapshot(
|
||||
$service->getLabel() ?? '',
|
||||
$service->getPrice() ?? 0,
|
||||
$service->getType(),
|
||||
$service->getId(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
$this->entityManager->persist($booking);
|
||||
$this->entityManager->flush();
|
||||
|
||||
if ($wasInquiry && !$booking->isInquiry()) {
|
||||
$this->bookingService->issueAccessLinkForDirectBooking($booking);
|
||||
$this->bookingService->sendCustomerConfirmationEmail($booking);
|
||||
}
|
||||
|
||||
$this->addFlash('success', 'Die Buchung wurde aktualisiert');
|
||||
|
||||
$this->logger->info('Updated accommodation booking', [
|
||||
'id' => $booking->getId(),
|
||||
'groupName' => $booking->getGroupName(),
|
||||
]);
|
||||
|
||||
return $this->redirectToRoute('app_admin_accommodationbooking_edit', ['id' => $booking->getId()]);
|
||||
}
|
||||
|
||||
return $this->render('admin/accommodation_booking/edit.html.twig', [
|
||||
'booking' => $booking,
|
||||
'form' => $form,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Controller\Admin\AccommodationBooking;
|
||||
|
||||
use App\Entity\Groups\AccommodationBooking;
|
||||
use App\Service\AccommodationBookingService;
|
||||
use Psr\Log\LoggerInterface;
|
||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\Routing\Attribute\Route;
|
||||
|
||||
class GenerateAccessLinkController extends AbstractController
|
||||
{
|
||||
public function __construct(
|
||||
private readonly AccommodationBookingService $bookingService,
|
||||
private readonly LoggerInterface $logger,
|
||||
) {
|
||||
}
|
||||
|
||||
#[Route('/admin/accommodation-booking/{id}/generate-access-link', name: 'app_admin_accommodationbooking_generate_access_link')]
|
||||
public function index(AccommodationBooking $booking, Request $request): Response
|
||||
{
|
||||
if ($request->isMethod(Request::METHOD_POST)) {
|
||||
$this->bookingService->regenerateAccessLinkAndNotifyCustomer($booking);
|
||||
|
||||
$this->addFlash('success', 'Der Zugangslink wurde generiert und dem Kunden per E-Mail zugestellt.');
|
||||
|
||||
$this->logger->info('Generated accommodation booking access link', [
|
||||
'id' => $booking->getId(),
|
||||
]);
|
||||
|
||||
return $this->redirectToRoute('app_admin_accommodationbooking_show', ['id' => $booking->getId()]);
|
||||
}
|
||||
|
||||
return $this->redirectToRoute('app_admin_accommodationbooking_show', ['id' => $booking->getId()]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Controller\Admin\AccommodationBooking;
|
||||
|
||||
use App\Repository\Groups\AccommodationBookingRepository;
|
||||
use Knp\Component\Pager\PaginatorInterface;
|
||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\Routing\Attribute\Route;
|
||||
|
||||
class IndexController extends AbstractController
|
||||
{
|
||||
public function __construct(
|
||||
private readonly AccommodationBookingRepository $bookingRepository,
|
||||
private readonly PaginatorInterface $paginator,
|
||||
) {
|
||||
}
|
||||
|
||||
#[Route('/admin/accommodation-booking', name: 'app_admin_accommodationbooking')]
|
||||
public function index(Request $request): Response
|
||||
{
|
||||
$today = new \DateTimeImmutable('today');
|
||||
|
||||
$qb = $this
|
||||
->bookingRepository
|
||||
->createQueryBuilder('booking')
|
||||
->leftJoin('booking.accommodation', 'accommodation')
|
||||
->addSelect('accommodation')
|
||||
->where('booking.dateTo >= :today')
|
||||
->setParameter('today', $today)
|
||||
;
|
||||
|
||||
$pagination = $this->paginator->paginate(
|
||||
$qb,
|
||||
$request->query->getInt('page', 1),
|
||||
$request->query->getInt('limit', 20),
|
||||
[
|
||||
'defaultSortFieldName' => 'booking.dateFrom',
|
||||
'defaultSortDirection' => 'ASC',
|
||||
]
|
||||
);
|
||||
|
||||
return $this->render('admin/accommodation_booking/index.html.twig', [
|
||||
'pagination' => $pagination,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Controller\Admin\AccommodationBooking;
|
||||
|
||||
use App\Controller\Traits\ReturnUrlTrait;
|
||||
use App\Entity\Groups\AccommodationBooking;
|
||||
use App\Service\AccommodationBookingBreakdownCalculator;
|
||||
use App\Service\AccommodationBookingLinkSigner;
|
||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\Routing\Attribute\Route;
|
||||
|
||||
class ShowController extends AbstractController
|
||||
{
|
||||
use ReturnUrlTrait;
|
||||
|
||||
public function __construct(
|
||||
private readonly AccommodationBookingBreakdownCalculator $breakdownCalculator,
|
||||
private readonly AccommodationBookingLinkSigner $linkSigner,
|
||||
) {
|
||||
}
|
||||
|
||||
#[Route('/admin/accommodation-booking/{id}', name: 'app_admin_accommodationbooking_show')]
|
||||
public function index(AccommodationBooking $booking, Request $request): Response
|
||||
{
|
||||
$hasAccessLink = null !== $booking->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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -47,9 +47,11 @@ class CreateController extends AbstractController
|
||||
]
|
||||
]);
|
||||
|
||||
return $this->redirectToRoute('app_admin_accommodation_edit', [
|
||||
$returnUrl = $this->generateUrl('app_admin_accommodation_edit', [
|
||||
'id' => $accommodation->getId(),
|
||||
]);
|
||||
|
||||
return $this->redirect($returnUrl.'#prices');
|
||||
}
|
||||
|
||||
return $this->render('admin/accommodation_price/create.html.twig', [
|
||||
|
||||
@@ -44,7 +44,7 @@ class DeleteController extends AbstractController
|
||||
'id' => $accommodation->getId(),
|
||||
]);
|
||||
|
||||
return new HxRedirectResponse($returnUrl);
|
||||
return new HxRedirectResponse($returnUrl.'#prices');
|
||||
}
|
||||
|
||||
return $this->render('admin/accommodation_price/modal_delete.html.twig', [
|
||||
|
||||
@@ -53,9 +53,11 @@ class EditController extends AbstractController
|
||||
]
|
||||
]);
|
||||
|
||||
return $this->redirectToRoute('app_admin_accommodation_edit', [
|
||||
$returnUrl = $this->generateUrl('app_admin_accommodation_edit', [
|
||||
'id' => $accommodation->getId(),
|
||||
]);
|
||||
|
||||
return $this->redirect($returnUrl.'#prices');
|
||||
}
|
||||
|
||||
return $this->render('admin/accommodation_price/edit.html.twig', [
|
||||
|
||||
@@ -4,7 +4,6 @@ declare(strict_types=1);
|
||||
|
||||
namespace App\Controller\Admin\AdditionalService;
|
||||
|
||||
use App\Controller\Traits\SortingControllerTrait;
|
||||
use App\Entity\Groups\Accommodation;
|
||||
use App\Entity\Groups\AdditionalService;
|
||||
use App\Form\Admin\Groups\AdditionalServiceType;
|
||||
@@ -17,8 +16,6 @@ use Symfony\Component\Routing\Attribute\Route;
|
||||
|
||||
class CreateController extends AbstractController
|
||||
{
|
||||
use SortingControllerTrait;
|
||||
|
||||
public function __construct(
|
||||
private readonly EntityManagerInterface $entityManager,
|
||||
private readonly LoggerInterface $logger,
|
||||
@@ -47,9 +44,11 @@ class CreateController extends AbstractController
|
||||
'label' => $additionalService->getLabel(),
|
||||
]);
|
||||
|
||||
return $this->redirectToRoute('app_admin_accommodation_edit', [
|
||||
$returnUrl = $this->generateUrl('app_admin_accommodation_edit', [
|
||||
'id' => $accommodation->getId(),
|
||||
]);
|
||||
|
||||
return $this->redirect($returnUrl.'#additional-services');
|
||||
}
|
||||
|
||||
return $this->render('admin/additional_service/create.html.twig', [
|
||||
|
||||
@@ -4,7 +4,6 @@ declare(strict_types=1);
|
||||
|
||||
namespace App\Controller\Admin\AdditionalService;
|
||||
|
||||
use App\Controller\Traits\SortingControllerTrait;
|
||||
use App\Entity\Groups\AdditionalService;
|
||||
use App\Htmx\HxRedirectResponse;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
@@ -16,8 +15,6 @@ use Symfony\Component\Routing\Attribute\Route;
|
||||
|
||||
class DeleteController extends AbstractController
|
||||
{
|
||||
use SortingControllerTrait;
|
||||
|
||||
public function __construct(
|
||||
private readonly EntityManagerInterface $entityManager,
|
||||
private readonly LoggerInterface $logger,
|
||||
@@ -45,7 +42,7 @@ class DeleteController extends AbstractController
|
||||
'id' => $accommodation->getId(),
|
||||
]);
|
||||
|
||||
return new HxRedirectResponse($returnUrl);
|
||||
return new HxRedirectResponse($returnUrl.'#additional-services');
|
||||
}
|
||||
|
||||
return $this->render('admin/additional_service/modal_delete.html.twig', [
|
||||
|
||||
@@ -4,7 +4,6 @@ declare(strict_types=1);
|
||||
|
||||
namespace App\Controller\Admin\AdditionalService;
|
||||
|
||||
use App\Controller\Traits\SortingControllerTrait;
|
||||
use App\Entity\Groups\Accommodation;
|
||||
use App\Entity\Groups\AdditionalService;
|
||||
use App\Form\Admin\Groups\AdditionalServiceType;
|
||||
@@ -19,8 +18,6 @@ use Symfony\Component\Routing\Attribute\Route;
|
||||
|
||||
class EditController extends AbstractController
|
||||
{
|
||||
use SortingControllerTrait;
|
||||
|
||||
public function __construct(
|
||||
private readonly EntityManagerInterface $entityManager,
|
||||
private readonly LoggerInterface $logger,
|
||||
@@ -56,9 +53,11 @@ class EditController extends AbstractController
|
||||
'label' => $additionalService->getLabel(),
|
||||
]);
|
||||
|
||||
return $this->redirectToRoute('app_admin_accommodation_edit', [
|
||||
$returnUrl = $this->generateUrl('app_admin_accommodation_edit', [
|
||||
'id' => $accommodation->getId(),
|
||||
]);
|
||||
|
||||
return $this->redirect($returnUrl.'#additional-services');
|
||||
}
|
||||
|
||||
return $this->render('admin/additional_service/edit.html.twig', [
|
||||
|
||||
@@ -1,32 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Controller\Admin\AdditionalService;
|
||||
|
||||
use App\Controller\Traits\SortingControllerTrait;
|
||||
use App\Entity\Groups\Accommodation;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\Routing\Attribute\Route;
|
||||
|
||||
class SortingController extends AbstractController
|
||||
{
|
||||
use SortingControllerTrait;
|
||||
|
||||
public function __construct(private readonly EntityManagerInterface $entityManager)
|
||||
{
|
||||
}
|
||||
|
||||
#[Route('/admin/additional-service/sort/{id}', name: 'app_admin_additionalservice_sort', methods: ['POST'])]
|
||||
public function index(Accommodation $accommodation, Request $request): Response
|
||||
{
|
||||
$this->applySorting($accommodation->getAdditionalServices(), $request->request->all('sorting'));
|
||||
|
||||
$this->entityManager->flush();
|
||||
|
||||
return new Response('', Response::HTTP_NO_CONTENT);
|
||||
}
|
||||
}
|
||||
@@ -4,7 +4,6 @@ declare(strict_types=1);
|
||||
|
||||
namespace App\Controller\Admin\BoardService;
|
||||
|
||||
use App\Controller\Traits\SortingControllerTrait;
|
||||
use App\Entity\Groups\Accommodation;
|
||||
use App\Entity\Groups\BoardService;
|
||||
use App\Form\Admin\Groups\BoardServiceType;
|
||||
@@ -17,7 +16,6 @@ use Symfony\Component\Routing\Attribute\Route;
|
||||
|
||||
class CreateController extends AbstractController
|
||||
{
|
||||
use SortingControllerTrait;
|
||||
public function __construct(
|
||||
private readonly EntityManagerInterface $entityManager,
|
||||
private readonly LoggerInterface $logger,
|
||||
@@ -46,9 +44,11 @@ class CreateController extends AbstractController
|
||||
'label' => $boardService->getLabel(),
|
||||
]);
|
||||
|
||||
return $this->redirectToRoute('app_admin_accommodation_edit', [
|
||||
$returnUrl = $this->redirectToRoute('app_admin_accommodation_edit', [
|
||||
'id' => $accommodation->getId(),
|
||||
]);
|
||||
|
||||
return $this->redirect($returnUrl.'#board-services');
|
||||
}
|
||||
|
||||
return $this->render('admin/board_service/create.html.twig', [
|
||||
|
||||
@@ -4,7 +4,6 @@ declare(strict_types=1);
|
||||
|
||||
namespace App\Controller\Admin\BoardService;
|
||||
|
||||
use App\Controller\Traits\SortingControllerTrait;
|
||||
use App\Entity\Groups\BoardService;
|
||||
use App\Htmx\HxRedirectResponse;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
@@ -16,7 +15,6 @@ use Symfony\Component\Routing\Attribute\Route;
|
||||
|
||||
class DeleteController extends AbstractController
|
||||
{
|
||||
use SortingControllerTrait;
|
||||
public function __construct(
|
||||
private readonly EntityManagerInterface $entityManager,
|
||||
private readonly LoggerInterface $logger,
|
||||
@@ -44,7 +42,7 @@ class DeleteController extends AbstractController
|
||||
'id' => $accommodation->getId(),
|
||||
]);
|
||||
|
||||
return new HxRedirectResponse($returnUrl);
|
||||
return new HxRedirectResponse($returnUrl.'#board-services');
|
||||
}
|
||||
|
||||
return $this->render('admin/board_service/modal_delete.html.twig', [
|
||||
|
||||
@@ -4,7 +4,6 @@ declare(strict_types=1);
|
||||
|
||||
namespace App\Controller\Admin\BoardService;
|
||||
|
||||
use App\Controller\Traits\SortingControllerTrait;
|
||||
use App\Entity\Groups\BoardService;
|
||||
use App\Form\Admin\Groups\BoardServiceType;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
@@ -16,7 +15,6 @@ use Symfony\Component\Routing\Attribute\Route;
|
||||
|
||||
class EditController extends AbstractController
|
||||
{
|
||||
use SortingControllerTrait;
|
||||
public function __construct(
|
||||
private readonly EntityManagerInterface $entityManager,
|
||||
private readonly LoggerInterface $logger,
|
||||
@@ -52,9 +50,11 @@ class EditController extends AbstractController
|
||||
'label' => $boardService->getLabel(),
|
||||
]);
|
||||
|
||||
return $this->redirectToRoute('app_admin_accommodation_edit', [
|
||||
$returnUrl = $this->redirectToRoute('app_admin_accommodation_edit', [
|
||||
'id' => $accommodation->getId(),
|
||||
]);
|
||||
|
||||
return $this->redirect($returnUrl.'#board-services');
|
||||
}
|
||||
|
||||
return $this->render('admin/board_service/edit.html.twig', [
|
||||
|
||||
@@ -1,32 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Controller\Admin\BoardService;
|
||||
|
||||
use App\Controller\Traits\SortingControllerTrait;
|
||||
use App\Entity\Groups\Accommodation;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\Routing\Attribute\Route;
|
||||
|
||||
class SortingController extends AbstractController
|
||||
{
|
||||
use SortingControllerTrait;
|
||||
|
||||
public function __construct(private readonly EntityManagerInterface $entityManager)
|
||||
{
|
||||
}
|
||||
|
||||
#[Route('/admin/board-service/sort/{id}', name: 'app_admin_boardservice_sort', methods: ['POST'])]
|
||||
public function index(Accommodation $accommodation, Request $request): Response
|
||||
{
|
||||
$this->applySorting($accommodation->getBoardServices(), $request->request->all('sorting'));
|
||||
|
||||
$this->entityManager->flush();
|
||||
|
||||
return new Response('', Response::HTTP_NO_CONTENT);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
<?php
|
||||
|
||||
namespace App\Controller\Api;
|
||||
|
||||
use App\Entity\Groups\AccommodationBooking;
|
||||
use App\Model\AccommodationBookingApiResponse;
|
||||
use App\Repository\Groups\AccommodationBookingRepository;
|
||||
use App\Service\AccommodationBookingBreakdownCalculator;
|
||||
use App\Service\AccommodationBookingService;
|
||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||
use Symfony\Component\HttpFoundation\JsonResponse;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\Routing\Attribute\Route;
|
||||
use Symfony\Component\Security\Http\Attribute\IsGranted;
|
||||
use Symfony\Component\Serializer\SerializerInterface;
|
||||
|
||||
#[Route('/api')]
|
||||
#[IsGranted('ROLE_OAUTH2_API')]
|
||||
class AccommodationBookingController extends AbstractController
|
||||
{
|
||||
public function __construct(
|
||||
private readonly AccommodationBookingRepository $bookingRepository,
|
||||
private readonly AccommodationBookingBreakdownCalculator $breakdownCalculator,
|
||||
private readonly SerializerInterface $serializer,
|
||||
private readonly AccommodationBookingService $bookingService,
|
||||
) {
|
||||
}
|
||||
|
||||
#[Route(path: '/accommodation-bookings/{uuid}', name: 'api_accommodation_bookings_single', methods: ['GET'])]
|
||||
public function single(string $uuid): JsonResponse
|
||||
{
|
||||
$booking = $this->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);
|
||||
}
|
||||
}
|
||||
@@ -114,7 +114,7 @@ class ContingentController extends AbstractController
|
||||
|
||||
/**
|
||||
* @param AccommodationPrice[] $prices
|
||||
* @return array{date: string, status: string, type: string|null, pricePerNight: float|null, defaultPricePerNight: float|null, priceAdditionalPerson: float|null, defaultPriceAdditionalPerson: float|null, includedPax: int|null, minPax: int|null, minNights: int|null}
|
||||
* @return array{date: string, status: string, type: string|null, pricePerNight: float|null, defaultPricePerNight: float|null, priceAdditionalPerson: float|null, defaultPriceAdditionalPerson: float|null, includedPax: int|null, minNights: int|null}
|
||||
*/
|
||||
private function enrichEntry(string $date, string $status, array $prices, string $currency): array
|
||||
{
|
||||
@@ -144,7 +144,6 @@ class ContingentController extends AbstractController
|
||||
'defaultPriceAdditionalPerson' => $defaultPrice !== null ? round($defaultPrice->getPriceAdditionalPerson() / 100, 2) : null,
|
||||
'currency' => $currency,
|
||||
'includedPax' => $winner?->getIncludedPax(),
|
||||
'minPax' => $winner?->getAccommodation()->getMinPaxSummer(),
|
||||
'minNights' => $winner?->getMinNights(),
|
||||
];
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Controller\Groups;
|
||||
|
||||
use App\Exception\AccommodationSessionNotFoundException;
|
||||
use App\Form\Model\AccommodationBookingDto;
|
||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||
use Symfony\Component\HttpFoundation\RedirectResponse;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
abstract class AbstractAccommodationController extends AbstractController
|
||||
{
|
||||
protected function validateStepAccess(AccommodationBookingDto $dto, int $expectedStep): ?RedirectResponse
|
||||
{
|
||||
if ($expectedStep > $dto->currentStep) {
|
||||
$this->addFlash('error', 'Bitte erst die vorherigen Schritte abschließen.');
|
||||
|
||||
return $this->redirectToCurrentStep($dto);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
protected function createFailureResponse(\Throwable $exception, bool $htmx): Response
|
||||
{
|
||||
if ($htmx) {
|
||||
return new Response('', 400);
|
||||
}
|
||||
|
||||
$message = $exception instanceof AccommodationSessionNotFoundException
|
||||
? 'Deine Sitzung ist abgelaufen. Bitte starte die Anfrage erneut.'
|
||||
: 'Die Anfrage konnte nicht geladen werden.';
|
||||
|
||||
$this->addFlash('error', $message);
|
||||
|
||||
return $this->redirectToRoute('app_groups_booking_error');
|
||||
}
|
||||
|
||||
private function redirectToCurrentStep(AccommodationBookingDto $dto): RedirectResponse
|
||||
{
|
||||
$route = match ($dto->currentStep) {
|
||||
2 => 'app_groups_booking_step_2',
|
||||
3 => 'app_groups_booking_step_3',
|
||||
default => 'app_groups_booking_step_1',
|
||||
};
|
||||
|
||||
return $this->redirectToRoute($route);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Controller\Groups;
|
||||
|
||||
use App\Htmx\HxTrait;
|
||||
use App\Model\AccommodationBookingQueryParams;
|
||||
use App\Service\AccommodationBookingService;
|
||||
use App\Service\AccommodationSessionManager;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\HttpFoundation\Session\FlashBagAwareSessionInterface;
|
||||
use Symfony\Component\HttpKernel\Attribute\MapQueryString;
|
||||
use Symfony\Component\Routing\Attribute\Route;
|
||||
|
||||
class IndexController extends AbstractAccommodationController
|
||||
{
|
||||
use HxTrait;
|
||||
|
||||
public function __construct(
|
||||
private readonly AccommodationBookingService $bookingService,
|
||||
private readonly AccommodationSessionManager $sessionManager,
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates query params, initializes the session DTO, and redirects to step 1.
|
||||
*
|
||||
* No intermediate loading page is needed because all work here is DB-only and fast.
|
||||
*/
|
||||
#[Route('/groups/booking/init', name: 'app_groups_booking_init')]
|
||||
public function init(Request $request, #[MapQueryString] ?AccommodationBookingQueryParams $params): Response
|
||||
{
|
||||
if (null === $params) {
|
||||
$this->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');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Controller\Groups;
|
||||
|
||||
use App\Repository\Groups\AccommodationBookingRepository;
|
||||
use App\Service\AccommodationBookingBreakdownCalculator;
|
||||
use App\Service\AccommodationBookingLinkSigner;
|
||||
use App\Service\AccommodationBookingService;
|
||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||
use Symfony\Component\HttpFoundation\RedirectResponse;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\Routing\Attribute\Route;
|
||||
|
||||
class OfferController extends AbstractController
|
||||
{
|
||||
public function __construct(
|
||||
private readonly AccommodationBookingRepository $bookingRepository,
|
||||
private readonly AccommodationBookingLinkSigner $linkSigner,
|
||||
private readonly AccommodationBookingBreakdownCalculator $breakdownCalculator,
|
||||
private readonly AccommodationBookingService $bookingService,
|
||||
) {
|
||||
}
|
||||
|
||||
#[Route(path: '/groups/booking/offer/{uuid}', name: 'app_groups_booking_offer', methods: ['GET', 'POST'])]
|
||||
public function index(string $uuid, Request $request): Response
|
||||
{
|
||||
$booking = $this->bookingRepository->findOneBy(['uuid' => $uuid]);
|
||||
|
||||
if (null === $booking || false === $this->linkSigner->isValidLinkRequest($request, $booking)) {
|
||||
return $this->render('groups/booking/offer_unavailable.html.twig');
|
||||
}
|
||||
|
||||
if ($request->isMethod(Request::METHOD_POST) && $booking->isInquiry()) {
|
||||
$this->bookingService->acceptBooking($booking);
|
||||
|
||||
return new RedirectResponse($request->getUri());
|
||||
}
|
||||
|
||||
return $this->render('groups/booking/offer.html.twig', [
|
||||
'booking' => $booking,
|
||||
'priceBreakdown' => $this->breakdownCalculator->compute($booking),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,292 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Controller\Groups;
|
||||
|
||||
use App\BpnConnect\ContingentsClient;
|
||||
use App\BpnConnect\Exception\BpnConnectException;
|
||||
use App\BpnConnect\Model\ContingentStatus;
|
||||
use App\Entity\Groups\AccommodationPrice;
|
||||
use App\Exception\AccommodationSessionNotFoundException;
|
||||
use App\Htmx\HxTrait;
|
||||
use App\Model\AccommodationBookingContext;
|
||||
use App\Repository\Groups\AccommodationPriceRepository;
|
||||
use App\Service\AccommodationBookingService;
|
||||
use App\Service\AccommodationSessionManager;
|
||||
use App\Service\CalendarGridBuilder;
|
||||
use App\Service\GroupsPriceCalculator;
|
||||
use App\Service\PriceTimelineBuilder;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\Routing\Attribute\Route;
|
||||
use Symfony\Contracts\Cache\CacheInterface;
|
||||
use Symfony\Contracts\Cache\ItemInterface;
|
||||
|
||||
class Step1Controller extends AbstractAccommodationController
|
||||
{
|
||||
use HxTrait;
|
||||
|
||||
private const int CALENDAR_MONTHS = 18;
|
||||
|
||||
public function __construct(
|
||||
private readonly AccommodationBookingService $bookingService,
|
||||
private readonly AccommodationSessionManager $sessionManager,
|
||||
private readonly ContingentsClient $contingentsClient,
|
||||
private readonly AccommodationPriceRepository $priceRepository,
|
||||
private readonly PriceTimelineBuilder $priceTimelineBuilder,
|
||||
private readonly CacheInterface $cache,
|
||||
private readonly CalendarGridBuilder $calendarGridBuilder,
|
||||
private readonly GroupsPriceCalculator $priceCalculator,
|
||||
) {
|
||||
}
|
||||
|
||||
#[Route('/groups/booking/step-1', name: 'app_groups_booking_step_1', methods: ['GET', 'POST'])]
|
||||
public function index(Request $request): Response
|
||||
{
|
||||
try {
|
||||
$dto = $this->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<string, array{status: string, minNights: int}>
|
||||
*/
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,191 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Controller\Groups;
|
||||
|
||||
use App\Entity\Groups\AdditionalService;
|
||||
use App\Entity\Groups\BoardService;
|
||||
use App\Exception\AccommodationSessionNotFoundException;
|
||||
use App\Form\AccommodationStep2Type;
|
||||
use App\Form\Model\AccommodationBookingDto;
|
||||
use App\Htmx\HxTrait;
|
||||
use App\Model\AccommodationBookingContext;
|
||||
use App\Service\AccommodationBookingService;
|
||||
use App\Service\AccommodationSessionManager;
|
||||
use App\Service\GroupsPriceCalculator;
|
||||
use Symfony\Component\Form\FormInterface;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\Routing\Attribute\Route;
|
||||
|
||||
class Step2Controller extends AbstractAccommodationController
|
||||
{
|
||||
use HxTrait;
|
||||
|
||||
public function __construct(
|
||||
private readonly AccommodationBookingService $bookingService,
|
||||
private readonly AccommodationSessionManager $sessionManager,
|
||||
private readonly GroupsPriceCalculator $priceCalculator,
|
||||
) {
|
||||
}
|
||||
|
||||
#[Route('/groups/booking/step-2', name: 'app_groups_booking_step_2')]
|
||||
public function index(Request $request): Response
|
||||
{
|
||||
try {
|
||||
$dto = $this->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<AccommodationBookingDto>}
|
||||
*/
|
||||
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<string, int>
|
||||
*/
|
||||
private function buildServiceChoices(array $services): array
|
||||
{
|
||||
$choices = [];
|
||||
foreach ($services as $service) {
|
||||
$choices[(string) $service->getId()] = $service->getId();
|
||||
}
|
||||
|
||||
return $choices;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Controller\Groups;
|
||||
|
||||
use App\Entity\Groups\Accommodation;
|
||||
use App\Exception\AccommodationSessionNotFoundException;
|
||||
use App\Form\AccommodationStep3ConfirmationType;
|
||||
use App\Form\AccommodationStep3Type;
|
||||
use App\Form\Model\AccommodationBookingDto;
|
||||
use App\Htmx\HxTrait;
|
||||
use App\Model\AccommodationBookingContext;
|
||||
use App\Service\AccommodationBookingService;
|
||||
use App\Service\AccommodationSessionManager;
|
||||
use Symfony\Component\Form\ClickableInterface;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\Routing\Attribute\Route;
|
||||
|
||||
class Step3Controller extends AbstractAccommodationController
|
||||
{
|
||||
use HxTrait;
|
||||
|
||||
public function __construct(
|
||||
private readonly AccommodationBookingService $bookingService,
|
||||
private readonly AccommodationSessionManager $sessionManager,
|
||||
) {
|
||||
}
|
||||
|
||||
#[Route('/groups/booking/step-3', name: 'app_groups_booking_step_3')]
|
||||
public function index(Request $request): Response
|
||||
{
|
||||
$dto = $this->resolveDto($request, false);
|
||||
if ($dto instanceof Response) {
|
||||
return $dto;
|
||||
}
|
||||
|
||||
$accommodation = $this->loadAccommodationOrFail($dto);
|
||||
$services = $this->bookingService->loadAvailableServices($dto, $accommodation);
|
||||
|
||||
$ctx = new AccommodationBookingContext(
|
||||
accommodation: $accommodation,
|
||||
hotelCmsData: $this->bookingService->loadHotelCmsData($accommodation),
|
||||
boardServices: $services['boardServices'],
|
||||
additionalServices: $services['additionalServices'],
|
||||
priceBreakdown: $dto->priceBreakdown ?: null,
|
||||
);
|
||||
|
||||
$form = $this->createForm(AccommodationStep3Type::class, $dto);
|
||||
$form->handleRequest($request);
|
||||
|
||||
if ($form->isSubmitted() && $form->isValid()) {
|
||||
$submitBookingClicked = false;
|
||||
// booking or inquiry status depends on clicked button
|
||||
if (true === $form->has('submitBooking')) {
|
||||
$submitBookingButton = $form->get('submitBooking');
|
||||
$submitBookingClicked = $submitBookingButton instanceof ClickableInterface && true === $submitBookingButton->isClicked();
|
||||
}
|
||||
|
||||
// Booking is only offered via the confirmation modal; a forced-inquiry DTO
|
||||
// can never actually reach this branch since submitBooking is disabled for it.
|
||||
if (true === $submitBookingClicked && false === $dto->isInquiry) {
|
||||
$dto->forceInquiry = false;
|
||||
$this->sessionManager->save($request, $dto);
|
||||
|
||||
$confirmationForm = $this->createForm(AccommodationStep3ConfirmationType::class, $dto);
|
||||
|
||||
return $this->render('groups/booking/step_3.html.twig', [
|
||||
'dto' => $dto,
|
||||
'ctx' => $ctx,
|
||||
'form' => $form,
|
||||
'confirmationForm' => $confirmationForm,
|
||||
]);
|
||||
}
|
||||
|
||||
$dto->forceInquiry = true;
|
||||
$this->persistBooking($request, $dto, $accommodation, $services);
|
||||
|
||||
return $this->redirectToRoute('app_groups_booking_success');
|
||||
}
|
||||
|
||||
return $this->render('groups/booking/step_3.html.twig', [
|
||||
'dto' => $dto,
|
||||
'ctx' => $ctx,
|
||||
'form' => $form,
|
||||
]);
|
||||
}
|
||||
|
||||
#[Route('/groups/booking/step-3/confirm', name: 'app_groups_booking_step_3_confirm', methods: ['POST'])]
|
||||
public function confirm(Request $request): Response
|
||||
{
|
||||
$dto = $this->resolveDto($request, true);
|
||||
if ($dto instanceof Response) {
|
||||
return $dto;
|
||||
}
|
||||
|
||||
$accommodation = $this->loadAccommodationOrFail($dto);
|
||||
|
||||
$confirmationForm = $this->createForm(AccommodationStep3ConfirmationType::class, $dto);
|
||||
$confirmationForm->handleRequest($request);
|
||||
|
||||
if ($confirmationForm->isSubmitted() && $confirmationForm->isValid() && false === $dto->isInquiry) {
|
||||
$services = $this->bookingService->loadAvailableServices($dto, $accommodation);
|
||||
$this->persistBooking($request, $dto, $accommodation, $services);
|
||||
|
||||
return $this->htmxRedirect($request, $this->generateUrl('app_groups_booking_success'));
|
||||
}
|
||||
|
||||
return $this->render('groups/booking/_step3_confirmation_modal.html.twig', [
|
||||
'confirmationForm' => $confirmationForm,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads the session DTO and checks step access, returning an early
|
||||
* response (session failure or step-access redirect) if either fails.
|
||||
*/
|
||||
private function resolveDto(Request $request, bool $htmx): AccommodationBookingDto|Response
|
||||
{
|
||||
try {
|
||||
$dto = $this->sessionManager->getOrFail($request);
|
||||
} catch (AccommodationSessionNotFoundException $e) {
|
||||
return $this->createFailureResponse($e, $htmx);
|
||||
}
|
||||
|
||||
if ($redirect = $this->validateStepAccess($dto, 3)) {
|
||||
return $redirect;
|
||||
}
|
||||
|
||||
return $dto;
|
||||
}
|
||||
|
||||
private function loadAccommodationOrFail(AccommodationBookingDto $dto): Accommodation
|
||||
{
|
||||
$accommodation = $this->bookingService->loadAccommodation($dto->accommodationId);
|
||||
if (null === $accommodation) {
|
||||
throw $this->createNotFoundException();
|
||||
}
|
||||
|
||||
return $accommodation;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array{boardServices: array, additionalServices: array} $services
|
||||
*/
|
||||
private function persistBooking(Request $request, AccommodationBookingDto $dto, Accommodation $accommodation, array $services): void
|
||||
{
|
||||
$prices = $this->bookingService->loadPrices($dto, $accommodation);
|
||||
|
||||
$booking = $this->bookingService->persist($dto, $accommodation, $prices, $services);
|
||||
$this->bookingService->issueAccessLinkForDirectBooking($booking);
|
||||
$this->bookingService->sendNotificationEmail($booking, $dto->priceBreakdown);
|
||||
$this->bookingService->sendCustomerConfirmationEmail($booking);
|
||||
$this->addFlash('groups_booking_result', $booking->isInquiry() ? 'inquiry' : 'booking');
|
||||
$this->sessionManager->clear($request);
|
||||
}
|
||||
}
|
||||
@@ -1,18 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Controller\Traits;
|
||||
|
||||
use App\Entity\Groups\SortableEntityInterface;
|
||||
|
||||
trait SortingControllerTrait
|
||||
{
|
||||
private function applySorting(iterable $collection, array $sortingMap): void
|
||||
{
|
||||
foreach ($collection as $entity) {
|
||||
/** @var SortableEntityInterface $entity */
|
||||
if (isset($sortingMap[$entity->getId()])) {
|
||||
$entity->setSorting((int) $sortingMap[$entity->getId()]);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -34,10 +34,6 @@ class Accommodation implements BlameableEntityInterface, TimestampableEntityInte
|
||||
#[ORM\Column(length: 16, nullable: true)]
|
||||
private ?string $cmsCode = null;
|
||||
|
||||
#[ORM\Column]
|
||||
#[Assert\NotNull(message: 'required')]
|
||||
private ?int $minPaxSummer = null;
|
||||
|
||||
/**
|
||||
* @var Collection<int, AccommodationPrice>
|
||||
*/
|
||||
@@ -49,14 +45,14 @@ class Accommodation implements BlameableEntityInterface, TimestampableEntityInte
|
||||
* @var Collection<int, BoardService>
|
||||
*/
|
||||
#[ORM\OneToMany(targetEntity: BoardService::class, mappedBy: 'accommodation', cascade: ['all'])]
|
||||
#[ORM\OrderBy(['sorting' => 'ASC'])]
|
||||
#[ORM\OrderBy(['dateFrom' => 'ASC', 'label' => 'ASC'])]
|
||||
private Collection $boardServices;
|
||||
|
||||
/**
|
||||
* @var Collection<int, AdditionalService>
|
||||
*/
|
||||
#[ORM\OneToMany(targetEntity: AdditionalService::class, mappedBy: 'accommodation', cascade: ['all'])]
|
||||
#[ORM\OrderBy(['sorting' => 'ASC'])]
|
||||
#[ORM\OrderBy(['dateFrom' => 'ASC', 'label' => 'ASC'])]
|
||||
private Collection $additionalServices;
|
||||
|
||||
#[ORM\Column]
|
||||
@@ -121,18 +117,6 @@ class Accommodation implements BlameableEntityInterface, TimestampableEntityInte
|
||||
return $this->cmsCode ?? $this->calendarCode;
|
||||
}
|
||||
|
||||
public function getMinPaxSummer(): ?int
|
||||
{
|
||||
return $this->minPaxSummer;
|
||||
}
|
||||
|
||||
public function setMinPaxSummer(int $minPaxSummer): self
|
||||
{
|
||||
$this->minPaxSummer = $minPaxSummer;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return Collection<int, AccommodationPrice>
|
||||
*/
|
||||
|
||||
@@ -0,0 +1,429 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Entity\Groups;
|
||||
|
||||
use App\Entity\BlameableEntity;
|
||||
use App\Entity\BlameableEntityInterface;
|
||||
use App\Entity\TimestampableEntity;
|
||||
use App\Entity\TimestampableEntityInterface;
|
||||
use App\Enum\Groups\AdditionalServiceType;
|
||||
use App\Repository\Groups\AccommodationBookingRepository;
|
||||
use Symfony\Component\Uid\Uuid;
|
||||
use Doctrine\DBAL\Types\Types;
|
||||
use Doctrine\ORM\Mapping as ORM;
|
||||
use Symfony\Component\Validator\Constraints as Assert;
|
||||
|
||||
#[ORM\Entity(repositoryClass: AccommodationBookingRepository::class)]
|
||||
class AccommodationBooking implements BlameableEntityInterface, TimestampableEntityInterface
|
||||
{
|
||||
use BlameableEntity;
|
||||
use TimestampableEntity;
|
||||
|
||||
#[ORM\Id]
|
||||
#[ORM\GeneratedValue]
|
||||
#[ORM\Column]
|
||||
private ?int $id = null;
|
||||
|
||||
#[ORM\Column(length: 36, unique: true)]
|
||||
private string $uuid;
|
||||
|
||||
#[ORM\ManyToOne]
|
||||
#[ORM\JoinColumn(nullable: false)]
|
||||
private ?Accommodation $accommodation = null;
|
||||
|
||||
#[ORM\Column(type: Types::DATE_IMMUTABLE)]
|
||||
private ?\DateTimeImmutable $dateFrom = null;
|
||||
|
||||
#[ORM\Column(type: Types::DATE_IMMUTABLE)]
|
||||
private ?\DateTimeImmutable $dateTo = null;
|
||||
|
||||
#[ORM\Column]
|
||||
private int $paxCount = 0;
|
||||
|
||||
#[ORM\Column]
|
||||
private int $minorsCount = 0;
|
||||
|
||||
#[ORM\Column]
|
||||
private int $childrenCount = 0;
|
||||
|
||||
#[ORM\Column(length: 255, nullable: true)]
|
||||
private ?string $boardServiceLabel = null;
|
||||
|
||||
#[ORM\Column(nullable: true)]
|
||||
private ?int $boardServicePrice = null;
|
||||
|
||||
#[ORM\Column(nullable: true)]
|
||||
private ?int $boardServiceOriginalId = null;
|
||||
|
||||
#[ORM\Column(type: Types::JSON)]
|
||||
private array $additionalServices = [];
|
||||
|
||||
#[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\Positive(message: 'invalid')]
|
||||
private ?int $discount = 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;
|
||||
}
|
||||
|
||||
public function getAdditionalServices(): array
|
||||
{
|
||||
return $this->additionalServices;
|
||||
}
|
||||
|
||||
public function setAdditionalServices(array $additionalServices): self
|
||||
{
|
||||
$this->additionalServices = $additionalServices;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function addAdditionalServiceSnapshot(string $label, int $price, AdditionalServiceType|string $type, ?int $originalServiceId): self
|
||||
{
|
||||
$this->additionalServices[] = [
|
||||
'label' => $label,
|
||||
'price' => $price,
|
||||
'type' => $type instanceof AdditionalServiceType ? $type->value : $type,
|
||||
'originalServiceId' => $originalServiceId,
|
||||
];
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function isInquiry(): bool
|
||||
{
|
||||
return $this->isInquiry;
|
||||
}
|
||||
|
||||
public function setIsInquiry(bool $isInquiry): self
|
||||
{
|
||||
$this->isInquiry = $isInquiry;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getAccessLinkIssuedAt(): ?\DateTimeImmutable
|
||||
{
|
||||
return $this->accessLinkIssuedAt;
|
||||
}
|
||||
|
||||
public function setAccessLinkIssuedAt(?\DateTimeImmutable $accessLinkIssuedAt): self
|
||||
{
|
||||
$this->accessLinkIssuedAt = $accessLinkIssuedAt;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getAcceptedAt(): ?\DateTimeImmutable
|
||||
{
|
||||
return $this->acceptedAt;
|
||||
}
|
||||
|
||||
public function setAcceptedAt(?\DateTimeImmutable $acceptedAt): self
|
||||
{
|
||||
$this->acceptedAt = $acceptedAt;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getGroupName(): ?string
|
||||
{
|
||||
return $this->groupName;
|
||||
}
|
||||
|
||||
public function setGroupName(string $groupName): self
|
||||
{
|
||||
$this->groupName = $groupName;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getSalutation(): ?string
|
||||
{
|
||||
return $this->salutation;
|
||||
}
|
||||
|
||||
public function setSalutation(?string $salutation): self
|
||||
{
|
||||
$this->salutation = $salutation;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getFirstName(): ?string
|
||||
{
|
||||
return $this->firstName;
|
||||
}
|
||||
|
||||
public function setFirstName(string $firstName): self
|
||||
{
|
||||
$this->firstName = $firstName;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getLastName(): ?string
|
||||
{
|
||||
return $this->lastName;
|
||||
}
|
||||
|
||||
public function setLastName(string $lastName): self
|
||||
{
|
||||
$this->lastName = $lastName;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getEmail(): ?string
|
||||
{
|
||||
return $this->email;
|
||||
}
|
||||
|
||||
public function setEmail(string $email): self
|
||||
{
|
||||
$this->email = $email;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getPhone(): ?string
|
||||
{
|
||||
return $this->phone;
|
||||
}
|
||||
|
||||
public function setPhone(?string $phone): self
|
||||
{
|
||||
$this->phone = $phone;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getStreet(): ?string
|
||||
{
|
||||
return $this->street;
|
||||
}
|
||||
|
||||
public function setStreet(?string $street): self
|
||||
{
|
||||
$this->street = $street;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getZip(): ?string
|
||||
{
|
||||
return $this->zip;
|
||||
}
|
||||
|
||||
public function setZip(?string $zip): self
|
||||
{
|
||||
$this->zip = $zip;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getCity(): ?string
|
||||
{
|
||||
return $this->city;
|
||||
}
|
||||
|
||||
public function setCity(?string $city): self
|
||||
{
|
||||
$this->city = $city;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getRemarks(): ?string
|
||||
{
|
||||
return $this->remarks;
|
||||
}
|
||||
|
||||
public function setRemarks(?string $remarks): self
|
||||
{
|
||||
$this->remarks = $remarks;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getDiscount(): ?int
|
||||
{
|
||||
return $this->discount;
|
||||
}
|
||||
|
||||
public function setDiscount(?int $discount): self
|
||||
{
|
||||
$this->discount = $discount;
|
||||
|
||||
return $this;
|
||||
}
|
||||
}
|
||||
@@ -32,7 +32,8 @@ class AccommodationPrice implements BlameableEntityInterface, TimestampableEntit
|
||||
#[Assert\NotNull(message: 'required')]
|
||||
private ?\DateTimeImmutable $dateFrom = null;
|
||||
|
||||
// Inclusive: the last night this price applies. A booking 01.01.–10.01. covers 9 nights (01.01.–09.01.); 10.01. is checkout.
|
||||
// Inclusive: the last night this price applies.
|
||||
// A booking 01.01.–10.01. covers 9 nights (01.01.–09.01.), 10.01. is checkout.
|
||||
#[ORM\Column(type: Types::DATE_IMMUTABLE)]
|
||||
#[Assert\NotNull(message: 'required')]
|
||||
#[Assert\GreaterThan(propertyPath: 'dateFrom', message: 'Das Bis-Datum muss nach dem Von-Datum liegen')]
|
||||
@@ -63,6 +64,9 @@ class AccommodationPrice implements BlameableEntityInterface, TimestampableEntit
|
||||
#[ORM\Column]
|
||||
private bool $acceptUndersubscription = false;
|
||||
|
||||
#[ORM\Column]
|
||||
private bool $acceptShortTerm = false;
|
||||
|
||||
public function __clone()
|
||||
{
|
||||
$this->id = null;
|
||||
@@ -192,4 +196,16 @@ class AccommodationPrice implements BlameableEntityInterface, TimestampableEntit
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function isAcceptShortTerm(): bool
|
||||
{
|
||||
return $this->acceptShortTerm;
|
||||
}
|
||||
|
||||
public function setAcceptShortTerm(bool $acceptShortTerm): self
|
||||
{
|
||||
$this->acceptShortTerm = $acceptShortTerm;
|
||||
|
||||
return $this;
|
||||
}
|
||||
}
|
||||
@@ -10,9 +10,10 @@ use App\Enum\Groups\AdditionalServiceType;
|
||||
use App\Repository\Groups\AdditionalServiceRepository;
|
||||
use Doctrine\DBAL\Types\Types;
|
||||
use Doctrine\ORM\Mapping as ORM;
|
||||
use Symfony\Component\Validator\Constraints as Assert;
|
||||
|
||||
#[ORM\Entity(repositoryClass: AdditionalServiceRepository::class)]
|
||||
class AdditionalService implements BlameableEntityInterface, TimestampableEntityInterface, SortableEntityInterface
|
||||
class AdditionalService implements BlameableEntityInterface, TimestampableEntityInterface
|
||||
{
|
||||
use BlameableEntity;
|
||||
use TimestampableEntity;
|
||||
@@ -27,26 +28,31 @@ class AdditionalService implements BlameableEntityInterface, TimestampableEntity
|
||||
private ?Accommodation $accommodation = null;
|
||||
|
||||
#[ORM\Column(length: 255)]
|
||||
#[Assert\NotBlank(message: 'required')]
|
||||
private ?string $label = null;
|
||||
|
||||
#[ORM\Column(type: 'text', nullable: true)]
|
||||
private ?string $description = null;
|
||||
|
||||
#[ORM\Column]
|
||||
#[Assert\NotNull(message: 'required')]
|
||||
private ?int $price = null;
|
||||
|
||||
#[ORM\Column(length: 20, enumType: AdditionalServiceType::class)]
|
||||
#[Assert\NotNull(message: 'required')]
|
||||
private ?AdditionalServiceType $type = null;
|
||||
|
||||
#[ORM\Column(type: Types::DATE_IMMUTABLE)]
|
||||
#[Assert\NotNull(message: 'required')]
|
||||
private ?\DateTimeImmutable $dateFrom = null;
|
||||
|
||||
#[ORM\Column(type: Types::DATE_IMMUTABLE)]
|
||||
#[Assert\NotNull(message: 'required')]
|
||||
private ?\DateTimeImmutable $dateTo = null;
|
||||
|
||||
#[ORM\Column(length: 128, nullable: true)]
|
||||
private ?string $selectionGroup = null;
|
||||
|
||||
#[ORM\Column]
|
||||
private ?int $sorting = 0;
|
||||
|
||||
public function __clone()
|
||||
{
|
||||
$this->id = null;
|
||||
@@ -81,6 +87,18 @@ class AdditionalService implements BlameableEntityInterface, TimestampableEntity
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getDescription(): ?string
|
||||
{
|
||||
return $this->description;
|
||||
}
|
||||
|
||||
public function setDescription(?string $description): self
|
||||
{
|
||||
$this->description = $description;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getPrice(): ?int
|
||||
{
|
||||
return $this->price;
|
||||
@@ -140,16 +158,4 @@ class AdditionalService implements BlameableEntityInterface, TimestampableEntity
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getSorting(): ?int
|
||||
{
|
||||
return $this->sorting;
|
||||
}
|
||||
|
||||
public function setSorting(?int $sorting): self
|
||||
{
|
||||
$this->sorting = $sorting;
|
||||
|
||||
return $this;
|
||||
}
|
||||
}
|
||||
@@ -9,9 +9,10 @@ use App\Entity\TimestampableEntityInterface;
|
||||
use App\Repository\Groups\BoardServiceRepository;
|
||||
use Doctrine\DBAL\Types\Types;
|
||||
use Doctrine\ORM\Mapping as ORM;
|
||||
use Symfony\Component\Validator\Constraints as Assert;
|
||||
|
||||
#[ORM\Entity(repositoryClass: BoardServiceRepository::class)]
|
||||
class BoardService implements BlameableEntityInterface, TimestampableEntityInterface, SortableEntityInterface
|
||||
class BoardService implements BlameableEntityInterface, TimestampableEntityInterface
|
||||
{
|
||||
use BlameableEntity;
|
||||
use TimestampableEntity;
|
||||
@@ -26,20 +27,24 @@ class BoardService implements BlameableEntityInterface, TimestampableEntityInter
|
||||
private ?Accommodation $accommodation = null;
|
||||
|
||||
#[ORM\Column(length: 255)]
|
||||
#[Assert\NotBlank(message: 'required')]
|
||||
private ?string $label = null;
|
||||
|
||||
#[ORM\Column(type: 'text', nullable: true)]
|
||||
private ?string $description = null;
|
||||
|
||||
#[ORM\Column]
|
||||
#[Assert\NotNull(message: 'required')]
|
||||
private ?int $price = null;
|
||||
|
||||
#[ORM\Column(type: Types::DATE_IMMUTABLE)]
|
||||
#[Assert\NotNull(message: 'required')]
|
||||
private ?\DateTimeImmutable $dateFrom = null;
|
||||
|
||||
#[ORM\Column(type: Types::DATE_IMMUTABLE)]
|
||||
#[Assert\NotNull(message: 'required')]
|
||||
private ?\DateTimeImmutable $dateTo = null;
|
||||
|
||||
#[ORM\Column]
|
||||
private ?int $sorting = 0;
|
||||
|
||||
public function __clone()
|
||||
{
|
||||
$this->id = null;
|
||||
@@ -74,6 +79,18 @@ class BoardService implements BlameableEntityInterface, TimestampableEntityInter
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getDescription(): ?string
|
||||
{
|
||||
return $this->description;
|
||||
}
|
||||
|
||||
public function setDescription(?string $description): self
|
||||
{
|
||||
$this->description = $description;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getPrice(): ?int
|
||||
{
|
||||
return $this->price;
|
||||
@@ -109,16 +126,4 @@ class BoardService implements BlameableEntityInterface, TimestampableEntityInter
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getSorting(): ?int
|
||||
{
|
||||
return $this->sorting;
|
||||
}
|
||||
|
||||
public function setSorting(?int $sorting): self
|
||||
{
|
||||
$this->sorting = $sorting;
|
||||
|
||||
return $this;
|
||||
}
|
||||
}
|
||||
@@ -1,10 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Entity\Groups;
|
||||
|
||||
interface SortableEntityInterface
|
||||
{
|
||||
public function getId(): ?int;
|
||||
|
||||
public function setSorting(?int $sorting): self;
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Exception;
|
||||
|
||||
use Symfony\Component\HttpKernel\Exception\HttpException;
|
||||
|
||||
class AccommodationSessionNotFoundException extends HttpException
|
||||
{
|
||||
public function __construct(?\Throwable $previous = null)
|
||||
{
|
||||
parent::__construct(404, 'No valid accommodation inquiry session found.', $previous);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Form;
|
||||
|
||||
use App\Form\Model\AccommodationBookingDto;
|
||||
use Symfony\Component\Form\AbstractType;
|
||||
use Symfony\Component\Form\Extension\Core\Type\ChoiceType;
|
||||
use Symfony\Component\Form\Extension\Core\Type\IntegerType;
|
||||
use Symfony\Component\Form\FormEvent;
|
||||
use Symfony\Component\Form\FormEvents;
|
||||
use Symfony\Component\Form\FormBuilderInterface;
|
||||
use Symfony\Component\OptionsResolver\OptionsResolver;
|
||||
|
||||
/**
|
||||
* @extends AbstractType<AccommodationBookingDto>
|
||||
*/
|
||||
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');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Form;
|
||||
|
||||
use App\Form\Model\AccommodationBookingDto;
|
||||
use Symfony\Component\Form\AbstractType;
|
||||
use Symfony\Component\Form\Extension\Core\Type\CheckboxType;
|
||||
use Symfony\Component\Form\FormBuilderInterface;
|
||||
use Symfony\Component\OptionsResolver\OptionsResolver;
|
||||
use Symfony\Component\Validator\Constraints\IsTrue;
|
||||
|
||||
/**
|
||||
* @extends AbstractType<AccommodationBookingDto>
|
||||
*/
|
||||
class AccommodationStep3ConfirmationType extends AbstractType
|
||||
{
|
||||
public function buildForm(FormBuilderInterface $builder, array $options): void
|
||||
{
|
||||
$termsLink = sprintf(
|
||||
'<a href="%s" target="_blank" rel="noopener" class="underline">allgemeinen Geschäftsbedingungen (AGB)</a>',
|
||||
htmlspecialchars($options['terms_url'], ENT_QUOTES, 'UTF-8')
|
||||
);
|
||||
|
||||
$builder->add('termsAccepted', CheckboxType::class, [
|
||||
'label' => sprintf('Durch Anklicken des Absenden-Buttons bestätige ich, dass ich die %s gelesen habe und damit einverstanden bin, dass eine kostenpflichtige Buchung zustande kommt.', $termsLink),
|
||||
'label_html' => true,
|
||||
'mapped' => false,
|
||||
'required' => true,
|
||||
'constraints' => [
|
||||
new IsTrue(message: 'Bitte akzeptiere die AGB, um fortzufahren.'),
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
public function configureOptions(OptionsResolver $resolver): void
|
||||
{
|
||||
$resolver->setDefaults([
|
||||
'data_class' => AccommodationBookingDto::class,
|
||||
'terms_url' => 'https://www.ep-reisen.de/skireisen/infos/allgemeine-infos/agb/',
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Form;
|
||||
|
||||
use App\Form\Model\AccommodationBookingDto;
|
||||
use Symfony\Component\Form\AbstractType;
|
||||
use Symfony\Component\Form\Extension\Core\Type\ChoiceType;
|
||||
use Symfony\Component\Form\Extension\Core\Type\EmailType;
|
||||
use Symfony\Component\Form\Extension\Core\Type\TextareaType;
|
||||
use Symfony\Component\Form\Extension\Core\Type\TextType;
|
||||
use Symfony\Component\Form\FormBuilderInterface;
|
||||
use Symfony\Component\OptionsResolver\OptionsResolver;
|
||||
|
||||
/**
|
||||
* @extends AbstractType<AccommodationBookingDto>
|
||||
*/
|
||||
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'],
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Form\Admin\Groups;
|
||||
|
||||
use App\Entity\Groups\Accommodation;
|
||||
use App\Entity\Groups\AccommodationBooking;
|
||||
use App\Entity\Groups\AdditionalService;
|
||||
use App\Entity\Groups\BoardService;
|
||||
use Symfony\Bridge\Doctrine\Form\Type\EntityType;
|
||||
use Symfony\Component\Form\AbstractType;
|
||||
use Symfony\Component\Form\Extension\Core\Type\CheckboxType;
|
||||
use Symfony\Component\Form\Extension\Core\Type\ChoiceType;
|
||||
use Symfony\Component\Form\Extension\Core\Type\DateType;
|
||||
use Symfony\Component\Form\Extension\Core\Type\EmailType;
|
||||
use Symfony\Component\Form\Extension\Core\Type\IntegerType;
|
||||
use Symfony\Component\Form\Extension\Core\Type\TextareaType;
|
||||
use Symfony\Component\Form\Extension\Core\Type\TextType;
|
||||
use Symfony\Component\Form\FormBuilderInterface;
|
||||
use Symfony\Component\OptionsResolver\OptionsResolver;
|
||||
use Symfony\Component\Validator\Constraints\Range;
|
||||
|
||||
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('discount', IntegerType::class, [
|
||||
'label' => 'Rabatt (%)',
|
||||
'required' => false,
|
||||
'constraints' => [
|
||||
new Range(min: 1, max: 100),
|
||||
],
|
||||
])
|
||||
->add('remarks', TextareaType::class, [
|
||||
'label' => 'Bemerkungen',
|
||||
'required' => false,
|
||||
])
|
||||
;
|
||||
}
|
||||
|
||||
public function configureOptions(OptionsResolver $resolver): void
|
||||
{
|
||||
$resolver->setDefaults([
|
||||
'data_class' => AccommodationBooking::class,
|
||||
'with_accommodation' => false,
|
||||
'max_adolescent_age' => 0,
|
||||
'board_services' => [],
|
||||
'additional_services' => [],
|
||||
'current_board_service' => null,
|
||||
'current_additional_services' => [],
|
||||
]);
|
||||
$resolver->setAllowedTypes('with_accommodation', 'bool');
|
||||
$resolver->setAllowedTypes('max_adolescent_age', 'int');
|
||||
$resolver->setAllowedTypes('board_services', 'array');
|
||||
$resolver->setAllowedTypes('additional_services', 'array');
|
||||
$resolver->setAllowedTypes('current_board_service', ['null', BoardService::class]);
|
||||
$resolver->setAllowedTypes('current_additional_services', 'array');
|
||||
}
|
||||
}
|
||||
@@ -7,9 +7,9 @@ namespace App\Form\Admin\Groups;
|
||||
use App\Entity\Groups\AccommodationPrice;
|
||||
use App\Enum\Groups\PriceType;
|
||||
use App\Enum\Groups\Season;
|
||||
use App\Form\DatepickerType;
|
||||
use Symfony\Component\Form\AbstractType;
|
||||
use Symfony\Component\Form\Extension\Core\Type\CheckboxType;
|
||||
use Symfony\Component\Form\Extension\Core\Type\DateType;
|
||||
use Symfony\Component\Form\Extension\Core\Type\EnumType;
|
||||
use Symfony\Component\Form\Extension\Core\Type\IntegerType;
|
||||
use Symfony\Component\Form\Extension\Core\Type\MoneyType;
|
||||
@@ -21,12 +21,12 @@ class AccommodationPriceType extends AbstractType
|
||||
public function buildForm(FormBuilderInterface $builder, array $options): void
|
||||
{
|
||||
$builder
|
||||
->add('dateFrom', DatepickerType::class, [
|
||||
->add('dateFrom', DateType::class, [
|
||||
'label' => 'Datum von',
|
||||
'html5' => true,
|
||||
'widget' => 'single_text',
|
||||
])
|
||||
->add('dateTo', DatepickerType::class, [
|
||||
->add('dateTo', DateType::class, [
|
||||
'label' => 'Datum bis',
|
||||
'html5' => true,
|
||||
'widget' => 'single_text',
|
||||
@@ -63,6 +63,10 @@ class AccommodationPriceType extends AbstractType
|
||||
'label' => 'Unterbelegung akzeptieren',
|
||||
'required' => false,
|
||||
])
|
||||
->add('acceptShortTerm', CheckboxType::class, [
|
||||
'label' => 'Kurzzeit-Belegung akzeptieren',
|
||||
'required' => false,
|
||||
])
|
||||
;
|
||||
}
|
||||
|
||||
|
||||
@@ -27,11 +27,8 @@ class AccommodationType extends AbstractType
|
||||
'label' => 'Code für CMS-Daten',
|
||||
'required' => false,
|
||||
])
|
||||
->add('minPaxSummer', IntegerType::class, [
|
||||
'label' => 'Mindestbelegung Sommer',
|
||||
])
|
||||
->add('maxAdolescentAge', IntegerType::class, [
|
||||
'label' => 'Altersgrenze Jugendliche',
|
||||
'label' => 'Altersgrenze Kinder',
|
||||
])
|
||||
->add('currency', ChoiceType::class, [
|
||||
'label' => 'Währung',
|
||||
|
||||
@@ -6,10 +6,11 @@ namespace App\Form\Admin\Groups;
|
||||
|
||||
use App\Entity\Groups\AdditionalService;
|
||||
use App\Enum\Groups\AdditionalServiceType as AdditionalServiceTypeEnum;
|
||||
use App\Form\DatepickerType;
|
||||
use Symfony\Component\Form\AbstractType;
|
||||
use Symfony\Component\Form\Extension\Core\Type\DateType;
|
||||
use Symfony\Component\Form\Extension\Core\Type\EnumType;
|
||||
use Symfony\Component\Form\Extension\Core\Type\MoneyType;
|
||||
use Symfony\Component\Form\Extension\Core\Type\TextareaType;
|
||||
use Symfony\Component\Form\Extension\Core\Type\TextType;
|
||||
use Symfony\Component\Form\FormBuilderInterface;
|
||||
use Symfony\Component\OptionsResolver\OptionsResolver;
|
||||
@@ -22,6 +23,13 @@ class AdditionalServiceType extends AbstractType
|
||||
->add('label', TextType::class, [
|
||||
'label' => 'Bezeichnung',
|
||||
])
|
||||
->add('description', TextareaType::class, [
|
||||
'label' => 'Beschreibung',
|
||||
'required' => false,
|
||||
'attr' => [
|
||||
'rows' => 2,
|
||||
],
|
||||
])
|
||||
->add('type', EnumType::class, [
|
||||
'label' => 'Preistyp',
|
||||
'class' => AdditionalServiceTypeEnum::class,
|
||||
@@ -35,13 +43,14 @@ class AdditionalServiceType extends AbstractType
|
||||
->add('selectionGroup', TextType::class, [
|
||||
'label' => 'Auswahlgruppe',
|
||||
'required' => false,
|
||||
'disabled' => true,
|
||||
])
|
||||
->add('dateFrom', DatepickerType::class, [
|
||||
->add('dateFrom', DateType::class, [
|
||||
'label' => 'Datum von',
|
||||
'html5' => true,
|
||||
'widget' => 'single_text',
|
||||
])
|
||||
->add('dateTo', DatepickerType::class, [
|
||||
->add('dateTo', DateType::class, [
|
||||
'label' => 'Datum bis',
|
||||
'html5' => true,
|
||||
'widget' => 'single_text',
|
||||
|
||||
@@ -5,9 +5,10 @@ declare(strict_types=1);
|
||||
namespace App\Form\Admin\Groups;
|
||||
|
||||
use App\Entity\Groups\BoardService;
|
||||
use App\Form\DatepickerType;
|
||||
use Symfony\Component\Form\AbstractType;
|
||||
use Symfony\Component\Form\Extension\Core\Type\DateType;
|
||||
use Symfony\Component\Form\Extension\Core\Type\MoneyType;
|
||||
use Symfony\Component\Form\Extension\Core\Type\TextareaType;
|
||||
use Symfony\Component\Form\Extension\Core\Type\TextType;
|
||||
use Symfony\Component\Form\FormBuilderInterface;
|
||||
use Symfony\Component\OptionsResolver\OptionsResolver;
|
||||
@@ -20,17 +21,24 @@ class BoardServiceType extends AbstractType
|
||||
->add('label', TextType::class, [
|
||||
'label' => 'Bezeichnung',
|
||||
])
|
||||
->add('description', TextareaType::class, [
|
||||
'label' => 'Beschreibung',
|
||||
'required' => false,
|
||||
'attr' => [
|
||||
'rows' => 2,
|
||||
],
|
||||
])
|
||||
->add('price', MoneyType::class, [
|
||||
'label' => 'Preis pro Person/Nacht',
|
||||
'currency' => $options['currency'],
|
||||
'divisor' => 100,
|
||||
])
|
||||
->add('dateFrom', DatepickerType::class, [
|
||||
->add('dateFrom', DateType::class, [
|
||||
'label' => 'Datum von',
|
||||
'html5' => true,
|
||||
'widget' => 'single_text',
|
||||
])
|
||||
->add('dateTo', DatepickerType::class, [
|
||||
->add('dateTo', DateType::class, [
|
||||
'label' => 'Datum bis',
|
||||
'html5' => true,
|
||||
'widget' => 'single_text',
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Form\Model;
|
||||
|
||||
use Symfony\Component\Validator\Constraints as Assert;
|
||||
|
||||
class AccommodationBookingDto
|
||||
{
|
||||
public int $currentStep = 1;
|
||||
|
||||
public ?int $accommodationId = null;
|
||||
|
||||
public ?\DateTimeImmutable $dateFrom = null;
|
||||
|
||||
public ?\DateTimeImmutable $dateTo = null;
|
||||
|
||||
#[Assert\Positive(message: 'invalid', groups: ['step_3'])]
|
||||
public int $paxCount = 1;
|
||||
|
||||
#[Assert\PositiveOrZero(message: 'invalid', groups: ['step_3'])]
|
||||
public int $minorsCount = 0;
|
||||
|
||||
#[Assert\PositiveOrZero(message: 'invalid', groups: ['step_3'])]
|
||||
public int $childrenCount = 0;
|
||||
|
||||
public ?int $selectedBoardServiceId = null;
|
||||
|
||||
/** @var list<int>*/
|
||||
public array $selectedAdditionalServiceIds = [];
|
||||
|
||||
public bool $isInquiry = false;
|
||||
|
||||
/** @var string[] */
|
||||
public array $inquiryReasons = [];
|
||||
|
||||
public bool $forceInquiry = false;
|
||||
|
||||
public ?int $totalPrice = null;
|
||||
|
||||
/** @var array<string, mixed> */
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -32,13 +32,14 @@ class AdminMenuBuilder extends AbstractMenuBuilder
|
||||
'routes' => [['pattern' => '/^app_admin_bookingeditdraft/']],
|
||||
],
|
||||
]);
|
||||
$menu->addChild('Benutzer', [
|
||||
'route' => 'app_admin_user',
|
||||
$menu->addChild('Guppenbuchungen', [
|
||||
'route' => 'app_admin_accommodationbooking',
|
||||
'linkAttributes' => [
|
||||
'title' => 'Benutzer',
|
||||
'title' => 'Buchungen',
|
||||
],
|
||||
'extras' => [
|
||||
'icon' => 'users',
|
||||
'icon' => 'list',
|
||||
'routes' => [['pattern' => '/^app_admin_accommodationbooking\//']],
|
||||
],
|
||||
]);
|
||||
$menu->addChild('Gruppenhäuser', [
|
||||
@@ -48,7 +49,16 @@ class AdminMenuBuilder extends AbstractMenuBuilder
|
||||
],
|
||||
'extras' => [
|
||||
'icon' => 'house',
|
||||
'routes' => [['pattern' => '/^app_admin_accommodation/']],
|
||||
'routes' => [['pattern' => '/^app_admin_accommodation\//']],
|
||||
],
|
||||
]);
|
||||
$menu->addChild('Benutzer', [
|
||||
'route' => 'app_admin_user',
|
||||
'linkAttributes' => [
|
||||
'title' => 'Benutzer',
|
||||
],
|
||||
'extras' => [
|
||||
'icon' => 'users',
|
||||
],
|
||||
]);
|
||||
$menu->addChild('Logs', [
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Model;
|
||||
|
||||
use Symfony\Component\Serializer\Attribute\Groups;
|
||||
|
||||
final readonly class AccommodationBookingAdditionalServiceItem
|
||||
{
|
||||
#[Groups(['api:single'])]
|
||||
public string $label;
|
||||
|
||||
#[Groups(['api:single'])]
|
||||
public int $price;
|
||||
|
||||
#[Groups(['api:single'])]
|
||||
public string $type;
|
||||
|
||||
/**
|
||||
* @param array{label?: string, price?: int, type?: string, originalServiceId?: int|null} $snapshot
|
||||
*/
|
||||
public function __construct(array $snapshot)
|
||||
{
|
||||
$this->label = $snapshot['label'] ?? '';
|
||||
$this->price = $snapshot['price'] ?? 0;
|
||||
$this->type = $snapshot['type'] ?? '';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Model;
|
||||
|
||||
use App\Entity\Groups\AccommodationBooking;
|
||||
use Symfony\Component\Serializer\Attribute\Context;
|
||||
use Symfony\Component\Serializer\Attribute\Groups;
|
||||
use Symfony\Component\Serializer\Normalizer\DateTimeNormalizer;
|
||||
|
||||
final readonly class AccommodationBookingApiResponse
|
||||
{
|
||||
#[Groups(['api:single'])]
|
||||
public string $uuid;
|
||||
|
||||
#[Groups(['api:single'])]
|
||||
public string $status;
|
||||
|
||||
#[Groups(['api:single'])]
|
||||
#[Context([DateTimeNormalizer::FORMAT_KEY => '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<AccommodationBookingAdditionalServiceItem>
|
||||
*/
|
||||
#[Groups(['api:single'])]
|
||||
public array $additionalServices;
|
||||
|
||||
#[Groups(['api:single'])]
|
||||
public ?int $discount;
|
||||
|
||||
/**
|
||||
* @var array<string, mixed>|null
|
||||
*/
|
||||
#[Groups(['api:single'])]
|
||||
public ?array $priceBreakdown;
|
||||
|
||||
/**
|
||||
* @param array<string, mixed>|null $priceBreakdown
|
||||
*/
|
||||
public function __construct(AccommodationBooking $booking, ?array $priceBreakdown)
|
||||
{
|
||||
$this->uuid = $booking->getUuid();
|
||||
$this->status = $booking->isInquiry() ? 'inquiry' : 'booking';
|
||||
$this->dateFrom = $booking->getDateFrom();
|
||||
$this->dateTo = $booking->getDateTo();
|
||||
$this->nights = $booking->getNights();
|
||||
$this->paxCount = $booking->getPaxCount();
|
||||
$this->minorsCount = $booking->getMinorsCount();
|
||||
$this->childrenCount = $booking->getChildrenCount();
|
||||
$this->groupName = $booking->getGroupName();
|
||||
$this->acceptedAt = $booking->getAcceptedAt();
|
||||
$this->personalData = new AccommodationBookingPersonalData($booking);
|
||||
$this->accommodation = new AccommodationBookingHotelInfo($booking->getAccommodation());
|
||||
$this->boardService = new AccommodationBookingBoardServiceInfo($booking);
|
||||
$this->additionalServices = array_map(
|
||||
static fn (array $service) => new AccommodationBookingAdditionalServiceItem($service),
|
||||
$booking->getAdditionalServices(),
|
||||
);
|
||||
$this->discount = $booking->getDiscount();
|
||||
$this->priceBreakdown = $priceBreakdown;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Model;
|
||||
|
||||
use App\Entity\Groups\AccommodationBooking;
|
||||
use Symfony\Component\Serializer\Attribute\Groups;
|
||||
|
||||
final readonly class AccommodationBookingBoardServiceInfo
|
||||
{
|
||||
#[Groups(['api:single'])]
|
||||
public ?string $label;
|
||||
|
||||
#[Groups(['api:single'])]
|
||||
public ?int $price;
|
||||
|
||||
public function __construct(AccommodationBooking $booking)
|
||||
{
|
||||
$this->label = $booking->getBoardServiceLabel();
|
||||
$this->price = $booking->getBoardServicePrice();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Model;
|
||||
|
||||
use App\Entity\Groups\Accommodation;
|
||||
use App\Entity\Groups\AdditionalService;
|
||||
use App\Entity\Groups\BoardService;
|
||||
|
||||
final readonly class AccommodationBookingContext
|
||||
{
|
||||
/**
|
||||
* @param BoardService[] $boardServices
|
||||
* @param AdditionalService[] $additionalServices
|
||||
* @param array<string, AdditionalService[]> $groupedAdditionalServices
|
||||
* @param AdditionalService[] $ungroupedAdditionalServices
|
||||
* @param array<string, mixed>|null $priceBreakdown
|
||||
* @param array<string, mixed>|null $hotelCmsData
|
||||
*/
|
||||
public function __construct(
|
||||
public Accommodation $accommodation,
|
||||
public ?array $hotelCmsData = null,
|
||||
public array $boardServices = [],
|
||||
public array $additionalServices = [],
|
||||
public array $groupedAdditionalServices = [],
|
||||
public array $ungroupedAdditionalServices = [],
|
||||
public ?array $priceBreakdown = null,
|
||||
) {
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Model;
|
||||
|
||||
use App\Entity\Groups\Accommodation;
|
||||
use Symfony\Component\Serializer\Attribute\Groups;
|
||||
|
||||
final readonly class AccommodationBookingHotelInfo
|
||||
{
|
||||
#[Groups(['api:single'])]
|
||||
public ?string $calendarCode;
|
||||
|
||||
#[Groups(['api:single'])]
|
||||
public ?string $cmsCode;
|
||||
|
||||
public function __construct(?Accommodation $accommodation)
|
||||
{
|
||||
$this->calendarCode = $accommodation?->getCalendarCode();
|
||||
$this->cmsCode = $accommodation?->getCmsCode();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Model;
|
||||
|
||||
use App\Entity\Groups\AccommodationBooking;
|
||||
use Symfony\Component\Serializer\Attribute\Groups;
|
||||
|
||||
final readonly class AccommodationBookingPersonalData
|
||||
{
|
||||
#[Groups(['api:single'])]
|
||||
public ?string $salutation;
|
||||
|
||||
#[Groups(['api:single'])]
|
||||
public ?string $firstName;
|
||||
|
||||
#[Groups(['api:single'])]
|
||||
public ?string $lastName;
|
||||
|
||||
#[Groups(['api:single'])]
|
||||
public ?string $email;
|
||||
|
||||
#[Groups(['api:single'])]
|
||||
public ?string $phone;
|
||||
|
||||
#[Groups(['api:single'])]
|
||||
public ?string $street;
|
||||
|
||||
#[Groups(['api:single'])]
|
||||
public ?string $zip;
|
||||
|
||||
#[Groups(['api:single'])]
|
||||
public ?string $city;
|
||||
|
||||
#[Groups(['api:single'])]
|
||||
public ?string $remarks;
|
||||
|
||||
public function __construct(AccommodationBooking $booking)
|
||||
{
|
||||
$this->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();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Model;
|
||||
|
||||
use Symfony\Component\Serializer\Attribute\SerializedName;
|
||||
use Symfony\Component\Validator\Constraints as Assert;
|
||||
|
||||
readonly class AccommodationBookingQueryParams
|
||||
{
|
||||
public function __construct(
|
||||
#[SerializedName('hotel_code')]
|
||||
#[Assert\NotBlank]
|
||||
public string $hotelCode,
|
||||
|
||||
#[SerializedName('date_from')]
|
||||
public ?string $dateFrom = null,
|
||||
|
||||
#[SerializedName('date_to')]
|
||||
public ?string $dateTo = null,
|
||||
) {
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Model;
|
||||
|
||||
final readonly class InquiryStatus
|
||||
{
|
||||
/** @param string[] $reasons */
|
||||
public function __construct(
|
||||
public bool $isInquiry,
|
||||
public array $reasons,
|
||||
) {
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Repository\Groups;
|
||||
|
||||
use App\Entity\Groups\AccommodationBooking;
|
||||
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
|
||||
use Doctrine\Persistence\ManagerRegistry;
|
||||
|
||||
/**
|
||||
* @extends ServiceEntityRepository<AccommodationBooking>
|
||||
*/
|
||||
class AccommodationBookingRepository extends ServiceEntityRepository
|
||||
{
|
||||
public function __construct(ManagerRegistry $registry)
|
||||
{
|
||||
parent::__construct($registry, AccommodationBooking::class);
|
||||
}
|
||||
}
|
||||
@@ -40,4 +40,24 @@ class AccommodationRepository extends ServiceEntityRepository
|
||||
// ->getOneOrNullResult()
|
||||
// ;
|
||||
// }
|
||||
|
||||
public function findOneByCmsCode(string $cmsCode): ?Accommodation
|
||||
{
|
||||
return $this->createQueryBuilder('a')
|
||||
->andWhere('a.cmsCode = :cmsCode')
|
||||
->setParameter('cmsCode', $cmsCode)
|
||||
->getQuery()
|
||||
->getOneOrNullResult()
|
||||
;
|
||||
}
|
||||
|
||||
public function findOneByCalendarCode(string $calendarCode): ?Accommodation
|
||||
{
|
||||
return $this->createQueryBuilder('a')
|
||||
->andWhere('a.calendarCode = :calendarCode')
|
||||
->setParameter('calendarCode', $calendarCode)
|
||||
->getQuery()
|
||||
->getOneOrNullResult()
|
||||
;
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,7 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Repository\Groups;
|
||||
|
||||
use App\Entity\Groups\AdditionalService;
|
||||
@@ -11,6 +13,8 @@ use Doctrine\Persistence\ManagerRegistry;
|
||||
*/
|
||||
class AdditionalServiceRepository extends ServiceEntityRepository
|
||||
{
|
||||
use FindsByAccommodationAndDateRangeTrait;
|
||||
|
||||
public function __construct(ManagerRegistry $registry)
|
||||
{
|
||||
parent::__construct($registry, AdditionalService::class);
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Repository\Groups;
|
||||
|
||||
use App\Entity\Groups\BoardService;
|
||||
@@ -11,6 +13,8 @@ use Doctrine\Persistence\ManagerRegistry;
|
||||
*/
|
||||
class BoardServiceRepository extends ServiceEntityRepository
|
||||
{
|
||||
use FindsByAccommodationAndDateRangeTrait;
|
||||
|
||||
public function __construct(ManagerRegistry $registry)
|
||||
{
|
||||
parent::__construct($registry, BoardService::class);
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Repository\Groups;
|
||||
|
||||
use App\Entity\Groups\Accommodation;
|
||||
|
||||
trait FindsByAccommodationAndDateRangeTrait
|
||||
{
|
||||
/**
|
||||
* Returns all records active during the given date range.
|
||||
*
|
||||
* Overlap condition: entity.dateFrom <= dateTo AND entity.dateTo >= dateFrom
|
||||
*/
|
||||
public function findByAccommodationAndDateRange(
|
||||
Accommodation $accommodation,
|
||||
\DateTimeImmutable $dateFrom,
|
||||
\DateTimeImmutable $dateTo,
|
||||
): array {
|
||||
return $this->createQueryBuilder('s')
|
||||
->where('s.accommodation = :accommodation')
|
||||
->andWhere('s.dateFrom <= :dateTo')
|
||||
->andWhere('s.dateTo >= :dateFrom')
|
||||
->setParameter('accommodation', $accommodation)
|
||||
->setParameter('dateFrom', $dateFrom)
|
||||
->setParameter('dateTo', $dateTo)
|
||||
->orderBy('s.dateFrom', 'ASC')
|
||||
->addOrderBy('s.label', 'ASC')
|
||||
->getQuery()
|
||||
->getResult();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Service;
|
||||
|
||||
use App\Entity\Groups\AccommodationBooking;
|
||||
use App\Repository\Groups\AccommodationPriceRepository;
|
||||
|
||||
class AccommodationBookingBreakdownCalculator
|
||||
{
|
||||
public function __construct(
|
||||
private readonly GroupsPriceCalculator $priceCalculator,
|
||||
private readonly AccommodationPriceRepository $priceRepo,
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Recomputes the price breakdown from the booking's frozen snapshot data and current
|
||||
* accommodation prices for the same date range.
|
||||
*
|
||||
* @return array<string, mixed>|null null when no accommodation or dates are set
|
||||
*/
|
||||
public function compute(AccommodationBooking $booking): ?array
|
||||
{
|
||||
$accommodation = $booking->getAccommodation();
|
||||
$dateFrom = $booking->getDateFrom();
|
||||
$dateTo = $booking->getDateTo();
|
||||
|
||||
if (null === $accommodation || null === $dateFrom || null === $dateTo) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$prices = $this->priceRepo->findByHotelCodeAndDateRange(
|
||||
$accommodation->getCalendarCode() ?? '',
|
||||
$dateFrom,
|
||||
$dateTo,
|
||||
);
|
||||
|
||||
return $this->priceCalculator->calculateFromSnapshots(
|
||||
$booking->getPaxCount(),
|
||||
$booking->getMinorsCount(),
|
||||
$booking->getNights(),
|
||||
$dateFrom,
|
||||
$dateTo,
|
||||
$prices,
|
||||
$booking->getBoardServicePrice(),
|
||||
$booking->getAdditionalServices(),
|
||||
$accommodation->getCurrency(),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Service;
|
||||
|
||||
use App\Entity\Groups\AccommodationBooking;
|
||||
use Symfony\Component\DependencyInjection\Attribute\Autowire;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\HttpFoundation\UriSigner;
|
||||
use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
|
||||
|
||||
class AccommodationBookingLinkSigner
|
||||
{
|
||||
private const int LINK_TTL_DAYS = 90;
|
||||
private const string TIMESTAMP_PARAM = 't';
|
||||
|
||||
private readonly UriSigner $uriSigner;
|
||||
|
||||
public function __construct(
|
||||
private readonly UrlGeneratorInterface $urlGenerator,
|
||||
#[Autowire(env: 'ACCOMMODATION_OFFER_LINK_SECRET')]
|
||||
string $secret,
|
||||
) {
|
||||
$this->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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,429 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Service;
|
||||
|
||||
use App\Email\Mailer;
|
||||
use App\Entity\Groups\Accommodation;
|
||||
use App\Entity\Groups\AccommodationBooking;
|
||||
use App\Entity\Groups\AccommodationPrice;
|
||||
use App\Entity\Groups\AdditionalService;
|
||||
use App\Entity\Groups\BoardService;
|
||||
use App\Form\Model\AccommodationBookingDto;
|
||||
use App\Model\AccommodationBookingQueryParams;
|
||||
use App\Model\InquiryStatus;
|
||||
use App\Repository\Groups\AccommodationPriceRepository;
|
||||
use App\Repository\Groups\AccommodationRepository;
|
||||
use App\Repository\Groups\AdditionalServiceRepository;
|
||||
use App\Repository\Groups\BoardServiceRepository;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Psr\Log\LoggerInterface;
|
||||
|
||||
class AccommodationBookingService
|
||||
{
|
||||
public function __construct(
|
||||
private readonly AccommodationRepository $accommodationRepo,
|
||||
private readonly AccommodationPriceRepository $priceRepo,
|
||||
private readonly AdditionalServiceRepository $additionalServiceRepo,
|
||||
private readonly BoardServiceRepository $boardServiceRepo,
|
||||
private readonly PriceTimelineBuilder $priceTimelineBuilder,
|
||||
private readonly EntityManagerInterface $entityManager,
|
||||
private readonly Mailer $mailer,
|
||||
private readonly LoggerInterface $logger,
|
||||
private readonly CmsDataProvider $cmsDataProvider,
|
||||
private readonly AccommodationBookingLinkSigner $linkSigner,
|
||||
private readonly AccommodationBookingBreakdownCalculator $breakdownCalculator,
|
||||
private readonly string $officeEmail,
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses query params, resolves the accommodation by calendar code, and returns a fresh DTO.
|
||||
*
|
||||
* @throws \InvalidArgumentException if the hotel code is not found or dates are invalid
|
||||
*/
|
||||
public function initFromParams(AccommodationBookingQueryParams $params): AccommodationBookingDto
|
||||
{
|
||||
$accommodation = $this->accommodationRepo->findOneBy(['calendarCode' => $params->hotelCode]);
|
||||
|
||||
if (null === $accommodation) {
|
||||
throw new \InvalidArgumentException(sprintf('Unterkunft mit Code "%s" nicht gefunden.', $params->hotelCode));
|
||||
}
|
||||
|
||||
$dto = new AccommodationBookingDto();
|
||||
$dto->accommodationId = $accommodation->getId();
|
||||
|
||||
if (null !== $params->dateFrom && null !== $params->dateTo) {
|
||||
$this->applyDates($dto, $params->dateFrom, $params->dateTo);
|
||||
$dateFrom = $dto->dateFrom;
|
||||
$dateTo = $dto->dateTo;
|
||||
if (null !== $dateFrom && null !== $dateTo) {
|
||||
$dto->paxCount = $this->resolveMinPax($params->hotelCode, $dateFrom, $dateTo);
|
||||
}
|
||||
}
|
||||
|
||||
return $dto;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses and validates raw date strings, then sets them on the DTO.
|
||||
*
|
||||
* @throws \InvalidArgumentException if dates are invalid or dateTo ≤ dateFrom
|
||||
*/
|
||||
public function applyDates(AccommodationBookingDto $dto, string $dateFromRaw, string $dateToRaw): void
|
||||
{
|
||||
$dateFrom = $this->parseDate($dateFromRaw);
|
||||
$dateTo = $this->parseDate($dateToRaw);
|
||||
|
||||
if (null === $dateFrom || null === $dateTo) {
|
||||
throw new \InvalidArgumentException('Ungültiges Datumsformat. Erwartet: YYYY-MM-DD.');
|
||||
}
|
||||
|
||||
if ($dateTo <= $dateFrom) {
|
||||
throw new \InvalidArgumentException('Das Abreisedatum muss nach dem Anreisedatum liegen.');
|
||||
}
|
||||
|
||||
$dto->dateFrom = $dateFrom->setTime(0, 0, 0);
|
||||
$dto->dateTo = $dateTo->setTime(0, 0, 0);
|
||||
}
|
||||
|
||||
public function computeInitialPaxCount(Accommodation $accommodation, \DateTimeImmutable $dateFrom, \DateTimeImmutable $dateTo): int
|
||||
{
|
||||
return $this->resolveMinPax($accommodation->getCalendarCode() ?? '', $dateFrom, $dateTo);
|
||||
}
|
||||
|
||||
public function loadAccommodation(int $id): ?Accommodation
|
||||
{
|
||||
return $this->accommodationRepo->find($id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches hotel name, image, and address from the CMS for display in the sidebar.
|
||||
*
|
||||
* @return array<string, mixed>|null null on CMS error or missing code
|
||||
*/
|
||||
public function loadHotelCmsData(Accommodation $accommodation): ?array
|
||||
{
|
||||
$code = $accommodation->getEffectiveCmsCode();
|
||||
$data = $this->cmsDataProvider->getHotelDetails($code);
|
||||
|
||||
return isset($data['success']) && false === $data['success'] ? null : $data;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return AccommodationPrice[]
|
||||
*/
|
||||
public function loadPrices(AccommodationBookingDto $dto, Accommodation $accommodation): array
|
||||
{
|
||||
return $this->priceRepo->findByHotelCodeAndDateRange(
|
||||
$accommodation->getCalendarCode() ?? '',
|
||||
$dto->dateFrom,
|
||||
$dto->dateTo,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{
|
||||
* boardServices: BoardService[],
|
||||
* additionalServices: AdditionalService[],
|
||||
* groupedAdditionalServices: array<string, AdditionalService[]>,
|
||||
* ungroupedAdditionalServices: AdditionalService[]
|
||||
* }
|
||||
*/
|
||||
public function loadAvailableServices(AccommodationBookingDto $dto, Accommodation $accommodation): array
|
||||
{
|
||||
$boardServices = $this->boardServiceRepo->findByAccommodationAndDateRange(
|
||||
$accommodation,
|
||||
$dto->dateFrom,
|
||||
$dto->dateTo,
|
||||
);
|
||||
|
||||
$additionalServices = $this->additionalServiceRepo->findByAccommodationAndDateRange(
|
||||
$accommodation,
|
||||
$dto->dateFrom,
|
||||
$dto->dateTo,
|
||||
);
|
||||
|
||||
$grouped = [];
|
||||
$ungrouped = [];
|
||||
|
||||
foreach ($additionalServices as $service) {
|
||||
if (null !== $service->getSelectionGroup()) {
|
||||
$grouped[$service->getSelectionGroup()][] = $service;
|
||||
} else {
|
||||
$ungrouped[] = $service;
|
||||
}
|
||||
}
|
||||
|
||||
return [
|
||||
'boardServices' => $boardServices,
|
||||
'additionalServices' => $additionalServices,
|
||||
'groupedAdditionalServices' => $grouped,
|
||||
'ungroupedAdditionalServices' => $ungrouped,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Determines whether the booking must be treated as non-binding and why.
|
||||
*
|
||||
* The effective price for the first day of the booking window is used as the authoritative
|
||||
* source for minNights and includedPax rules. This mirrors how the pricing side works —
|
||||
* per-day prices are iterated for cost calculation, but the booking-mode decision is
|
||||
* anchored to the first night's effective price.
|
||||
*
|
||||
* PriceTimelineBuilder::resolveWinner() resolves the DISCOUNT > OVERRIDE > base priority.
|
||||
*
|
||||
* @param AccommodationPrice[] $prices
|
||||
*/
|
||||
public function computeInquiryStatus(AccommodationBookingDto $dto, array $prices): InquiryStatus
|
||||
{
|
||||
if (empty($prices)) {
|
||||
return new InquiryStatus(true, ['Für den gewählten Zeitraum ist kein Preis hinterlegt.']);
|
||||
}
|
||||
|
||||
$nights = $dto->getNights();
|
||||
$firstDay = $dto->dateFrom;
|
||||
|
||||
$candidates = array_values(array_filter(
|
||||
$prices,
|
||||
fn(AccommodationPrice $p) => $p->getDateFrom() <= $firstDay && $p->getDateTo() >= $firstDay,
|
||||
));
|
||||
|
||||
$winner = $this->priceTimelineBuilder->resolveWinner($candidates);
|
||||
|
||||
if (null === $winner) {
|
||||
return new InquiryStatus(true, ['Für den Anreisezeitpunkt ist kein Preis hinterlegt.']);
|
||||
}
|
||||
|
||||
$reasons = [];
|
||||
|
||||
$adultPax = $dto->paxCount - $dto->minorsCount;
|
||||
if ($adultPax < $winner->getIncludedPax() && !$winner->isAcceptUndersubscription()) {
|
||||
$reasons[] = sprintf(
|
||||
'Mindestpersonenzahl: %d (gebucht: %d)',
|
||||
$winner->getIncludedPax(),
|
||||
$adultPax,
|
||||
);
|
||||
}
|
||||
|
||||
if ($nights < $winner->getMinNights() && !$winner->isAcceptShortTerm()) {
|
||||
$reasons[] = sprintf(
|
||||
'Mindestaufenthalt: %d Nächte (gebucht: %d)',
|
||||
$winner->getMinNights(),
|
||||
$nights,
|
||||
);
|
||||
}
|
||||
|
||||
return new InquiryStatus(!empty($reasons), $reasons);
|
||||
}
|
||||
|
||||
/**
|
||||
* Persists the completed booking to the database.
|
||||
*
|
||||
* Board and additional services are stored as frozen snapshots — independent of
|
||||
* the catalog entities so that future catalog edits do not affect existing bookings.
|
||||
*
|
||||
* @param AccommodationPrice[] $prices
|
||||
* @param array{additionalServices: AdditionalService[], boardServices: mixed[]} $services
|
||||
*/
|
||||
public function persist(
|
||||
AccommodationBookingDto $dto,
|
||||
Accommodation $accommodation,
|
||||
array $prices,
|
||||
array $services,
|
||||
): AccommodationBooking {
|
||||
$booking = new AccommodationBooking();
|
||||
$booking->setAccommodation($accommodation);
|
||||
$booking->setGroupName($dto->groupName);
|
||||
$booking->setDateFrom($dto->dateFrom);
|
||||
$booking->setDateTo($dto->dateTo);
|
||||
$booking->setPaxCount($dto->paxCount);
|
||||
$booking->setMinorsCount($dto->minorsCount);
|
||||
$booking->setChildrenCount($dto->childrenCount);
|
||||
$booking->setIsInquiry($dto->forceInquiry || $this->computeInquiryStatus($dto, $prices)->isInquiry);
|
||||
|
||||
// Freeze board service as scalar fields (no FK)
|
||||
if (null !== $dto->selectedBoardServiceId) {
|
||||
foreach ($services['boardServices'] as $boardService) {
|
||||
if ($boardService->getId() === $dto->selectedBoardServiceId) {
|
||||
$booking->setBoardServiceLabel($boardService->getLabel());
|
||||
$booking->setBoardServicePrice($boardService->getPrice());
|
||||
$booking->setBoardServiceOriginalId($boardService->getId());
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Freeze additional services as JSON snapshots (no FK to catalog)
|
||||
$selectedIds = array_flip($dto->selectedAdditionalServiceIds);
|
||||
foreach ($services['additionalServices'] as $additionalService) {
|
||||
if (!isset($selectedIds[$additionalService->getId()])) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$booking->addAdditionalServiceSnapshot(
|
||||
$additionalService->getLabel(),
|
||||
$additionalService->getPrice(),
|
||||
$additionalService->getType(),
|
||||
$additionalService->getId(),
|
||||
);
|
||||
}
|
||||
|
||||
// Personal data
|
||||
$booking->setSalutation($dto->salutation);
|
||||
$booking->setFirstName($dto->firstName);
|
||||
$booking->setLastName($dto->lastName);
|
||||
$booking->setEmail($dto->email);
|
||||
$booking->setPhone($dto->phone);
|
||||
$booking->setStreet($dto->street);
|
||||
$booking->setZip($dto->zip);
|
||||
$booking->setCity($dto->city);
|
||||
$booking->setRemarks($dto->remarks);
|
||||
|
||||
$this->entityManager->persist($booking);
|
||||
$this->entityManager->flush();
|
||||
|
||||
return $booking;
|
||||
}
|
||||
|
||||
/** @param array<string, mixed> $priceBreakdown */
|
||||
public function sendNotificationEmail(AccommodationBooking $booking, array $priceBreakdown = []): void
|
||||
{
|
||||
try {
|
||||
$this->mailer->createAndSendEmail(
|
||||
[
|
||||
'booking' => $booking,
|
||||
'priceBreakdown' => $priceBreakdown,
|
||||
'accessLink' => $this->accessLinkOrNull($booking),
|
||||
],
|
||||
[
|
||||
'to' => $this->officeEmail,
|
||||
'subject' => sprintf(
|
||||
'Neue Unterkunfts%s: %s',
|
||||
$booking->isInquiry() ? 'anfrage' : 'buchung',
|
||||
$booking->getAccommodation()?->getName(),
|
||||
),
|
||||
'template' => 'email/accommodation_booking.html.twig',
|
||||
'attachments' => [],
|
||||
],
|
||||
);
|
||||
} catch (\Throwable $e) {
|
||||
$this->logger->error('Failed to send accommodation booking notification email', [
|
||||
'booking_id' => $booking->getId(),
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets accessLinkIssuedAt for a direct (non-inquiry) booking if it doesn't have one yet.
|
||||
* No email side effect — the confirmation email is always sent separately via
|
||||
* sendCustomerConfirmationEmail(), regardless of whether a link exists.
|
||||
*/
|
||||
public function issueAccessLinkForDirectBooking(AccommodationBooking $booking): void
|
||||
{
|
||||
if ($booking->isInquiry() || null !== $booking->getAccessLinkIssuedAt()) {
|
||||
return;
|
||||
}
|
||||
|
||||
$booking->setAccessLinkIssuedAt(new \DateTimeImmutable());
|
||||
$this->entityManager->flush();
|
||||
}
|
||||
|
||||
/**
|
||||
* Always sends a confirmation email to the customer, whether or not an access link
|
||||
* exists yet — the template renders differently depending on accessLink being present.
|
||||
*/
|
||||
public function sendCustomerConfirmationEmail(AccommodationBooking $booking): void
|
||||
{
|
||||
try {
|
||||
$this->mailer->createAndSendEmail(
|
||||
[
|
||||
'booking' => $booking,
|
||||
'priceBreakdown' => $this->breakdownCalculator->compute($booking) ?? [],
|
||||
'accessLink' => $this->accessLinkOrNull($booking),
|
||||
],
|
||||
[
|
||||
'to' => $booking->getEmail(),
|
||||
'subject' => $booking->isInquiry()
|
||||
? 'Deine Anfrage ist bei uns eingegangen'
|
||||
: 'Deine Buchung ist bestätigt',
|
||||
'template' => 'email/accommodation_booking_customer.html.twig',
|
||||
'attachments' => [],
|
||||
],
|
||||
);
|
||||
} catch (\Throwable $e) {
|
||||
$this->logger->error('Failed to send accommodation booking customer confirmation email', [
|
||||
'booking_id' => $booking->getId(),
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Explicit admin action: (re)issues the access link — invalidating any previously issued
|
||||
* link for this booking — and sends the customer confirmation email, now including it.
|
||||
*/
|
||||
public function regenerateAccessLinkAndNotifyCustomer(AccommodationBooking $booking): void
|
||||
{
|
||||
$booking->setAccessLinkIssuedAt(new \DateTimeImmutable());
|
||||
$this->entityManager->flush();
|
||||
|
||||
$this->sendCustomerConfirmationEmail($booking);
|
||||
}
|
||||
|
||||
/**
|
||||
* Transitions a booking from inquiry to booking. Idempotent — a no-op if the booking
|
||||
* is already accepted.
|
||||
*/
|
||||
public function acceptBooking(AccommodationBooking $booking): void
|
||||
{
|
||||
if (!$booking->isInquiry()) {
|
||||
return;
|
||||
}
|
||||
|
||||
$booking->setIsInquiry(false);
|
||||
$booking->setAcceptedAt(new \DateTimeImmutable());
|
||||
$this->entityManager->flush();
|
||||
}
|
||||
|
||||
private function accessLinkOrNull(AccommodationBooking $booking): ?string
|
||||
{
|
||||
return null !== $booking->getAccessLinkIssuedAt() ? $this->linkSigner->sign($booking) : null;
|
||||
}
|
||||
|
||||
private function parseDate(string $value): ?\DateTimeImmutable
|
||||
{
|
||||
$date = \DateTimeImmutable::createFromFormat('!Y-m-d', $value);
|
||||
$errors = \DateTimeImmutable::getLastErrors();
|
||||
|
||||
if (false === $date) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (false !== $errors && ($errors['warning_count'] > 0 || $errors['error_count'] > 0)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if ($date->format('Y-m-d') !== $value) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $date;
|
||||
}
|
||||
|
||||
private function resolveMinPax(
|
||||
string $hotelCode,
|
||||
\DateTimeImmutable $dateFrom,
|
||||
\DateTimeImmutable $dateTo,
|
||||
): int {
|
||||
$prices = $this->priceRepo->findByHotelCodeAndDateRange($hotelCode, $dateFrom, $dateTo);
|
||||
$candidates = array_values(array_filter(
|
||||
$prices,
|
||||
fn(AccommodationPrice $price): bool => $price->getDateFrom() <= $dateFrom && $price->getDateTo() >= $dateFrom,
|
||||
));
|
||||
|
||||
return $this->priceTimelineBuilder->resolveWinner($candidates)?->getIncludedPax() ?? 1;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Service;
|
||||
|
||||
use App\Exception\AccommodationSessionNotFoundException;
|
||||
use App\Form\Model\AccommodationBookingDto;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
|
||||
class AccommodationSessionManager
|
||||
{
|
||||
// Key kept as accommodation_inquiry for session continuity during the inquiry→booking rename.
|
||||
public const SESSION_KEY = 'accommodation_inquiry';
|
||||
|
||||
public function getDto(Request $request): ?AccommodationBookingDto
|
||||
{
|
||||
$session = $request->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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Service;
|
||||
|
||||
class CalendarGridBuilder
|
||||
{
|
||||
private const array MONTH_NAMES = [
|
||||
'Januar', 'Februar', 'März', 'April', 'Mai', 'Juni',
|
||||
'Juli', 'August', 'September', 'Oktober', 'November', 'Dezember',
|
||||
];
|
||||
|
||||
/**
|
||||
* Builds a calendar structure for $count months starting from the 1st of $from's month.
|
||||
*
|
||||
* @return list<array{month: \DateTimeImmutable, label: string, weeks: list<list<array{date: \DateTimeImmutable, inMonth: bool}>>}>
|
||||
*/
|
||||
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<list<array{date: \DateTimeImmutable, inMonth: bool}>>
|
||||
*/
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,305 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Service;
|
||||
|
||||
use App\Entity\Groups\AccommodationPrice;
|
||||
use App\Entity\Groups\AdditionalService;
|
||||
use App\Entity\Groups\BoardService;
|
||||
use App\Enum\Groups\AdditionalServiceType;
|
||||
use Symfony\Component\OptionsResolver\OptionsResolver;
|
||||
|
||||
class GroupsPriceCalculator
|
||||
{
|
||||
/** @var array<int, int> short-term surcharge percentages (nights → percent) */
|
||||
private const array SHORT_TERM_FACTORS = [1 => 30, 2 => 20, 3 => 10];
|
||||
|
||||
/** @var array<string, int|float> */
|
||||
private array $config;
|
||||
|
||||
public function __construct(
|
||||
private readonly PriceTimelineBuilder $priceTimelineBuilder,
|
||||
array $config,
|
||||
) {
|
||||
$this->config = $this->resolveConfig($config);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, int|float> $config
|
||||
* @return array<string, int|float>
|
||||
*/
|
||||
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<array{label: string, price: int}>,
|
||||
* 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<array{label: string, price: int, type: string, originalServiceId: int|null}> $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<array{label: string, price: int}>,
|
||||
* servicesPrice: int,
|
||||
* runningCosts: int,
|
||||
* total: int,
|
||||
* currency: string,
|
||||
* }
|
||||
*/
|
||||
public function calculateFromSnapshots(
|
||||
int $paxCount,
|
||||
int $minorsCount,
|
||||
int $nights,
|
||||
\DateTimeImmutable $dateFrom,
|
||||
\DateTimeImmutable $dateTo,
|
||||
array $prices,
|
||||
?int $boardServicePricePerPersonNight,
|
||||
array $additionalServiceSnapshots,
|
||||
string $currency,
|
||||
): array {
|
||||
$items = array_map(
|
||||
fn(array $s) => [
|
||||
'label' => $s['label'] ?? '',
|
||||
'price' => $s['price'] ?? 0,
|
||||
'type' => AdditionalServiceType::tryFrom($s['type'] ?? '') ?? AdditionalServiceType::Flat,
|
||||
],
|
||||
$additionalServiceSnapshots,
|
||||
);
|
||||
|
||||
return $this->doCalculate(
|
||||
$paxCount,
|
||||
$minorsCount,
|
||||
$nights,
|
||||
$dateFrom,
|
||||
$dateTo,
|
||||
$prices,
|
||||
$boardServicePricePerPersonNight,
|
||||
$items,
|
||||
$currency,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param AccommodationPrice[] $prices
|
||||
* @param list<array{label: string, price: int, type: AdditionalServiceType}> $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<array{label: string, price: int}>,
|
||||
* 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,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -8,7 +8,6 @@
|
||||
</div>
|
||||
{{ form_row(form.calendarCode) }}
|
||||
{{ form_row(form.cmsCode) }}
|
||||
{{ form_row(form.minPaxSummer) }}
|
||||
{{ form_row(form.maxAdolescentAge) }}
|
||||
{{ form_row(form.currency) }}
|
||||
</div>
|
||||
@@ -54,118 +53,128 @@
|
||||
|
||||
<hr class="my-8">
|
||||
|
||||
<h2 class="text-xl font-bold">
|
||||
Preise
|
||||
</h2>
|
||||
<div class="data-table-wrapper mb-4">
|
||||
<div class="data-table-wrapper__inner text-sm">
|
||||
<table class="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>
|
||||
von
|
||||
</th>
|
||||
<th>
|
||||
bis
|
||||
</th>
|
||||
<th>
|
||||
Typ
|
||||
</th>
|
||||
<th>
|
||||
Saison
|
||||
</th>
|
||||
<th>
|
||||
Pax bis
|
||||
</th>
|
||||
<th>
|
||||
Mindestmiete pro Nacht
|
||||
</th>
|
||||
<th>
|
||||
weitere Personen pro Nacht
|
||||
</th>
|
||||
<th>
|
||||
Unterbelegung
|
||||
</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for price in accommodation.accommodationPrices %}
|
||||
<tr>
|
||||
<td>
|
||||
{{ price.dateFrom | date('d.m.Y') }}
|
||||
</td>
|
||||
<td>
|
||||
{{ price.dateTo | date('d.m.Y') }}
|
||||
</td>
|
||||
<td>
|
||||
{% 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 %}
|
||||
</td>
|
||||
<td>
|
||||
{{ price.season.token | trans }}
|
||||
</td>
|
||||
<td>
|
||||
{{ price.includedPax }}
|
||||
</td>
|
||||
<td>
|
||||
{{ (price.pricePerNight / 100) | format_currency(accommodation.currency) }}
|
||||
</td>
|
||||
<td>
|
||||
{{ (price.priceAdditionalPerson / 100) | format_currency(accommodation.currency) }}
|
||||
</td>
|
||||
<td>
|
||||
{% if price.acceptUndersubscription %}
|
||||
{{ icon('check', 'w-4 h-4') }}
|
||||
{% endif %}
|
||||
</td>
|
||||
<td class="text-right">
|
||||
<twig:dropdown>
|
||||
<twig:dropdown:link url="{{ path('app_admin_accommodationprice_edit', { 'id': price.id }) }}">
|
||||
bearbeiten
|
||||
</twig:dropdown:link>
|
||||
<twig:dropdown:link url="{{ path('app_admin_accommodationprice_duplicate', { 'id': price.id }) }}">
|
||||
duplizieren
|
||||
</twig:dropdown:link>
|
||||
<twig:dropdown:hxbutton url="{{ path('app_admin_accommodationprice_delete', { 'id': price.id }) }}" warning>
|
||||
löschen
|
||||
</twig:dropdown:hxbutton>
|
||||
</twig:dropdown>
|
||||
</td>
|
||||
</tr>
|
||||
{% else %}
|
||||
<tr>
|
||||
<td colspan="7" class="text-center">
|
||||
Keine Daten
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex justify-end">
|
||||
<a href="{{ path('app_admin_accommodationprice_create', { 'id': accommodation.id }) }}" class="button button--primary button--small">
|
||||
neu
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<hr class="my-8">
|
||||
|
||||
<h2 class="text-xl font-bold">
|
||||
Verpflegungsleistungen
|
||||
</h2>
|
||||
<div class="data-table-wrapper mb-4">
|
||||
<section id="prices">
|
||||
<h2 class="text-xl font-bold">
|
||||
Preise
|
||||
</h2>
|
||||
<div class="data-table-wrapper mb-4">
|
||||
<div class="data-table-wrapper__inner text-sm">
|
||||
<table class="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>
|
||||
von
|
||||
</th>
|
||||
<th>
|
||||
bis
|
||||
</th>
|
||||
<th>
|
||||
Typ
|
||||
</th>
|
||||
<th>
|
||||
Saison
|
||||
</th>
|
||||
<th>
|
||||
Pax bis
|
||||
</th>
|
||||
<th>
|
||||
Mindestmiete pro Nacht
|
||||
</th>
|
||||
<th>
|
||||
weitere Personen pro Nacht
|
||||
</th>
|
||||
<th>
|
||||
Unterbel.
|
||||
</th>
|
||||
<th>
|
||||
Kurzzeit
|
||||
</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for price in accommodation.accommodationPrices %}
|
||||
<tr>
|
||||
<td>
|
||||
{{ price.dateFrom | date('d.m.Y') }}
|
||||
</td>
|
||||
<td>
|
||||
{{ price.dateTo | date('d.m.Y') }}
|
||||
</td>
|
||||
<td>
|
||||
{% 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 %}
|
||||
</td>
|
||||
<td>
|
||||
{{ price.season.token | trans }}
|
||||
</td>
|
||||
<td>
|
||||
{{ price.includedPax }}
|
||||
</td>
|
||||
<td>
|
||||
{{ (price.pricePerNight / 100) | format_currency(accommodation.currency) }}
|
||||
</td>
|
||||
<td>
|
||||
{{ (price.priceAdditionalPerson / 100) | format_currency(accommodation.currency) }}
|
||||
</td>
|
||||
<td>
|
||||
{% if price.acceptUndersubscription %}
|
||||
{{ icon('check', 'w-4 h-4') }}
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
{% if price.acceptShortterm %}
|
||||
{{ icon('check', 'w-4 h-4') }}
|
||||
{% endif %}
|
||||
</td>
|
||||
<td class="text-right">
|
||||
<twig:dropdown>
|
||||
<twig:dropdown:link url="{{ path('app_admin_accommodationprice_edit', { 'id': price.id }) }}">
|
||||
bearbeiten
|
||||
</twig:dropdown:link>
|
||||
<twig:dropdown:link url="{{ path('app_admin_accommodationprice_duplicate', { 'id': price.id }) }}">
|
||||
duplizieren
|
||||
</twig:dropdown:link>
|
||||
<twig:dropdown:hxbutton url="{{ path('app_admin_accommodationprice_delete', { 'id': price.id }) }}" warning>
|
||||
löschen
|
||||
</twig:dropdown:hxbutton>
|
||||
</twig:dropdown>
|
||||
</td>
|
||||
</tr>
|
||||
{% else %}
|
||||
<tr>
|
||||
<td colspan="7" class="text-center">
|
||||
Keine Daten
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex justify-end">
|
||||
<a href="{{ path('app_admin_accommodationprice_create', { 'id': accommodation.id }) }}" class="button button--primary button--small">
|
||||
neu
|
||||
</a>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<hr class="my-8">
|
||||
|
||||
<section id="board-services">
|
||||
<h2 class="text-xl font-bold">
|
||||
Verpflegungsleistungen
|
||||
</h2>
|
||||
<div class="data-table-wrapper mb-4">
|
||||
<div class="data-table-wrapper__inner text-sm">
|
||||
<table class="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>
|
||||
Datums-Bereich
|
||||
</th>
|
||||
@@ -178,18 +187,14 @@
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody data-controller="sortable" data-sortable-url-value="{{ path('app_admin_boardservice_sort', { 'id': accommodation.id }) }}">
|
||||
<tbody>
|
||||
{% for service in accommodation.boardServices %}
|
||||
<tr data-sortable-target="item">
|
||||
<td class="cursor-grab w-6" data-sortable-target="handle">
|
||||
{{ icon('draggable', 'w-4 h-4') }}
|
||||
</td>
|
||||
<tr>
|
||||
<td>
|
||||
{{ service.dateFrom | date('d.m.Y') }} - {{ service.dateTo | date('d.m.Y') }}
|
||||
</td>
|
||||
<td>
|
||||
{{ service.label }}
|
||||
<input type="hidden" name="sorting[{{ service.id }}]" value="{{ loop.index }}" data-sortable-target="positionField" data-id="{{ service.id }}">
|
||||
</td>
|
||||
<td>
|
||||
{{ (service.price / 100) | format_currency(accommodation.currency) }}
|
||||
@@ -210,7 +215,7 @@
|
||||
</tr>
|
||||
{% else %}
|
||||
<tr>
|
||||
<td colspan="5" class="text-center">
|
||||
<td colspan="4" class="text-center">
|
||||
Keine Daten
|
||||
</td>
|
||||
</tr>
|
||||
@@ -224,79 +229,77 @@
|
||||
neu
|
||||
</a>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<hr class="my-8">
|
||||
|
||||
<h2 class="text-xl font-bold">
|
||||
Optionale Zusatzleistungen
|
||||
</h2>
|
||||
<div class="data-table-wrapper mb-4">
|
||||
<div class="data-table-wrapper__inner text-sm">
|
||||
<table class="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th></th>
|
||||
<th>
|
||||
Datums-Bereich
|
||||
</th>
|
||||
<th>
|
||||
Bezeichnung
|
||||
</th>
|
||||
<th>
|
||||
Preistyp
|
||||
</th>
|
||||
<th>
|
||||
Preis
|
||||
</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody data-controller="sortable" data-sortable-url-value="{{ path('app_admin_additionalservice_sort', { 'id': accommodation.id }) }}">
|
||||
{% for service in accommodation.additionalServices %}
|
||||
<tr data-sortable-target="item">
|
||||
<td class="cursor-grab w-6" data-sortable-target="handle">
|
||||
{{ icon('draggable', 'w-4 h-4') }}
|
||||
</td>
|
||||
<td>
|
||||
{{ service.dateFrom | date('d.m.Y') }} - {{ service.dateTo | date('d.m.Y') }}
|
||||
</td>
|
||||
<td>
|
||||
{{ service.label }}
|
||||
<input type="hidden" name="sorting[{{ service.id }}]" value="{{ loop.index }}" data-sortable-target="positionField" data-id="{{ service.id }}">
|
||||
</td>
|
||||
<td>
|
||||
{{ service.type.label | trans }}
|
||||
</td>
|
||||
<td>
|
||||
{{ (service.price / 100) | format_currency(accommodation.currency) }}
|
||||
</td>
|
||||
<td class="text-right">
|
||||
<twig:dropdown>
|
||||
<twig:dropdown:link url="{{ path('app_admin_additionalservice_edit', { 'id': service.id }) }}">
|
||||
bearbeiten
|
||||
</twig:dropdown:link>
|
||||
<twig:dropdown:link url="{{ path('app_admin_additionalservice_duplicate', { 'id': service.id }) }}">
|
||||
duplizieren
|
||||
</twig:dropdown:link>
|
||||
<twig:dropdown:hxbutton url="{{ path('app_admin_additionalservice_delete', { 'id': service.id }) }}" warning>
|
||||
löschen
|
||||
</twig:dropdown:hxbutton>
|
||||
</twig:dropdown>
|
||||
</td>
|
||||
</tr>
|
||||
{% else %}
|
||||
<section id="additional-services">
|
||||
<h2 class="text-xl font-bold">
|
||||
Optionale Zusatzleistungen
|
||||
</h2>
|
||||
<div class="data-table-wrapper mb-4">
|
||||
<div class="data-table-wrapper__inner text-sm">
|
||||
<table class="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<td colspan="6" class="text-center">
|
||||
Keine Daten
|
||||
</td>
|
||||
<th>
|
||||
Datums-Bereich
|
||||
</th>
|
||||
<th>
|
||||
Bezeichnung
|
||||
</th>
|
||||
<th>
|
||||
Preistyp
|
||||
</th>
|
||||
<th>
|
||||
Preis
|
||||
</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for service in accommodation.additionalServices %}
|
||||
<tr>
|
||||
<td>
|
||||
{{ service.dateFrom | date('d.m.Y') }} - {{ service.dateTo | date('d.m.Y') }}
|
||||
</td>
|
||||
<td>
|
||||
{{ service.label }}
|
||||
</td>
|
||||
<td>
|
||||
{{ service.type.label | trans }}
|
||||
</td>
|
||||
<td>
|
||||
{{ (service.price / 100) | format_currency(accommodation.currency) }}
|
||||
</td>
|
||||
<td class="text-right">
|
||||
<twig:dropdown>
|
||||
<twig:dropdown:link url="{{ path('app_admin_additionalservice_edit', { 'id': service.id }) }}">
|
||||
bearbeiten
|
||||
</twig:dropdown:link>
|
||||
<twig:dropdown:link url="{{ path('app_admin_additionalservice_duplicate', { 'id': service.id }) }}">
|
||||
duplizieren
|
||||
</twig:dropdown:link>
|
||||
<twig:dropdown:hxbutton url="{{ path('app_admin_additionalservice_delete', { 'id': service.id }) }}" warning>
|
||||
löschen
|
||||
</twig:dropdown:hxbutton>
|
||||
</twig:dropdown>
|
||||
</td>
|
||||
</tr>
|
||||
{% else %}
|
||||
<tr>
|
||||
<td colspan="5" class="text-center">
|
||||
Keine Daten
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex justify-end">
|
||||
<a href="{{ path('app_admin_additionalservice_create', { 'id': accommodation.id }) }}" class="button button--primary button--small">
|
||||
neu
|
||||
</a>
|
||||
</div>
|
||||
<div class="flex justify-end">
|
||||
<a href="{{ path('app_admin_additionalservice_create', { 'id': accommodation.id }) }}" class="button button--primary button--small">
|
||||
neu
|
||||
</a>
|
||||
</div>
|
||||
</section>
|
||||
@@ -26,7 +26,9 @@
|
||||
{% for accommodation in accommodations %}
|
||||
<tr>
|
||||
<td>
|
||||
{{ accommodation.name }}
|
||||
<a href="{{ path('app_admin_accommodation_edit', { 'id': accommodation.id, 'r': return_url() }) }}">
|
||||
{{ accommodation.name }}
|
||||
</a>
|
||||
</td>
|
||||
<td>
|
||||
{{ accommodation.calendarCode }}
|
||||
|
||||
@@ -8,8 +8,7 @@
|
||||
{{ form_start(form) }}
|
||||
<div class="grid lg:grid-cols-2 gap-y-4 lg:gap-x-8">
|
||||
{{ form_row(form.name) }}
|
||||
{{ form_row(form.code) }}
|
||||
{{ form_row(form.minPaxSummer) }}
|
||||
{{ form_row(form.calendarCode) }}
|
||||
{{ form_row(form.maxAdolescentAge) }}
|
||||
{{ form_row(form.currency) }}
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
{% extends 'layout_admin.html.twig' %}
|
||||
|
||||
{% block content %}
|
||||
<twig:page:heading>Neue Buchung</twig:page:heading>
|
||||
|
||||
{% form_theme form 'forms_admin.html.twig' %}
|
||||
|
||||
{{ form_start(form) }}
|
||||
<div class="grid lg:grid-cols-2 gap-y-4 lg:gap-x-8">
|
||||
<div class="lg:col-span-2">
|
||||
{{ form_row(form.accommodation) }}
|
||||
</div>
|
||||
|
||||
<h2 class="lg:col-span-2 text-base font-bold pt-2">Kontaktdaten</h2>
|
||||
{{ 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) }}
|
||||
|
||||
<h2 class="lg:col-span-2 text-base font-bold pt-2">Reisedaten</h2>
|
||||
{{ form_row(form.dateFrom) }}
|
||||
{{ form_row(form.dateTo) }}
|
||||
{{ form_row(form.paxCount) }}
|
||||
{{ form_row(form.minorsCount) }}
|
||||
{{ form_row(form.childrenCount) }}
|
||||
|
||||
<h2 class="lg:col-span-2 text-base font-bold pt-2">Status & Rabatt</h2>
|
||||
<div class="lg:col-span-2">
|
||||
{{ form_row(form.isInquiry) }}
|
||||
</div>
|
||||
{{ form_row(form.discount) }}
|
||||
<div class="lg:col-span-2">
|
||||
{{ form_row(form.remarks) }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex justify-between pt-8">
|
||||
<a href="{{ path('app_admin_accommodationbooking') }}" class="button button--secondary button--small">
|
||||
zurück
|
||||
</a>
|
||||
<button type="submit" class="button button--primary button--small">
|
||||
speichern
|
||||
</button>
|
||||
</div>
|
||||
{{ form_rest(form) }}
|
||||
{{ form_end(form) }}
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,60 @@
|
||||
{% extends 'layout_admin.html.twig' %}
|
||||
|
||||
{% block content %}
|
||||
<twig:page:heading>Buchung {{ booking.groupName }}</twig:page:heading>
|
||||
<p class="text-sm text-gray-500 mb-6">{{ booking.accommodation.name }}</p>
|
||||
|
||||
{% form_theme form 'forms_admin.html.twig' %}
|
||||
|
||||
{{ form_start(form) }}
|
||||
<div class="grid lg:grid-cols-2 gap-y-4 lg:gap-x-8">
|
||||
<h2 class="lg:col-span-2 text-base font-bold">Kontaktdaten</h2>
|
||||
{{ 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) }}
|
||||
|
||||
<h2 class="lg:col-span-2 text-base font-bold pt-2">Reisedaten</h2>
|
||||
{{ 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 %}
|
||||
<h2 class="lg:col-span-2 text-base font-bold pt-2">Leistungen</h2>
|
||||
{% if form.boardService is defined %}
|
||||
{{ form_row(form.boardService) }}
|
||||
{% endif %}
|
||||
{% if form.selectedAdditionalServices is defined %}
|
||||
<div class="lg:col-span-2">
|
||||
{{ form_row(form.selectedAdditionalServices) }}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
|
||||
<h2 class="lg:col-span-2 text-base font-bold pt-2">Status & Rabatt</h2>
|
||||
<div class="lg:col-span-2">
|
||||
{{ form_row(form.isInquiry) }}
|
||||
</div>
|
||||
{{ form_row(form.discount) }}
|
||||
<div class="lg:col-span-2">
|
||||
{{ form_row(form.remarks) }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex justify-between pt-8">
|
||||
<a href="{{ path('app_admin_accommodationbooking_show', { 'id': booking.id }) }}" class="button button--secondary button--small">
|
||||
zurück
|
||||
</a>
|
||||
<button type="submit" class="button button--primary button--small">
|
||||
speichern
|
||||
</button>
|
||||
</div>
|
||||
{{ form_rest(form) }}
|
||||
{{ form_end(form) }}
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,94 @@
|
||||
{% extends 'layout_admin.html.twig' %}
|
||||
|
||||
{% block content %}
|
||||
<twig:page:heading>Buchungen & Anfragen</twig:page:heading>
|
||||
<div class="data-table-wrapper">
|
||||
<div class="data-table-wrapper__inner text-sm">
|
||||
<table class="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>
|
||||
{{ knp_pagination_sortable(pagination, 'Eingang', 'booking.createdAt') }}
|
||||
</th>
|
||||
<th>
|
||||
{{ knp_pagination_sortable(pagination, 'Gruppenhaus', 'accommodation.name') }}
|
||||
</th>
|
||||
<th>
|
||||
{{ knp_pagination_sortable(pagination, 'Gruppe', 'booking.groupName') }}
|
||||
</th>
|
||||
<th>
|
||||
{{ knp_pagination_sortable(pagination, 'Anreise', 'booking.dateFrom') }}
|
||||
</th>
|
||||
<th>
|
||||
Personen
|
||||
</th>
|
||||
<th>
|
||||
Status
|
||||
</th>
|
||||
<th>
|
||||
Rabatt
|
||||
</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for booking in pagination %}
|
||||
<tr>
|
||||
<td>
|
||||
{{ booking.createdAt | date('d.m.Y, H:i') }}
|
||||
</td>
|
||||
<td>
|
||||
{{ booking.accommodation.name }}
|
||||
</td>
|
||||
<td>
|
||||
{{ booking.groupName }}
|
||||
</td>
|
||||
<td>
|
||||
{{ booking.dateFrom | date('d.m.Y') }} – {{ booking.dateTo | date('d.m.Y') }}
|
||||
</td>
|
||||
<td>
|
||||
{{ booking.paxCount }}
|
||||
</td>
|
||||
<td>
|
||||
{% if booking.isInquiry %}
|
||||
Anfrage
|
||||
{% else %}
|
||||
Buchung
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
{% if booking.discount is not null %}
|
||||
{{ booking.discount }} %
|
||||
{% else %}
|
||||
–
|
||||
{% endif %}
|
||||
</td>
|
||||
<td class="text-right">
|
||||
<twig:dropdown>
|
||||
<twig:dropdown:link url="{{ path('app_admin_accommodationbooking_show', { 'id': booking.id, 'r': return_url() }) }}">
|
||||
Details
|
||||
</twig:dropdown:link>
|
||||
<twig:dropdown:link url="{{ path('app_admin_accommodationbooking_edit', { 'id': booking.id }) }}">
|
||||
bearbeiten
|
||||
</twig:dropdown:link>
|
||||
</twig:dropdown>
|
||||
</td>
|
||||
</tr>
|
||||
{% else %}
|
||||
<tr>
|
||||
<td colspan="8">
|
||||
Keine Daten...
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
{{ knp_pagination_render(pagination) }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex justify-end">
|
||||
<a href="{{ path('app_admin_accommodationbooking_create') }}" class="button button--primary button--small">
|
||||
neu
|
||||
</a>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,200 @@
|
||||
{% extends 'layout_admin.html.twig' %}
|
||||
|
||||
{% block content %}
|
||||
<twig:page:heading>
|
||||
{% if booking.isInquiry %}Anfrage{% else %}Buchung{% endif %}
|
||||
{{ booking.groupName }}
|
||||
</twig:page:heading>
|
||||
|
||||
<div class="grid lg:grid-cols-2 gap-8">
|
||||
<div>
|
||||
<h2 class="text-lg font-bold mb-4">Kontakt</h2>
|
||||
<dl class="grid grid-cols-2 gap-y-2 text-sm">
|
||||
<dt class="font-medium">Gruppenname</dt>
|
||||
<dd>{{ booking.groupName }}</dd>
|
||||
<dt class="font-medium">Anrede</dt>
|
||||
<dd>{{ booking.salutation | default('–') }}</dd>
|
||||
<dt class="font-medium">Name</dt>
|
||||
<dd>{{ booking.firstName }} {{ booking.lastName }}</dd>
|
||||
<dt class="font-medium">E-Mail</dt>
|
||||
<dd>{{ booking.email }}</dd>
|
||||
<dt class="font-medium">Telefon</dt>
|
||||
<dd>{{ booking.phone | default('–') }}</dd>
|
||||
<dt class="font-medium">Adresse</dt>
|
||||
<dd>
|
||||
{% if booking.street %}
|
||||
{{ booking.street }}<br>
|
||||
{{ booking.zip }} {{ booking.city }}
|
||||
{% else %}
|
||||
–
|
||||
{% endif %}
|
||||
</dd>
|
||||
</dl>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h2 class="text-lg font-bold mb-4">Reisedaten</h2>
|
||||
<dl class="grid grid-cols-2 gap-y-2 text-sm">
|
||||
<dt class="font-medium">Gruppenhaus</dt>
|
||||
<dd>{{ booking.accommodation.name }}</dd>
|
||||
<dt class="font-medium">Anreise</dt>
|
||||
<dd>{{ booking.dateFrom | date('d.m.Y') }}</dd>
|
||||
<dt class="font-medium">Abreise</dt>
|
||||
<dd>{{ booking.dateTo | date('d.m.Y') }}</dd>
|
||||
<dt class="font-medium">Nächte</dt>
|
||||
<dd>{{ booking.nights }}</dd>
|
||||
<dt class="font-medium">Anzahl Personen</dt>
|
||||
<dd>{{ booking.paxCount }}</dd>
|
||||
{% if booking.minorsCount > 0 %}
|
||||
<dt class="font-medium">davon Kinder (0–3 Jahre)</dt>
|
||||
<dd>{{ booking.minorsCount }}</dd>
|
||||
{% endif %}
|
||||
{% if booking.childrenCount > 0 %}
|
||||
<dt class="font-medium">davon Kinder (4–{{ booking.accommodation.maxAdolescentAge }} Jahre)</dt>
|
||||
<dd>{{ booking.childrenCount }}</dd>
|
||||
{% endif %}
|
||||
<dt class="font-medium">Status</dt>
|
||||
<dd>{% if booking.isInquiry %}Anfrage{% else %}Buchung{% endif %}</dd>
|
||||
</dl>
|
||||
</div>
|
||||
|
||||
{% if booking.boardServiceLabel or booking.additionalServices | length > 0 %}
|
||||
<div>
|
||||
<h2 class="text-lg font-bold mb-4">Gebuchte Leistungen</h2>
|
||||
<dl class="grid grid-cols-2 gap-y-2 text-sm">
|
||||
{% if booking.boardServiceLabel %}
|
||||
<dt class="font-medium">Verpflegung</dt>
|
||||
<dd>{{ booking.boardServiceLabel }}</dd>
|
||||
{% endif %}
|
||||
{% for service in booking.additionalServices %}
|
||||
<dt class="font-medium">{{ loop.first ? 'Zusatzleistungen' : '' }}</dt>
|
||||
<dd>{{ service.label }}</dd>
|
||||
{% endfor %}
|
||||
</dl>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if priceBreakdown is not null %}
|
||||
<div>
|
||||
<h2 class="text-lg font-bold mb-4">Preise</h2>
|
||||
{% set currency = booking.accommodation.currency %}
|
||||
<table class="w-full text-sm">
|
||||
{% if priceBreakdown.basePrice > 0 %}
|
||||
<tr>
|
||||
<td class="py-1">
|
||||
Basispreis
|
||||
<span class="text-xs text-gray-500">für {{ priceBreakdown.includedPax }} Pers.</span>
|
||||
</td>
|
||||
<td class="py-1 text-right whitespace-nowrap">{{ (priceBreakdown.basePrice / 100) | format_currency(currency) }}</td>
|
||||
</tr>
|
||||
{% endif %}
|
||||
{% if priceBreakdown.additionalPersonsPrice > 0 %}
|
||||
<tr>
|
||||
<td class="py-1">
|
||||
Aufpreis Personenzahl
|
||||
<span class="text-xs text-gray-500">für {{ priceBreakdown.effectivePax - priceBreakdown.includedPax }} Pers.</span>
|
||||
</td>
|
||||
<td class="py-1 text-right whitespace-nowrap">{{ (priceBreakdown.additionalPersonsPrice / 100) | format_currency(currency) }}</td>
|
||||
</tr>
|
||||
{% endif %}
|
||||
{% if priceBreakdown.shortTermSurcharge > 0 %}
|
||||
<tr>
|
||||
<td class="py-1">Aufpreis Kurzzeit</td>
|
||||
<td class="py-1 text-right whitespace-nowrap">{{ (priceBreakdown.shortTermSurcharge / 100) | format_currency(currency) }}</td>
|
||||
</tr>
|
||||
{% endif %}
|
||||
{% if priceBreakdown.boardPrice > 0 %}
|
||||
<tr>
|
||||
<td class="py-1">Verpflegung</td>
|
||||
<td class="py-1 text-right whitespace-nowrap">{{ (priceBreakdown.boardPrice / 100) | format_currency(currency) }}</td>
|
||||
</tr>
|
||||
{% endif %}
|
||||
{% if priceBreakdown.undersubscriptionSurcharge > 0 %}
|
||||
<tr>
|
||||
<td class="py-1">Verpflegungs-Aufschlag für Gruppen unter {{ priceBreakdown.undersubscriptionThreshold }} Personen</td>
|
||||
<td class="py-1 text-right whitespace-nowrap">{{ (priceBreakdown.undersubscriptionSurcharge / 100) | format_currency(currency) }}</td>
|
||||
</tr>
|
||||
{% endif %}
|
||||
{% for service in priceBreakdown.serviceDetails %}
|
||||
{% if service.price > 0 %}
|
||||
<tr>
|
||||
<td class="py-1">{{ service.label }}</td>
|
||||
<td class="py-1 text-right whitespace-nowrap">{{ (service.price / 100) | format_currency(currency) }}</td>
|
||||
</tr>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
{% if priceBreakdown.runningCosts > 0 %}
|
||||
<tr>
|
||||
<td class="py-1">Strom- und Abfallgebühren</td>
|
||||
<td class="py-1 text-right whitespace-nowrap">{{ (priceBreakdown.runningCosts / 100) | format_currency(currency) }}</td>
|
||||
</tr>
|
||||
{% endif %}
|
||||
<tr class="border-t border-gray-200 font-semibold">
|
||||
<td class="pt-2">
|
||||
Gesamtpreis
|
||||
<span class="block text-xs font-normal text-gray-500">zzgl. ortsabhängiger Gebühren</span>
|
||||
</td>
|
||||
<td class="pt-2 text-right whitespace-nowrap">{{ (priceBreakdown.total / 100) | format_currency(currency) }}</td>
|
||||
</tr>
|
||||
{% if booking.discount is not null %}
|
||||
{% set discountAmount = (priceBreakdown.total * booking.discount / 100) | round | number_format(0, '.', '') %}
|
||||
{% set discountedTotal = priceBreakdown.total - discountAmount %}
|
||||
<tr class="text-green-700">
|
||||
<td class="pt-1">Rabatt {{ booking.discount }} %</td>
|
||||
<td class="pt-1 text-right whitespace-nowrap">– {{ (discountAmount / 100) | format_currency(currency) }}</td>
|
||||
</tr>
|
||||
<tr class="border-t border-gray-200 font-bold text-green-700">
|
||||
<td class="pt-2">Gesamtpreis nach Rabatt</td>
|
||||
<td class="pt-2 text-right whitespace-nowrap">{{ (discountedTotal / 100) | format_currency(currency) }}</td>
|
||||
</tr>
|
||||
{% endif %}
|
||||
</table>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if booking.remarks %}
|
||||
<div class="lg:col-span-2">
|
||||
<h2 class="text-lg font-bold mb-4">Bemerkungen</h2>
|
||||
<p class="text-sm">{{ booking.remarks | nl2br }}</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="text-xs text-gray-400 mt-6">
|
||||
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 %}
|
||||
</div>
|
||||
|
||||
<div class="mt-6 border-t border-gray-200 pt-6">
|
||||
<h2 class="text-lg font-bold mb-2">Zugangslink</h2>
|
||||
{% if accessLink %}
|
||||
<p class="text-sm break-all">
|
||||
<a href="{{ accessLink }}" target="_blank">{{ accessLink }}</a>
|
||||
</p>
|
||||
<p class="text-xs text-gray-500 mt-1">
|
||||
Gültig bis {{ accessLinkExpiresAt | date('d.m.Y') }}
|
||||
{% if accessLinkExpiresAt < date() %}
|
||||
<span class="text-red-600 font-medium">(abgelaufen)</span>
|
||||
{% endif %}
|
||||
</p>
|
||||
{% else %}
|
||||
<p class="text-sm text-gray-500">Es wurde noch kein Zugangslink generiert.</p>
|
||||
{% endif %}
|
||||
<form method="post" action="{{ path('app_admin_accommodationbooking_generate_access_link', { 'id': booking.id }) }}" class="mt-2">
|
||||
<button type="submit" class="button button--secondary button--small">
|
||||
{{ accessLink ? 'Link erneut generieren' : 'Link generieren' }}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-between pt-8">
|
||||
<a href="{{ returnUrl }}" class="button button--secondary button--small">
|
||||
zurück
|
||||
</a>
|
||||
<a href="{{ path('app_admin_accommodationbooking_edit', { 'id': booking.id }) }}" class="button button--primary button--small">
|
||||
bearbeiten
|
||||
</a>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -13,12 +13,15 @@
|
||||
{{ form_row(form.includedPax) }}
|
||||
{{ form_row(form.minNights) }}
|
||||
{{ form_row(form.acceptUndersubscription) }}
|
||||
{{ form_row(form.acceptShortTerm) }}
|
||||
</div>
|
||||
<div class="flex justify-between pt-8">
|
||||
<a href="{{ path('app_admin_accommodation_edit', { 'id': accommodation.id }) }}" class="button button--secondary button--small">
|
||||
<a href="{{ path('app_admin_accommodation_edit', { 'id': accommodation.id }) }}#prices"
|
||||
class="button button--secondary button--small">
|
||||
zurück
|
||||
</a>
|
||||
<button type="submit" class="button button--primary button--small">
|
||||
<button type="submit"
|
||||
class="button button--primary button--small">
|
||||
speichern
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -4,16 +4,21 @@
|
||||
<div class="grid lg:grid-cols-2 gap-y-4 lg:gap-x-8">
|
||||
{{ form_row(form.label) }}
|
||||
{{ form_row(form.type) }}
|
||||
<div class="lg:col-span-2">
|
||||
{{ form_row(form.description) }}
|
||||
</div>
|
||||
{{ form_row(form.price) }}
|
||||
{{ form_row(form.selectionGroup) }}
|
||||
{{ form_row(form.dateFrom) }}
|
||||
{{ form_row(form.dateTo) }}
|
||||
</div>
|
||||
<div class="flex justify-between pt-8">
|
||||
<a href="{{ path('app_admin_accommodation_edit', { 'id': accommodation.id }) }}" class="button button--secondary button--small">
|
||||
<a href="{{ path('app_admin_accommodation_edit', { 'id': accommodation.id }) }}#additional-services"
|
||||
class="button button--secondary button--small">
|
||||
zurück
|
||||
</a>
|
||||
<button type="submit" class="button button--primary button--small">
|
||||
<button type="submit"
|
||||
class="button button--primary button--small">
|
||||
speichern
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -4,14 +4,19 @@
|
||||
<div class="grid lg:grid-cols-2 gap-y-4 lg:gap-x-8">
|
||||
{{ form_row(form.label) }}
|
||||
{{ form_row(form.price) }}
|
||||
<div class="lg:col-span-2">
|
||||
{{ form_row(form.description) }}
|
||||
</div>
|
||||
{{ form_row(form.dateFrom) }}
|
||||
{{ form_row(form.dateTo) }}
|
||||
</div>
|
||||
<div class="flex justify-between pt-8">
|
||||
<a href="{{ path('app_admin_accommodation_edit', { 'id': accommodation.id }) }}" class="button button--secondary button--small">
|
||||
<a href="{{ path('app_admin_accommodation_edit', { 'id': accommodation.id }) }}#board-services"
|
||||
class="button button--secondary button--small">
|
||||
zurück
|
||||
</a>
|
||||
<button type="submit" class="button button--primary button--small">
|
||||
<button type="submit"
|
||||
class="button button--primary button--small">
|
||||
speichern
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,163 @@
|
||||
{% extends 'email/layout.html.twig' %}
|
||||
|
||||
{% block body %}
|
||||
<h1>
|
||||
Neue Unterkunfts{% if booking.inquiry %}anfrage{% else %}buchung{% endif %}
|
||||
</h1>
|
||||
|
||||
<p>
|
||||
<strong>Art:</strong>
|
||||
{% if booking.inquiry %}
|
||||
Unverbindliche Anfrage
|
||||
{% else %}
|
||||
Verbindliche Buchung
|
||||
{% endif %}
|
||||
</p>
|
||||
|
||||
{% if accessLink %}
|
||||
<p>
|
||||
<a href="{{ accessLink }}" class="button">Kundenansicht öffnen</a>
|
||||
</p>
|
||||
{% endif %}
|
||||
|
||||
<h2>Unterkunft</h2>
|
||||
<table>
|
||||
<tr>
|
||||
<td><strong>Name</strong></td>
|
||||
<td>{{ booking.accommodation.name }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><strong>Anreise</strong></td>
|
||||
<td>{{ booking.dateFrom|date('d.m.Y') }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><strong>Abreise</strong></td>
|
||||
<td>{{ booking.dateTo|date('d.m.Y') }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><strong>Nächte</strong></td>
|
||||
<td>{{ booking.nights }}</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
<h2>Personen</h2>
|
||||
<table>
|
||||
<tr>
|
||||
<td><strong>Personen</strong></td>
|
||||
<td>{{ booking.paxCount }}</td>
|
||||
</tr>
|
||||
{% if booking.minorsCount > 0 %}
|
||||
<tr>
|
||||
<td><strong>Kinder (0–3 J.)</strong></td>
|
||||
<td>{{ booking.minorsCount }}</td>
|
||||
</tr>
|
||||
{% endif %}
|
||||
{% if booking.childrenCount > 0 %}
|
||||
<tr>
|
||||
<td><strong>Kinder (4–{{ booking.accommodation.maxAdolescentAge }} J.)</strong></td>
|
||||
<td>{{ booking.childrenCount }}</td>
|
||||
</tr>
|
||||
{% endif %}
|
||||
</table>
|
||||
|
||||
{% if booking.boardServiceLabel %}
|
||||
<h2>Verpflegung</h2>
|
||||
<p>{{ booking.boardServiceLabel }}</p>
|
||||
{% endif %}
|
||||
|
||||
{% if booking.additionalServices is not empty %}
|
||||
<h2>Zusatzleistungen</h2>
|
||||
<ul>
|
||||
{% for service in booking.additionalServices %}
|
||||
<li>{{ service.label }}</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% endif %}
|
||||
|
||||
{% if priceBreakdown is not empty %}
|
||||
{% set currency = priceBreakdown.currency %}
|
||||
<h2>Preise</h2>
|
||||
<table>
|
||||
{% if priceBreakdown.basePrice > 0 %}
|
||||
<tr>
|
||||
<td><strong>Basispreis</strong>{% if priceBreakdown.includedPax is defined %} ({{ priceBreakdown.includedPax }} Pers.){% endif %}</td>
|
||||
<td>{{ (priceBreakdown.basePrice / 100)|format_currency(currency) }}</td>
|
||||
</tr>
|
||||
{% endif %}
|
||||
{% if priceBreakdown.additionalPersonsPrice > 0 %}
|
||||
<tr>
|
||||
<td><strong>Aufpreis Personenzahl</strong>{% if priceBreakdown.includedPax is defined %} ({{ priceBreakdown.effectivePax - priceBreakdown.includedPax }} Pers.){% endif %}</td>
|
||||
<td>{{ (priceBreakdown.additionalPersonsPrice / 100)|format_currency(currency) }}</td>
|
||||
</tr>
|
||||
{% endif %}
|
||||
{% if priceBreakdown.shortTermSurcharge > 0 %}
|
||||
<tr>
|
||||
<td><strong>Aufpreis Kurzzeit</strong></td>
|
||||
<td>{{ (priceBreakdown.shortTermSurcharge / 100)|format_currency(currency) }}</td>
|
||||
</tr>
|
||||
{% endif %}
|
||||
{% if priceBreakdown.boardPrice > 0 %}
|
||||
<tr>
|
||||
<td><strong>Verpflegung</strong></td>
|
||||
<td>{{ (priceBreakdown.boardPrice / 100)|format_currency(currency) }}</td>
|
||||
</tr>
|
||||
{% endif %}
|
||||
{% if priceBreakdown.undersubscriptionSurcharge > 0 %}
|
||||
<tr>
|
||||
<td><strong>Verpflegungs-Aufschlag</strong> (unter {{ priceBreakdown.undersubscriptionThreshold }} Pers.)</td>
|
||||
<td>{{ (priceBreakdown.undersubscriptionSurcharge / 100)|format_currency(currency) }}</td>
|
||||
</tr>
|
||||
{% endif %}
|
||||
{% for service in priceBreakdown.serviceDetails %}
|
||||
{% if service.price > 0 %}
|
||||
<tr>
|
||||
<td><strong>{{ service.label }}</strong></td>
|
||||
<td>{{ (service.price / 100)|format_currency(currency) }}</td>
|
||||
</tr>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
{% if priceBreakdown.runningCosts > 0 %}
|
||||
<tr>
|
||||
<td><strong>Strom- und Abfallgebühren</strong></td>
|
||||
<td>{{ (priceBreakdown.runningCosts / 100)|format_currency(currency) }}</td>
|
||||
</tr>
|
||||
{% endif %}
|
||||
<tr>
|
||||
<td><strong>Gesamtpreis</strong> <em>(zzgl. ortsabhängiger Gebühren)</em></td>
|
||||
<td><strong>{{ (priceBreakdown.total / 100)|format_currency(currency) }}</strong></td>
|
||||
</tr>
|
||||
</table>
|
||||
{% endif %}
|
||||
|
||||
<h2>Kontakt</h2>
|
||||
<table>
|
||||
<tr>
|
||||
<td><strong>Name</strong></td>
|
||||
<td>{{ booking.salutation }} {{ booking.firstName }} {{ booking.lastName }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><strong>E-Mail</strong></td>
|
||||
<td><a href="mailto:{{ booking.email }}">{{ booking.email }}</a></td>
|
||||
</tr>
|
||||
{% if booking.phone %}
|
||||
<tr>
|
||||
<td><strong>Telefon</strong></td>
|
||||
<td>{{ booking.phone }}</td>
|
||||
</tr>
|
||||
{% endif %}
|
||||
{% if booking.street or booking.zip or booking.city %}
|
||||
<tr>
|
||||
<td><strong>Adresse</strong></td>
|
||||
<td>
|
||||
{% if booking.street %}{{ booking.street }}<br>{% endif %}
|
||||
{% if booking.zip or booking.city %}{{ booking.zip }} {{ booking.city }}{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
{% endif %}
|
||||
</table>
|
||||
|
||||
{% if booking.remarks %}
|
||||
<h2>Anmerkungen</h2>
|
||||
<p>{{ booking.remarks|nl2br }}</p>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,66 @@
|
||||
{% extends 'email/layout.html.twig' %}
|
||||
|
||||
{% block body %}
|
||||
{% if accessLink %}
|
||||
<h1>{{ booking.inquiry ? 'Dein Angebot ist bereit' : 'Deine Buchung ist bestätigt' }}</h1>
|
||||
|
||||
<p>
|
||||
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 %}
|
||||
</p>
|
||||
|
||||
<p>
|
||||
<a href="{{ accessLink }}" class="button">
|
||||
{{ booking.inquiry ? 'Angebot ansehen & bestätigen' : 'Buchung ansehen' }}
|
||||
</a>
|
||||
</p>
|
||||
{% else %}
|
||||
<h1>Deine Anfrage ist bei uns eingegangen</h1>
|
||||
|
||||
<p>
|
||||
Hallo {{ booking.firstName }}, vielen Dank für deine Anfrage! Wir melden uns so schnell wie möglich
|
||||
bei dir mit deinem persönlichen Angebot.
|
||||
</p>
|
||||
{% endif %}
|
||||
|
||||
<h2>Unterkunft</h2>
|
||||
<table>
|
||||
<tr>
|
||||
<td><strong>Name</strong></td>
|
||||
<td>{{ booking.accommodation.name }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><strong>Anreise</strong></td>
|
||||
<td>{{ booking.dateFrom|date('d.m.Y') }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><strong>Abreise</strong></td>
|
||||
<td>{{ booking.dateTo|date('d.m.Y') }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><strong>Nächte</strong></td>
|
||||
<td>{{ booking.nights }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><strong>Personen</strong></td>
|
||||
<td>{{ booking.paxCount }}</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
{% if priceBreakdown is not empty %}
|
||||
{% set currency = priceBreakdown.currency %}
|
||||
<h2>Preise</h2>
|
||||
<table>
|
||||
<tr>
|
||||
<td><strong>Gesamtpreis</strong> <em>(zzgl. ortsabhängiger Gebühren)</em></td>
|
||||
<td><strong>{{ (priceBreakdown.total / 100)|format_currency(currency) }}</strong></td>
|
||||
</tr>
|
||||
</table>
|
||||
{% endif %}
|
||||
|
||||
<p class="small">Bei Fragen antworte einfach auf diese E-Mail.</p>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,32 @@
|
||||
{% set current_step = current_step ?? 1 %}
|
||||
{% set step_routes = {
|
||||
1: 'app_groups_booking_step_1',
|
||||
2: 'app_groups_booking_step_2',
|
||||
3: 'app_groups_booking_step_3',
|
||||
} %}
|
||||
<nav class="pagination">
|
||||
{% for step in 1..3 %}
|
||||
<div class="pagination-item">
|
||||
{% if step < current_step %}
|
||||
<a href="{{ path(step_routes[step]) }}"
|
||||
class="pagination-item__border">
|
||||
<span class="pagination-item__inner bg-primary-bg text-gray-800">
|
||||
{{ step }}
|
||||
</span>
|
||||
</a>
|
||||
{% else %}
|
||||
<span class="pagination-item__border">
|
||||
<span class="{{ html_classes('pagination-item__inner', {
|
||||
'bg-primary-light text-white': step == current_step and booking_theme() == 'base',
|
||||
'bg-sbw-primary text-white': step == current_step and booking_theme() == 'sbw',
|
||||
'bg-snz-primary-dark text-white': step == current_step and booking_theme() == 'snz',
|
||||
'bg-ser-primary text-white': step == current_step and booking_theme() == 'ser',
|
||||
'bg-primary-bg text-gray-800': step != current_step
|
||||
}) }}">
|
||||
{{ step }}
|
||||
</span>
|
||||
</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endfor %}
|
||||
</nav>
|
||||
@@ -0,0 +1,80 @@
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-8 pb-4">
|
||||
{% for monthData in months %}
|
||||
<div class="mb-10">
|
||||
|
||||
<h3 class="text-base font-semibold mb-3">{{ monthData.label }}</h3>
|
||||
|
||||
{# Weekday headers (Mon–Sun) #}
|
||||
<div class="grid grid-cols-7 text-center text-xs font-medium text-gray-400 mb-1">
|
||||
<div>Mo</div>
|
||||
<div>Di</div>
|
||||
<div>Mi</div>
|
||||
<div>Do</div>
|
||||
<div>Fr</div>
|
||||
<div>Sa</div>
|
||||
<div>So</div>
|
||||
</div>
|
||||
|
||||
{# Day grid #}
|
||||
<div class="grid grid-cols-7 gap-0.5">
|
||||
{% 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 %}
|
||||
<button type="button"
|
||||
class="pc-day"
|
||||
data-price-calendar-target="day"
|
||||
data-action="price-calendar#selectDate"
|
||||
data-date="{{ dateStr }}"
|
||||
data-status="{{ status }}"
|
||||
data-min-nights="{{ minNights }}"
|
||||
{% if status == 'blocked' %}title="Nicht verfügbar"{% elseif minNights > 1 %}title="Mindestaufenthalt {{ minNights }} Nächte"{% endif %}>
|
||||
{{ dayData.date|date('j') }}
|
||||
</button>
|
||||
{% else %}
|
||||
<div class="pc-day pc-day--out-of-month"></div>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
<div class="flex justify-between">
|
||||
{# Status message (arrival hint, short-stay warning, etc.) #}
|
||||
<div class="text-sm text-primary-medium mb-2 min-h-[1.25rem]"
|
||||
data-price-calendar-target="message"></div>
|
||||
|
||||
{# Reset link — revealed once a start date is selected #}
|
||||
<button type="button"
|
||||
class="text-sm text-gray-400 underline mb-4"
|
||||
data-price-calendar-target="resetButton"
|
||||
data-action="price-calendar#reset">
|
||||
Auswahl zurücksetzen
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{# Navigation — prev/next trigger HTMX month swap; no Stimulus involvement #}
|
||||
<div class="flex items-center justify-between">
|
||||
<button type="button"
|
||||
class="button button--secondary"
|
||||
{% if offset == 0 %}disabled{% endif %}
|
||||
hx-get="{{ path('app_groups_booking_calendar_grid', {'offset': offset - 1}) }}"
|
||||
hx-target="#calendar-mount"
|
||||
hx-swap="innerHTML">
|
||||
← früher
|
||||
</button>
|
||||
<button type="button"
|
||||
class="button button--secondary"
|
||||
{% if offset >= totalMonths - 2 %}disabled{% endif %}
|
||||
hx-get="{{ path('app_groups_booking_calendar_grid', {'offset': offset + 1}) }}"
|
||||
hx-target="#calendar-mount"
|
||||
hx-swap="innerHTML">
|
||||
später →
|
||||
</button>
|
||||
</div>
|
||||
@@ -0,0 +1,24 @@
|
||||
{% extends 'htmx_modal.html.twig' %}
|
||||
|
||||
{% block title %}Buchung bestätigen{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
{% from '_partials/_validation_errors.html.twig' import validation_alert %}
|
||||
{{ validation_alert(confirmationForm, 'Bitte akzeptiere die AGB, um die Buchung abzuschließen.', false) }}
|
||||
|
||||
{{ form_start(confirmationForm, {
|
||||
'action': path('app_groups_booking_step_3_confirm'),
|
||||
'attr': {
|
||||
'hx-post': path('app_groups_booking_step_3_confirm'),
|
||||
'hx-target': '#htmx-modal',
|
||||
'hx-swap': 'outerHTML',
|
||||
}
|
||||
}) }}
|
||||
{{ form_row(confirmationForm.termsAccepted) }}
|
||||
|
||||
<div class="mt-6 flex flex-col space-y-4 md:flex-row md:space-y-0 justify-between">
|
||||
<button type="submit" class="button button--primary">Buchung verbindlich absenden</button>
|
||||
<button type="button" class="button button--secondary" {{ stimulus_action('modal', 'close') }}>Abbrechen</button>
|
||||
</div>
|
||||
{{ form_end(confirmationForm) }}
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,163 @@
|
||||
{% block booking_summary %}
|
||||
<div class="bg-white h-full flex flex-col lg:border-l lg:border-primary-bg"
|
||||
id="booking-summary"
|
||||
{% if htmx_oob_swap ?? false %}hx-swap-oob="outerHTML"{% endif %}>
|
||||
{# Header #}
|
||||
<div class="shrink-0 px-4 lg:px-8 py-4 lg:flex lg:flex-col lg:justify-center shadow-md relative z-10">
|
||||
{% if ctx.priceBreakdown is not null %}
|
||||
<div class="flex items-center justify-between pb-2">
|
||||
<span class="block uppercase font-semibold">Gesamtpreis</span>
|
||||
<span class="font-semibold">{{ (ctx.priceBreakdown.total / 100)|format_currency(ctx.accommodation.currency) }}</span>
|
||||
</div>
|
||||
{% endif %}
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="block uppercase font-semibold">Übersicht</span>
|
||||
<button type="button"
|
||||
class="lg:hidden button button--small bg-primary-light"
|
||||
data-action="toggle#toggle">
|
||||
<svg class="w-4 h-4 text-white transition-transform"
|
||||
data-toggle-target="icon"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 256 256">
|
||||
<rect width="256" height="256" fill="none"/>
|
||||
<polyline points="208 96 128 176 48 96" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="16"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# Content #}
|
||||
<div class="hidden lg:block flex-1 min-h-0 bg-white shadow-lg lg:shadow-none overflow-y-auto px-4 lg:px-8 py-8"
|
||||
data-toggle-target="content">
|
||||
|
||||
{# Accommodation info #}
|
||||
{% set hasImage = ctx.hotelCmsData is not null
|
||||
and ctx.hotelCmsData.images is defined
|
||||
and ctx.hotelCmsData.images.resized is defined
|
||||
and ctx.hotelCmsData.images.resized.l is defined
|
||||
and ctx.hotelCmsData.images.resized.l[0] is defined %}
|
||||
<div class="border border-primary-bg mb-4">
|
||||
<div class="p-2 bg-primary-bg uppercase font-semibold">Unterkunft</div>
|
||||
<div class="grid grid-cols-3 gap-x-4 p-2">
|
||||
{% if hasImage %}
|
||||
<img src="{{ ctx.hotelCmsData.images.resized.l[0].url }}"
|
||||
alt="{{ ctx.hotelCmsData.images.resized.l[0].alt ?? ctx.accommodation.name }}"
|
||||
class="block w-full h-auto">
|
||||
{% endif %}
|
||||
<div class="{{ hasImage ? 'col-span-2' : 'col-span-3' }}">
|
||||
<span class="block font-semibold">{{ ctx.accommodation.name }}</span>
|
||||
{% if ctx.hotelCmsData is not null and ctx.hotelCmsData.address is defined %}
|
||||
<div class="text-sm text-gray-500 mt-1">{{ ctx.hotelCmsData.address|nl2br }}</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# Price breakdown #}
|
||||
{% if ctx.priceBreakdown is not null %}
|
||||
<div class="border border-primary-bg mt-4">
|
||||
<div class="p-2 bg-primary-bg uppercase font-semibold">Preise</div>
|
||||
<table class="w-full table-fixed text-sm">
|
||||
<tr>
|
||||
<td class="p-2">
|
||||
Zeitraum
|
||||
</td>
|
||||
<td class="p-2 text-right">
|
||||
{% if dto.dateFrom %}
|
||||
{{ dto.dateFrom|date('d.m.Y') }} – {{ dto.dateTo|date('d.m.Y') }},
|
||||
<br>
|
||||
{{ dto.nights }} {{ dto.nights != 1 ? 'Nächte' : 'Nacht' }}
|
||||
{% else %}
|
||||
<span class="italic">noch nicht gewählt</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="p-2">
|
||||
Anzahl Personen
|
||||
</td>
|
||||
<td class="p-2 text-right">
|
||||
{{ dto.paxCount }}
|
||||
</td>
|
||||
</tr>
|
||||
{% if dto.minorsCount > 0 %}
|
||||
<tr>
|
||||
<td class="p-2">davon Kinder (0–3 J.)</td>
|
||||
<td class="p-2 text-right">{{ dto.minorsCount }}</td>
|
||||
</tr>
|
||||
{% endif %}
|
||||
{% if dto.childrenCount > 0 %}
|
||||
<tr>
|
||||
<td class="p-2">davon Kinder (4–{{ ctx.accommodation.maxAdolescentAge }} J.)</td>
|
||||
<td class="p-2 text-right">{{ dto.childrenCount }}</td>
|
||||
</tr>
|
||||
{% endif %}
|
||||
|
||||
{% if ctx.priceBreakdown.basePrice > 0 %}
|
||||
<tr>
|
||||
<td class="p-2">
|
||||
Basispreis
|
||||
<p class="text-xs text-gray-500">für {{ ctx.priceBreakdown.includedPax }} Pers.</p>
|
||||
</td>
|
||||
<td class="p-2 text-right whitespace-nowrap">{{ (ctx.priceBreakdown.basePrice / 100)|format_currency(ctx.accommodation.currency) }}</td>
|
||||
</tr>
|
||||
{% endif %}
|
||||
{% if ctx.priceBreakdown.additionalPersonsPrice > 0 %}
|
||||
<tr>
|
||||
<td class="p-2">
|
||||
Aufpreis Personenzahl
|
||||
<p class="text-xs text-gray-500">für {{ ctx.priceBreakdown.effectivePax - ctx.priceBreakdown.includedPax }} Pers.</p>
|
||||
</td>
|
||||
<td class="p-2 text-right whitespace-nowrap">{{ (ctx.priceBreakdown.additionalPersonsPrice / 100)|format_currency(ctx.accommodation.currency) }}</td>
|
||||
</tr>
|
||||
{% endif %}
|
||||
{% if ctx.priceBreakdown.shortTermSurcharge > 0 %}
|
||||
<tr>
|
||||
<td class="p-2">Aufpreis Kurzzeit</td>
|
||||
<td class="p-2 text-right whitespace-nowrap">{{ (ctx.priceBreakdown.shortTermSurcharge / 100)|format_currency(ctx.accommodation.currency) }}</td>
|
||||
</tr>
|
||||
{% endif %}
|
||||
{% if ctx.priceBreakdown.boardPrice > 0 %}
|
||||
<tr>
|
||||
<td class="p-2">Verpflegung</td>
|
||||
<td class="p-2 text-right whitespace-nowrap">{{ (ctx.priceBreakdown.boardPrice / 100)|format_currency(ctx.accommodation.currency) }}</td>
|
||||
</tr>
|
||||
{% endif %}
|
||||
{% if ctx.priceBreakdown.undersubscriptionSurcharge > 0 %}
|
||||
<tr>
|
||||
<td class="p-2">
|
||||
Verpflegungs-Aufschlag
|
||||
<p class="text-xs text-gray-500">für Gruppen unter {{ ctx.priceBreakdown.undersubscriptionThreshold }} Pers.</p>
|
||||
</td>
|
||||
<td class="p-2 text-right whitespace-nowrap">{{ (ctx.priceBreakdown.undersubscriptionSurcharge / 100)|format_currency(ctx.accommodation.currency) }}</td>
|
||||
</tr>
|
||||
{% endif %}
|
||||
{% for service in ctx.priceBreakdown.serviceDetails %}
|
||||
{% if service.price > 0 %}
|
||||
<tr>
|
||||
<td class="p-2">{{ service.label }}</td>
|
||||
<td class="p-2 text-right whitespace-nowrap">{{ (service.price / 100)|format_currency(ctx.accommodation.currency) }}</td>
|
||||
</tr>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
{% if ctx.priceBreakdown.runningCosts > 0 %}
|
||||
<tr>
|
||||
<td class="p-2">Strom- und Abfallgebühren</td>
|
||||
<td class="p-2 text-right whitespace-nowrap">{{ (ctx.priceBreakdown.runningCosts / 100)|format_currency(ctx.accommodation.currency) }}</td>
|
||||
</tr>
|
||||
{% endif %}
|
||||
<tr class="border-t border-primary-bg font-semibold text-base">
|
||||
<td class="p-2">
|
||||
Gesamtpreis
|
||||
<p class="text-xs font-bold">zzgl. ortsabhängiger Gebühren</p>
|
||||
</td>
|
||||
<td class="p-2 text-right whitespace-nowrap">
|
||||
{{ (ctx.priceBreakdown.total / 100)|format_currency(ctx.accommodation.currency) }}
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,18 @@
|
||||
{% extends 'layout.html.twig' %}
|
||||
|
||||
{% block title %}Fehler{% endblock %}
|
||||
|
||||
{% block background %}bg-outer bg-outer--summer{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="flex-1 flex items-center justify-center px-4">
|
||||
<div class="max-w-lg w-full">
|
||||
{% 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
|
||||
} %}
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,82 @@
|
||||
{% extends 'layout.html.twig' %}
|
||||
|
||||
{% block title %}{{ booking.inquiry ? 'Dein Angebot' : 'Deine Buchung' }}{% endblock %}
|
||||
|
||||
{% block background %}bg-outer bg-outer--summer{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="max-w-2xl w-full mx-auto p-8">
|
||||
<h1 class="text-2xl font-bold mb-2">
|
||||
{{ booking.inquiry ? 'Dein Angebot' : 'Deine Buchung' }}
|
||||
</h1>
|
||||
<p class="text-gray-500 mb-6">{{ booking.accommodation.name }}</p>
|
||||
|
||||
<div class="grid grid-cols-2 gap-y-2 text-sm mb-8">
|
||||
<dt class="font-medium">Anreise</dt>
|
||||
<dd>{{ booking.dateFrom | date('d.m.Y') }}</dd>
|
||||
<dt class="font-medium">Abreise</dt>
|
||||
<dd>{{ booking.dateTo | date('d.m.Y') }}</dd>
|
||||
<dt class="font-medium">Nächte</dt>
|
||||
<dd>{{ booking.nights }}</dd>
|
||||
<dt class="font-medium">Personen</dt>
|
||||
<dd>{{ booking.paxCount }}</dd>
|
||||
{% if booking.boardServiceLabel %}
|
||||
<dt class="font-medium">Verpflegung</dt>
|
||||
<dd>{{ booking.boardServiceLabel }}</dd>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
{% if booking.additionalServices | length > 0 %}
|
||||
<div class="mb-8">
|
||||
<h2 class="text-lg font-bold mb-2">Zusatzleistungen</h2>
|
||||
<ul class="text-sm list-disc list-inside">
|
||||
{% for service in booking.additionalServices %}
|
||||
<li>{{ service.label }}</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if priceBreakdown is not null %}
|
||||
{% set currency = booking.accommodation.currency %}
|
||||
<div class="mb-8">
|
||||
<h2 class="text-lg font-bold mb-2">Preise</h2>
|
||||
<table class="w-full text-sm">
|
||||
<tr class="border-t border-gray-200 font-semibold">
|
||||
<td class="pt-2">
|
||||
Gesamtpreis
|
||||
<span class="block text-xs font-normal text-gray-500">zzgl. ortsabhängiger Gebühren</span>
|
||||
</td>
|
||||
<td class="pt-2 text-right whitespace-nowrap">{{ (priceBreakdown.total / 100) | format_currency(currency) }}</td>
|
||||
</tr>
|
||||
{% if booking.discount is not null %}
|
||||
{% set discountAmount = (priceBreakdown.total * booking.discount / 100) | round | number_format(0, '.', '') %}
|
||||
{% set discountedTotal = priceBreakdown.total - discountAmount %}
|
||||
<tr class="text-green-700">
|
||||
<td class="pt-1">Rabatt {{ booking.discount }} %</td>
|
||||
<td class="pt-1 text-right whitespace-nowrap">– {{ (discountAmount / 100) | format_currency(currency) }}</td>
|
||||
</tr>
|
||||
<tr class="border-t border-gray-200 font-bold text-green-700">
|
||||
<td class="pt-2">Gesamtpreis nach Rabatt</td>
|
||||
<td class="pt-2 text-right whitespace-nowrap">{{ (discountedTotal / 100) | format_currency(currency) }}</td>
|
||||
</tr>
|
||||
{% endif %}
|
||||
</table>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if booking.inquiry %}
|
||||
<form method="post" action="{{ app.request.requestUri }}">
|
||||
<button type="submit" class="button button--primary">
|
||||
Angebot verbindlich buchen
|
||||
</button>
|
||||
</form>
|
||||
{% else %}
|
||||
{% include '_partials/_alert.html.twig' with {
|
||||
level: 'success',
|
||||
title: 'Bestätigt',
|
||||
messages: ['Deine Buchung ist verbindlich bestätigt.'],
|
||||
} %}
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endblock %}
|
||||
Loaded 100 of 115 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user