diff --git a/.env.dist b/.env.dist index 7457cc06..4bb6dac6 100644 --- a/.env.dist +++ b/.env.dist @@ -25,7 +25,7 @@ DATABASE_URL=mysql://db_user:db_password@127.0.0.1:3306/db_name # define database server version # when using maria db use the following syntax: 10.8.7-MariaDB # see https://symfony.com/doc/current/reference/configuration/doctrine.html#doctrine-dbal-configuration -DB_SERVER_VERSION=5.6 +DB_SERVER_VERSION=8.0 ###< doctrine/doctrine-bundle ### ### mailer settings ### @@ -45,7 +45,7 @@ USE_PASSWORD_BLACKLIST=true ###> symfony/mailer ### # e.g. smtp://username:password@yourdomain.tld:port -MAILER_DSN=null://localhost +MAILER_DSN=null://null ###< symfony/mailer ### ###> web-auth/webauthn-symfony-bundle ### @@ -58,3 +58,14 @@ PASSKEY_ENABLED=false # comma-separated list of customer salutations CUSTOMER_SALUTATIONS="Ms,Mr,Family" + +# invoice export filename pattern (without extension) +# allowed placeholders: , , , , , , +# fallback syntax is supported: , the first non-empty value will be used +INVOICE_FILENAME_PATTERN="Invoice-" + +###> symfony/routing ### +# Configure how to generate URLs in non-HTTP contexts, such as CLI commands. +# See https://symfony.com/doc/current/routing.html#generating-urls-in-commands +DEFAULT_URI=http://localhost +###< symfony/routing ### diff --git a/.gitignore b/.gitignore index 2697966e..90156509 100644 --- a/.gitignore +++ b/.gitignore @@ -34,3 +34,7 @@ package-lock.json /public/assets/ /assets/vendor/ ###< symfony/asset-mapper ### + +###> phpstan/phpstan ### +phpstan.neon +###< phpstan/phpstan ### diff --git a/assets/app.js b/assets/app.js index d2cbf649..8333ae49 100644 --- a/assets/app.js +++ b/assets/app.js @@ -1,4 +1,4 @@ -import './bootstrap.js'; +import './stimulus_bootstrap.js'; /* * Welcome to your app's main JavaScript file! * diff --git a/assets/controllers/csrf_protection_controller.js b/assets/controllers/csrf_protection_controller.js index 2811f210..511fffa5 100644 --- a/assets/controllers/csrf_protection_controller.js +++ b/assets/controllers/csrf_protection_controller.js @@ -1,7 +1,9 @@ const nameCheck = /^[-_a-zA-Z0-9]{4,22}$/; -const tokenCheck = /^[-_\/+a-zA-Z0-9]{24,}$/; +const tokenCheck = /^[-_/+a-zA-Z0-9]{24,}$/; // Generate and double-submit a CSRF token in a form field and a cookie, as defined by Symfony's SameOriginCsrfTokenManager +// Use `form.requestSubmit()` to ensure that the submit event is triggered. Using `form.submit()` will not trigger the event +// and thus this event-listener will not be executed. document.addEventListener('submit', function (event) { generateCsrfToken(event.target); }, true); @@ -33,8 +35,8 @@ export function generateCsrfToken (formElement) { if (!csrfCookie && nameCheck.test(csrfToken)) { csrfField.setAttribute('data-csrf-protection-cookie-value', csrfCookie = csrfToken); csrfField.defaultValue = csrfToken = btoa(String.fromCharCode.apply(null, (window.crypto || window.msCrypto).getRandomValues(new Uint8Array(18)))); - csrfField.dispatchEvent(new Event('change', { bubbles: true })); } + csrfField.dispatchEvent(new Event('change', { bubbles: true })); if (csrfCookie && tokenCheck.test(csrfToken)) { const cookie = csrfCookie + '_' + csrfToken + '=' + csrfCookie + '; path=/; samesite=strict'; diff --git a/assets/controllers/housekeeping_controller.js b/assets/controllers/housekeeping_controller.js new file mode 100644 index 00000000..ffcb1050 --- /dev/null +++ b/assets/controllers/housekeeping_controller.js @@ -0,0 +1,51 @@ +import { Controller } from '@hotwired/stimulus'; +import { request as httpRequest, serializeForm as httpSerializeForm } from './http_controller.js'; + +export default class extends Controller { + static targets = ['form', 'spinner']; + + submitFilters(event) { + this.spin(); + if (event) { + event.preventDefault(); + } + + if (this.formTarget && typeof this.formTarget.requestSubmit === 'function') { + this.formTarget.requestSubmit(); + } else if (this.formTarget) { + this.formTarget.submit(); + } + } + + spin() { + if (this.hasSpinnerTarget) { + this.spinnerTarget.classList.add('fa-spin'); + } + } + + async saveRow(event) { + event.preventDefault(); + const form = event.target; + const submitter = event.submitter || form.querySelector('button[type="submit"]'); + + if (submitter) { + submitter.disabled = true; + } + + httpRequest({ + url: form.action, + method: form.method || 'POST', + data: httpSerializeForm(form), + loader: false, + onSuccess: () => {}, + onComplete: () => { + if (submitter) { + submitter.disabled = false; + } + }, + onError: (message) => { + console.warn('[housekeeping] save failed', message); + }, + }); + } +} diff --git a/assets/controllers/operations_reports_controller.js b/assets/controllers/operations_reports_controller.js new file mode 100644 index 00000000..acb92ee0 --- /dev/null +++ b/assets/controllers/operations_reports_controller.js @@ -0,0 +1,69 @@ +import { Controller } from '@hotwired/stimulus'; +import { request as httpRequest, serializeForm as httpSerializeForm } from './http_controller.js'; + +export default class extends Controller { + static targets = ['form', 'download', 'preview', 'spinner']; + + connect() { + this.updateLinks(); + this.loadPreview(); + } + + preventSubmit(event) { + if (event) { + event.preventDefault(); + } + } + + updateLinks() { + if (!this.hasFormTarget) { + return; + } + const query = httpSerializeForm(this.formTarget); + if (this.hasDownloadTarget) { + const baseUrl = this.downloadTarget.dataset.baseUrl || this.downloadTarget.href; + this.downloadTarget.href = this.appendQuery(baseUrl, query); + } + if (this.hasPreviewTarget) { + const basePreview = this.previewTarget.dataset.basePreviewUrl || ''; + if (basePreview) { + this.previewTarget.dataset.previewUrl = this.appendQuery(basePreview, query); + } + } + } + + loadPreview(event) { + if (event) { + event.preventDefault(); + } + this.updateLinks(); + if (!this.hasPreviewTarget) { + return; + } + const url = this.previewTarget.dataset.previewUrl; + if (!url) { + return; + } + if (this.hasSpinnerTarget) { + this.spinnerTarget.classList.add('fa-spin'); + } + httpRequest({ + url, + method: 'GET', + target: this.previewTarget, + loader: false, + onComplete: () => { + if (this.hasSpinnerTarget) { + this.spinnerTarget.classList.remove('fa-spin'); + } + }, + }); + } + + appendQuery(baseUrl, query) { + if (!query) { + return baseUrl; + } + return baseUrl + (baseUrl.includes('?') ? '&' : '?') + query; + } +} diff --git a/assets/controllers/reservations_controller.js b/assets/controllers/reservations_controller.js index db665446..af9d80e7 100644 --- a/assets/controllers/reservations_controller.js +++ b/assets/controllers/reservations_controller.js @@ -507,11 +507,17 @@ export default class extends Controller { event.preventDefault(); const id = event.currentTarget.dataset.attachmentId; const isInvoice = event.currentTarget.dataset.isInvoice === 'true'; + const einvoiceCheckbox = event.currentTarget.querySelector('[data-einvoice-checkbox]'); + const isEInvoice = !!(einvoiceCheckbox && einvoiceCheckbox.checked); const url = event.currentTarget.dataset.url; if (url && this.modalContent) { this.modalContent.dataset.addAttachmentUrl = url; } - this.addAsAttachment(id, isInvoice); + this.addAsAttachment(id, isInvoice, isEInvoice); + } + + stopAttachmentRowClickAction(event) { + event.stopPropagation(); } previewTemplateForReservationAction(event) { @@ -1051,6 +1057,25 @@ export default class extends Controller { } } + updateReservationStatusAction(event) { + const select = event.currentTarget; + const url = select.dataset.url; + const token = select.dataset.token; + if (!url || !token) { + return; + } + select.disabled = true; + httpRequest({ + url, + method: 'POST', + loader: false, + data: { status: select.value, _token: token }, + onComplete: () => { + select.disabled = false; + } + }); + } + showFeedback(data, target = null) { if (!data || typeof data !== 'string' || data.trim().length === 0) { return false; @@ -1688,7 +1713,7 @@ export default class extends Controller { - addAsAttachment(id, isInvoice) { + addAsAttachment(id, isInvoice, isEInvoice = false) { const url = this.getContextValue('addAttachmentUrl'); if (!url) { return false; @@ -1696,9 +1721,15 @@ export default class extends Controller { httpRequest({ url, method: 'POST', - data: { id, isInvoice }, + data: { id, isInvoice, isEInvoice }, + loader: false, target: this.modalContent, - onSuccess: () => this.previewTemplateForReservation(0, 'false') + onSuccess: (data) => { + if (this.showFeedback(data)) { + return; + } + this.previewTemplateForReservation(0, 'false'); + } }); return false; } diff --git a/assets/controllers/statistics_controller.js b/assets/controllers/statistics_controller.js index 1b719741..2e8099a1 100644 --- a/assets/controllers/statistics_controller.js +++ b/assets/controllers/statistics_controller.js @@ -13,6 +13,7 @@ export default class extends Controller { 'monthlyChart', 'yearlyChart', 'invoiceStatusForm', + 'reservationStatusForm', 'snapshotMonth', 'snapshotYear', 'snapshotArrivalsTotal', @@ -34,6 +35,7 @@ export default class extends Controller { monthlyOriginUrl: String, yearlyOriginUrl: String, snapshotUrl: String, + snapshotIgnoreUrl: String, snapshotArrivalsLabel: String, snapshotOvernightsLabel: String, snapshotRoomLabel: String, @@ -195,6 +197,9 @@ export default class extends Controller { if (this.hasObjectsTarget) { params.append('objectId', this.objectsTarget.value); } + if (this.hasReservationStatusFormTarget) { + new FormData(this.reservationStatusFormTarget).forEach((v, k) => params.append(k, v)); + } if (!yearOnly) { params.append('monthStart', this.monthlyStartTarget.value); params.append('monthEnd', this.monthlyEndTarget.value); @@ -312,6 +317,7 @@ export default class extends Controller { const params = this.snapshotParams(force); const response = await fetch(`${this.snapshotUrlValue}?${params.toString()}`); const data = await response.json(); + this.currentSnapshotId = data.id || null; const countryNames = data.countryNames || {}; this.updateSnapshotSummary(data.metrics || {}); @@ -331,6 +337,9 @@ export default class extends Controller { if (this.hasObjectsTarget) { params.append('objectId', this.objectsTarget.value); } + if (this.hasReservationStatusFormTarget) { + new FormData(this.reservationStatusFormTarget).forEach((v, k) => params.append(k, v)); + } if (this.hasSnapshotMonthTarget) { params.append('month', this.snapshotMonthTarget.value); } @@ -369,20 +378,34 @@ export default class extends Controller { updateSnapshotWarnings(warnings) { if (!this.hasSnapshotWarningsTarget) return; this.snapshotWarningsTarget.innerHTML = ''; - if (!warnings.length) { + const activeWarnings = warnings.filter((warning) => !warning.ignored); + if (!activeWarnings.length) { const li = document.createElement('li'); li.className = 'text-muted'; li.textContent = this.snapshotWarningsTarget.dataset.emptyText || ''; this.snapshotWarningsTarget.appendChild(li); return; } - warnings.forEach((warning) => { + activeWarnings.forEach((warning) => { + const checkbox = document.createElement('input'); + checkbox.type = 'checkbox'; + checkbox.className = 'form-check-input me-2'; + checkbox.checked = false; + checkbox.addEventListener('change', async () => { + await this.toggleWarningIgnore(warning, checkbox.checked); + this.drawSnapshot(false); + }); + const li = document.createElement('li'); + li.className = 'd-flex align-items-start mb-1'; const start = warning.start_date || ''; const end = warning.end_date || ''; const roomLabel = this.snapshotRoomLabelValue || ''; const room = warning.appartment_number ? ` ${roomLabel} ${warning.appartment_number}` : ''; - li.textContent = `${warning.message || ''}${room} ${start} - ${end}`.trim(); + const text = document.createElement('span'); + text.textContent = `${warning.message || ''}${room} ${start} - ${end}`.trim(); + li.appendChild(checkbox); + li.appendChild(text); this.snapshotWarningsTarget.appendChild(li); }); } @@ -475,4 +498,23 @@ export default class extends Controller { const upper = code.toUpperCase(); return countryNames[upper] || countryNames[code] || code; } + + async toggleWarningIgnore(warning, ignored) { + if (!this.snapshotIgnoreUrlValue) return; + if (!this.currentSnapshotId) return; + const params = new URLSearchParams(); + params.append('reservationId', warning.reservation_id); + params.append('ignored', ignored ? '1' : '0'); + const csrfToken = document.getElementById('statistics_csrf_token'); + if (csrfToken) { + params.append('_csrf_token', csrfToken.value); + } + + const baseUrl = this.snapshotIgnoreUrlValue.replace(/\/0$/, ''); + await fetch(`${baseUrl}/${this.currentSnapshotId}`, { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body: params.toString(), + }); + } } diff --git a/assets/controllers/templates_controller.js b/assets/controllers/templates_controller.js index 2d09af15..25835e90 100644 --- a/assets/controllers/templates_controller.js +++ b/assets/controllers/templates_controller.js @@ -1,6 +1,8 @@ import { Controller } from '@hotwired/stimulus'; export default class extends Controller { + static targets = ['importNotice']; + connect() { this.modalContent = document.getElementById('modal-content-ajax'); const modalDialog = document.querySelector('#modalCenter .modal-dialog'); @@ -11,6 +13,7 @@ export default class extends Controller { this.registerTinyMceFocusFix(); this.observeModalContent(); this.initTinyMceEditor(); + this.hideDismissedImportNotice(); } disconnect() { @@ -101,14 +104,14 @@ export default class extends Controller { protect: [ /\{\%[\s\S]*?%\}/g, /\{\#[\s\S]*?#\}/g, - /<\/?.*(html)?pageheader.*?>/g, - /<\/?.*(html)?pagefooter.*?>/g, ], + custom_elements: 'htmlpageheader,htmlpagefooter,sethtmlpageheader,sethtmlpagefooter', + extended_valid_elements: 'htmlpageheader[name|class|style],htmlpagefooter[name|class|style],sethtmlpageheader[name|value|show-this-page],sethtmlpagefooter[name|value|page]', templates: templatesUrl, entity_encoding: 'raw', branding: false, promotion: false, - valid_children: '+body[style]', + valid_children: '+body[style|htmlpageheader|htmlpagefooter|sethtmlpageheader|sethtmlpagefooter],+htmlpageheader[div|span|p|br|#text],+htmlpagefooter[div|span|p|br|#text]', content_css: [ `${basepath}/resources/css/editor.css`, ], @@ -149,4 +152,33 @@ export default class extends Controller { observer.observe(this.modalContent, { childList: true, subtree: true }); this.modalObserver = observer; } + + dismissImportNotice() { + this.storeImportNoticeDismissed(); + if (this.hasImportNoticeTarget) { + this.importNoticeTarget.remove(); + } + } + + hideDismissedImportNotice() { + if (this.isImportNoticeDismissed() && this.hasImportNoticeTarget) { + this.importNoticeTarget.remove(); + } + } + + isImportNoticeDismissed() { + try { + return window.localStorage.getItem('templates.operations.import.dismissed') === '1'; + } catch (error) { + return false; + } + } + + storeImportNoticeDismissed() { + try { + window.localStorage.setItem('templates.operations.import.dismissed', '1'); + } catch (error) { + // Ignore storage issues. + } + } } diff --git a/assets/bootstrap.js b/assets/stimulus_bootstrap.js similarity index 87% rename from assets/bootstrap.js rename to assets/stimulus_bootstrap.js index f90ffc34..4c6dde8a 100644 --- a/assets/bootstrap.js +++ b/assets/stimulus_bootstrap.js @@ -14,6 +14,8 @@ import TemplatesController from './controllers/templates_controller.js'; import PricesController from './controllers/prices_controller.js'; import ThemeController from './controllers/theme_controller.js'; import CalendarImportsController from './controllers/calendar_imports_controller.js'; +import HousekeepingController from './controllers/housekeeping_controller.js'; +import OperationsReportsController from './controllers/operations_reports_controller.js'; const app = startStimulusApp(); app.register('login', LoginController); @@ -31,3 +33,5 @@ app.register('templates', TemplatesController); app.register('prices', PricesController); app.register('theme', ThemeController); app.register('calendar-imports', CalendarImportsController); +app.register('housekeeping', HousekeepingController); +app.register('operations-reports', OperationsReportsController); diff --git a/assets/styles/app.css b/assets/styles/app.css index e436e314..5882902e 100644 --- a/assets/styles/app.css +++ b/assets/styles/app.css @@ -64,6 +64,10 @@ body { font-size: 0.9rem } +.hk-row-form { + display: contents; +} + .btn { font-size: 0.9rem; } diff --git a/bin/console b/bin/console index de879221..d8d530e2 100644 --- a/bin/console +++ b/bin/console @@ -1,21 +1,21 @@ -#!/usr/bin/env php -=8.4", "ext-ctype": "*", "ext-iconv": "*", - "azuyalabs/yasumi": "2.7.*", - "doctrine/doctrine-bundle": "^2.11", - "doctrine/doctrine-fixtures-bundle": "^3.5", - "doctrine/doctrine-migrations-bundle": "^3.3", + "azuyalabs/yasumi": "2.10.*", + "doctrine/doctrine-bundle": "^3.2", + "doctrine/doctrine-fixtures-bundle": "^4.0", + "doctrine/doctrine-migrations-bundle": "^4.0", "doctrine/orm": "^3.6", "horstoeko/zugferd": "^1.0", "mpdf/mpdf": "^8.2", @@ -85,7 +85,9 @@ "symfony/polyfill-php74": "*", "symfony/polyfill-php80": "*", "symfony/polyfill-php81": "*", - "symfony/polyfill-php82": "*" + "symfony/polyfill-php82": "*", + "symfony/polyfill-php83": "*", + "symfony/polyfill-php84": "*" }, "scripts": { "auto-scripts": { diff --git a/composer.lock b/composer.lock index d60d58de..3c7a75bf 100644 --- a/composer.lock +++ b/composer.lock @@ -4,34 +4,33 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "c491c57d611b3f768d15eb7f08a54e6d", + "content-hash": "c4c5a55f1bd7e7557b1f15bde8d3475f", "packages": [ { "name": "azuyalabs/yasumi", - "version": "2.7.0", + "version": "2.10.0", "source": { "type": "git", "url": "https://github.com/azuyalabs/yasumi.git", - "reference": "37d1215d4f4012d3185bb9990c76ca17a4ff1c30" + "reference": "b9b26a70c3e0ba0b39723e7b756787bff4348536" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/azuyalabs/yasumi/zipball/37d1215d4f4012d3185bb9990c76ca17a4ff1c30", - "reference": "37d1215d4f4012d3185bb9990c76ca17a4ff1c30", + "url": "https://api.github.com/repos/azuyalabs/yasumi/zipball/b9b26a70c3e0ba0b39723e7b756787bff4348536", + "reference": "b9b26a70c3e0ba0b39723e7b756787bff4348536", "shasum": "" }, "require": { "ext-json": "*", - "php": ">=8.0" + "php": ">=8.2" }, "require-dev": { + "azuyalabs/php-cs-fixer-config": "^0.3", "ext-intl": "*", - "friendsofphp/php-cs-fixer": "^2.19 || ^3.40", "mikey179/vfsstream": "^1.6", - "phan/phan": "^5.4", - "phpstan/phpstan": "^1.10", - "phpunit/phpunit": "^8.5 || ^9.6", - "vimeo/psalm": "^5.16" + "phpstan/phpstan": "^2.1", + "phpstan/phpstan-deprecation-rules": "^2.0", + "phpunit/phpunit": "^8.5 || ^9.6" }, "suggest": { "ext-calendar": "For calculating the date of Easter" @@ -50,10 +49,11 @@ { "name": "Sacha Telgenhof", "email": "me@sachatelgenhof.com", + "homepage": "https://www.sachatelgenhof.com", "role": "Maintainer" } ], - "description": "The easy PHP Library for calculating holidays.", + "description": "The easy PHP Library for calculating holidays", "homepage": "https://www.yasumi.dev", "keywords": [ "Bank", @@ -77,20 +77,20 @@ "type": "other" } ], - "time": "2024-01-07T14:12:44+00:00" + "time": "2026-01-22T12:18:00+00:00" }, { "name": "brick/math", - "version": "0.14.1", + "version": "0.14.4", "source": { "type": "git", "url": "https://github.com/brick/math.git", - "reference": "f05858549e5f9d7bb45875a75583240a38a281d0" + "reference": "a8b53e6cc4d3a336543f042a4dfa0e3f2f2356a4" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/brick/math/zipball/f05858549e5f9d7bb45875a75583240a38a281d0", - "reference": "f05858549e5f9d7bb45875a75583240a38a281d0", + "url": "https://api.github.com/repos/brick/math/zipball/a8b53e6cc4d3a336543f042a4dfa0e3f2f2356a4", + "reference": "a8b53e6cc4d3a336543f042a4dfa0e3f2f2356a4", "shasum": "" }, "require": { @@ -129,7 +129,7 @@ ], "support": { "issues": "https://github.com/brick/math/issues", - "source": "https://github.com/brick/math/tree/0.14.1" + "source": "https://github.com/brick/math/tree/0.14.4" }, "funding": [ { @@ -137,7 +137,7 @@ "type": "github" } ], - "time": "2025-11-24T14:40:29+00:00" + "time": "2026-02-02T16:57:31+00:00" }, { "name": "composer/semver", @@ -541,63 +541,57 @@ }, { "name": "doctrine/doctrine-bundle", - "version": "2.18.2", + "version": "3.2.2", "source": { "type": "git", "url": "https://github.com/doctrine/DoctrineBundle.git", - "reference": "0ff098b29b8b3c68307c8987dcaed7fd829c6546" + "reference": "af84173db6978c3d2688ea3bcf3a91720b0704ce" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/doctrine/DoctrineBundle/zipball/0ff098b29b8b3c68307c8987dcaed7fd829c6546", - "reference": "0ff098b29b8b3c68307c8987dcaed7fd829c6546", + "url": "https://api.github.com/repos/doctrine/DoctrineBundle/zipball/af84173db6978c3d2688ea3bcf3a91720b0704ce", + "reference": "af84173db6978c3d2688ea3bcf3a91720b0704ce", "shasum": "" }, "require": { - "doctrine/dbal": "^3.7.0 || ^4.0", + "doctrine/dbal": "^4.0", "doctrine/deprecations": "^1.0", - "doctrine/persistence": "^3.1 || ^4", + "doctrine/persistence": "^4", "doctrine/sql-formatter": "^1.0.1", - "php": "^8.1", - "symfony/cache": "^6.4 || ^7.0", - "symfony/config": "^6.4 || ^7.0", - "symfony/console": "^6.4 || ^7.0", - "symfony/dependency-injection": "^6.4 || ^7.0", - "symfony/doctrine-bridge": "^6.4.3 || ^7.0.3", - "symfony/framework-bundle": "^6.4 || ^7.0", - "symfony/service-contracts": "^2.5 || ^3" + "php": "^8.4", + "symfony/cache": "^6.4 || ^7.0 || ^8.0", + "symfony/config": "^6.4 || ^7.0 || ^8.0", + "symfony/console": "^6.4 || ^7.0 || ^8.0", + "symfony/dependency-injection": "^6.4 || ^7.0 || ^8.0", + "symfony/doctrine-bridge": "^6.4.3 || ^7.0.3 || ^8.0", + "symfony/framework-bundle": "^6.4 || ^7.0 || ^8.0", + "symfony/service-contracts": "^3" }, "conflict": { - "doctrine/annotations": ">=3.0", - "doctrine/cache": "< 1.11", - "doctrine/orm": "<2.17 || >=4.0", - "symfony/var-exporter": "< 6.4.1 || 7.0.0", - "twig/twig": "<2.13 || >=3.0 <3.0.4" + "doctrine/orm": "<3.0 || >=4.0", + "twig/twig": "<3.0.4" }, "require-dev": { - "doctrine/annotations": "^1 || ^2", - "doctrine/cache": "^1.11 || ^2.0", "doctrine/coding-standard": "^14", - "doctrine/orm": "^2.17 || ^3.1", - "friendsofphp/proxy-manager-lts": "^1.0", + "doctrine/orm": "^3.4.4", "phpstan/phpstan": "2.1.1", "phpstan/phpstan-phpunit": "2.0.3", "phpstan/phpstan-strict-rules": "^2", - "phpunit/phpunit": "^10.5.53 || ^12.3.10", - "psr/log": "^1.1.4 || ^2.0 || ^3.0", - "symfony/doctrine-messenger": "^6.4 || ^7.0", - "symfony/expression-language": "^6.4 || ^7.0", - "symfony/messenger": "^6.4 || ^7.0", - "symfony/property-info": "^6.4 || ^7.0", - "symfony/security-bundle": "^6.4 || ^7.0", - "symfony/stopwatch": "^6.4 || ^7.0", - "symfony/string": "^6.4 || ^7.0", - "symfony/twig-bridge": "^6.4 || ^7.0", - "symfony/validator": "^6.4 || ^7.0", - "symfony/var-exporter": "^6.4.1 || ^7.0.1", - "symfony/web-profiler-bundle": "^6.4 || ^7.0", - "symfony/yaml": "^6.4 || ^7.0", - "twig/twig": "^2.14.7 || ^3.0.4" + "phpstan/phpstan-symfony": "^2.0", + "phpunit/phpunit": "^12.3.10", + "psr/log": "^3.0", + "symfony/doctrine-messenger": "^6.4 || ^7.0 || ^8.0", + "symfony/expression-language": "^6.4 || ^7.0 || ^8.0", + "symfony/messenger": "^6.4 || ^7.0 || ^8.0", + "symfony/property-info": "^6.4 || ^7.0 || ^8.0", + "symfony/security-bundle": "^6.4 || ^7.0 || ^8.0", + "symfony/stopwatch": "^6.4 || ^7.0 || ^8.0", + "symfony/string": "^6.4 || ^7.0 || ^8.0", + "symfony/twig-bridge": "^6.4 || ^7.0 || ^8.0", + "symfony/validator": "^6.4 || ^7.0 || ^8.0", + "symfony/web-profiler-bundle": "^6.4 || ^7.0 || ^8.0", + "symfony/yaml": "^6.4 || ^7.0 || ^8.0", + "twig/twig": "^3.21.1" }, "suggest": { "doctrine/orm": "The Doctrine ORM integration is optional in the bundle.", @@ -642,7 +636,7 @@ ], "support": { "issues": "https://github.com/doctrine/DoctrineBundle/issues", - "source": "https://github.com/doctrine/DoctrineBundle/tree/2.18.2" + "source": "https://github.com/doctrine/DoctrineBundle/tree/3.2.2" }, "funding": [ { @@ -658,44 +652,43 @@ "type": "tidelift" } ], - "time": "2025-12-20T21:35:32+00:00" + "time": "2025-12-24T12:24:29+00:00" }, { "name": "doctrine/doctrine-fixtures-bundle", - "version": "3.7.3", + "version": "4.3.1", "source": { "type": "git", "url": "https://github.com/doctrine/DoctrineFixturesBundle.git", - "reference": "4c3dfcc819ba2725a574f4286aa3f6459f582d5b" + "reference": "9e013ed10d49bf7746b07204d336384a7d9b5a4d" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/doctrine/DoctrineFixturesBundle/zipball/4c3dfcc819ba2725a574f4286aa3f6459f582d5b", - "reference": "4c3dfcc819ba2725a574f4286aa3f6459f582d5b", + "url": "https://api.github.com/repos/doctrine/DoctrineFixturesBundle/zipball/9e013ed10d49bf7746b07204d336384a7d9b5a4d", + "reference": "9e013ed10d49bf7746b07204d336384a7d9b5a4d", "shasum": "" }, "require": { - "doctrine/data-fixtures": "^1.5 || ^2.0", - "doctrine/doctrine-bundle": "^2.2", + "doctrine/data-fixtures": "^2.2", + "doctrine/doctrine-bundle": "^2.2 || ^3.0", "doctrine/orm": "^2.14.0 || ^3.0", - "doctrine/persistence": "^2.4 || ^3.0 || ^4", - "php": "^7.4 || ^8.0", - "psr/log": "^1 || ^2 || ^3", - "symfony/config": "^5.4 || ^6.0 || ^7.0", - "symfony/console": "^5.4 || ^6.0 || ^7.0", - "symfony/dependency-injection": "^5.4 || ^6.0 || ^7.0", + "doctrine/persistence": "^2.4 || ^3.0 || ^4.0", + "php": "^8.1", + "psr/log": "^2 || ^3", + "symfony/config": "^6.4 || ^7.0 || ^8.0", + "symfony/console": "^6.4 || ^7.0 || ^8.0", + "symfony/dependency-injection": "^6.4 || ^7.0 || ^8.0", "symfony/deprecation-contracts": "^2.1 || ^3", - "symfony/doctrine-bridge": "^5.4.48 || ^6.4.16 || ^7.1.9", - "symfony/http-kernel": "^5.4 || ^6.0 || ^7.0" + "symfony/doctrine-bridge": "^6.4.16 || ^7.1.9 || ^8.0", + "symfony/http-kernel": "^6.4 || ^7.0 || ^8.0" }, "conflict": { "doctrine/dbal": "< 3" }, "require-dev": { "doctrine/coding-standard": "14.0.0", - "phpstan/phpstan": "2.1.32", - "phpunit/phpunit": "^9.6.13 || 11.4.14", - "symfony/phpunit-bridge": "7.3.4" + "phpstan/phpstan": "2.1.11", + "phpunit/phpunit": "^10.5.38 || 11.4.14" }, "type": "symfony-bundle", "autoload": { @@ -729,7 +722,7 @@ ], "support": { "issues": "https://github.com/doctrine/DoctrineFixturesBundle/issues", - "source": "https://github.com/doctrine/DoctrineFixturesBundle/tree/3.7.3" + "source": "https://github.com/doctrine/DoctrineFixturesBundle/tree/4.3.1" }, "funding": [ { @@ -745,41 +738,48 @@ "type": "tidelift" } ], - "time": "2025-12-03T15:47:21+00:00" + "time": "2025-12-03T16:05:42+00:00" }, { "name": "doctrine/doctrine-migrations-bundle", - "version": "3.7.0", + "version": "4.0.0", "source": { "type": "git", "url": "https://github.com/doctrine/DoctrineMigrationsBundle.git", - "reference": "1e380c6dd8ac8488217f39cff6b77e367f1a644b" + "reference": "20505da78735744fb4a42a3bb9a416b345ad6f7c" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/doctrine/DoctrineMigrationsBundle/zipball/1e380c6dd8ac8488217f39cff6b77e367f1a644b", - "reference": "1e380c6dd8ac8488217f39cff6b77e367f1a644b", + "url": "https://api.github.com/repos/doctrine/DoctrineMigrationsBundle/zipball/20505da78735744fb4a42a3bb9a416b345ad6f7c", + "reference": "20505da78735744fb4a42a3bb9a416b345ad6f7c", "shasum": "" }, "require": { - "doctrine/doctrine-bundle": "^2.4 || ^3.0", + "doctrine/dbal": "^4", + "doctrine/doctrine-bundle": "^3", "doctrine/migrations": "^3.2", - "php": "^7.2 || ^8.0", - "symfony/deprecation-contracts": "^2.1 || ^3", - "symfony/framework-bundle": "^5.4 || ^6.0 || ^7.0 || ^8.0" + "php": "^8.4", + "psr/log": "^3", + "symfony/config": "^6.4 || ^7.0 || ^8.0", + "symfony/console": "^6.4 || ^7.0 || ^8.0", + "symfony/dependency-injection": "^6.4 || ^7.0 || ^8.0", + "symfony/deprecation-contracts": "^3", + "symfony/framework-bundle": "^6.4 || ^7.0 || ^8.0", + "symfony/http-foundation": "^6.4 || ^7.0 || ^8.0", + "symfony/http-kernel": "^6.4 || ^7.0 || ^8.0", + "symfony/service-contracts": "^3.0" }, "require-dev": { "composer/semver": "^3.0", - "doctrine/coding-standard": "^12 || ^14", - "doctrine/orm": "^2.6 || ^3", - "phpstan/phpstan": "^1.4 || ^2", - "phpstan/phpstan-deprecation-rules": "^1 || ^2", - "phpstan/phpstan-phpunit": "^1 || ^2", - "phpstan/phpstan-strict-rules": "^1.1 || ^2", - "phpstan/phpstan-symfony": "^1.3 || ^2", - "phpunit/phpunit": "^8.5 || ^9.5", - "symfony/phpunit-bridge": "^6.3 || ^7 || ^8", - "symfony/var-exporter": "^5.4 || ^6 || ^7 || ^8" + "doctrine/coding-standard": "^14", + "doctrine/orm": "^3", + "phpstan/phpstan": "^2", + "phpstan/phpstan-deprecation-rules": "^2", + "phpstan/phpstan-phpunit": "^2", + "phpstan/phpstan-strict-rules": "^2", + "phpstan/phpstan-symfony": "^2", + "phpunit/phpunit": "^12.5", + "symfony/var-exporter": "^6.4 || ^7 || ^8" }, "type": "symfony-bundle", "autoload": { @@ -814,7 +814,7 @@ ], "support": { "issues": "https://github.com/doctrine/DoctrineMigrationsBundle/issues", - "source": "https://github.com/doctrine/DoctrineMigrationsBundle/tree/3.7.0" + "source": "https://github.com/doctrine/DoctrineMigrationsBundle/tree/4.0.0" }, "funding": [ { @@ -830,20 +830,20 @@ "type": "tidelift" } ], - "time": "2025-11-15T19:02:59+00:00" + "time": "2025-12-05T08:14:38+00:00" }, { "name": "doctrine/event-manager", - "version": "2.1.0", + "version": "2.1.1", "source": { "type": "git", "url": "https://github.com/doctrine/event-manager.git", - "reference": "c07799fcf5ad362050960a0fd068dded40b1e312" + "reference": "dda33921b198841ca8dbad2eaa5d4d34769d18cf" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/doctrine/event-manager/zipball/c07799fcf5ad362050960a0fd068dded40b1e312", - "reference": "c07799fcf5ad362050960a0fd068dded40b1e312", + "url": "https://api.github.com/repos/doctrine/event-manager/zipball/dda33921b198841ca8dbad2eaa5d4d34769d18cf", + "reference": "dda33921b198841ca8dbad2eaa5d4d34769d18cf", "shasum": "" }, "require": { @@ -905,7 +905,7 @@ ], "support": { "issues": "https://github.com/doctrine/event-manager/issues", - "source": "https://github.com/doctrine/event-manager/tree/2.1.0" + "source": "https://github.com/doctrine/event-manager/tree/2.1.1" }, "funding": [ { @@ -921,7 +921,7 @@ "type": "tidelift" } ], - "time": "2026-01-17T22:40:21+00:00" + "time": "2026-01-29T07:11:08+00:00" }, { "name": "doctrine/inflector", @@ -1264,16 +1264,16 @@ }, { "name": "doctrine/orm", - "version": "3.6.1", + "version": "3.6.2", "source": { "type": "git", "url": "https://github.com/doctrine/orm.git", - "reference": "2148940290e4c44b9101095707e71fb590832fa5" + "reference": "4262eb495b4d2a53b45de1ac58881e0091f2970f" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/doctrine/orm/zipball/2148940290e4c44b9101095707e71fb590832fa5", - "reference": "2148940290e4c44b9101095707e71fb590832fa5", + "url": "https://api.github.com/repos/doctrine/orm/zipball/4262eb495b4d2a53b45de1ac58881e0091f2970f", + "reference": "4262eb495b4d2a53b45de1ac58881e0091f2970f", "shasum": "" }, "require": { @@ -1346,9 +1346,9 @@ ], "support": { "issues": "https://github.com/doctrine/orm/issues", - "source": "https://github.com/doctrine/orm/tree/3.6.1" + "source": "https://github.com/doctrine/orm/tree/3.6.2" }, - "time": "2026-01-09T05:28:15+00:00" + "time": "2026-01-30T21:41:41+00:00" }, { "name": "doctrine/persistence", @@ -2608,16 +2608,16 @@ }, { "name": "phpstan/phpdoc-parser", - "version": "2.3.1", + "version": "2.3.2", "source": { "type": "git", "url": "https://github.com/phpstan/phpdoc-parser.git", - "reference": "16dbf9937da8d4528ceb2145c9c7c0bd29e26374" + "reference": "a004701b11273a26cd7955a61d67a7f1e525a45a" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/phpstan/phpdoc-parser/zipball/16dbf9937da8d4528ceb2145c9c7c0bd29e26374", - "reference": "16dbf9937da8d4528ceb2145c9c7c0bd29e26374", + "url": "https://api.github.com/repos/phpstan/phpdoc-parser/zipball/a004701b11273a26cd7955a61d67a7f1e525a45a", + "reference": "a004701b11273a26cd7955a61d67a7f1e525a45a", "shasum": "" }, "require": { @@ -2649,9 +2649,9 @@ "description": "PHPDoc parser with support for nullable, intersection and generic types", "support": { "issues": "https://github.com/phpstan/phpdoc-parser/issues", - "source": "https://github.com/phpstan/phpdoc-parser/tree/2.3.1" + "source": "https://github.com/phpstan/phpdoc-parser/tree/2.3.2" }, - "time": "2026-01-12T11:33:04+00:00" + "time": "2026-01-25T14:56:51+00:00" }, { "name": "psr/cache", @@ -3363,16 +3363,16 @@ }, { "name": "symfony/asset", - "version": "v7.4.0", + "version": "v7.4.4", "source": { "type": "git", "url": "https://github.com/symfony/asset.git", - "reference": "0f7bccb9ffa1f373cbd659774d90629b2773464f" + "reference": "a6f49cf087a1fcfe7130b9b604a8a2b878b06c40" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/asset/zipball/0f7bccb9ffa1f373cbd659774d90629b2773464f", - "reference": "0f7bccb9ffa1f373cbd659774d90629b2773464f", + "url": "https://api.github.com/repos/symfony/asset/zipball/a6f49cf087a1fcfe7130b9b604a8a2b878b06c40", + "reference": "a6f49cf087a1fcfe7130b9b604a8a2b878b06c40", "shasum": "" }, "require": { @@ -3412,7 +3412,7 @@ "description": "Manages URL generation and versioning of web assets such as CSS stylesheets, JavaScript files and image files", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/asset/tree/v7.4.0" + "source": "https://github.com/symfony/asset/tree/v7.4.4" }, "funding": [ { @@ -3432,20 +3432,20 @@ "type": "tidelift" } ], - "time": "2025-08-04T07:05:15+00:00" + "time": "2026-01-13T10:40:19+00:00" }, { "name": "symfony/asset-mapper", - "version": "v7.4.3", + "version": "v7.4.4", "source": { "type": "git", "url": "https://github.com/symfony/asset-mapper.git", - "reference": "1784eb8d4e8c6781046bcda7d68bcd99efecb722" + "reference": "251e308991ee2a03060eeed7be4a064ab30ac8dd" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/asset-mapper/zipball/1784eb8d4e8c6781046bcda7d68bcd99efecb722", - "reference": "1784eb8d4e8c6781046bcda7d68bcd99efecb722", + "url": "https://api.github.com/repos/symfony/asset-mapper/zipball/251e308991ee2a03060eeed7be4a064ab30ac8dd", + "reference": "251e308991ee2a03060eeed7be4a064ab30ac8dd", "shasum": "" }, "require": { @@ -3497,7 +3497,7 @@ "description": "Maps directories of assets & makes them available in a public directory with versioned filenames.", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/asset-mapper/tree/v7.4.3" + "source": "https://github.com/symfony/asset-mapper/tree/v7.4.4" }, "funding": [ { @@ -3517,20 +3517,20 @@ "type": "tidelift" } ], - "time": "2025-12-19T10:00:43+00:00" + "time": "2026-01-13T10:40:19+00:00" }, { "name": "symfony/cache", - "version": "v7.4.3", + "version": "v7.4.5", "source": { "type": "git", "url": "https://github.com/symfony/cache.git", - "reference": "642117d18bc56832e74b68235359ccefab03dd11" + "reference": "8dde98d5a4123b53877aca493f9be57b333f14bd" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/cache/zipball/642117d18bc56832e74b68235359ccefab03dd11", - "reference": "642117d18bc56832e74b68235359ccefab03dd11", + "url": "https://api.github.com/repos/symfony/cache/zipball/8dde98d5a4123b53877aca493f9be57b333f14bd", + "reference": "8dde98d5a4123b53877aca493f9be57b333f14bd", "shasum": "" }, "require": { @@ -3601,7 +3601,7 @@ "psr6" ], "support": { - "source": "https://github.com/symfony/cache/tree/v7.4.3" + "source": "https://github.com/symfony/cache/tree/v7.4.5" }, "funding": [ { @@ -3621,7 +3621,7 @@ "type": "tidelift" } ], - "time": "2025-12-28T10:45:24+00:00" + "time": "2026-01-27T16:16:02+00:00" }, { "name": "symfony/cache-contracts", @@ -3779,16 +3779,16 @@ }, { "name": "symfony/config", - "version": "v7.4.3", + "version": "v7.4.4", "source": { "type": "git", "url": "https://github.com/symfony/config.git", - "reference": "800ce889e358a53a9678b3212b0c8cecd8c6aace" + "reference": "4275b53b8ab0cf37f48bf273dc2285c8178efdfb" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/config/zipball/800ce889e358a53a9678b3212b0c8cecd8c6aace", - "reference": "800ce889e358a53a9678b3212b0c8cecd8c6aace", + "url": "https://api.github.com/repos/symfony/config/zipball/4275b53b8ab0cf37f48bf273dc2285c8178efdfb", + "reference": "4275b53b8ab0cf37f48bf273dc2285c8178efdfb", "shasum": "" }, "require": { @@ -3834,7 +3834,7 @@ "description": "Helps you find, load, combine, autofill and validate configuration values of any kind", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/config/tree/v7.4.3" + "source": "https://github.com/symfony/config/tree/v7.4.4" }, "funding": [ { @@ -3854,20 +3854,20 @@ "type": "tidelift" } ], - "time": "2025-12-23T14:24:27+00:00" + "time": "2026-01-13T11:36:38+00:00" }, { "name": "symfony/console", - "version": "v7.4.3", + "version": "v7.4.4", "source": { "type": "git", "url": "https://github.com/symfony/console.git", - "reference": "732a9ca6cd9dfd940c639062d5edbde2f6727fb6" + "reference": "41e38717ac1dd7a46b6bda7d6a82af2d98a78894" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/console/zipball/732a9ca6cd9dfd940c639062d5edbde2f6727fb6", - "reference": "732a9ca6cd9dfd940c639062d5edbde2f6727fb6", + "url": "https://api.github.com/repos/symfony/console/zipball/41e38717ac1dd7a46b6bda7d6a82af2d98a78894", + "reference": "41e38717ac1dd7a46b6bda7d6a82af2d98a78894", "shasum": "" }, "require": { @@ -3932,7 +3932,7 @@ "terminal" ], "support": { - "source": "https://github.com/symfony/console/tree/v7.4.3" + "source": "https://github.com/symfony/console/tree/v7.4.4" }, "funding": [ { @@ -3952,20 +3952,20 @@ "type": "tidelift" } ], - "time": "2025-12-23T14:50:43+00:00" + "time": "2026-01-13T11:36:38+00:00" }, { "name": "symfony/dependency-injection", - "version": "v7.4.3", + "version": "v7.4.5", "source": { "type": "git", "url": "https://github.com/symfony/dependency-injection.git", - "reference": "54122901b6d772e94f1e71a75e0533bc16563499" + "reference": "76a02cddca45a5254479ad68f9fa274ead0a7ef2" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/dependency-injection/zipball/54122901b6d772e94f1e71a75e0533bc16563499", - "reference": "54122901b6d772e94f1e71a75e0533bc16563499", + "url": "https://api.github.com/repos/symfony/dependency-injection/zipball/76a02cddca45a5254479ad68f9fa274ead0a7ef2", + "reference": "76a02cddca45a5254479ad68f9fa274ead0a7ef2", "shasum": "" }, "require": { @@ -4016,7 +4016,7 @@ "description": "Allows you to standardize and centralize the way objects are constructed in your application", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/dependency-injection/tree/v7.4.3" + "source": "https://github.com/symfony/dependency-injection/tree/v7.4.5" }, "funding": [ { @@ -4036,7 +4036,7 @@ "type": "tidelift" } ], - "time": "2025-12-28T10:55:46+00:00" + "time": "2026-01-27T16:16:02+00:00" }, { "name": "symfony/deprecation-contracts", @@ -4107,16 +4107,16 @@ }, { "name": "symfony/doctrine-bridge", - "version": "v7.4.3", + "version": "v7.4.4", "source": { "type": "git", "url": "https://github.com/symfony/doctrine-bridge.git", - "reference": "bd338ba08f5c47fe77e0a15e85ec3c5d070f9ceb" + "reference": "3408d9fb7bda6c8db9f3e4099863c9017bcbc62d" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/doctrine-bridge/zipball/bd338ba08f5c47fe77e0a15e85ec3c5d070f9ceb", - "reference": "bd338ba08f5c47fe77e0a15e85ec3c5d070f9ceb", + "url": "https://api.github.com/repos/symfony/doctrine-bridge/zipball/3408d9fb7bda6c8db9f3e4099863c9017bcbc62d", + "reference": "3408d9fb7bda6c8db9f3e4099863c9017bcbc62d", "shasum": "" }, "require": { @@ -4196,7 +4196,7 @@ "description": "Provides integration for Doctrine with various Symfony components", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/doctrine-bridge/tree/v7.4.3" + "source": "https://github.com/symfony/doctrine-bridge/tree/v7.4.4" }, "funding": [ { @@ -4216,7 +4216,7 @@ "type": "tidelift" } ], - "time": "2025-12-22T13:47:05+00:00" + "time": "2026-01-20T16:42:42+00:00" }, { "name": "symfony/dotenv", @@ -4298,16 +4298,16 @@ }, { "name": "symfony/error-handler", - "version": "v7.4.0", + "version": "v7.4.4", "source": { "type": "git", "url": "https://github.com/symfony/error-handler.git", - "reference": "48be2b0653594eea32dcef130cca1c811dcf25c2" + "reference": "8da531f364ddfee53e36092a7eebbbd0b775f6b8" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/error-handler/zipball/48be2b0653594eea32dcef130cca1c811dcf25c2", - "reference": "48be2b0653594eea32dcef130cca1c811dcf25c2", + "url": "https://api.github.com/repos/symfony/error-handler/zipball/8da531f364ddfee53e36092a7eebbbd0b775f6b8", + "reference": "8da531f364ddfee53e36092a7eebbbd0b775f6b8", "shasum": "" }, "require": { @@ -4356,7 +4356,7 @@ "description": "Provides tools to manage errors and ease debugging PHP code", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/error-handler/tree/v7.4.0" + "source": "https://github.com/symfony/error-handler/tree/v7.4.4" }, "funding": [ { @@ -4376,20 +4376,20 @@ "type": "tidelift" } ], - "time": "2025-11-05T14:29:59+00:00" + "time": "2026-01-20T16:42:42+00:00" }, { "name": "symfony/event-dispatcher", - "version": "v7.4.0", + "version": "v7.4.4", "source": { "type": "git", "url": "https://github.com/symfony/event-dispatcher.git", - "reference": "9dddcddff1ef974ad87b3708e4b442dc38b2261d" + "reference": "dc2c0eba1af673e736bb851d747d266108aea746" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/event-dispatcher/zipball/9dddcddff1ef974ad87b3708e4b442dc38b2261d", - "reference": "9dddcddff1ef974ad87b3708e4b442dc38b2261d", + "url": "https://api.github.com/repos/symfony/event-dispatcher/zipball/dc2c0eba1af673e736bb851d747d266108aea746", + "reference": "dc2c0eba1af673e736bb851d747d266108aea746", "shasum": "" }, "require": { @@ -4441,7 +4441,7 @@ "description": "Provides tools that allow your application components to communicate with each other by dispatching events and listening to them", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/event-dispatcher/tree/v7.4.0" + "source": "https://github.com/symfony/event-dispatcher/tree/v7.4.4" }, "funding": [ { @@ -4461,7 +4461,7 @@ "type": "tidelift" } ], - "time": "2025-10-28T09:38:46+00:00" + "time": "2026-01-05T11:45:34+00:00" }, { "name": "symfony/event-dispatcher-contracts", @@ -4541,16 +4541,16 @@ }, { "name": "symfony/expression-language", - "version": "v7.4.0", + "version": "v7.4.4", "source": { "type": "git", "url": "https://github.com/symfony/expression-language.git", - "reference": "8b9bbbb8c71f79a09638f6ea77c531e511139efa" + "reference": "f3a6497eb6573e185f2ec41cd3b3f0cd68ddf667" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/expression-language/zipball/8b9bbbb8c71f79a09638f6ea77c531e511139efa", - "reference": "8b9bbbb8c71f79a09638f6ea77c531e511139efa", + "url": "https://api.github.com/repos/symfony/expression-language/zipball/f3a6497eb6573e185f2ec41cd3b3f0cd68ddf667", + "reference": "f3a6497eb6573e185f2ec41cd3b3f0cd68ddf667", "shasum": "" }, "require": { @@ -4585,7 +4585,7 @@ "description": "Provides an engine that can compile and evaluate expressions", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/expression-language/tree/v7.4.0" + "source": "https://github.com/symfony/expression-language/tree/v7.4.4" }, "funding": [ { @@ -4605,7 +4605,7 @@ "type": "tidelift" } ], - "time": "2025-11-12T15:39:26+00:00" + "time": "2026-01-05T08:47:25+00:00" }, { "name": "symfony/filesystem", @@ -4679,16 +4679,16 @@ }, { "name": "symfony/finder", - "version": "v7.4.3", + "version": "v7.4.5", "source": { "type": "git", "url": "https://github.com/symfony/finder.git", - "reference": "fffe05569336549b20a1be64250b40516d6e8d06" + "reference": "ad4daa7c38668dcb031e63bc99ea9bd42196a2cb" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/finder/zipball/fffe05569336549b20a1be64250b40516d6e8d06", - "reference": "fffe05569336549b20a1be64250b40516d6e8d06", + "url": "https://api.github.com/repos/symfony/finder/zipball/ad4daa7c38668dcb031e63bc99ea9bd42196a2cb", + "reference": "ad4daa7c38668dcb031e63bc99ea9bd42196a2cb", "shasum": "" }, "require": { @@ -4723,7 +4723,7 @@ "description": "Finds files and directories via an intuitive fluent interface", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/finder/tree/v7.4.3" + "source": "https://github.com/symfony/finder/tree/v7.4.5" }, "funding": [ { @@ -4743,7 +4743,7 @@ "type": "tidelift" } ], - "time": "2025-12-23T14:50:43+00:00" + "time": "2026-01-26T15:07:59+00:00" }, { "name": "symfony/flex", @@ -4820,16 +4820,16 @@ }, { "name": "symfony/form", - "version": "v7.4.3", + "version": "v7.4.4", "source": { "type": "git", "url": "https://github.com/symfony/form.git", - "reference": "f7e147d3e57198122568f17909bc85266b0b2592" + "reference": "264fc873f01376216f0b884ecc81b34b830e25a8" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/form/zipball/f7e147d3e57198122568f17909bc85266b0b2592", - "reference": "f7e147d3e57198122568f17909bc85266b0b2592", + "url": "https://api.github.com/repos/symfony/form/zipball/264fc873f01376216f0b884ecc81b34b830e25a8", + "reference": "264fc873f01376216f0b884ecc81b34b830e25a8", "shasum": "" }, "require": { @@ -4899,7 +4899,7 @@ "description": "Allows to easily create, process and reuse HTML forms", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/form/tree/v7.4.3" + "source": "https://github.com/symfony/form/tree/v7.4.4" }, "funding": [ { @@ -4919,20 +4919,20 @@ "type": "tidelift" } ], - "time": "2025-12-23T14:50:43+00:00" + "time": "2026-01-23T10:51:15+00:00" }, { "name": "symfony/framework-bundle", - "version": "v7.4.3", + "version": "v7.4.5", "source": { "type": "git", "url": "https://github.com/symfony/framework-bundle.git", - "reference": "df908e8f9e5f6cc3c9e0d0172e030a5c1c280582" + "reference": "dcf89ca6712d9e1b5d3f14dea0e1c2685a05d1cd" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/framework-bundle/zipball/df908e8f9e5f6cc3c9e0d0172e030a5c1c280582", - "reference": "df908e8f9e5f6cc3c9e0d0172e030a5c1c280582", + "url": "https://api.github.com/repos/symfony/framework-bundle/zipball/dcf89ca6712d9e1b5d3f14dea0e1c2685a05d1cd", + "reference": "dcf89ca6712d9e1b5d3f14dea0e1c2685a05d1cd", "shasum": "" }, "require": { @@ -4940,8 +4940,8 @@ "ext-xml": "*", "php": ">=8.2", "symfony/cache": "^6.4.12|^7.0|^8.0", - "symfony/config": "^7.4.3|^8.0.3", - "symfony/dependency-injection": "^7.4|^8.0", + "symfony/config": "^7.4.4|^8.0.4", + "symfony/dependency-injection": "^7.4.4|^8.0.4", "symfony/deprecation-contracts": "^2.5|^3", "symfony/error-handler": "^7.3|^8.0", "symfony/event-dispatcher": "^6.4|^7.0|^8.0", @@ -4955,8 +4955,8 @@ }, "conflict": { "doctrine/persistence": "<1.3", - "phpdocumentor/reflection-docblock": "<3.2.2", - "phpdocumentor/type-resolver": "<1.4.0", + "phpdocumentor/reflection-docblock": "<5.2|>=6", + "phpdocumentor/type-resolver": "<1.5.1", "symfony/asset": "<6.4", "symfony/asset-mapper": "<6.4", "symfony/clock": "<6.4", @@ -4988,7 +4988,7 @@ "require-dev": { "doctrine/persistence": "^1.3|^2|^3", "dragonmantank/cron-expression": "^3.1", - "phpdocumentor/reflection-docblock": "^3.0|^4.0|^5.0", + "phpdocumentor/reflection-docblock": "^5.2", "seld/jsonlint": "^1.10", "symfony/asset": "^6.4|^7.0|^8.0", "symfony/asset-mapper": "^6.4|^7.0|^8.0", @@ -5057,7 +5057,7 @@ "description": "Provides a tight integration between Symfony components and the Symfony full-stack framework", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/framework-bundle/tree/v7.4.3" + "source": "https://github.com/symfony/framework-bundle/tree/v7.4.5" }, "funding": [ { @@ -5077,20 +5077,20 @@ "type": "tidelift" } ], - "time": "2025-12-29T09:31:36+00:00" + "time": "2026-01-27T08:59:58+00:00" }, { "name": "symfony/http-client", - "version": "v7.4.3", + "version": "v7.4.5", "source": { "type": "git", "url": "https://github.com/symfony/http-client.git", - "reference": "d01dfac1e0dc99f18da48b18101c23ce57929616" + "reference": "84bb634857a893cc146cceb467e31b3f02c5fe9f" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/http-client/zipball/d01dfac1e0dc99f18da48b18101c23ce57929616", - "reference": "d01dfac1e0dc99f18da48b18101c23ce57929616", + "url": "https://api.github.com/repos/symfony/http-client/zipball/84bb634857a893cc146cceb467e31b3f02c5fe9f", + "reference": "84bb634857a893cc146cceb467e31b3f02c5fe9f", "shasum": "" }, "require": { @@ -5158,7 +5158,7 @@ "http" ], "support": { - "source": "https://github.com/symfony/http-client/tree/v7.4.3" + "source": "https://github.com/symfony/http-client/tree/v7.4.5" }, "funding": [ { @@ -5178,7 +5178,7 @@ "type": "tidelift" } ], - "time": "2025-12-23T14:50:43+00:00" + "time": "2026-01-27T16:16:02+00:00" }, { "name": "symfony/http-client-contracts", @@ -5260,16 +5260,16 @@ }, { "name": "symfony/http-foundation", - "version": "v7.4.3", + "version": "v7.4.5", "source": { "type": "git", "url": "https://github.com/symfony/http-foundation.git", - "reference": "a70c745d4cea48dbd609f4075e5f5cbce453bd52" + "reference": "446d0db2b1f21575f1284b74533e425096abdfb6" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/http-foundation/zipball/a70c745d4cea48dbd609f4075e5f5cbce453bd52", - "reference": "a70c745d4cea48dbd609f4075e5f5cbce453bd52", + "url": "https://api.github.com/repos/symfony/http-foundation/zipball/446d0db2b1f21575f1284b74533e425096abdfb6", + "reference": "446d0db2b1f21575f1284b74533e425096abdfb6", "shasum": "" }, "require": { @@ -5318,7 +5318,7 @@ "description": "Defines an object-oriented layer for the HTTP specification", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/http-foundation/tree/v7.4.3" + "source": "https://github.com/symfony/http-foundation/tree/v7.4.5" }, "funding": [ { @@ -5338,20 +5338,20 @@ "type": "tidelift" } ], - "time": "2025-12-23T14:23:49+00:00" + "time": "2026-01-27T16:16:02+00:00" }, { "name": "symfony/http-kernel", - "version": "v7.4.3", + "version": "v7.4.5", "source": { "type": "git", "url": "https://github.com/symfony/http-kernel.git", - "reference": "885211d4bed3f857b8c964011923528a55702aa5" + "reference": "229eda477017f92bd2ce7615d06222ec0c19e82a" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/http-kernel/zipball/885211d4bed3f857b8c964011923528a55702aa5", - "reference": "885211d4bed3f857b8c964011923528a55702aa5", + "url": "https://api.github.com/repos/symfony/http-kernel/zipball/229eda477017f92bd2ce7615d06222ec0c19e82a", + "reference": "229eda477017f92bd2ce7615d06222ec0c19e82a", "shasum": "" }, "require": { @@ -5437,7 +5437,7 @@ "description": "Provides a structured process for converting a Request into a Response", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/http-kernel/tree/v7.4.3" + "source": "https://github.com/symfony/http-kernel/tree/v7.4.5" }, "funding": [ { @@ -5457,20 +5457,20 @@ "type": "tidelift" } ], - "time": "2025-12-31T08:43:57+00:00" + "time": "2026-01-28T10:33:42+00:00" }, { "name": "symfony/intl", - "version": "v7.4.0", + "version": "v7.4.4", "source": { "type": "git", "url": "https://github.com/symfony/intl.git", - "reference": "2fa074de6c7faa6b54f2891fc22708f42245ed5c" + "reference": "7fa2d46174166bcd7829abc8717949f8a0b21fb7" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/intl/zipball/2fa074de6c7faa6b54f2891fc22708f42245ed5c", - "reference": "2fa074de6c7faa6b54f2891fc22708f42245ed5c", + "url": "https://api.github.com/repos/symfony/intl/zipball/7fa2d46174166bcd7829abc8717949f8a0b21fb7", + "reference": "7fa2d46174166bcd7829abc8717949f8a0b21fb7", "shasum": "" }, "require": { @@ -5527,7 +5527,7 @@ "localization" ], "support": { - "source": "https://github.com/symfony/intl/tree/v7.4.0" + "source": "https://github.com/symfony/intl/tree/v7.4.4" }, "funding": [ { @@ -5547,20 +5547,20 @@ "type": "tidelift" } ], - "time": "2025-11-27T13:27:24+00:00" + "time": "2026-01-12T12:19:02+00:00" }, { "name": "symfony/mailer", - "version": "v7.4.3", + "version": "v7.4.4", "source": { "type": "git", "url": "https://github.com/symfony/mailer.git", - "reference": "e472d35e230108231ccb7f51eb6b2100cac02ee4" + "reference": "7b750074c40c694ceb34cb926d6dffee231c5cd6" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/mailer/zipball/e472d35e230108231ccb7f51eb6b2100cac02ee4", - "reference": "e472d35e230108231ccb7f51eb6b2100cac02ee4", + "url": "https://api.github.com/repos/symfony/mailer/zipball/7b750074c40c694ceb34cb926d6dffee231c5cd6", + "reference": "7b750074c40c694ceb34cb926d6dffee231c5cd6", "shasum": "" }, "require": { @@ -5611,7 +5611,7 @@ "description": "Helps sending emails", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/mailer/tree/v7.4.3" + "source": "https://github.com/symfony/mailer/tree/v7.4.4" }, "funding": [ { @@ -5631,20 +5631,20 @@ "type": "tidelift" } ], - "time": "2025-12-16T08:02:06+00:00" + "time": "2026-01-08T08:25:11+00:00" }, { "name": "symfony/mime", - "version": "v7.4.0", + "version": "v7.4.5", "source": { "type": "git", "url": "https://github.com/symfony/mime.git", - "reference": "bdb02729471be5d047a3ac4a69068748f1a6be7a" + "reference": "b18c7e6e9eee1e19958138df10412f3c4c316148" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/mime/zipball/bdb02729471be5d047a3ac4a69068748f1a6be7a", - "reference": "bdb02729471be5d047a3ac4a69068748f1a6be7a", + "url": "https://api.github.com/repos/symfony/mime/zipball/b18c7e6e9eee1e19958138df10412f3c4c316148", + "reference": "b18c7e6e9eee1e19958138df10412f3c4c316148", "shasum": "" }, "require": { @@ -5655,15 +5655,15 @@ }, "conflict": { "egulias/email-validator": "~3.0.0", - "phpdocumentor/reflection-docblock": "<3.2.2", - "phpdocumentor/type-resolver": "<1.4.0", + "phpdocumentor/reflection-docblock": "<5.2|>=6", + "phpdocumentor/type-resolver": "<1.5.1", "symfony/mailer": "<6.4", "symfony/serializer": "<6.4.3|>7.0,<7.0.3" }, "require-dev": { "egulias/email-validator": "^2.1.10|^3.1|^4", "league/html-to-markdown": "^5.0", - "phpdocumentor/reflection-docblock": "^3.0|^4.0|^5.0", + "phpdocumentor/reflection-docblock": "^5.2", "symfony/dependency-injection": "^6.4|^7.0|^8.0", "symfony/process": "^6.4|^7.0|^8.0", "symfony/property-access": "^6.4|^7.0|^8.0", @@ -5700,7 +5700,7 @@ "mime-type" ], "support": { - "source": "https://github.com/symfony/mime/tree/v7.4.0" + "source": "https://github.com/symfony/mime/tree/v7.4.5" }, "funding": [ { @@ -5720,20 +5720,20 @@ "type": "tidelift" } ], - "time": "2025-11-16T10:14:42+00:00" + "time": "2026-01-27T08:59:58+00:00" }, { "name": "symfony/monolog-bridge", - "version": "v7.4.0", + "version": "v7.4.4", "source": { "type": "git", "url": "https://github.com/symfony/monolog-bridge.git", - "reference": "189d16466ff83d9c51fad26382bf0beeb41bda21" + "reference": "9c34e8170b09f062a9a38880a3cb58ee35cb7fd4" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/monolog-bridge/zipball/189d16466ff83d9c51fad26382bf0beeb41bda21", - "reference": "189d16466ff83d9c51fad26382bf0beeb41bda21", + "url": "https://api.github.com/repos/symfony/monolog-bridge/zipball/9c34e8170b09f062a9a38880a3cb58ee35cb7fd4", + "reference": "9c34e8170b09f062a9a38880a3cb58ee35cb7fd4", "shasum": "" }, "require": { @@ -5783,7 +5783,7 @@ "description": "Provides integration for Monolog with various Symfony components", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/monolog-bridge/tree/v7.4.0" + "source": "https://github.com/symfony/monolog-bridge/tree/v7.4.4" }, "funding": [ { @@ -5803,7 +5803,7 @@ "type": "tidelift" } ], - "time": "2025-11-01T09:17:33+00:00" + "time": "2026-01-07T11:35:36+00:00" }, { "name": "symfony/monolog-bundle", @@ -5958,16 +5958,16 @@ }, { "name": "symfony/password-hasher", - "version": "v7.4.0", + "version": "v7.4.4", "source": { "type": "git", "url": "https://github.com/symfony/password-hasher.git", - "reference": "aa075ce6f54fe931f03c1e382597912f4fd94e1e" + "reference": "ab8e0ef42483f31c417c82ecfcf7be7b91d784fe" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/password-hasher/zipball/aa075ce6f54fe931f03c1e382597912f4fd94e1e", - "reference": "aa075ce6f54fe931f03c1e382597912f4fd94e1e", + "url": "https://api.github.com/repos/symfony/password-hasher/zipball/ab8e0ef42483f31c417c82ecfcf7be7b91d784fe", + "reference": "ab8e0ef42483f31c417c82ecfcf7be7b91d784fe", "shasum": "" }, "require": { @@ -6010,7 +6010,7 @@ "password" ], "support": { - "source": "https://github.com/symfony/password-hasher/tree/v7.4.0" + "source": "https://github.com/symfony/password-hasher/tree/v7.4.4" }, "funding": [ { @@ -6030,7 +6030,7 @@ "type": "tidelift" } ], - "time": "2025-08-13T16:46:49+00:00" + "time": "2026-01-01T22:13:48+00:00" }, { "name": "symfony/polyfill-intl-grapheme", @@ -6459,166 +6459,6 @@ ], "time": "2024-12-23T08:48:59+00:00" }, - { - "name": "symfony/polyfill-php83", - "version": "v1.33.0", - "source": { - "type": "git", - "url": "https://github.com/symfony/polyfill-php83.git", - "reference": "17f6f9a6b1735c0f163024d959f700cfbc5155e5" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-php83/zipball/17f6f9a6b1735c0f163024d959f700cfbc5155e5", - "reference": "17f6f9a6b1735c0f163024d959f700cfbc5155e5", - "shasum": "" - }, - "require": { - "php": ">=7.2" - }, - "type": "library", - "extra": { - "thanks": { - "url": "https://github.com/symfony/polyfill", - "name": "symfony/polyfill" - } - }, - "autoload": { - "files": [ - "bootstrap.php" - ], - "psr-4": { - "Symfony\\Polyfill\\Php83\\": "" - }, - "classmap": [ - "Resources/stubs" - ] - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "Nicolas Grekas", - "email": "p@tchwork.com" - }, - { - "name": "Symfony Community", - "homepage": "https://symfony.com/contributors" - } - ], - "description": "Symfony polyfill backporting some PHP 8.3+ features to lower PHP versions", - "homepage": "https://symfony.com", - "keywords": [ - "compatibility", - "polyfill", - "portable", - "shim" - ], - "support": { - "source": "https://github.com/symfony/polyfill-php83/tree/v1.33.0" - }, - "funding": [ - { - "url": "https://symfony.com/sponsor", - "type": "custom" - }, - { - "url": "https://github.com/fabpot", - "type": "github" - }, - { - "url": "https://github.com/nicolas-grekas", - "type": "github" - }, - { - "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", - "type": "tidelift" - } - ], - "time": "2025-07-08T02:45:35+00:00" - }, - { - "name": "symfony/polyfill-php84", - "version": "v1.33.0", - "source": { - "type": "git", - "url": "https://github.com/symfony/polyfill-php84.git", - "reference": "d8ced4d875142b6a7426000426b8abc631d6b191" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-php84/zipball/d8ced4d875142b6a7426000426b8abc631d6b191", - "reference": "d8ced4d875142b6a7426000426b8abc631d6b191", - "shasum": "" - }, - "require": { - "php": ">=7.2" - }, - "type": "library", - "extra": { - "thanks": { - "url": "https://github.com/symfony/polyfill", - "name": "symfony/polyfill" - } - }, - "autoload": { - "files": [ - "bootstrap.php" - ], - "psr-4": { - "Symfony\\Polyfill\\Php84\\": "" - }, - "classmap": [ - "Resources/stubs" - ] - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "Nicolas Grekas", - "email": "p@tchwork.com" - }, - { - "name": "Symfony Community", - "homepage": "https://symfony.com/contributors" - } - ], - "description": "Symfony polyfill backporting some PHP 8.4+ features to lower PHP versions", - "homepage": "https://symfony.com", - "keywords": [ - "compatibility", - "polyfill", - "portable", - "shim" - ], - "support": { - "source": "https://github.com/symfony/polyfill-php84/tree/v1.33.0" - }, - "funding": [ - { - "url": "https://symfony.com/sponsor", - "type": "custom" - }, - { - "url": "https://github.com/fabpot", - "type": "github" - }, - { - "url": "https://github.com/nicolas-grekas", - "type": "github" - }, - { - "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", - "type": "tidelift" - } - ], - "time": "2025-06-24T13:30:11+00:00" - }, { "name": "symfony/polyfill-php85", "version": "v1.33.0", @@ -6784,16 +6624,16 @@ }, { "name": "symfony/process", - "version": "v7.4.3", + "version": "v7.4.5", "source": { "type": "git", "url": "https://github.com/symfony/process.git", - "reference": "2f8e1a6cdf590ca63715da4d3a7a3327404a523f" + "reference": "608476f4604102976d687c483ac63a79ba18cc97" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/process/zipball/2f8e1a6cdf590ca63715da4d3a7a3327404a523f", - "reference": "2f8e1a6cdf590ca63715da4d3a7a3327404a523f", + "url": "https://api.github.com/repos/symfony/process/zipball/608476f4604102976d687c483ac63a79ba18cc97", + "reference": "608476f4604102976d687c483ac63a79ba18cc97", "shasum": "" }, "require": { @@ -6825,7 +6665,7 @@ "description": "Executes commands in sub-processes", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/process/tree/v7.4.3" + "source": "https://github.com/symfony/process/tree/v7.4.5" }, "funding": [ { @@ -6845,25 +6685,25 @@ "type": "tidelift" } ], - "time": "2025-12-19T10:00:43+00:00" + "time": "2026-01-26T15:07:59+00:00" }, { "name": "symfony/property-access", - "version": "v7.4.3", + "version": "v7.4.4", "source": { "type": "git", "url": "https://github.com/symfony/property-access.git", - "reference": "30aff8455647be949fc2e8fcef2847d5a6743c98" + "reference": "fa49bf1ca8fce1ba0e2dba4e4658554cfb9364b1" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/property-access/zipball/30aff8455647be949fc2e8fcef2847d5a6743c98", - "reference": "30aff8455647be949fc2e8fcef2847d5a6743c98", + "url": "https://api.github.com/repos/symfony/property-access/zipball/fa49bf1ca8fce1ba0e2dba4e4658554cfb9364b1", + "reference": "fa49bf1ca8fce1ba0e2dba4e4658554cfb9364b1", "shasum": "" }, "require": { "php": ">=8.2", - "symfony/property-info": "^6.4.31|~7.3.9|^7.4.2|^8.0.3" + "symfony/property-info": "^6.4.32|~7.3.10|^7.4.4|^8.0.4" }, "require-dev": { "symfony/cache": "^6.4|^7.0|^8.0", @@ -6906,7 +6746,7 @@ "reflection" ], "support": { - "source": "https://github.com/symfony/property-access/tree/v7.4.3" + "source": "https://github.com/symfony/property-access/tree/v7.4.4" }, "funding": [ { @@ -6926,30 +6766,30 @@ "type": "tidelift" } ], - "time": "2025-12-18T10:35:58+00:00" + "time": "2026-01-05T08:47:25+00:00" }, { "name": "symfony/property-info", - "version": "v7.4.3", + "version": "v7.4.5", "source": { "type": "git", "url": "https://github.com/symfony/property-info.git", - "reference": "ea62b28cd68fb36e252abd77de61e505a0f2a7b1" + "reference": "1c9d326bd69602561e2ea467a16c09b5972eee21" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/property-info/zipball/ea62b28cd68fb36e252abd77de61e505a0f2a7b1", - "reference": "ea62b28cd68fb36e252abd77de61e505a0f2a7b1", + "url": "https://api.github.com/repos/symfony/property-info/zipball/1c9d326bd69602561e2ea467a16c09b5972eee21", + "reference": "1c9d326bd69602561e2ea467a16c09b5972eee21", "shasum": "" }, "require": { "php": ">=8.2", "symfony/deprecation-contracts": "^2.5|^3", "symfony/string": "^6.4|^7.0|^8.0", - "symfony/type-info": "~7.3.8|^7.4.1|^8.0.1" + "symfony/type-info": "~7.3.10|^7.4.4|^8.0.4" }, "conflict": { - "phpdocumentor/reflection-docblock": "<5.2", + "phpdocumentor/reflection-docblock": "<5.2|>=6", "phpdocumentor/type-resolver": "<1.5.1", "symfony/cache": "<6.4", "symfony/dependency-injection": "<6.4", @@ -6996,7 +6836,7 @@ "validator" ], "support": { - "source": "https://github.com/symfony/property-info/tree/v7.4.3" + "source": "https://github.com/symfony/property-info/tree/v7.4.5" }, "funding": [ { @@ -7016,20 +6856,20 @@ "type": "tidelift" } ], - "time": "2025-12-18T08:28:41+00:00" + "time": "2026-01-27T16:16:02+00:00" }, { "name": "symfony/routing", - "version": "v7.4.3", + "version": "v7.4.4", "source": { "type": "git", "url": "https://github.com/symfony/routing.git", - "reference": "5d3fd7adf8896c2fdb54e2f0f35b1bcbd9e45090" + "reference": "0798827fe2c79caeed41d70b680c2c3507d10147" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/routing/zipball/5d3fd7adf8896c2fdb54e2f0f35b1bcbd9e45090", - "reference": "5d3fd7adf8896c2fdb54e2f0f35b1bcbd9e45090", + "url": "https://api.github.com/repos/symfony/routing/zipball/0798827fe2c79caeed41d70b680c2c3507d10147", + "reference": "0798827fe2c79caeed41d70b680c2c3507d10147", "shasum": "" }, "require": { @@ -7081,7 +6921,7 @@ "url" ], "support": { - "source": "https://github.com/symfony/routing/tree/v7.4.3" + "source": "https://github.com/symfony/routing/tree/v7.4.4" }, "funding": [ { @@ -7101,7 +6941,7 @@ "type": "tidelift" } ], - "time": "2025-12-19T10:00:43+00:00" + "time": "2026-01-12T12:19:02+00:00" }, { "name": "symfony/runtime", @@ -7188,16 +7028,16 @@ }, { "name": "symfony/security-bundle", - "version": "v7.4.0", + "version": "v7.4.4", "source": { "type": "git", "url": "https://github.com/symfony/security-bundle.git", - "reference": "48a64e746857464a5e8fd7bab84b31c9ba967eb9" + "reference": "7281b644c76985ddf3927f5e65152b0cc29d175b" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/security-bundle/zipball/48a64e746857464a5e8fd7bab84b31c9ba967eb9", - "reference": "48a64e746857464a5e8fd7bab84b31c9ba967eb9", + "url": "https://api.github.com/repos/symfony/security-bundle/zipball/7281b644c76985ddf3927f5e65152b0cc29d175b", + "reference": "7281b644c76985ddf3927f5e65152b0cc29d175b", "shasum": "" }, "require": { @@ -7276,7 +7116,7 @@ "description": "Provides a tight integration of the Security component into the Symfony full-stack framework", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/security-bundle/tree/v7.4.0" + "source": "https://github.com/symfony/security-bundle/tree/v7.4.4" }, "funding": [ { @@ -7296,20 +7136,20 @@ "type": "tidelift" } ], - "time": "2025-11-14T09:57:20+00:00" + "time": "2026-01-10T13:56:23+00:00" }, { "name": "symfony/security-core", - "version": "v7.4.3", + "version": "v7.4.4", "source": { "type": "git", "url": "https://github.com/symfony/security-core.git", - "reference": "be0b8585f2d69b48a9b1a6372aa48d23c7e7eeb4" + "reference": "958a70725a8d669bec6721f4cd318d209712e944" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/security-core/zipball/be0b8585f2d69b48a9b1a6372aa48d23c7e7eeb4", - "reference": "be0b8585f2d69b48a9b1a6372aa48d23c7e7eeb4", + "url": "https://api.github.com/repos/symfony/security-core/zipball/958a70725a8d669bec6721f4cd318d209712e944", + "reference": "958a70725a8d669bec6721f4cd318d209712e944", "shasum": "" }, "require": { @@ -7367,7 +7207,7 @@ "description": "Symfony Security Component - Core Library", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/security-core/tree/v7.4.3" + "source": "https://github.com/symfony/security-core/tree/v7.4.4" }, "funding": [ { @@ -7387,20 +7227,20 @@ "type": "tidelift" } ], - "time": "2025-12-19T23:18:26+00:00" + "time": "2026-01-14T09:36:49+00:00" }, { "name": "symfony/security-csrf", - "version": "v7.4.3", + "version": "v7.4.4", "source": { "type": "git", "url": "https://github.com/symfony/security-csrf.git", - "reference": "d526fa61963d926e91c9fb22edf829d9f8793dfe" + "reference": "06a2a2f90f355b8b4ec23685fa6ceff8d5dc41cc" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/security-csrf/zipball/d526fa61963d926e91c9fb22edf829d9f8793dfe", - "reference": "d526fa61963d926e91c9fb22edf829d9f8793dfe", + "url": "https://api.github.com/repos/symfony/security-csrf/zipball/06a2a2f90f355b8b4ec23685fa6ceff8d5dc41cc", + "reference": "06a2a2f90f355b8b4ec23685fa6ceff8d5dc41cc", "shasum": "" }, "require": { @@ -7441,7 +7281,7 @@ "description": "Symfony Security Component - CSRF Library", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/security-csrf/tree/v7.4.3" + "source": "https://github.com/symfony/security-csrf/tree/v7.4.4" }, "funding": [ { @@ -7461,20 +7301,20 @@ "type": "tidelift" } ], - "time": "2025-12-23T15:24:11+00:00" + "time": "2026-01-14T10:11:16+00:00" }, { "name": "symfony/security-http", - "version": "v7.4.3", + "version": "v7.4.4", "source": { "type": "git", "url": "https://github.com/symfony/security-http.git", - "reference": "72f3b3fa9f322c9579d5246895a09f945cc33e36" + "reference": "9d41a473637bf5d074c5f5a73177fd9d769407fd" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/security-http/zipball/72f3b3fa9f322c9579d5246895a09f945cc33e36", - "reference": "72f3b3fa9f322c9579d5246895a09f945cc33e36", + "url": "https://api.github.com/repos/symfony/security-http/zipball/9d41a473637bf5d074c5f5a73177fd9d769407fd", + "reference": "9d41a473637bf5d074c5f5a73177fd9d769407fd", "shasum": "" }, "require": { @@ -7533,7 +7373,7 @@ "description": "Symfony Security Component - HTTP Integration", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/security-http/tree/v7.4.3" + "source": "https://github.com/symfony/security-http/tree/v7.4.4" }, "funding": [ { @@ -7553,20 +7393,20 @@ "type": "tidelift" } ], - "time": "2025-12-19T23:18:26+00:00" + "time": "2026-01-14T10:11:16+00:00" }, { "name": "symfony/serializer", - "version": "v7.4.3", + "version": "v7.4.5", "source": { "type": "git", "url": "https://github.com/symfony/serializer.git", - "reference": "af01e99d6fc63549063fb9e849ce1240cfef5c4a" + "reference": "480cd1237c98ab1219c20945b92c9d4480a44f47" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/serializer/zipball/af01e99d6fc63549063fb9e849ce1240cfef5c4a", - "reference": "af01e99d6fc63549063fb9e849ce1240cfef5c4a", + "url": "https://api.github.com/repos/symfony/serializer/zipball/480cd1237c98ab1219c20945b92c9d4480a44f47", + "reference": "480cd1237c98ab1219c20945b92c9d4480a44f47", "shasum": "" }, "require": { @@ -7576,8 +7416,8 @@ "symfony/polyfill-php84": "^1.30" }, "conflict": { - "phpdocumentor/reflection-docblock": "<3.2.2", - "phpdocumentor/type-resolver": "<1.4.0", + "phpdocumentor/reflection-docblock": "<5.2|>=6", + "phpdocumentor/type-resolver": "<1.5.1", "symfony/dependency-injection": "<6.4", "symfony/property-access": "<6.4", "symfony/property-info": "<6.4", @@ -7586,7 +7426,7 @@ "symfony/yaml": "<6.4" }, "require-dev": { - "phpdocumentor/reflection-docblock": "^3.2|^4.0|^5.0", + "phpdocumentor/reflection-docblock": "^5.2", "phpstan/phpdoc-parser": "^1.0|^2.0", "seld/jsonlint": "^1.10", "symfony/cache": "^6.4|^7.0|^8.0", @@ -7636,7 +7476,7 @@ "description": "Handles serializing and deserializing data structures, including object graphs, into array structures or other formats like XML and JSON.", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/serializer/tree/v7.4.3" + "source": "https://github.com/symfony/serializer/tree/v7.4.5" }, "funding": [ { @@ -7656,7 +7496,7 @@ "type": "tidelift" } ], - "time": "2025-12-23T14:50:43+00:00" + "time": "2026-01-27T08:59:58+00:00" }, { "name": "symfony/service-contracts", @@ -7886,16 +7726,16 @@ }, { "name": "symfony/string", - "version": "v7.4.0", + "version": "v7.4.4", "source": { "type": "git", "url": "https://github.com/symfony/string.git", - "reference": "d50e862cb0a0e0886f73ca1f31b865efbb795003" + "reference": "1c4b10461bf2ec27537b5f36105337262f5f5d6f" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/string/zipball/d50e862cb0a0e0886f73ca1f31b865efbb795003", - "reference": "d50e862cb0a0e0886f73ca1f31b865efbb795003", + "url": "https://api.github.com/repos/symfony/string/zipball/1c4b10461bf2ec27537b5f36105337262f5f5d6f", + "reference": "1c4b10461bf2ec27537b5f36105337262f5f5d6f", "shasum": "" }, "require": { @@ -7953,7 +7793,7 @@ "utf8" ], "support": { - "source": "https://github.com/symfony/string/tree/v7.4.0" + "source": "https://github.com/symfony/string/tree/v7.4.4" }, "funding": [ { @@ -7973,20 +7813,20 @@ "type": "tidelift" } ], - "time": "2025-11-27T13:27:24+00:00" + "time": "2026-01-12T10:54:30+00:00" }, { "name": "symfony/translation", - "version": "v7.4.3", + "version": "v7.4.4", "source": { "type": "git", "url": "https://github.com/symfony/translation.git", - "reference": "7ef27c65d78886f7599fdd5c93d12c9243ecf44d" + "reference": "bfde13711f53f549e73b06d27b35a55207528877" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/translation/zipball/7ef27c65d78886f7599fdd5c93d12c9243ecf44d", - "reference": "7ef27c65d78886f7599fdd5c93d12c9243ecf44d", + "url": "https://api.github.com/repos/symfony/translation/zipball/bfde13711f53f549e73b06d27b35a55207528877", + "reference": "bfde13711f53f549e73b06d27b35a55207528877", "shasum": "" }, "require": { @@ -8053,7 +7893,7 @@ "description": "Provides tools to internationalize your application", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/translation/tree/v7.4.3" + "source": "https://github.com/symfony/translation/tree/v7.4.4" }, "funding": [ { @@ -8073,7 +7913,7 @@ "type": "tidelift" } ], - "time": "2025-12-29T09:31:36+00:00" + "time": "2026-01-13T10:40:19+00:00" }, { "name": "symfony/translation-contracts", @@ -8159,16 +7999,16 @@ }, { "name": "symfony/twig-bridge", - "version": "v7.4.3", + "version": "v7.4.5", "source": { "type": "git", "url": "https://github.com/symfony/twig-bridge.git", - "reference": "43c922fce020060c65b0fd54bfd8def3b38949b6" + "reference": "f2dd26b604e856476ef7e0efa4568bc07eb7ddc8" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/twig-bridge/zipball/43c922fce020060c65b0fd54bfd8def3b38949b6", - "reference": "43c922fce020060c65b0fd54bfd8def3b38949b6", + "url": "https://api.github.com/repos/symfony/twig-bridge/zipball/f2dd26b604e856476ef7e0efa4568bc07eb7ddc8", + "reference": "f2dd26b604e856476ef7e0efa4568bc07eb7ddc8", "shasum": "" }, "require": { @@ -8178,10 +8018,10 @@ "twig/twig": "^3.21" }, "conflict": { - "phpdocumentor/reflection-docblock": "<3.2.2", - "phpdocumentor/type-resolver": "<1.4.0", + "phpdocumentor/reflection-docblock": "<5.2|>=6", + "phpdocumentor/type-resolver": "<1.5.1", "symfony/console": "<6.4", - "symfony/form": "<6.4", + "symfony/form": "<6.4.32|>7,<7.3.10|>7.4,<7.4.4|>8.0,<8.0.4", "symfony/http-foundation": "<6.4", "symfony/http-kernel": "<6.4", "symfony/mime": "<6.4", @@ -8192,7 +8032,7 @@ "require-dev": { "egulias/email-validator": "^2.1.10|^3|^4", "league/html-to-markdown": "^5.0", - "phpdocumentor/reflection-docblock": "^3.0|^4.0|^5.0", + "phpdocumentor/reflection-docblock": "^5.2", "symfony/asset": "^6.4|^7.0|^8.0", "symfony/asset-mapper": "^6.4|^7.0|^8.0", "symfony/console": "^6.4|^7.0|^8.0", @@ -8200,7 +8040,7 @@ "symfony/emoji": "^7.1|^8.0", "symfony/expression-language": "^6.4|^7.0|^8.0", "symfony/finder": "^6.4|^7.0|^8.0", - "symfony/form": "^6.4.30|~7.3.8|^7.4.1|^8.0.1", + "symfony/form": "^6.4.32|~7.3.10|^7.4.4|^8.0.4", "symfony/html-sanitizer": "^6.4|^7.0|^8.0", "symfony/http-foundation": "^7.3|^8.0", "symfony/http-kernel": "^6.4|^7.0|^8.0", @@ -8250,7 +8090,7 @@ "description": "Provides integration for Twig with various Symfony components", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/twig-bridge/tree/v7.4.3" + "source": "https://github.com/symfony/twig-bridge/tree/v7.4.5" }, "funding": [ { @@ -8270,20 +8110,20 @@ "type": "tidelift" } ], - "time": "2025-12-16T08:02:06+00:00" + "time": "2026-01-27T08:59:58+00:00" }, { "name": "symfony/twig-bundle", - "version": "v7.4.3", + "version": "v7.4.4", "source": { "type": "git", "url": "https://github.com/symfony/twig-bundle.git", - "reference": "9e1f5fd2668ed26c60d17d63f15fe270ed8da5e6" + "reference": "e8829e02ff96a391ed0703bac9e7ff0537480b6b" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/twig-bundle/zipball/9e1f5fd2668ed26c60d17d63f15fe270ed8da5e6", - "reference": "9e1f5fd2668ed26c60d17d63f15fe270ed8da5e6", + "url": "https://api.github.com/repos/symfony/twig-bundle/zipball/e8829e02ff96a391ed0703bac9e7ff0537480b6b", + "reference": "e8829e02ff96a391ed0703bac9e7ff0537480b6b", "shasum": "" }, "require": { @@ -8340,7 +8180,7 @@ "description": "Provides a tight integration of Twig into the Symfony full-stack framework", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/twig-bundle/tree/v7.4.3" + "source": "https://github.com/symfony/twig-bundle/tree/v7.4.4" }, "funding": [ { @@ -8360,20 +8200,20 @@ "type": "tidelift" } ], - "time": "2025-12-19T10:00:43+00:00" + "time": "2026-01-06T12:34:24+00:00" }, { "name": "symfony/type-info", - "version": "v7.4.1", + "version": "v7.4.4", "source": { "type": "git", "url": "https://github.com/symfony/type-info.git", - "reference": "ac5ab66b21c758df71b7210cf1033d1ac807f202" + "reference": "f83c725e72b39b2704b9d6fc85070ad6ac7a5889" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/type-info/zipball/ac5ab66b21c758df71b7210cf1033d1ac807f202", - "reference": "ac5ab66b21c758df71b7210cf1033d1ac807f202", + "url": "https://api.github.com/repos/symfony/type-info/zipball/f83c725e72b39b2704b9d6fc85070ad6ac7a5889", + "reference": "f83c725e72b39b2704b9d6fc85070ad6ac7a5889", "shasum": "" }, "require": { @@ -8423,7 +8263,7 @@ "type" ], "support": { - "source": "https://github.com/symfony/type-info/tree/v7.4.1" + "source": "https://github.com/symfony/type-info/tree/v7.4.4" }, "funding": [ { @@ -8443,20 +8283,20 @@ "type": "tidelift" } ], - "time": "2025-12-05T14:04:53+00:00" + "time": "2026-01-09T12:14:21+00:00" }, { "name": "symfony/uid", - "version": "v7.4.0", + "version": "v7.4.4", "source": { "type": "git", "url": "https://github.com/symfony/uid.git", - "reference": "2498e9f81b7baa206f44de583f2f48350b90142c" + "reference": "7719ce8aba76be93dfe249192f1fbfa52c588e36" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/uid/zipball/2498e9f81b7baa206f44de583f2f48350b90142c", - "reference": "2498e9f81b7baa206f44de583f2f48350b90142c", + "url": "https://api.github.com/repos/symfony/uid/zipball/7719ce8aba76be93dfe249192f1fbfa52c588e36", + "reference": "7719ce8aba76be93dfe249192f1fbfa52c588e36", "shasum": "" }, "require": { @@ -8501,7 +8341,7 @@ "uuid" ], "support": { - "source": "https://github.com/symfony/uid/tree/v7.4.0" + "source": "https://github.com/symfony/uid/tree/v7.4.4" }, "funding": [ { @@ -8521,7 +8361,7 @@ "type": "tidelift" } ], - "time": "2025-09-25T11:02:55+00:00" + "time": "2026-01-03T23:30:35+00:00" }, { "name": "symfony/ux-turbo", @@ -8628,16 +8468,16 @@ }, { "name": "symfony/validator", - "version": "v7.4.3", + "version": "v7.4.5", "source": { "type": "git", "url": "https://github.com/symfony/validator.git", - "reference": "9670bedf4c454b21d1e04606b6c227990da8bebe" + "reference": "fcec92c40df1c93507857da08226005573b655c6" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/validator/zipball/9670bedf4c454b21d1e04606b6c227990da8bebe", - "reference": "9670bedf4c454b21d1e04606b6c227990da8bebe", + "url": "https://api.github.com/repos/symfony/validator/zipball/fcec92c40df1c93507857da08226005573b655c6", + "reference": "fcec92c40df1c93507857da08226005573b655c6", "shasum": "" }, "require": { @@ -8708,7 +8548,7 @@ "description": "Provides tools to validate values", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/validator/tree/v7.4.3" + "source": "https://github.com/symfony/validator/tree/v7.4.5" }, "funding": [ { @@ -8728,20 +8568,20 @@ "type": "tidelift" } ], - "time": "2025-12-27T17:05:22+00:00" + "time": "2026-01-27T08:59:58+00:00" }, { "name": "symfony/var-dumper", - "version": "v7.4.3", + "version": "v7.4.4", "source": { "type": "git", "url": "https://github.com/symfony/var-dumper.git", - "reference": "7e99bebcb3f90d8721890f2963463280848cba92" + "reference": "0e4769b46a0c3c62390d124635ce59f66874b282" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/var-dumper/zipball/7e99bebcb3f90d8721890f2963463280848cba92", - "reference": "7e99bebcb3f90d8721890f2963463280848cba92", + "url": "https://api.github.com/repos/symfony/var-dumper/zipball/0e4769b46a0c3c62390d124635ce59f66874b282", + "reference": "0e4769b46a0c3c62390d124635ce59f66874b282", "shasum": "" }, "require": { @@ -8795,7 +8635,7 @@ "dump" ], "support": { - "source": "https://github.com/symfony/var-dumper/tree/v7.4.3" + "source": "https://github.com/symfony/var-dumper/tree/v7.4.4" }, "funding": [ { @@ -8815,7 +8655,7 @@ "type": "tidelift" } ], - "time": "2025-12-18T07:04:31+00:00" + "time": "2026-01-01T22:13:48+00:00" }, { "name": "symfony/var-exporter", @@ -8900,16 +8740,16 @@ }, { "name": "symfony/web-link", - "version": "v7.4.0", + "version": "v7.4.4", "source": { "type": "git", "url": "https://github.com/symfony/web-link.git", - "reference": "c62edd6b52e31cf2f6f38fd3386725f364f19942" + "reference": "9ff1f19069e3d2d341d60729392a4a6dfc45052a" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/web-link/zipball/c62edd6b52e31cf2f6f38fd3386725f364f19942", - "reference": "c62edd6b52e31cf2f6f38fd3386725f364f19942", + "url": "https://api.github.com/repos/symfony/web-link/zipball/9ff1f19069e3d2d341d60729392a4a6dfc45052a", + "reference": "9ff1f19069e3d2d341d60729392a4a6dfc45052a", "shasum": "" }, "require": { @@ -8963,7 +8803,7 @@ "push" ], "support": { - "source": "https://github.com/symfony/web-link/tree/v7.4.0" + "source": "https://github.com/symfony/web-link/tree/v7.4.4" }, "funding": [ { @@ -8983,7 +8823,7 @@ "type": "tidelift" } ], - "time": "2025-08-04T07:05:15+00:00" + "time": "2026-01-01T22:13:48+00:00" }, { "name": "symfony/yaml", @@ -9110,16 +8950,16 @@ }, { "name": "twig/twig", - "version": "v3.22.2", + "version": "v3.23.0", "source": { "type": "git", "url": "https://github.com/twigphp/Twig.git", - "reference": "946ddeafa3c9f4ce279d1f34051af041db0e16f2" + "reference": "a64dc5d2cc7d6cafb9347f6cd802d0d06d0351c9" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/twigphp/Twig/zipball/946ddeafa3c9f4ce279d1f34051af041db0e16f2", - "reference": "946ddeafa3c9f4ce279d1f34051af041db0e16f2", + "url": "https://api.github.com/repos/twigphp/Twig/zipball/a64dc5d2cc7d6cafb9347f6cd802d0d06d0351c9", + "reference": "a64dc5d2cc7d6cafb9347f6cd802d0d06d0351c9", "shasum": "" }, "require": { @@ -9173,7 +9013,7 @@ ], "support": { "issues": "https://github.com/twigphp/Twig/issues", - "source": "https://github.com/twigphp/Twig/tree/v3.22.2" + "source": "https://github.com/twigphp/Twig/tree/v3.23.0" }, "funding": [ { @@ -9185,7 +9025,7 @@ "type": "tidelift" } ], - "time": "2025-12-14T11:28:47+00:00" + "time": "2026-01-23T21:00:41+00:00" }, { "name": "web-auth/cose-lib", @@ -9944,16 +9784,16 @@ }, { "name": "phpunit/php-file-iterator", - "version": "6.0.0", + "version": "6.0.1", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/php-file-iterator.git", - "reference": "961bc913d42fe24a257bfff826a5068079ac7782" + "reference": "3d1cd096ef6bea4bf2762ba586e35dbd317cbfd5" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/php-file-iterator/zipball/961bc913d42fe24a257bfff826a5068079ac7782", - "reference": "961bc913d42fe24a257bfff826a5068079ac7782", + "url": "https://api.github.com/repos/sebastianbergmann/php-file-iterator/zipball/3d1cd096ef6bea4bf2762ba586e35dbd317cbfd5", + "reference": "3d1cd096ef6bea4bf2762ba586e35dbd317cbfd5", "shasum": "" }, "require": { @@ -9993,15 +9833,27 @@ "support": { "issues": "https://github.com/sebastianbergmann/php-file-iterator/issues", "security": "https://github.com/sebastianbergmann/php-file-iterator/security/policy", - "source": "https://github.com/sebastianbergmann/php-file-iterator/tree/6.0.0" + "source": "https://github.com/sebastianbergmann/php-file-iterator/tree/6.0.1" }, "funding": [ { "url": "https://github.com/sebastianbergmann", "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/phpunit/php-file-iterator", + "type": "tidelift" } ], - "time": "2025-02-07T04:58:37+00:00" + "time": "2026-02-02T14:04:18+00:00" }, { "name": "phpunit/php-invoker", @@ -10189,16 +10041,16 @@ }, { "name": "phpunit/phpunit", - "version": "12.5.6", + "version": "12.5.8", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/phpunit.git", - "reference": "ab8e4374264bc65523d1458d14bf80261577e01f" + "reference": "37ddb96c14bfee10304825edbb7e66d341ec6889" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/ab8e4374264bc65523d1458d14bf80261577e01f", - "reference": "ab8e4374264bc65523d1458d14bf80261577e01f", + "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/37ddb96c14bfee10304825edbb7e66d341ec6889", + "reference": "37ddb96c14bfee10304825edbb7e66d341ec6889", "shasum": "" }, "require": { @@ -10218,7 +10070,7 @@ "phpunit/php-text-template": "^5.0.0", "phpunit/php-timer": "^8.0.0", "sebastian/cli-parser": "^4.2.0", - "sebastian/comparator": "^7.1.3", + "sebastian/comparator": "^7.1.4", "sebastian/diff": "^7.0.0", "sebastian/environment": "^8.0.3", "sebastian/exporter": "^7.0.2", @@ -10266,7 +10118,7 @@ "support": { "issues": "https://github.com/sebastianbergmann/phpunit/issues", "security": "https://github.com/sebastianbergmann/phpunit/security/policy", - "source": "https://github.com/sebastianbergmann/phpunit/tree/12.5.6" + "source": "https://github.com/sebastianbergmann/phpunit/tree/12.5.8" }, "funding": [ { @@ -10290,7 +10142,7 @@ "type": "tidelift" } ], - "time": "2026-01-16T16:28:10+00:00" + "time": "2026-01-27T06:12:29+00:00" }, { "name": "rector/rector", @@ -10424,16 +10276,16 @@ }, { "name": "sebastian/comparator", - "version": "7.1.3", + "version": "7.1.4", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/comparator.git", - "reference": "dc904b4bb3ab070865fa4068cd84f3da8b945148" + "reference": "6a7de5df2e094f9a80b40a522391a7e6022df5f6" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/comparator/zipball/dc904b4bb3ab070865fa4068cd84f3da8b945148", - "reference": "dc904b4bb3ab070865fa4068cd84f3da8b945148", + "url": "https://api.github.com/repos/sebastianbergmann/comparator/zipball/6a7de5df2e094f9a80b40a522391a7e6022df5f6", + "reference": "6a7de5df2e094f9a80b40a522391a7e6022df5f6", "shasum": "" }, "require": { @@ -10492,7 +10344,7 @@ "support": { "issues": "https://github.com/sebastianbergmann/comparator/issues", "security": "https://github.com/sebastianbergmann/comparator/security/policy", - "source": "https://github.com/sebastianbergmann/comparator/tree/7.1.3" + "source": "https://github.com/sebastianbergmann/comparator/tree/7.1.4" }, "funding": [ { @@ -10512,7 +10364,7 @@ "type": "tidelift" } ], - "time": "2025-08-20T11:27:00+00:00" + "time": "2026-01-24T09:28:48+00:00" }, { "name": "sebastian/complexity", @@ -11304,16 +11156,16 @@ }, { "name": "symfony/browser-kit", - "version": "v7.4.3", + "version": "v7.4.4", "source": { "type": "git", "url": "https://github.com/symfony/browser-kit.git", - "reference": "d5b5c731005f224fbc25289587a8538e4f62c762" + "reference": "bed167eadaaba641f51fc842c9227aa5e251309e" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/browser-kit/zipball/d5b5c731005f224fbc25289587a8538e4f62c762", - "reference": "d5b5c731005f224fbc25289587a8538e4f62c762", + "url": "https://api.github.com/repos/symfony/browser-kit/zipball/bed167eadaaba641f51fc842c9227aa5e251309e", + "reference": "bed167eadaaba641f51fc842c9227aa5e251309e", "shasum": "" }, "require": { @@ -11353,7 +11205,7 @@ "description": "Simulates the behavior of a web browser, allowing you to make requests, click on links and submit forms programmatically", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/browser-kit/tree/v7.4.3" + "source": "https://github.com/symfony/browser-kit/tree/v7.4.4" }, "funding": [ { @@ -11373,7 +11225,7 @@ "type": "tidelift" } ], - "time": "2025-12-16T08:02:06+00:00" + "time": "2026-01-13T10:40:19+00:00" }, { "name": "symfony/css-selector", @@ -11521,16 +11373,16 @@ }, { "name": "symfony/dom-crawler", - "version": "v7.4.1", + "version": "v7.4.4", "source": { "type": "git", "url": "https://github.com/symfony/dom-crawler.git", - "reference": "0c5e8f20c74c78172a8ee72b125909b505033597" + "reference": "71fd6a82fc357c8b5de22f78b228acfc43dee965" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/dom-crawler/zipball/0c5e8f20c74c78172a8ee72b125909b505033597", - "reference": "0c5e8f20c74c78172a8ee72b125909b505033597", + "url": "https://api.github.com/repos/symfony/dom-crawler/zipball/71fd6a82fc357c8b5de22f78b228acfc43dee965", + "reference": "71fd6a82fc357c8b5de22f78b228acfc43dee965", "shasum": "" }, "require": { @@ -11569,7 +11421,7 @@ "description": "Eases DOM navigation for HTML and XML documents", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/dom-crawler/tree/v7.4.1" + "source": "https://github.com/symfony/dom-crawler/tree/v7.4.4" }, "funding": [ { @@ -11589,7 +11441,7 @@ "type": "tidelift" } ], - "time": "2025-12-06T15:47:47+00:00" + "time": "2026-01-05T08:47:25+00:00" }, { "name": "symfony/maker-bundle", @@ -11776,16 +11628,16 @@ }, { "name": "symfony/web-profiler-bundle", - "version": "v7.4.3", + "version": "v7.4.4", "source": { "type": "git", "url": "https://github.com/symfony/web-profiler-bundle.git", - "reference": "5220b59d06f6554658a0dc4d6bd4497a789e51dd" + "reference": "be165e29e6109efb89bfaefe56e3deccf72a8643" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/web-profiler-bundle/zipball/5220b59d06f6554658a0dc4d6bd4497a789e51dd", - "reference": "5220b59d06f6554658a0dc4d6bd4497a789e51dd", + "url": "https://api.github.com/repos/symfony/web-profiler-bundle/zipball/be165e29e6109efb89bfaefe56e3deccf72a8643", + "reference": "be165e29e6109efb89bfaefe56e3deccf72a8643", "shasum": "" }, "require": { @@ -11842,7 +11694,7 @@ "dev" ], "support": { - "source": "https://github.com/symfony/web-profiler-bundle/tree/v7.4.3" + "source": "https://github.com/symfony/web-profiler-bundle/tree/v7.4.4" }, "funding": [ { @@ -11862,7 +11714,7 @@ "type": "tidelift" } ], - "time": "2025-12-27T17:05:22+00:00" + "time": "2026-01-07T11:56:45+00:00" }, { "name": "theseer/tokenizer", diff --git a/config/packages/csrf.yaml b/config/packages/csrf.yaml new file mode 100644 index 00000000..40d40405 --- /dev/null +++ b/config/packages/csrf.yaml @@ -0,0 +1,11 @@ +# Enable stateless CSRF protection for forms and logins/logouts +framework: + form: + csrf_protection: + token_id: submit + + csrf_protection: + stateless_token_ids: + - submit + - authenticate + - logout diff --git a/config/packages/debug.yaml b/config/packages/debug.yaml new file mode 100644 index 00000000..ad874afd --- /dev/null +++ b/config/packages/debug.yaml @@ -0,0 +1,5 @@ +when@dev: + debug: + # Forwards VarDumper Data clones to a centralized server allowing to inspect dumps on CLI or in your browser. + # See the "server:dump" command to start a new server. + dump_destination: "tcp://%env(VAR_DUMPER_SERVER)%" diff --git a/config/packages/dev/debug.yaml b/config/packages/dev/debug.yaml deleted file mode 100644 index 26d4e53d..00000000 --- a/config/packages/dev/debug.yaml +++ /dev/null @@ -1,4 +0,0 @@ -debug: - # Forwards VarDumper Data clones to a centralized server allowing to inspect dumps on CLI or in your browser. - # See the "server:dump" command to start a new server. - dump_destination: "tcp://%env(VAR_DUMPER_SERVER)%" diff --git a/config/packages/doctrine.yaml b/config/packages/doctrine.yaml index b0b9a90f..8a82006f 100644 --- a/config/packages/doctrine.yaml +++ b/config/packages/doctrine.yaml @@ -5,7 +5,7 @@ parameters: # You should not need to change this value. env(DATABASE_URL): '' # define default server version when env variable is not defined - env(DB_SERVER_VERSION): '5.6' + env(DB_SERVER_VERSION): '8.0' doctrine: dbal: @@ -20,19 +20,13 @@ doctrine: charset: utf8mb4 collate: utf8mb4_unicode_ci url: '%env(resolve:DATABASE_URL)%' - use_savepoints: true + profiling_collect_backtrace: '%kernel.debug%' orm: - auto_generate_proxy_classes: '%kernel.debug%' - enable_lazy_ghost_objects: true - enable_native_lazy_objects: true default_entity_manager: default - controller_resolver: - auto_mapping: false entity_managers: default: connection: default naming_strategy: doctrine.orm.naming_strategy.underscore_number_aware - report_fields_where_declared: true validate_xml_mapping: true auto_mapping: true mappings: @@ -46,7 +40,6 @@ doctrine: background: connection: default naming_strategy: doctrine.orm.naming_strategy.underscore_number_aware - report_fields_where_declared: true validate_xml_mapping: true auto_mapping: false mappings: @@ -56,3 +49,48 @@ doctrine: dir: '%kernel.project_dir%/src/Entity' prefix: 'App\Entity' alias: App + +when@test: + doctrine: + dbal: + # "TEST_TOKEN" is typically set by ParaTest + dbname_suffix: '_test%env(default::TEST_TOKEN)%' + +when@prod: + doctrine: + orm: + proxy_dir: '%kernel.build_dir%/doctrine/orm/Proxies' + query_cache_driver: + type: pool + pool: doctrine.system_cache_pool + result_cache_driver: + type: pool + pool: doctrine.result_cache_pool + + framework: + cache: + pools: + doctrine.result_cache_pool: + adapter: cache.app + doctrine.system_cache_pool: + adapter: cache.system + + +when@redis: + doctrine: + orm: + proxy_dir: '%kernel.build_dir%/doctrine/orm/Proxies' + query_cache_driver: + type: pool + pool: doctrine.system_cache_pool + result_cache_driver: + type: pool + pool: doctrine.result_cache_pool + + framework: + cache: + pools: + doctrine.result_cache_pool: + adapter: cache.adapter.redis + doctrine.system_cache_pool: + adapter: cache.adapter.redis diff --git a/config/packages/monolog.yaml b/config/packages/monolog.yaml index 15939ea5..7807dd4d 100644 --- a/config/packages/monolog.yaml +++ b/config/packages/monolog.yaml @@ -10,14 +10,6 @@ when@dev: path: "%kernel.logs_dir%/%kernel.environment%.log" level: debug channels: ["!event"] - # uncomment to get logging in your browser - # you may have to allow bigger header sizes in your Web server configuration - #firephp: - # type: firephp - # level: info - #chromephp: - # type: chromephp - # level: info console: type: console process_psr_3_messages: false diff --git a/config/packages/prod/doctrine.yaml b/config/packages/prod/doctrine.yaml deleted file mode 100644 index 4f0b4473..00000000 --- a/config/packages/prod/doctrine.yaml +++ /dev/null @@ -1,19 +0,0 @@ -doctrine: - orm: - metadata_cache_driver: - type: pool - pool: doctrine.system_cache_pool - query_cache_driver: - type: pool - pool: doctrine.system_cache_pool - result_cache_driver: - type: pool - pool: doctrine.result_cache_pool - -framework: - cache: - pools: - doctrine.result_cache_pool: - adapter: cache.app - doctrine.system_cache_pool: - adapter: cache.system diff --git a/config/packages/redis/doctrine.yaml b/config/packages/redis/doctrine.yaml deleted file mode 100644 index f2767303..00000000 --- a/config/packages/redis/doctrine.yaml +++ /dev/null @@ -1,19 +0,0 @@ -doctrine: - orm: - metadata_cache_driver: - type: pool - pool: doctrine.system_cache_pool - query_cache_driver: - type: pool - pool: doctrine.system_cache_pool - result_cache_driver: - type: pool - pool: doctrine.result_cache_pool - -framework: - cache: - pools: - doctrine.result_cache_pool: - adapter: cache.adapter.redis - doctrine.system_cache_pool: - adapter: cache.adapter.redis diff --git a/config/packages/routing.yaml b/config/packages/routing.yaml index bd6702fd..0f34f872 100644 --- a/config/packages/routing.yaml +++ b/config/packages/routing.yaml @@ -1,9 +1,10 @@ -framework: - router: - utf8: true - -when@prod: - framework: - router: - strict_requirements: null - \ No newline at end of file +framework: + router: + # Configure how to generate URLs in non-HTTP contexts, such as CLI commands. + # See https://symfony.com/doc/current/routing.html#generating-urls-in-commands + default_uri: '%env(DEFAULT_URI)%' + +when@prod: + framework: + router: + strict_requirements: null diff --git a/config/packages/security.yaml b/config/packages/security.yaml index cc292395..154b85c7 100644 --- a/config/packages/security.yaml +++ b/config/packages/security.yaml @@ -1,7 +1,9 @@ security: + erase_credentials: false + # https://symfony.com/doc/current/security.html#registering-the-user-hashing-passwords password_hashers: - App\Entity\User: - algorithm: auto + Symfony\Component\Security\Core\User\PasswordAuthenticatedUserInterface: 'auto' + # https://symfony.com/doc/current/security.html#where-do-users-come-from-user-providers providers: our_db_provider: @@ -10,7 +12,7 @@ security: # property: username firewalls: dev: - pattern: ^/(_(profiler|wdt)|css|images|js)/ + pattern: ^/(_profiler|_wdt|assets|build)/ security: false main: #pattern: ^/ @@ -28,18 +30,10 @@ security: default_target_path: /dashboard logout: path: /logout - target: start + target: dashboard.redirect remember_me: secret: '%kernel.secret%' lazy: true - - # activate different ways to authenticate - - # http_basic: true - # https://symfony.com/doc/current/security.html#a-configuring-how-your-users-will-authenticate - - # form_login: true - # https://symfony.com/doc/current/security/form_login_setup.html role_hierarchy: ROLE_RESERVATIONS: ROLE_RESERVATIONS_RO @@ -50,6 +44,7 @@ security: - ROLE_REGISTRATIONBOOK - ROLE_STATISTICS - ROLE_CASHJOURNAL + - ROLE_HOUSEKEEPING # Easy way to control access for large sections of your site # Note: Only the *first* access control that matches will be used @@ -59,6 +54,9 @@ security: - { path: ^/register, roles: PUBLIC_ACCESS} - { path: ^/apartments/calendar, roles: PUBLIC_ACCESS } - { path: ^/reservation/*, roles: [ROLE_RESERVATIONS_RO, ROLE_RESERVATIONS] } + - { path: ^/operations/housekeeping, roles: ROLE_HOUSEKEEPING } + - { path: ^/operations/frontdesk, roles: ROLE_HOUSEKEEPING } + - { path: ^/operations/reports, roles: ROLE_HOUSEKEEPING } - { path: ^/customers, roles: ROLE_CUSTOMERS } - { path: ^/invoices, roles: ROLE_INVOICES } - { path: ^/registrationbook, roles: ROLE_REGISTRATIONBOOK } @@ -69,3 +67,14 @@ security: - { path: ^/logout, roles: IS_AUTHENTICATED } - { path: ^/dashboard, roles: IS_AUTHENTICATED } - { path: ^/, roles: IS_AUTHENTICATED } + +when@test: + security: + password_hashers: + # Password hashers are resource-intensive by design to ensure security. + # In tests, it's safe to reduce their cost to improve performance. + Symfony\Component\Security\Core\User\PasswordAuthenticatedUserInterface: + algorithm: auto + cost: 4 # Lowest possible value for bcrypt + time_cost: 3 # Lowest possible value for argon + memory_cost: 10 # Lowest possible value for argon \ No newline at end of file diff --git a/config/packages/test/twig.yaml b/config/packages/test/twig.yaml deleted file mode 100644 index 8c6e0b40..00000000 --- a/config/packages/test/twig.yaml +++ /dev/null @@ -1,2 +0,0 @@ -twig: - strict_variables: true diff --git a/config/packages/translation.yaml b/config/packages/translation.yaml index 2b8420d1..9201003d 100644 --- a/config/packages/translation.yaml +++ b/config/packages/translation.yaml @@ -20,3 +20,4 @@ framework: - '%kernel.project_dir%/translations/Users' - '%kernel.project_dir%/translations/RoomCategory' - '%kernel.project_dir%/translations/ReservationStatus' + - '%kernel.project_dir%/translations/Housekeeping' diff --git a/config/packages/twig.yaml b/config/packages/twig.yaml index 7d774b34..c2724fae 100644 --- a/config/packages/twig.yaml +++ b/config/packages/twig.yaml @@ -3,6 +3,7 @@ parameters: env(CUSTOMER_SALUTATIONS): 'Ms,Mr,Family' twig: + file_name_pattern: '*.twig' default_path: '%kernel.project_dir%/templates' debug: '%kernel.debug%' strict_variables: '%kernel.debug%' @@ -10,3 +11,7 @@ twig: globals: customer_salutations: '%customer_salutations%' passkey_enabled: '%passkey_enabled%' + +when@test: + twig: + strict_variables: true diff --git a/config/reference.php b/config/reference.php index b8af8b79..d72b4332 100644 --- a/config/reference.php +++ b/config/reference.php @@ -33,7 +33,7 @@ * type?: string|null, * ignore_errors?: bool, * }> - * @psalm-type ParametersConfig = array|null>|null> + * @psalm-type ParametersConfig = array|Param|null>|Param|null> * @psalm-type ArgumentsType = list|array * @psalm-type CallType = array|array{0:string, 1?:ArgumentsType, 2?:bool}|array{method:string, arguments?:ArgumentsType, returns_clone?:bool} * @psalm-type TagsType = list>> // arrays inside the list must have only one element, with the tag name as the key @@ -126,44 +126,44 @@ * } * @psalm-type ExtensionType = array * @psalm-type FrameworkConfig = array{ - * secret?: scalar|null|Param, + * secret?: scalar|Param|null, * http_method_override?: bool|Param, // Set true to enable support for the '_method' request parameter to determine the intended HTTP method on POST requests. // Default: false * allowed_http_method_override?: list|null, - * trust_x_sendfile_type_header?: scalar|null|Param, // Set true to enable support for xsendfile in binary file responses. // Default: "%env(bool:default::SYMFONY_TRUST_X_SENDFILE_TYPE_HEADER)%" - * ide?: scalar|null|Param, // Default: "%env(default::SYMFONY_IDE)%" + * trust_x_sendfile_type_header?: scalar|Param|null, // Set true to enable support for xsendfile in binary file responses. // Default: "%env(bool:default::SYMFONY_TRUST_X_SENDFILE_TYPE_HEADER)%" + * ide?: scalar|Param|null, // Default: "%env(default::SYMFONY_IDE)%" * test?: bool|Param, - * default_locale?: scalar|null|Param, // Default: "en" + * default_locale?: scalar|Param|null, // Default: "en" * set_locale_from_accept_language?: bool|Param, // Whether to use the Accept-Language HTTP header to set the Request locale (only when the "_locale" request attribute is not passed). // Default: false * set_content_language_from_locale?: bool|Param, // Whether to set the Content-Language HTTP header on the Response using the Request locale. // Default: false - * enabled_locales?: list, - * trusted_hosts?: list, + * enabled_locales?: list, + * trusted_hosts?: list, * trusted_proxies?: mixed, // Default: ["%env(default::SYMFONY_TRUSTED_PROXIES)%"] - * trusted_headers?: list, - * error_controller?: scalar|null|Param, // Default: "error_controller" + * trusted_headers?: list, + * error_controller?: scalar|Param|null, // Default: "error_controller" * handle_all_throwables?: bool|Param, // HttpKernel will handle all kinds of \Throwable. // Default: true * csrf_protection?: bool|array{ - * enabled?: scalar|null|Param, // Default: null - * stateless_token_ids?: list, - * check_header?: scalar|null|Param, // Whether to check the CSRF token in a header in addition to a cookie when using stateless protection. // Default: false - * cookie_name?: scalar|null|Param, // The name of the cookie to use when using stateless protection. // Default: "csrf-token" + * enabled?: scalar|Param|null, // Default: null + * stateless_token_ids?: list, + * check_header?: scalar|Param|null, // Whether to check the CSRF token in a header in addition to a cookie when using stateless protection. // Default: false + * cookie_name?: scalar|Param|null, // The name of the cookie to use when using stateless protection. // Default: "csrf-token" * }, * form?: bool|array{ // Form configuration * enabled?: bool|Param, // Default: true - * csrf_protection?: array{ - * enabled?: scalar|null|Param, // Default: null - * token_id?: scalar|null|Param, // Default: null - * field_name?: scalar|null|Param, // Default: "_token" - * field_attr?: array, + * csrf_protection?: bool|array{ + * enabled?: scalar|Param|null, // Default: null + * token_id?: scalar|Param|null, // Default: null + * field_name?: scalar|Param|null, // Default: "_token" + * field_attr?: array, * }, * }, * http_cache?: bool|array{ // HTTP cache configuration * enabled?: bool|Param, // Default: false * debug?: bool|Param, // Default: "%kernel.debug%" * trace_level?: "none"|"short"|"full"|Param, - * trace_header?: scalar|null|Param, + * trace_header?: scalar|Param|null, * default_ttl?: int|Param, - * private_headers?: list, - * skip_response_headers?: list, + * private_headers?: list, + * skip_response_headers?: list, * allow_reload?: bool|Param, * allow_revalidate?: bool|Param, * stale_while_revalidate?: int|Param, @@ -178,16 +178,16 @@ * }, * fragments?: bool|array{ // Fragments configuration * enabled?: bool|Param, // Default: false - * hinclude_default_template?: scalar|null|Param, // Default: null - * path?: scalar|null|Param, // Default: "/_fragment" + * hinclude_default_template?: scalar|Param|null, // Default: null + * path?: scalar|Param|null, // Default: "/_fragment" * }, * profiler?: bool|array{ // Profiler configuration * enabled?: bool|Param, // Default: false * collect?: bool|Param, // Default: true - * collect_parameter?: scalar|null|Param, // The name of the parameter to use to enable or disable collection on a per request basis. // Default: null + * collect_parameter?: scalar|Param|null, // The name of the parameter to use to enable or disable collection on a per request basis. // Default: null * only_exceptions?: bool|Param, // Default: false * only_main_requests?: bool|Param, // Default: false - * dsn?: scalar|null|Param, // Default: "file:%kernel.cache_dir%/profiler" + * dsn?: scalar|Param|null, // Default: "file:%kernel.cache_dir%/profiler" * collect_serializer_data?: bool|Param, // Enables the serializer data collector and profiler panel. // Default: false * }, * workflows?: bool|array{ @@ -199,16 +199,16 @@ * type?: "workflow"|"state_machine"|Param, // Default: "state_machine" * marking_store?: array{ * type?: "method"|Param, - * property?: scalar|null|Param, - * service?: scalar|null|Param, + * property?: scalar|Param|null, + * service?: scalar|Param|null, * }, - * supports?: list, - * definition_validators?: list, - * support_strategy?: scalar|null|Param, - * initial_marking?: list, + * supports?: list, + * definition_validators?: list, + * support_strategy?: scalar|Param|null, + * initial_marking?: list, * events_to_dispatch?: list|null, * places?: list, * }>, * transitions: list>, + * formats?: array>, * }, * assets?: bool|array{ // Assets configuration * enabled?: bool|Param, // Default: true * strict_mode?: bool|Param, // Throw an exception if an entry is missing from the manifest.json. // Default: false - * version_strategy?: scalar|null|Param, // Default: null - * version?: scalar|null|Param, // Default: null - * version_format?: scalar|null|Param, // Default: "%%s?%%s" - * json_manifest_path?: scalar|null|Param, // Default: null - * base_path?: scalar|null|Param, // Default: "" - * base_urls?: list, + * version_strategy?: scalar|Param|null, // Default: null + * version?: scalar|Param|null, // Default: null + * version_format?: scalar|Param|null, // Default: "%%s?%%s" + * json_manifest_path?: scalar|Param|null, // Default: null + * base_path?: scalar|Param|null, // Default: "" + * base_urls?: list, * packages?: array, + * version_strategy?: scalar|Param|null, // Default: null + * version?: scalar|Param|null, + * version_format?: scalar|Param|null, // Default: null + * json_manifest_path?: scalar|Param|null, // Default: null + * base_path?: scalar|Param|null, // Default: "" + * base_urls?: list, * }>, * }, * asset_mapper?: bool|array{ // Asset Mapper configuration * enabled?: bool|Param, // Default: true - * paths?: array, - * excluded_patterns?: list, + * paths?: array, + * excluded_patterns?: list, * exclude_dotfiles?: bool|Param, // If true, any files starting with "." will be excluded from the asset mapper. // Default: true * server?: bool|Param, // If true, a "dev server" will return the assets from the public directory (true in "debug" mode only by default). // Default: true - * public_prefix?: scalar|null|Param, // The public path where the assets will be written to (and served from when "server" is true). // Default: "/assets/" + * public_prefix?: scalar|Param|null, // The public path where the assets will be written to (and served from when "server" is true). // Default: "/assets/" * missing_import_mode?: "strict"|"warn"|"ignore"|Param, // Behavior if an asset cannot be found when imported from JavaScript or CSS files - e.g. "import './non-existent.js'". "strict" means an exception is thrown, "warn" means a warning is logged, "ignore" means the import is left as-is. // Default: "warn" - * extensions?: array, - * importmap_path?: scalar|null|Param, // The path of the importmap.php file. // Default: "%kernel.project_dir%/importmap.php" - * importmap_polyfill?: scalar|null|Param, // The importmap name that will be used to load the polyfill. Set to false to disable. // Default: "es-module-shims" - * importmap_script_attributes?: array, - * vendor_dir?: scalar|null|Param, // The directory to store JavaScript vendors. // Default: "%kernel.project_dir%/assets/vendor" + * extensions?: array, + * importmap_path?: scalar|Param|null, // The path of the importmap.php file. // Default: "%kernel.project_dir%/importmap.php" + * importmap_polyfill?: scalar|Param|null, // The importmap name that will be used to load the polyfill. Set to false to disable. // Default: "es-module-shims" + * importmap_script_attributes?: array, + * vendor_dir?: scalar|Param|null, // The directory to store JavaScript vendors. // Default: "%kernel.project_dir%/assets/vendor" * precompress?: bool|array{ // Precompress assets with Brotli, Zstandard and gzip. * enabled?: bool|Param, // Default: false - * formats?: list, - * extensions?: list, + * formats?: list, + * extensions?: list, * }, * }, * translator?: bool|array{ // Translator configuration * enabled?: bool|Param, // Default: true - * fallbacks?: list, + * fallbacks?: list, * logging?: bool|Param, // Default: false - * formatter?: scalar|null|Param, // Default: "translator.formatter.default" - * cache_dir?: scalar|null|Param, // Default: "%kernel.cache_dir%/translations" - * default_path?: scalar|null|Param, // The default path used to load translations. // Default: "%kernel.project_dir%/translations" - * paths?: list, + * formatter?: scalar|Param|null, // Default: "translator.formatter.default" + * cache_dir?: scalar|Param|null, // Default: "%kernel.cache_dir%/translations" + * default_path?: scalar|Param|null, // The default path used to load translations. // Default: "%kernel.project_dir%/translations" + * paths?: list, * pseudo_localization?: bool|array{ * enabled?: bool|Param, // Default: false * accents?: bool|Param, // Default: true * expansion_factor?: float|Param, // Default: 1.0 * brackets?: bool|Param, // Default: true * parse_html?: bool|Param, // Default: false - * localizable_html_attributes?: list, + * localizable_html_attributes?: list, * }, * providers?: array, - * locales?: list, + * dsn?: scalar|Param|null, + * domains?: list, + * locales?: list, * }>, * globals?: array, + * parameters?: array, * domain?: string|Param, * }>, * }, * validation?: bool|array{ // Validation configuration * enabled?: bool|Param, // Default: true - * cache?: scalar|null|Param, // Deprecated: Setting the "framework.validation.cache.cache" configuration option is deprecated. It will be removed in version 8.0. + * cache?: scalar|Param|null, // Deprecated: Setting the "framework.validation.cache.cache" configuration option is deprecated. It will be removed in version 8.0. * enable_attributes?: bool|Param, // Default: true - * static_method?: list, - * translation_domain?: scalar|null|Param, // Default: "validators" + * static_method?: list, + * translation_domain?: scalar|Param|null, // Default: "validators" * email_validation_mode?: "html5"|"html5-allow-no-tld"|"strict"|"loose"|Param, // Default: "html5" * mapping?: array{ - * paths?: list, + * paths?: list, * }, * not_compromised_password?: bool|array{ * enabled?: bool|Param, // When disabled, compromised passwords will be accepted as valid. // Default: true - * endpoint?: scalar|null|Param, // API endpoint for the NotCompromisedPassword Validator. // Default: null + * endpoint?: scalar|Param|null, // API endpoint for the NotCompromisedPassword Validator. // Default: null * }, * disable_translation?: bool|Param, // Default: false * auto_mapping?: array, + * services?: list, * }>, * }, * annotations?: bool|array{ @@ -354,15 +354,15 @@ * serializer?: bool|array{ // Serializer configuration * enabled?: bool|Param, // Default: true * enable_attributes?: bool|Param, // Default: true - * name_converter?: scalar|null|Param, - * circular_reference_handler?: scalar|null|Param, - * max_depth_handler?: scalar|null|Param, + * name_converter?: scalar|Param|null, + * circular_reference_handler?: scalar|Param|null, + * max_depth_handler?: scalar|Param|null, * mapping?: array{ - * paths?: list, + * paths?: list, * }, * default_context?: list, * named_serializers?: array, * include_built_in_normalizers?: bool|Param, // Whether to include the built-in normalizers // Default: true * include_built_in_encoders?: bool|Param, // Whether to include the built-in encoders // Default: true @@ -378,31 +378,31 @@ * }, * type_info?: bool|array{ // Type info configuration * enabled?: bool|Param, // Default: true - * aliases?: array, + * aliases?: array, * }, * property_info?: bool|array{ // Property info configuration * enabled?: bool|Param, // Default: true * with_constructor_extractor?: bool|Param, // Registers the constructor extractor. * }, * cache?: array{ // Cache configuration - * prefix_seed?: scalar|null|Param, // Used to namespace cache keys when using several apps with the same shared backend. // Default: "_%kernel.project_dir%.%kernel.container_class%" - * app?: scalar|null|Param, // App related cache pools configuration. // Default: "cache.adapter.filesystem" - * system?: scalar|null|Param, // System related cache pools configuration. // Default: "cache.adapter.system" - * directory?: scalar|null|Param, // Default: "%kernel.share_dir%/pools/app" - * default_psr6_provider?: scalar|null|Param, - * default_redis_provider?: scalar|null|Param, // Default: "redis://localhost" - * default_valkey_provider?: scalar|null|Param, // Default: "valkey://localhost" - * default_memcached_provider?: scalar|null|Param, // Default: "memcached://localhost" - * default_doctrine_dbal_provider?: scalar|null|Param, // Default: "database_connection" - * default_pdo_provider?: scalar|null|Param, // Default: null + * prefix_seed?: scalar|Param|null, // Used to namespace cache keys when using several apps with the same shared backend. // Default: "_%kernel.project_dir%.%kernel.container_class%" + * app?: scalar|Param|null, // App related cache pools configuration. // Default: "cache.adapter.filesystem" + * system?: scalar|Param|null, // System related cache pools configuration. // Default: "cache.adapter.system" + * directory?: scalar|Param|null, // Default: "%kernel.share_dir%/pools/app" + * default_psr6_provider?: scalar|Param|null, + * default_redis_provider?: scalar|Param|null, // Default: "redis://localhost" + * default_valkey_provider?: scalar|Param|null, // Default: "valkey://localhost" + * default_memcached_provider?: scalar|Param|null, // Default: "memcached://localhost" + * default_doctrine_dbal_provider?: scalar|Param|null, // Default: "database_connection" + * default_pdo_provider?: scalar|Param|null, // Default: null * pools?: array, - * tags?: scalar|null|Param, // Default: null + * adapters?: list, + * tags?: scalar|Param|null, // Default: null * public?: bool|Param, // Default: false - * default_lifetime?: scalar|null|Param, // Default lifetime of the pool. - * provider?: scalar|null|Param, // Overwrite the setting from the default provider for this adapter. - * early_expiration_message_bus?: scalar|null|Param, - * clearer?: scalar|null|Param, + * default_lifetime?: scalar|Param|null, // Default lifetime of the pool. + * provider?: scalar|Param|null, // Overwrite the setting from the default provider for this adapter. + * early_expiration_message_bus?: scalar|Param|null, + * clearer?: scalar|Param|null, * }>, * }, * php_errors?: array{ // PHP errors handling configuration @@ -410,51 +410,51 @@ * throw?: bool|Param, // Throw PHP errors as \ErrorException instances. // Default: true * }, * exceptions?: array, * web_link?: bool|array{ // Web links configuration * enabled?: bool|Param, // Default: true * }, * lock?: bool|string|array{ // Lock configuration * enabled?: bool|Param, // Default: false - * resources?: array>, + * resources?: array>, * }, * semaphore?: bool|string|array{ // Semaphore configuration * enabled?: bool|Param, // Default: false - * resources?: array, + * resources?: array, * }, * messenger?: bool|array{ // Messenger configuration * enabled?: bool|Param, // Default: false * routing?: array, + * senders?: list, * }>, * serializer?: array{ - * default_serializer?: scalar|null|Param, // Service id to use as the default serializer for the transports. // Default: "messenger.transport.native_php_serializer" + * default_serializer?: scalar|Param|null, // Service id to use as the default serializer for the transports. // Default: "messenger.transport.native_php_serializer" * symfony_serializer?: array{ - * format?: scalar|null|Param, // Serialization format for the messenger.transport.symfony_serializer service (which is not the serializer used by default). // Default: "json" + * format?: scalar|Param|null, // Serialization format for the messenger.transport.symfony_serializer service (which is not the serializer used by default). // Default: "json" * context?: array, * }, * }, * transports?: array, - * failure_transport?: scalar|null|Param, // Transport name to send failed messages to (after all retries have failed). // Default: null + * failure_transport?: scalar|Param|null, // Transport name to send failed messages to (after all retries have failed). // Default: null * retry_strategy?: string|array{ - * service?: scalar|null|Param, // Service id to override the retry strategy entirely. // Default: null + * service?: scalar|Param|null, // Service id to override the retry strategy entirely. // Default: null * max_retries?: int|Param, // Default: 3 * delay?: int|Param, // Time in ms to delay (or the initial value when multiplier is used). // Default: 1000 * multiplier?: float|Param, // If greater than 1, delay will grow exponentially for each retry: this delay = (delay * (multiple ^ retries)). // Default: 2 * max_delay?: int|Param, // Max time in ms that a retry should ever be delayed (0 = infinite). // Default: 0 * jitter?: float|Param, // Randomness to apply to the delay (between 0 and 1). // Default: 0.1 * }, - * rate_limiter?: scalar|null|Param, // Rate limiter name to use when processing messages. // Default: null + * rate_limiter?: scalar|Param|null, // Rate limiter name to use when processing messages. // Default: null * }>, - * failure_transport?: scalar|null|Param, // Transport name to send failed messages to (after all retries have failed). // Default: null - * stop_worker_on_signals?: list, - * default_bus?: scalar|null|Param, // Default: null + * failure_transport?: scalar|Param|null, // Transport name to send failed messages to (after all retries have failed). // Default: null + * stop_worker_on_signals?: list, + * default_bus?: scalar|Param|null, // Default: null * buses?: array, * }>, * }>, @@ -478,29 +478,29 @@ * headers?: array, * vars?: array, * max_redirects?: int|Param, // The maximum number of redirects to follow. - * http_version?: scalar|null|Param, // The default HTTP version, typically 1.1 or 2.0, leave to null for the best version. - * resolve?: array, - * proxy?: scalar|null|Param, // The URL of the proxy to pass requests through or null for automatic detection. - * no_proxy?: scalar|null|Param, // A comma separated list of hosts that do not require a proxy to be reached. + * http_version?: scalar|Param|null, // The default HTTP version, typically 1.1 or 2.0, leave to null for the best version. + * resolve?: array, + * proxy?: scalar|Param|null, // The URL of the proxy to pass requests through or null for automatic detection. + * no_proxy?: scalar|Param|null, // A comma separated list of hosts that do not require a proxy to be reached. * timeout?: float|Param, // The idle timeout, defaults to the "default_socket_timeout" ini parameter. * max_duration?: float|Param, // The maximum execution time for the request+response as a whole. - * bindto?: scalar|null|Param, // A network interface name, IP address, a host name or a UNIX socket to bind to. + * bindto?: scalar|Param|null, // A network interface name, IP address, a host name or a UNIX socket to bind to. * verify_peer?: bool|Param, // Indicates if the peer should be verified in a TLS context. * verify_host?: bool|Param, // Indicates if the host should exist as a certificate common name. - * cafile?: scalar|null|Param, // A certificate authority file. - * capath?: scalar|null|Param, // A directory that contains multiple certificate authority files. - * local_cert?: scalar|null|Param, // A PEM formatted certificate file. - * local_pk?: scalar|null|Param, // A private key file. - * passphrase?: scalar|null|Param, // The passphrase used to encrypt the "local_pk" file. - * ciphers?: scalar|null|Param, // A list of TLS ciphers separated by colons, commas or spaces (e.g. "RC3-SHA:TLS13-AES-128-GCM-SHA256"...) + * cafile?: scalar|Param|null, // A certificate authority file. + * capath?: scalar|Param|null, // A directory that contains multiple certificate authority files. + * local_cert?: scalar|Param|null, // A PEM formatted certificate file. + * local_pk?: scalar|Param|null, // A private key file. + * passphrase?: scalar|Param|null, // The passphrase used to encrypt the "local_pk" file. + * ciphers?: scalar|Param|null, // A list of TLS ciphers separated by colons, commas or spaces (e.g. "RC3-SHA:TLS13-AES-128-GCM-SHA256"...) * peer_fingerprint?: array{ // Associative array: hashing algorithm => hash(es). * sha1?: mixed, * pin-sha256?: mixed, * md5?: mixed, * }, - * crypto_method?: scalar|null|Param, // The minimum version of TLS to accept; must be one of STREAM_CRYPTO_METHOD_TLSv*_CLIENT constants. + * crypto_method?: scalar|Param|null, // The minimum version of TLS to accept; must be one of STREAM_CRYPTO_METHOD_TLSv*_CLIENT constants. * extra?: array, - * rate_limiter?: scalar|null|Param, // Rate limiter name to use for throttling requests. // Default: null + * rate_limiter?: scalar|Param|null, // Rate limiter name to use for throttling requests. // Default: null * caching?: bool|array{ // Caching configuration. * enabled?: bool|Param, // Default: false * cache_pool?: string|Param, // The taggable cache pool to use for storing the responses. // Default: "cache.http_client" @@ -509,7 +509,7 @@ * }, * retry_failed?: bool|array{ * enabled?: bool|Param, // Default: false - * retry_strategy?: scalar|null|Param, // service id to override the retry strategy. // Default: null + * retry_strategy?: scalar|Param|null, // service id to override the retry strategy. // Default: null * http_codes?: array, @@ -521,39 +521,39 @@ * jitter?: float|Param, // Randomness in percent (between 0 and 1) to apply to the delay. // Default: 0.1 * }, * }, - * mock_response_factory?: scalar|null|Param, // The id of the service that should generate mock responses. It should be either an invokable or an iterable. + * mock_response_factory?: scalar|Param|null, // The id of the service that should generate mock responses. It should be either an invokable or an iterable. * scoped_clients?: array, + * scope?: scalar|Param|null, // The regular expression that the request URL must match before adding the other options. When none is provided, the base URI is used instead. + * base_uri?: scalar|Param|null, // The URI to resolve relative URLs, following rules in RFC 3985, section 2. + * auth_basic?: scalar|Param|null, // An HTTP Basic authentication "username:password". + * auth_bearer?: scalar|Param|null, // A token enabling HTTP Bearer authorization. + * auth_ntlm?: scalar|Param|null, // A "username:password" pair to use Microsoft NTLM authentication (requires the cURL extension). + * query?: array, * headers?: array, * max_redirects?: int|Param, // The maximum number of redirects to follow. - * http_version?: scalar|null|Param, // The default HTTP version, typically 1.1 or 2.0, leave to null for the best version. - * resolve?: array, - * proxy?: scalar|null|Param, // The URL of the proxy to pass requests through or null for automatic detection. - * no_proxy?: scalar|null|Param, // A comma separated list of hosts that do not require a proxy to be reached. + * http_version?: scalar|Param|null, // The default HTTP version, typically 1.1 or 2.0, leave to null for the best version. + * resolve?: array, + * proxy?: scalar|Param|null, // The URL of the proxy to pass requests through or null for automatic detection. + * no_proxy?: scalar|Param|null, // A comma separated list of hosts that do not require a proxy to be reached. * timeout?: float|Param, // The idle timeout, defaults to the "default_socket_timeout" ini parameter. * max_duration?: float|Param, // The maximum execution time for the request+response as a whole. - * bindto?: scalar|null|Param, // A network interface name, IP address, a host name or a UNIX socket to bind to. + * bindto?: scalar|Param|null, // A network interface name, IP address, a host name or a UNIX socket to bind to. * verify_peer?: bool|Param, // Indicates if the peer should be verified in a TLS context. * verify_host?: bool|Param, // Indicates if the host should exist as a certificate common name. - * cafile?: scalar|null|Param, // A certificate authority file. - * capath?: scalar|null|Param, // A directory that contains multiple certificate authority files. - * local_cert?: scalar|null|Param, // A PEM formatted certificate file. - * local_pk?: scalar|null|Param, // A private key file. - * passphrase?: scalar|null|Param, // The passphrase used to encrypt the "local_pk" file. - * ciphers?: scalar|null|Param, // A list of TLS ciphers separated by colons, commas or spaces (e.g. "RC3-SHA:TLS13-AES-128-GCM-SHA256"...). + * cafile?: scalar|Param|null, // A certificate authority file. + * capath?: scalar|Param|null, // A directory that contains multiple certificate authority files. + * local_cert?: scalar|Param|null, // A PEM formatted certificate file. + * local_pk?: scalar|Param|null, // A private key file. + * passphrase?: scalar|Param|null, // The passphrase used to encrypt the "local_pk" file. + * ciphers?: scalar|Param|null, // A list of TLS ciphers separated by colons, commas or spaces (e.g. "RC3-SHA:TLS13-AES-128-GCM-SHA256"...). * peer_fingerprint?: array{ // Associative array: hashing algorithm => hash(es). * sha1?: mixed, * pin-sha256?: mixed, * md5?: mixed, * }, - * crypto_method?: scalar|null|Param, // The minimum version of TLS to accept; must be one of STREAM_CRYPTO_METHOD_TLSv*_CLIENT constants. + * crypto_method?: scalar|Param|null, // The minimum version of TLS to accept; must be one of STREAM_CRYPTO_METHOD_TLSv*_CLIENT constants. * extra?: array, - * rate_limiter?: scalar|null|Param, // Rate limiter name to use for throttling requests. // Default: null + * rate_limiter?: scalar|Param|null, // Rate limiter name to use for throttling requests. // Default: null * caching?: bool|array{ // Caching configuration. * enabled?: bool|Param, // Default: false * cache_pool?: string|Param, // The taggable cache pool to use for storing the responses. // Default: "cache.http_client" @@ -562,7 +562,7 @@ * }, * retry_failed?: bool|array{ * enabled?: bool|Param, // Default: false - * retry_strategy?: scalar|null|Param, // service id to override the retry strategy. // Default: null + * retry_strategy?: scalar|Param|null, // service id to override the retry strategy. // Default: null * http_codes?: array, @@ -577,69 +577,69 @@ * }, * mailer?: bool|array{ // Mailer configuration * enabled?: bool|Param, // Default: true - * message_bus?: scalar|null|Param, // The message bus to use. Defaults to the default bus if the Messenger component is installed. // Default: null - * dsn?: scalar|null|Param, // Default: null - * transports?: array, + * message_bus?: scalar|Param|null, // The message bus to use. Defaults to the default bus if the Messenger component is installed. // Default: null + * dsn?: scalar|Param|null, // Default: null + * transports?: array, * envelope?: array{ // Mailer Envelope configuration - * sender?: scalar|null|Param, - * recipients?: list, - * allowed_recipients?: list, + * sender?: scalar|Param|null, + * recipients?: list, + * allowed_recipients?: list, * }, * headers?: array, * dkim_signer?: bool|array{ // DKIM signer configuration * enabled?: bool|Param, // Default: false - * key?: scalar|null|Param, // Key content, or path to key (in PEM format with the `file://` prefix) // Default: "" - * domain?: scalar|null|Param, // Default: "" - * select?: scalar|null|Param, // Default: "" - * passphrase?: scalar|null|Param, // The private key passphrase // Default: "" + * key?: scalar|Param|null, // Key content, or path to key (in PEM format with the `file://` prefix) // Default: "" + * domain?: scalar|Param|null, // Default: "" + * select?: scalar|Param|null, // Default: "" + * passphrase?: scalar|Param|null, // The private key passphrase // Default: "" * options?: array, * }, * smime_signer?: bool|array{ // S/MIME signer configuration * enabled?: bool|Param, // Default: false - * key?: scalar|null|Param, // Path to key (in PEM format) // Default: "" - * certificate?: scalar|null|Param, // Path to certificate (in PEM format without the `file://` prefix) // Default: "" - * passphrase?: scalar|null|Param, // The private key passphrase // Default: null - * extra_certificates?: scalar|null|Param, // Default: null + * key?: scalar|Param|null, // Path to key (in PEM format) // Default: "" + * certificate?: scalar|Param|null, // Path to certificate (in PEM format without the `file://` prefix) // Default: "" + * passphrase?: scalar|Param|null, // The private key passphrase // Default: null + * extra_certificates?: scalar|Param|null, // Default: null * sign_options?: int|Param, // Default: null * }, * smime_encrypter?: bool|array{ // S/MIME encrypter configuration * enabled?: bool|Param, // Default: false - * repository?: scalar|null|Param, // S/MIME certificate repository service. This service shall implement the `Symfony\Component\Mailer\EventListener\SmimeCertificateRepositoryInterface`. // Default: "" + * repository?: scalar|Param|null, // S/MIME certificate repository service. This service shall implement the `Symfony\Component\Mailer\EventListener\SmimeCertificateRepositoryInterface`. // Default: "" * cipher?: int|Param, // A set of algorithms used to encrypt the message // Default: null * }, * }, * secrets?: bool|array{ * enabled?: bool|Param, // Default: true - * vault_directory?: scalar|null|Param, // Default: "%kernel.project_dir%/config/secrets/%kernel.runtime_environment%" - * local_dotenv_file?: scalar|null|Param, // Default: "%kernel.project_dir%/.env.%kernel.runtime_environment%.local" - * decryption_env_var?: scalar|null|Param, // Default: "base64:default::SYMFONY_DECRYPTION_SECRET" + * vault_directory?: scalar|Param|null, // Default: "%kernel.project_dir%/config/secrets/%kernel.runtime_environment%" + * local_dotenv_file?: scalar|Param|null, // Default: "%kernel.project_dir%/.env.%kernel.environment%.local" + * decryption_env_var?: scalar|Param|null, // Default: "base64:default::SYMFONY_DECRYPTION_SECRET" * }, * notifier?: bool|array{ // Notifier configuration * enabled?: bool|Param, // Default: false - * message_bus?: scalar|null|Param, // The message bus to use. Defaults to the default bus if the Messenger component is installed. // Default: null - * chatter_transports?: array, - * texter_transports?: array, + * message_bus?: scalar|Param|null, // The message bus to use. Defaults to the default bus if the Messenger component is installed. // Default: null + * chatter_transports?: array, + * texter_transports?: array, * notification_on_failed_messages?: bool|Param, // Default: false - * channel_policy?: array>, + * channel_policy?: array>, * admin_recipients?: list, * }, * rate_limiter?: bool|array{ // Rate limiter configuration * enabled?: bool|Param, // Default: false * limiters?: array, + * limiters?: list, * limit?: int|Param, // The maximum allowed hits in a fixed interval or burst. - * interval?: scalar|null|Param, // Configures the fixed interval if "policy" is set to "fixed_window" or "sliding_window". The value must be a number followed by "second", "minute", "hour", "day", "week" or "month" (or their plural equivalent). + * interval?: scalar|Param|null, // Configures the fixed interval if "policy" is set to "fixed_window" or "sliding_window". The value must be a number followed by "second", "minute", "hour", "day", "week" or "month" (or their plural equivalent). * rate?: array{ // Configures the fill rate if "policy" is set to "token_bucket". - * interval?: scalar|null|Param, // Configures the rate interval. The value must be a number followed by "second", "minute", "hour", "day", "week" or "month" (or their plural equivalent). + * interval?: scalar|Param|null, // Configures the rate interval. The value must be a number followed by "second", "minute", "hour", "day", "week" or "month" (or their plural equivalent). * amount?: int|Param, // Amount of tokens to add each interval. // Default: 1 * }, * }>, @@ -648,9 +648,9 @@ * enabled?: bool|Param, // Default: true * default_uuid_version?: 7|6|4|1|Param, // Default: 7 * name_based_uuid_version?: 5|3|Param, // Default: 5 - * name_based_uuid_namespace?: scalar|null|Param, + * name_based_uuid_namespace?: scalar|Param|null, * time_based_uuid_version?: 7|6|1|Param, // Default: 7 - * time_based_uuid_node?: scalar|null|Param, + * time_based_uuid_node?: scalar|Param|null, * }, * html_sanitizer?: bool|array{ // HtmlSanitizer configuration * enabled?: bool|Param, // Default: false @@ -677,10 +677,10 @@ * }, * webhook?: bool|array{ // Webhook configuration * enabled?: bool|Param, // Default: false - * message_bus?: scalar|null|Param, // The message bus to use. // Default: "messenger.default_bus" + * message_bus?: scalar|Param|null, // The message bus to use. // Default: "messenger.default_bus" * routing?: array, * }, * remote-event?: bool|array{ // RemoteEvent configuration @@ -691,800 +691,755 @@ * }, * } * @psalm-type TwigConfig = array{ - * form_themes?: list, + * form_themes?: list, * globals?: array, - * autoescape_service?: scalar|null|Param, // Default: null - * autoescape_service_method?: scalar|null|Param, // Default: null - * base_template_class?: scalar|null|Param, // Deprecated: The child node "base_template_class" at path "twig.base_template_class" is deprecated. - * cache?: scalar|null|Param, // Default: true - * charset?: scalar|null|Param, // Default: "%kernel.charset%" + * autoescape_service?: scalar|Param|null, // Default: null + * autoescape_service_method?: scalar|Param|null, // Default: null + * base_template_class?: scalar|Param|null, // Deprecated: The child node "base_template_class" at path "twig.base_template_class" is deprecated. + * cache?: scalar|Param|null, // Default: true + * charset?: scalar|Param|null, // Default: "%kernel.charset%" * debug?: bool|Param, // Default: "%kernel.debug%" * strict_variables?: bool|Param, // Default: "%kernel.debug%" - * auto_reload?: scalar|null|Param, + * auto_reload?: scalar|Param|null, * optimizations?: int|Param, - * default_path?: scalar|null|Param, // The default path used to load templates. // Default: "%kernel.project_dir%/templates" - * file_name_pattern?: list, + * default_path?: scalar|Param|null, // The default path used to load templates. // Default: "%kernel.project_dir%/templates" + * file_name_pattern?: list, * paths?: array, * date?: array{ // The default format options used by the date filter. - * format?: scalar|null|Param, // Default: "F j, Y H:i" - * interval_format?: scalar|null|Param, // Default: "%d days" - * timezone?: scalar|null|Param, // The timezone used when formatting dates, when set to null, the timezone returned by date_default_timezone_get() is used. // Default: null + * format?: scalar|Param|null, // Default: "F j, Y H:i" + * interval_format?: scalar|Param|null, // Default: "%d days" + * timezone?: scalar|Param|null, // The timezone used when formatting dates, when set to null, the timezone returned by date_default_timezone_get() is used. // Default: null * }, * number_format?: array{ // The default format options for the number_format filter. * decimals?: int|Param, // Default: 0 - * decimal_point?: scalar|null|Param, // Default: "." - * thousands_separator?: scalar|null|Param, // Default: "," + * decimal_point?: scalar|Param|null, // Default: "." + * thousands_separator?: scalar|Param|null, // Default: "," * }, * mailer?: array{ - * html_to_text_converter?: scalar|null|Param, // A service implementing the "Symfony\Component\Mime\HtmlToTextConverter\HtmlToTextConverterInterface". // Default: null + * html_to_text_converter?: scalar|Param|null, // A service implementing the "Symfony\Component\Mime\HtmlToTextConverter\HtmlToTextConverterInterface". // Default: null * }, * } * @psalm-type SecurityConfig = array{ - * access_denied_url?: scalar|null|Param, // Default: null + * access_denied_url?: scalar|Param|null, // Default: null * session_fixation_strategy?: "none"|"migrate"|"invalidate"|Param, // Default: "migrate" * hide_user_not_found?: bool|Param, // Deprecated: The "hide_user_not_found" option is deprecated and will be removed in 8.0. Use the "expose_security_errors" option instead. * expose_security_errors?: \Symfony\Component\Security\Http\Authentication\ExposeSecurityLevel::None|\Symfony\Component\Security\Http\Authentication\ExposeSecurityLevel::AccountStatus|\Symfony\Component\Security\Http\Authentication\ExposeSecurityLevel::All|Param, // Default: "none" * erase_credentials?: bool|Param, // Default: true * access_decision_manager?: array{ * strategy?: "affirmative"|"consensus"|"unanimous"|"priority"|Param, - * service?: scalar|null|Param, - * strategy_service?: scalar|null|Param, + * service?: scalar|Param|null, + * strategy_service?: scalar|Param|null, * allow_if_all_abstain?: bool|Param, // Default: false * allow_if_equal_granted_denied?: bool|Param, // Default: true * }, * password_hashers?: array, - * hash_algorithm?: scalar|null|Param, // Name of hashing algorithm for PBKDF2 (i.e. sha256, sha512, etc..) See hash_algos() for a list of supported algorithms. // Default: "sha512" - * key_length?: scalar|null|Param, // Default: 40 + * algorithm?: scalar|Param|null, + * migrate_from?: list, + * hash_algorithm?: scalar|Param|null, // Name of hashing algorithm for PBKDF2 (i.e. sha256, sha512, etc..) See hash_algos() for a list of supported algorithms. // Default: "sha512" + * key_length?: scalar|Param|null, // Default: 40 * ignore_case?: bool|Param, // Default: false * encode_as_base64?: bool|Param, // Default: true - * iterations?: scalar|null|Param, // Default: 5000 + * iterations?: scalar|Param|null, // Default: 5000 * cost?: int|Param, // Default: null - * memory_cost?: scalar|null|Param, // Default: null - * time_cost?: scalar|null|Param, // Default: null - * id?: scalar|null|Param, + * memory_cost?: scalar|Param|null, // Default: null + * time_cost?: scalar|Param|null, // Default: null + * id?: scalar|Param|null, * }>, * providers?: array, + * providers?: list, * }, * memory?: array{ * users?: array, + * password?: scalar|Param|null, // Default: null + * roles?: list, * }>, * }, * ldap?: array{ - * service: scalar|null|Param, - * base_dn: scalar|null|Param, - * search_dn?: scalar|null|Param, // Default: null - * search_password?: scalar|null|Param, // Default: null - * extra_fields?: list, - * default_roles?: list, - * role_fetcher?: scalar|null|Param, // Default: null - * uid_key?: scalar|null|Param, // Default: "sAMAccountName" - * filter?: scalar|null|Param, // Default: "({uid_key}={user_identifier})" - * password_attribute?: scalar|null|Param, // Default: null + * service: scalar|Param|null, + * base_dn: scalar|Param|null, + * search_dn?: scalar|Param|null, // Default: null + * search_password?: scalar|Param|null, // Default: null + * extra_fields?: list, + * default_roles?: list, + * role_fetcher?: scalar|Param|null, // Default: null + * uid_key?: scalar|Param|null, // Default: "sAMAccountName" + * filter?: scalar|Param|null, // Default: "({uid_key}={user_identifier})" + * password_attribute?: scalar|Param|null, // Default: null * }, * entity?: array{ - * class: scalar|null|Param, // The full entity class name of your user class. - * property?: scalar|null|Param, // Default: null - * manager_name?: scalar|null|Param, // Default: null + * class: scalar|Param|null, // The full entity class name of your user class. + * property?: scalar|Param|null, // Default: null + * manager_name?: scalar|Param|null, // Default: null * }, * }>, * firewalls: array, + * pattern?: scalar|Param|null, + * host?: scalar|Param|null, + * methods?: list, * security?: bool|Param, // Default: true - * user_checker?: scalar|null|Param, // The UserChecker to use when authenticating users in this firewall. // Default: "security.user_checker" - * request_matcher?: scalar|null|Param, - * access_denied_url?: scalar|null|Param, - * access_denied_handler?: scalar|null|Param, - * entry_point?: scalar|null|Param, // An enabled authenticator name or a service id that implements "Symfony\Component\Security\Http\EntryPoint\AuthenticationEntryPointInterface". - * provider?: scalar|null|Param, + * user_checker?: scalar|Param|null, // The UserChecker to use when authenticating users in this firewall. // Default: "security.user_checker" + * request_matcher?: scalar|Param|null, + * access_denied_url?: scalar|Param|null, + * access_denied_handler?: scalar|Param|null, + * entry_point?: scalar|Param|null, // An enabled authenticator name or a service id that implements "Symfony\Component\Security\Http\EntryPoint\AuthenticationEntryPointInterface". + * provider?: scalar|Param|null, * stateless?: bool|Param, // Default: false * lazy?: bool|Param, // Default: false - * context?: scalar|null|Param, + * context?: scalar|Param|null, * logout?: array{ - * enable_csrf?: bool|null|Param, // Default: null - * csrf_token_id?: scalar|null|Param, // Default: "logout" - * csrf_parameter?: scalar|null|Param, // Default: "_csrf_token" - * csrf_token_manager?: scalar|null|Param, - * path?: scalar|null|Param, // Default: "/logout" - * target?: scalar|null|Param, // Default: "/" + * enable_csrf?: bool|Param|null, // Default: null + * csrf_token_id?: scalar|Param|null, // Default: "logout" + * csrf_parameter?: scalar|Param|null, // Default: "_csrf_token" + * csrf_token_manager?: scalar|Param|null, + * path?: scalar|Param|null, // Default: "/logout" + * target?: scalar|Param|null, // Default: "/" * invalidate_session?: bool|Param, // Default: true * clear_site_data?: list<"*"|"cache"|"cookies"|"storage"|"executionContexts"|Param>, * delete_cookies?: array, * }, * switch_user?: array{ - * provider?: scalar|null|Param, - * parameter?: scalar|null|Param, // Default: "_switch_user" - * role?: scalar|null|Param, // Default: "ROLE_ALLOWED_TO_SWITCH" - * target_route?: scalar|null|Param, // Default: null + * provider?: scalar|Param|null, + * parameter?: scalar|Param|null, // Default: "_switch_user" + * role?: scalar|Param|null, // Default: "ROLE_ALLOWED_TO_SWITCH" + * target_route?: scalar|Param|null, // Default: null * }, - * required_badges?: list, - * custom_authenticators?: list, + * required_badges?: list, + * custom_authenticators?: list, * login_throttling?: array{ - * limiter?: scalar|null|Param, // A service id implementing "Symfony\Component\HttpFoundation\RateLimiter\RequestRateLimiterInterface". + * limiter?: scalar|Param|null, // A service id implementing "Symfony\Component\HttpFoundation\RateLimiter\RequestRateLimiterInterface". * max_attempts?: int|Param, // Default: 5 - * interval?: scalar|null|Param, // Default: "1 minute" - * lock_factory?: scalar|null|Param, // The service ID of the lock factory used by the login rate limiter (or null to disable locking). // Default: null + * interval?: scalar|Param|null, // Default: "1 minute" + * lock_factory?: scalar|Param|null, // The service ID of the lock factory used by the login rate limiter (or null to disable locking). // Default: null * cache_pool?: string|Param, // The cache pool to use for storing the limiter state // Default: "cache.rate_limiter" * storage_service?: string|Param, // The service ID of a custom storage implementation, this precedes any configured "cache_pool" // Default: null * }, * webauthn?: array{ - * user_provider?: scalar|null|Param, // Default: null - * options_storage?: scalar|null|Param, // Deprecated: The child node "options_storage" at path "security.firewalls..webauthn.options_storage" is deprecated. Please use the root option "options_storage" instead. // Default: null - * success_handler?: scalar|null|Param, // Default: "Webauthn\\Bundle\\Security\\Handler\\DefaultSuccessHandler" - * failure_handler?: scalar|null|Param, // Default: "Webauthn\\Bundle\\Security\\Handler\\DefaultFailureHandler" - * secured_rp_ids?: array, + * user_provider?: scalar|Param|null, // Default: null + * options_storage?: scalar|Param|null, // Deprecated: The child node "options_storage" at path "security.firewalls..webauthn.options_storage" is deprecated. Please use the root option "options_storage" instead. // Default: null + * success_handler?: scalar|Param|null, // Default: "Webauthn\\Bundle\\Security\\Handler\\DefaultSuccessHandler" + * failure_handler?: scalar|Param|null, // Default: "Webauthn\\Bundle\\Security\\Handler\\DefaultFailureHandler" + * secured_rp_ids?: array, * authentication?: bool|array{ * enabled?: bool|Param, // Default: true - * profile?: scalar|null|Param, // Default: "default" - * options_builder?: scalar|null|Param, // Default: null + * profile?: scalar|Param|null, // Default: "default" + * options_builder?: scalar|Param|null, // Default: null * routes?: array{ - * host?: scalar|null|Param, // Default: null - * options_method?: scalar|null|Param, // Default: "POST" - * options_path?: scalar|null|Param, // Default: "/login/options" - * result_method?: scalar|null|Param, // Default: "POST" - * result_path?: scalar|null|Param, // Default: "/login" + * host?: scalar|Param|null, // Default: null + * options_method?: scalar|Param|null, // Default: "POST" + * options_path?: scalar|Param|null, // Default: "/login/options" + * result_method?: scalar|Param|null, // Default: "POST" + * result_path?: scalar|Param|null, // Default: "/login" * }, - * options_handler?: scalar|null|Param, // Default: "Webauthn\\Bundle\\Security\\Handler\\DefaultRequestOptionsHandler" + * options_handler?: scalar|Param|null, // Default: "Webauthn\\Bundle\\Security\\Handler\\DefaultRequestOptionsHandler" * }, * registration?: bool|array{ * enabled?: bool|Param, // Default: false - * profile?: scalar|null|Param, // Default: "default" - * options_builder?: scalar|null|Param, // Default: null + * profile?: scalar|Param|null, // Default: "default" + * options_builder?: scalar|Param|null, // Default: null * routes?: array{ - * host?: scalar|null|Param, // Default: null - * options_method?: scalar|null|Param, // Default: "POST" - * options_path?: scalar|null|Param, // Default: "/register/options" - * result_method?: scalar|null|Param, // Default: "POST" - * result_path?: scalar|null|Param, // Default: "/register" + * host?: scalar|Param|null, // Default: null + * options_method?: scalar|Param|null, // Default: "POST" + * options_path?: scalar|Param|null, // Default: "/register/options" + * result_method?: scalar|Param|null, // Default: "POST" + * result_path?: scalar|Param|null, // Default: "/register" * }, - * options_handler?: scalar|null|Param, // Default: "Webauthn\\Bundle\\Security\\Handler\\DefaultCreationOptionsHandler" + * options_handler?: scalar|Param|null, // Default: "Webauthn\\Bundle\\Security\\Handler\\DefaultCreationOptionsHandler" * }, * }, * x509?: array{ - * provider?: scalar|null|Param, - * user?: scalar|null|Param, // Default: "SSL_CLIENT_S_DN_Email" - * credentials?: scalar|null|Param, // Default: "SSL_CLIENT_S_DN" - * user_identifier?: scalar|null|Param, // Default: "emailAddress" + * provider?: scalar|Param|null, + * user?: scalar|Param|null, // Default: "SSL_CLIENT_S_DN_Email" + * credentials?: scalar|Param|null, // Default: "SSL_CLIENT_S_DN" + * user_identifier?: scalar|Param|null, // Default: "emailAddress" * }, * remote_user?: array{ - * provider?: scalar|null|Param, - * user?: scalar|null|Param, // Default: "REMOTE_USER" + * provider?: scalar|Param|null, + * user?: scalar|Param|null, // Default: "REMOTE_USER" * }, * login_link?: array{ - * check_route: scalar|null|Param, // Route that will validate the login link - e.g. "app_login_link_verify". - * check_post_only?: scalar|null|Param, // If true, only HTTP POST requests to "check_route" will be handled by the authenticator. // Default: false - * signature_properties: list, + * check_route: scalar|Param|null, // Route that will validate the login link - e.g. "app_login_link_verify". + * check_post_only?: scalar|Param|null, // If true, only HTTP POST requests to "check_route" will be handled by the authenticator. // Default: false + * signature_properties: list, * lifetime?: int|Param, // The lifetime of the login link in seconds. // Default: 600 * max_uses?: int|Param, // Max number of times a login link can be used - null means unlimited within lifetime. // Default: null - * used_link_cache?: scalar|null|Param, // Cache service id used to expired links of max_uses is set. - * success_handler?: scalar|null|Param, // A service id that implements Symfony\Component\Security\Http\Authentication\AuthenticationSuccessHandlerInterface. - * failure_handler?: scalar|null|Param, // A service id that implements Symfony\Component\Security\Http\Authentication\AuthenticationFailureHandlerInterface. - * provider?: scalar|null|Param, // The user provider to load users from. - * secret?: scalar|null|Param, // Default: "%kernel.secret%" + * used_link_cache?: scalar|Param|null, // Cache service id used to expired links of max_uses is set. + * success_handler?: scalar|Param|null, // A service id that implements Symfony\Component\Security\Http\Authentication\AuthenticationSuccessHandlerInterface. + * failure_handler?: scalar|Param|null, // A service id that implements Symfony\Component\Security\Http\Authentication\AuthenticationFailureHandlerInterface. + * provider?: scalar|Param|null, // The user provider to load users from. + * secret?: scalar|Param|null, // Default: "%kernel.secret%" * always_use_default_target_path?: bool|Param, // Default: false - * default_target_path?: scalar|null|Param, // Default: "/" - * login_path?: scalar|null|Param, // Default: "/login" - * target_path_parameter?: scalar|null|Param, // Default: "_target_path" + * default_target_path?: scalar|Param|null, // Default: "/" + * login_path?: scalar|Param|null, // Default: "/login" + * target_path_parameter?: scalar|Param|null, // Default: "_target_path" * use_referer?: bool|Param, // Default: false - * failure_path?: scalar|null|Param, // Default: null + * failure_path?: scalar|Param|null, // Default: null * failure_forward?: bool|Param, // Default: false - * failure_path_parameter?: scalar|null|Param, // Default: "_failure_path" + * failure_path_parameter?: scalar|Param|null, // Default: "_failure_path" * }, * form_login?: array{ - * provider?: scalar|null|Param, + * provider?: scalar|Param|null, * remember_me?: bool|Param, // Default: true - * success_handler?: scalar|null|Param, - * failure_handler?: scalar|null|Param, - * check_path?: scalar|null|Param, // Default: "/login_check" + * success_handler?: scalar|Param|null, + * failure_handler?: scalar|Param|null, + * check_path?: scalar|Param|null, // Default: "/login_check" * use_forward?: bool|Param, // Default: false - * login_path?: scalar|null|Param, // Default: "/login" - * username_parameter?: scalar|null|Param, // Default: "_username" - * password_parameter?: scalar|null|Param, // Default: "_password" - * csrf_parameter?: scalar|null|Param, // Default: "_csrf_token" - * csrf_token_id?: scalar|null|Param, // Default: "authenticate" + * login_path?: scalar|Param|null, // Default: "/login" + * username_parameter?: scalar|Param|null, // Default: "_username" + * password_parameter?: scalar|Param|null, // Default: "_password" + * csrf_parameter?: scalar|Param|null, // Default: "_csrf_token" + * csrf_token_id?: scalar|Param|null, // Default: "authenticate" * enable_csrf?: bool|Param, // Default: false * post_only?: bool|Param, // Default: true * form_only?: bool|Param, // Default: false * always_use_default_target_path?: bool|Param, // Default: false - * default_target_path?: scalar|null|Param, // Default: "/" - * target_path_parameter?: scalar|null|Param, // Default: "_target_path" + * default_target_path?: scalar|Param|null, // Default: "/" + * target_path_parameter?: scalar|Param|null, // Default: "_target_path" * use_referer?: bool|Param, // Default: false - * failure_path?: scalar|null|Param, // Default: null + * failure_path?: scalar|Param|null, // Default: null * failure_forward?: bool|Param, // Default: false - * failure_path_parameter?: scalar|null|Param, // Default: "_failure_path" + * failure_path_parameter?: scalar|Param|null, // Default: "_failure_path" * }, * form_login_ldap?: array{ - * provider?: scalar|null|Param, + * provider?: scalar|Param|null, * remember_me?: bool|Param, // Default: true - * success_handler?: scalar|null|Param, - * failure_handler?: scalar|null|Param, - * check_path?: scalar|null|Param, // Default: "/login_check" + * success_handler?: scalar|Param|null, + * failure_handler?: scalar|Param|null, + * check_path?: scalar|Param|null, // Default: "/login_check" * use_forward?: bool|Param, // Default: false - * login_path?: scalar|null|Param, // Default: "/login" - * username_parameter?: scalar|null|Param, // Default: "_username" - * password_parameter?: scalar|null|Param, // Default: "_password" - * csrf_parameter?: scalar|null|Param, // Default: "_csrf_token" - * csrf_token_id?: scalar|null|Param, // Default: "authenticate" + * login_path?: scalar|Param|null, // Default: "/login" + * username_parameter?: scalar|Param|null, // Default: "_username" + * password_parameter?: scalar|Param|null, // Default: "_password" + * csrf_parameter?: scalar|Param|null, // Default: "_csrf_token" + * csrf_token_id?: scalar|Param|null, // Default: "authenticate" * enable_csrf?: bool|Param, // Default: false * post_only?: bool|Param, // Default: true * form_only?: bool|Param, // Default: false * always_use_default_target_path?: bool|Param, // Default: false - * default_target_path?: scalar|null|Param, // Default: "/" - * target_path_parameter?: scalar|null|Param, // Default: "_target_path" + * default_target_path?: scalar|Param|null, // Default: "/" + * target_path_parameter?: scalar|Param|null, // Default: "_target_path" * use_referer?: bool|Param, // Default: false - * failure_path?: scalar|null|Param, // Default: null + * failure_path?: scalar|Param|null, // Default: null * failure_forward?: bool|Param, // Default: false - * failure_path_parameter?: scalar|null|Param, // Default: "_failure_path" - * service?: scalar|null|Param, // Default: "ldap" - * dn_string?: scalar|null|Param, // Default: "{user_identifier}" - * query_string?: scalar|null|Param, - * search_dn?: scalar|null|Param, // Default: "" - * search_password?: scalar|null|Param, // Default: "" + * failure_path_parameter?: scalar|Param|null, // Default: "_failure_path" + * service?: scalar|Param|null, // Default: "ldap" + * dn_string?: scalar|Param|null, // Default: "{user_identifier}" + * query_string?: scalar|Param|null, + * search_dn?: scalar|Param|null, // Default: "" + * search_password?: scalar|Param|null, // Default: "" * }, * json_login?: array{ - * provider?: scalar|null|Param, + * provider?: scalar|Param|null, * remember_me?: bool|Param, // Default: true - * success_handler?: scalar|null|Param, - * failure_handler?: scalar|null|Param, - * check_path?: scalar|null|Param, // Default: "/login_check" + * success_handler?: scalar|Param|null, + * failure_handler?: scalar|Param|null, + * check_path?: scalar|Param|null, // Default: "/login_check" * use_forward?: bool|Param, // Default: false - * login_path?: scalar|null|Param, // Default: "/login" - * username_path?: scalar|null|Param, // Default: "username" - * password_path?: scalar|null|Param, // Default: "password" + * login_path?: scalar|Param|null, // Default: "/login" + * username_path?: scalar|Param|null, // Default: "username" + * password_path?: scalar|Param|null, // Default: "password" * }, * json_login_ldap?: array{ - * provider?: scalar|null|Param, + * provider?: scalar|Param|null, * remember_me?: bool|Param, // Default: true - * success_handler?: scalar|null|Param, - * failure_handler?: scalar|null|Param, - * check_path?: scalar|null|Param, // Default: "/login_check" + * success_handler?: scalar|Param|null, + * failure_handler?: scalar|Param|null, + * check_path?: scalar|Param|null, // Default: "/login_check" * use_forward?: bool|Param, // Default: false - * login_path?: scalar|null|Param, // Default: "/login" - * username_path?: scalar|null|Param, // Default: "username" - * password_path?: scalar|null|Param, // Default: "password" - * service?: scalar|null|Param, // Default: "ldap" - * dn_string?: scalar|null|Param, // Default: "{user_identifier}" - * query_string?: scalar|null|Param, - * search_dn?: scalar|null|Param, // Default: "" - * search_password?: scalar|null|Param, // Default: "" + * login_path?: scalar|Param|null, // Default: "/login" + * username_path?: scalar|Param|null, // Default: "username" + * password_path?: scalar|Param|null, // Default: "password" + * service?: scalar|Param|null, // Default: "ldap" + * dn_string?: scalar|Param|null, // Default: "{user_identifier}" + * query_string?: scalar|Param|null, + * search_dn?: scalar|Param|null, // Default: "" + * search_password?: scalar|Param|null, // Default: "" * }, * access_token?: array{ - * provider?: scalar|null|Param, + * provider?: scalar|Param|null, * remember_me?: bool|Param, // Default: true - * success_handler?: scalar|null|Param, - * failure_handler?: scalar|null|Param, - * realm?: scalar|null|Param, // Default: null - * token_extractors?: list, + * success_handler?: scalar|Param|null, + * failure_handler?: scalar|Param|null, + * realm?: scalar|Param|null, // Default: null + * token_extractors?: list, * token_handler: string|array{ - * id?: scalar|null|Param, + * id?: scalar|Param|null, * oidc_user_info?: string|array{ - * base_uri: scalar|null|Param, // Base URI of the userinfo endpoint on the OIDC server, or the OIDC server URI to use the discovery (require "discovery" to be configured). + * base_uri: scalar|Param|null, // Base URI of the userinfo endpoint on the OIDC server, or the OIDC server URI to use the discovery (require "discovery" to be configured). * discovery?: array{ // Enable the OIDC discovery. * cache?: array{ - * id: scalar|null|Param, // Cache service id to use to cache the OIDC discovery configuration. + * id: scalar|Param|null, // Cache service id to use to cache the OIDC discovery configuration. * }, * }, - * claim?: scalar|null|Param, // Claim which contains the user identifier (e.g. sub, email, etc.). // Default: "sub" - * client?: scalar|null|Param, // HttpClient service id to use to call the OIDC server. + * claim?: scalar|Param|null, // Claim which contains the user identifier (e.g. sub, email, etc.). // Default: "sub" + * client?: scalar|Param|null, // HttpClient service id to use to call the OIDC server. * }, * oidc?: array{ * discovery?: array{ // Enable the OIDC discovery. - * base_uri: list, + * base_uri: list, * cache?: array{ - * id: scalar|null|Param, // Cache service id to use to cache the OIDC discovery configuration. + * id: scalar|Param|null, // Cache service id to use to cache the OIDC discovery configuration. * }, * }, - * claim?: scalar|null|Param, // Claim which contains the user identifier (e.g.: sub, email..). // Default: "sub" - * audience: scalar|null|Param, // Audience set in the token, for validation purpose. - * issuers: list, + * claim?: scalar|Param|null, // Claim which contains the user identifier (e.g.: sub, email..). // Default: "sub" + * audience: scalar|Param|null, // Audience set in the token, for validation purpose. + * issuers: list, * algorithm?: array, - * algorithms: list, - * key?: scalar|null|Param, // Deprecated: The "key" option is deprecated and will be removed in 8.0. Use the "keyset" option instead. // JSON-encoded JWK used to sign the token (must contain a "kty" key). - * keyset?: scalar|null|Param, // JSON-encoded JWKSet used to sign the token (must contain a list of valid public keys). + * algorithms: list, + * key?: scalar|Param|null, // Deprecated: The "key" option is deprecated and will be removed in 8.0. Use the "keyset" option instead. // JSON-encoded JWK used to sign the token (must contain a "kty" key). + * keyset?: scalar|Param|null, // JSON-encoded JWKSet used to sign the token (must contain a list of valid public keys). * encryption?: bool|array{ * enabled?: bool|Param, // Default: false * enforce?: bool|Param, // When enabled, the token shall be encrypted. // Default: false - * algorithms: list, - * keyset: scalar|null|Param, // JSON-encoded JWKSet used to decrypt the token (must contain a list of valid private keys). + * algorithms: list, + * keyset: scalar|Param|null, // JSON-encoded JWKSet used to decrypt the token (must contain a list of valid private keys). * }, * }, * cas?: array{ - * validation_url: scalar|null|Param, // CAS server validation URL - * prefix?: scalar|null|Param, // CAS prefix // Default: "cas" - * http_client?: scalar|null|Param, // HTTP Client service // Default: null + * validation_url: scalar|Param|null, // CAS server validation URL + * prefix?: scalar|Param|null, // CAS prefix // Default: "cas" + * http_client?: scalar|Param|null, // HTTP Client service // Default: null * }, - * oauth2?: scalar|null|Param, + * oauth2?: scalar|Param|null, * }, * }, * http_basic?: array{ - * provider?: scalar|null|Param, - * realm?: scalar|null|Param, // Default: "Secured Area" + * provider?: scalar|Param|null, + * realm?: scalar|Param|null, // Default: "Secured Area" * }, * http_basic_ldap?: array{ - * provider?: scalar|null|Param, - * realm?: scalar|null|Param, // Default: "Secured Area" - * service?: scalar|null|Param, // Default: "ldap" - * dn_string?: scalar|null|Param, // Default: "{user_identifier}" - * query_string?: scalar|null|Param, - * search_dn?: scalar|null|Param, // Default: "" - * search_password?: scalar|null|Param, // Default: "" + * provider?: scalar|Param|null, + * realm?: scalar|Param|null, // Default: "Secured Area" + * service?: scalar|Param|null, // Default: "ldap" + * dn_string?: scalar|Param|null, // Default: "{user_identifier}" + * query_string?: scalar|Param|null, + * search_dn?: scalar|Param|null, // Default: "" + * search_password?: scalar|Param|null, // Default: "" * }, * remember_me?: array{ - * secret?: scalar|null|Param, // Default: "%kernel.secret%" - * service?: scalar|null|Param, - * user_providers?: list, + * secret?: scalar|Param|null, // Default: "%kernel.secret%" + * service?: scalar|Param|null, + * user_providers?: list, * catch_exceptions?: bool|Param, // Default: true - * signature_properties?: list, + * signature_properties?: list, * token_provider?: string|array{ - * service?: scalar|null|Param, // The service ID of a custom remember-me token provider. + * service?: scalar|Param|null, // The service ID of a custom remember-me token provider. * doctrine?: bool|array{ * enabled?: bool|Param, // Default: false - * connection?: scalar|null|Param, // Default: null + * connection?: scalar|Param|null, // Default: null * }, * }, - * token_verifier?: scalar|null|Param, // The service ID of a custom rememberme token verifier. - * name?: scalar|null|Param, // Default: "REMEMBERME" + * token_verifier?: scalar|Param|null, // The service ID of a custom rememberme token verifier. + * name?: scalar|Param|null, // Default: "REMEMBERME" * lifetime?: int|Param, // Default: 31536000 - * path?: scalar|null|Param, // Default: "/" - * domain?: scalar|null|Param, // Default: null + * path?: scalar|Param|null, // Default: "/" + * domain?: scalar|Param|null, // Default: null * secure?: true|false|"auto"|Param, // Default: null * httponly?: bool|Param, // Default: true * samesite?: null|"lax"|"strict"|"none"|Param, // Default: "lax" * always_remember_me?: bool|Param, // Default: false - * remember_me_parameter?: scalar|null|Param, // Default: "_remember_me" + * remember_me_parameter?: scalar|Param|null, // Default: "_remember_me" * }, * }>, * access_control?: list, - * attributes?: array, - * route?: scalar|null|Param, // Default: null - * methods?: list, - * allow_if?: scalar|null|Param, // Default: null - * roles?: list, + * ips?: list, + * attributes?: array, + * route?: scalar|Param|null, // Default: null + * methods?: list, + * allow_if?: scalar|Param|null, // Default: null + * roles?: list, * }>, - * role_hierarchy?: array>, + * role_hierarchy?: array>, * } * @psalm-type DoctrineConfig = array{ * dbal?: array{ - * default_connection?: scalar|null|Param, + * default_connection?: scalar|Param|null, * types?: array, - * driver_schemes?: array, + * driver_schemes?: array, * connections?: array, - * mapping_types?: array, - * default_table_options?: array, - * schema_manager_factory?: scalar|null|Param, // Default: "doctrine.dbal.default_schema_manager_factory" - * result_cache?: scalar|null|Param, - * slaves?: array, + * mapping_types?: array, + * default_table_options?: array, + * schema_manager_factory?: scalar|Param|null, // Default: "doctrine.dbal.default_schema_manager_factory" + * result_cache?: scalar|Param|null, * replicas?: array, * }>, * }, * orm?: array{ - * default_entity_manager?: scalar|null|Param, - * auto_generate_proxy_classes?: scalar|null|Param, // Auto generate mode possible values are: "NEVER", "ALWAYS", "FILE_NOT_EXISTS", "EVAL", "FILE_NOT_EXISTS_OR_CHANGED", this option is ignored when the "enable_native_lazy_objects" option is true // Default: false - * enable_lazy_ghost_objects?: bool|Param, // Enables the new implementation of proxies based on lazy ghosts instead of using the legacy implementation // Default: true - * enable_native_lazy_objects?: bool|Param, // Enables the new native implementation of PHP lazy objects instead of generated proxies // Default: false - * proxy_dir?: scalar|null|Param, // Configures the path where generated proxy classes are saved when using non-native lazy objects, this option is ignored when the "enable_native_lazy_objects" option is true // Default: "%kernel.build_dir%/doctrine/orm/Proxies" - * proxy_namespace?: scalar|null|Param, // Defines the root namespace for generated proxy classes when using non-native lazy objects, this option is ignored when the "enable_native_lazy_objects" option is true // Default: "Proxies" + * default_entity_manager?: scalar|Param|null, + * enable_native_lazy_objects?: bool|Param, // Deprecated: The "enable_native_lazy_objects" option is deprecated and will be removed in DoctrineBundle 4.0, as native lazy objects are now always enabled. // Default: true * controller_resolver?: bool|array{ * enabled?: bool|Param, // Default: true - * auto_mapping?: bool|null|Param, // Set to false to disable using route placeholders as lookup criteria when the primary key doesn't match the argument name // Default: null + * auto_mapping?: bool|Param, // Deprecated: The "doctrine.orm.controller_resolver.auto_mapping.auto_mapping" option is deprecated and will be removed in DoctrineBundle 4.0, as it only accepts `false` since 3.0. // Set to true to enable using route placeholders as lookup criteria when the primary key doesn't match the argument name // Default: false * evict_cache?: bool|Param, // Set to true to fetch the entity from the database instead of using the cache, if any // Default: false * }, * entity_managers?: array, * }>, * }>, * }, - * connection?: scalar|null|Param, - * class_metadata_factory_name?: scalar|null|Param, // Default: "Doctrine\\ORM\\Mapping\\ClassMetadataFactory" - * default_repository_class?: scalar|null|Param, // Default: "Doctrine\\ORM\\EntityRepository" - * auto_mapping?: scalar|null|Param, // Default: false - * naming_strategy?: scalar|null|Param, // Default: "doctrine.orm.naming_strategy.default" - * quote_strategy?: scalar|null|Param, // Default: "doctrine.orm.quote_strategy.default" - * typed_field_mapper?: scalar|null|Param, // Default: "doctrine.orm.typed_field_mapper.default" - * entity_listener_resolver?: scalar|null|Param, // Default: null - * fetch_mode_subselect_batch_size?: scalar|null|Param, - * repository_factory?: scalar|null|Param, // Default: "doctrine.orm.container_repository_factory" - * schema_ignore_classes?: list, - * report_fields_where_declared?: bool|Param, // Set to "true" to opt-in to the new mapping driver mode that was added in Doctrine ORM 2.16 and will be mandatory in ORM 3.0. See https://github.com/doctrine/orm/pull/10455. // Default: true - * validate_xml_mapping?: bool|Param, // Set to "true" to opt-in to the new mapping driver mode that was added in Doctrine ORM 2.14. See https://github.com/doctrine/orm/pull/6728. // Default: false + * connection?: scalar|Param|null, + * class_metadata_factory_name?: scalar|Param|null, // Default: "Doctrine\\ORM\\Mapping\\ClassMetadataFactory" + * default_repository_class?: scalar|Param|null, // Default: "Doctrine\\ORM\\EntityRepository" + * auto_mapping?: scalar|Param|null, // Default: false + * naming_strategy?: scalar|Param|null, // Default: "doctrine.orm.naming_strategy.default" + * quote_strategy?: scalar|Param|null, // Default: "doctrine.orm.quote_strategy.default" + * typed_field_mapper?: scalar|Param|null, // Default: "doctrine.orm.typed_field_mapper.default" + * entity_listener_resolver?: scalar|Param|null, // Default: null + * fetch_mode_subselect_batch_size?: scalar|Param|null, + * repository_factory?: scalar|Param|null, // Default: "doctrine.orm.container_repository_factory" + * schema_ignore_classes?: list, + * validate_xml_mapping?: bool|Param, // Set to "true" to opt-in to the new mapping driver mode that was added in Doctrine ORM 2.14 and will be mandatory in ORM 3.0. See https://github.com/doctrine/orm/pull/6728. // Default: false * second_level_cache?: array{ * region_cache_driver?: string|array{ - * type?: scalar|null|Param, // Default: null - * id?: scalar|null|Param, - * pool?: scalar|null|Param, + * type?: scalar|Param|null, // Default: null + * id?: scalar|Param|null, + * pool?: scalar|Param|null, * }, - * region_lock_lifetime?: scalar|null|Param, // Default: 60 + * region_lock_lifetime?: scalar|Param|null, // Default: 60 * log_enabled?: bool|Param, // Default: true - * region_lifetime?: scalar|null|Param, // Default: 3600 + * region_lifetime?: scalar|Param|null, // Default: 3600 * enabled?: bool|Param, // Default: true - * factory?: scalar|null|Param, + * factory?: scalar|Param|null, * regions?: array, * loggers?: array, * }, - * hydrators?: array, + * hydrators?: array, * mappings?: array, * dql?: array{ - * string_functions?: array, - * numeric_functions?: array, - * datetime_functions?: array, + * string_functions?: array, + * numeric_functions?: array, + * datetime_functions?: array, * }, * filters?: array, * }>, - * identity_generation_preferences?: array, + * identity_generation_preferences?: array, * }>, - * resolve_target_entities?: array, + * resolve_target_entities?: array, * }, * } * @psalm-type DoctrineMigrationsConfig = array{ * enable_service_migrations?: bool|Param, // Whether to enable fetching migrations from the service container. // Default: false - * migrations_paths?: array, - * services?: array, - * factories?: array, + * migrations_paths?: array, + * services?: array, + * factories?: array, * storage?: array{ // Storage to use for migration status metadata. * table_storage?: array{ // The default metadata storage, implemented as a table in the database. - * table_name?: scalar|null|Param, // Default: null - * version_column_name?: scalar|null|Param, // Default: null - * version_column_length?: scalar|null|Param, // Default: null - * executed_at_column_name?: scalar|null|Param, // Default: null - * execution_time_column_name?: scalar|null|Param, // Default: null + * table_name?: scalar|Param|null, // Default: null + * version_column_name?: scalar|Param|null, // Default: null + * version_column_length?: scalar|Param|null, // Default: null + * executed_at_column_name?: scalar|Param|null, // Default: null + * execution_time_column_name?: scalar|Param|null, // Default: null * }, * }, - * migrations?: list, - * connection?: scalar|null|Param, // Connection name to use for the migrations database. // Default: null - * em?: scalar|null|Param, // Entity manager name to use for the migrations database (available when doctrine/orm is installed). // Default: null - * all_or_nothing?: scalar|null|Param, // Run all migrations in a transaction. // Default: false - * check_database_platform?: scalar|null|Param, // Adds an extra check in the generated migrations to allow execution only on the same platform as they were initially generated on. // Default: true - * custom_template?: scalar|null|Param, // Custom template path for generated migration classes. // Default: null - * organize_migrations?: scalar|null|Param, // Organize migrations mode. Possible values are: "BY_YEAR", "BY_YEAR_AND_MONTH", false // Default: false + * migrations?: list, + * connection?: scalar|Param|null, // Connection name to use for the migrations database. // Default: null + * em?: scalar|Param|null, // Entity manager name to use for the migrations database (available when doctrine/orm is installed). // Default: null + * all_or_nothing?: scalar|Param|null, // Run all migrations in a transaction. // Default: false + * check_database_platform?: scalar|Param|null, // Adds an extra check in the generated migrations to allow execution only on the same platform as they were initially generated on. // Default: true + * custom_template?: scalar|Param|null, // Custom template path for generated migration classes. // Default: null + * organize_migrations?: scalar|Param|null, // Organize migrations mode. Possible values are: "BY_YEAR", "BY_YEAR_AND_MONTH", false // Default: false * enable_profiler?: bool|Param, // Whether or not to enable the profiler collector to calculate and visualize migration status. This adds some queries overhead. // Default: false * transactional?: bool|Param, // Whether or not to wrap migrations in a single transaction. // Default: true * } * @psalm-type MonologConfig = array{ - * use_microseconds?: scalar|null|Param, // Default: true - * channels?: list, + * use_microseconds?: scalar|Param|null, // Default: true + * channels?: list, * handlers?: array, + * passthru_level?: scalar|Param|null, // Default: null + * excluded_404s?: list, * excluded_http_codes?: list, + * code?: scalar|Param|null, + * urls?: list, * }>, - * accepted_levels?: list, - * min_level?: scalar|null|Param, // Default: "DEBUG" - * max_level?: scalar|null|Param, // Default: "EMERGENCY" - * buffer_size?: scalar|null|Param, // Default: 0 + * accepted_levels?: list, + * min_level?: scalar|Param|null, // Default: "DEBUG" + * max_level?: scalar|Param|null, // Default: "EMERGENCY" + * buffer_size?: scalar|Param|null, // Default: 0 * flush_on_overflow?: bool|Param, // Default: false - * handler?: scalar|null|Param, - * url?: scalar|null|Param, - * exchange?: scalar|null|Param, - * exchange_name?: scalar|null|Param, // Default: "log" - * room?: scalar|null|Param, - * message_format?: scalar|null|Param, // Default: "text" - * api_version?: scalar|null|Param, // Default: null - * channel?: scalar|null|Param, // Default: null - * bot_name?: scalar|null|Param, // Default: "Monolog" - * use_attachment?: scalar|null|Param, // Default: true - * use_short_attachment?: scalar|null|Param, // Default: false - * include_extra?: scalar|null|Param, // Default: false - * icon_emoji?: scalar|null|Param, // Default: null - * webhook_url?: scalar|null|Param, - * exclude_fields?: list, - * team?: scalar|null|Param, - * notify?: scalar|null|Param, // Default: false - * nickname?: scalar|null|Param, // Default: "Monolog" - * token?: scalar|null|Param, - * region?: scalar|null|Param, - * source?: scalar|null|Param, + * handler?: scalar|Param|null, + * url?: scalar|Param|null, + * exchange?: scalar|Param|null, + * exchange_name?: scalar|Param|null, // Default: "log" + * room?: scalar|Param|null, + * message_format?: scalar|Param|null, // Default: "text" + * api_version?: scalar|Param|null, // Default: null + * channel?: scalar|Param|null, // Default: null + * bot_name?: scalar|Param|null, // Default: "Monolog" + * use_attachment?: scalar|Param|null, // Default: true + * use_short_attachment?: scalar|Param|null, // Default: false + * include_extra?: scalar|Param|null, // Default: false + * icon_emoji?: scalar|Param|null, // Default: null + * webhook_url?: scalar|Param|null, + * exclude_fields?: list, + * team?: scalar|Param|null, + * notify?: scalar|Param|null, // Default: false + * nickname?: scalar|Param|null, // Default: "Monolog" + * token?: scalar|Param|null, + * region?: scalar|Param|null, + * source?: scalar|Param|null, * use_ssl?: bool|Param, // Default: true * user?: mixed, - * title?: scalar|null|Param, // Default: null - * host?: scalar|null|Param, // Default: null - * port?: scalar|null|Param, // Default: 514 - * config?: list, - * members?: list, - * connection_string?: scalar|null|Param, - * timeout?: scalar|null|Param, - * time?: scalar|null|Param, // Default: 60 - * deduplication_level?: scalar|null|Param, // Default: 400 - * store?: scalar|null|Param, // Default: null - * connection_timeout?: scalar|null|Param, + * title?: scalar|Param|null, // Default: null + * host?: scalar|Param|null, // Default: null + * port?: scalar|Param|null, // Default: 514 + * config?: list, + * members?: list, + * connection_string?: scalar|Param|null, + * timeout?: scalar|Param|null, + * time?: scalar|Param|null, // Default: 60 + * deduplication_level?: scalar|Param|null, // Default: 400 + * store?: scalar|Param|null, // Default: null + * connection_timeout?: scalar|Param|null, * persistent?: bool|Param, - * dsn?: scalar|null|Param, - * hub_id?: scalar|null|Param, // Default: null - * client_id?: scalar|null|Param, // Default: null - * auto_log_stacks?: scalar|null|Param, // Default: false - * release?: scalar|null|Param, // Default: null - * environment?: scalar|null|Param, // Default: null - * message_type?: scalar|null|Param, // Default: 0 - * parse_mode?: scalar|null|Param, // Default: null - * disable_webpage_preview?: bool|null|Param, // Default: null - * disable_notification?: bool|null|Param, // Default: null + * dsn?: scalar|Param|null, + * hub_id?: scalar|Param|null, // Default: null + * client_id?: scalar|Param|null, // Default: null + * auto_log_stacks?: scalar|Param|null, // Default: false + * release?: scalar|Param|null, // Default: null + * environment?: scalar|Param|null, // Default: null + * message_type?: scalar|Param|null, // Default: 0 + * parse_mode?: scalar|Param|null, // Default: null + * disable_webpage_preview?: bool|Param|null, // Default: null + * disable_notification?: bool|Param|null, // Default: null * split_long_messages?: bool|Param, // Default: false * delay_between_messages?: bool|Param, // Default: false * topic?: int|Param, // Default: null * factor?: int|Param, // Default: 1 - * tags?: list, + * tags?: list, * console_formater_options?: mixed, // Deprecated: "monolog.handlers..console_formater_options.console_formater_options" is deprecated, use "monolog.handlers..console_formater_options.console_formatter_options" instead. * console_formatter_options?: mixed, // Default: [] - * formatter?: scalar|null|Param, + * formatter?: scalar|Param|null, * nested?: bool|Param, // Default: false * publisher?: string|array{ - * id?: scalar|null|Param, - * hostname?: scalar|null|Param, - * port?: scalar|null|Param, // Default: 12201 - * chunk_size?: scalar|null|Param, // Default: 1420 + * id?: scalar|Param|null, + * hostname?: scalar|Param|null, + * port?: scalar|Param|null, // Default: 12201 + * chunk_size?: scalar|Param|null, // Default: 1420 * encoder?: "json"|"compressed_json"|Param, * }, * mongo?: string|array{ - * id?: scalar|null|Param, - * host?: scalar|null|Param, - * port?: scalar|null|Param, // Default: 27017 - * user?: scalar|null|Param, - * pass?: scalar|null|Param, - * database?: scalar|null|Param, // Default: "monolog" - * collection?: scalar|null|Param, // Default: "logs" + * id?: scalar|Param|null, + * host?: scalar|Param|null, + * port?: scalar|Param|null, // Default: 27017 + * user?: scalar|Param|null, + * pass?: scalar|Param|null, + * database?: scalar|Param|null, // Default: "monolog" + * collection?: scalar|Param|null, // Default: "logs" * }, * mongodb?: string|array{ - * id?: scalar|null|Param, // ID of a MongoDB\Client service - * uri?: scalar|null|Param, - * username?: scalar|null|Param, - * password?: scalar|null|Param, - * database?: scalar|null|Param, // Default: "monolog" - * collection?: scalar|null|Param, // Default: "logs" + * id?: scalar|Param|null, // ID of a MongoDB\Client service + * uri?: scalar|Param|null, + * username?: scalar|Param|null, + * password?: scalar|Param|null, + * database?: scalar|Param|null, // Default: "monolog" + * collection?: scalar|Param|null, // Default: "logs" * }, * elasticsearch?: string|array{ - * id?: scalar|null|Param, - * hosts?: list, - * host?: scalar|null|Param, - * port?: scalar|null|Param, // Default: 9200 - * transport?: scalar|null|Param, // Default: "Http" - * user?: scalar|null|Param, // Default: null - * password?: scalar|null|Param, // Default: null + * id?: scalar|Param|null, + * hosts?: list, + * host?: scalar|Param|null, + * port?: scalar|Param|null, // Default: 9200 + * transport?: scalar|Param|null, // Default: "Http" + * user?: scalar|Param|null, // Default: null + * password?: scalar|Param|null, // Default: null * }, - * index?: scalar|null|Param, // Default: "monolog" - * document_type?: scalar|null|Param, // Default: "logs" - * ignore_error?: scalar|null|Param, // Default: false + * index?: scalar|Param|null, // Default: "monolog" + * document_type?: scalar|Param|null, // Default: "logs" + * ignore_error?: scalar|Param|null, // Default: false * redis?: string|array{ - * id?: scalar|null|Param, - * host?: scalar|null|Param, - * password?: scalar|null|Param, // Default: null - * port?: scalar|null|Param, // Default: 6379 - * database?: scalar|null|Param, // Default: 0 - * key_name?: scalar|null|Param, // Default: "monolog_redis" + * id?: scalar|Param|null, + * host?: scalar|Param|null, + * password?: scalar|Param|null, // Default: null + * port?: scalar|Param|null, // Default: 6379 + * database?: scalar|Param|null, // Default: 0 + * key_name?: scalar|Param|null, // Default: "monolog_redis" * }, * predis?: string|array{ - * id?: scalar|null|Param, - * host?: scalar|null|Param, + * id?: scalar|Param|null, + * host?: scalar|Param|null, * }, - * from_email?: scalar|null|Param, - * to_email?: list, - * subject?: scalar|null|Param, - * content_type?: scalar|null|Param, // Default: null - * headers?: list, - * mailer?: scalar|null|Param, // Default: null + * from_email?: scalar|Param|null, + * to_email?: list, + * subject?: scalar|Param|null, + * content_type?: scalar|Param|null, // Default: null + * headers?: list, + * mailer?: scalar|Param|null, // Default: null * email_prototype?: string|array{ - * id: scalar|null|Param, - * method?: scalar|null|Param, // Default: null + * id: scalar|Param|null, + * method?: scalar|Param|null, // Default: null * }, * lazy?: bool|Param, // Default: true * verbosity_levels?: array{ - * VERBOSITY_QUIET?: scalar|null|Param, // Default: "ERROR" - * VERBOSITY_NORMAL?: scalar|null|Param, // Default: "WARNING" - * VERBOSITY_VERBOSE?: scalar|null|Param, // Default: "NOTICE" - * VERBOSITY_VERY_VERBOSE?: scalar|null|Param, // Default: "INFO" - * VERBOSITY_DEBUG?: scalar|null|Param, // Default: "DEBUG" + * VERBOSITY_QUIET?: scalar|Param|null, // Default: "ERROR" + * VERBOSITY_NORMAL?: scalar|Param|null, // Default: "WARNING" + * VERBOSITY_VERBOSE?: scalar|Param|null, // Default: "NOTICE" + * VERBOSITY_VERY_VERBOSE?: scalar|Param|null, // Default: "INFO" + * VERBOSITY_DEBUG?: scalar|Param|null, // Default: "DEBUG" * }, * channels?: string|array{ - * type?: scalar|null|Param, - * elements?: list, + * type?: scalar|Param|null, + * elements?: list, * }, * }>, * } @@ -1494,124 +1449,123 @@ * ajax_replace?: bool|Param, // Replace toolbar on AJAX requests // Default: false * }, * intercept_redirects?: bool|Param, // Default: false - * excluded_ajax_paths?: scalar|null|Param, // Default: "^/((index|app(_[\\w]+)?)\\.php/)?_wdt" + * excluded_ajax_paths?: scalar|Param|null, // Default: "^/((index|app(_[\\w]+)?)\\.php/)?_wdt" * } * @psalm-type DebugConfig = array{ * max_items?: int|Param, // Max number of displayed items past the first level, -1 means no limit. // Default: 2500 * min_depth?: int|Param, // Minimum tree depth to clone all the items, 1 is default. // Default: 1 * max_string_length?: int|Param, // Max length of displayed strings, -1 means no limit. // Default: -1 - * dump_destination?: scalar|null|Param, // A stream URL where dumps should be written to. // Default: null + * dump_destination?: scalar|Param|null, // A stream URL where dumps should be written to. // Default: null * theme?: "dark"|"light"|Param, // Changes the color of the dump() output when rendered directly on the templating. "dark" (default) or "light". // Default: "dark" * } * @psalm-type MakerConfig = array{ - * root_namespace?: scalar|null|Param, // Default: "App" + * root_namespace?: scalar|Param|null, // Default: "App" * generate_final_classes?: bool|Param, // Default: true * generate_final_entities?: bool|Param, // Default: false * } * @psalm-type SymfonycastsResetPasswordConfig = array{ - * request_password_repository: scalar|null|Param, // A class that implements ResetPasswordRequestRepositoryInterface - usually your ResetPasswordRequestRepository. + * request_password_repository: scalar|Param|null, // A class that implements ResetPasswordRequestRepositoryInterface - usually your ResetPasswordRequestRepository. * lifetime?: int|Param, // The length of time in seconds that a password reset request is valid for after it is created. // Default: 3600 * throttle_limit?: int|Param, // Another password reset cannot be made faster than this throttle time in seconds. // Default: 3600 * enable_garbage_collection?: bool|Param, // Enable/Disable automatic garbage collection. // Default: true * } * @psalm-type WebauthnConfig = array{ - * fake_credential_generator?: scalar|null|Param, // A service that implements the FakeCredentialGenerator to generate fake credentials for preventing username enumeration. // Default: "Webauthn\\SimpleFakeCredentialGenerator" - * clock?: scalar|null|Param, // PSR-20 Clock service. // Default: "webauthn.clock.default" - * options_storage?: scalar|null|Param, // Service responsible of the options/user entity storage during the ceremony // Default: "Webauthn\\Bundle\\Security\\Storage\\SessionStorage" - * event_dispatcher?: scalar|null|Param, // PSR-14 Event Dispatcher service. // Default: "Psr\\EventDispatcher\\EventDispatcherInterface" - * http_client?: scalar|null|Param, // A Symfony HTTP client. // Default: "webauthn.http_client.default" - * logger?: scalar|null|Param, // A PSR-3 logger to receive logs during the processes // Default: "webauthn.logger.default" - * credential_repository?: scalar|null|Param, // This repository is responsible of the credential storage // Default: "Webauthn\\Bundle\\Repository\\DummyPublicKeyCredentialSourceRepository" - * user_repository?: scalar|null|Param, // This repository is responsible of the user storage // Default: "Webauthn\\Bundle\\Repository\\DummyPublicKeyCredentialUserEntityRepository" - * allowed_origins?: array, + * fake_credential_generator?: scalar|Param|null, // A service that implements the FakeCredentialGenerator to generate fake credentials for preventing username enumeration. // Default: "Webauthn\\SimpleFakeCredentialGenerator" + * clock?: scalar|Param|null, // PSR-20 Clock service. // Default: "webauthn.clock.default" + * options_storage?: scalar|Param|null, // Service responsible of the options/user entity storage during the ceremony // Default: "Webauthn\\Bundle\\Security\\Storage\\SessionStorage" + * event_dispatcher?: scalar|Param|null, // PSR-14 Event Dispatcher service. // Default: "Psr\\EventDispatcher\\EventDispatcherInterface" + * http_client?: scalar|Param|null, // A Symfony HTTP client. // Default: "webauthn.http_client.default" + * logger?: scalar|Param|null, // A PSR-3 logger to receive logs during the processes // Default: "webauthn.logger.default" + * credential_repository?: scalar|Param|null, // This repository is responsible of the credential storage // Default: "Webauthn\\Bundle\\Repository\\DummyPublicKeyCredentialSourceRepository" + * user_repository?: scalar|Param|null, // This repository is responsible of the user storage // Default: "Webauthn\\Bundle\\Repository\\DummyPublicKeyCredentialUserEntityRepository" + * allowed_origins?: array, * allow_subdomains?: bool|Param, // Default: false - * secured_rp_ids?: array, - * counter_checker?: scalar|null|Param, // This service will check if the counter is valid. By default it throws an exception (recommended). // Default: "Webauthn\\Counter\\ThrowExceptionIfInvalid" - * top_origin_validator?: scalar|null|Param, // For cross origin (e.g. iframe), this service will be in charge of verifying the top origin. // Default: null + * secured_rp_ids?: array, + * counter_checker?: scalar|Param|null, // This service will check if the counter is valid. By default it throws an exception (recommended). // Default: "Webauthn\\Counter\\ThrowExceptionIfInvalid" + * top_origin_validator?: scalar|Param|null, // For cross origin (e.g. iframe), this service will be in charge of verifying the top origin. // Default: null * creation_profiles?: array, + * extensions?: array, * public_key_credential_parameters?: list, - * attestation_conveyance?: scalar|null|Param, // Default: "none" + * attestation_conveyance?: scalar|Param|null, // Default: "none" * }>, * request_profiles?: array, + * user_verification?: scalar|Param|null, // Default: "preferred" + * extensions?: array, * }>, * metadata?: bool|array{ // Enable the support of the Metadata Statements. Please read the documentation for this feature. * enabled?: bool|Param, // Default: false - * mds_repository: scalar|null|Param, // The Metadata Statement repository. - * status_report_repository: scalar|null|Param, // The Status Report repository. - * certificate_chain_checker?: scalar|null|Param, // A Certificate Chain checker. // Default: "Webauthn\\MetadataService\\CertificateChain\\PhpCertificateChainValidator" + * mds_repository: scalar|Param|null, // The Metadata Statement repository. + * status_report_repository: scalar|Param|null, // The Status Report repository. + * certificate_chain_checker?: scalar|Param|null, // A Certificate Chain checker. // Default: "Webauthn\\MetadataService\\CertificateChain\\PhpCertificateChainValidator" * }, * controllers?: bool|array{ * enabled?: bool|Param, // Default: false * creation?: array, + * options_method?: scalar|Param|null, // Default: "POST" + * options_path: scalar|Param|null, + * result_method?: scalar|Param|null, // Default: "POST" + * result_path?: scalar|Param|null, // Default: null + * host?: scalar|Param|null, // Default: null + * profile?: scalar|Param|null, // Default: "default" + * options_builder?: scalar|Param|null, // When set, corresponds to the ID of the Public Key Credential Creation Builder. The profile-based ebuilder is ignored. // Default: null + * user_entity_guesser: scalar|Param|null, + * hide_existing_credentials?: scalar|Param|null, // In order to prevent username enumeration, the existing credentials can be hidden. This is highly recommended when the attestation ceremony is performed by anonymous users. // Default: false + * options_storage?: scalar|Param|null, // Deprecated: The child node "options_storage" at path "webauthn.controllers.creation..options_storage" is deprecated. Please use the root option "options_storage" instead. // Service responsible of the options/user entity storage during the ceremony // Default: null + * success_handler?: scalar|Param|null, // Default: "Webauthn\\Bundle\\Service\\DefaultSuccessHandler" + * failure_handler?: scalar|Param|null, // Default: "Webauthn\\Bundle\\Service\\DefaultFailureHandler" + * options_handler?: scalar|Param|null, // Default: "Webauthn\\Bundle\\Security\\Handler\\DefaultCreationOptionsHandler" + * allowed_origins?: array, * allow_subdomains?: bool|Param, // Default: false - * secured_rp_ids?: array, + * secured_rp_ids?: array, * }>, * request?: array, + * options_method?: scalar|Param|null, // Default: "POST" + * options_path: scalar|Param|null, + * result_method?: scalar|Param|null, // Default: "POST" + * result_path?: scalar|Param|null, // Default: null + * host?: scalar|Param|null, // Default: null + * profile?: scalar|Param|null, // Default: "default" + * options_builder?: scalar|Param|null, // When set, corresponds to the ID of the Public Key Credential Creation Builder. The profile-based ebuilder is ignored. // Default: null + * options_storage?: scalar|Param|null, // Deprecated: The child node "options_storage" at path "webauthn.controllers.request..options_storage" is deprecated. Please use the root option "options_storage" instead. // Service responsible of the options/user entity storage during the ceremony // Default: null + * success_handler?: scalar|Param|null, // Default: "Webauthn\\Bundle\\Service\\DefaultSuccessHandler" + * failure_handler?: scalar|Param|null, // Default: "Webauthn\\Bundle\\Service\\DefaultFailureHandler" + * options_handler?: scalar|Param|null, // Default: "Webauthn\\Bundle\\Security\\Handler\\DefaultRequestOptionsHandler" + * allowed_origins?: array, * allow_subdomains?: bool|Param, // Default: false - * secured_rp_ids?: array, + * secured_rp_ids?: array, * }>, * }, * } * @psalm-type StimulusConfig = array{ - * controller_paths?: list, - * controllers_json?: scalar|null|Param, // Default: "%kernel.project_dir%/assets/controllers.json" + * controller_paths?: list, + * controllers_json?: scalar|Param|null, // Default: "%kernel.project_dir%/assets/controllers.json" * } * @psalm-type TurboConfig = array{ * broadcast?: bool|array{ * enabled?: bool|Param, // Default: true - * entity_template_prefixes?: list, + * entity_template_prefixes?: list, * doctrine_orm?: bool|array{ // Enable the Doctrine ORM integration * enabled?: bool|Param, // Default: true * }, * }, - * default_transport?: scalar|null|Param, // Default: "default" + * default_transport?: scalar|Param|null, // Default: "default" * } - * @psalm-type WebauthnStimulusConfig = array * @psalm-type ConfigType = array{ * imports?: ImportsConfig, * parameters?: ParametersConfig, @@ -1626,7 +1580,6 @@ * webauthn?: WebauthnConfig, * stimulus?: StimulusConfig, * turbo?: TurboConfig, - * webauthn_stimulus?: WebauthnStimulusConfig, * "when@dev"?: array{ * imports?: ImportsConfig, * parameters?: ParametersConfig, @@ -1644,7 +1597,6 @@ * webauthn?: WebauthnConfig, * stimulus?: StimulusConfig, * turbo?: TurboConfig, - * webauthn_stimulus?: WebauthnStimulusConfig, * }, * "when@prod"?: array{ * imports?: ImportsConfig, @@ -1660,7 +1612,6 @@ * webauthn?: WebauthnConfig, * stimulus?: StimulusConfig, * turbo?: TurboConfig, - * webauthn_stimulus?: WebauthnStimulusConfig, * }, * "when@redis"?: array{ * imports?: ImportsConfig, @@ -1676,7 +1627,6 @@ * webauthn?: WebauthnConfig, * stimulus?: StimulusConfig, * turbo?: TurboConfig, - * webauthn_stimulus?: WebauthnStimulusConfig, * }, * "when@test"?: array{ * imports?: ImportsConfig, @@ -1694,7 +1644,6 @@ * webauthn?: WebauthnConfig, * stimulus?: StimulusConfig, * turbo?: TurboConfig, - * webauthn_stimulus?: WebauthnStimulusConfig, * }, * ...addSql('CREATE TABLE room_day_statuses (id INT AUTO_INCREMENT NOT NULL, appartment_id INT NOT NULL, assigned_to_id INT DEFAULT NULL, updated_by_id INT DEFAULT NULL, date DATE NOT NULL, hk_status VARCHAR(20) NOT NULL, note LONGTEXT DEFAULT NULL, updated_at DATETIME NOT NULL, UNIQUE INDEX uniq_room_day (appartment_id, date), INDEX IDX_1C76B19393362AA5 (appartment_id), INDEX IDX_1C76B193F91F2105 (assigned_to_id), INDEX IDX_1C76B193896DBBDE (updated_by_id), PRIMARY KEY(id)) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_unicode_ci` ENGINE = InnoDB'); + $this->addSql('ALTER TABLE room_day_statuses ADD CONSTRAINT FK_1C76B19393362AA5 FOREIGN KEY (appartment_id) REFERENCES appartments (id)'); + $this->addSql('ALTER TABLE room_day_statuses ADD CONSTRAINT FK_1C76B193F91F2105 FOREIGN KEY (assigned_to_id) REFERENCES users (id)'); + $this->addSql('ALTER TABLE room_day_statuses ADD CONSTRAINT FK_1C76B193896DBBDE FOREIGN KEY (updated_by_id) REFERENCES users (id)'); + $this->addSql('INSERT INTO roles (id, name, role) VALUES (NULL, "Housekeeping", "ROLE_HOUSEKEEPING")'); + $this->addSql("INSERT INTO template_types (name, icon, service, editor_template) SELECT 'TEMPLATE_OPERATIONS_PDF', 'fa-file-pdf', 'OperationsReportService', 'editor_template_operations.json.twig' WHERE NOT EXISTS (SELECT 1 FROM template_types WHERE name = 'TEMPLATE_OPERATIONS_PDF')"); + $this->addSql("INSERT INTO template_types (name, icon, service, editor_template) SELECT 'TEMPLATE_REGISTRATION_PDF', 'fa-file-pdf', 'ReservationService', 'editor_template_reservation.json.twig' WHERE NOT EXISTS (SELECT 1 FROM template_types WHERE name = 'TEMPLATE_REGISTRATION_PDF')"); + + $this->addSql('ALTER TABLE reservation_status ADD code VARCHAR(50) DEFAULT NULL, ADD is_blocking TINYINT(1) NOT NULL DEFAULT 1'); + $this->addSql('CREATE UNIQUE INDEX UNIQ_RESERVATION_STATUS_CODE ON reservation_status (code)'); + $this->addSql("UPDATE reservation_status SET is_blocking = 1 WHERE is_blocking IS NULL"); + $this->addSql("INSERT INTO reservation_status (name, color, contrast_color, code, is_blocking) SELECT 'Storniert / No-Show', '#6c757d', '#ffffff', 'canceled_noshow', 0 WHERE NOT EXISTS (SELECT 1 FROM reservation_status WHERE code = 'canceled_noshow')"); + + $this->addSql('ALTER TABLE correspondence ADD binary_payload LONGBLOB DEFAULT NULL'); + } + + public function down(Schema $schema): void + { + $this->addSql('DROP TABLE room_day_statuses'); + $this->addSql('DELETE FROM roles WHERE roles.role = "ROLE_HOUSEKEEPING"'); + $this->addSql("DELETE FROM template_types WHERE name = 'TEMPLATE_OPERATIONS_PDF'"); + $this->addSql("DELETE FROM template_types WHERE name = 'TEMPLATE_REGISTRATION_PDF'"); + + $this->addSql("DELETE FROM reservation_status WHERE code = 'canceled_noshow'"); + $this->addSql('DROP INDEX UNIQ_RESERVATION_STATUS_CODE ON reservation_status'); + $this->addSql('ALTER TABLE reservation_status DROP code, DROP is_blocking'); + + $this->addSql('ALTER TABLE correspondence DROP binary_payload'); + + } + + public function isTransactional(): bool + { + return false; + } +} diff --git a/phpstan.neon b/phpstan.dist.neon similarity index 100% rename from phpstan.neon rename to phpstan.dist.neon diff --git a/public/resources/css/editor.css b/public/resources/css/editor.css index 22d72bcb..3551e7d9 100644 --- a/public/resources/css/editor.css +++ b/public/resources/css/editor.css @@ -1,4 +1,4 @@ -.footer { +.footer, .header { border: 1px #DDD dashed; width: 100%; } diff --git a/src/Command/FirstRunCommand.php b/src/Command/FirstRunCommand.php index 65af16e2..3d8e7c38 100644 --- a/src/Command/FirstRunCommand.php +++ b/src/Command/FirstRunCommand.php @@ -203,6 +203,16 @@ private function createTemplateTypes(): void $t6->setName('TEMPLATE_GDPR_PDF'); $t6->setService('CustomerService'); $t6->setEditorTemplate('editor_template_customer.json.twig'); + $t7 = new TemplateType(); + $t7->setIcon('fa-file-pdf'); + $t7->setName('TEMPLATE_OPERATIONS_PDF'); + $t7->setService('OperationsReportService'); + $t7->setEditorTemplate('editor_template_operations.json.twig'); + $t8 = new TemplateType(); + $t8->setIcon('fa-file-pdf'); + $t8->setName('TEMPLATE_REGISTRATION_PDF'); + $t8->setService('ReservationService'); + $t8->setEditorTemplate('editor_template_reservation.json.twig'); $this->em->persist($t1); $this->em->persist($t2); @@ -210,6 +220,8 @@ private function createTemplateTypes(): void $this->em->persist($t4); $this->em->persist($t5); $this->em->persist($t6); + $this->em->persist($t7); + $this->em->persist($t8); } private function createDummyCustomer(): void diff --git a/src/Controller/DashboardRedirectController.php b/src/Controller/DashboardRedirectController.php index 1da54cc5..f8b04f2c 100644 --- a/src/Controller/DashboardRedirectController.php +++ b/src/Controller/DashboardRedirectController.php @@ -25,6 +25,7 @@ public function __invoke(): RedirectResponse { $roleRouteMap = [ 'ROLE_RESERVATIONS' => 'start', + 'ROLE_HOUSEKEEPING' => 'operations.housekeeping', 'ROLE_CUSTOMERS' => 'customers.overview', 'ROLE_INVOICES' => 'invoices.overview', 'ROLE_REGISTRATIONBOOK' => 'registrationbook.overview', diff --git a/src/Controller/HousekeepingController.php b/src/Controller/HousekeepingController.php new file mode 100644 index 00000000..e4e5f6c2 --- /dev/null +++ b/src/Controller/HousekeepingController.php @@ -0,0 +1,193 @@ +getManager(); + $subsidiaries = $em->getRepository(Subsidiary::class)->findAll(); + $subsidiaryId = (string) $request->query->get('subsidiary', 'all'); + $selectedSubsidiary = $filterService->resolveSubsidiary($em, $subsidiaryId); + $selectedDate = $filterService->resolveDate($request->query->get('date')); + $view = $request->query->get('view', 'day'); + $queryParams = $request->query->all(); + $selectedOccupancyTypes = $viewService->normalizeOccupancyTypes($queryParams['occupancyTypes'] ?? null); + + $dayView = null; + $weekView = null; + if ('week' === $view) { + $weekStart = $filterService->resolveWeekStart($selectedDate); + $weekEnd = $weekStart->modify('+6 days'); + $weekView = $viewService->buildWeekView($weekStart, $weekEnd, $selectedSubsidiary); + $weekView = $viewService->filterWeekViewByOccupancy($weekView, $selectedOccupancyTypes); + } else { + $view = 'day'; + $dayView = $viewService->buildDayView($selectedDate, $selectedSubsidiary); + $dayView = $viewService->filterDayViewByOccupancy($dayView, $selectedOccupancyTypes); + } + + $rowForms = []; + $rowFormsMobile = []; + if ('day' === $view && $dayView) { + foreach ($dayView['rows'] as $row) { + $status = $row['status'] ?? new RoomDayStatus(); + $form = $this->createForm(HousekeepingRowType::class, $status, [ + 'date' => $selectedDate->format('Y-m-d'), + ]); + $mobileForm = $this->createForm(HousekeepingRowType::class, $status, [ + 'date' => $selectedDate->format('Y-m-d'), + ]); + $rowForms[$row['apartment']->getId()] = $form->createView(); + $rowFormsMobile[$row['apartment']->getId()] = $mobileForm->createView(); + } + } + + return $this->render('Operations/Housekeeping/index.html.twig', [ + 'subsidiaries' => $subsidiaries, + 'selectedSubsidiaryId' => $subsidiaryId, + 'selectedDate' => $selectedDate, + 'view' => $view, + 'dayView' => $dayView, + 'weekView' => $weekView, + 'rowForms' => $rowForms, + 'rowFormsMobile' => $rowFormsMobile, + 'statusLabels' => $viewService->getStatusLabels(), + 'occupancyLabels' => $viewService->getOccupancyLabels(), + 'occupancyClasses' => $this->getOccupancyClasses(), + 'occupancyTypes' => $viewService->getAllowedOccupancyTypes(), + 'selectedOccupancyTypes' => $selectedOccupancyTypes, + ]); + } + + /** + * Persist changes to the housekeeping status for a room and date. + */ + #[Route('/update/{id}', name: 'operations.housekeeping.update', methods: ['POST'])] + public function updateAction( + ManagerRegistry $doctrine, + Request $request, + Appartment $apartment, + OperationsFilterService $filterService + ): Response { + $em = $doctrine->getManager(); + $formPayload = $request->request->all('housekeeping_row'); + $dateValue = is_array($formPayload) ? (string) ($formPayload['date'] ?? '') : ''; + $date = $filterService->resolveDate($dateValue); + + $status = $em->getRepository(RoomDayStatus::class)->findOneBy([ + 'appartment' => $apartment, + 'date' => $date, + ]) ?? new RoomDayStatus(); + + $status->setAppartment($apartment); + $status->setDate($date); + + $form = $this->createForm(HousekeepingRowType::class, $status, [ + 'date' => $date->format('Y-m-d'), + ]); + $form->handleRequest($request); + if (!$form->isSubmitted() || !$form->isValid()) { + if ($form->has('_token') && $form->get('_token')->getErrors()->count() > 0) { + $this->addFlash('warning', 'flash.invalidtoken'); + } + + return new JsonResponse([ + 'ok' => false, + 'message' => 'flash.invalidtoken', + ], Response::HTTP_BAD_REQUEST); + } + + $status->setUpdatedAt(new \DateTimeImmutable('now', new \DateTimeZone('UTC'))); + $status->setUpdatedBy($this->getUser() instanceof User ? $this->getUser() : null); + + $em->persist($status); + $em->flush(); + + return new JsonResponse([ + 'ok' => true, + 'hkStatus' => $status->getHkStatus()->value, + ]); + } + + /** + * Stream a CSV export for the selected day or week. + */ + #[Route('/export', name: 'operations.housekeeping.export', methods: ['GET'])] + public function exportAction( + ManagerRegistry $doctrine, + Request $request, + HousekeepingViewService $viewService, + HousekeepingExportService $exportService, + OperationsFilterService $filterService + ): Response { + $em = $doctrine->getManager(); + $subsidiaryId = (string) $request->query->get('subsidiary', 'all'); + $subsidiary = $filterService->resolveSubsidiary($em, $subsidiaryId); + $selectedDate = $filterService->resolveDate($request->query->get('date')); + $range = (string) $request->query->get('range', 'day'); + $queryParams = $request->query->all(); + $selectedOccupancyTypes = $viewService->normalizeOccupancyTypes($queryParams['occupancyTypes'] ?? null); + $locale = $request->getLocale(); + + if ('week' === $range) { + $weekStart = $filterService->resolveWeekStart($selectedDate); + $weekEnd = $weekStart->modify('+6 days'); + $weekView = $viewService->buildWeekView($weekStart, $weekEnd, $subsidiary); + $weekView = $viewService->filterWeekViewByOccupancy($weekView, $selectedOccupancyTypes); + + return $exportService->buildWeekCsvResponse($weekView, $subsidiaryId, $locale); + } + + $dayView = $viewService->buildDayView($selectedDate, $subsidiary); + $dayView = $viewService->filterDayViewByOccupancy($dayView, $selectedOccupancyTypes); + + return $exportService->buildDayCsvResponse($dayView, $subsidiaryId, $locale); + } + + /** + * Define CSS classes for occupancy badges. + */ + private function getOccupancyClasses(): array + { + return [ + 'FREE' => 'bg-secondary', + 'STAYOVER' => 'bg-info', + 'ARRIVAL' => 'bg-success', + 'DEPARTURE' => 'bg-warning text-dark', + 'TURNOVER' => 'bg-danger', + ]; + } +} diff --git a/src/Controller/InvoiceServiceController.php b/src/Controller/InvoiceServiceController.php index de1c9e96..62ebd67d 100644 --- a/src/Controller/InvoiceServiceController.php +++ b/src/Controller/InvoiceServiceController.php @@ -32,7 +32,6 @@ use App\Service\TemplatesService; use Doctrine\Common\Collections\ArrayCollection; use Doctrine\Persistence\ManagerRegistry; -use horstoeko\zugferd\ZugferdDocumentPdfMerger; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; use Symfony\Component\HttpFoundation\HeaderUtils; use Symfony\Component\HttpFoundation\Request; @@ -819,49 +818,36 @@ public function showChangeRemarkInvoiceEditAction(ManagerRegistry $doctrine, Req ); } - #[Route('/export/pdf/{id}/{templateId}', name: 'invoices.export.pdf', methods: ['GET'])] - public function exportToPdfAction(ManagerRegistry $doctrine, RequestStack $requestStack, TemplatesService $ts, InvoiceService $is, Invoice $invoice, int $templateId): Response + #[Route('/export/pdf/{id}/{template}', name: 'invoices.export.pdf', methods: ['GET'])] + public function exportToPdfAction(ManagerRegistry $doctrine, RequestStack $requestStack, TemplatesService $ts, InvoiceService $is, Invoice $invoice, Template $template): Response { $em = $doctrine->getManager(); // save id, after page reload template will be preselected in dropdown - $requestStack->getSession()->set('invoice-template-id', $templateId); + $requestStack->getSession()->set('invoice-template-id', $template->getId()); $templateOutput = null; try { - $templateOutput = $ts->renderTemplate($templateId, $invoice->getId(), $is); + $templateOutput = $ts->renderTemplate($template->getId(), $invoice->getId(), $is); } catch (\InvalidArgumentException $e) { $this->addFlash('warning', $e->getMessage()); return $this->redirect($this->generateUrl('invoices.overview')); } - $template = $em->getRepository(Template::class)->find($templateId); - - $pdfOutput = $ts->getPDFOutput($templateOutput, 'Rechnung-'.$invoice->getNumber(), $template); + $filenameBase = $is->buildInvoiceExportFilename($invoice); + $pdfOutput = $ts->getPDFOutput($templateOutput, $filenameBase, $template); $response = new Response($pdfOutput); $response->headers->set('Content-Type', 'application/pdf'); return $response; } - #[Route('/export/pdf-xml/{id}/{templateId}', name: 'invoices.export.pdfxml', methods: ['GET'])] - public function exportToPdfXMLAction(ManagerRegistry $doctrine, RequestStack $requestStack, TemplatesService $ts, InvoiceService $is, EInvoiceExportService $einvoice, Invoice $invoice, int $templateId): Response + #[Route('/export/pdf-xml/{id}/{template}', name: 'invoices.export.pdfxml', methods: ['GET'])] + public function exportToPdfXMLAction(ManagerRegistry $doctrine, RequestStack $requestStack, TemplatesService $ts, InvoiceService $is, EInvoiceExportService $einvoice, Invoice $invoice, Template $template): Response { $em = $doctrine->getManager(); // save id, after page reload template will be preselected in dropdown - $requestStack->getSession()->set('invoice-template-id', $templateId); - - $templateOutput = null; - try { - $templateOutput = $ts->renderTemplate($templateId, $invoice->getId(), $is); - } catch (\InvalidArgumentException $e) { - $this->addFlash('warning', $e->getMessage()); - - return $this->redirect($this->generateUrl('invoices.overview')); - } - - $template = $em->getRepository(Template::class)->find($templateId); - $pdfOutput = $ts->getPDFOutput($templateOutput, 'Rechnung-'.$invoice->getNumber(), $template, true); + $requestStack->getSession()->set('invoice-template-id', $template->getId()); $invoiceSettings = $em->getRepository(InvoiceSettingsData::class)->findOneBy(['isActive' => true]); if (!($invoiceSettings instanceof InvoiceSettingsData)) { @@ -871,21 +857,19 @@ public function exportToPdfXMLAction(ManagerRegistry $doctrine, RequestStack $re } try { - $xml = $einvoice->generateInvoiceData($invoice, $invoiceSettings); - $mergedPdf = (new ZugferdDocumentPdfMerger($xml, $pdfOutput)) - ->generateDocument() - ->downloadString(); + $mergedPdf = $is->generateInvoicePdfXml($ts, $einvoice, $invoice, $template, $invoiceSettings); } catch (\Throwable $e) { $this->addFlash('warning', $e->getMessage()); return $this->redirect($this->generateUrl('invoices.overview')); } + $filenameBase = $is->buildInvoiceExportFilename($invoice, true); $response = new Response($mergedPdf); $response->headers->set('Content-Type', 'application/pdf'); $disposition = HeaderUtils::makeDisposition( HeaderUtils::DISPOSITION_ATTACHMENT, - 'Rechnung-'.$invoice->getNumber().'-einvoice.pdf' + $filenameBase.'.pdf' ); $response->headers->set('Content-Disposition', $disposition); @@ -893,7 +877,7 @@ public function exportToPdfXMLAction(ManagerRegistry $doctrine, RequestStack $re } #[Route('/{id}/export/einvoice', name: 'invoices.export.xrechnung', methods: ['GET'])] - public function exportToXRechnung(ManagerRegistry $doctrine, RequestStack $requestStack, EInvoiceExportService $einvoice, Invoice $invoice): Response + public function exportToXRechnung(ManagerRegistry $doctrine, InvoiceService $is, EInvoiceExportService $einvoice, Invoice $invoice): Response { $em = $doctrine->getManager(); $invoiceSettings = $em->getRepository(InvoiceSettingsData::class)->findOneBy(['isActive' => true]); @@ -911,12 +895,13 @@ public function exportToXRechnung(ManagerRegistry $doctrine, RequestStack $reque return $this->redirect($this->generateUrl('invoices.overview')); } + $filenameBase = $is->buildInvoiceExportFilename($invoice, true); $response = new Response($xml); $response->headers->set('Content-Type', 'text/xml'); $disposition = HeaderUtils::makeDisposition( HeaderUtils::DISPOSITION_ATTACHMENT, - 'XRechnung-'.$invoice->getNumber().'.xml' + $filenameBase.'.xml' ); $response->headers->set('Content-Disposition', $disposition); diff --git a/src/Controller/OperationsFrontdeskController.php b/src/Controller/OperationsFrontdeskController.php new file mode 100644 index 00000000..488f1c4d --- /dev/null +++ b/src/Controller/OperationsFrontdeskController.php @@ -0,0 +1,190 @@ +getSession(); + $em = $doctrine->getManager(); + $subsidiaries = $em->getRepository(Subsidiary::class)->findAll(); + $reservationStatuses = $em->getRepository(ReservationStatus::class)->findAll(); + $subsidiaryId = $filterService->resolveFilterValue( + $request, + $session, + 'frontdesk.subsidiary', + 'subsidiary', + 'all' + ); + $selectedSubsidiary = $filterService->resolveSubsidiary($em, $subsidiaryId); + $selectedDate = $filterService->resolveDate( + $filterService->resolveFilterValue($request, $session, 'frontdesk.date', 'date') + ); + $selectedCategories = $filterService->normalizeCategories( + $filterService->resolveFilterArray($request, $session, 'frontdesk.categories', 'categories') + ); + $includeCanceled = $filterService->resolveFilterBool( + $request, + $session, + 'frontdesk.includeCanceled', + 'includeCanceled', + true + ); + $statusMode = $includeCanceled ? 'all' : 'blocking'; + + $rangeView = $viewService->buildRangeView( + $selectedDate, + $selectedDate, + $selectedSubsidiary, + $viewService->getAllowedOccupancyTypes(), + $statusMode + ); + $dayKey = $selectedDate->format('Y-m-d'); + $dayView = $rangeView['dayViews'][$dayKey] ?? [ + 'date' => $selectedDate, + 'rows' => [], + 'apartments' => $rangeView['apartments'] ?? [], + ]; + + $items = $frontdeskViewService->buildItems($dayView['rows'] ?? [], $selectedDate, $selectedCategories); + + return $this->render('Operations/Frontdesk/index.html.twig', [ + 'subsidiaries' => $subsidiaries, + 'selectedSubsidiaryId' => $subsidiaryId, + 'selectedDate' => $selectedDate, + 'selectedCategories' => $selectedCategories, + 'includeCanceled' => $includeCanceled, + 'frontdeskItems' => $items, + 'reservationStatuses' => $reservationStatuses, + ]); + } + + /** + * Download the default registration template for a reservation. + */ + #[Route('/registration/download', name: 'operations.frontdesk.registration.download', methods: ['GET'])] + public function downloadRegistrationTemplateAction( + ManagerRegistry $doctrine, + TemplatesService $templatesService, + ReservationService $reservationService, + Request $request + ): Response { + $em = $doctrine->getManager(); + $reservationId = $request->query->getInt('reservationId', 0); + $reservation = $reservationId > 0 ? $em->getRepository(Reservation::class)->find($reservationId) : null; + + if (!$reservation instanceof Reservation) { + $this->addFlash('warning', 'templates.notfound'); + + return $this->redirect($request->headers->get('referer') ?? $this->generateUrl('operations.frontdesk')); + } + + $templates = $em->getRepository(Template::class)->loadByTypeName(['TEMPLATE_REGISTRATION_PDF']); + $template = $templatesService->getDefaultTemplate($templates); + + if (!$template instanceof Template) { + $this->addFlash('warning', 'operations.frontdesk.registration.missing'); + + return $this->redirect($this->generateUrl('operations.frontdesk')); + } + + $templateOutput = $templatesService->renderTemplate( + $template->getId(), + [$reservation], + $reservationService + ); + $pdfOutput = $templatesService->getPDFOutput( + $templateOutput, + 'Registration-'.$reservation->getId(), + $template, + false, + 'I' + ); + + $response = new Response($pdfOutput); + $response->headers->set('Content-Type', 'application/pdf'); + //$response->headers->set('Content-Disposition', 'attachment; filename="Registration-'.$reservation->getId().'.pdf"'); + + return $response; + } + + /** + * Update reservation status from the frontdesk view. + */ + #[Route('/reservation/{id}/status', name: 'operations.frontdesk.reservation.status', methods: ['POST'])] + public function updateReservationStatusAction( + ManagerRegistry $doctrine, + CsrfTokenManagerInterface $csrfTokenManager, + Request $request, + Reservation $reservation + ): Response { + $token = new CsrfToken('reservation-status-update', (string) $request->request->get('_token')); + if (!$csrfTokenManager->isTokenValid($token)) { + return new Response('invalid token', 400); + } + + $statusId = (int) $request->request->get('status'); + $status = $doctrine->getManager()->getRepository(ReservationStatus::class)->find($statusId); + if ($status instanceof ReservationStatus) { + $reservation->setReservationStatus($status); + $doctrine->getManager()->flush(); + } + + return new Response('ok'); + } + + /** + * Quick-select a reservation and open the template selection directly. + */ + #[Route('/select/template/quick', name: 'operations.frontdesk.template.quick', methods: ['POST'])] + public function selectTemplateQuickAction(ReservationService $reservationService, Request $request): Response + { + $reservationService->resetSelectedReservations(); + $reservationId = $request->query->getInt('reservationId', 0); + if ($reservationId > 0) { + $reservationService->addReservationToSelection($reservationId); + } + + if (!$request->request->has('inProcess')) { + $request->request->set('inProcess', 'false'); + } + + return $this->forward('App\\Controller\\ReservationServiceController::selectTemplateAction', [], $request->request->all()); + } + +} diff --git a/src/Controller/OperationsReportController.php b/src/Controller/OperationsReportController.php new file mode 100644 index 00000000..ae8f9306 --- /dev/null +++ b/src/Controller/OperationsReportController.php @@ -0,0 +1,154 @@ +getManager(); + $subsidiaries = $em->getRepository(Subsidiary::class)->findAll(); + $subsidiaryId = (string) $request->query->get('subsidiary', 'all'); + $selectedSubsidiary = $filterService->resolveSubsidiary($em, $subsidiaryId); + + $startDate = $filterService->resolveStartDate($request->query->get('start')); + $endDate = $filterService->resolveEndDate($request->query->get('end'), $startDate); + $queryParams = $request->query->all(); + $selectedOccupancyTypes = $housekeepingViewService->normalizeOccupancyTypes($queryParams['occupancyTypes'] ?? null); + + $templates = $em->getRepository(Template::class)->loadByTypeName(['TEMPLATE_OPERATIONS_PDF']); + $templateId = $templatesService->getTemplateId($doctrine, $requestStack, 'TEMPLATE_OPERATIONS_PDF', 'operations-template-id'); + $selectedTemplateId = (int) $request->query->get('templateId', $templateId); + + return $this->render('Operations/Reports/index.html.twig', [ + 'subsidiaries' => $subsidiaries, + 'selectedSubsidiaryId' => $subsidiaryId, + 'selectedSubsidiary' => $selectedSubsidiary, + 'startDate' => $startDate, + 'endDate' => $endDate, + 'occupancyTypes' => $housekeepingViewService->getAllowedOccupancyTypes(), + 'selectedOccupancyTypes' => $selectedOccupancyTypes, + 'occupancyLabels' => $housekeepingViewService->getOccupancyLabels(), + 'templates' => $templates, + 'templateId' => $selectedTemplateId, + ]); + } + + /** + * Download the report as a PDF. + */ + #[Route('/export/pdf', name: 'operations.reports.export.pdf', methods: ['GET'])] + public function exportPdfAction( + ManagerRegistry $doctrine, + Request $request, + RequestStack $requestStack, + TemplatesService $templatesService, + OperationsReportService $reportService, + HousekeepingViewService $housekeepingViewService, + OperationsFilterService $filterService + ): Response { + $em = $doctrine->getManager(); + $subsidiaryId = (string) $request->query->get('subsidiary', 'all'); + $subsidiary = $filterService->resolveSubsidiary($em, $subsidiaryId); + + $startDate = $filterService->resolveStartDate($request->query->get('start')); + $endDate = $filterService->resolveEndDate($request->query->get('end'), $startDate); + $queryParams = $request->query->all(); + $selectedOccupancyTypes = $housekeepingViewService->normalizeOccupancyTypes($queryParams['occupancyTypes'] ?? null); + + $templateId = (int) $request->query->get('templateId', 0); + if (0 === $templateId) { + $this->addFlash('warning', 'operations.reports.template.missing'); + + return $this->redirect($this->generateUrl('operations.reports')); + } + + $template = $em->getRepository(Template::class)->find($templateId); + if (!$template instanceof Template) { + $this->addFlash('warning', 'templates.notfound'); + + return $this->redirect($this->generateUrl('operations.reports')); + } + + $requestStack->getSession()->set('operations-template-id', $templateId); + + $reportData = $reportService->buildReportData($startDate, $endDate, $subsidiary, $selectedOccupancyTypes); + $reportData['filters']['template'] = $template; + $reportData['filters']['subsidiaryId'] = $subsidiaryId; + + $templateOutput = $templatesService->renderTemplate($templateId, $reportData, $reportService); + dump($reportData); + $isPreview = $request->query->getBoolean('preview'); + $pdfOutput = $templatesService->getPDFOutput( + $templateOutput, + 'Operations-Report-'.$startDate->format('Y-m-d'), + $template, + false, + $isPreview ? 'I' : null + ); + + $response = new Response($pdfOutput); + $response->headers->set('Content-Type', 'application/pdf'); + if ($isPreview) { + $response->headers->set('Content-Disposition', 'inline; filename="Operations-Report-'.$startDate->format('Y-m-d').'.pdf"'); + } + + return $response; + } + + /** + * Render the PDF preview container. + */ + #[Route('/preview', name: 'operations.reports.preview', methods: ['GET'])] + public function previewAction(Request $request): Response + { + $templateId = (int) $request->query->get('templateId', 0); + if (0 === $templateId) { + return $this->render('Operations/Reports/_preview.html.twig', [ + 'previewUrl' => null, + 'message' => 'operations.reports.template.missing', + ]); + } + + $params = $request->query->all(); + $params['preview'] = 1; + $previewUrl = $this->generateUrl('operations.reports.export.pdf', $params); + + return $this->render('Operations/Reports/_preview.html.twig', [ + 'previewUrl' => $previewUrl, + 'message' => null, + ]); + } + +} diff --git a/src/Controller/ReservationServiceController.php b/src/Controller/ReservationServiceController.php index 55e8417d..e175b990 100644 --- a/src/Controller/ReservationServiceController.php +++ b/src/Controller/ReservationServiceController.php @@ -77,6 +77,7 @@ public function indexAction(ManagerRegistry $doctrine, RequestStack $requestStac $selectedApartmentId = $requestStack->getSession()->get('reservation-overview-apartment', $firstApartmentId); $show = $requestStack->getSession()->get('reservation-overview', 'table'); + $showCanceled = (bool) $requestStack->getSession()->get('reservation-overview-show-canceled', false); $conflictCount = $em->getRepository(Reservation::class)->countActiveConflicts(); $reviewCount = $em->getRepository(Reservation::class)->countImportedWithoutBooker(); $alertCount = $conflictCount + $reviewCount; @@ -93,6 +94,7 @@ public function indexAction(ManagerRegistry $doctrine, RequestStack $requestStac 'selectedCountry' => 'DE', 'selectedSubdivision' => 'all', 'show' => $show, + 'showCanceled' => $showCanceled, 'showFirstSteps' => (0 == $firstApartmentId), 'conflictCount' => $alertCount, 'hasConflicts' => $conflictCount > 0, @@ -139,6 +141,7 @@ private function _handleTableRequest(ManagerRegistry $doctrine, RequestStack $re $objectId = $request->query->get('object'); $holidayCountry = $request->query->get('holidayCountry', 'DE'); $selectedSubdivision = $request->query->get('holidaySubdivision', 'all'); + $showCanceledParam = $request->query->get('showCanceled'); if (null == $date) { $dateRef = new \DateTimeImmutable('today', new \DateTimeZone('UTC')); @@ -168,6 +171,9 @@ private function _handleTableRequest(ManagerRegistry $doctrine, RequestStack $re $requestStack->getSession()->set('reservation-overview-interval', $interval); $requestStack->getSession()->set('reservation-overview-objectid', $objectId); $requestStack->getSession()->set('reservation-overview', 'table'); + if (null !== $showCanceledParam) { + $requestStack->getSession()->set('reservation-overview-show-canceled', '1' === $showCanceledParam || 'true' === $showCanceledParam); + } return $this->render('Reservations/reservation_table.html.twig', [ 'appartments' => $appartments, @@ -190,6 +196,7 @@ private function _handleTableYearlyRequest(ManagerRegistry $doctrine, RequestSta $objectId = $request->query->get('object'); $year = $request->query->get('year', date('Y')); $apartmentId = $request->query->get('apartment'); + $showCanceledParam = $request->query->get('showCanceled'); if (null == $apartmentId) { $apartments = $em->getRepository(Appartment::class)->findAllByProperty($objectId); $apartmentId = isset($apartments[0]) ? $apartments[0]->getId() : 0; @@ -208,6 +215,9 @@ private function _handleTableYearlyRequest(ManagerRegistry $doctrine, RequestSta $requestStack->getSession()->set('reservation-overview-year', $year); $requestStack->getSession()->set('reservation-overview-apartment', $apartment->getId()); $requestStack->getSession()->set('reservation-overview', 'yearly'); + if (null !== $showCanceledParam) { + $requestStack->getSession()->set('reservation-overview-show-canceled', '1' === $showCanceledParam || 'true' === $showCanceledParam); + } return $this->render('Reservations/reservation_table_year.html.twig', [ 'year' => $year, @@ -225,9 +235,14 @@ public function tableSettingsAction(ManagerRegistry $doctrine, RequestStack $req $objects = $em->getRepository(Subsidiary::class)->findAll(); $selectedCountry = $request->request->get('holidayCountry', 'DE'); $selectedSubdivision = $request->request->get('holidaySubdivision', 'all'); + $showCanceledParam = $request->request->get('showCanceled'); $requestStack->getSession()->set('reservation-overview', 'table'); + if (null !== $showCanceledParam) { + $requestStack->getSession()->set('reservation-overview-show-canceled', '1' === $showCanceledParam || 'true' === $showCanceledParam); + } $objectId = $requestStack->getSession()->get('reservation-overview-objectid', 'all'); + $showCanceled = (bool) $requestStack->getSession()->get('reservation-overview-show-canceled', false); return $this->render('Reservations/reservation_table_settings_input_fields.html.twig', [ 'objects' => $objects, @@ -235,6 +250,7 @@ public function tableSettingsAction(ManagerRegistry $doctrine, RequestStack $req 'holidayCountries' => $cs->getHolidayCountries($requestStack->getCurrentRequest()->getLocale()), 'selectedCountry' => $selectedCountry, 'selectedSubdivision' => $selectedSubdivision, + 'showCanceled' => $showCanceled, ]); } diff --git a/src/Controller/ReservationStatusController.php b/src/Controller/ReservationStatusController.php index cdb79ab7..038d5b73 100644 --- a/src/Controller/ReservationStatusController.php +++ b/src/Controller/ReservationStatusController.php @@ -52,9 +52,18 @@ public function new(ManagerRegistry $doctrine, Request $request): Response #[Route('/{id}/edit', name: 'reservation_status_edit', methods: ['GET', 'POST'])] public function edit(ManagerRegistry $doctrine, Request $request, ReservationStatus $reservationStatus): Response { - $form = $this->createForm(ReservationStatusType::class, $reservationStatus); + $isSystem = $reservationStatus->isSystem(); + $form = $this->createForm(ReservationStatusType::class, $reservationStatus, [ + 'disabled' => $isSystem, + ]); $form->handleRequest($request); + if ($isSystem && $form->isSubmitted()) { + $this->addFlash('warning', 'status.flash.edit.error.system'); + + return new Response('', Response::HTTP_FORBIDDEN); + } + if ($form->isSubmitted() && $form->isValid()) { $reservationStatus->setContrastColor($this->calculateColor($reservationStatus->getColor())); $doctrine->getManager()->flush(); @@ -80,6 +89,11 @@ public function delete(ManagerRegistry $doctrine, Request $request, ReservationS 'id' => $reservationStatus->getId(), ]); } elseif ($this->isCsrfTokenValid('delete'.$reservationStatus->getId(), $request->request->get('_token'))) { + if ($reservationStatus->isSystem()) { + $this->addFlash('warning', 'status.flash.delete.error.system'); + + return new Response('', Response::HTTP_NO_CONTENT); + } if ($reservationStatus->getReservations()->count() > 0) { $this->addFlash('warning', 'status.flash.delete.error.still.in.use'); } else { diff --git a/src/Controller/StatisticsController.php b/src/Controller/StatisticsController.php index 96e0e69b..8299680c 100644 --- a/src/Controller/StatisticsController.php +++ b/src/Controller/StatisticsController.php @@ -14,8 +14,10 @@ namespace App\Controller; use App\Entity\Appartment; +use App\Entity\MonthlyStatsSnapshot; use App\Entity\Reservation; use App\Entity\ReservationOrigin; +use App\Entity\ReservationStatus; use App\Entity\Subsidiary; use App\Service\InvoiceService; use App\Service\MonthlyStatsService; @@ -29,6 +31,8 @@ use Symfony\Component\Intl\Countries; use Symfony\Component\Routing\Attribute\Route; use Symfony\Component\Security\Http\Attribute\IsGranted; +use Symfony\Component\Security\Csrf\CsrfToken; +use Symfony\Component\Security\Csrf\CsrfTokenManagerInterface; #[IsGranted('ROLE_STATISTICS')] #[Route('/statistics')] @@ -44,7 +48,17 @@ class StatisticsController extends AbstractController #[Route('/utilization', name: 'statistics.utilization', methods: ['GET'])] public function utilizationAction(ManagerRegistry $doctrine, RequestStack $requestStack): Response { - return $this->loadIndex('Statistics/utilization.html.twig', $doctrine, $requestStack); + $em = $doctrine->getManager(); + $reservationStatuses = $em->getRepository(ReservationStatus::class)->findAll(); + + return $this->loadIndex( + 'Statistics/utilization.html.twig', + $doctrine, + $requestStack, + [ + 'reservationStatuses' => $reservationStatuses, + ] + ); } #[Route('/utilization/monthtly', name: 'statistics.utilization.monthtly', methods: ['GET'])] @@ -53,6 +67,7 @@ public function getUtilizationForMonthAction(ManagerRegistry $doctrine, Request $em = $doctrine->getManager(); $objectId = $request->query->get('objectId', 'all'); + $reservationStatus = $request->query->all('reservation-status'); $monthStart = (int) $request->query->get('monthStart'); $monthEnd = (int) $request->query->get('monthEnd'); $yearStart = (int) $request->query->get('yearStart'); @@ -78,7 +93,11 @@ public function getUtilizationForMonthAction(ManagerRegistry $doctrine, Request $data = []; $timeStartStr = $currentDate->format('Y-m-'); for ($i = 1; $i <= $daysInMonth; ++$i) { - $utilization = $em->getRepository(Reservation::class)->loadUtilizationForDay($timeStartStr.$i, $objectId); + $utilization = $em->getRepository(Reservation::class)->loadUtilizationForDay( + $timeStartStr.$i, + $objectId, + $reservationStatus + ); $data[] = $utilization * 100 / $beds; } $result['datasets'][] = [ @@ -101,6 +120,7 @@ public function getUtilizationForYearAction(ManagerRegistry $doctrine, Statistic { $em = $doctrine->getManager(); $objectId = $request->query->get('objectId', 'all'); + $reservationStatus = $request->query->all('reservation-status'); $yearStart = (int) $request->query->get('yearStart'); $yearEnd = (int) $request->query->get('yearEnd'); @@ -119,7 +139,7 @@ public function getUtilizationForYearAction(ManagerRegistry $doctrine, Statistic for ($y = $yearStart; $y <= $yearEnd; ++$y) { $result['datasets'][] = [ 'label' => (string) $y, - 'data' => $ss->loadUtilizationForYear($objectId, $y, $beds), + 'data' => $ss->loadUtilizationForYear($objectId, $y, $beds, $reservationStatus), ]; } @@ -136,6 +156,7 @@ public function getMonthlySnapshotAction(ManagerRegistry $doctrine, MonthlyStats $month = (int) $request->query->get('month'); $year = (int) $request->query->get('year'); $objectId = $request->query->get('objectId', 'all'); + $reservationStatus = $request->query->all('reservation-status'); $force = (bool) $request->query->get('force', false); if ($month < 1 || $month > 12 || $year < 1) { @@ -152,19 +173,58 @@ public function getMonthlySnapshotAction(ManagerRegistry $doctrine, MonthlyStats $payload = $monthlyStatsService->getOrCreateSnapshotWithWarnings($month, $year, $subsidiary, $force); $snapshot = $payload['snapshot']; + $metrics = $snapshot->getMetrics(); $warnings = $payload['warnings']; + $statusFilter = $reservationStatus; + if (!$statusFilter) { + $statusFilter = $em->getRepository(ReservationStatus::class)->findDefaultIds(); + } + if ($statusFilter) { + $metrics = $monthlyStatsService->filterMetricsByStatus($metrics, $statusFilter); + $warnings = $metrics['warnings'] ?? []; + } + return new JsonResponse([ 'id' => $snapshot->getId(), 'month' => $snapshot->getMonth(), 'year' => $snapshot->getYear(), 'subsidiary' => $subsidiary?->getId(), - 'metrics' => $snapshot->getMetrics(), + 'metrics' => $metrics, 'warnings' => $warnings, 'countryNames' => Countries::getNames($request->getLocale()), ]); } + #[Route('/snapshot/warning/ignore/{snapshot}', name: 'statistics.snapshot.warning.ignore', methods: ['POST'])] + public function ignoreSnapshotWarning(ManagerRegistry $doctrine, CsrfTokenManagerInterface $csrfTokenManager, MonthlyStatsService $monthlyStatsService, MonthlyStatsSnapshot $snapshot, Request $request): JsonResponse + { + $em = $doctrine->getManager(); + $reservationId = (int) $request->request->get('reservationId'); + $ignored = filter_var($request->request->get('ignored', '1'), FILTER_VALIDATE_BOOL); + $token = new CsrfToken('statistics.snapshot.warning.ignore', (string) $request->request->get('_csrf_token')); + + if ($reservationId < 1 || !$csrfTokenManager->isTokenValid($token)) { + return new JsonResponse(['error' => 'invalid request'], 400); + } + + $metrics = $snapshot->getMetrics(); + $warnings = $metrics['warnings'] ?? []; + foreach ($warnings as &$warning) { + if (($warning['reservation_id'] ?? 0) === $reservationId) { + $warning['ignored'] = $ignored; + break; + } + } + unset($warning); + $metrics['warnings'] = $warnings; + $snapshot->setMetrics($metrics); + $snapshot->touchUpdatedAt(); + $em->flush(); + + return new JsonResponse(['ok' => true]); + } + /** * Index Action reservationorigin page. */ @@ -177,7 +237,17 @@ public function originAction(ManagerRegistry $doctrine, RequestStack $requestSta #[Route('/tourism', name: 'statistics.tourism', methods: ['GET'])] public function tourismAction(ManagerRegistry $doctrine, RequestStack $requestStack): Response { - return $this->loadIndex('Statistics/tourism.html.twig', $doctrine, $requestStack); + $em = $doctrine->getManager(); + $reservationStatuses = $em->getRepository(ReservationStatus::class)->findAll(); + + return $this->loadIndex( + 'Statistics/tourism.html.twig', + $doctrine, + $requestStack, + [ + 'reservationStatuses' => $reservationStatuses, + ] + ); } /** @@ -321,7 +391,7 @@ public function getTurnoverForMonthAction(ManagerRegistry $doctrine, InvoiceServ * * @throws \Exception */ - private function loadIndex(string $template, ManagerRegistry $doctrine, RequestStack $requestStack): Response + private function loadIndex(string $template, ManagerRegistry $doctrine, RequestStack $requestStack, array $extra = []): Response { $em = $doctrine->getManager(); $objects = $em->getRepository(Subsidiary::class)->findAll(); @@ -332,12 +402,12 @@ private function loadIndex(string $template, ManagerRegistry $doctrine, RequestS $minDate = new \DateTime($minStr); $maxDate = new \DateTime($maxStr); - return $this->render($template, [ + return $this->render($template, array_merge([ 'objects' => $objects, 'objectId' => $objectId, 'minYear' => $minDate->format('Y'), 'maxYear' => $maxDate->format('Y'), - ]); + ], $extra)); } private function getLocalizedDate($monthNumber, $pattern, $locale) diff --git a/src/Controller/TemplatesServiceController.php b/src/Controller/TemplatesServiceController.php index b21def32..66767228 100644 --- a/src/Controller/TemplatesServiceController.php +++ b/src/Controller/TemplatesServiceController.php @@ -15,12 +15,15 @@ use App\Entity\Correspondence; use App\Entity\FileCorrespondence; +use App\Entity\Invoice; +use App\Entity\InvoiceSettingsData; use App\Entity\MailCorrespondence; use App\Entity\Reservation; use App\Entity\Template; use App\Entity\TemplateType; use App\Service\CSRFProtectionService; use App\Service\FileUploader; +use App\Service\EInvoice\EInvoiceExportService; use App\Service\InvoiceService; use App\Service\MailService; use App\Service\ReservationService; @@ -28,10 +31,13 @@ use Doctrine\Persistence\ManagerRegistry; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; use Symfony\Component\HttpFoundation\File\UploadedFile; +use Symfony\Component\HttpFoundation\HeaderUtils; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\RequestStack; use Symfony\Component\HttpFoundation\Response; use Symfony\Component\Routing\Attribute\Route; +use Symfony\Component\Security\Csrf\CsrfToken; +use Symfony\Component\Security\Csrf\CsrfTokenManagerInterface; #[Route(path: '/settings/templates')] class TemplatesServiceController extends AbstractController @@ -40,13 +46,18 @@ class TemplatesServiceController extends AbstractController * Index-View. */ #[Route('/', name: 'settings.templates.overview', methods: ['GET'])] - public function indexAction(ManagerRegistry $doctrine): Response + public function indexAction(ManagerRegistry $doctrine, CsrfTokenManagerInterface $csrfTokenManager): Response { $em = $doctrine->getManager(); $templates = $em->getRepository(Template::class)->findAll(); + $operationsTemplates = $em->getRepository(Template::class)->loadByTypeName(['TEMPLATE_OPERATIONS_PDF']); + $registrationTemplates = $em->getRepository(Template::class)->loadByTypeName(['TEMPLATE_REGISTRATION_PDF']); + $operationsTemplatesMissing = empty($operationsTemplates) || empty($registrationTemplates); return $this->render('Templates/index.html.twig', [ 'templates' => $templates, + 'operationsTemplatesMissing' => $operationsTemplatesMissing, + 'importToken' => $csrfTokenManager->getToken('templates.import.operations')->getValue(), ]); } @@ -188,6 +199,67 @@ public function previewAction(ManagerRegistry $doctrine, TemplatesService $ts, $ ]); } + /** + * Import default operations report templates from the examples repository. + */ + #[Route('/import/operations', name: 'settings.templates.import.operations', methods: ['POST'])] + public function importOperationsTemplatesAction( + ManagerRegistry $doctrine, + CsrfTokenManagerInterface $csrfTokenManager, + TemplatesService $templatesService, + Request $request + ): Response { + $token = new CsrfToken('templates.import.operations', (string) $request->request->get('_csrf_token')); + if (!$csrfTokenManager->isTokenValid($token)) { + $this->addFlash('warning', 'flash.invalidtoken'); + + return $this->redirectToRoute('settings.templates.overview'); + } + + $em = $doctrine->getManager(); + $type = $em->getRepository(TemplateType::class)->findOneBy(['name' => 'TEMPLATE_OPERATIONS_PDF']); + if (!$type instanceof TemplateType) { + $this->addFlash('warning', 'templates.operations.import.missing_type'); + + return $this->redirectToRoute('settings.templates.overview'); + } + + $existingOperations = $em->getRepository(Template::class)->loadByTypeName(['TEMPLATE_OPERATIONS_PDF']); + $existingRegistration = $em->getRepository(Template::class)->loadByTypeName(['TEMPLATE_REGISTRATION_PDF']); + $needsOperationsImport = empty($existingOperations); + $needsRegistrationImport = empty($existingRegistration); + if (!$needsOperationsImport && !$needsRegistrationImport) { + $this->addFlash('info', 'templates.operations.import.already_present'); + + return $this->redirectToRoute('settings.templates.overview'); + } + + $baseUrl = TemplatesService::EXAMPLES_BASE_URL; + $imported = 0; + if ($needsOperationsImport) { + $entries = $templatesService->getOperationsTemplateDefinitions(); + $imported += $templatesService->importTemplates($type, $entries, $baseUrl); + } + if ($needsRegistrationImport) { + $registrationType = $em->getRepository(TemplateType::class)->findOneBy(['name' => 'TEMPLATE_REGISTRATION_PDF']); + if ($registrationType instanceof TemplateType) { + $registrationEntries = $templatesService->getRegistrationTemplateDefinitions(); + $imported += $templatesService->importTemplates($registrationType, $registrationEntries, $baseUrl); + } + } + + if ($imported > 0) { + $em->flush(); + $this->addFlash('success', 'templates.operations.import.success'); + } else { + $this->addFlash('warning', 'templates.operations.import.failed'); + } + + return $this->redirectToRoute('settings.templates.overview'); + } + + + /** * Called when clicking add conversation in the reservation overview. */ @@ -450,17 +522,54 @@ public function deleteCorrespondenceAction(ManagerRegistry $doctrine, CSRFProtec * Adds an already added file (correspondence) as attachment of the current mail. */ #[Route('/attachment/add', name: 'settings.templates.attachment.add', methods: ['POST'])] - public function addAttachmentAction(TemplatesService $ts, Request $request, InvoiceService $is): Response + public function addAttachmentAction(ManagerRegistry $doctrine, TemplatesService $ts, Request $request, InvoiceService $is, EInvoiceExportService $einvoice): Response { + $em = $doctrine->getManager(); $error = false; $isInvoice = $request->request->get('isInvoice', 'false'); + $isEInvoice = $request->request->get('isEInvoice', 'false'); $cId = $request->request->get('id'); if ('false' != $isInvoice) { - $cId = $ts->makeCorespondenceOfInvoice($cId, $is); + $binaryPayload = null; + if ('false' != $isEInvoice) { + $invoice = $cId ? $em->getRepository(Invoice::class)->find($cId) : null; + if (!($invoice instanceof Invoice)) { + $this->addFlash('warning', 'templates.attachment.notfound'); + $error = true; + } else { + $invoiceSettings = $em->getRepository(InvoiceSettingsData::class)->findOneBy(['isActive' => true]); + if (!($invoiceSettings instanceof InvoiceSettingsData)) { + $this->addFlash('danger', 'invoice.settings.active.error'); + $error = true; + } else { + $templates = $em->getRepository(Template::class)->loadByTypeName(['TEMPLATE_INVOICE_PDF']); + $defaultTemplate = $ts->getDefaultTemplate($templates); + if (null === $defaultTemplate) { + $this->addFlash('warning', 'templates.notfound'); + $error = true; + } else { + try { + $binaryPayload = $is->generateInvoicePdfXml($ts, $einvoice, $invoice, $defaultTemplate, $invoiceSettings); + } catch (\InvalidArgumentException $e) { + $this->addFlash('warning', $e->getMessage()); + $error = true; + } catch (\Throwable $e) { + $this->addFlash('warning', $e->getMessage()); + $error = true; + } + } + } + } + } + if (!$error) { + $cId = $ts->makeCorespondenceOfInvoice($cId, $is, $binaryPayload, 'false' != $isEInvoice); + } } - $reservations = $ts->getReferencedReservationsInSession(); - $ts->addFileAsAttachment($cId, $reservations); + if (!$error) { + $reservations = $ts->getReferencedReservationsInSession(); + $ts->addFileAsAttachment($cId, $reservations); + } return $this->render('feedback.html.twig', [ 'error' => $error, @@ -468,18 +577,25 @@ public function addAttachmentAction(TemplatesService $ts, Request $request, Invo } #[Route('/correspondence/export/pdf/{id}/', name: 'settings.templates.correspondence.export.pdf', methods: ['GET'], defaults: ['id' => '0'])] - public function exportPDFCorrespondenceAction(ManagerRegistry $doctrine, TemplatesService $ts, Request $request, $id) + public function exportPDFCorrespondenceAction(ManagerRegistry $doctrine, TemplatesService $ts, InvoiceService $is, $id) { $em = $doctrine->getManager(); $correspondence = $em->getRepository(Correspondence::class)->find($id); if ($correspondence instanceof FileCorrespondence) { - $output = $ts->getPDFOutput( + $safeName = $is->sanitizeFilename($correspondence->getName()); + $binaryPayload = $correspondence->getBinaryPayload(); + $output = $binaryPayload ?: $ts->getPDFOutput( $correspondence->getText(), - $correspondence->getName(), + $safeName, $correspondence->getTemplate() ); $response = new Response($output); $response->headers->set('Content-Type', 'application/pdf'); + $disposition = HeaderUtils::makeDisposition( + HeaderUtils::DISPOSITION_ATTACHMENT, + $safeName.'.pdf' + ); + $response->headers->set('Content-Disposition', $disposition); return $response; } diff --git a/src/DataFixtures/SettingsFixtures.php b/src/DataFixtures/SettingsFixtures.php index c8dc3605..b51c02c5 100644 --- a/src/DataFixtures/SettingsFixtures.php +++ b/src/DataFixtures/SettingsFixtures.php @@ -184,22 +184,42 @@ private function createCustomer(ObjectManager $manager): void private function createReservationStatus(ObjectManager $manager): void { + $repository = $manager->getRepository(ReservationStatus::class); $reservationStatus = [ [ 'name' => $this->translator->trans('status.confirmed'), 'color' => '#2D9434', 'contrast' => '#ffffff', + 'code' => null, + 'isBlocking' => true, ], [ 'name' => $this->translator->trans('status.option'), 'color' => '#f6e95c', 'contrast' => '#000000', + 'code' => null, + 'isBlocking' => true, + ], [ + 'name' => $this->translator->trans('status.canceled_noshow'), + 'color' => '#6c757d', + 'contrast' => '#ffffff', + 'code' => ReservationStatus::CODE_CANCELED_NOSHOW, + 'isBlocking' => false, ], ]; foreach ($reservationStatus as $status) { + if (null !== $status['code']) { + if ($repository->findOneBy(['code' => $status['code']]) instanceof ReservationStatus) { + continue; + } + } elseif ($repository->findOneBy(['name' => $status['name']]) instanceof ReservationStatus) { + continue; + } $rs = new ReservationStatus(); $rs->setName($status['name']); $rs->setColor($status['color']); $rs->setContrastColor($status['contrast']); + $rs->setCode($status['code']); + $rs->setIsBlocking($status['isBlocking']); $manager->persist($rs); } } diff --git a/src/DataFixtures/TemplatesFixtures.php b/src/DataFixtures/TemplatesFixtures.php index e5e04c68..fb73a879 100644 --- a/src/DataFixtures/TemplatesFixtures.php +++ b/src/DataFixtures/TemplatesFixtures.php @@ -12,17 +12,15 @@ namespace App\DataFixtures; -use App\Entity\Template; use App\Entity\TemplateType; +use App\Service\TemplatesService; use Doctrine\Bundle\FixturesBundle\Fixture; use Doctrine\Bundle\FixturesBundle\FixtureGroupInterface; use Doctrine\Persistence\ObjectManager; -use Symfony\Component\HttpClient\HttpClient; -use Symfony\Contracts\Translation\TranslatorInterface; class TemplatesFixtures extends Fixture implements FixtureGroupInterface { - public function __construct(private readonly TranslatorInterface $translator) + public function __construct(private readonly TemplatesService $templatesService) { } @@ -33,16 +31,27 @@ public static function getGroups(): array public function load(ObjectManager $manager): void { - $baseUrl = 'https://raw.githubusercontent.com/developeregrem/fewohbee-examples/master/templates/'; + $baseUrl = TemplatesService::EXAMPLES_BASE_URL; $templates = [ - 'TEMPLATE_GDPR_PDF' => 'dsgvo-export.txt', - 'TEMPLATE_CASHJOURNAL_PDF' => 'kassenblatt.txt', - 'TEMPLATE_RESERVATION_EMAIL' => 'email-buchungsbestätigung.txt', - 'TEMPLATE_RESERVATION_PDF' => 'pdf-reservierungsbestätigung.txt', - 'TEMPLATE_INVOICE_PDF' => 'rechnung-default.txt', + 'TEMPLATE_GDPR_PDF' => [ + ['file' => 'dsgvo-export.txt', 'isDefault' => true], + ], + 'TEMPLATE_CASHJOURNAL_PDF' => [ + ['file' => 'kassenblatt.txt', 'isDefault' => true], + ], + 'TEMPLATE_RESERVATION_EMAIL' => [ + ['file' => 'email-buchungsbestätigung.txt', 'isDefault' => true,], + ], + 'TEMPLATE_RESERVATION_PDF' => [ + ['file' => 'pdf-reservierungsbestätigung.txt', 'isDefault' => true,], + ], + 'TEMPLATE_INVOICE_PDF' => [ + ['file' => 'rechnung-default.txt', 'isDefault' => true,], + ], + 'TEMPLATE_REGISTRATION_PDF' => $this->templatesService->getRegistrationTemplateDefinitions(), + 'TEMPLATE_OPERATIONS_PDF' => $this->templatesService->getOperationsTemplateDefinitions(), ]; $types = $manager->getRepository(TemplateType::class)->findAll(); - $client = HttpClient::create(); /* @var $type TemplateType */ foreach ($types as $type) { $name = $type->getName(); @@ -50,22 +59,8 @@ public function load(ObjectManager $manager): void continue; } - $response = $client->request('GET', $baseUrl.$templates[$name]); - if (200 !== $response->getStatusCode()) { - echo "Could not load $name"; - continue; - } - - $content = $response->getContent(); - - $template = new Template(); - $template->setParams('{"orientation": "P", "marginLeft": 25, "marginRight": 20, "marginTop": 20, "marginBottom": 20, "marginHeader": 9, "marginFooter": 9}'); - $template->setIsDefault(true); - $template->setName($this->translator->trans($name)); - $template->setTemplateType($type); - $template->setText($content); - - $manager->persist($template); + $templateEntries = $templates[$name]; + $this->templatesService->importTemplates($type, $templateEntries, $baseUrl); } $manager->flush(); diff --git a/src/Entity/Enum/HousekeepingStatus.php b/src/Entity/Enum/HousekeepingStatus.php new file mode 100644 index 00000000..f0196bcd --- /dev/null +++ b/src/Entity/Enum/HousekeepingStatus.php @@ -0,0 +1,14 @@ +fileName; } + + /** + * @return string|null + */ + public function getBinaryPayload() + { + if (null === $this->binaryPayload) { + return null; + } + if (is_resource($this->binaryPayload)) { + return stream_get_contents($this->binaryPayload) ?: null; + } + + return $this->binaryPayload; + } + + /** + * @param string|null $binaryPayload + * + * @return FileCorrespondence + */ + public function setBinaryPayload($binaryPayload) + { + $this->binaryPayload = $binaryPayload; + + return $this; + } } diff --git a/src/Entity/ReservationStatus.php b/src/Entity/ReservationStatus.php index da1ed8ea..16bdc0c4 100644 --- a/src/Entity/ReservationStatus.php +++ b/src/Entity/ReservationStatus.php @@ -13,6 +13,8 @@ #[ORM\Entity(repositoryClass: ReservationStatusRepository::class)] class ReservationStatus { + public const CODE_CANCELED_NOSHOW = 'canceled_noshow'; + #[ORM\Id] #[ORM\GeneratedValue] #[ORM\Column(type: 'integer')] @@ -27,6 +29,10 @@ class ReservationStatus private $reservations; #[ORM\Column(type: 'string', length: 7)] private $contrastColor; + #[ORM\Column(type: 'string', length: 50, unique: true, nullable: true)] + private $code; + #[ORM\Column(type: 'boolean')] + private $isBlocking = true; #[ORM\ManyToMany(targetEntity: CalendarSync::class, mappedBy: 'reservationStatus')] private $calendarSyncs; @@ -107,6 +113,35 @@ public function setContrastColor(string $contrastColor): self return $this; } + public function getCode(): ?string + { + return $this->code; + } + + public function setCode(?string $code): self + { + $this->code = $code; + + return $this; + } + + public function isBlocking(): bool + { + return (bool) $this->isBlocking; + } + + public function setIsBlocking(bool $isBlocking): self + { + $this->isBlocking = $isBlocking; + + return $this; + } + + public function isSystem(): bool + { + return null !== $this->code && '' !== $this->code; + } + /** * @return Collection|CalendarSync[] */ diff --git a/src/Entity/RoomDayStatus.php b/src/Entity/RoomDayStatus.php new file mode 100644 index 00000000..1cb0f0f8 --- /dev/null +++ b/src/Entity/RoomDayStatus.php @@ -0,0 +1,186 @@ +updatedAt = new \DateTimeImmutable('now', new \DateTimeZone('UTC')); + } + + /** + * Return the primary database identifier. + */ + public function getId(): ?int + { + return $this->id; + } + + /** + * Return the apartment this status belongs to. + */ + public function getAppartment(): Appartment + { + return $this->appartment; + } + + /** + * Assign the apartment this status belongs to. + */ + public function setAppartment(Appartment $appartment): self + { + $this->appartment = $appartment; + + return $this; + } + + /** + * Return the date this status entry represents. + */ + public function getDate(): \DateTimeImmutable + { + return $this->date; + } + + /** + * Set the date this status entry represents. + */ + public function setDate(\DateTimeImmutable $date): self + { + $this->date = $date; + + return $this; + } + + /** + * Return the current housekeeping status. + */ + public function getHkStatus(): HousekeepingStatus + { + return $this->hkStatus; + } + + /** + * Set the housekeeping status for the day. + */ + public function setHkStatus(HousekeepingStatus $hkStatus): self + { + $this->hkStatus = $hkStatus; + + return $this; + } + + /** + * Return the assigned user, if any. + */ + public function getAssignedTo(): ?User + { + return $this->assignedTo; + } + + /** + * Assign a user to this housekeeping task. + */ + public function setAssignedTo(?User $assignedTo): self + { + $this->assignedTo = $assignedTo; + + return $this; + } + + /** + * Return the housekeeping note. + */ + public function getNote(): ?string + { + return $this->note; + } + + /** + * Set the housekeeping note. + */ + public function setNote(?string $note): self + { + $this->note = $note; + + return $this; + } + + /** + * Return the last update timestamp. + */ + public function getUpdatedAt(): \DateTimeImmutable + { + return $this->updatedAt; + } + + /** + * Update the timestamp for this entry. + */ + public function setUpdatedAt(\DateTimeImmutable $updatedAt): self + { + $this->updatedAt = $updatedAt; + + return $this; + } + + /** + * Return the user who last updated this entry. + */ + public function getUpdatedBy(): ?User + { + return $this->updatedBy; + } + + /** + * Set the user who last updated this entry. + */ + public function setUpdatedBy(?User $updatedBy): self + { + $this->updatedBy = $updatedBy; + + return $this; + } +} diff --git a/src/Enum/InvoiceStatus.php b/src/Enum/InvoiceStatus.php new file mode 100644 index 00000000..0a1295e9 --- /dev/null +++ b/src/Enum/InvoiceStatus.php @@ -0,0 +1,41 @@ + self::OPEN, + 2 => self::PAYED, + 3 => self::PREPAYED, + 4 => self::CANCELED, + default => null, + }; + } + + public function labelKey(): string + { + return match ($this) { + self::OPEN => 'invoice.status.notpayed', + self::PAYED => 'invoice.status.payed', + self::PREPAYED => 'invoice.status.prepayment', + self::CANCELED => 'invoice.status.canceled', + }; + } +} diff --git a/src/Form/HousekeepingRowType.php b/src/Form/HousekeepingRowType.php new file mode 100644 index 00000000..37afa635 --- /dev/null +++ b/src/Form/HousekeepingRowType.php @@ -0,0 +1,59 @@ +add('date', HiddenType::class, [ + 'mapped' => false, + 'data' => $options['date'] ?? null, + ]) + ->add('hkStatus', ChoiceType::class, [ + 'label' => false, + 'choices' => HousekeepingStatus::cases(), + 'choice_value' => static fn (?HousekeepingStatus $status): ?string => $status?->value, + 'choice_label' => static fn (HousekeepingStatus $status): string => 'housekeeping.status.'.strtolower($status->value), + 'choice_translation_domain' => 'Housekeeping', + ]) + ->add('assignedTo', EntityType::class, [ + 'label' => false, + 'class' => User::class, + 'required' => false, + 'placeholder' => 'housekeeping.unassigned', + 'translation_domain' => 'Housekeeping', + 'choice_label' => static fn (User $user): string => trim(sprintf('%s %s', $user->getFirstname(), $user->getLastname())), + ]) + ->add('note', TextType::class, [ + 'label' => false, + 'required' => false, + ]); + } + + public function configureOptions(OptionsResolver $resolver): void + { + $resolver->setDefaults([ + 'data_class' => RoomDayStatus::class, + 'csrf_token_id' => 'housekeeping_update', + 'date' => null, + ]); + } +} diff --git a/src/Form/ProfilePersonalDataType.php b/src/Form/ProfilePersonalDataType.php index 739a8c48..d1db230c 100644 --- a/src/Form/ProfilePersonalDataType.php +++ b/src/Form/ProfilePersonalDataType.php @@ -56,7 +56,7 @@ public function buildForm(FormBuilderInterface $builder, array $options): void ], 'help' => 'profile.theme.help', 'constraints' => [ - new Assert\Choice(['auto', 'dark', 'light']), + new Assert\Choice(choices: ['auto', 'dark', 'light']), ], ]) ->add('password', PasswordType::class, [ diff --git a/src/Repository/ReservationRepository.php b/src/Repository/ReservationRepository.php index 59485b73..89ece447 100644 --- a/src/Repository/ReservationRepository.php +++ b/src/Repository/ReservationRepository.php @@ -7,8 +7,11 @@ use App\Entity\Appartment; use App\Entity\CalendarSyncImport; use App\Entity\Reservation; -use Doctrine\ORM\EntityRepository; +use App\Entity\Subsidiary; +use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository; +use Doctrine\DBAL\ArrayParameterType; use Doctrine\ORM\NoResultException; +use Doctrine\Persistence\ManagerRegistry; /** * ReservationRepository. @@ -16,15 +19,51 @@ * This class was generated by the Doctrine ORM. Add your own custom * repository methods below. */ -class ReservationRepository extends EntityRepository +class ReservationRepository extends ServiceEntityRepository { + public function __construct(ManagerRegistry $registry) + { + parent::__construct($registry, Reservation::class); + } + + private function applyBlockingStatusFilter($qb, string $reservationAlias = 'u', string $mode = 'blocking', string $statusAlias = 'rs'): void + { + if ('all' === $mode) { + return; + } + + $qb->join($reservationAlias.'.reservationStatus', $statusAlias); + if ('non_blocking' === $mode) { + $qb->andWhere($statusAlias.'.isBlocking = 0'); + } else { + $qb->andWhere($statusAlias.'.isBlocking = 1'); + } + } + + private function applyReservationStatusFilter($qb, ?array $statusIds, string $reservationAlias = 'u', string $statusAlias = 'rs'): void + { + if (null === $statusIds) { + return; + } + + if (!$statusIds) { + $this->applyBlockingStatusFilter($qb, $reservationAlias, 'blocking', $statusAlias); + + return; + } + + $qb->join($reservationAlias.'.reservationStatus', $statusAlias) + ->andWhere($statusAlias.'.id IN (:reservationStatus)') + ->setParameter('reservationStatus', $statusIds, ArrayParameterType::INTEGER); + } + public function loadReservationsForPeriod($startDate, $endDate) { $start = date('Y-m-d', strtotime($startDate)); $end = date('Y-m-d', strtotime($endDate)); // if($customer == null) { - $q = $this + $qb = $this ->createQueryBuilder('u') ->select('u') ->andWhere('((u.startDate >= :start AND u.startDate < :end AND u.endDate > :start AND u.endDate <= :end) OR' @@ -34,8 +73,9 @@ public function loadReservationsForPeriod($startDate, $endDate) // ->andWhere('u.invoice IS NULL') ->setParameter('start', $start) ->setParameter('end', $end) - ->addOrderBy('u.endDate', 'ASC') - ->getQuery(); + ->addOrderBy('u.endDate', 'ASC'); + $this->applyBlockingStatusFilter($qb, 'u'); + $q = $qb->getQuery(); // } else { // $q = $this // ->createQueryBuilder('u') @@ -62,6 +102,41 @@ public function loadReservationsForPeriod($startDate, $endDate) return $reservations; } + /** + * Load reservations for housekeeping views with required joins and conflict filters. + * + * @return Reservation[] + */ + public function findForHousekeepingRange( + \DateTimeImmutable $start, + \DateTimeImmutable $end, + ?Subsidiary $subsidiary, + string $statusMode = 'blocking' + ): array { + $qb = $this->createQueryBuilder('r') + ->addSelect('a', 'booker', 'customer') + ->leftJoin('r.appartment', 'a') + ->leftJoin('r.booker', 'booker') + ->leftJoin('r.customers', 'customer') + ->distinct() + ->andWhere('r.startDate < :end') + ->andWhere('r.endDate >= :start') + ->andWhere('r.isConflict = 0') + ->andWhere('r.isConflictIgnored = 0') + ->setParameter('start', $start) + ->setParameter('end', $end) + ->addOrderBy('r.startDate', 'ASC'); + + if ($subsidiary instanceof Subsidiary) { + $qb->andWhere('a.object = :subsidiary') + ->setParameter('subsidiary', $subsidiary->getId()); + } + + $this->applyBlockingStatusFilter($qb, 'r', $statusMode); + + return $qb->getQuery()->getResult(); + } + public function supportsClass($class) { return $this->getEntityName() === $class @@ -71,9 +146,9 @@ public function supportsClass($class) /** * Loads reservations that fits into the period and will include reservations that end at the given start date or starts at the given end date. */ - public function loadReservationsForApartment(\DateTimeInterface $start, \DateTimeInterface $end, Appartment $apartment): array + public function loadReservationsForApartment(\DateTimeInterface $start, \DateTimeInterface $end, Appartment $apartment, string $statusMode = 'blocking'): array { - $q = $this + $qb = $this ->createQueryBuilder('u') ->select('u') ->where('u.appartment = :app ') @@ -85,8 +160,11 @@ public function loadReservationsForApartment(\DateTimeInterface $start, \DateTim ->setParameter('start', $start) ->setParameter('end', $end) ->setParameter('app', $apartment->getId()) - ->addOrderBy('u.endDate', 'ASC') - ->getQuery(); + ->addOrderBy('u.endDate', 'ASC'); + + $this->applyBlockingStatusFilter($qb, 'u', $statusMode); + + $q = $qb->getQuery(); $reservations = []; try { @@ -102,7 +180,7 @@ public function loadReservationsForApartment(\DateTimeInterface $start, \DateTim */ public function loadReservationsForApartmentWithoutStartEnd(\DateTimeInterface $start, \DateTimeInterface $end, Appartment $apartment): array { - $q = $this + $qb = $this ->createQueryBuilder('u') ->select('u') ->where('u.appartment = :app ') @@ -114,8 +192,11 @@ public function loadReservationsForApartmentWithoutStartEnd(\DateTimeInterface $ ->setParameter('start', $start) ->setParameter('end', $end) ->setParameter('app', $apartment->getId()) - ->addOrderBy('u.endDate', 'ASC') - ->getQuery(); + ->addOrderBy('u.endDate', 'ASC'); + + $this->applyBlockingStatusFilter($qb, 'u'); + + $q = $qb->getQuery(); $reservations = []; try { @@ -146,20 +227,19 @@ public function loadReservationsWithoutInvoiceForCustomer(\App\Entity\Customer $ return $reservations; } - public function loadUtilizationForDay($day, $objectId) + public function loadUtilizationForDay($day, $objectId, array $reservationStatus = []) { if ('all' === $objectId) { - $query = $this->createQueryBuilder('u') + $qb = $this->createQueryBuilder('u') ->select('SUM(u.persons)') ->where(':day >= u.startDate and :day < u.endDate') ->andWhere('u.isConflict = 0') ->andWhere('u.isConflictIgnored = 0') // ->andWhere('u.status=1') // ->addGroupBy('u.persons') - ->setParameter('day', $day) - ->getQuery(); + ->setParameter('day', $day); } else { - $query = $this->createQueryBuilder('u') + $qb = $this->createQueryBuilder('u') ->select('SUM(u.persons)') ->where('a.object = :objId and :day >= u.startDate and :day < u.endDate') ->andWhere('u.isConflict = 0') @@ -168,12 +248,13 @@ public function loadUtilizationForDay($day, $objectId) ->join('u.appartment', 'a') // ->addGroupBy('u.persons') ->setParameter('day', $day) - ->setParameter('objId', $objectId) - ->getQuery(); + ->setParameter('objId', $objectId); } try { - return $query->getSingleScalarResult(); + $this->applyReservationStatusFilter($qb, $reservationStatus, 'u'); + + return $qb->getQuery()->getSingleScalarResult(); } catch (NoResultException $ex) { return 0; } @@ -199,13 +280,13 @@ public function getMaxStartDate() return $q->getSingleScalarResult(); } - public function loadReservationsForMonth($month, $year, $objectId) + public function loadReservationsForMonth($month, $year, $objectId, ?array $reservationStatus = []) { $startTs = strtotime($year.'-'.$month.'-01'); $start = date('Y-m-d', $startTs); $end = date('Y-m-d', strtotime('+1 month', $startTs)); - $q = $this + $qb = $this ->createQueryBuilder('u') ->select('u') ->join('u.appartment', 'a') @@ -218,21 +299,23 @@ public function loadReservationsForMonth($month, $year, $objectId) ->addOrderBy('u.endDate', 'ASC'); if ('all' !== $objectId) { - $q->andWhere('a.object = :objId') + $qb->andWhere('a.object = :objId') ->setParameter('objId', $objectId); } try { - return $q->getQuery()->getResult(); + $this->applyReservationStatusFilter($qb, $reservationStatus, 'u'); + + return $qb->getQuery()->getResult(); } catch (NoResultException $e) { return []; } } - public function loadOriginStatisticForPeriod($start, $end, $objectId) + public function loadOriginStatisticForPeriod($start, $end, $objectId, ?array $reservationStatus = []) { if ('all' === $objectId) { - $query = $this->createQueryBuilder('u') + $qb = $this->createQueryBuilder('u') ->select('ro.id, COUNT(u.id) as origins') ->join('u.reservationOrigin', 'ro') ->where('u.startDate >= :start and u.endDate <= :end') @@ -241,10 +324,9 @@ public function loadOriginStatisticForPeriod($start, $end, $objectId) // ->andWhere('u.status=1') ->addGroupBy('u.reservationOrigin') ->setParameter('start', $start) - ->setParameter('end', $end) - ->getQuery(); + ->setParameter('end', $end); } else { - $query = $this->createQueryBuilder('u') + $qb = $this->createQueryBuilder('u') ->select('ro.id, COUNT(u.id) as origins') ->join('u.reservationOrigin', 'ro') ->where('a.object = :objId and u.startDate >= :start and u.endDate <= :end') @@ -255,12 +337,13 @@ public function loadOriginStatisticForPeriod($start, $end, $objectId) ->addGroupBy('u.reservationOrigin') ->setParameter('start', $start) ->setParameter('end', $end) - ->setParameter('objId', $objectId) - ->getQuery(); + ->setParameter('objId', $objectId); } try { - return $query->getArrayResult(); + $this->applyReservationStatusFilter($qb, $reservationStatus, 'u'); + + return $qb->getQuery()->getArrayResult(); } catch (NoResultException $ex) { return 0; } diff --git a/src/Repository/ReservationStatusRepository.php b/src/Repository/ReservationStatusRepository.php index 0f9aae80..2ae17c91 100644 --- a/src/Repository/ReservationStatusRepository.php +++ b/src/Repository/ReservationStatusRepository.php @@ -20,33 +20,19 @@ public function __construct(ManagerRegistry $registry) { parent::__construct($registry, ReservationStatus::class); } - - // /** - // * @return ReservationStatus[] Returns an array of ReservationStatus objects - // */ - /* - public function findByExampleField($value) + /** + * every non-system status is default + * @return int[] + */ + public function findDefaultIds(): array { - return $this->createQueryBuilder('r') - ->andWhere('r.exampleField = :val') - ->setParameter('val', $value) - ->orderBy('r.id', 'ASC') - ->setMaxResults(10) + $rows = $this->createQueryBuilder('rs') + ->select('rs.id') + ->andWhere('rs.code IS NULL OR rs.code = :empty') + ->setParameter('empty', '') ->getQuery() - ->getResult() - ; - } - */ + ->getArrayResult(); - /* - public function findOneBySomeField($value): ?ReservationStatus - { - return $this->createQueryBuilder('r') - ->andWhere('r.exampleField = :val') - ->setParameter('val', $value) - ->getQuery() - ->getOneOrNullResult() - ; + return array_map(static fn (array $row): int => (int) $row['id'], $rows); } - */ } diff --git a/src/Repository/RoomDayStatusRepository.php b/src/Repository/RoomDayStatusRepository.php new file mode 100644 index 00000000..7dc90aa2 --- /dev/null +++ b/src/Repository/RoomDayStatusRepository.php @@ -0,0 +1,60 @@ +> + */ + public function findForApartmentsAndDates(array $apartments, \DateTimeImmutable $start, \DateTimeImmutable $end): array + { + if (0 === count($apartments)) { + return []; + } + + $entries = $this->createQueryBuilder('rds') + ->addSelect('a') + ->addSelect('assignee') + ->addSelect('updatedBy') + ->leftJoin('rds.appartment', 'a') + ->leftJoin('rds.assignedTo', 'assignee') + ->leftJoin('rds.updatedBy', 'updatedBy') + ->andWhere('rds.appartment IN (:apartments)') + ->andWhere('rds.date >= :start') + ->andWhere('rds.date <= :end') + ->setParameter('apartments', $apartments) + ->setParameter('start', $start, Types::DATE_IMMUTABLE) + ->setParameter('end', $end, Types::DATE_IMMUTABLE) + ->getQuery() + ->getResult(); + + $map = []; + foreach ($entries as $entry) { + $dateKey = $entry->getDate()->format('Y-m-d'); + $map[$entry->getAppartment()->getId()][$dateKey] = $entry; + } + + return $map; + } +} diff --git a/src/Repository/TemplateRepository.php b/src/Repository/TemplateRepository.php index 96006f10..d7a45b38 100644 --- a/src/Repository/TemplateRepository.php +++ b/src/Repository/TemplateRepository.php @@ -17,7 +17,10 @@ class TemplateRepository extends EntityRepository { public function findAll(): array { - return $this->findBy([], ['name' => 'ASC']); + return $this->findBy([], [ + 'templateType' => 'ASC', + 'name' => 'ASC' + ]); } public function loadByTypeName($names) diff --git a/src/Service/CalendarService.php b/src/Service/CalendarService.php index 13ca3793..ce37667a 100644 --- a/src/Service/CalendarService.php +++ b/src/Service/CalendarService.php @@ -128,6 +128,10 @@ public function getIcalContent(CalendarSync $sync): string /* @var $reservation Reservation */ foreach ($room->getReservations() as $reservation) { + // Exclude conflict entries from the export feed. + if ($reservation->isConflict() || $reservation->isConflictIgnored()) { + continue; + } // filter reservation status if ($sync->getReservationStatus()->contains($reservation->getReservationStatus())) { $content .= $this->getIcalEventBody($reservation, $sync); diff --git a/src/Service/FrontdeskViewService.php b/src/Service/FrontdeskViewService.php new file mode 100644 index 00000000..c829c079 --- /dev/null +++ b/src/Service/FrontdeskViewService.php @@ -0,0 +1,78 @@ +> $rows + * @param array $selectedCategories + * + * @return array> + */ + public function buildItems(array $rows, \DateTimeImmutable $date, array $selectedCategories): array + { + $dateKey = $date->format('Y-m-d'); + $items = []; + $seen = []; + + foreach ($rows as $row) { + $reservations = $row['apartmentReservations'] ?? []; + foreach ($reservations as $reservation) { + if (!$reservation instanceof Reservation) { + continue; + } + $reservationId = $reservation->getId(); + if (isset($seen[$reservationId])) { + continue; + } + + $startKey = $reservation->getStartDate()->format('Y-m-d'); + $endKey = $reservation->getEndDate()->format('Y-m-d'); + $categories = []; + if ($startKey === $dateKey) { + $categories[] = 'arrival'; + } + if ($endKey === $dateKey) { + $categories[] = 'departure'; + } + if ($startKey < $dateKey && $endKey > $dateKey) { + $categories[] = 'inhouse'; + } + + if (empty($categories)) { + continue; + } + + if (count(array_intersect($categories, $selectedCategories)) === 0) { + continue; + } + + $invoiceStatusLabel = null; + $firstInvoice = $reservation->getInvoices()->first(); + if ($firstInvoice) { + $statusEnum = InvoiceStatus::fromStatus($firstInvoice->getStatus()); + $invoiceStatusLabel = $statusEnum?->labelKey(); + } + + $items[] = [ + 'reservation' => $reservation, + 'apartment' => $row['apartment'], + 'categories' => $categories, + 'invoiceStatusLabel' => $invoiceStatusLabel, + ]; + $seen[$reservationId] = true; + } + } + + return $items; + } +} diff --git a/src/Service/HousekeepingExportService.php b/src/Service/HousekeepingExportService.php new file mode 100644 index 00000000..ada0e277 --- /dev/null +++ b/src/Service/HousekeepingExportService.php @@ -0,0 +1,211 @@ + + * } $dayView + */ + public function buildDayCsvResponse(array $dayView, string $subsidiaryId, string $locale): StreamedResponse + { + $filename = sprintf('housekeeping_%s.csv', $dayView['date']->format('Y-m-d')); + $headers = [ + 'Content-Type' => 'text/csv; charset=UTF-8', + 'Content-Disposition' => sprintf('attachment; filename="%s"', $filename), + ]; + + $response = new StreamedResponse(function () use ($dayView, $locale) { + $handle = fopen('php://output', 'w'); + $occupancyLabels = $this->viewService->getOccupancyLabels(); + $statusLabels = $this->viewService->getStatusLabels(); + fputcsv($handle, [ + $this->translator->trans('housekeeping.date', [], 'Housekeeping', $locale), + $this->translator->trans('housekeeping.room', [], 'Housekeeping', $locale), + $this->translator->trans('housekeeping.occupancy', [], 'Housekeeping', $locale), + $this->translator->trans('housekeeping.guests', [], 'Housekeeping', $locale), + $this->translator->trans('housekeeping.reservation', [], 'Housekeeping', $locale), + $this->translator->trans('housekeeping.status', [], 'Housekeeping', $locale), + $this->translator->trans('housekeeping.assigned_to', [], 'Housekeeping', $locale), + $this->translator->trans('housekeeping.note', [], 'Housekeeping', $locale), + ], ';', '"', "\\"); + + foreach ($dayView['rows'] as $row) { + $status = $row['status']; + $assigned = $status instanceof RoomDayStatus ? $status->getAssignedTo() : null; + $occupancyLabel = $this->translator->trans( + $occupancyLabels[$row['occupancyType']] ?? $row['occupancyType'], + [], + 'Housekeeping', + $locale + ); + $statusLabel = $status instanceof RoomDayStatus + ? $this->translator->trans( + $statusLabels[$status->getHkStatus()->value] ?? $status->getHkStatus()->value, + [], + 'Housekeeping', + $locale + ) + : ''; + + fputcsv($handle, [ + $dayView['date']->format('Y-m-d'), + $this->formatApartmentLabel($row['apartment']), + $occupancyLabel, + $row['guestCount'] ?? '', + $row['reservationSummary'] ?? '', + $statusLabel, + $assigned instanceof User ? $this->formatUserName($assigned) : '', + $status instanceof RoomDayStatus ? ($status->getNote() ?? '') : '', + ], ';', '"', "\\"); + } + + fclose($handle); + }, Response::HTTP_OK, $headers); + + $response->headers->set('X-Selected-Subsidiary', $subsidiaryId); + + return $response; + } + + /** + * Build the CSV response for a week view export. + * + * @param array{ + * start: \DateTimeImmutable, + * days: \DateTimeImmutable[], + * rows: array + * }> + * } $weekView + */ + public function buildWeekCsvResponse(array $weekView, string $subsidiaryId, string $locale): StreamedResponse + { + $filename = sprintf('housekeeping_week_%s.csv', $weekView['start']->format('Y-m-d')); + $headers = [ + 'Content-Type' => 'text/csv; charset=UTF-8', + 'Content-Disposition' => sprintf('attachment; filename="%s"', $filename), + ]; + + $response = new StreamedResponse(function () use ($weekView, $locale) { + $handle = fopen('php://output', 'w'); + $header = [$this->translator->trans('housekeeping.room', [], 'Housekeeping', $locale)]; + foreach ($weekView['days'] as $day) { + $header[] = sprintf('%s %s', $this->formatWeekdayLabel($day, $locale), $day->format('Y-m-d')); + } + fputcsv($handle, $header, ';', '"', "\\"); + + $occupancyLabels = $this->viewService->getOccupancyLabels(); + $statusLabels = $this->viewService->getStatusLabels(); + foreach ($weekView['rows'] as $row) { + $line = [$this->formatApartmentLabel($row['apartment'])]; + foreach ($weekView['days'] as $day) { + $dateKey = $day->format('Y-m-d'); + $cell = $row['days'][$dateKey] ?? null; + if (null === $cell) { + $line[] = ''; + continue; + } + $statusValue = $cell['status'] instanceof RoomDayStatus ? $cell['status']->getHkStatus()->value : ''; + $statusLabel = '' === $statusValue + ? '' + : $this->translator->trans( + $statusLabels[$statusValue] ?? $statusValue, + [], + 'Housekeeping', + $locale + ); + $occupancyLabel = $this->translator->trans( + $occupancyLabels[$cell['occupancyType']] ?? $cell['occupancyType'], + [], + 'Housekeeping', + $locale + ); + $guestCount = $cell['guestCount'] ?? ''; + $line[] = trim(sprintf('%s / %s / %s', $occupancyLabel, $statusLabel, $guestCount)); + } + fputcsv($handle, $line, ';', '"', "\\"); + } + + fclose($handle); + }, Response::HTTP_OK, $headers); + + $response->headers->set('X-Selected-Subsidiary', $subsidiaryId); + + return $response; + } + + /** + * Format a user name for display and exports. + */ + private function formatUserName(User $user): string + { + return trim(sprintf('%s %s', $user->getFirstname(), $user->getLastname())); + } + + /** + * Format the apartment label for tables and exports. + */ + private function formatApartmentLabel(Appartment $apartment): string + { + $label = trim(sprintf('%s %s', $apartment->getNumber(), $apartment->getDescription())); + + return '' === $label ? (string) $apartment->getId() : $label; + } + + /** + * Format a localized short weekday label for CSV exports. + */ + private function formatWeekdayLabel(\DateTimeImmutable $date, string $locale): string + { + if (!class_exists(\IntlDateFormatter::class)) { + return $date->format('D'); + } + + $formatter = new \IntlDateFormatter( + $locale, + \IntlDateFormatter::FULL, + \IntlDateFormatter::NONE, + 'UTC', + null, + 'EEE' + ); + + return $formatter->format($date) ?: $date->format('D'); + } +} diff --git a/src/Service/HousekeepingViewService.php b/src/Service/HousekeepingViewService.php new file mode 100644 index 00000000..4e105aac --- /dev/null +++ b/src/Service/HousekeepingViewService.php @@ -0,0 +1,546 @@ + + * } + */ + public function buildDayView(\DateTimeImmutable $date, ?Subsidiary $subsidiary, string $statusMode = 'blocking'): array + { + $apartments = $this->loadApartments($subsidiary); + $reservations = $this->reservationRepository->findForHousekeepingRange($date, $date->modify('+1 day'), $subsidiary, $statusMode); + $reservationsByApartment = $this->groupReservationsByApartment($reservations); + $statusMap = $this->loadStatusMap($apartments, $date, $date); + $dateKey = $date->format('Y-m-d'); + + $rows = []; + foreach ($apartments as $apartment) { + $apartmentReservations = $reservationsByApartment[$apartment->getId()] ?? []; + $occupancy = $this->resolveOccupancyForDay($date, $apartmentReservations); + $rows[] = [ + 'apartment' => $apartment, + 'occupancyType' => $occupancy['type'], + 'guestCount' => $occupancy['guestCount'], + 'reservationSummary' => $occupancy['summary'], + 'status' => $statusMap[$apartment->getId()][$dateKey] ?? null, + ]; + } + + return [ + 'date' => $date, + 'apartments' => $apartments, + 'rows' => $rows, + ]; + } + + /** + * Build a housekeeping view model for a date range including reservations. + * + * @return array{ + * start: \DateTimeImmutable, + * end: \DateTimeImmutable, + * days: \DateTimeImmutable[], + * apartments: Appartment[], + * reservations: Reservation[], + * dayViews: array + * }> + * } + */ + public function buildRangeView( + \DateTimeImmutable $start, + \DateTimeImmutable $end, + ?Subsidiary $subsidiary, + array $occupancyTypes, + string $statusMode = 'blocking' + ): array { + $apartments = $this->loadApartments($subsidiary); + $reservations = $this->reservationRepository->findForHousekeepingRange($start, $end->modify('+1 day'), $subsidiary, $statusMode); + $reservationsByApartment = $this->groupReservationsByApartment($reservations); + $statusMap = $this->loadStatusMap($apartments, $start, $end); + $days = $this->buildDaysRange($start, $end); + + $dayViews = []; + foreach ($days as $day) { + $dateKey = $day->format('Y-m-d'); + $rows = []; + foreach ($apartments as $apartment) { + $apartmentReservations = $reservationsByApartment[$apartment->getId()] ?? []; + $occupancy = $this->resolveOccupancyForDay($day, $apartmentReservations); + $rows[] = [ + 'apartment' => $apartment, + 'occupancyType' => $occupancy['type'], + 'guestCount' => $occupancy['guestCount'], + 'reservationSummary' => $occupancy['summary'], + 'status' => $statusMap[$apartment->getId()][$dateKey] ?? null, + 'apartmentReservations' => $this->filterReservationsForDate($day, $apartmentReservations), + ]; + } + + $dayView = [ + 'date' => $day, + 'apartments' => $apartments, + 'rows' => $rows, + ]; + $dayViews[$dateKey] = $this->filterDayViewByOccupancy($dayView, $occupancyTypes); + } + + return [ + 'start' => $start, + 'end' => $end, + 'days' => $days, + 'apartments' => $apartments, + 'reservations' => $reservations, + 'dayViews' => $dayViews, + ]; + } + + /** + * Build the housekeeping view model for a Monday-Sunday week. + * + * @return array{ + * start: \DateTimeImmutable, + * end: \DateTimeImmutable, + * days: \DateTimeImmutable[], + * apartments: Appartment[], + * rows: array + * }> + * } + */ + public function buildWeekView(\DateTimeImmutable $start, \DateTimeImmutable $end, ?Subsidiary $subsidiary, string $statusMode = 'blocking'): array + { + $apartments = $this->loadApartments($subsidiary); + $reservations = $this->reservationRepository->findForHousekeepingRange($start, $end->modify('+1 day'), $subsidiary, $statusMode); + $reservationsByApartment = $this->groupReservationsByApartment($reservations); + $statusMap = $this->loadStatusMap($apartments, $start, $end); + $days = $this->buildDaysRange($start, $end); + + $rows = []; + foreach ($apartments as $apartment) { + $apartmentReservations = $reservationsByApartment[$apartment->getId()] ?? []; + $dayEntries = []; + foreach ($days as $day) { + $dateKey = $day->format('Y-m-d'); + $occupancy = $this->resolveOccupancyForDay($day, $apartmentReservations); + $dayEntries[$dateKey] = [ + 'occupancyType' => $occupancy['type'], + 'guestCount' => $occupancy['guestCount'], + 'reservationSummary' => $occupancy['summary'], + 'status' => $statusMap[$apartment->getId()][$dateKey] ?? null, + ]; + } + $rows[] = [ + 'apartment' => $apartment, + 'days' => $dayEntries, + ]; + } + + return [ + 'start' => $start, + 'end' => $end, + 'days' => $days, + 'apartments' => $apartments, + 'rows' => $rows, + ]; + } + + /** + * Resolve the occupancy type and display details for a single day. + * + * @param Reservation[] $reservations + * + * @return array{type: string, guestCount: int|null, summary: string|null} + */ + public function resolveOccupancyForDay(\DateTimeImmutable $date, array $reservations): array + { + $arrivals = []; + $departures = []; + $stayovers = []; + + $dateKey = $date->format('Y-m-d'); + foreach ($reservations as $reservation) { + $startKey = $reservation->getStartDate()->format('Y-m-d'); + $endKey = $reservation->getEndDate()->format('Y-m-d'); + + if ($startKey === $dateKey) { + $arrivals[] = $reservation; + } + if ($endKey === $dateKey) { + $departures[] = $reservation; + } + if ($startKey < $dateKey && $endKey > $dateKey) { + $stayovers[] = $reservation; + } + } + + if (!empty($arrivals) && !empty($departures)) { + $primary = $arrivals[0]; + + return [ + 'type' => 'TURNOVER', + 'guestCount' => $primary->getPersons(), + 'summary' => $this->buildReservationSummary($primary, $arrivals, $departures), + ]; + } + + if (!empty($arrivals)) { + $primary = $arrivals[0]; + + return [ + 'type' => 'ARRIVAL', + 'guestCount' => $primary->getPersons(), + 'summary' => $this->buildReservationSummary($primary, $arrivals, $departures), + ]; + } + + if (!empty($departures)) { + $primary = $departures[0]; + + return [ + 'type' => 'DEPARTURE', + 'guestCount' => $primary->getPersons(), + 'summary' => $this->buildReservationSummary($primary, $arrivals, $departures), + ]; + } + + if (!empty($stayovers)) { + $primary = $stayovers[0]; + + return [ + 'type' => 'STAYOVER', + 'guestCount' => $primary->getPersons(), + 'summary' => $this->buildReservationSummary($primary, $arrivals, $departures), + ]; + } + + return [ + 'type' => 'FREE', + 'guestCount' => null, + 'summary' => null, + ]; + } + + /** + * Define translation keys for housekeeping status values. + */ + public function getStatusLabels(): array + { + return [ + 'OPEN' => 'housekeeping.status.open', + 'IN_PROGRESS' => 'housekeeping.status.in_progress', + 'CLEANED' => 'housekeeping.status.cleaned', + 'INSPECTED' => 'housekeeping.status.inspected', + ]; + } + + /** + * Define translation keys for occupancy types. + */ + public function getOccupancyLabels(): array + { + return [ + 'FREE' => 'housekeeping.occupancy.free', + 'STAYOVER' => 'housekeeping.occupancy.stayover', + 'ARRIVAL' => 'housekeeping.occupancy.arrival', + 'DEPARTURE' => 'housekeeping.occupancy.departure', + 'TURNOVER' => 'housekeeping.occupancy.turnover', + ]; + } + + /** + * @return string[] + */ + public function getAllowedOccupancyTypes(): array + { + return ['FREE', 'STAYOVER', 'ARRIVAL', 'DEPARTURE', 'TURNOVER']; + } + + /** + * Normalize the occupancy filter selection. + * + * @return string[] + */ + public function normalizeOccupancyTypes(mixed $param): array + { + if (is_string($param)) { + $values = array_filter(array_map('trim', explode(',', $param))); + } elseif (is_array($param)) { + $values = array_filter(array_map('trim', $param)); + } else { + $values = []; + } + + $allowed = $this->getAllowedOccupancyTypes(); + $filtered = array_values(array_intersect($allowed, $values)); + + return [] === $filtered ? $allowed : $filtered; + } + + /** + * @param array{ + * date: \DateTimeImmutable, + * apartments: Appartment[], + * rows: array + * } $dayView + */ + public function filterDayViewByOccupancy(array $dayView, array $allowedTypes): array + { + $dayView['rows'] = array_values(array_filter($dayView['rows'], static function (array $row) use ($allowedTypes): bool { + return in_array($row['occupancyType'], $allowedTypes, true); + })); + + return $dayView; + } + + /** + * @param array{ + * start: \DateTimeImmutable, + * end: \DateTimeImmutable, + * days: \DateTimeImmutable[], + * apartments: Appartment[], + * rows: array + * }> + * } $weekView + */ + public function filterWeekViewByOccupancy(array $weekView, array $allowedTypes): array + { + $weekView['rows'] = array_values(array_filter($weekView['rows'], static function (array $row) use ($allowedTypes): bool { + foreach ($row['days'] as $cell) { + if (in_array($cell['occupancyType'], $allowedTypes, true)) { + return true; + } + } + + return false; + })); + + return $weekView; + } + + /** + * Load apartments filtered by subsidiary if provided. + * + * @return Appartment[] + */ + private function loadApartments(?Subsidiary $subsidiary): array + { + if (!$subsidiary instanceof Subsidiary) { + return $this->appartmentRepository->findAll(); + } + + return $this->appartmentRepository->findAllByProperty($subsidiary->getId()); + } + + /** + * Group reservations by apartment id for faster lookups. + * + * @param Reservation[] $reservations + * + * @return array + */ + private function groupReservationsByApartment(array $reservations): array + { + $grouped = []; + foreach ($reservations as $reservation) { + $apartment = $reservation->getAppartment(); + if (!$apartment instanceof Appartment) { + continue; + } + $grouped[$apartment->getId()][] = $reservation; + } + + return $grouped; + } + + /** + * Filter reservations matching the given date. + * + * @param Reservation[] $reservations + * + * @return Reservation[] + */ + private function filterReservationsForDate(\DateTimeImmutable $date, array $reservations): array + { + $dateKey = $date->format('Y-m-d'); + + return array_values(array_filter($reservations, static function (Reservation $reservation) use ($dateKey): bool { + $startKey = $reservation->getStartDate()->format('Y-m-d'); + $endKey = $reservation->getEndDate()->format('Y-m-d'); + + return $startKey <= $dateKey && $endKey >= $dateKey; + })); + } + + /** + * Build a readable reservation summary for quick scanning. + * + * @param Reservation[] $arrivals + * @param Reservation[] $departures + * + * @return string|null + */ + private function buildReservationSummary(Reservation $primary, array $arrivals, array $departures): ?string + { + $name = $this->resolveReservationName($primary); + if ('' === $name) { + return null; + } + + if (!empty($arrivals) && !empty($departures)) { + $departureName = $this->resolveReservationName($departures[0]); + if ('' !== $departureName && $departureName !== $name) { + $departLabel = $this->translator->trans('housekeeping.summary.depart', [], 'Housekeeping'); + $arriveLabel = $this->translator->trans('housekeeping.summary.arrive', [], 'Housekeeping'); + + return sprintf('%s: %s / %s: %s', $departLabel, $departureName, $arriveLabel, $name); + } + } + + return $name; + } + + /** + * Resolve a display name for a reservation from booker/import data. + * + * @return string + */ + private function resolveReservationName(Reservation $reservation): string + { + $booker = $reservation->getBooker(); + if ($booker instanceof \App\Entity\Customer) { + $business = $this->resolveBusinessCompany($booker); + if (null !== $business) { + $lastname = trim((string) $booker->getLastname()); + if ('' !== $lastname) { + return sprintf('%s (%s)', $business, $lastname); + } + + return $business; + } + + return trim(sprintf('%s %s', (string) $booker->getLastname(), (string) $booker->getFirstname())); + } + + $import = $reservation->getCalendarSyncImport(); + if ($import instanceof \App\Entity\CalendarSyncImport) { + $name = trim($import->getName()); + if ('' !== $name) { + return $name; + } + } + + return ''; + } + + /** + * Resolve the business company name for a customer if available. + */ + private function resolveBusinessCompany(\App\Entity\Customer $customer): ?string + { + foreach ($customer->getCustomerAddresses() as $address) { + if ('CUSTOMER_ADDRESS_TYPE_BUSINESS' === $address->getType()) { + $company = trim((string) $address->getCompany()); + if ('' !== $company) { + return $company; + } + } + } + + return null; + } + + + /** + * Build an inclusive list of days between start and end. + * + * @return \DateTimeImmutable[] + */ + private function buildDaysRange(\DateTimeImmutable $start, \DateTimeImmutable $end): array + { + $days = []; + $cursor = $start; + while ($cursor <= $end) { + $days[] = $cursor; + $cursor = $cursor->modify('+1 day'); + } + + return $days; + } + + /** + * Load existing housekeeping status entries for the given apartments and date range. + * + * @param Appartment[] $apartments + * + * @return array> + */ + private function loadStatusMap(array $apartments, \DateTimeImmutable $start, \DateTimeImmutable $end): array + { + return $this->roomDayStatusRepository->findForApartmentsAndDates($apartments, $start, $end); + } +} diff --git a/src/Service/InvoiceService.php b/src/Service/InvoiceService.php index de78b003..55d11a59 100644 --- a/src/Service/InvoiceService.php +++ b/src/Service/InvoiceService.php @@ -17,24 +17,33 @@ use App\Entity\Invoice; use App\Entity\InvoiceAppartment; use App\Entity\InvoicePosition; +use App\Entity\InvoiceSettingsData; use App\Entity\Price; use App\Entity\Reservation; use App\Entity\Template; +use App\Enum\InvoiceStatus; use App\Interfaces\ITemplateRenderer; +use App\Service\EInvoice\EInvoiceExportService; use Doctrine\Common\Collections\ArrayCollection; use Doctrine\Common\Collections\Collection; use Doctrine\ORM\EntityManagerInterface; +use horstoeko\zugferd\ZugferdDocumentPdfMerger; use Symfony\Component\HttpFoundation\RequestStack; +use Symfony\Contracts\Translation\TranslatorInterface; class InvoiceService implements ITemplateRenderer { private $em; private $ps; + private TranslatorInterface $translator; + private string $invoiceFilenamePattern; - public function __construct(EntityManagerInterface $em, PriceService $ps) + public function __construct(EntityManagerInterface $em, PriceService $ps, TranslatorInterface $translator, string $invoiceFilenamePattern) { $this->em = $em; $this->ps = $ps; + $this->translator = $translator; + $this->invoiceFilenamePattern = $invoiceFilenamePattern; } /** @@ -231,6 +240,102 @@ public function getRenderParams(Template $template, mixed $param) return $params; } + public function generateInvoicePdfXml(TemplatesService $ts, EInvoiceExportService $einvoice, Invoice $invoice, Template $template, InvoiceSettingsData $invoiceSettings): string + { + $templateOutput = $ts->renderTemplate($template->getId(), $invoice->getId(), $this); + $pdfOutput = $ts->getPDFOutput($templateOutput, $this->buildInvoiceExportFilename($invoice, true), $template, true); + $xml = $einvoice->generateInvoiceData($invoice, $invoiceSettings); + + return (new ZugferdDocumentPdfMerger($xml, $pdfOutput)) + ->generateDocument() + ->downloadString(); + } + + /** + * Builds a sanitized invoice export filename (without extension) from the configured pattern. + */ + public function buildInvoiceExportFilename(Invoice $invoice, bool $appendEinvoiceSuffix = false): string + { + $pattern = trim($this->invoiceFilenamePattern); + if ('' === $pattern) { + $pattern = $this->translator->trans('invoice.number.short') . '-'; + } + + $statusLabel = ''; + $statusEnum = InvoiceStatus::fromStatus($invoice->getStatus()); + if (null !== $statusEnum) { + $statusLabel = $this->translator->trans($statusEnum->labelKey()); + } + + $paymentLabel = ''; + $paymentMeans = $invoice->getPaymentMeans(); + if (null !== $paymentMeans) { + $paymentLabel = $this->translator->trans($paymentMeans->name); + } + + $replacements = [ + 'company' => (string) $invoice->getCompany(), + 'lastname' => (string) $invoice->getLastname(), + 'firstname' => (string) $invoice->getFirstname(), + 'status' => $statusLabel, + 'payment' => $paymentLabel, + 'paymentmeans' => $paymentLabel, + 'payment_means' => $paymentLabel, + 'number' => (string) $invoice->getNumber(), + 'date' => $invoice->getDate()->format('Y-m-d'), + ]; + + $filename = preg_replace_callback('/<([a-zA-Z_|]+)>/', static function (array $matches) use ($replacements): string { + $keys = array_filter(array_map('trim', explode('|', strtolower($matches[1])))); + foreach ($keys as $key) { + $value = $replacements[$key] ?? ''; + if ('' !== trim((string) $value)) { + return (string) $value; + } + } + + return ''; + }, $pattern); + + if ($appendEinvoiceSuffix) { + $filename .= '-einvoice'; + } + + return $this->sanitizeFilename($filename); + } + + /** + * Converts a raw filename to a safe ASCII-only filename. + */ + public function sanitizeFilename(string $value): string + { + $value = $this->replaceGermanUmlauts($value); + $value = transliterator_transliterate('Any-Latin; Latin-ASCII', $value); + $value = preg_replace('/\s+/', '_', $value); + $value = preg_replace('/[^A-Za-z0-9._-]/', '', $value); + $value = preg_replace('/_+/', '_', $value); + $value = preg_replace('/-+/', '-', $value); + $value = trim($value, "._-"); + + return '' !== $value ? $value : 'invoice'; + } + + /** + * Replaces German umlauts and Eszett with ASCII equivalents. + */ + private function replaceGermanUmlauts(string $value): string + { + return strtr($value, [ + 'ä' => 'ae', + 'ö' => 'oe', + 'ü' => 'ue', + 'Ä' => 'Ae', + 'Ö' => 'Oe', + 'Ü' => 'Ue', + 'ß' => 'ss', + ]); + } + /** * Retrieves valid prices for each day of stay and prefills the apartment position for the reservation * each day has exactly one valid price category. diff --git a/src/Service/MonthlyStatsService.php b/src/Service/MonthlyStatsService.php index fbbc1dd8..6e9e459b 100644 --- a/src/Service/MonthlyStatsService.php +++ b/src/Service/MonthlyStatsService.php @@ -8,9 +8,9 @@ use App\Entity\Customer; use App\Entity\MonthlyStatsSnapshot; use App\Entity\Reservation; -use App\Entity\ReservationOrigin; use App\Entity\Subsidiary; use App\Entity\Enum\InvoiceStatus; +use App\Entity\ReservationStatus; use Doctrine\DBAL\Exception\UniqueConstraintViolationException; use Doctrine\ORM\EntityManagerInterface; use Doctrine\Persistence\ManagerRegistry; @@ -40,9 +40,16 @@ public function getOrCreateSnapshot(int $month, int $year, ?Subsidiary $subsidia /** * Build the metrics payload and warnings for a snapshot without persisting it. */ - public function buildMetrics(int $month, int $year, ?Subsidiary $subsidiary): array + public function buildMetrics( + int $month, + int $year, + ?Subsidiary $subsidiary, + array $ignoredWarnings = [], + array $reservationStatus = [] + ): array { $this->ensureEntityManager(); + $defaultStatusIds = $reservationStatus ?: $this->getDefaultReservationStatusIds(); $objectId = $subsidiary?->getId() ?? 'all'; $appartmentRepo = $this->em->getRepository(Appartment::class); $reservationRepo = $this->em->getRepository(Reservation::class); @@ -55,21 +62,55 @@ public function buildMetrics(int $month, int $year, ?Subsidiary $subsidiary): ar $monthStart = new \DateTimeImmutable(sprintf('%04d-%02d-01', $year, $month)); $monthEndExclusive = $monthStart->modify('first day of next month'); - $monthEndInclusive = $monthEndExclusive->modify('-1 day'); - $reservations = $reservationRepo->loadReservationsForMonth($month, $year, $objectId); + $reservations = $reservationRepo->loadReservationsForMonth($month, $year, $objectId, null); /* * Calculate utilization and tourism stats (arrivals, overnights) for the month. */ + $defaultStatusLookup = array_flip(array_map('intval', $defaultStatusIds)); + $summary = [ + 'reservations_total' => 0, + 'guests_total' => 0, + 'nights_total' => 0, + 'arrivals_total' => 0, + 'departures_total' => 0, + 'turnovers_count' => 0, + ]; + $tourism = [ + 'arrivals_total' => 0, + 'overnights_total' => 0, + 'arrivals_by_country' => [], + 'overnights_by_country' => [], + ]; + $originStats = []; $stays = 0; - $arrivalsTotal = 0; - $overnightsTotal = 0; - $arrivalsByCountry = []; - $overnightsByCountry = []; $warningsByReservation = []; + $arrivalDatesByApartment = []; + $departureDatesByApartment = []; + $byStatus = []; foreach ($reservations as $reservation) { + $statusId = $reservation->getReservationStatus()?->getId(); + if (null === $statusId) { + continue; + } + $statusKey = (string) $statusId; + $isDefaultStatus = isset($defaultStatusLookup[$statusId]); + if (!isset($byStatus[$statusKey])) { + $byStatus[$statusKey] = [ + 'tourism' => [ + 'arrivals_total' => 0, + 'overnights_total' => 0, + 'arrivals_by_country' => [], + 'overnights_by_country' => [], + ], + 'utilization' => [ + 'stays' => 0, + ], + ]; + } + $customers = $reservation->getCustomers(); $customerCount = $customers->count(); $persons = $reservation->getPersons(); @@ -81,6 +122,7 @@ public function buildMetrics(int $month, int $year, ?Subsidiary $subsidiary): ar $appartmentNumber = $appartment ? $appartment->getNumber() : null; $warningsByReservation[$reservationId] = [ 'reservation_id' => $reservationId, + 'status_id' => $statusId, 'start_date' => $reservation->getStartDate()->format('Y-m-d'), 'end_date' => $reservation->getEndDate()->format('Y-m-d'), 'appartment_number' => $appartmentNumber, @@ -92,6 +134,10 @@ public function buildMetrics(int $month, int $year, ?Subsidiary $subsidiary): ar continue; } + if ($isDefaultStatus) { + $summary['reservations_total'] += 1; + } + $startDate = $this->toImmutable($reservation->getStartDate()); $endDate = $this->toImmutable($reservation->getEndDate()); @@ -101,16 +147,33 @@ public function buildMetrics(int $month, int $year, ?Subsidiary $subsidiary): ar // Only count overnights that fall within the report month. if ($resStart < $resEnd) { $nights = $resStart->diff($resEnd)->days; - $stays += $nights * $persons; + $byStatus[$statusKey]['utilization']['stays'] += $nights * $persons; + if ($isDefaultStatus) { + $stays += $nights * $persons; + } if ($useBookerFallback) { $country = $this->resolveCountryForCustomer($reservation->getBooker()); - $overnightsByCountry[$country] = ($overnightsByCountry[$country] ?? 0) + ($nights * $persons); - $overnightsTotal += $nights * $persons; + $byStatus[$statusKey]['tourism']['overnights_by_country'][$country] = + ($byStatus[$statusKey]['tourism']['overnights_by_country'][$country] ?? 0) + ($nights * $persons); + $byStatus[$statusKey]['tourism']['overnights_total'] += $nights * $persons; + if ($isDefaultStatus) { + $tourism['overnights_by_country'][$country] = + ($tourism['overnights_by_country'][$country] ?? 0) + ($nights * $persons); + $tourism['overnights_total'] += $nights * $persons; + $summary['nights_total'] += $nights * $persons; + } } else { foreach ($customers as $customer) { $country = $this->resolveCountryForCustomer($customer); - $overnightsByCountry[$country] = ($overnightsByCountry[$country] ?? 0) + $nights; - $overnightsTotal += $nights; + $byStatus[$statusKey]['tourism']['overnights_by_country'][$country] = + ($byStatus[$statusKey]['tourism']['overnights_by_country'][$country] ?? 0) + $nights; + $byStatus[$statusKey]['tourism']['overnights_total'] += $nights; + if ($isDefaultStatus) { + $tourism['overnights_by_country'][$country] = + ($tourism['overnights_by_country'][$country] ?? 0) + $nights; + $tourism['overnights_total'] += $nights; + $summary['nights_total'] += $nights; + } } } } @@ -119,48 +182,96 @@ public function buildMetrics(int $month, int $year, ?Subsidiary $subsidiary): ar if ($startDate >= $monthStart && $startDate < $monthEndExclusive) { if ($useBookerFallback) { $country = $this->resolveCountryForCustomer($reservation->getBooker()); - $arrivalsByCountry[$country] = ($arrivalsByCountry[$country] ?? 0) + $persons; - $arrivalsTotal += $persons; + $byStatus[$statusKey]['tourism']['arrivals_by_country'][$country] = + ($byStatus[$statusKey]['tourism']['arrivals_by_country'][$country] ?? 0) + $persons; + $byStatus[$statusKey]['tourism']['arrivals_total'] += $persons; + if ($isDefaultStatus) { + $tourism['arrivals_by_country'][$country] = + ($tourism['arrivals_by_country'][$country] ?? 0) + $persons; + $tourism['arrivals_total'] += $persons; + $summary['arrivals_total'] += $persons; + $summary['guests_total'] += $persons; + } } else { foreach ($customers as $customer) { $country = $this->resolveCountryForCustomer($customer); - $arrivalsByCountry[$country] = ($arrivalsByCountry[$country] ?? 0) + 1; - $arrivalsTotal += 1; + $byStatus[$statusKey]['tourism']['arrivals_by_country'][$country] = + ($byStatus[$statusKey]['tourism']['arrivals_by_country'][$country] ?? 0) + 1; + $byStatus[$statusKey]['tourism']['arrivals_total'] += 1; + if ($isDefaultStatus) { + $tourism['arrivals_by_country'][$country] = + ($tourism['arrivals_by_country'][$country] ?? 0) + 1; + $tourism['arrivals_total'] += 1; + $summary['arrivals_total'] += 1; + $summary['guests_total'] += 1; + } } } + + $appartment = $reservation->getAppartment(); + if ($isDefaultStatus && $appartment instanceof Appartment) { + $arrivalDatesByApartment[$appartment->getId()][$startDate->format('Y-m-d')] = true; + } } - } - ksort($arrivalsByCountry); - ksort($overnightsByCountry); + // Departures are counted only in the end month of the reservation. + if ($endDate > $monthStart && $endDate <= $monthEndExclusive) { + if ($isDefaultStatus) { + if ($useBookerFallback) { + $summary['departures_total'] += $persons; + } else { + $summary['departures_total'] += max(1, $customerCount); + } + } - /* - * Calculate overall utilization percentage for the month. - */ - $daysInMonth = (int) $monthStart->format('t'); - $utilization = 0.0; - if ($bedsTotal > 0 && $daysInMonth > 0) { - $utilization = $stays * 100.0 / ($bedsTotal * $daysInMonth); + $appartment = $reservation->getAppartment(); + if ($isDefaultStatus && $appartment instanceof Appartment) { + $departureDatesByApartment[$appartment->getId()][$endDate->format('Y-m-d')] = true; + } + } + + $origin = $reservation->getReservationOrigin(); + if ($isDefaultStatus && null !== $origin) { + $originName = $origin->getName(); + $originStats[$originName] = ($originStats[$originName] ?? 0) + 1; + } } - $dailyUtilization = $this->buildDailyUtilization($monthStart, $daysInMonth, $objectId, $bedsTotal, $reservationRepo); - /* - * Calculate reservation origins for the month. - */ - $originRows = $reservationRepo->loadOriginStatisticForPeriod( - $monthStart->format('Y-m-d'), - $monthEndInclusive->format('Y-m-d'), - $objectId - ); - $originStats = []; - foreach ($originRows as $row) { - $origin = $this->em->getRepository(ReservationOrigin::class)->find($row['id']); - if (null !== $origin) { - $originStats[$origin->getName()] = (int) $row['origins']; + $daysInMonth = (int) $monthStart->format('t'); + foreach ($byStatus as $statusKey => &$bucket) { + ksort($bucket['tourism']['arrivals_by_country']); + ksort($bucket['tourism']['overnights_by_country']); + } + unset($bucket); + + $turnoversTotal = 0; + foreach ($arrivalDatesByApartment as $apartmentId => $arrivalDatesForApartment) { + $departureDatesForApartment = $departureDatesByApartment[$apartmentId] ?? []; + foreach ($arrivalDatesForApartment as $dateKey => $present) { + if (isset($departureDatesForApartment[$dateKey])) { + ++$turnoversTotal; + } } } + $summary['turnovers_count'] = $turnoversTotal; + ksort($tourism['arrivals_by_country']); + ksort($tourism['overnights_by_country']); ksort($originStats); + $dailyUtilization = $this->buildDailyUtilization( + $monthStart, + $daysInMonth, + $objectId, + $bedsTotal, + $reservationRepo, + $defaultStatusIds + ); + + // Attach ignored flags to warnings. + foreach ($warningsByReservation as $reservationId => &$warning) { + $warning['ignored'] = $ignoredWarnings[$reservationId] ?? false; + } + unset($warning); $warnings = array_values($warningsByReservation); $metrics = [ 'period' => [ @@ -168,22 +279,21 @@ public function buildMetrics(int $month, int $year, ?Subsidiary $subsidiary): ar 'month' => $month, ], 'subsidiary' => $subsidiary?->getId(), + 'summary' => $summary, 'inventory' => [ 'rooms_total' => $roomsTotal, 'beds_total' => $bedsTotal, ], 'utilization' => [ - 'month_percent' => $utilization, + 'month_percent' => ($bedsTotal > 0 && $daysInMonth > 0) + ? ($stays * 100.0 / ($bedsTotal * $daysInMonth)) + : 0.0, 'daily_percent' => $dailyUtilization, ], - 'tourism' => [ - 'arrivals_total' => $arrivalsTotal, - 'overnights_total' => $overnightsTotal, - 'arrivals_by_country' => $arrivalsByCountry, - 'overnights_by_country' => $overnightsByCountry, - ], + 'tourism' => $tourism, 'reservation_origin' => $originStats, 'warnings' => $warnings, + 'by_status' => $byStatus, ]; if (null === $subsidiary) { /* @@ -257,17 +367,63 @@ private function toImmutable(\DateTimeInterface $date): \DateTimeImmutable /** * Build daily utilization percentages for a month without persisting them. */ - public function getDailyUtilizationForMonth(int $month, int $year, ?Subsidiary $subsidiary): array + public function getDailyUtilizationForMonth( + int $month, + int $year, + ?Subsidiary $subsidiary, + array $reservationStatus = [] + ): array { $objectId = $subsidiary?->getId() ?? 'all'; $appartmentRepo = $this->em->getRepository(Appartment::class); $reservationRepo = $this->em->getRepository(Reservation::class); $bedsTotal = (int) $appartmentRepo->loadSumBedsMinForObject($objectId); + if (!$reservationStatus) { + $reservationStatus = $this->getDefaultReservationStatusIds(); + } $monthStart = new \DateTimeImmutable(sprintf('%04d-%02d-01', $year, $month)); $daysInMonth = (int) $monthStart->format('t'); - return $this->buildDailyUtilization($monthStart, $daysInMonth, $objectId, $bedsTotal, $reservationRepo); + return $this->buildDailyUtilization( + $monthStart, + $daysInMonth, + $objectId, + $bedsTotal, + $reservationRepo, + $reservationStatus + ); + } + + /** + * Filter existing metrics to only include data for the given reservation status IDs. + */ + public function filterMetricsByStatus(array $metrics, array $statusIds): array + { + $statusIds = array_values(array_filter(array_map('intval', $statusIds), static fn (int $id): bool => $id > 0)); + if (!$statusIds || empty($metrics['by_status']) || !is_array($metrics['by_status'])) { + return $metrics; + } + + $period = $metrics['period'] ?? []; + $year = (int) ($period['year'] ?? 0); + $month = (int) ($period['month'] ?? 0); + $daysInMonth = 0; + if ($year > 0 && $month > 0) { + $daysInMonth = (int) (new \DateTimeImmutable(sprintf('%04d-%02d-01', $year, $month)))->format('t'); + } + + $bedsTotal = (int) ($metrics['inventory']['beds_total'] ?? 0); + $aggregate = $this->aggregateByStatus($metrics['by_status'], $statusIds, $bedsTotal, $daysInMonth); + $metrics['tourism'] = $aggregate['tourism']; + $metrics['utilization']['month_percent'] = $aggregate['utilization']['month_percent']; + $metrics['utilization']['daily_percent'] = []; + $metrics['warnings'] = array_values(array_filter( + $metrics['warnings'] ?? [], + static fn (array $warning): bool => in_array((int) ($warning['status_id'] ?? 0), $statusIds, true) + )); + + return $metrics; } /** @@ -296,7 +452,16 @@ private function upsertSnapshot(int $month, int $year, ?Subsidiary $subsidiary, } $snapshot->setIsAll(null === $subsidiary); - $payload = $this->buildMetrics($month, $year, $subsidiary); + $ignoredWarnings = []; + if (null !== $snapshot) { + $existingMetrics = $snapshot->getMetrics(); + foreach (($existingMetrics['warnings'] ?? []) as $warning) { + if (!empty($warning['ignored']) && isset($warning['reservation_id'])) { + $ignoredWarnings[(int) $warning['reservation_id']] = true; + } + } + } + $payload = $this->buildMetrics($month, $year, $subsidiary, $ignoredWarnings); $snapshot->setMetrics($payload['metrics']); $snapshot->touchUpdatedAt(); try { @@ -336,19 +501,71 @@ private function buildDailyUtilization( int $daysInMonth, $objectId, int $bedsTotal, - $reservationRepo + $reservationRepo, + array $reservationStatus = [] ): array { $beds = 0 === $bedsTotal ? 1 : $bedsTotal; $data = []; $timeStartStr = $monthStart->format('Y-m-'); for ($i = 1; $i <= $daysInMonth; ++$i) { - $utilization = $reservationRepo->loadUtilizationForDay($timeStartStr.$i, $objectId); + $utilization = $reservationRepo->loadUtilizationForDay($timeStartStr.$i, $objectId, $reservationStatus); $data[] = $utilization * 100 / $beds; } return $data; } + private function getDefaultReservationStatusIds(): array + { + return $this->em->getRepository(ReservationStatus::class)->findDefaultIds(); + } + + /** + * Loop over reservation status IDs in the 'by_status' field and calculate the summed metrics. + */ + private function aggregateByStatus(array $byStatus, array $statusIds, int $bedsTotal, int $daysInMonth): array + { + $tourism = [ + 'arrivals_total' => 0, + 'overnights_total' => 0, + 'arrivals_by_country' => [], + 'overnights_by_country' => [], + ]; + $stays = 0; + + foreach ($statusIds as $statusId) { + $key = (string) $statusId; + if (!isset($byStatus[$key]) || !is_array($byStatus[$key])) { + continue; + } + $bucket = $byStatus[$key]; + $tourism['arrivals_total'] += (int) ($bucket['tourism']['arrivals_total'] ?? 0); + $tourism['overnights_total'] += (int) ($bucket['tourism']['overnights_total'] ?? 0); + foreach (($bucket['tourism']['arrivals_by_country'] ?? []) as $country => $count) { + $tourism['arrivals_by_country'][$country] = ($tourism['arrivals_by_country'][$country] ?? 0) + (int) $count; + } + foreach (($bucket['tourism']['overnights_by_country'] ?? []) as $country => $count) { + $tourism['overnights_by_country'][$country] = ($tourism['overnights_by_country'][$country] ?? 0) + (int) $count; + } + $stays += (int) ($bucket['utilization']['stays'] ?? 0); + } + + ksort($tourism['arrivals_by_country']); + ksort($tourism['overnights_by_country']); + + $utilization = [ + 'stays' => $stays, + 'month_percent' => ($bedsTotal > 0 && $daysInMonth > 0) + ? ($stays * 100.0 / ($bedsTotal * $daysInMonth)) + : 0.0, + ]; + + return [ + 'tourism' => $tourism, + 'utilization' => $utilization, + ]; + } + /** * Build or update a snapshot and return it with runtime warnings. */ diff --git a/src/Service/OperationsFilterService.php b/src/Service/OperationsFilterService.php new file mode 100644 index 00000000..981e4021 --- /dev/null +++ b/src/Service/OperationsFilterService.php @@ -0,0 +1,164 @@ +setTime(0, 0, 0); + } + } + + return (new \DateTimeImmutable('today', $tz))->setTime(0, 0, 0); + } + + /** + * Resolve the requested subsidiary entity, if any. + */ + public function resolveSubsidiary(EntityManagerInterface $em, string $subsidiaryId): ?Subsidiary + { + if ('all' === $subsidiaryId || '' === $subsidiaryId) { + return null; + } + + $subsidiary = $em->getRepository(Subsidiary::class)->find($subsidiaryId); + + return $subsidiary instanceof Subsidiary ? $subsidiary : null; + } + + /** + * Normalize a date into its Monday week start. + */ + public function resolveWeekStart(\DateTimeImmutable $date): \DateTimeImmutable + { + return $date->modify('monday this week')->setTime(0, 0, 0); + } + + /** + * Resolve the start date, defaulting to Monday of the current week. + */ + public function resolveStartDate(?string $dateParam, ?\DateTimeZone $timezone = null): \DateTimeImmutable + { + $tz = $timezone ?? new \DateTimeZone('UTC'); + if ($dateParam) { + $parsed = \DateTimeImmutable::createFromFormat('Y-m-d', $dateParam, $tz); + if ($parsed instanceof \DateTimeImmutable) { + return $parsed->setTime(0, 0, 0); + } + } + + return (new \DateTimeImmutable('today', $tz))->modify('monday this week')->setTime(0, 0, 0); + } + + /** + * Resolve the end date, defaulting to Sunday of the start week. + */ + public function resolveEndDate(?string $dateParam, \DateTimeImmutable $start, ?\DateTimeZone $timezone = null): \DateTimeImmutable + { + $tz = $timezone ?? new \DateTimeZone('UTC'); + if ($dateParam) { + $parsed = \DateTimeImmutable::createFromFormat('Y-m-d', $dateParam, $tz); + if ($parsed instanceof \DateTimeImmutable) { + $end = $parsed->setTime(0, 0, 0); + + return $end < $start ? $start : $end; + } + } + + return $start->modify('+6 days')->setTime(0, 0, 0); + } + + /** + * Normalize selected categories (arrival, departure, inhouse). + * + * @return string[] + */ + public function normalizeCategories(array $selected): array + { + $allowed = ['arrival', 'departure', 'inhouse']; + $values = array_values(array_intersect($allowed, array_map('strval', $selected))); + + return [] === $values ? $allowed : $values; + } + + /** + * Resolve a string filter value with session fallback. + */ + public function resolveFilterValue( + Request $request, + SessionInterface $session, + string $sessionKey, + string $queryKey, + string $default = '' + ): string { + if ($request->query->has($queryKey)) { + $value = (string) $request->query->get($queryKey, $default); + $session->set($sessionKey, $value); + + return $value; + } + + return (string) $session->get($sessionKey, $default); + } + + /** + * Resolve an array filter value with session fallback. + * + * @return array + */ + public function resolveFilterArray( + Request $request, + SessionInterface $session, + string $sessionKey, + string $queryKey + ): array { + if ($request->query->has($queryKey)) { + $value = $request->query->all($queryKey); + $session->set($sessionKey, $value); + + return is_array($value) ? $value : []; + } + + $stored = $session->get($sessionKey, []); + + return is_array($stored) ? $stored : []; + } + + /** + * Resolve a boolean filter value with session fallback. + */ + public function resolveFilterBool( + Request $request, + SessionInterface $session, + string $sessionKey, + string $queryKey, + bool $default + ): bool { + if ($request->query->has($queryKey)) { + $value = $request->query->getBoolean($queryKey, $default); + $session->set($sessionKey, $value); + + return $value; + } + + return (bool) $session->get($sessionKey, $default); + } +} diff --git a/src/Service/OperationsReportService.php b/src/Service/OperationsReportService.php new file mode 100644 index 00000000..9fcbc3e3 --- /dev/null +++ b/src/Service/OperationsReportService.php @@ -0,0 +1,123 @@ +housekeepingViewService->buildRangeView($start, $end, $subsidiary, $occupancyTypes); + + return [ + 'filters' => [ + 'start' => $start, + 'end' => $end, + 'subsidiary' => $subsidiary, + 'occupancyTypes' => $occupancyTypes, + ], + 'rangeView' => $rangeView, + 'dayViews' => $rangeView['dayViews'], + 'reservations' => $rangeView['reservations'], + 'occupancyLabels' => $this->housekeepingViewService->getOccupancyLabels(), + 'statusLabels' => $this->housekeepingViewService->getStatusLabels(), + 'invoiceStatusLabels' => [ + \App\Enum\InvoiceStatus::OPEN->value => \App\Enum\InvoiceStatus::OPEN->labelKey(), + \App\Enum\InvoiceStatus::PAYED->value => \App\Enum\InvoiceStatus::PAYED->labelKey(), + \App\Enum\InvoiceStatus::PREPAYED->value => \App\Enum\InvoiceStatus::PREPAYED->labelKey(), + \App\Enum\InvoiceStatus::CANCELED->value => \App\Enum\InvoiceStatus::CANCELED->labelKey(), + ], + ]; + } + + /** + * Provide parameters for template rendering. + */ + public function getRenderParams(Template $template, mixed $param) + { + if (is_array($param)) { + if ($this->shouldIncludeStatistics($template, $param)) { + $filters = $param['filters'] ?? []; + $start = $filters['start'] ?? null; + $end = $filters['end'] ?? null; + $subsidiary = $filters['subsidiary'] ?? null; + if ($start instanceof \DateTimeImmutable && $end instanceof \DateTimeImmutable) { + $param['statistics'] = $this->buildStatisticsPayload($start, $end, $subsidiary); + } + } + + return $param; + } + + return []; + } + + /** + * Quick check if the template references statistics data. + */ + private function shouldIncludeStatistics(Template $template, array $param): bool + { + if (isset($param['statistics'])) { + return false; + } + + return str_contains($template->getText(), 'statistics.'); + } + + /** + * Build statistics payload using monthly snapshot metrics for the date range. + */ + private function buildStatisticsPayload( + \DateTimeImmutable $start, + \DateTimeImmutable $end, + ?Subsidiary $subsidiary + ): array { + $cursor = $start->modify('first day of this month'); + $endMonth = $end->modify('first day of this month'); + $months = []; + + while ($cursor <= $endMonth) { + $month = (int) $cursor->format('n'); + $year = (int) $cursor->format('Y'); + $payload = $this->monthlyStatsService->buildMetrics($month, $year, $subsidiary); + $months[] = [ + 'year' => $year, + 'month' => $month, + 'metrics' => $payload['metrics'], + 'warnings' => $payload['warnings'], + ]; + $cursor = $cursor->modify('first day of next month'); + } + + return [ + 'range' => [ + 'start' => $start, + 'end' => $end, + ], + 'months' => $months, + ]; + } +} diff --git a/src/Service/StatisticsService.php b/src/Service/StatisticsService.php index 49d24c06..09bcc129 100644 --- a/src/Service/StatisticsService.php +++ b/src/Service/StatisticsService.php @@ -26,12 +26,17 @@ public function __construct(EntityManagerInterface $em) $this->em = $em; } - public function loadUtilizationForYear($objectId, $year, $beds) + public function loadUtilizationForYear($objectId, $year, $beds, array $reservationStatus = []) { $data = []; // each month of the year for ($i = 1; $i <= 12; ++$i) { - $reservations = $this->em->getRepository(Reservation::class)->loadReservationsForMonth($i, $year, $objectId); + $reservations = $this->em->getRepository(Reservation::class)->loadReservationsForMonth( + $i, + $year, + $objectId, + $reservationStatus + ); $startDate = new \DateTime($year.'-'.$i.'-01'); $endDate = new \DateTime($year.'-'.$i.'-'.$startDate->format('t')); diff --git a/src/Service/TemplatesService.php b/src/Service/TemplatesService.php index 6630580a..42950887 100644 --- a/src/Service/TemplatesService.php +++ b/src/Service/TemplatesService.php @@ -21,6 +21,7 @@ use App\Entity\Template; use App\Entity\TemplateType; use App\Interfaces\ITemplateRenderer; +use App\Service\InvoiceService; use Doctrine\ORM\EntityManagerInterface; use Doctrine\Persistence\ManagerRegistry; use Symfony\Component\HttpFoundation\Request; @@ -30,6 +31,7 @@ class TemplatesService { + public const EXAMPLES_BASE_URL = 'https://raw.githubusercontent.com/developeregrem/fewohbee-examples/master/templates/'; private $webHost; public function __construct( @@ -124,6 +126,121 @@ public function renderTemplate(int $templateId, mixed $param, ITemplateRenderer return $templateStr->render($params); } + /** + * Default definitions for operations report templates. + * + * @return array> + */ + public function getOperationsTemplateDefinitions(): array + { + return [ + [ + 'file' => 'report_housekeeping_day.html.twig', + 'name' => 'templates.operations.housekeeping_day', + 'isDefault' => true, + 'params' => ['orientation' => 'L'], + ], + [ + 'file' => 'report_housekeeping_week.html.twig', + 'name' => 'templates.operations.housekeeping_week', + 'params' => ['orientation' => 'L'], + ], + ['file' => 'report_housekeeping_summary.html.twig'], + [ + 'file' => 'report_frontdesk_checklist.html.twig', + 'name' => 'templates.operations.frontdesk_checklist', + ], + [ + 'file' => 'report_meals_checklist.html.twig', + 'name' => 'templates.operations.meals_checklist', + ], + [ + 'file' => 'report_management_monthly_summary.html.twig', + 'name' => 'templates.operations.management_monthly_summary', + ], + ]; + } + + /** + * Default definitions for registration templates. + * + * @return array> + */ + public function getRegistrationTemplateDefinitions(): array + { + return [ + [ + 'file' => 'report_registration_form.html.twig', + 'isDefault' => true, + ], + ]; + } + + /** + * Import templates from a remote base URL. + * + * @param array> $entries + */ + public function importTemplates(TemplateType $type, array $entries, string $baseUrl): int + { + $client = \Symfony\Component\HttpClient\HttpClient::create(); + $imported = 0; + + foreach ($entries as $entry) { + $templateFile = $entry['file']; + $response = $client->request('GET', $baseUrl.$templateFile); + if (200 !== $response->getStatusCode()) { + continue; + } + + $content = $response->getContent(); + $template = new Template(); + $template->setParams($this->buildTemplateParams($entry['params'] ?? [])); + $template->setIsDefault(isset($entry['isDefault']) ? (bool) $entry['isDefault'] : false); + $template->setName($this->resolveTemplateName( + $type->getName(), + $entry['name'] ?? null + )); + $template->setTemplateType($type); + $template->setText($content); + + $this->em->persist($template); + ++$imported; + } + + return $imported; + } + + /** + * Build template params by merging custom settings with defaults. + */ + public function buildTemplateParams(array $custom): string + { + $params = array_merge([ + 'orientation' => 'P', + 'marginLeft' => 25, + 'marginRight' => 20, + 'marginTop' => 20, + 'marginBottom' => 20, + 'marginHeader' => 9, + 'marginFooter' => 9, + ], $custom); + + return json_encode($params, JSON_THROW_ON_ERROR); + } + + /** + * Resolve display name for a template. + */ + public function resolveTemplateName(string $typeName, ?string $translationKey): string + { + if (null !== $translationKey) { + return $this->translator->trans($translationKey); + } + + return $this->translator->trans($typeName); + } + public function getReferencedReservationsInSession() { $reservations = []; @@ -152,7 +269,7 @@ public function getCorrespondencesForAttachment() return $correspondences; } - public function makeCorespondenceOfInvoice($id, InvoiceService $is): ?int + public function makeCorespondenceOfInvoice($id, InvoiceService $is, ?string $binaryPayload = null, bool $isEInvoice = false): ?int { $invoice = $this->em->find(Invoice::class, $id); if (!$invoice instanceof Invoice) { @@ -170,11 +287,15 @@ public function makeCorespondenceOfInvoice($id, InvoiceService $is): ?int $fileId = 0; foreach ($reservations as $reservation) { $file = new FileCorrespondence(); - $file->setFileName($this->translator->trans('invoice.number.short').'-'.$invoice->getNumber()) - ->setName($this->translator->trans('invoice.number.short').'-'.$invoice->getNumber()) + $fileName = $is->buildInvoiceExportFilename($invoice, $isEInvoice); + $file->setFileName($fileName) + ->setName($fileName) ->setText($templateOutput) ->setTemplate($defaultTemlate) ->setReservation($reservation); + if (null !== $binaryPayload) { + $file->setBinaryPayload($binaryPayload); + } $this->em->persist($file); $this->em->flush(); $fileId = $file->getId(); @@ -210,7 +331,8 @@ public function getMailAttachment($attachmentId): ?MailAttachment /* @var $attachment \App\Entity\Correspondence */ $attachment = $this->em->getRepository(Correspondence::class)->find($attachmentId); if ($attachment instanceof FileCorrespondence) { - $data = $this->getPDFOutput($attachment->getText(), $attachment->getName(), $attachment->getTemplate(), true); + $binaryPayload = $attachment->getBinaryPayload(); + $data = $binaryPayload ?: $this->getPDFOutput($attachment->getText(), $attachment->getName(), $attachment->getTemplate(), true); return new MailAttachment($data, $attachment->getName().'.pdf', 'application/pdf'); } @@ -218,7 +340,7 @@ public function getMailAttachment($attachmentId): ?MailAttachment return null; } - public function getPDFOutput($input, $name, $template, $noResponseOutput = false) + public function getPDFOutput($input, $name, $template, $noResponseOutput = false, ?string $destOverride = null) { /* * I: send the file inline to the browser. The plug-in is used if available. The name given by filename is used when one selects the "Save as" option on the link generating the PDF. @@ -226,7 +348,7 @@ public function getPDFOutput($input, $name, $template, $noResponseOutput = false * F: save to a local file with the name given by filename (may include a path). * S: return the document as a string. filename is ignored. */ - $dest = ($noResponseOutput ? 'S' : 'D'); + $dest = $destOverride ?: ($noResponseOutput ? 'S' : 'D'); $mpdf = $this->mpdfs->getMpdf(); $params = json_decode($template->getParams()); @@ -319,8 +441,9 @@ private function replaceTwigSyntax(string $string): string $t5 = str_replace('[#', '{#', $t4); $t6 = str_replace('#]', '#}', $t5); - $t7 = preg_replace("/
(.*)<\/div>/s", '$1', $t6); + $t7 = preg_replace("/
(.*)<\/div>/s", '$1', $t6); + $t8 = preg_replace("/
(.*)<\/div>/s", '$1', $t7); - return $t7; + return $t8; } } diff --git a/src/Twig/AppTwigExtensions.php b/src/Twig/AppTwigExtensions.php index 30e31005..0c3f828e 100644 --- a/src/Twig/AppTwigExtensions.php +++ b/src/Twig/AppTwigExtensions.php @@ -89,8 +89,10 @@ public function getReservationsForPeriodFilter($today, $intervall, $apartment) { $start = new \DateTime(date('Y-m-d', $today)); $end = new \DateTime(date('Y-m-d', $today + ($intervall * 3600 * 24))); + $showCanceledOnly = (bool) $this->requestStack->getSession()->get('reservation-overview-show-canceled', false); + $statusMode = $showCanceledOnly ? 'non_blocking' : 'blocking'; - return $this->em->getRepository(Reservation::class)->loadReservationsForApartment($start, $end, $apartment); + return $this->em->getRepository(Reservation::class)->loadReservationsForApartment($start, $end, $apartment, $statusMode); } public function isSingleReservationForDayFilter(int $today, int $period, int $reservationIdx, array $reservations, string $type = 'start'): bool diff --git a/symfony.lock b/symfony.lock index 2312cc5c..5da35e0e 100644 --- a/symfony.lock +++ b/symfony.lock @@ -6,13 +6,11 @@ "version": "1.13", "recipe": { "repo": "github.com/symfony/recipes", - "branch": "master", - "version": "1.0", - "ref": "a2759dd6123694c8d901d0ec80006e044c2e6457" + "branch": "main", + "version": "1.10", + "ref": "64d8583af5ea57b7afa4aba4b159907f3a148b05" }, - "files": [ - "./config/routes/annotations.yaml" - ] + "files": [] }, "doctrine/collections": { "version": "1.6.8" @@ -24,47 +22,51 @@ "version": "3.1.3" }, "doctrine/deprecations": { - "version": "v0.5.3" + "version": "1.1", + "recipe": { + "repo": "github.com/symfony/recipes", + "branch": "main", + "version": "1.0", + "ref": "87424683adc81d7dc305eefec1fced883084aab9" + } }, "doctrine/doctrine-bundle": { - "version": "2.4", + "version": "2.18", "recipe": { "repo": "github.com/symfony/recipes", - "branch": "master", - "version": "2.4", - "ref": "bac5c852ff628886de2753215fe5eb1f9ce980fb" + "branch": "main", + "version": "2.13", + "ref": "620b57f496f2e599a6015a9fa222c2ee0a32adcb" }, "files": [ - "./config/packages/doctrine.yaml", - "./config/packages/test/doctrine.yaml", - "./config/packages/prod/doctrine.yaml", - "./src/Repository/.gitignore", - "./src/Entity/.gitignore" + "config/packages/doctrine.yaml", + "src/Entity/.gitignore", + "src/Repository/.gitignore" ] }, "doctrine/doctrine-fixtures-bundle": { - "version": "3.4", + "version": "3.7", "recipe": { "repo": "github.com/symfony/recipes", - "branch": "master", + "branch": "main", "version": "3.0", - "ref": "e5b542d4ef47d8a003c91beb35650c76907f7e53" + "ref": "1f5514cfa15b947298df4d771e694e578d4c204d" }, "files": [ - "./src/DataFixtures/AppFixtures.php" + "src/DataFixtures/AppFixtures.php" ] }, "doctrine/doctrine-migrations-bundle": { - "version": "3.1", + "version": "3.7", "recipe": { "repo": "github.com/symfony/recipes", - "branch": "master", + "branch": "main", "version": "3.1", - "ref": "ee609429c9ee23e22d6fa5728211768f51ed2818" + "ref": "1d01ec03c6ecbd67c3375c5478c9a423ae5d6a33" }, "files": [ - "./config/packages/doctrine_migrations.yaml", - "./migrations/.gitignore" + "config/packages/doctrine_migrations.yaml", + "migrations/.gitignore" ] }, "doctrine/event-manager": { @@ -125,15 +127,15 @@ "version": "v1.0.2" }, "phpstan/phpstan": { - "version": "1.10", + "version": "1.12", "recipe": { "repo": "github.com/symfony/recipes-contrib", "branch": "main", "version": "1.0", - "ref": "d74d4d719d5f53856c9c13544aa22d44144b1819" + "ref": "5e490cc197fb6bb1ae22e5abbc531ddc633b6767" }, "files": [ - "phpstan.neon" + "phpstan.dist.neon" ] }, "phpunit/php-code-coverage": { @@ -152,17 +154,18 @@ "version": "5.0.3" }, "phpunit/phpunit": { - "version": "9.5", + "version": "12.5", "recipe": { "repo": "github.com/symfony/recipes", - "branch": "master", - "version": "9.3", - "ref": "a6249a6c4392e9169b87abf93225f7f9f59025e6" + "branch": "main", + "version": "11.1", + "ref": "1117deb12541f35793eec9fff7494d7aa12283fc" }, "files": [ - "./.env.test", - "./phpunit.xml.dist", - "./tests/bootstrap.php" + ".env.test", + "bin/phpunit", + "phpunit.xml.dist", + "tests/bootstrap.php" ] }, "psr/cache": { @@ -256,30 +259,30 @@ "version": "v5.3.4" }, "symfony/console": { - "version": "5.3", + "version": "7.4", "recipe": { "repo": "github.com/symfony/recipes", - "branch": "master", + "branch": "main", "version": "5.3", - "ref": "da0c8be8157600ad34f10ff0c9cc91232522e047" + "ref": "1781ff40d8a17d87cf53f8d4cf0c8346ed2bb461" }, "files": [ - "./bin/console" + "bin/console" ] }, "symfony/css-selector": { "version": "v5.3.4" }, "symfony/debug-bundle": { - "version": "5.3", + "version": "7.4", "recipe": { "repo": "github.com/symfony/recipes", - "branch": "master", - "version": "4.1", - "ref": "0ce7a032d344fb7b661cd25d31914cd703ad445b" + "branch": "main", + "version": "5.3", + "ref": "5aa8aa48234c8eb6dbdd7b3cd5d791485d2cec4b" }, "files": [ - "./config/packages/dev/debug.yaml" + "config/packages/debug.yaml" ] }, "symfony/dependency-injection": { @@ -316,37 +319,48 @@ "version": "v5.3.7" }, "symfony/flex": { - "version": "1.17", + "version": "2.10", "recipe": { "repo": "github.com/symfony/recipes", - "branch": "master", - "version": "1.0", - "ref": "c0eeb50665f0f77226616b6038a9b06c03752d8e" + "branch": "main", + "version": "2.4", + "ref": "52e9754527a15e2b79d9a610f98185a1fe46622a" }, "files": [ - "./.env" + ".env", + ".env.dev" ] }, "symfony/form": { - "version": "v5.3.8" + "version": "7.4", + "recipe": { + "repo": "github.com/symfony/recipes", + "branch": "main", + "version": "7.2", + "ref": "7d86a6723f4a623f59e2bf966b6aad2fc461d36b" + }, + "files": [ + "config/packages/csrf.yaml" + ] }, "symfony/framework-bundle": { - "version": "5.3", + "version": "7.4", "recipe": { "repo": "github.com/symfony/recipes", - "branch": "master", - "version": "5.3", - "ref": "414ba00ad43fa71be42c7906a551f1831716b03c" + "branch": "main", + "version": "7.4", + "ref": "09f6e081c763a206802674ce0cb34a022f0ffc6d" }, "files": [ - "./config/services.yaml", - "./config/routes/framework.yaml", - "./config/preload.php", - "./config/packages/cache.yaml", - "./config/packages/framework.yaml", - "./public/index.php", - "./src/Kernel.php", - "./src/Controller/.gitignore" + ".editorconfig", + "config/packages/cache.yaml", + "config/packages/framework.yaml", + "config/preload.php", + "config/routes/framework.yaml", + "config/services.yaml", + "public/index.php", + "src/Controller/.gitignore", + "src/Kernel.php" ] }, "symfony/http-client": { @@ -365,15 +379,15 @@ "version": "v5.3.8" }, "symfony/mailer": { - "version": "5.3", + "version": "7.4", "recipe": { "repo": "github.com/symfony/recipes", - "branch": "master", + "branch": "main", "version": "4.3", - "ref": "bbfc7e27257d3a3f12a6fb0a42540a42d9623a37" + "ref": "09051cfde49476e3c12cd3a0e44289ace1c75a4f" }, "files": [ - "./config/packages/mailer.yaml" + "config/packages/mailer.yaml" ] }, "symfony/maker-bundle": { @@ -392,18 +406,15 @@ "version": "v5.3.7" }, "symfony/monolog-bundle": { - "version": "3.7", + "version": "3.11", "recipe": { "repo": "github.com/symfony/recipes", - "branch": "master", + "branch": "main", "version": "3.7", - "ref": "a7bace7dbc5a7ed5608dbe2165e0774c87175fe6" + "ref": "1b9efb10c54cb51c713a9391c9300ff8bceda459" }, "files": [ - "./config/packages/test/monolog.yaml", - "./config/packages/prod/monolog.yaml", - "./config/packages/prod/deprecations.yaml", - "./config/packages/dev/monolog.yaml" + "config/packages/monolog.yaml" ] }, "symfony/options-resolver": { @@ -413,19 +424,14 @@ "version": "v5.3.8" }, "symfony/phpunit-bridge": { - "version": "5.3", + "version": "7.4", "recipe": { "repo": "github.com/symfony/recipes", - "branch": "master", - "version": "5.3", - "ref": "97cb3dc7b0f39c7cfc4b7553504c9d7b7795de96" + "branch": "main", + "version": "7.3", + "ref": "dc13fec96bd527bd399c3c01f0aab915c67fd544" }, - "files": [ - "./.env.test", - "./bin/phpunit", - "./phpunit.xml.dist", - "./tests/bootstrap.php" - ] + "files": [] }, "symfony/polyfill-intl-grapheme": { "version": "v1.23.1" @@ -449,31 +455,41 @@ "version": "v5.3.8" }, "symfony/property-info": { - "version": "v5.3.8" + "version": "7.4", + "recipe": { + "repo": "github.com/symfony/recipes", + "branch": "main", + "version": "7.3", + "ref": "dae70df71978ae9226ae915ffd5fad817f5ca1f7" + }, + "files": [ + "config/packages/property_info.yaml" + ] }, "symfony/routing": { - "version": "5.3", + "version": "7.4", "recipe": { "repo": "github.com/symfony/recipes", - "branch": "master", - "version": "5.3", - "ref": "44633353926a0382d7dfb0530922c5c0b30fae11" + "branch": "main", + "version": "7.4", + "ref": "bc94c4fd86f393f3ab3947c18b830ea343e51ded" }, "files": [ - "./config/routes.yaml", - "./config/packages/routing.yaml" + "config/packages/routing.yaml", + "config/routes.yaml" ] }, "symfony/security-bundle": { - "version": "5.3", + "version": "7.4", "recipe": { "repo": "github.com/symfony/recipes", - "branch": "master", - "version": "5.3", - "ref": "9c4fcf79873f7400c885b90935f7163233615d6f" + "branch": "main", + "version": "7.4", + "ref": "c42fee7802181cdd50f61b8622715829f5d2335c" }, "files": [ - "./config/packages/security.yaml" + "config/packages/security.yaml", + "config/routes/security.yaml" ] }, "symfony/security-core": { @@ -492,18 +508,18 @@ "version": "v2.4.0" }, "symfony/stimulus-bundle": { - "version": "2.24", + "version": "2.32", "recipe": { "repo": "github.com/symfony/recipes", "branch": "main", - "version": "2.20", - "ref": "3acc494b566816514a6873a89023a35440b6386d" + "version": "2.24", + "ref": "3357f2fa6627b93658d8e13baa416b2a94a50c5f" }, "files": [ - "assets/bootstrap.js", "assets/controllers.json", "assets/controllers/csrf_protection_controller.js", - "assets/controllers/hello_controller.js" + "assets/controllers/hello_controller.js", + "assets/stimulus_bootstrap.js" ] }, "symfony/stopwatch": { @@ -513,16 +529,16 @@ "version": "v5.3.7" }, "symfony/translation": { - "version": "5.3", + "version": "7.4", "recipe": { "repo": "github.com/symfony/recipes", - "branch": "master", - "version": "5.3", - "ref": "da64f5a2b6d96f5dc24914517c0350a5f91dee43" + "branch": "main", + "version": "6.3", + "ref": "620a1b84865ceb2ba304c8f8bf2a185fbf32a843" }, "files": [ - "./config/packages/translation.yaml", - "./translations/.gitignore" + "config/packages/translation.yaml", + "translations/.gitignore" ] }, "symfony/translation-contracts": { @@ -532,41 +548,49 @@ "version": "v5.3.7" }, "symfony/twig-bundle": { - "version": "5.3", + "version": "7.4", "recipe": { "repo": "github.com/symfony/recipes", - "branch": "master", - "version": "5.3", - "ref": "3dd530739a4284e3272274c128dbb7a8140a66f1" + "branch": "main", + "version": "6.4", + "ref": "cab5fd2a13a45c266d45a7d9337e28dee6272877" }, "files": [ - "./config/packages/twig.yaml", - "./templates/base.html.twig" + "config/packages/twig.yaml", + "templates/base.html.twig" ] }, "symfony/uid": { - "version": "v6.0.3" + "version": "7.4", + "recipe": { + "repo": "github.com/symfony/recipes", + "branch": "main", + "version": "7.0", + "ref": "0df5844274d871b37fc3816c57a768ffc60a43a5" + } }, "symfony/ux-turbo": { - "version": "2.24", + "version": "2.32", "recipe": { "repo": "github.com/symfony/recipes", "branch": "main", "version": "2.20", - "ref": "c85ff94da66841d7ff087c19cbcd97a2df744ef9" - } + "ref": "287f7c6eb6e9b65e422d34c00795b360a787380b" + }, + "files": [ + "config/packages/ux_turbo.yaml" + ] }, "symfony/validator": { - "version": "5.3", + "version": "7.4", "recipe": { "repo": "github.com/symfony/recipes", - "branch": "master", - "version": "4.3", - "ref": "3eb8df139ec05414489d55b97603c5f6ca0c44cb" + "branch": "main", + "version": "7.0", + "ref": "8c1c4e28d26a124b0bb273f537ca8ce443472bfd" }, "files": [ - "./config/packages/validator.yaml", - "./config/packages/test/validator.yaml" + "config/packages/validator.yaml" ] }, "symfony/var-dumper": { @@ -579,17 +603,16 @@ "version": "v5.3.4" }, "symfony/web-profiler-bundle": { - "version": "5.3", + "version": "7.4", "recipe": { "repo": "github.com/symfony/recipes", - "branch": "master", - "version": "3.3", - "ref": "6bdfa1a95f6b2e677ab985cd1af2eae35d62e0f6" + "branch": "main", + "version": "7.3", + "ref": "a363460c1b0b4a4d0242f2ce1a843ca0f6ac9026" }, "files": [ - "./config/routes/dev/web_profiler.yaml", - "./config/packages/test/web_profiler.yaml", - "./config/packages/dev/web_profiler.yaml" + "config/packages/web_profiler.yaml", + "config/routes/web_profiler.yaml" ] }, "symfony/yaml": { diff --git a/templates/Invoices/invoice_form_show.html.twig b/templates/Invoices/invoice_form_show.html.twig index edef1408..37e5575a 100644 --- a/templates/Invoices/invoice_form_show.html.twig +++ b/templates/Invoices/invoice_form_show.html.twig @@ -232,7 +232,7 @@ - {{ 'button.export.pdf'|trans }} + {{ 'button.export.pdf'|trans }} {% if is_granted('ROLE_ADMIN') %} diff --git a/templates/Invoices/invoice_table.html.twig b/templates/Invoices/invoice_table.html.twig index cf3e588b..85d50bbc 100644 --- a/templates/Invoices/invoice_table.html.twig +++ b/templates/Invoices/invoice_table.html.twig @@ -40,11 +40,11 @@ data-bs-toggle="modal" data-bs-target="#modalCenter"> +
+
+

{{ 'operations.frontdesk.title'|trans }}

+
+ {{ selectedDate|date('d.m.Y') }} +
+
+
+ +
+
+ + +
+
+ + +
+
+
+ +
+ + + +
+ +
+
+
+
+ +
+ + + + + + + + + + + + + + + + + {% for item in frontdeskItems %} + {% set reservation = item.reservation %} + {% set guestNames = [] %} + {% for customer in reservation.customers %} + {% set name = (customer.lastname ~ ' ' ~ customer.firstname)|trim %} + {% if name != '' %} + {% set guestNames = guestNames|merge([name]) %} + {% endif %} + {% endfor %} + {% if guestNames is empty and reservation.booker %} + {% set name = (reservation.booker.lastname ~ ' ' ~ reservation.booker.firstname)|trim %} + {% if name != '' %} + {% set guestNames = [name] %} + {% endif %} + {% endif %} + + + + + + + + + + + + + {% else %} + + + + {% endfor %} + +
{{ 'operations.frontdesk.room'|trans }}{{ 'operations.frontdesk.guest'|trans }}{{ 'operations.frontdesk.period'|trans }}{{ 'operations.frontdesk.category'|trans }}{{ 'reservation.status'|trans }}{{ 'operations.frontdesk.header.registration'|trans }}{{ 'operations.frontdesk.header.invoice'|trans }}{{ 'operations.frontdesk.header.email'|trans }}{{ 'operations.frontdesk.header.note'|trans }}
+ + + {{ 'reservation.details'|trans }} + + {{ item.apartment.number }} {{ item.apartment.description }}{{ guestNames|join(', ') ?: '-' }}{{ reservation.startDate|date('d.m.Y') }} - {{ reservation.endDate|date('d.m.Y') }} + {% if 'arrival' in item.categories %} + {{ 'operations.frontdesk.arrivals'|trans }} + {% endif %} + {% if 'departure' in item.categories %} + {{ 'operations.frontdesk.departures'|trans }} + {% endif %} + {% if 'inhouse' in item.categories %} + {{ 'operations.frontdesk.inhouse'|trans }} + {% endif %} + + + + + + {{ 'operations.frontdesk.registration.print'|trans }} + + + {% if reservation.invoices|length > 0 %} + {% set invoice = reservation.invoices|first %} + {% set invoiceBadgeClass = 'text-bg-light' %} + {% if item.invoiceStatusLabel == 'invoice.status.notpayed' %} + {% set invoiceBadgeClass = 'text-bg-warning' %} + {% elseif item.invoiceStatusLabel == 'invoice.status.payed' %} + {% set invoiceBadgeClass = 'text-bg-success' %} + {% endif %} + + {{ item.invoiceStatusLabel ? (item.invoiceStatusLabel|trans) : ('reservation.invoice'|trans) }} + + {% else %} + + + {{ 'reservation.button.create.invoice'|trans }} + + {% endif %} + + + + {{ 'operations.frontdesk.confirmation.send'|trans }} + + + + + {{ 'operations.frontdesk.note.edit'|trans }} + +
-
+
+ +
+ {% for item in frontdeskItems %} + {% set reservation = item.reservation %} + {% set guestNames = [] %} + {% for customer in reservation.customers %} + {% set name = (customer.lastname ~ ' ' ~ customer.firstname)|trim %} + {% if name != '' %} + {% set guestNames = guestNames|merge([name]) %} + {% endif %} + {% endfor %} + {% if guestNames is empty and reservation.booker %} + {% set name = (reservation.booker.lastname ~ ' ' ~ reservation.booker.firstname)|trim %} + {% if name != '' %} + {% set guestNames = [name] %} + {% endif %} + {% endif %} +
+
+ +
+
+
{{ item.apartment.number }}
+
{{ item.apartment.description }}
+
+
+ {% if 'arrival' in item.categories %} + {{ 'operations.frontdesk.arrivals'|trans }} + {% endif %} + {% if 'departure' in item.categories %} + {{ 'operations.frontdesk.departures'|trans }} + {% endif %} + {% if 'inhouse' in item.categories %} + {{ 'operations.frontdesk.inhouse'|trans }} + {% endif %} +
+
+
+ {{ 'operations.frontdesk.guest'|trans }}: {{ guestNames|join(', ') ?: '-' }} +
+
+ {{ 'operations.frontdesk.period'|trans }}: {{ reservation.startDate|date('d.m.Y') }} - {{ reservation.endDate|date('d.m.Y') }} +
+
+ + +
+
+ + + {{ 'operations.frontdesk.registration.print'|trans }} + + {% if reservation.invoices|length > 0 %} + {% set invoice = reservation.invoices|first %} + {% set invoiceBadgeClass = 'text-bg-light' %} + {% if item.invoiceStatusLabel == 'invoice.status.notpayed' %} + {% set invoiceBadgeClass = 'text-bg-warning' %} + {% elseif item.invoiceStatusLabel == 'invoice.status.payed' %} + {% set invoiceBadgeClass = 'text-bg-success' %} + {% endif %} + + {{ item.invoiceStatusLabel ? (item.invoiceStatusLabel|trans) : ('reservation.invoice'|trans) }} + + {% else %} + + + {{ 'reservation.button.create.invoice'|trans }} + + {% endif %} + + + {{ 'operations.frontdesk.confirmation.send'|trans }} + + + + {{ 'operations.frontdesk.note.edit'|trans }} + +
+
+
+ {% else %} +
-
+ {% endfor %} +
+
+{% endblock %} + +{% block javascripts %} + {{ parent() }} + +{% endblock %} diff --git a/templates/Operations/Housekeeping/_day_view.html.twig b/templates/Operations/Housekeeping/_day_view.html.twig new file mode 100644 index 00000000..2411b77e --- /dev/null +++ b/templates/Operations/Housekeeping/_day_view.html.twig @@ -0,0 +1,127 @@ +{% set roomLabel = 'housekeeping.room'|trans({}, 'Housekeeping') %} +{% set occupancyLabel = 'housekeeping.occupancy'|trans({}, 'Housekeeping') %} +{% set guestsLabel = 'housekeeping.guests'|trans({}, 'Housekeeping') %} +{% set reservationLabel = 'housekeeping.reservation'|trans({}, 'Housekeeping') %} +{% set statusLabel = 'housekeeping.status'|trans({}, 'Housekeeping') %} +{% set assignedLabel = 'housekeeping.assigned_to'|trans({}, 'Housekeeping') %} +{% set noteLabel = 'housekeeping.note'|trans({}, 'Housekeeping') %} + +
+ + + + + + + + + + + + + + + {% for row in dayView.rows %} + {% set formView = rowForms[row.apartment.id] %} + {{ form_start(formView, { + action: path('operations.housekeeping.update', { id: row.apartment.id }), + attr: { class: 'hk-row-form', 'data-action': 'submit->housekeeping#saveRow' } + }) }} + {{ form_widget(formView.date) }} + {{ form_widget(formView._token) }} + + + + + + + + + + + {{ form_end(formView, { render_rest: false }) }} + {% else %} + + + + {% endfor %} + +
{{ roomLabel }}{{ occupancyLabel }}{{ guestsLabel }}{{ reservationLabel }}{{ statusLabel }}{{ assignedLabel }}{{ noteLabel }}
+
{{ row.apartment.number }}
+
{{ row.apartment.description }}
+
+ {% set occClass = occupancyClasses[row.occupancyType]|default('bg-secondary') %} + + {{ occupancyLabels[row.occupancyType]|trans({}, 'Housekeeping') }} + + {{ row.guestCount ?? '-' }}{{ row.reservationSummary ?? '-' }} + {{ form_widget(formView.hkStatus, { attr: { class: 'form-select form-select-sm' } }) }} + + {{ form_widget(formView.assignedTo, { attr: { class: 'form-select form-select-sm' } }) }} + + {{ form_widget(formView.note, { attr: { class: 'form-control form-control-sm' } }) }} + + +
+ {{ 'housekeeping.no_rooms'|trans({}, 'Housekeeping') }} +
+
+ +
+ {% for row in dayView.rows %} +
+
+
+
+
{{ row.apartment.number }}
+
{{ row.apartment.description }}
+
+ {% set occClass = occupancyClasses[row.occupancyType]|default('bg-secondary') %} + + {{ occupancyLabels[row.occupancyType]|trans({}, 'Housekeeping') }} + +
+ +
+ {{ reservationLabel }}: {{ row.reservationSummary ?? '-' }} +
+
+ {{ guestsLabel }}: {{ row.guestCount ?? '-' }} +
+ + {% set formView = rowFormsMobile[row.apartment.id] %} + {{ form_start(formView, { + action: path('operations.housekeeping.update', { id: row.apartment.id }), + attr: { 'data-action': 'submit->housekeeping#saveRow' } + }) }} + {{ form_widget(formView.date) }} + {{ form_widget(formView._token) }} + +
+ + {{ form_widget(formView.hkStatus, { attr: { class: 'form-select form-select-sm' } }) }} +
+
+ + {{ form_widget(formView.assignedTo, { attr: { class: 'form-select form-select-sm' } }) }} +
+
+ + {{ form_widget(formView.note, { attr: { class: 'form-control form-control-sm' } }) }} +
+ + {{ form_end(formView, { render_rest: false }) }} +
+
+ {% else %} +
+ {{ 'housekeeping.no_rooms'|trans({}, 'Housekeeping') }} +
+ {% endfor %} +
diff --git a/templates/Operations/Housekeeping/_week_view.html.twig b/templates/Operations/Housekeeping/_week_view.html.twig new file mode 100644 index 00000000..21d51e79 --- /dev/null +++ b/templates/Operations/Housekeeping/_week_view.html.twig @@ -0,0 +1,47 @@ +
+ + + + + {% for day in weekView.days %} + + {% endfor %} + + + + {% for row in weekView.rows %} + + + {% for day in weekView.days %} + {% set dateKey = day|date('Y-m-d') %} + {% set cell = row.days[dateKey] %} + + {% endfor %} + + {% else %} + + + + {% endfor %} + +
{{ 'housekeeping.room'|trans({}, 'Housekeeping') }} +
{{ getLocalizedDate(day, 'EEE', app.request.locale) }}
+
{{ day|date('d.m.') }}
+
+
{{ row.apartment.number }}
+
{{ row.apartment.description }}
+
+
+ {{ occupancyLabels[cell.occupancyType]|trans({}, 'Housekeeping') }} +
+
+ {{ statusLabels[cell.status ? cell.status.hkStatus.value : 'OPEN']|trans({}, 'Housekeeping') }} +
+ {% if cell.guestCount is not null %} + {% set guestKey = cell.guestCount == 1 ? 'housekeeping.guest.short' : 'housekeeping.guests.short' %} +
{{ cell.guestCount }} {{ guestKey|trans({}, 'Housekeeping') }}
+ {% endif %} +
+ {{ 'housekeeping.no_rooms'|trans({}, 'Housekeeping') }} +
+
diff --git a/templates/Operations/Housekeeping/index.html.twig b/templates/Operations/Housekeeping/index.html.twig new file mode 100644 index 00000000..29d20d3b --- /dev/null +++ b/templates/Operations/Housekeeping/index.html.twig @@ -0,0 +1,106 @@ +{% extends 'base.html.twig' %} + +{% block title %}{{ 'housekeeping.title'|trans({}, 'Housekeeping') }}{% endblock %} + +{% block content %} +
+
+
+

{{ 'housekeeping.title'|trans({}, 'Housekeeping') }}

+
+ {{ selectedDate|date('d.m.Y') }} +
+
+ +
+ +
+
+ + +
+
+ + +
+ +
+ +
+ {% for occType in occupancyTypes %} + + {% endfor %} +
+
+ + +
+ + {% if view == 'week' %} + {% include 'Operations/Housekeeping/_week_view.html.twig' with { + weekView: weekView, + occupancyLabels: occupancyLabels, + statusLabels: statusLabels + } %} + {% else %} + {% include 'Operations/Housekeeping/_day_view.html.twig' with { + dayView: dayView, + rowForms: rowForms, + rowFormsMobile: rowFormsMobile, + occupancyLabels: occupancyLabels, + occupancyClasses: occupancyClasses, + statusLabels: statusLabels, + selectedDate: selectedDate, + selectedSubsidiaryId: selectedSubsidiaryId, + view: view + } %} + {% endif %} +
+{% endblock %} diff --git a/templates/Operations/Reports/_preview.html.twig b/templates/Operations/Reports/_preview.html.twig new file mode 100644 index 00000000..67b7ad7e --- /dev/null +++ b/templates/Operations/Reports/_preview.html.twig @@ -0,0 +1,9 @@ +{% if previewUrl %} +
+ +
+{% else %} +
+ {{ message|trans }} +
+{% endif %} diff --git a/templates/Operations/Reports/index.html.twig b/templates/Operations/Reports/index.html.twig new file mode 100644 index 00000000..f7fcd301 --- /dev/null +++ b/templates/Operations/Reports/index.html.twig @@ -0,0 +1,91 @@ +{% extends 'base.html.twig' %} + +{% block title %}{{ 'operations.reports.title'|trans }}{% endblock %} + +{% block content %} +
+
+
+

{{ 'operations.reports.title'|trans }}

+
+
+ +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+ +
+ +
+ {% for occType in occupancyTypes %} + + {% endfor %} +
+
+
+ +
+
+
{{ 'operations.reports.preview'|trans }}
+
+
+
+{% endblock %} diff --git a/templates/ReservationStatus/index.html.twig b/templates/ReservationStatus/index.html.twig index d6a6921e..a430be8b 100644 --- a/templates/ReservationStatus/index.html.twig +++ b/templates/ReservationStatus/index.html.twig @@ -42,20 +42,26 @@ data-action="click->settings#openModalAction" data-url="{{ path('reservation_status_edit', {'id': status.id}) }}" data-title="{{ 'button.details'|trans }}"> - - - {% set id = status.id %} - {% set targetUrl = path('reservation_status_delete', {'id': status.id}) %} - {% use "common/delete_popover.html.twig" %} - - + {% if not status.system %} + + + {% set id = status.id %} + {% set targetUrl = path('reservation_status_delete', {'id': status.id}) %} + {% use "common/delete_popover.html.twig" %} + + + {% else %} + + + + {% endif %} {% endfor %} diff --git a/templates/Reservations/reservation_form_preview_template.html.twig b/templates/Reservations/reservation_form_preview_template.html.twig index 29d34a1a..3162ed18 100644 --- a/templates/Reservations/reservation_form_preview_template.html.twig +++ b/templates/Reservations/reservation_form_preview_template.html.twig @@ -9,7 +9,9 @@
{% if template.templateType.name == 'TEMPLATE_RESERVATION_EMAIL' %} {% include 'Reservations/reservation_form_preview_template_email.html.twig' %} - {% elseif template.templateType.name == 'TEMPLATE_FILE_PDF' or template.templateType.name == 'TEMPLATE_RESERVATION_PDF'%} + {% elseif template.templateType.name == 'TEMPLATE_FILE_PDF' + or template.templateType.name == 'TEMPLATE_RESERVATION_PDF' + or template.templateType.name == 'TEMPLATE_REGISTRATION_PDF' %} {% include 'Reservations/reservation_form_preview_template_file.html.twig' %} {% endif %}
diff --git a/templates/Reservations/reservation_form_select_template.html.twig b/templates/Reservations/reservation_form_select_template.html.twig index 7332bbc2..0d16665c 100644 --- a/templates/Reservations/reservation_form_select_template.html.twig +++ b/templates/Reservations/reservation_form_select_template.html.twig @@ -42,22 +42,29 @@
- - - - + + + + + {% for invoice in invoices %} - - - - - {% endfor %} + + + + + + {% endfor %}
{{ 'invoice.number'|trans }}
{{ 'invoice.number'|trans }}{{ 'invoice.einvoice'|trans }}
{{ invoice.number }} ({{ invoice.date|date('d.m.Y') }})
{{ invoice.number }} ({{ invoice.date|date('d.m.Y') }}) + +
{% endif %} diff --git a/templates/Reservations/reservation_table_settings_input_fields.html.twig b/templates/Reservations/reservation_table_settings_input_fields.html.twig index 17781b6b..f6efccca 100644 --- a/templates/Reservations/reservation_table_settings_input_fields.html.twig +++ b/templates/Reservations/reservation_table_settings_input_fields.html.twig @@ -9,9 +9,9 @@